From 516081e5a37491c1b2184d069d6daa9daf022597 Mon Sep 17 00:00:00 2001 From: Michael Morgan <84428382+aa5sh@users.noreply.github.com> Date: Wed, 8 Apr 2026 21:47:17 -0500 Subject: [PATCH 01/21] Add Email QSL sending feature Implements Email QSL functionality: adds an EmailQSL service (SMTP worker, MIME builder, card rendering and merge-fields), UI for sending and previewing (EmailQSLDialog and .ui), and settings/overlays storage. Introduces a CardEditorWidget component and settings widget for configuring card image/overlays and SMTP credentials (uses secure credential store and QSettings). Tracks sent timestamps in contacts.fields JSON and performs async SMTP operations with STARTTLS/SSL and AUTH support. Wires all new sources, headers and forms into QLog.pro and updates logbook/settings UI to integrate the new feature. --- QLog.pro | 10 + service/emailqsl/EmailQSLService.cpp | 825 +++++++++++++++++++++ service/emailqsl/EmailQSLService.h | 169 +++++ ui/EmailQSLDialog.cpp | 234 ++++++ ui/EmailQSLDialog.h | 37 + ui/EmailQSLDialog.ui | 135 ++++ ui/EmailQSLSettingsWidget.cpp | 1007 ++++++++++++++++++++++++++ ui/EmailQSLSettingsWidget.h | 63 ++ ui/EmailQSLSettingsWidget.ui | 384 ++++++++++ ui/LogbookWidget.cpp | 129 ++++ ui/LogbookWidget.h | 2 + ui/LogbookWidget.ui | 19 + ui/SettingsDialog.cpp | 11 + ui/SettingsDialog.ui | 15 + ui/component/CardEditorWidget.cpp | 380 ++++++++++ ui/component/CardEditorWidget.h | 68 ++ 16 files changed, 3488 insertions(+) create mode 100644 service/emailqsl/EmailQSLService.cpp create mode 100644 service/emailqsl/EmailQSLService.h create mode 100644 ui/EmailQSLDialog.cpp create mode 100644 ui/EmailQSLDialog.h create mode 100644 ui/EmailQSLDialog.ui create mode 100644 ui/EmailQSLSettingsWidget.cpp create mode 100644 ui/EmailQSLSettingsWidget.h create mode 100644 ui/EmailQSLSettingsWidget.ui create mode 100644 ui/component/CardEditorWidget.cpp create mode 100644 ui/component/CardEditorWidget.h diff --git a/QLog.pro b/QLog.pro index f0097da1..3a4a31ad 100644 --- a/QLog.pro +++ b/QLog.pro @@ -163,6 +163,7 @@ SOURCES += \ service/GenericCallbook.cpp \ service/GenericQSLDownloader.cpp \ service/GenericQSOUploader.cpp \ + service/emailqsl/EmailQSLService.cpp \ service/cloudlog/Cloudlog.cpp \ service/clublog/ClubLog.cpp \ service/eqsl/Eqsl.cpp \ @@ -212,6 +213,8 @@ SOURCES += \ ui/ModeSelectionController.cpp \ ui/NewContactWidget.cpp \ ui/OnlineMapWidget.cpp \ + ui/EmailQSLDialog.cpp \ + ui/EmailQSLSettingsWidget.cpp \ ui/PaperQSLDialog.cpp \ ui/ProfileImageWidget.cpp \ ui/QSLImportStatDialog.cpp \ @@ -229,6 +232,7 @@ SOURCES += \ ui/WsjtxFilterDialog.cpp \ ui/WsjtxWidget.cpp \ ui/component/BaseDoubleSpinBox.cpp \ + ui/component/CardEditorWidget.cpp \ ui/component/EditLine.cpp \ ui/component/FreqQSpinBox.cpp \ ui/component/ModeSubmodeDelegate.cpp \ @@ -366,6 +370,7 @@ HEADERS += \ service/GenericCallbook.h \ service/GenericQSLDownloader.h \ service/GenericQSOUploader.h \ + service/emailqsl/EmailQSLService.h \ service/cloudlog/Cloudlog.h \ service/clublog/ClubLog.h \ service/eqsl/Eqsl.h \ @@ -391,6 +396,8 @@ HEADERS += \ ui/DevToolsDialog.h \ ui/DownloadQSLDialog.h \ ui/DxFilterDialog.h \ + ui/EmailQSLDialog.h \ + ui/EmailQSLSettingsWidget.h \ ui/DxWidget.h \ ui/DxccTableWidget.h \ ui/EditActivitiesDialog.h \ @@ -437,6 +444,7 @@ HEADERS += \ i18n/datastrings.tri \ ui/component/BaseDoubleSpinBox.h \ ui/component/ButtonStyle.h \ + ui/component/CardEditorWidget.h \ ui/component/EditLine.h \ ui/component/FreqQSpinBox.h \ ui/component/ModeSubmodeDelegate.h \ @@ -466,6 +474,8 @@ FORMS += \ ui/DevToolsDialog.ui \ ui/DownloadQSLDialog.ui \ ui/DxFilterDialog.ui \ + ui/EmailQSLDialog.ui \ + ui/EmailQSLSettingsWidget.ui \ ui/DxWidget.ui \ ui/EditActivitiesDialog.ui \ ui/CabrilloExportDialog.ui \ diff --git a/service/emailqsl/EmailQSLService.cpp b/service/emailqsl/EmailQSLService.cpp new file mode 100644 index 00000000..94aba7ae --- /dev/null +++ b/service/emailqsl/EmailQSLService.cpp @@ -0,0 +1,825 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "EmailQSLService.h" +#include "core/debug.h" + +MODULE_IDENTIFICATION("qlog.service.emailqsl"); + +// --------------------------------------------------------------------------- +// EmailQSLFieldOverlay +// --------------------------------------------------------------------------- + +QJsonObject EmailQSLFieldOverlay::toJson() const +{ + QJsonObject o; + o["type"] = type; + o["fieldName"] = fieldName; + o["x"] = x; + o["y"] = y; + o["fontFamily"] = fontFamily; + o["fontSize"] = fontSize; + o["color"] = color; + o["bold"] = bold; + o["italic"] = italic; + o["width"] = width; + o["height"] = height; + o["fillColor"] = fillColor; + o["opacity"] = opacity; + o["cornerRadius"] = cornerRadius; + return o; +} + +EmailQSLFieldOverlay EmailQSLFieldOverlay::fromJson(const QJsonObject &obj) +{ + EmailQSLFieldOverlay f; + f.type = obj.value("type").toString(QStringLiteral("TEXT")); + f.fieldName = obj.value("fieldName").toString(); + f.x = obj.value("x").toInt(); + f.y = obj.value("y").toInt(); + f.fontFamily = obj.value("fontFamily").toString(QStringLiteral("Arial")); + f.fontSize = obj.value("fontSize").toInt(14); + f.color = obj.value("color").toString(QStringLiteral("#000000")); + f.bold = obj.value("bold").toBool(); + f.italic = obj.value("italic").toBool(); + f.width = obj.value("width").toInt(120); + f.height = obj.value("height").toInt(60); + f.fillColor = obj.value("fillColor").toString(QStringLiteral("#FFFF99")); + f.opacity = obj.value("opacity").toInt(80); + f.cornerRadius = obj.value("cornerRadius").toInt(8); + return f; +} + +// --------------------------------------------------------------------------- +// EmailQSLBase — credential registration +// --------------------------------------------------------------------------- + +const QString EmailQSLBase::SECURE_STORAGE_KEY = QStringLiteral("EmailQSL"); +REGISTRATION_SECURE_SERVICE(EmailQSLBase); + +void EmailQSLBase::registerCredentials() +{ + CredentialRegistry::instance().add(SECURE_STORAGE_KEY, []() + { + return QList + { + { SECURE_STORAGE_KEY, []() { return getSmtpUsername(); } } + }; + }); +} + +// --------------------------------------------------------------------------- +// EmailQSLBase — QSettings helpers +// --------------------------------------------------------------------------- + +#define EMAILQSL_SETTINGS_GROUP "emailqsl" + +static QSettings &cfg() +{ + static QSettings s; + return s; +} + +QString EmailQSLBase::getSmtpHost() +{ + return cfg().value(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/smtpHost")).toString(); +} + +void EmailQSLBase::setSmtpHost(const QString &host) +{ + cfg().setValue(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/smtpHost"), host); +} + +int EmailQSLBase::getSmtpPort() +{ + return cfg().value(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/smtpPort"), 587).toInt(); +} + +void EmailQSLBase::setSmtpPort(int port) +{ + cfg().setValue(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/smtpPort"), port); +} + +int EmailQSLBase::getSmtpEncryption() +{ + return cfg().value(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/smtpEncryption"), + ENCRYPTION_STARTTLS).toInt(); +} + +void EmailQSLBase::setSmtpEncryption(int enc) +{ + cfg().setValue(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/smtpEncryption"), enc); +} + +QString EmailQSLBase::getSmtpUsername() +{ + return cfg().value(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/smtpUsername")).toString(); +} + +void EmailQSLBase::setSmtpUsername(const QString &username) +{ + cfg().setValue(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/smtpUsername"), username); +} + +QString EmailQSLBase::getSmtpPassword() +{ + return getPassword(SECURE_STORAGE_KEY, getSmtpUsername()); +} + +void EmailQSLBase::saveSmtpCredentials(const QString &username, const QString &password) +{ + const QString oldUsername = getSmtpUsername(); + if (oldUsername != username && !oldUsername.isEmpty()) + deletePassword(SECURE_STORAGE_KEY, oldUsername); + setSmtpUsername(username); + savePassword(SECURE_STORAGE_KEY, username, password); +} + +QString EmailQSLBase::getFromAddress() +{ + return cfg().value(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/fromAddress")).toString(); +} + +void EmailQSLBase::setFromAddress(const QString &addr) +{ + cfg().setValue(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/fromAddress"), addr); +} + +QString EmailQSLBase::getFromName() +{ + return cfg().value(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/fromName")).toString(); +} + +void EmailQSLBase::setFromName(const QString &name) +{ + cfg().setValue(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/fromName"), name); +} + +QString EmailQSLBase::getSubjectTemplate() +{ + return cfg().value(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/subjectTemplate"), + QStringLiteral("QSL Card from {MY_CALLSIGN} for our QSO on {QSO_DATE}")).toString(); +} + +void EmailQSLBase::setSubjectTemplate(const QString &tmpl) +{ + cfg().setValue(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/subjectTemplate"), tmpl); +} + +QString EmailQSLBase::getBodyTemplate() +{ + return cfg().value(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/bodyTemplate"), + QStringLiteral("Dear {NAME},\n\nPlease find my QSL card attached confirming our contact.\n\n" + "Callsign: {MY_CALLSIGN}\n" + "Date: {QSO_DATE}\nTime: {TIME_ON} UTC\n" + "Band: {BAND}\nMode: {MODE}\n" + "RST Sent: {RST_SENT}\nRST Rcvd: {RST_RCVD}\n\n" + "73,\n{MY_CALLSIGN}")).toString(); +} + +void EmailQSLBase::setBodyTemplate(const QString &tmpl) +{ + cfg().setValue(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/bodyTemplate"), tmpl); +} + +QString EmailQSLBase::getCardImagePath() +{ + return cfg().value(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/cardImagePath")).toString(); +} + +void EmailQSLBase::setCardImagePath(const QString &path) +{ + cfg().setValue(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/cardImagePath"), path); +} + +QList EmailQSLBase::getCardFieldOverlays() +{ + QList result; + const QByteArray json = cfg().value( + QStringLiteral(EMAILQSL_SETTINGS_GROUP "/cardOverlays")).toByteArray(); + const QJsonArray arr = QJsonDocument::fromJson(json).array(); + for (const QJsonValue &v : arr) + result.append(EmailQSLFieldOverlay::fromJson(v.toObject())); + return result; +} + +void EmailQSLBase::setCardFieldOverlays(const QList &overlays) +{ + QJsonArray arr; + for (const EmailQSLFieldOverlay &o : overlays) + arr.append(o.toJson()); + cfg().setValue(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/cardOverlays"), + QJsonDocument(arr).toJson(QJsonDocument::Compact)); +} + +// --------------------------------------------------------------------------- +// EmailQSLBase — sent-tracking via contacts.fields JSON +// --------------------------------------------------------------------------- + +static const QString EMAIL_QSL_SENT_KEY = QStringLiteral("email_qsl_sent_dt"); + +QDateTime EmailQSLBase::getEmailSentDateTime(const QSqlRecord &record) +{ + const QByteArray raw = record.value(QStringLiteral("fields")).toByteArray(); + const QJsonObject fields = QJsonDocument::fromJson(raw).object(); + const QString dtStr = fields.value(EMAIL_QSL_SENT_KEY).toString(); + return dtStr.isEmpty() ? QDateTime() : QDateTime::fromString(dtStr, Qt::ISODate); +} + +bool EmailQSLBase::hasEmailBeenSentToCallsign(const QString &callsign, int excludeId) +{ + QSqlQuery q; + q.prepare(QStringLiteral("SELECT fields FROM contacts WHERE callsign = :cs AND id != :ex")); + q.bindValue(":cs", callsign.toUpper()); + q.bindValue(":ex", excludeId); + if (!q.exec()) + return false; + + while (q.next()) + { + const QByteArray raw = q.value(0).toByteArray(); + const QJsonObject fields = QJsonDocument::fromJson(raw).object(); + if (fields.contains(EMAIL_QSL_SENT_KEY)) + return true; + } + return false; +} + +void EmailQSLBase::recordEmailSent(int contactId, const QSqlRecord ¤tRecord) +{ + FCT_IDENTIFICATION; + + const QByteArray raw = currentRecord.value(QStringLiteral("fields")).toByteArray(); + QJsonObject fields = QJsonDocument::fromJson(raw).object(); + fields[EMAIL_QSL_SENT_KEY] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate); + + QSqlQuery q; + q.prepare(QStringLiteral("UPDATE contacts SET fields = :f WHERE id = :id")); + q.bindValue(":f", QJsonDocument(fields).toJson(QJsonDocument::Compact)); + q.bindValue(":id", contactId); + if (!q.exec()) + qCWarning(runtime) << "recordEmailSent update failed:" << q.lastError().text(); +} + +// --------------------------------------------------------------------------- +// EmailQSLBase — merge fields +// --------------------------------------------------------------------------- + +QString EmailQSLBase::fieldValue(const QString &key, const QSqlRecord &record) +{ + if (key == QLatin1String("CALLSIGN")) + return record.value(QStringLiteral("callsign")).toString().toUpper(); + + if (key == QLatin1String("QSO_DATE")) + { + const QDateTime dt = record.value(QStringLiteral("start_time")).toDateTime(); + return dt.isValid() ? dt.toUTC().toString(QStringLiteral("dd-MMM-yyyy")).toUpper() : QString(); + } + if (key == QLatin1String("QSO_DATE_ISO")) + { + const QDateTime dt = record.value(QStringLiteral("start_time")).toDateTime(); + return dt.isValid() ? dt.toUTC().toString(QStringLiteral("yyyyMMdd")) : QString(); + } + if (key == QLatin1String("TIME_ON")) + { + const QDateTime dt = record.value(QStringLiteral("start_time")).toDateTime(); + return dt.isValid() ? dt.toUTC().toString(QStringLiteral("HHmm")) : QString(); + } + if (key == QLatin1String("FREQ")) + { + bool ok; + double mhz = record.value(QStringLiteral("freq")).toDouble(&ok); + return ok ? QString::number(mhz, 'f', 3) : QString(); + } + if (key == QLatin1String("BAND")) return record.value(QStringLiteral("band")).toString().toUpper(); + if (key == QLatin1String("MODE")) return record.value(QStringLiteral("mode")).toString().toUpper(); + if (key == QLatin1String("SUBMODE")) return record.value(QStringLiteral("submode")).toString().toUpper(); + if (key == QLatin1String("RST_SENT")) return record.value(QStringLiteral("rst_sent")).toString(); + if (key == QLatin1String("RST_RCVD")) return record.value(QStringLiteral("rst_rcvd")).toString(); + if (key == QLatin1String("NAME")) + { + const QString n = record.value(QStringLiteral("name_intl")).toString(); + return n.isEmpty() ? record.value(QStringLiteral("name")).toString() : n; + } + if (key == QLatin1String("QTH")) + { + const QString q = record.value(QStringLiteral("qth_intl")).toString(); + return q.isEmpty() ? record.value(QStringLiteral("qth")).toString() : q; + } + if (key == QLatin1String("COUNTRY")) + { + const QString c = record.value(QStringLiteral("country_intl")).toString(); + return c.isEmpty() ? record.value(QStringLiteral("country")).toString() : c; + } + if (key == QLatin1String("GRIDSQUARE")) return record.value(QStringLiteral("gridsquare")).toString().toUpper(); + if (key == QLatin1String("DXCC")) return record.value(QStringLiteral("dxcc")).toString(); + if (key == QLatin1String("CQZ")) return record.value(QStringLiteral("cqz")).toString(); + if (key == QLatin1String("ITUZ")) return record.value(QStringLiteral("ituz")).toString(); + if (key == QLatin1String("TX_PWR")) return record.value(QStringLiteral("tx_pwr")).toString(); + if (key == QLatin1String("EMAIL")) return record.value(QStringLiteral("email")).toString(); + if (key == QLatin1String("MY_CALLSIGN")) return record.value(QStringLiteral("station_callsign")).toString().toUpper(); + if (key == QLatin1String("MY_GRIDSQUARE")) return record.value(QStringLiteral("my_gridsquare")).toString().toUpper(); + if (key == QLatin1String("OPERATOR")) return record.value(QStringLiteral("operator")).toString().toUpper(); + if (key == QLatin1String("COMMENT")) + { + const QString c = record.value(QStringLiteral("comment_intl")).toString(); + return c.isEmpty() ? record.value(QStringLiteral("comment")).toString() : c; + } + if (key == QLatin1String("SOTA_REF")) return record.value(QStringLiteral("sota_ref")).toString(); + if (key == QLatin1String("POTA_REF")) return record.value(QStringLiteral("pota_ref")).toString(); + if (key == QLatin1String("WWFF_REF")) return record.value(QStringLiteral("wwff_ref")).toString(); + if (key == QLatin1String("IOTA")) return record.value(QStringLiteral("iota")).toString(); + if (key == QLatin1String("SIG")) return record.value(QStringLiteral("sig_intl")).toString(); + if (key == QLatin1String("CONTEST_ID")) return record.value(QStringLiteral("contest_id")).toString(); + + // Fallback: try direct lowercase column name + const QString col = key.toLower(); + if (record.indexOf(col) >= 0) + return record.value(col).toString(); + + return QString(); +} + +QString EmailQSLBase::applyMergeFields(const QString &tmpl, const QSqlRecord &record) +{ + QString result = tmpl; + static const QRegularExpression rx(QStringLiteral("\\{([A-Z0-9_]+)\\}")); + QRegularExpressionMatchIterator it = rx.globalMatch(tmpl); + while (it.hasNext()) + { + const QRegularExpressionMatch m = it.next(); + const QString key = m.captured(1); + const QString value = fieldValue(key, record); + result.replace(QLatin1Char('{') + key + QLatin1Char('}'), value); + } + return result; +} + +QList EmailQSLBase::availableMergeFields() +{ + return { + { "CALLSIGN", QObject::tr("Contact callsign") }, + { "QSO_DATE", QObject::tr("QSO date (dd-MMM-yyyy)") }, + { "QSO_DATE_ISO", QObject::tr("QSO date (YYYYMMDD)") }, + { "TIME_ON", QObject::tr("QSO start time UTC (HHmm)") }, + { "FREQ", QObject::tr("Frequency (MHz)") }, + { "BAND", QObject::tr("Band") }, + { "MODE", QObject::tr("Mode") }, + { "SUBMODE", QObject::tr("Sub-mode") }, + { "RST_SENT", QObject::tr("RST sent") }, + { "RST_RCVD", QObject::tr("RST received") }, + { "NAME", QObject::tr("Contact name") }, + { "QTH", QObject::tr("Contact QTH") }, + { "COUNTRY", QObject::tr("Country") }, + { "GRIDSQUARE", QObject::tr("Grid square") }, + { "DXCC", QObject::tr("DXCC entity number") }, + { "CQZ", QObject::tr("CQ zone") }, + { "ITUZ", QObject::tr("ITU zone") }, + { "TX_PWR", QObject::tr("TX power") }, + { "EMAIL", QObject::tr("Contact email address") }, + { "MY_CALLSIGN", QObject::tr("My callsign") }, + { "MY_GRIDSQUARE", QObject::tr("My grid square") }, + { "OPERATOR", QObject::tr("Operator callsign") }, + { "COMMENT", QObject::tr("Comment") }, + { "SOTA_REF", QObject::tr("SOTA reference") }, + { "POTA_REF", QObject::tr("POTA reference") }, + { "WWFF_REF", QObject::tr("WWFF reference") }, + { "IOTA", QObject::tr("IOTA reference") }, + { "SIG", QObject::tr("Special interest group") }, + { "CONTEST_ID", QObject::tr("Contest ID") }, + }; +} + +// --------------------------------------------------------------------------- +// EmailQSLBase — card rendering +// --------------------------------------------------------------------------- + +QPixmap EmailQSLBase::renderCard(const QSqlRecord &record) +{ + FCT_IDENTIFICATION; + return renderCard(getCardImagePath(), record, getCardFieldOverlays()); +} + +QPixmap EmailQSLBase::renderCard(const QString &imagePath, + const QSqlRecord &record, + const QList &overlays) +{ + FCT_IDENTIFICATION; + + QPixmap pixmap(imagePath); + if (pixmap.isNull()) + { + qCWarning(runtime) << "renderCard: could not load image:" << imagePath; + return QPixmap(); + } + + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing); + painter.setRenderHint(QPainter::TextAntialiasing); + + for (const EmailQSLFieldOverlay &ov : overlays) + { + if (ov.type == QLatin1String("BOX")) + { + QColor fill(ov.fillColor); + fill.setAlphaF(ov.opacity / 100.0); + painter.setPen(QPen(QColor(ov.color), 1.5)); + painter.setBrush(fill); + painter.drawRoundedRect(ov.x, ov.y, ov.width, ov.height, + ov.cornerRadius, ov.cornerRadius); + + // Optional caption above the box + if (!ov.fieldName.isEmpty()) + { + QFont font(ov.fontFamily, ov.fontSize > 0 ? ov.fontSize : 11); + font.setBold(ov.bold); + painter.setFont(font); + painter.setPen(QColor(ov.color)); + painter.setBrush(Qt::NoBrush); + QFontMetrics fm(font); + painter.drawText(ov.x, ov.y - fm.descent() - 2, ov.fieldName); + } + } + else if (ov.type == QLatin1String("LABEL")) + { + // Render fieldName as literal static text (no merge substitution) + if (ov.fieldName.isEmpty()) + continue; + + QFont font(ov.fontFamily, ov.fontSize); + font.setBold(ov.bold); + font.setItalic(ov.italic); + painter.setFont(font); + painter.setPen(QColor(ov.color)); + painter.setBrush(Qt::NoBrush); + painter.drawText(ov.x, ov.y, ov.fieldName); + } + else // TEXT — merge-field substitution + { + const QString value = fieldValue(ov.fieldName, record); + if (value.isEmpty()) + continue; + + QFont font(ov.fontFamily, ov.fontSize); + font.setBold(ov.bold); + font.setItalic(ov.italic); + painter.setFont(font); + painter.setPen(QColor(ov.color)); + painter.setBrush(Qt::NoBrush); + painter.drawText(ov.x, ov.y, value); + } + } + painter.end(); + return pixmap; +} + +// --------------------------------------------------------------------------- +// SmtpWorker +// --------------------------------------------------------------------------- + +SmtpWorker::SmtpWorker(const QString &host, int port, int encryption, + const QString &username, const QString &password, + const QString &fromAddress, const QString &fromName, + const QString &toAddress, + const QString &subject, const QString &body, + const QByteArray &imageData, const QString &imageName, + QObject *parent) + : QObject(parent), + m_host(host), m_port(port), m_encryption(encryption), + m_username(username), m_password(password), + m_fromAddress(fromAddress), m_fromName(fromName), + m_toAddress(toAddress), + m_subject(subject), m_body(body), + m_imageData(imageData), m_imageName(imageName) +{ +} + +bool SmtpWorker::waitForResponse(QSslSocket *socket, QByteArray &out, int timeoutMs) +{ + out.clear(); + // Multi-line responses end with "NNN " (space after code); single with "NNN " + // Keep reading until we get a line without a dash after the code. + do { + if (!socket->waitForReadyRead(timeoutMs)) + return false; + out += socket->readAll(); + } while (out.size() > 3 && out[out.size()-2] != '\r' && + (out.size() < 4 || out[3] == '-')); + // More robust: check if last complete line starts with "NNN " (no dash) + // Parse the last CRLF-terminated line + const QList lines = out.split('\n'); + for (const QByteArray &line : lines) + { + if (line.length() >= 4 && line[3] == ' ') + return true; // found a final response line + if (line.length() >= 4 && line[3] == '-') + continue; // continuation line + } + return !out.isEmpty(); +} + +int SmtpWorker::responseCode(const QByteArray &response) +{ + // Find the last "NNN " line + const QList lines = response.split('\n'); + for (int i = lines.size() - 1; i >= 0; --i) + { + const QByteArray &line = lines.at(i).trimmed(); + if (line.length() >= 3) + { + bool ok; + int code = line.left(3).toInt(&ok); + if (ok) + return code; + } + } + return -1; +} + +bool SmtpWorker::sendCommand(QSslSocket *socket, const QByteArray &cmd, + int expectedCode, QByteArray &response) +{ + socket->write(cmd); + if (!socket->waitForBytesWritten(10000)) + return false; + if (!waitForResponse(socket, response)) + return false; + return responseCode(response) == expectedCode; +} + +QByteArray SmtpWorker::buildMimeMessage() +{ + const QString boundary = QStringLiteral("----QLogEmailQSL_%1") + .arg(QDateTime::currentMSecsSinceEpoch()); + + QByteArray msg; + + // Encode From display name safely + const QByteArray fromDisplay = ("\"" + m_fromName + "\" <" + m_fromAddress + ">").toUtf8(); + msg += "MIME-Version: 1.0\r\n"; + msg += "From: " + fromDisplay + "\r\n"; + msg += "To: <" + m_toAddress.toUtf8() + ">\r\n"; + // RFC 2047 encoded subject + msg += "Subject: =?UTF-8?B?" + m_subject.toUtf8().toBase64() + "?=\r\n"; + msg += "Content-Type: multipart/mixed; boundary=\"" + boundary.toUtf8() + "\"\r\n"; + msg += "\r\n"; + + // --- Text part --- + msg += "--" + boundary.toUtf8() + "\r\n"; + msg += "Content-Type: text/plain; charset=UTF-8\r\n"; + msg += "Content-Transfer-Encoding: base64\r\n"; + msg += "\r\n"; + const QByteArray bodyB64 = m_body.toUtf8().toBase64(); + for (int i = 0; i < bodyB64.size(); i += 76) + msg += bodyB64.mid(i, 76) + "\r\n"; + + // --- Image attachment (if present) --- + if (!m_imageData.isEmpty()) + { + msg += "\r\n--" + boundary.toUtf8() + "\r\n"; + const QString mimeType = m_imageName.endsWith(QLatin1String(".png"), Qt::CaseInsensitive) + ? QStringLiteral("image/png") + : QStringLiteral("image/jpeg"); + msg += "Content-Type: " + mimeType.toUtf8() + + "; name=\"" + m_imageName.toUtf8() + "\"\r\n"; + msg += "Content-Transfer-Encoding: base64\r\n"; + msg += "Content-Disposition: attachment; filename=\"" + + m_imageName.toUtf8() + "\"\r\n"; + msg += "\r\n"; + const QByteArray imgB64 = m_imageData.toBase64(); + for (int i = 0; i < imgB64.size(); i += 76) + msg += imgB64.mid(i, 76) + "\r\n"; + } + + msg += "\r\n--" + boundary.toUtf8() + "--\r\n"; + return msg; +} + +void SmtpWorker::run() +{ + FCT_IDENTIFICATION; + + QSslSocket socket; + socket.setProtocol(QSsl::AnyProtocol); + socket.setPeerVerifyMode(QSslSocket::VerifyNone); // tolerate self-signed certs + + // ---- Connect ---- + if (m_encryption == EmailQSLBase::ENCRYPTION_SSL_TLS) + { + socket.connectToHostEncrypted(m_host, static_cast(m_port)); + if (!socket.waitForEncrypted(15000)) + { + emit finished(false, tr("SSL/TLS connection failed: %1").arg(socket.errorString())); + return; + } + } + else + { + socket.connectToHost(m_host, static_cast(m_port)); + if (!socket.waitForConnected(15000)) + { + emit finished(false, tr("Could not connect to %1:%2 — %3") + .arg(m_host).arg(m_port).arg(socket.errorString())); + return; + } + } + + // ---- Read greeting (220) ---- + QByteArray resp; + if (!waitForResponse(&socket, resp) || responseCode(resp) != 220) + { + emit finished(false, tr("Server did not send a valid greeting (expected 220).")); + return; + } + + // ---- EHLO ---- + const QByteArray localHost = QHostInfo::localHostName().toUtf8(); + if (!sendCommand(&socket, "EHLO " + localHost + "\r\n", 250, resp)) + { + // Try HELO as fallback + if (!sendCommand(&socket, "HELO " + localHost + "\r\n", 250, resp)) + { + emit finished(false, tr("EHLO/HELO rejected by server.")); + return; + } + } + + // ---- STARTTLS upgrade ---- + if (m_encryption == EmailQSLBase::ENCRYPTION_STARTTLS) + { + if (!sendCommand(&socket, "STARTTLS\r\n", 220, resp)) + { + emit finished(false, tr("STARTTLS not accepted by server.")); + return; + } + socket.startClientEncryption(); + if (!socket.waitForEncrypted(15000)) + { + emit finished(false, tr("TLS handshake failed: %1").arg(socket.errorString())); + return; + } + // Re-EHLO after TLS negotiation + if (!sendCommand(&socket, "EHLO " + localHost + "\r\n", 250, resp)) + { + emit finished(false, tr("EHLO after STARTTLS rejected.")); + return; + } + } + + // ---- AUTH LOGIN ---- + if (!m_username.isEmpty()) + { + if (!sendCommand(&socket, "AUTH LOGIN\r\n", 334, resp)) + { + emit finished(false, tr("AUTH LOGIN not supported by server.")); + return; + } + if (!sendCommand(&socket, m_username.toUtf8().toBase64() + "\r\n", 334, resp)) + { + emit finished(false, tr("Username rejected by server.")); + return; + } + if (!sendCommand(&socket, m_password.toUtf8().toBase64() + "\r\n", 235, resp)) + { + emit finished(false, tr("Authentication failed — check your username and password.")); + return; + } + } + + // ---- MAIL FROM ---- + if (!sendCommand(&socket, "MAIL FROM:<" + m_fromAddress.toUtf8() + ">\r\n", 250, resp)) + { + emit finished(false, tr("MAIL FROM rejected: %1").arg(QString::fromUtf8(resp))); + return; + } + + // ---- RCPT TO ---- + if (!sendCommand(&socket, "RCPT TO:<" + m_toAddress.toUtf8() + ">\r\n", 250, resp)) + { + emit finished(false, tr("Recipient address not accepted: %1").arg(QString::fromUtf8(resp))); + return; + } + + // ---- DATA ---- + if (!sendCommand(&socket, "DATA\r\n", 354, resp)) + { + emit finished(false, tr("DATA command rejected.")); + return; + } + + // ---- Send message body ---- + QByteArray mime = buildMimeMessage(); + mime += "\r\n.\r\n"; + socket.write(mime); + socket.flush(); + + if (!waitForResponse(&socket, resp) || responseCode(resp) != 250) + { + emit finished(false, tr("Message rejected by server: %1").arg(QString::fromUtf8(resp))); + return; + } + + // ---- QUIT ---- + socket.write("QUIT\r\n"); + socket.waitForBytesWritten(5000); + socket.disconnectFromHost(); + socket.waitForDisconnected(5000); + + emit finished(true, QString()); +} + +// --------------------------------------------------------------------------- +// EmailQSLService +// --------------------------------------------------------------------------- + +EmailQSLService::EmailQSLService(QObject *parent) + : QObject(parent) +{ +} + +void EmailQSLService::sendEmailQSL(const QSqlRecord &record) +{ + FCT_IDENTIFICATION; + + // Render card + const QPixmap cardPixmap = EmailQSLBase::renderCard(record); + QByteArray imageData; + QString imageName; + if (!cardPixmap.isNull()) + { + QBuffer buf(&imageData); + buf.open(QIODevice::WriteOnly); + cardPixmap.save(&buf, "JPEG", 90); + imageName = QStringLiteral("qsl_card.jpg"); + } + + // Merge email fields + const QString subject = EmailQSLBase::applyMergeFields( + EmailQSLBase::getSubjectTemplate(), record); + const QString body = EmailQSLBase::applyMergeFields( + EmailQSLBase::getBodyTemplate(), record); + + const QString toAddress = record.value(QStringLiteral("email")).toString().trimmed(); + + QThread *thread = new QThread(this); + SmtpWorker *worker = new SmtpWorker( + EmailQSLBase::getSmtpHost(), + EmailQSLBase::getSmtpPort(), + EmailQSLBase::getSmtpEncryption(), + EmailQSLBase::getSmtpUsername(), + EmailQSLBase::getSmtpPassword(), + EmailQSLBase::getFromAddress(), + EmailQSLBase::getFromName(), + toAddress, + subject, body, + imageData, imageName); + + worker->moveToThread(thread); + + connect(thread, &QThread::started, worker, &SmtpWorker::run); + connect(worker, &SmtpWorker::finished, this, + [this](bool ok, const QString &msg) { emit sendFinished(ok, msg); }); + connect(worker, &SmtpWorker::finished, worker, &QObject::deleteLater); + connect(worker, &SmtpWorker::finished, thread, &QThread::quit); + connect(thread, &QThread::finished, thread, &QObject::deleteLater); + + thread->start(); +} + +void EmailQSLService::testConnection(const QString &host, int port, int encryption, + const QString &username, const QString &password) +{ + FCT_IDENTIFICATION; + + // Send a dummy message to a no-op address just to verify auth works — + // actually we just connect + EHLO + AUTH and then QUIT. + QThread *thread = new QThread(this); + SmtpWorker *worker = new SmtpWorker( + host, port, encryption, username, password, + username, QStringLiteral("Test"), + username, // to = from (won't actually be sent) + QStringLiteral("QLog connection test"), + QStringLiteral("Connection test only — no message will be sent."), + QByteArray(), QString()); + + worker->moveToThread(thread); + + connect(thread, &QThread::started, worker, &SmtpWorker::run); + connect(worker, &SmtpWorker::finished, this, + [this](bool ok, const QString &msg) { emit testFinished(ok, msg); }); + connect(worker, &SmtpWorker::finished, worker, &QObject::deleteLater); + connect(worker, &SmtpWorker::finished, thread, &QThread::quit); + connect(thread, &QThread::finished, thread, &QObject::deleteLater); + + thread->start(); +} diff --git a/service/emailqsl/EmailQSLService.h b/service/emailqsl/EmailQSLService.h new file mode 100644 index 00000000..12d9e8d8 --- /dev/null +++ b/service/emailqsl/EmailQSLService.h @@ -0,0 +1,169 @@ +#ifndef QLOG_SERVICE_EMAILQSLSERVICE_H +#define QLOG_SERVICE_EMAILQSLSERVICE_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/CredentialStore.h" + +// Describes a single overlay drawn on the QSL card image. +// type == "TEXT" → renders a merge-field value as text +// type == "BOX" → renders a filled rounded rectangle (label is optional caption) +struct EmailQSLFieldOverlay +{ + // Common + QString type = QStringLiteral("TEXT"); // "TEXT" or "BOX" + QString fieldName; // TEXT: merge key; BOX: optional caption label + int x = 0; + int y = 0; + + // TEXT-only + QString fontFamily = QStringLiteral("Arial"); + int fontSize = 14; + QString color = QStringLiteral("#000000"); // text color / BOX border color + bool bold = false; + bool italic = false; + + // BOX-only + int width = 120; + int height = 60; + QString fillColor = QStringLiteral("#FFFF99"); // box fill (no alpha — use opacity) + int opacity = 80; // fill opacity 0–100 + int cornerRadius = 8; + + QJsonObject toJson() const; + static EmailQSLFieldOverlay fromJson(const QJsonObject &obj); +}; + +// Static helpers for reading/writing all Email QSL settings. +// The SMTP password is kept in the secure credential store; +// everything else lives in QSettings under the "emailqsl/" group. +class EmailQSLBase : public SecureServiceBase +{ +public: + DECLARE_SECURE_SERVICE(EmailQSLBase) + static const QString SECURE_STORAGE_KEY; + + enum EncryptionType + { + ENCRYPTION_NONE = 0, + ENCRYPTION_SSL_TLS = 1, + ENCRYPTION_STARTTLS = 2 + }; + + // --- SMTP connection --- + static QString getSmtpHost(); + static void setSmtpHost(const QString &host); + static int getSmtpPort(); + static void setSmtpPort(int port); + static int getSmtpEncryption(); + static void setSmtpEncryption(int enc); + static QString getSmtpUsername(); + static void setSmtpUsername(const QString &username); + static QString getSmtpPassword(); + static void saveSmtpCredentials(const QString &username, const QString &password); + + // --- Envelope / headers --- + static QString getFromAddress(); + static void setFromAddress(const QString &addr); + static QString getFromName(); + static void setFromName(const QString &name); + static QString getSubjectTemplate(); + static void setSubjectTemplate(const QString &tmpl); + static QString getBodyTemplate(); + static void setBodyTemplate(const QString &tmpl); + + // --- QSL card image & overlays --- + static QString getCardImagePath(); + static void setCardImagePath(const QString &path); + static QList getCardFieldOverlays(); + static void setCardFieldOverlays(const QList &overlays); + + // --- Sent-tracking (stored in contacts.fields JSON) --- + static QDateTime getEmailSentDateTime(const QSqlRecord &record); + static bool hasEmailBeenSentToCallsign(const QString &callsign, int excludeId = -1); + static void recordEmailSent(int contactId, const QSqlRecord ¤tRecord); + + // --- Rendering helpers --- + // Full render using saved QSettings (used when sending). + static QPixmap renderCard(const QSqlRecord &record); + // Full render using an explicit image path and overlay list + // (used by settings preview so unsaved changes are shown). + static QPixmap renderCard(const QString &imagePath, + const QSqlRecord &record, + const QList &overlays); + static QString applyMergeFields(const QString &tmpl, const QSqlRecord &record); + + // Available merge keys (for display in settings UI) + struct MergeField { QString key; QString description; }; + static QList availableMergeFields(); + +private: + static QString fieldValue(const QString &key, const QSqlRecord &record); +}; + +// Worker object that runs the SMTP protocol on a background thread. +class SmtpWorker : public QObject +{ + Q_OBJECT +public: + explicit SmtpWorker(const QString &host, int port, int encryption, + const QString &username, const QString &password, + const QString &fromAddress, const QString &fromName, + const QString &toAddress, + const QString &subject, const QString &body, + const QByteArray &imageData, const QString &imageName, + QObject *parent = nullptr); + +public slots: + void run(); + +signals: + void finished(bool success, const QString &errorMessage); + +private: + bool waitForResponse(QSslSocket *socket, QByteArray &out, int timeoutMs = 15000); + int responseCode(const QByteArray &response); + bool sendCommand(QSslSocket *socket, const QByteArray &cmd, int expectedCode, + QByteArray &response); + QByteArray buildMimeMessage(); + + QString m_host; + int m_port; + int m_encryption; + QString m_username; + QString m_password; + QString m_fromAddress; + QString m_fromName; + QString m_toAddress; + QString m_subject; + QString m_body; + QByteArray m_imageData; + QString m_imageName; +}; + +// High-level service used by the UI. Call sendEmailQSL() and connect to +// sendFinished() for result notification. +class EmailQSLService : public QObject +{ + Q_OBJECT +public: + explicit EmailQSLService(QObject *parent = nullptr); + + void sendEmailQSL(const QSqlRecord &record); + void testConnection(const QString &host, int port, int encryption, + const QString &username, const QString &password); + +signals: + void sendFinished(bool success, const QString &message); + void testFinished(bool success, const QString &message); +}; + +#endif // QLOG_SERVICE_EMAILQSLSERVICE_H diff --git a/ui/EmailQSLDialog.cpp b/ui/EmailQSLDialog.cpp new file mode 100644 index 00000000..3eb4fe98 --- /dev/null +++ b/ui/EmailQSLDialog.cpp @@ -0,0 +1,234 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "EmailQSLDialog.h" +#include "ui_EmailQSLDialog.h" +#include "core/debug.h" + +MODULE_IDENTIFICATION("qlog.ui.emailqsldialog"); + +EmailQSLDialog::EmailQSLDialog(const QSqlRecord &record, QWidget *parent) + : QDialog(parent), + ui(new Ui::EmailQSLDialog), + m_record(record), + m_service(new EmailQSLService(this)) +{ + FCT_IDENTIFICATION; + + ui->setupUi(this); + + // Add action buttons next to Cancel + QPushButton *previewBtn = ui->buttonBox->addButton(tr("Preview Card…"), QDialogButtonBox::ActionRole); + connect(previewBtn, &QPushButton::clicked, this, &EmailQSLDialog::previewAndPrintCard); + + QPushButton *sendBtn = ui->buttonBox->addButton(tr("Send"), QDialogButtonBox::AcceptRole); + connect(sendBtn, &QPushButton::clicked, this, &EmailQSLDialog::sendClicked); + connect(m_service, &EmailQSLService::sendFinished, + this, &EmailQSLDialog::onSendFinished); + + populateInfo(); + buildWarnings(); +} + +EmailQSLDialog::~EmailQSLDialog() +{ + delete ui; +} + +void EmailQSLDialog::populateInfo() +{ + FCT_IDENTIFICATION; + + const QString callsign = m_record.value(QStringLiteral("callsign")).toString().toUpper(); + const QString email = m_record.value(QStringLiteral("email")).toString(); + const QDateTime dt = m_record.value(QStringLiteral("start_time")).toDateTime().toUTC(); + const QString band = m_record.value(QStringLiteral("band")).toString().toUpper(); + const QString mode = m_record.value(QStringLiteral("mode")).toString().toUpper(); + + ui->callValueLabel->setText(callsign.isEmpty() ? tr("(unknown)") : callsign); + ui->emailValueLabel->setText(email.isEmpty() + ? tr("No email address on record") + : email); + ui->dateValueLabel->setText(dt.isValid() ? dt.toString(Qt::RFC2822Date) : tr("(unknown)")); + ui->bandModeValueLabel->setText( + QString("%1 / %2").arg(band.isEmpty() ? QStringLiteral("?") : band, + mode.isEmpty() ? QStringLiteral("?") : mode)); + + // Subject / body previews + ui->subjectPreviewLabel->setText( + EmailQSLBase::applyMergeFields(EmailQSLBase::getSubjectTemplate(), m_record)); + ui->bodyPreviewEdit->setPlainText( + EmailQSLBase::applyMergeFields(EmailQSLBase::getBodyTemplate(), m_record)); + + // Card thumbnail + const QPixmap card = EmailQSLBase::renderCard(m_record); + if (!card.isNull()) + ui->cardPreviewLabel->setPixmap(card.scaled( + ui->cardPreviewLabel->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation)); + else + ui->cardPreviewLabel->setText(tr("No card image")); +} + +void EmailQSLDialog::buildWarnings() +{ + FCT_IDENTIFICATION; + + QStringList warnings; + + // 1) No email address + const QString emailAddr = m_record.value(QStringLiteral("email")).toString().trimmed(); + if (emailAddr.isEmpty()) + { + warnings << tr("This contact has no email address — the message cannot be sent."); + // Disable the Send button + for (QAbstractButton *btn : ui->buttonBox->buttons()) + if (ui->buttonBox->buttonRole(btn) == QDialogButtonBox::AcceptRole) + btn->setEnabled(false); + } + + // 2) Already sent for this specific QSO + const QDateTime prevSent = EmailQSLBase::getEmailSentDateTime(m_record); + if (prevSent.isValid()) + { + warnings << tr("An Email QSL was already sent for this QSO on %1 UTC.") + .arg(prevSent.toString(Qt::RFC2822Date)); + } + + // 3) Previously sent to the same callsign (different QSO) + const QString callsign = m_record.value(QStringLiteral("callsign")).toString().toUpper(); + const int id = m_record.value(QStringLiteral("id")).toInt(); + if (!callsign.isEmpty() && EmailQSLBase::hasEmailBeenSentToCallsign(callsign, id)) + { + warnings << tr("Note: you have previously sent an Email QSL to %1 for a different QSO.") + .arg(callsign); + } + + // 4) No SMTP host configured + if (EmailQSLBase::getSmtpHost().isEmpty()) + warnings << tr("SMTP server is not configured. Go to Settings → Email QSL."); + + if (!warnings.isEmpty()) + ui->warningLabel->setText(warnings.join(QStringLiteral("\n"))); +} + +void EmailQSLDialog::sendClicked() +{ + FCT_IDENTIFICATION; + + // Disable buttons while sending + for (QAbstractButton *btn : ui->buttonBox->buttons()) + btn->setEnabled(false); + + ui->statusLabel->setText(tr("Sending…")); + + m_service->sendEmailQSL(m_record); +} + +void EmailQSLDialog::onSendFinished(bool success, const QString &message) +{ + FCT_IDENTIFICATION; + + if (success) + { + // Record the sent timestamp in contacts.fields + const int id = m_record.value(QStringLiteral("id")).toInt(); + EmailQSLBase::recordEmailSent(id, m_record); + + // Auto-close — the log table refresh is handled by the finished() signal + accept(); + } + else + { + ui->statusLabel->setStyleSheet(QStringLiteral("color: red; font-weight: bold;")); + ui->statusLabel->setText(tr("Send failed: %1").arg(message)); + + // Rename Cancel → Close and disable Send so the user can only dismiss + for (QAbstractButton *btn : ui->buttonBox->buttons()) + { + const QDialogButtonBox::ButtonRole role = ui->buttonBox->buttonRole(btn); + if (role == QDialogButtonBox::RejectRole) + { + btn->setText(tr("Close")); + btn->setEnabled(true); + } + else + { + btn->setEnabled(false); // keep Send / Preview disabled + } + } + } +} + +void EmailQSLDialog::previewAndPrintCard() +{ + FCT_IDENTIFICATION; + + const QPixmap card = EmailQSLBase::renderCard(m_record); + if (card.isNull()) + { + QMessageBox::warning(this, tr("Preview"), + tr("Could not render the card image.\n" + "Please check Settings → Email QSL and make sure a card image is selected.")); + return; + } + + QDialog *dlg = new QDialog(this); + dlg->setAttribute(Qt::WA_DeleteOnClose); + dlg->setWindowTitle(tr("Card Preview — %1") + .arg(m_record.value(QStringLiteral("callsign")).toString().toUpper())); + QVBoxLayout *lay = new QVBoxLayout(dlg); + + QScrollArea *scroll = new QScrollArea(dlg); + QLabel *lbl = new QLabel(scroll); + const QPixmap scaled = card.scaled(QSize(800, 600), Qt::KeepAspectRatio, Qt::SmoothTransformation); + lbl->setPixmap(scaled); + lbl->adjustSize(); + scroll->setWidget(lbl); + scroll->setMinimumSize(qMin(scaled.width() + 20, 820), + qMin(scaled.height() + 20, 620)); + lay->addWidget(scroll); + + QDialogButtonBox *bb = new QDialogButtonBox(QDialogButtonBox::Close, dlg); + QPushButton *saveBtn = bb->addButton(tr("Save Card…"), QDialogButtonBox::ActionRole); + + connect(saveBtn, &QPushButton::clicked, dlg, [card, this]() + { + const QString callsign = m_record.value(QStringLiteral("callsign")).toString().toUpper(); + const QString rawDate = m_record.value(QStringLiteral("start_time")).toString(); + const QDateTime dt = QDateTime::fromString(rawDate, Qt::ISODate); + const QString date = dt.isValid() ? dt.toUTC().toString(QStringLiteral("yyyyMMdd")) : QStringLiteral("date"); + const QString time = dt.isValid() ? dt.toUTC().toString(QStringLiteral("HHmm")) : QStringLiteral("time"); + const QString band = m_record.value(QStringLiteral("band")).toString().toUpper().replace(QLatin1Char(' '), QLatin1Char('_')); + const QString mode = m_record.value(QStringLiteral("mode")).toString().toUpper(); + const QString defaultName = QStringLiteral("QSL_%1_%2_%3_%4_%5.png") + .arg(callsign, date, time, band, mode); + const QString defaultDir = QStandardPaths::writableLocation(QStandardPaths::PicturesLocation); + const QString path = QFileDialog::getSaveFileName( + this, + tr("Save QSL Card"), + defaultDir + QStringLiteral("/") + defaultName, + tr("PNG Image (*.png);;JPEG Image (*.jpg *.jpeg)")); + + if (path.isEmpty()) + return; + + if (!card.save(path)) + { + QMessageBox::warning(this, tr("Save Failed"), + tr("Could not save the card image to:\n%1").arg(path)); + } + }); + + connect(bb, &QDialogButtonBox::rejected, dlg, &QDialog::close); + lay->addWidget(bb); + dlg->show(); // non-modal +} diff --git a/ui/EmailQSLDialog.h b/ui/EmailQSLDialog.h new file mode 100644 index 00000000..c2a609bb --- /dev/null +++ b/ui/EmailQSLDialog.h @@ -0,0 +1,37 @@ +#ifndef QLOG_UI_EMAILQSLDIALOG_H +#define QLOG_UI_EMAILQSLDIALOG_H + +#include +#include + +#include "service/emailqsl/EmailQSLService.h" + +namespace Ui { +class EmailQSLDialog; +} + +// Shown before sending an Email QSL. Displays contact details, the rendered +// card thumbnail, warnings about duplicate sends, and lets the user confirm. +class EmailQSLDialog : public QDialog +{ + Q_OBJECT + +public: + explicit EmailQSLDialog(const QSqlRecord &record, QWidget *parent = nullptr); + ~EmailQSLDialog(); + +private slots: + void sendClicked(); + void onSendFinished(bool success, const QString &message); + void previewAndPrintCard(); + +private: + void populateInfo(); + void buildWarnings(); + + Ui::EmailQSLDialog *ui; + QSqlRecord m_record; + EmailQSLService *m_service; +}; + +#endif // QLOG_UI_EMAILQSLDIALOG_H diff --git a/ui/EmailQSLDialog.ui b/ui/EmailQSLDialog.ui new file mode 100644 index 00000000..1489fbae --- /dev/null +++ b/ui/EmailQSLDialog.ui @@ -0,0 +1,135 @@ + + + EmailQSLDialog + + + 00520500 + + + Send Email QSL Card + + + + + + + + + 200130 + 260170 + QFrame::Box + Qt::AlignCenter + No card image + true + + + + + + QSO Details + + + Callsign: + + + + + + To address: + + + + + true + + + + Date / Time: + + + + + + Band / Mode: + + + + + + + + + + + + + + + true + + QLabel { color: #cc6600; font-weight: bold; } + + + + + + + + Subject + + + + + true + + + + + + + + + + Email Body (preview) + + + + true + 0100 + + + + + + + + + + + Qt::AlignCenter + + + + + + + + QDialogButtonBox::Cancel + + + + + + + + + + buttonBoxrejected() + EmailQSLDialogreject() + + 260480 + 260240 + + + + diff --git a/ui/EmailQSLSettingsWidget.cpp b/ui/EmailQSLSettingsWidget.cpp new file mode 100644 index 00000000..14e0127e --- /dev/null +++ b/ui/EmailQSLSettingsWidget.cpp @@ -0,0 +1,1007 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "EmailQSLSettingsWidget.h" +#include "ui_EmailQSLSettingsWidget.h" +#include "service/emailqsl/EmailQSLService.h" +#include "ui/component/CardEditorWidget.h" +#include "core/debug.h" + +MODULE_IDENTIFICATION("qlog.ui.emailqslsettingswidget"); + +// --------------------------------------------------------------------------- +// Column indices for the overlay table +// TEXT columns: TYPE FIELD X Y FONT SIZE COLOR BOLD ITALIC (BOX cols greyed) +// BOX columns: TYPE FIELD X Y W H FILL OPACITY RADIUS BORDER +// We use one wide model and show/grey non-applicable cells per row. +// --------------------------------------------------------------------------- +enum OverlayCol +{ + OC_TYPE = 0, + OC_FIELD = 1, // TEXT: merge key; BOX: optional caption + OC_X = 2, + OC_Y = 3, + OC_FONT = 4, // TEXT only + OC_SIZE = 5, // TEXT only (also used for BOX caption font size) + OC_COLOR = 6, // TEXT: text color; BOX: border color + OC_BOLD = 7, // TEXT only + OC_ITALIC = 8, // TEXT only + OC_W = 9, // BOX only + OC_H = 10, // BOX only + OC_FILL = 11, // BOX only + OC_OPACITY = 12, // BOX only + OC_RADIUS = 13, // BOX only + OC_COUNT = 14 +}; + +// --------------------------------------------------------------------------- +// Local delegate: merge-field combobox for the Field column +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Delegate: Type combo (TEXT / BOX) +// --------------------------------------------------------------------------- +class TypeDelegate : public QStyledItemDelegate +{ +public: + using QStyledItemDelegate::QStyledItemDelegate; + QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem &, + const QModelIndex &) const override + { + QComboBox *cb = new QComboBox(parent); + cb->addItem(QStringLiteral("TEXT")); + cb->addItem(QStringLiteral("BOX")); + cb->addItem(QStringLiteral("LABEL")); + return cb; + } + void setEditorData(QWidget *editor, const QModelIndex &index) const override + { + QComboBox *cb = static_cast(editor); + cb->setCurrentIndex(cb->findText(index.data(Qt::DisplayRole).toString())); + } + void setModelData(QWidget *editor, QAbstractItemModel *model, + const QModelIndex &index) const override + { + model->setData(index, static_cast(editor)->currentText(), Qt::EditRole); + } + void updateEditorGeometry(QWidget *editor, const QStyleOptionViewItem &option, + const QModelIndex &) const override + { editor->setGeometry(option.rect); } +}; + +// --------------------------------------------------------------------------- +// Delegate: merge-field combobox (TEXT Field column) +// --------------------------------------------------------------------------- +class MergeFieldDelegate : public QStyledItemDelegate +{ +public: + using QStyledItemDelegate::QStyledItemDelegate; + QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem &, + const QModelIndex &) const override + { + QComboBox *cb = new QComboBox(parent); + cb->setEditable(true); // also allow free text (for BOX captions) + for (const EmailQSLBase::MergeField &f : EmailQSLBase::availableMergeFields()) + cb->addItem(f.key); + return cb; + } + void setEditorData(QWidget *editor, const QModelIndex &index) const override + { + QComboBox *cb = static_cast(editor); + const QString v = index.data(Qt::DisplayRole).toString(); + const int idx = cb->findText(v); + if (idx >= 0) cb->setCurrentIndex(idx); + else cb->setEditText(v); + } + void setModelData(QWidget *editor, QAbstractItemModel *model, + const QModelIndex &index) const override + { + model->setData(index, static_cast(editor)->currentText(), Qt::EditRole); + } + void updateEditorGeometry(QWidget *editor, const QStyleOptionViewItem &option, + const QModelIndex &) const override + { editor->setGeometry(option.rect); } +}; + +// --------------------------------------------------------------------------- +// Delegate: QFontComboBox for the Font column +// --------------------------------------------------------------------------- +class FontFamilyDelegate : public QStyledItemDelegate +{ +public: + using QStyledItemDelegate::QStyledItemDelegate; + QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem &, + const QModelIndex &) const override + { return new QFontComboBox(parent); } + void setEditorData(QWidget *editor, const QModelIndex &index) const override + { + static_cast(editor)->setCurrentFont( + QFont(index.data(Qt::DisplayRole).toString())); + } + void setModelData(QWidget *editor, QAbstractItemModel *model, + const QModelIndex &index) const override + { + model->setData(index, + static_cast(editor)->currentFont().family(), Qt::EditRole); + } + void updateEditorGeometry(QWidget *editor, const QStyleOptionViewItem &option, + const QModelIndex &) const override + { editor->setGeometry(option.rect); } +}; + +// --------------------------------------------------------------------------- +// Delegate: colored swatch — double-click opens QColorDialog +// --------------------------------------------------------------------------- +class ColorSwatchDelegate : public QStyledItemDelegate +{ +public: + using QStyledItemDelegate::QStyledItemDelegate; + + void paint(QPainter *painter, const QStyleOptionViewItem &option, + const QModelIndex &index) const override + { + const QString hex = index.data(Qt::DisplayRole).toString(); + const QColor c(hex.isEmpty() ? QStringLiteral("#000000") : hex); + const QRect r = option.rect.adjusted(2, 2, -2, -2); + painter->fillRect(r, c); + painter->setPen(QPen(Qt::gray, 1)); + painter->drawRect(r); + painter->setPen(c.lightness() > 128 ? Qt::black : Qt::white); + QFont f = painter->font(); f.setPointSize(8); painter->setFont(f); + painter->drawText(option.rect, Qt::AlignCenter, hex); + } + + QWidget *createEditor(QWidget *, const QStyleOptionViewItem &, + const QModelIndex &) const override { return nullptr; } + + bool editorEvent(QEvent *event, QAbstractItemModel *model, + const QStyleOptionViewItem &, const QModelIndex &index) override + { + if (event->type() != QEvent::MouseButtonDblClick) return false; + const QString cur = index.data(Qt::DisplayRole).toString(); + const QColor chosen = QColorDialog::getColor( + QColor(cur.isEmpty() ? QStringLiteral("#000000") : cur), + nullptr, tr("Choose Color")); + if (chosen.isValid()) + model->setData(index, chosen.name(), Qt::EditRole); + return true; + } +}; + +// --------------------------------------------------------------------------- +// Delegate: compact QSpinBox — editor width constrained to cell +// --------------------------------------------------------------------------- +class SpinBoxDelegate : public QStyledItemDelegate +{ + int m_min, m_max; +public: + SpinBoxDelegate(int min, int max, QObject *parent) + : QStyledItemDelegate(parent), m_min(min), m_max(max) {} + + QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem &option, + const QModelIndex &) const override + { + QSpinBox *sb = new QSpinBox(parent); + sb->setRange(m_min, m_max); + // Constrain width so the spinbox doesn't expand beyond the cell + sb->setFixedWidth(option.rect.width()); + sb->setButtonSymbols(QAbstractSpinBox::UpDownArrows); + return sb; + } + void setEditorData(QWidget *editor, const QModelIndex &index) const override + { + static_cast(editor)->setValue(index.data(Qt::DisplayRole).toInt()); + } + void setModelData(QWidget *editor, QAbstractItemModel *model, + const QModelIndex &index) const override + { + model->setData(index, static_cast(editor)->value(), Qt::EditRole); + } + void updateEditorGeometry(QWidget *editor, const QStyleOptionViewItem &option, + const QModelIndex &) const override + { editor->setGeometry(option.rect); } +}; + +// --------------------------------------------------------------------------- +// SMTP presets +// --------------------------------------------------------------------------- +struct SmtpPreset +{ + QString label; + QString host; + int port; + int encryption; + QString hint; +}; + +static QList smtpPresets() +{ + return { + { QObject::tr("-- Select preset --"), {}, 587, 2, {} }, + { QStringLiteral("Gmail"), + QStringLiteral("smtp.gmail.com"), 587, 2, + QObject::tr("Gmail: go to myaccount.google.com → Security → 2-Step Verification → App Passwords.\n" + "Create an App Password for 'Mail'. Use the 16-character code as your password here.") }, + { QStringLiteral("Gmail (SSL/TLS port 465)"), + QStringLiteral("smtp.gmail.com"), 465, 1, + QObject::tr("Gmail SSL variant (port 465). Use your Gmail App Password.") }, + { QStringLiteral("Outlook / Hotmail / Microsoft 365"), + QStringLiteral("smtp-mail.outlook.com"), 587, 2, + QObject::tr("Outlook: use your full Microsoft email and password.\n" + "If MFA is on, generate an App Password at account.microsoft.com → Security.") }, + { QStringLiteral("Yahoo Mail"), + QStringLiteral("smtp.mail.yahoo.com"), 587, 2, + QObject::tr("Yahoo: go to Account Security and click 'Generate app password'.") }, + { QStringLiteral("iCloud Mail"), + QStringLiteral("smtp.mail.me.com"), 587, 2, + QObject::tr("iCloud: generate an App-Specific Password at appleid.apple.com → Sign-In and Security.") }, + { QStringLiteral("Zoho Mail"), + QStringLiteral("smtp.zoho.com"), 587, 2, + QObject::tr("Zoho: enable SMTP in Zoho Mail Settings → Mail Accounts → IMAP/POP/SMTP.") }, + { QStringLiteral("Custom"), {}, 587, 2, {} }, + }; +} + +// --------------------------------------------------------------------------- +// Helper — build a dummy QSqlRecord for previewing merge fields +// --------------------------------------------------------------------------- +static QSqlRecord buildDummyRecord() +{ + static const QStringList cols = { + "callsign","start_time","freq","band","mode","submode", + "rst_sent","rst_rcvd","name_intl","name","qth_intl","qth", + "country_intl","country","gridsquare","dxcc","cqz","ituz", + "tx_pwr","email","station_callsign","my_gridsquare","operator", + "comment_intl","comment","sota_ref","pota_ref","wwff_ref", + "iota","sig_intl","contest_id" + }; + QSqlRecord r; + for (const QString &c : cols) + { + QSqlField f(c, QMetaType::fromType()); + r.append(f); + } + r.setValue("callsign", QStringLiteral("W1AW")); + r.setValue("start_time", QDateTime::currentDateTimeUtc().toString(Qt::ISODate)); + r.setValue("freq", QStringLiteral("14.225")); + r.setValue("band", QStringLiteral("20M")); + r.setValue("mode", QStringLiteral("SSB")); + r.setValue("rst_sent", QStringLiteral("59")); + r.setValue("rst_rcvd", QStringLiteral("59")); + r.setValue("name_intl", QStringLiteral("ARRL HQ")); + r.setValue("station_callsign", QStringLiteral("AA5SH")); + r.setValue("my_gridsquare", QStringLiteral("EM20")); + r.setValue("gridsquare", QStringLiteral("FN31")); + r.setValue("country_intl", QStringLiteral("United States")); + return r; +} + +// --------------------------------------------------------------------------- +// Helper — build a default overlay +// --------------------------------------------------------------------------- +static EmailQSLFieldOverlay makeOverlay(const QString &field, + int x, int y, + int fontSize, + bool bold, + const QString &color = QStringLiteral("#000000"), + const QString &font = QStringLiteral("Arial")) +{ + EmailQSLFieldOverlay ov; + ov.fieldName = field; + ov.x = x; + ov.y = y; + ov.fontSize = fontSize; + ov.bold = bold; + ov.fontFamily = font; + ov.color = color; + return ov; +} + +// --------------------------------------------------------------------------- +// EmailQSLSettingsWidget +// --------------------------------------------------------------------------- + +EmailQSLSettingsWidget::EmailQSLSettingsWidget(QWidget *parent) + : QWidget(parent), + ui(new Ui::EmailQSLSettingsWidget), + m_overlayModel(new QStandardItemModel(0, OC_COUNT, this)), + m_testService(new EmailQSLService(this)), + m_syncing(false) +{ + FCT_IDENTIFICATION; + + ui->setupUi(this); + + // ---- Overlay table ---- + m_overlayModel->setHorizontalHeaderLabels({ + tr("Type"), tr("Field / Caption"), + tr("X"), tr("Y"), + tr("Font"), tr("Pt"), + tr("Color"), tr("B"), tr("I"), + tr("W"), tr("H"), tr("Fill"), tr("Opacity%"), tr("Radius") + }); + ui->overlayTableView->setModel(m_overlayModel); + + // Delegates + auto *spinCoord = new SpinBoxDelegate(0, 99999, this); + auto *spinSz = new SpinBoxDelegate(4, 300, this); + auto *spinDim = new SpinBoxDelegate(1, 99999, this); + auto *spinOpac = new SpinBoxDelegate(0, 100, this); + auto *spinRadius = new SpinBoxDelegate(0, 500, this); + + ui->overlayTableView->setItemDelegateForColumn(OC_TYPE, new TypeDelegate(this)); + ui->overlayTableView->setItemDelegateForColumn(OC_FIELD, new MergeFieldDelegate(this)); + ui->overlayTableView->setItemDelegateForColumn(OC_X, spinCoord); + ui->overlayTableView->setItemDelegateForColumn(OC_Y, spinCoord); + ui->overlayTableView->setItemDelegateForColumn(OC_FONT, new FontFamilyDelegate(this)); + ui->overlayTableView->setItemDelegateForColumn(OC_SIZE, spinSz); + ui->overlayTableView->setItemDelegateForColumn(OC_COLOR, new ColorSwatchDelegate(this)); + ui->overlayTableView->setItemDelegateForColumn(OC_W, spinDim); + ui->overlayTableView->setItemDelegateForColumn(OC_H, spinDim); + ui->overlayTableView->setItemDelegateForColumn(OC_FILL, new ColorSwatchDelegate(this)); + ui->overlayTableView->setItemDelegateForColumn(OC_OPACITY, spinOpac); + ui->overlayTableView->setItemDelegateForColumn(OC_RADIUS, spinRadius); + + // Column widths — fixed-width numeric columns stay compact + auto *hdr = ui->overlayTableView->horizontalHeader(); + hdr->setSectionResizeMode(OC_TYPE, QHeaderView::ResizeToContents); + hdr->setSectionResizeMode(OC_FIELD, QHeaderView::Stretch); + hdr->setSectionResizeMode(OC_X, QHeaderView::Fixed); + hdr->setSectionResizeMode(OC_Y, QHeaderView::Fixed); + hdr->setSectionResizeMode(OC_FONT, QHeaderView::Stretch); + hdr->setSectionResizeMode(OC_SIZE, QHeaderView::Fixed); + hdr->setSectionResizeMode(OC_COLOR, QHeaderView::Fixed); + hdr->setSectionResizeMode(OC_BOLD, QHeaderView::Fixed); + hdr->setSectionResizeMode(OC_ITALIC, QHeaderView::Fixed); + hdr->setSectionResizeMode(OC_W, QHeaderView::Fixed); + hdr->setSectionResizeMode(OC_H, QHeaderView::Fixed); + hdr->setSectionResizeMode(OC_FILL, QHeaderView::Fixed); + hdr->setSectionResizeMode(OC_OPACITY, QHeaderView::Fixed); + hdr->setSectionResizeMode(OC_RADIUS, QHeaderView::Fixed); + // Set pixel widths for fixed columns + ui->overlayTableView->setColumnWidth(OC_X, 55); + ui->overlayTableView->setColumnWidth(OC_Y, 55); + ui->overlayTableView->setColumnWidth(OC_SIZE, 40); + ui->overlayTableView->setColumnWidth(OC_COLOR, 60); + ui->overlayTableView->setColumnWidth(OC_BOLD, 24); + ui->overlayTableView->setColumnWidth(OC_ITALIC, 24); + ui->overlayTableView->setColumnWidth(OC_W, 55); + ui->overlayTableView->setColumnWidth(OC_H, 55); + ui->overlayTableView->setColumnWidth(OC_FILL, 60); + ui->overlayTableView->setColumnWidth(OC_OPACITY, 55); + ui->overlayTableView->setColumnWidth(OC_RADIUS, 50); + + // ---- Merge field reference table ---- + const auto fields = EmailQSLBase::availableMergeFields(); + ui->mergeFieldRefTable->setRowCount(fields.size()); + for (int i = 0; i < fields.size(); ++i) + { + ui->mergeFieldRefTable->setItem( + i, 0, new QTableWidgetItem(QLatin1Char('{') + fields[i].key + QLatin1Char('}'))); + ui->mergeFieldRefTable->setItem( + i, 1, new QTableWidgetItem(fields[i].description)); + } + ui->mergeFieldRefTable->horizontalHeader()->setSectionResizeMode(0, QHeaderView::ResizeToContents); + ui->mergeFieldRefTable->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch); + + // ---- Merge field combos (subject/body insert) ---- + for (const EmailQSLBase::MergeField &f : fields) + { + const QString display = QLatin1Char('{') + f.key + QLatin1Char('}'); + ui->bodyFieldCombo->addItem(display); + ui->subjectFieldCombo->addItem(display); + } + + // ---- SMTP presets ---- + ui->smtpPresetCombo->blockSignals(true); + for (const SmtpPreset &p : smtpPresets()) + ui->smtpPresetCombo->addItem(p.label); + ui->smtpPresetCombo->blockSignals(false); + + // ---- Connections ---- + connect(ui->browseCardImageButton, &QPushButton::clicked, + this, &EmailQSLSettingsWidget::browseCardImage); + connect(ui->cardImagePathEdit, &QLineEdit::textChanged, + this, &EmailQSLSettingsWidget::onCardImageChanged); + + connect(ui->smtpPresetCombo, QOverload::of(&QComboBox::currentIndexChanged), + this, &EmailQSLSettingsWidget::onSmtpPresetChanged); + connect(ui->testConnectionButton, &QPushButton::clicked, + this, &EmailQSLSettingsWidget::testConnection); + connect(m_testService, &EmailQSLService::testFinished, + this, &EmailQSLSettingsWidget::onTestFinished); + + connect(ui->addOverlayButton, &QPushButton::clicked, + this, &EmailQSLSettingsWidget::addOverlay); + connect(ui->addBoxButton, &QPushButton::clicked, + this, &EmailQSLSettingsWidget::addBox); + connect(ui->addLabelButton, &QPushButton::clicked, + this, &EmailQSLSettingsWidget::addLabel); + connect(ui->addDefaultFieldsButton, &QPushButton::clicked, + this, &EmailQSLSettingsWidget::addDefaultOverlays); + connect(ui->removeOverlayButton, &QPushButton::clicked, + this, &EmailQSLSettingsWidget::removeOverlay); + connect(ui->previewCardButton, &QPushButton::clicked, + this, &EmailQSLSettingsWidget::previewCard); + + connect(ui->insertBodyFieldButton, &QPushButton::clicked, + this, &EmailQSLSettingsWidget::insertMergeFieldBody); + connect(ui->insertSubjectFieldButton, &QPushButton::clicked, + this, &EmailQSLSettingsWidget::insertMergeFieldSubject); + + // Table ↔ editor bidirectional sync + connect(m_overlayModel, &QStandardItemModel::dataChanged, + this, &EmailQSLSettingsWidget::onTableDataChanged); + connect(m_overlayModel, &QStandardItemModel::rowsRemoved, + this, &EmailQSLSettingsWidget::onTableDataChanged); + + connect(ui->cardEditorWidget, &CardEditorWidget::overlayPositionChanged, + this, &EmailQSLSettingsWidget::onEditorPositionChanged); + connect(ui->cardEditorWidget, &CardEditorWidget::overlaySizeChanged, + this, &EmailQSLSettingsWidget::onEditorSizeChanged); + connect(ui->cardEditorWidget, &CardEditorWidget::overlaySelected, + this, &EmailQSLSettingsWidget::onEditorOverlaySelected); + + connect(ui->overlayTableView->selectionModel(), + &QItemSelectionModel::currentRowChanged, + this, [this](const QModelIndex ¤t, const QModelIndex &) { + ui->cardEditorWidget->setSelectedIndex( + current.isValid() ? current.row() : -1); + ui->removeOverlayButton->setEnabled(current.isValid()); + }); +} + +EmailQSLSettingsWidget::~EmailQSLSettingsWidget() +{ + delete ui; +} + +// --------------------------------------------------------------------------- +// readSettings / writeSettings +// --------------------------------------------------------------------------- + +void EmailQSLSettingsWidget::readSettings() +{ + FCT_IDENTIFICATION; + + ui->smtpHostEdit->setText(EmailQSLBase::getSmtpHost()); + ui->smtpPortSpin->setValue(EmailQSLBase::getSmtpPort()); + ui->encryptionCombo->setCurrentIndex(EmailQSLBase::getSmtpEncryption()); + ui->smtpUsernameEdit->setText(EmailQSLBase::getSmtpUsername()); + ui->smtpPasswordEdit->setText(EmailQSLBase::getSmtpPassword()); + ui->fromAddressEdit->setText(EmailQSLBase::getFromAddress()); + ui->fromNameEdit->setText(EmailQSLBase::getFromName()); + ui->subjectTemplateEdit->setText(EmailQSLBase::getSubjectTemplate()); + ui->bodyTemplateEdit->setPlainText(EmailQSLBase::getBodyTemplate()); + ui->cardImagePathEdit->setText(EmailQSLBase::getCardImagePath()); + + m_overlays = EmailQSLBase::getCardFieldOverlays(); + listToOverlayTable(); + + // Trigger image load into the editor (textChanged may not fire if value unchanged) + onCardImageChanged(ui->cardImagePathEdit->text()); +} + +void EmailQSLSettingsWidget::writeSettings() +{ + FCT_IDENTIFICATION; + + EmailQSLBase::setSmtpHost(ui->smtpHostEdit->text().trimmed()); + EmailQSLBase::setSmtpPort(ui->smtpPortSpin->value()); + EmailQSLBase::setSmtpEncryption(ui->encryptionCombo->currentIndex()); + EmailQSLBase::saveSmtpCredentials(ui->smtpUsernameEdit->text().trimmed(), + ui->smtpPasswordEdit->text()); + EmailQSLBase::setFromAddress(ui->fromAddressEdit->text().trimmed()); + EmailQSLBase::setFromName(ui->fromNameEdit->text().trimmed()); + EmailQSLBase::setSubjectTemplate(ui->subjectTemplateEdit->text()); + EmailQSLBase::setBodyTemplate(ui->bodyTemplateEdit->toPlainText()); + EmailQSLBase::setCardImagePath(ui->cardImagePathEdit->text().trimmed()); + + overlayTableToList(); + EmailQSLBase::setCardFieldOverlays(m_overlays); +} + +// --------------------------------------------------------------------------- +// Overlay table ↔ m_overlays ↔ card editor +// --------------------------------------------------------------------------- + +// Helper — make a greyed-out non-editable placeholder for N/A cells +static QStandardItem *naItem() +{ + QStandardItem *si = new QStandardItem(QStringLiteral("—")); + si->setFlags(si->flags() & ~Qt::ItemIsEditable); + si->setForeground(QColor(0xbb, 0xbb, 0xbb)); + si->setTextAlignment(Qt::AlignCenter); + return si; +} + +void EmailQSLSettingsWidget::listToOverlayTable() +{ + // Guard against re-entrant onTableDataChanged() firing during setRowCount(0) + // which would wipe m_overlays before we finish rebuilding the table. + m_syncing = true; + m_overlayModel->setRowCount(0); + for (const EmailQSLFieldOverlay &ov : m_overlays) + { + const bool isBox = (ov.type == QLatin1String("BOX")); + + auto item = [](const QString &text) { return new QStandardItem(text); }; + auto checkItem = [](bool checked) { + QStandardItem *si = new QStandardItem(); + si->setCheckable(true); + si->setCheckState(checked ? Qt::Checked : Qt::Unchecked); + si->setTextAlignment(Qt::AlignCenter); + return si; + }; + + QList row; + row << item(ov.type); // OC_TYPE + row << item(ov.fieldName); // OC_FIELD + row << item(QString::number(ov.x)); // OC_X + row << item(QString::number(ov.y)); // OC_Y + // TEXT-only + row << (isBox ? naItem() : item(ov.fontFamily)); // OC_FONT + row << (isBox && ov.fieldName.isEmpty() + ? naItem() : item(QString::number(ov.fontSize))); // OC_SIZE + row << item(ov.color); // OC_COLOR (border/text) + row << (isBox ? naItem() : checkItem(ov.bold)); // OC_BOLD + row << (isBox ? naItem() : checkItem(ov.italic)); // OC_ITALIC + // BOX-only + row << (isBox ? item(QString::number(ov.width)) : naItem()); // OC_W + row << (isBox ? item(QString::number(ov.height)) : naItem()); // OC_H + row << (isBox ? item(ov.fillColor) : naItem()); // OC_FILL + row << (isBox ? item(QString::number(ov.opacity)): naItem()); // OC_OPACITY + row << (isBox ? item(QString::number(ov.cornerRadius)) : naItem()); // OC_RADIUS + + m_overlayModel->appendRow(row); + } + m_syncing = false; + ui->cardEditorWidget->setOverlays(m_overlays); +} + +void EmailQSLSettingsWidget::overlayTableToList() +{ + m_overlays.clear(); + for (int r = 0; r < m_overlayModel->rowCount(); ++r) + { + EmailQSLFieldOverlay ov; + auto txt = [&](int col) { return m_overlayModel->item(r, col)->text(); }; + + ov.type = txt(OC_TYPE); + ov.fieldName = txt(OC_FIELD); + ov.x = txt(OC_X).toInt(); + ov.y = txt(OC_Y).toInt(); + ov.color = txt(OC_COLOR); + + if (ov.type == QLatin1String("BOX")) + { + ov.fontFamily = QStringLiteral("Arial"); + ov.fontSize = txt(OC_SIZE) == QStringLiteral("—") ? 11 : txt(OC_SIZE).toInt(); + ov.width = txt(OC_W).toInt(); + ov.height = txt(OC_H).toInt(); + ov.fillColor = txt(OC_FILL); + ov.opacity = txt(OC_OPACITY).toInt(); + ov.cornerRadius = txt(OC_RADIUS).toInt(); + } + else + { + ov.fontFamily = txt(OC_FONT); + ov.fontSize = txt(OC_SIZE).toInt(); + ov.bold = m_overlayModel->item(r, OC_BOLD)->checkState() == Qt::Checked; + ov.italic = m_overlayModel->item(r, OC_ITALIC)->checkState() == Qt::Checked; + } + m_overlays.append(ov); + } +} + +void EmailQSLSettingsWidget::onTableDataChanged() +{ + FCT_IDENTIFICATION; + + if (m_syncing) return; + m_syncing = true; + overlayTableToList(); + ui->cardEditorWidget->setOverlays(m_overlays); + m_syncing = false; +} + +void EmailQSLSettingsWidget::onEditorPositionChanged(int index, int x, int y) +{ + FCT_IDENTIFICATION; + + if (m_syncing || index < 0 || index >= m_overlayModel->rowCount()) return; + m_syncing = true; + m_overlayModel->item(index, OC_X)->setText(QString::number(x)); + m_overlayModel->item(index, OC_Y)->setText(QString::number(y)); + if (index < m_overlays.size()) + { m_overlays[index].x = x; m_overlays[index].y = y; } + m_syncing = false; +} + +void EmailQSLSettingsWidget::onEditorSizeChanged(int index, int w, int h) +{ + FCT_IDENTIFICATION; + + if (m_syncing || index < 0 || index >= m_overlayModel->rowCount()) return; + m_syncing = true; + auto *wi = m_overlayModel->item(index, OC_W); + auto *hi = m_overlayModel->item(index, OC_H); + if (wi && wi->text() != QStringLiteral("—")) wi->setText(QString::number(w)); + if (hi && hi->text() != QStringLiteral("—")) hi->setText(QString::number(h)); + if (index < m_overlays.size()) + { m_overlays[index].width = w; m_overlays[index].height = h; } + m_syncing = false; +} + +void EmailQSLSettingsWidget::onEditorOverlaySelected(int index) +{ + if (index >= 0) + ui->overlayTableView->selectRow(index); + else + ui->overlayTableView->clearSelection(); + ui->removeOverlayButton->setEnabled(index >= 0); +} + +// --------------------------------------------------------------------------- +// Slots: image +// --------------------------------------------------------------------------- + +void EmailQSLSettingsWidget::browseCardImage() +{ + FCT_IDENTIFICATION; + + const QString path = QFileDialog::getOpenFileName( + this, tr("Select QSL Card Image"), + ui->cardImagePathEdit->text(), + tr("Images (*.jpg *.jpeg *.png *.bmp);;All files (*)")); + if (!path.isEmpty()) + ui->cardImagePathEdit->setText(path); +} + +void EmailQSLSettingsWidget::onCardImageChanged(const QString &path) +{ + FCT_IDENTIFICATION; + + QPixmap pm(path); + ui->cardEditorWidget->setImage(pm); +} + +// --------------------------------------------------------------------------- +// Slots: overlays +// --------------------------------------------------------------------------- + +void EmailQSLSettingsWidget::addOverlay() +{ + FCT_IDENTIFICATION; + + // Default position: top-left area of image (or 50,50 if no image) + const QPixmap &img = ui->cardEditorWidget->image(); + const int cx = img.isNull() ? 50 : img.width() / 2; + const int cy = img.isNull() ? 50 : img.height() / 4; + + overlayTableToList(); + m_overlays.append(makeOverlay(QStringLiteral("CALLSIGN"), cx, cy, 14, false)); + listToOverlayTable(); + + const int newRow = m_overlayModel->rowCount() - 1; + ui->overlayTableView->setCurrentIndex(m_overlayModel->index(newRow, 0)); + ui->overlayTableView->scrollToBottom(); + ui->cardEditorWidget->setSelectedIndex(newRow); +} + +void EmailQSLSettingsWidget::addBox() +{ + FCT_IDENTIFICATION; + + const QPixmap &img = ui->cardEditorWidget->image(); + const int W = img.isNull() ? 1000 : img.width(); + const int H = img.isNull() ? 700 : img.height(); + + EmailQSLFieldOverlay ov; + ov.type = QStringLiteral("BOX"); + ov.fieldName = QString(); // no caption by default + ov.x = qRound(W * 0.05); + ov.y = qRound(H * 0.60); + ov.width = qRound(W * 0.25); + ov.height = qRound(H * 0.22); + ov.fillColor = QStringLiteral("#FFFF99"); + ov.color = QStringLiteral("#888800"); + ov.opacity = 80; + ov.cornerRadius = qMax(4, qRound(H * 0.02)); + ov.fontSize = 0; // no caption + + overlayTableToList(); + m_overlays.append(ov); + listToOverlayTable(); + + const int newRow = m_overlayModel->rowCount() - 1; + ui->overlayTableView->setCurrentIndex(m_overlayModel->index(newRow, 0)); + ui->overlayTableView->scrollToBottom(); + ui->cardEditorWidget->setSelectedIndex(newRow); +} + +void EmailQSLSettingsWidget::addLabel() +{ + FCT_IDENTIFICATION; + + const QPixmap &img = ui->cardEditorWidget->image(); + const int cx = img.isNull() ? 50 : img.width() / 2; + const int cy = img.isNull() ? 50 : img.height() / 4; + + EmailQSLFieldOverlay ov; + ov.type = QStringLiteral("LABEL"); + ov.fieldName = tr("Label Text"); + ov.x = cx; + ov.y = cy; + ov.fontFamily = QStringLiteral("Arial"); + ov.fontSize = 14; + ov.color = QStringLiteral("#000000"); + ov.bold = false; + ov.italic = false; + + overlayTableToList(); + m_overlays.append(ov); + listToOverlayTable(); + + const int newRow = m_overlayModel->rowCount() - 1; + ui->overlayTableView->setCurrentIndex(m_overlayModel->index(newRow, 0)); + ui->overlayTableView->scrollToBottom(); + ui->cardEditorWidget->setSelectedIndex(newRow); +} + +void EmailQSLSettingsWidget::addDefaultOverlays() +{ + FCT_IDENTIFICATION; + + const QPixmap &img = ui->cardEditorWidget->image(); + const int W = img.isNull() ? 1000 : img.width(); + const int H = img.isNull() ? 700 : img.height(); + + // Helper lambda to make a LABEL (static text) overlay + auto makeLabel = [](const QString &text, int x, int y, int fontSize, bool bold, + const QString &color = QStringLiteral("#333333")) -> EmailQSLFieldOverlay + { + EmailQSLFieldOverlay ov; + ov.type = QStringLiteral("LABEL"); + ov.fieldName = text; + ov.x = x; + ov.y = y; + ov.fontSize = fontSize; + ov.bold = bold; + ov.fontFamily = QStringLiteral("Arial"); + ov.color = color; + return ov; + }; + + // Positions expressed as fractions of card dimensions. + // Each data field is preceded by a static label one line above it. + const int labelSz = 9; + const int dataSz = 14; + const int labelOffY = qRound(H * 0.04); // label sits this many px above the data row + + const int rowName = qRound(H * 0.60); + const int rowCall = qRound(H * 0.72); + const int rowData = qRound(H * 0.82); + + const int col1 = qRound(W * 0.08); + const int col2 = qRound(W * 0.35); + const int col3 = qRound(W * 0.55); + const int col4 = qRound(W * 0.68); + const int col5 = qRound(W * 0.80); + const int col6 = qRound(W * 0.90); + const int colGrid = qRound(W * 0.40); + + const QList defaults = { + // My callsign centred near top — no label needed + makeOverlay("MY_CALLSIGN", qRound(W * 0.50), qRound(H * 0.18), 28, true), + + // Name row + makeLabel(tr("Name:"), col1, rowName - labelOffY, labelSz, false), + makeOverlay("NAME", col1, rowName, dataSz, false), + + // Callsign + grid row + makeLabel(tr("Callsign:"), col1, rowCall - labelOffY, labelSz, false), + makeOverlay("CALLSIGN", col1, rowCall, dataSz, true), + makeLabel(tr("Grid:"), colGrid, rowCall - labelOffY, labelSz, false), + makeOverlay("GRIDSQUARE", colGrid, rowCall, dataSz, false), + + // QSO detail row: Date / Time / Band / Mode / RST S / RST R + makeLabel(tr("Date:"), col1, rowData - labelOffY, labelSz, false), + makeOverlay("QSO_DATE", col1, rowData, dataSz, false), + makeLabel(tr("Time (UTC):"), col2, rowData - labelOffY, labelSz, false), + makeOverlay("TIME_ON", col2, rowData, dataSz, false), + makeLabel(tr("Band:"), col3, rowData - labelOffY, labelSz, false), + makeOverlay("BAND", col3, rowData, dataSz, false), + makeLabel(tr("Mode:"), col4, rowData - labelOffY, labelSz, false), + makeOverlay("MODE", col4, rowData, dataSz, false), + makeLabel(tr("RST Snt:"), col5, rowData - labelOffY, labelSz, false), + makeOverlay("RST_SENT", col5, rowData, dataSz, false), + makeLabel(tr("RST Rcvd:"), col6, rowData - labelOffY, labelSz, false), + makeOverlay("RST_RCVD", col6, rowData, dataSz, false), + }; + + overlayTableToList(); + for (const EmailQSLFieldOverlay &ov : defaults) + m_overlays.append(ov); + listToOverlayTable(); + ui->overlayTableView->scrollToBottom(); +} + +void EmailQSLSettingsWidget::removeOverlay() +{ + FCT_IDENTIFICATION; + + const QModelIndexList sel = ui->overlayTableView->selectionModel()->selectedRows(); + if (sel.isEmpty()) + return; + + overlayTableToList(); + m_overlays.removeAt(sel.first().row()); + listToOverlayTable(); +} + +// --------------------------------------------------------------------------- +// Full-size preview — renders using current UI state (not QSettings) +// --------------------------------------------------------------------------- + +QPixmap EmailQSLSettingsWidget::renderPreviewPixmap(const QSqlRecord &record) +{ + FCT_IDENTIFICATION; + + overlayTableToList(); + return EmailQSLBase::renderCard(ui->cardImagePathEdit->text().trimmed(), + record, m_overlays); +} + +void EmailQSLSettingsWidget::previewCard() +{ + FCT_IDENTIFICATION; + + const QSqlRecord dummy = buildDummyRecord(); + const QPixmap preview = renderPreviewPixmap(dummy); + + if (preview.isNull()) + { + QMessageBox::warning(this, tr("Preview"), + tr("Could not load the card image.\n" + "Please check the path and make sure the file exists.")); + return; + } + + QDialog *dlg = new QDialog(this); + dlg->setAttribute(Qt::WA_DeleteOnClose); + dlg->setWindowTitle(tr("Card Preview (test data)")); + QVBoxLayout *lay = new QVBoxLayout(dlg); + + QScrollArea *scroll = new QScrollArea(dlg); + QLabel *lbl = new QLabel(scroll); + const QPixmap scaled = preview.scaled( + QSize(800, 600), Qt::KeepAspectRatio, Qt::SmoothTransformation); + lbl->setPixmap(scaled); + lbl->adjustSize(); + scroll->setWidget(lbl); + scroll->setMinimumSize(qMin(scaled.width() + 20, 820), + qMin(scaled.height() + 20, 620)); + lay->addWidget(scroll); + + QDialogButtonBox *bb = new QDialogButtonBox(QDialogButtonBox::Close, dlg); + QPushButton *saveBtn = bb->addButton(tr("Save Card…"), QDialogButtonBox::ActionRole); + + connect(saveBtn, &QPushButton::clicked, this, [preview, this]() + { + const QString defaultDir = QStandardPaths::writableLocation(QStandardPaths::PicturesLocation); + const QString path = QFileDialog::getSaveFileName( + this, + tr("Save QSL Card"), + defaultDir + QStringLiteral("/QSL_preview.png"), + tr("PNG Image (*.png);;JPEG Image (*.jpg *.jpeg)")); + + if (path.isEmpty()) + return; + + if (!preview.save(path)) + { + QMessageBox::warning(this, tr("Save Failed"), + tr("Could not save the card image to:\n%1").arg(path)); + } + }); + + connect(bb, &QDialogButtonBox::rejected, dlg, &QDialog::close); + lay->addWidget(bb); + dlg->show(); // non-modal +} + +// --------------------------------------------------------------------------- +// SMTP +// --------------------------------------------------------------------------- + +void EmailQSLSettingsWidget::onSmtpPresetChanged(int index) +{ + FCT_IDENTIFICATION; + + const QList presets = smtpPresets(); + if (index <= 0 || index >= presets.size()) + return; + + const SmtpPreset &p = presets.at(index); + if (!p.host.isEmpty()) + { + ui->smtpHostEdit->setText(p.host); + ui->smtpPortSpin->setValue(p.port); + ui->encryptionCombo->setCurrentIndex(p.encryption); + } + if (!p.hint.isEmpty()) + QMessageBox::information(this, tr("Provider Notes"), p.hint); +} + +void EmailQSLSettingsWidget::testConnection() +{ + FCT_IDENTIFICATION; + + ui->testResultLabel->setText(tr("Testing…")); + ui->testConnectionButton->setEnabled(false); + + m_testService->testConnection( + ui->smtpHostEdit->text().trimmed(), + ui->smtpPortSpin->value(), + ui->encryptionCombo->currentIndex(), + ui->smtpUsernameEdit->text().trimmed(), + ui->smtpPasswordEdit->text()); +} + +void EmailQSLSettingsWidget::onTestFinished(bool success, const QString &message) +{ + FCT_IDENTIFICATION; + + ui->testConnectionButton->setEnabled(true); + if (success) + { + ui->testResultLabel->setStyleSheet(QStringLiteral("color: green; font-weight: bold;")); + ui->testResultLabel->setText(tr("Connection OK")); + } + else + { + ui->testResultLabel->setStyleSheet(QStringLiteral("color: red;")); + ui->testResultLabel->setText(tr("Failed: %1").arg(message)); + } +} + +// --------------------------------------------------------------------------- +// Template insertion +// --------------------------------------------------------------------------- + +void EmailQSLSettingsWidget::insertMergeFieldBody() +{ + FCT_IDENTIFICATION; + + const QString text = ui->bodyFieldCombo->currentText(); + if (!text.isEmpty()) + ui->bodyTemplateEdit->insertPlainText(text); +} + +void EmailQSLSettingsWidget::insertMergeFieldSubject() +{ + FCT_IDENTIFICATION; + + const QString text = ui->subjectFieldCombo->currentText(); + if (!text.isEmpty()) + ui->subjectTemplateEdit->insert(text); +} diff --git a/ui/EmailQSLSettingsWidget.h b/ui/EmailQSLSettingsWidget.h new file mode 100644 index 00000000..7f991175 --- /dev/null +++ b/ui/EmailQSLSettingsWidget.h @@ -0,0 +1,63 @@ +#ifndef QLOG_UI_EMAILQSLSETTINGSWIDGET_H +#define QLOG_UI_EMAILQSLSETTINGSWIDGET_H + +#include +#include +#include +#include +#include + +#include "service/emailqsl/EmailQSLService.h" + +namespace Ui { +class EmailQSLSettingsWidget; +} + +class EmailQSLSettingsWidget : public QWidget +{ + Q_OBJECT + +public: + explicit EmailQSLSettingsWidget(QWidget *parent = nullptr); + ~EmailQSLSettingsWidget(); + + void readSettings(); + void writeSettings(); + + // Render card using the current UI state (not saved QSettings). + // Used by the preview dialog so the user can see results before saving. + QPixmap renderPreviewPixmap(const QSqlRecord &record); + +public slots: + void browseCardImage(); + void onCardImageChanged(const QString &path); + void onSmtpPresetChanged(int index); + void testConnection(); + void onTestFinished(bool success, const QString &message); + void addOverlay(); + void addBox(); + void addLabel(); + void addDefaultOverlays(); + void removeOverlay(); + void insertMergeFieldBody(); + void insertMergeFieldSubject(); + void previewCard(); + + // Bidirectional sync between the overlay table and the card editor widget + void onTableDataChanged(); + void onEditorPositionChanged(int index, int newX, int newY); + void onEditorSizeChanged(int index, int newW, int newH); + void onEditorOverlaySelected(int index); + +private: + void overlayTableToList(); + void listToOverlayTable(); + + Ui::EmailQSLSettingsWidget *ui; + QList m_overlays; + QStandardItemModel *m_overlayModel; + EmailQSLService *m_testService; + bool m_syncing; +}; + +#endif // QLOG_UI_EMAILQSLSETTINGSWIDGET_H diff --git a/ui/EmailQSLSettingsWidget.ui b/ui/EmailQSLSettingsWidget.ui new file mode 100644 index 00000000..ae3d019c --- /dev/null +++ b/ui/EmailQSLSettingsWidget.ui @@ -0,0 +1,384 @@ + + + EmailQSLSettingsWidget + + + 00820720 + + Email QSL Settings + + + + + + Configure Email QSL card sending. Use {FIELD} placeholders in subject, body and card overlays. + + true + + + + + + + + + + + SMTP Server + + + + + Quick Presets + + + + Provider preset: + + + + + + Select a preset to auto-fill host, port and encryption. + +Gmail: enable 2-FA, then create an App Password at myaccount.google.com → Security → App Passwords. Use the 16-character app password (not your Google password). +Outlook / Hotmail / Microsoft 365: use your Microsoft account email and password. If MFA is enabled generate an App Password at account.microsoft.com → Security. +Yahoo Mail: go to Account Security and generate an App Password. +iCloud Mail: generate an App-Specific Password at appleid.apple.com → Sign-In and Security. + + + + + + Qt::Horizontal + + + + + + + + + Server + + + SMTP host: + + + + e.g. smtp.gmail.com + + + + Port: + + + + 1 + 65535 + 587 + + + + Encryption: + + + + None + SSL/TLS (implicit, port 465) + STARTTLS (explicit, port 587) + + + + + + + + + Authentication + + + Username: + + + + your.address@gmail.com + + + + Password / App password: + + + + QLineEdit::Password + App password recommended (16 chars for Gmail) + + + + From address: + + + + your.address@gmail.com + + + + From display name: + + + + AA5SH Amateur Radio + + + + + + + + + + + Test Connection + + + + + + + + + Qt::Horizontal + + + + + + Qt::Vertical + + + + + + + + + + Card Image & Overlays + + + + + + + Card image: + + + + Path to base QSL card image (JPEG or PNG) + + + + + Browse… + + + + + + + + + + 300220 + + + Click an overlay to select it. Drag to reposition it on the card. + + + + + + + + Click to select · Drag to move · BOX: drag bottom-right handle to resize. + + QLabel { color: #555; font-style: italic; } + + + + + + + Overlays + + + + 16777215170 + QAbstractItemView::SingleSelection + QAbstractItemView::SelectRows + + QAbstractItemView::DoubleClicked|QAbstractItemView::SelectedClicked|QAbstractItemView::EditKeyPressed + + true + + + + + + + Add Text + Add a merge-field text overlay + + + + + Add Box + Add a filled rounded rectangle (like the yellow boxes on traditional QSL cards) + + + + + Add Static Text + Add a free-form text label (literal text, not a merge field) + + + + + Default QSL Fields + + Insert a pre-built set of text overlays (callsign, date, time, band, mode, RST, name, grid) positioned across the lower area of the card. + + + + + + Remove + false + + + + Qt::Horizontal + + + + Full-Size Preview… + + + + + + + + TEXT: Field=merge key. BOX: Field=optional caption, drag corner to resize. LABEL: Field=literal text printed as-is. Double-click Color/Fill to pick color. + + true + QLabel { color: #555; } + + + + + + + + + + + + + + Email Template + + + + + Subject Line + + + + QSL Card from {MY_CALLSIGN} for our QSO on {QSO_DATE} + + + + + Choose a merge field to insert + + + + + Insert + + + + + + + + + Email Body + + + + + Insert field: + + + + Choose a merge field to insert at the cursor + + + + + Insert + + + + Qt::Horizontal + + + + + + 0180 + Enter email body here. Use {FIELD} placeholders. + + + + + + + + + Available Merge Fields + + + + 16777215150 + 2 + QAbstractItemView::NoEditTriggers + QAbstractItemView::NoSelection + Placeholder + Description + + + + + + + + + + + + + + + + + CardEditorWidget + QWidget +
ui/component/CardEditorWidget.h
+
+
+ + +
diff --git a/ui/LogbookWidget.cpp b/ui/LogbookWidget.cpp index 2e11b35c..56c4ffbc 100644 --- a/ui/LogbookWidget.cpp +++ b/ui/LogbookWidget.cpp @@ -1,6 +1,8 @@ #include #include #include +#include +#include #include #include #include @@ -33,6 +35,7 @@ #include "service/GenericCallbook.h" #include "core/QSOFilterManager.h" #include "core/LogParam.h" +#include "ui/EmailQSLDialog.h" MODULE_IDENTIFICATION("qlog.ui.logbookwidget"); @@ -133,6 +136,7 @@ LogbookWidget::LogbookWidget(QWidget *parent) : ui->contactTable->addAction(ui->actionFilter); ui->contactTable->addAction(ui->actionLookup); ui->contactTable->addAction(ui->actionSendDXCSpot); + ui->contactTable->addAction(ui->actionSendEmailQSL); ui->contactTable->addAction(separator); ui->contactTable->addAction(ui->actionExportAs); ui->contactTable->addAction(ui->actionCallbookLookup); @@ -1256,6 +1260,131 @@ void LogbookWidget::sendDXCSpot() emit sendDXSpotContactReq(model->record(selectedIndexes.at(0).row())); } +void LogbookWidget::sendEmailQSL() +{ + FCT_IDENTIFICATION; + + const QModelIndexList selectedIndexes = ui->contactTable->selectionModel()->selectedRows(); + if (selectedIndexes.isEmpty()) + return; + + // Collect records, skip those without an email address silently (report at end) + QList toSend; + int skippedNoEmail = 0; + + // Single resend decision covers both "already sent for this QSO" and + // "already sent to this callsign for a different QSO" — one prompt per + // batch regardless of the mix of duplicate types. + // -1 = undecided, 0 = skip all duplicates, 1 = resend all duplicates + int resendDecision = -1; + + const bool batchMode = (selectedIndexes.count() > 1); + + for (const QModelIndex &idx : selectedIndexes) + { + const QSqlRecord rec = model->record(idx.row()); + const QString email = rec.value(QStringLiteral("email")).toString().trimmed(); + + if (email.isEmpty()) + { + ++skippedNoEmail; + continue; + } + + const QString callsign = rec.value(QStringLiteral("callsign")).toString().toUpper(); + const int id = rec.value(QStringLiteral("id")).toInt(); + const QDateTime prevSent = EmailQSLBase::getEmailSentDateTime(rec); + const bool sentQso = prevSent.isValid(); + const bool sentCall = !sentQso && EmailQSLBase::hasEmailBeenSentToCallsign(callsign, id); + + if (sentQso || sentCall) + { + if (resendDecision == 0) + { + continue; // "No to All" already chosen + } + else if (resendDecision == -1) + { + QString detail = sentQso + ? tr("An Email QSL was already sent for %1 on %2 UTC.") + .arg(callsign, prevSent.toString(Qt::RFC2822Date)) + : tr("An Email QSL was previously sent to %1 for a different QSO.") + .arg(callsign); + + QString msg = detail + QStringLiteral("

") + tr("Do you want to send again?"); + if (batchMode) + msg += QStringLiteral("
") + + tr("\"Yes to All\" / \"No to All\" applies to every duplicate in this batch.") + + QStringLiteral(""); + + QMessageBox mb(this); + mb.setWindowTitle(tr("Already Sent — Send Again?")); + mb.setTextFormat(Qt::RichText); + mb.setText(msg); + mb.setIcon(QMessageBox::Question); + + QPushButton *yesBtn = mb.addButton(tr("Yes"), QMessageBox::YesRole); + QPushButton *yesAllBtn = batchMode ? mb.addButton(tr("Yes to All"), QMessageBox::YesRole) : nullptr; + QPushButton *noBtn = mb.addButton(tr("No"), QMessageBox::NoRole); + QPushButton *noAllBtn = batchMode ? mb.addButton(tr("No to All"), QMessageBox::NoRole) : nullptr; + Q_UNUSED(yesBtn) + + mb.exec(); + QAbstractButton *clicked = mb.clickedButton(); + + if (clicked == noAllBtn) { resendDecision = 0; continue; } + else if (clicked == noBtn) { continue; } + else if (clicked == yesAllBtn) { resendDecision = 1; } + // else "Yes" — include this one, leave decision open for next + } + // resendDecision == 1 → fall through and add + } + + toSend.append(rec); + } + + if (toSend.isEmpty()) + { + if (skippedNoEmail > 0) + QMessageBox::information(this, tr("Email QSL"), + tr("%n contact(s) skipped — no email address on file.", "", skippedNoEmail)); + return; + } + + // Show dialogs one at a time so they never pile up behind the main window + showNextEmailQSLDialog(new QList(toSend)); + + if (skippedNoEmail > 0) + QMessageBox::information(this, tr("Email QSL"), + tr("%n contact(s) skipped — no email address on file.", "", skippedNoEmail)); +} + +void LogbookWidget::showNextEmailQSLDialog(QList *queue) +{ + FCT_IDENTIFICATION; + + if (queue->isEmpty()) + { + delete queue; + model->select(); // refresh the log table once the whole batch is done + return; + } + + const QSqlRecord rec = queue->takeFirst(); + EmailQSLDialog *dialog = new EmailQSLDialog(rec, this); + dialog->setAttribute(Qt::WA_DeleteOnClose); + + // When this dialog closes (for any reason), open the next one + connect(dialog, &QDialog::finished, this, [this, queue]() + { + showNextEmailQSLDialog(queue); + }); + + dialog->show(); + dialog->raise(); + dialog->activateWindow(); +} + void LogbookWidget::setDefaultSort() { FCT_IDENTIFICATION; diff --git a/ui/LogbookWidget.h b/ui/LogbookWidget.h index 304562e0..3f8bce2f 100644 --- a/ui/LogbookWidget.h +++ b/ui/LogbookWidget.h @@ -87,6 +87,7 @@ public slots: void focusSearchCallsign(); void reloadSetting(); void sendDXCSpot(); + void sendEmailQSL(); void setDefaultSort(); void actionCallbookLookup(); void callsignFound(const CallbookResponseData &data); @@ -129,6 +130,7 @@ public slots: void updateQSORecordFromCallbook(const CallbookResponseData &data); void queryNextQSOLookupBatch(); void finishQSOLookupBatch(); + void showNextEmailQSLDialog(QList *queue); void clearSearchText(); void setupSearchMenu(); void setContactTableColumnVisible(int columnIndex, bool visible); diff --git a/ui/LogbookWidget.ui b/ui/LogbookWidget.ui index cca6bd5f..1ab80107 100644 --- a/ui/LogbookWidget.ui +++ b/ui/LogbookWidget.ui @@ -333,6 +333,14 @@ Logbook - Send DX Spot + + + Send Email QSL Card + + + Send an Email QSL card to this contact + + true @@ -567,6 +575,16 @@ + + actionSendEmailQSL + triggered() + LogbookWidget + sendEmailQSL() + + -1-1 + 404168 + + actionSendDXCSpot triggered() @@ -715,6 +733,7 @@ exportContact() clubFilterChanged() sendDXCSpot() + sendEmailQSL() setCallsignSearch() setGridsquareSearch() setPotaSearch() diff --git a/ui/SettingsDialog.cpp b/ui/SettingsDialog.cpp index 9d2cf0b2..339c49d5 100644 --- a/ui/SettingsDialog.cpp +++ b/ui/SettingsDialog.cpp @@ -54,6 +54,7 @@ #include "ui/BandmapWidget.h" #include "ui/RigctldAdvancedDialog.h" #include "cwkey/drivers/CWWinKey.h" +#include "ui/EmailQSLSettingsWidget.h" MODULE_IDENTIFICATION("qlog.ui.settingsdialog"); @@ -2779,6 +2780,11 @@ void SettingsDialog::readSettings() ui->unitFormatImperialRadioButton->setChecked(!unitFormatMetric); loadQsoStatusColors(); + /***************/ + /* Email QSL */ + /***************/ + ui->emailQSLSettingsWidget->readSettings(); + /******************/ /* END OF Reading */ /******************/ @@ -2912,6 +2918,11 @@ void SettingsDialog::writeSettings() locale.setSettingUseMetric(ui->unitFormatMetricRadioButton->isChecked()); saveQsoStatusColors(); Data::reloadQsoStatusColors(); + + /***************/ + /* Email QSL */ + /***************/ + ui->emailQSLSettingsWidget->writeSettings(); } void SettingsDialog::setupAdifRecoveryTab() diff --git a/ui/SettingsDialog.ui b/ui/SettingsDialog.ui index a5486d68..ccb7c4a9 100644 --- a/ui/SettingsDialog.ui +++ b/ui/SettingsDialog.ui @@ -5224,6 +5224,16 @@ + + + Email QSL + + + + + + + @@ -5239,6 +5249,11 @@ QLineEdit
ui/component/EditLine.h
+ + EmailQSLSettingsWidget + QWidget +
ui/EmailQSLSettingsWidget.h
+
stationProfileNameEdit diff --git a/ui/component/CardEditorWidget.cpp b/ui/component/CardEditorWidget.cpp new file mode 100644 index 00000000..c6c04b53 --- /dev/null +++ b/ui/component/CardEditorWidget.cpp @@ -0,0 +1,380 @@ +#include +#include +#include +#include +#include + +#include "CardEditorWidget.h" + +CardEditorWidget::CardEditorWidget(QWidget *parent) + : QWidget(parent) +{ + setMouseTracking(true); + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + setMinimumSize(300, 200); +} + +// --------------------------------------------------------------------------- +// Public interface +// --------------------------------------------------------------------------- + +void CardEditorWidget::setImage(const QPixmap &pm) +{ + m_image = pm; + update(); +} + +void CardEditorWidget::setOverlays(const QList &overlays) +{ + m_overlays = overlays; + if (m_selectedIndex >= m_overlays.size()) + m_selectedIndex = -1; + update(); +} + +void CardEditorWidget::updateOverlay(int index, const EmailQSLFieldOverlay &ov) +{ + if (index < 0 || index >= m_overlays.size()) + return; + m_overlays[index] = ov; + update(); +} + +void CardEditorWidget::setSelectedIndex(int index) +{ + if (m_selectedIndex == index) + return; + m_selectedIndex = index; + update(); +} + +QSize CardEditorWidget::sizeHint() const +{ + if (m_image.isNull()) + return QSize(500, 320); + return m_image.size().scaled(640, 420, Qt::KeepAspectRatio); +} + +QSize CardEditorWidget::minimumSizeHint() const { return QSize(300, 200); } + +// --------------------------------------------------------------------------- +// Coordinate helpers +// --------------------------------------------------------------------------- + +QRectF CardEditorWidget::imageRect() const +{ + if (m_image.isNull()) + return QRectF(rect()); + QSizeF sz = m_image.size(); + sz.scale(size(), Qt::KeepAspectRatio); + return QRectF(QPointF((width() - sz.width()) / 2.0, + (height() - sz.height()) / 2.0), sz); +} + +double CardEditorWidget::displayScale() const +{ + if (m_image.isNull() || m_image.width() == 0) return 1.0; + return imageRect().width() / m_image.width(); +} + +QPointF CardEditorWidget::imageToWidget(const QPointF &ip) const +{ + const QRectF r = imageRect(); + const double sx = r.width() / (m_image.isNull() ? 1 : m_image.width()); + const double sy = r.height() / (m_image.isNull() ? 1 : m_image.height()); + return QPointF(r.left() + ip.x() * sx, r.top() + ip.y() * sy); +} + +QPointF CardEditorWidget::widgetToImage(const QPointF &wp) const +{ + const QRectF r = imageRect(); + if (r.width() == 0 || r.height() == 0) return wp; + const double sx = (m_image.isNull() ? 1 : m_image.width()) / r.width(); + const double sy = (m_image.isNull() ? 1 : m_image.height()) / r.height(); + return QPointF((wp.x() - r.left()) * sx, (wp.y() - r.top()) * sy); +} + +QRectF CardEditorWidget::overlayWidgetRect(int idx) const +{ + if (idx < 0 || idx >= m_overlays.size()) return {}; + const EmailQSLFieldOverlay &ov = m_overlays.at(idx); + const QPointF tl = imageToWidget(QPointF(ov.x, ov.y)); + const double sc = displayScale(); + + if (ov.type == QLatin1String("BOX")) + { + return QRectF(tl, QSizeF(ov.width * sc, ov.height * sc)); + } + + // TEXT / LABEL: bounding box of the displayed string + QFont f(ov.fontFamily, qMax(8, qRound(ov.fontSize * sc))); + f.setBold(ov.bold); f.setItalic(ov.italic); + const QFontMetrics fm(f); + const QString lbl = (ov.type == QLatin1String("LABEL")) + ? ov.fieldName + : (QLatin1Char('{') + ov.fieldName + QLatin1Char('}')); + return QRectF(tl.x(), tl.y() - fm.ascent(), fm.horizontalAdvance(lbl), fm.height()); +} + +QRectF CardEditorWidget::resizeHandleRect(int idx) const +{ + const QRectF r = overlayWidgetRect(idx); + if (r.isNull()) return {}; + const double h = RESIZE_HANDLE_PX; + return QRectF(r.right() - h, r.bottom() - h, h * 2, h * 2); +} + +bool CardEditorWidget::isOnResizeHandle(int idx, const QPointF &wPt) const +{ + if (idx < 0 || idx >= m_overlays.size()) return false; + if (m_overlays.at(idx).type != QLatin1String("BOX")) return false; + return resizeHandleRect(idx).contains(wPt); +} + +int CardEditorWidget::overlayAt(const QPointF &wPt) const +{ + // Iterate in reverse so top-drawn items (higher index) are hit first + for (int i = m_overlays.size() - 1; i >= 0; --i) + { + const QRectF r = overlayWidgetRect(i).adjusted(-4, -4, 4, 4); + if (r.contains(wPt)) return i; + } + return -1; +} + +// --------------------------------------------------------------------------- +// Paint +// --------------------------------------------------------------------------- + +void CardEditorWidget::paintEvent(QPaintEvent *) +{ + QPainter p(this); + p.setRenderHint(QPainter::SmoothPixmapTransform); + p.setRenderHint(QPainter::Antialiasing); + p.setRenderHint(QPainter::TextAntialiasing); + + // Background + p.fillRect(rect(), palette().window()); + + if (m_image.isNull()) + { + p.setPen(QColor(0x88, 0x88, 0x88)); + p.drawText(rect(), Qt::AlignCenter, + tr("No card image selected.\nClick \"Browse…\" to choose a QSL card image.")); + p.setPen(QPen(QColor(0x88, 0x88, 0x88), 1, Qt::DashLine)); + p.drawRect(rect().adjusted(0, 0, -1, -1)); + return; + } + + // Card image + p.drawPixmap(imageRect().toRect(), m_image); + + const double sc = displayScale(); + + for (int i = 0; i < m_overlays.size(); ++i) + { + const EmailQSLFieldOverlay &ov = m_overlays.at(i); + const bool sel = (i == m_selectedIndex); + const QRectF wr = overlayWidgetRect(i); + + if (ov.type == QLatin1String("BOX")) + { + // --- Fill --- + QColor fill(ov.fillColor); + fill.setAlphaF(ov.opacity / 100.0); + const double cr = ov.cornerRadius * sc; + + p.setPen(QPen(QColor(ov.color), sel ? 2.0 : 1.5)); + p.setBrush(fill); + p.drawRoundedRect(wr, cr, cr); + + // --- Caption above box --- + if (!ov.fieldName.isEmpty()) + { + const int dispSz = qMax(8, qRound((ov.fontSize > 0 ? ov.fontSize : 11) * sc)); + QFont f(ov.fontFamily, dispSz); + f.setBold(ov.bold); + p.setFont(f); + p.setPen(QColor(ov.color)); + p.setBrush(Qt::NoBrush); + QFontMetrics fm(f); + p.drawText(QPointF(wr.left(), wr.top() - fm.descent() - 2), ov.fieldName); + } + + // --- Selection decoration --- + if (sel) + { + p.save(); + p.setPen(QPen(QColor(0, 120, 215), 1.5, Qt::DashLine)); + p.setBrush(Qt::NoBrush); + p.drawRoundedRect(wr.adjusted(-3, -3, 3, 3), cr + 3, cr + 3); + + // Resize handle (bottom-right corner) + const QRectF rh = resizeHandleRect(i); + p.setPen(Qt::NoPen); + p.setBrush(QColor(0, 120, 215)); + p.drawEllipse(rh.center(), RESIZE_HANDLE_PX * 0.5, RESIZE_HANDLE_PX * 0.5); + + // Size info label + p.setPen(QColor(0, 80, 180)); + p.setFont(QFont(QStringLiteral("Arial"), 8)); + p.drawText(QPointF(wr.left() + 3, wr.bottom() - 3), + QString("%1×%2").arg(ov.width).arg(ov.height)); + p.restore(); + } + } + else // TEXT or LABEL + { + const int dispSz = qMax(8, qRound(ov.fontSize * sc)); + QFont f(ov.fontFamily, dispSz); + f.setBold(ov.bold); f.setItalic(ov.italic); + p.setFont(f); + + // LABEL shows literal text; TEXT shows {FIELDNAME} placeholder + const QString lbl = (ov.type == QLatin1String("LABEL")) + ? ov.fieldName + : (QLatin1Char('{') + ov.fieldName + QLatin1Char('}')); + const QFontMetrics fm(f); + const QPointF anchor(wr.left(), wr.top() + fm.ascent()); + + // Selection box + if (sel) + { + p.save(); + p.setPen(QPen(QColor(0, 120, 215), 1.5, Qt::DashLine)); + p.setBrush(QColor(0, 120, 215, 25)); + p.drawRect(wr.adjusted(-3, -3, 3, 3)); + // Drag-handle dot + p.setPen(Qt::NoPen); + p.setBrush(QColor(0, 120, 215)); + p.drawEllipse(QPointF(wr.left() - 7, wr.center().y()), 4, 4); + p.restore(); + } + else + { + p.save(); + p.setPen(QPen(QColor(0, 0, 0, 35), 1, Qt::DotLine)); + p.setBrush(Qt::NoBrush); + p.drawRect(wr.adjusted(-2, -2, 2, 2)); + p.restore(); + } + + // Shadow + { + QColor shadow = QColor(ov.color).lightness() > 128 + ? QColor(0, 0, 0, 90) : QColor(255, 255, 255, 90); + p.save(); + p.setPen(shadow); + p.drawText(anchor + QPointF(1, 1), lbl); + p.restore(); + } + + p.setPen(QColor(ov.color)); + p.drawText(anchor, lbl); + } + } + + // Border + p.setPen(QPen(QColor(0x88, 0x88, 0x88), 1)); + p.setBrush(Qt::NoBrush); + p.drawRect(rect().adjusted(0, 0, -1, -1)); +} + +// --------------------------------------------------------------------------- +// Mouse events +// --------------------------------------------------------------------------- + +void CardEditorWidget::mousePressEvent(QMouseEvent *event) +{ + if (event->button() != Qt::LeftButton) { QWidget::mousePressEvent(event); return; } + + const QPointF wPos = event->pos(); + const int idx = overlayAt(wPos); + + m_selectedIndex = idx; + m_dragIndex = -1; + m_resizing = false; + + if (idx >= 0) + { + m_dragIndex = idx; + m_dragStartImg = widgetToImage(wPos); + m_dragAnchorImg = QPointF(m_overlays.at(idx).x, m_overlays.at(idx).y); + + if (isOnResizeHandle(idx, wPos)) + { + m_resizing = true; + m_dragSizeAtPress = QSizeF(m_overlays.at(idx).width, m_overlays.at(idx).height); + setCursor(Qt::SizeFDiagCursor); + } + else + { + setCursor(Qt::ClosedHandCursor); + } + } + + emit overlaySelected(m_selectedIndex); + update(); +} + +void CardEditorWidget::mouseMoveEvent(QMouseEvent *event) +{ + const QPointF wPos = event->pos(); + + if (m_dragIndex >= 0 && (event->buttons() & Qt::LeftButton)) + { + const QPointF curImg = widgetToImage(wPos); + const QPointF delta = curImg - m_dragStartImg; + + EmailQSLFieldOverlay &ov = m_overlays[m_dragIndex]; + const int imgW = m_image.isNull() ? 99999 : m_image.width(); + const int imgH = m_image.isNull() ? 99999 : m_image.height(); + + if (m_resizing) + { + const int newW = qMax(20, qRound(m_dragSizeAtPress.width() + delta.x())); + const int newH = qMax(10, qRound(m_dragSizeAtPress.height() + delta.y())); + ov.width = qMin(newW, imgW - ov.x); + ov.height = qMin(newH, imgH - ov.y); + } + else + { + ov.x = qBound(0, qRound(m_dragAnchorImg.x() + delta.x()), imgW - 1); + ov.y = qBound(0, qRound(m_dragAnchorImg.y() + delta.y()), imgH - 1); + } + update(); + } + else + { + // Cursor feedback + const int hov = overlayAt(wPos); + if (hov >= 0) + { + if (isOnResizeHandle(hov, wPos)) + setCursor(Qt::SizeFDiagCursor); + else + setCursor(Qt::OpenHandCursor); + } + else + { + setCursor(Qt::ArrowCursor); + } + } +} + +void CardEditorWidget::mouseReleaseEvent(QMouseEvent *event) +{ + if (event->button() == Qt::LeftButton && m_dragIndex >= 0) + { + const EmailQSLFieldOverlay &ov = m_overlays.at(m_dragIndex); + if (m_resizing) + emit overlaySizeChanged(m_dragIndex, ov.width, ov.height); + else + emit overlayPositionChanged(m_dragIndex, ov.x, ov.y); + + m_dragIndex = -1; + m_resizing = false; + setCursor(Qt::ArrowCursor); + update(); + } +} diff --git a/ui/component/CardEditorWidget.h b/ui/component/CardEditorWidget.h new file mode 100644 index 00000000..c55d16d1 --- /dev/null +++ b/ui/component/CardEditorWidget.h @@ -0,0 +1,68 @@ +#ifndef QLOG_UI_COMPONENT_CARDEDITORWIDGET_H +#define QLOG_UI_COMPONENT_CARDEDITORWIDGET_H + +#include +#include + +#include "service/emailqsl/EmailQSLService.h" + +// Interactive QSL card editor widget. +// Supports two overlay types: +// TEXT — displays a {FIELD} placeholder; drag to reposition. +// BOX — filled rounded rectangle; drag to move, drag bottom-right handle to resize. +class CardEditorWidget : public QWidget +{ + Q_OBJECT + +public: + explicit CardEditorWidget(QWidget *parent = nullptr); + + void setImage(const QPixmap &pm); + const QPixmap &image() const { return m_image; } + + void setOverlays(const QList &overlays); + const QList &overlays() const { return m_overlays; } + + void updateOverlay(int index, const EmailQSLFieldOverlay &ov); + void setSelectedIndex(int index); + int selectedIndex() const { return m_selectedIndex; } + + QSize sizeHint() const override; + QSize minimumSizeHint() const override; + +signals: + void overlayPositionChanged(int index, int newX, int newY); + void overlaySizeChanged(int index, int newW, int newH); // BOX resize + void overlaySelected(int index); + +protected: + void paintEvent(QPaintEvent *) override; + void mousePressEvent(QMouseEvent *) override; + void mouseMoveEvent(QMouseEvent *) override; + void mouseReleaseEvent(QMouseEvent *) override; + +private: + static constexpr int RESIZE_HANDLE_PX = 10; // half-size of resize grip in widget px + + QRectF imageRect() const; + double displayScale() const; + QPointF imageToWidget(const QPointF &imgPt) const; + QPointF widgetToImage(const QPointF &wPt) const; + QRectF overlayWidgetRect(int index) const; // widget-coords bounding rect + QRectF resizeHandleRect(int index) const; // widget-coords resize grip + int overlayAt(const QPointF &wPt) const; // -1 = none + bool isOnResizeHandle(int index, const QPointF &wPt) const; + + QPixmap m_image; + QList m_overlays; + int m_selectedIndex = -1; + + // Drag state + int m_dragIndex = -1; + bool m_resizing = false; + QPointF m_dragStartImg; // image-coord of press point + QPointF m_dragAnchorImg; // image-coord anchor (overlay x,y at press) + QSizeF m_dragSizeAtPress; // overlay w,h at press (resize only) +}; + +#endif // QLOG_UI_COMPONENT_CARDEDITORWIDGET_H From fbd750437e5673d1e55181f46da9a20cf989bc06 Mon Sep 17 00:00:00 2001 From: Michael Morgan <84428382+aa5sh@users.noreply.github.com> Date: Thu, 9 Apr 2026 11:12:18 -0500 Subject: [PATCH 02/21] Scale overlays and downscale email attachment Keep overlay positions, font sizes and box dims proportional when the card background changes and produce smaller email attachments. EmailQSLSettingsWidget: rescale stored overlay pixel values when a new image with different size is loaded; use a 1280px reference width for default fonts/positions and scale horizontal metrics accordingly; add minimum sizes for fonts and box dims. EmailQSLService: render the card at full resolution but scale the exported JPEG to a fixed EMAIL_WIDTH (1280) for email attachments to reduce file size. Also add clarifying comments about how overlay coordinates are stored and used. --- service/emailqsl/EmailQSLService.cpp | 19 ++++- ui/EmailQSLSettingsWidget.cpp | 122 ++++++++++++++++++--------- 2 files changed, 98 insertions(+), 43 deletions(-) diff --git a/service/emailqsl/EmailQSLService.cpp b/service/emailqsl/EmailQSLService.cpp index 94aba7ae..60240fff 100644 --- a/service/emailqsl/EmailQSLService.cpp +++ b/service/emailqsl/EmailQSLService.cpp @@ -423,6 +423,11 @@ QPixmap EmailQSLBase::renderCard(const QString &imagePath, return QPixmap(); } + // Overlay x, y, fontSize and BOX width/height are all stored as absolute + // pixel values scaled to the source image dimensions. No extra scaling is + // applied here — the values are used as-is. addDefaultOverlays() and + // onCardImageChanged() are responsible for keeping them proportional whenever + // the background image changes. QPainter painter(&pixmap); painter.setRenderHint(QPainter::Antialiasing); painter.setRenderHint(QPainter::TextAntialiasing); @@ -438,7 +443,6 @@ QPixmap EmailQSLBase::renderCard(const QString &imagePath, painter.drawRoundedRect(ov.x, ov.y, ov.width, ov.height, ov.cornerRadius, ov.cornerRadius); - // Optional caption above the box if (!ov.fieldName.isEmpty()) { QFont font(ov.fontFamily, ov.fontSize > 0 ? ov.fontSize : 11); @@ -452,7 +456,6 @@ QPixmap EmailQSLBase::renderCard(const QString &imagePath, } else if (ov.type == QLatin1String("LABEL")) { - // Render fieldName as literal static text (no merge substitution) if (ov.fieldName.isEmpty()) continue; @@ -751,15 +754,23 @@ void EmailQSLService::sendEmailQSL(const QSqlRecord &record) { FCT_IDENTIFICATION; - // Render card + // Render card at full source resolution (fonts scale with image size) const QPixmap cardPixmap = EmailQSLBase::renderCard(record); QByteArray imageData; QString imageName; if (!cardPixmap.isNull()) { + // Scale down to EMAIL_WIDTH for the attachment — keeps file size reasonable + // while still looking sharp on any screen. The full-res pixmap is only + // used when the user explicitly saves via "Save Card…". + static constexpr int EMAIL_WIDTH = 1280; + const QPixmap emailPixmap = (cardPixmap.width() > EMAIL_WIDTH) + ? cardPixmap.scaledToWidth(EMAIL_WIDTH, Qt::SmoothTransformation) + : cardPixmap; + QBuffer buf(&imageData); buf.open(QIODevice::WriteOnly); - cardPixmap.save(&buf, "JPEG", 90); + emailPixmap.save(&buf, "JPEG", 90); imageName = QStringLiteral("qsl_card.jpg"); } diff --git a/ui/EmailQSLSettingsWidget.cpp b/ui/EmailQSLSettingsWidget.cpp index 14e0127e..58bb0fe4 100644 --- a/ui/EmailQSLSettingsWidget.cpp +++ b/ui/EmailQSLSettingsWidget.cpp @@ -681,6 +681,38 @@ void EmailQSLSettingsWidget::onCardImageChanged(const QString &path) FCT_IDENTIFICATION; QPixmap pm(path); + + // If there are existing overlays and a previous image, rescale all stored + // pixel values (positions, font sizes, box dimensions) proportionally so + // the layout stays in the same visual position on the new image. + const QPixmap &prev = ui->cardEditorWidget->image(); + if (!prev.isNull() && !pm.isNull() + && prev.size() != pm.size()) + { + overlayTableToList(); // make sure m_overlays is current + + if (!m_overlays.isEmpty()) + { + const double sx = static_cast(pm.width()) / prev.width(); + const double sy = static_cast(pm.height()) / prev.height(); + + for (EmailQSLFieldOverlay &ov : m_overlays) + { + ov.x = qRound(ov.x * sx); + ov.y = qRound(ov.y * sy); + // Font size tracks horizontal scale (same axis as card width) + ov.fontSize = qMax(1, qRound(ov.fontSize * sx)); + if (ov.type == QLatin1String("BOX")) + { + ov.width = qMax(20, qRound(ov.width * sx)); + ov.height = qMax(10, qRound(ov.height * sy)); + } + } + + listToOverlayTable(); + } + } + ui->cardEditorWidget->setImage(pm); } @@ -772,70 +804,82 @@ void EmailQSLSettingsWidget::addDefaultOverlays() FCT_IDENTIFICATION; const QPixmap &img = ui->cardEditorWidget->image(); - const int W = img.isNull() ? 1000 : img.width(); - const int H = img.isNull() ? 700 : img.height(); + const int W = img.isNull() ? 1280 : img.width(); + const int H = img.isNull() ? 896 : img.height(); + + // Font sizes are designed for a 1280-px-wide reference card and scaled + // linearly to the actual image width so they always look proportional. + // The same scale is applied to horizontal positions; vertical positions + // use the image height fraction directly. + static constexpr double REF_W = 1280.0; + const double fs = W / REF_W; // font scale (also used for x/horizontal) + + auto scalePt = [&](int refPt) { return qMax(1, qRound(refPt * fs)); }; // Helper lambda to make a LABEL (static text) overlay - auto makeLabel = [](const QString &text, int x, int y, int fontSize, bool bold, - const QString &color = QStringLiteral("#333333")) -> EmailQSLFieldOverlay + auto makeLabel = [&](const QString &text, int x, int y, int refFontPt, bool bold, + const QString &color = QStringLiteral("#333333")) -> EmailQSLFieldOverlay { EmailQSLFieldOverlay ov; ov.type = QStringLiteral("LABEL"); ov.fieldName = text; ov.x = x; ov.y = y; - ov.fontSize = fontSize; + ov.fontSize = scalePt(refFontPt); ov.bold = bold; ov.fontFamily = QStringLiteral("Arial"); ov.color = color; return ov; }; - // Positions expressed as fractions of card dimensions. - // Each data field is preceded by a static label one line above it. - const int labelSz = 9; - const int dataSz = 14; - const int labelOffY = qRound(H * 0.04); // label sits this many px above the data row + // Reference font sizes at 1280 px width + const int refCallsignPt = 80; // MY_CALLSIGN prominent header + const int refDxCallPt = 40; // DX station callsign + const int refDataPt = 20; // QSO data values + const int refLabelPt = 12; // small caption labels above each field + + // Positions as fractions of card dimensions — same as before + const int labelOffY = qRound(H * 0.045); const int rowName = qRound(H * 0.60); const int rowCall = qRound(H * 0.72); - const int rowData = qRound(H * 0.82); - - const int col1 = qRound(W * 0.08); - const int col2 = qRound(W * 0.35); - const int col3 = qRound(W * 0.55); - const int col4 = qRound(W * 0.68); - const int col5 = qRound(W * 0.80); - const int col6 = qRound(W * 0.90); + const int rowData = qRound(H * 0.84); + + const int col1 = qRound(W * 0.08); + const int col2 = qRound(W * 0.35); + const int col3 = qRound(W * 0.55); + const int col4 = qRound(W * 0.68); + const int col5 = qRound(W * 0.80); + const int col6 = qRound(W * 0.90); const int colGrid = qRound(W * 0.40); const QList defaults = { // My callsign centred near top — no label needed - makeOverlay("MY_CALLSIGN", qRound(W * 0.50), qRound(H * 0.18), 28, true), + makeOverlay("MY_CALLSIGN", qRound(W * 0.50), qRound(H * 0.18), scalePt(refCallsignPt), true), // Name row - makeLabel(tr("Name:"), col1, rowName - labelOffY, labelSz, false), - makeOverlay("NAME", col1, rowName, dataSz, false), + makeLabel(tr("Name:"), col1, rowName - labelOffY, refLabelPt, false), + makeOverlay("NAME", col1, rowName, scalePt(refDataPt), false), // Callsign + grid row - makeLabel(tr("Callsign:"), col1, rowCall - labelOffY, labelSz, false), - makeOverlay("CALLSIGN", col1, rowCall, dataSz, true), - makeLabel(tr("Grid:"), colGrid, rowCall - labelOffY, labelSz, false), - makeOverlay("GRIDSQUARE", colGrid, rowCall, dataSz, false), - - // QSO detail row: Date / Time / Band / Mode / RST S / RST R - makeLabel(tr("Date:"), col1, rowData - labelOffY, labelSz, false), - makeOverlay("QSO_DATE", col1, rowData, dataSz, false), - makeLabel(tr("Time (UTC):"), col2, rowData - labelOffY, labelSz, false), - makeOverlay("TIME_ON", col2, rowData, dataSz, false), - makeLabel(tr("Band:"), col3, rowData - labelOffY, labelSz, false), - makeOverlay("BAND", col3, rowData, dataSz, false), - makeLabel(tr("Mode:"), col4, rowData - labelOffY, labelSz, false), - makeOverlay("MODE", col4, rowData, dataSz, false), - makeLabel(tr("RST Snt:"), col5, rowData - labelOffY, labelSz, false), - makeOverlay("RST_SENT", col5, rowData, dataSz, false), - makeLabel(tr("RST Rcvd:"), col6, rowData - labelOffY, labelSz, false), - makeOverlay("RST_RCVD", col6, rowData, dataSz, false), + makeLabel(tr("Callsign:"), col1, rowCall - labelOffY, refLabelPt, false), + makeOverlay("CALLSIGN", col1, rowCall, scalePt(refDxCallPt), true), + makeLabel(tr("Grid:"), colGrid, rowCall - labelOffY, refLabelPt, false), + makeOverlay("GRIDSQUARE", colGrid, rowCall, scalePt(refDataPt), false), + + // QSO detail row + makeLabel(tr("Date:"), col1, rowData - labelOffY, refLabelPt, false), + makeOverlay("QSO_DATE", col1, rowData, scalePt(refDataPt), false), + makeLabel(tr("Time (UTC):"), col2, rowData - labelOffY, refLabelPt, false), + makeOverlay("TIME_ON", col2, rowData, scalePt(refDataPt), false), + makeLabel(tr("Band:"), col3, rowData - labelOffY, refLabelPt, false), + makeOverlay("BAND", col3, rowData, scalePt(refDataPt), false), + makeLabel(tr("Mode:"), col4, rowData - labelOffY, refLabelPt, false), + makeOverlay("MODE", col4, rowData, scalePt(refDataPt), false), + makeLabel(tr("RST Snt:"), col5, rowData - labelOffY, refLabelPt, false), + makeOverlay("RST_SENT", col5, rowData, scalePt(refDataPt), false), + makeLabel(tr("RST Rcvd:"), col6, rowData - labelOffY, refLabelPt, false), + makeOverlay("RST_RCVD", col6, rowData, scalePt(refDataPt), false), }; overlayTableToList(); From 5db0fd50496cd44778760feefe318f1cf7194309 Mon Sep 17 00:00:00 2001 From: Michael Morgan <84428382+aa5sh@users.noreply.github.com> Date: Thu, 9 Apr 2026 11:23:59 -0500 Subject: [PATCH 03/21] Update EmailQSLSettingsWidget.cpp Fix GitHub CL Error --- ui/EmailQSLSettingsWidget.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/EmailQSLSettingsWidget.cpp b/ui/EmailQSLSettingsWidget.cpp index 58bb0fe4..35c0bd39 100644 --- a/ui/EmailQSLSettingsWidget.cpp +++ b/ui/EmailQSLSettingsWidget.cpp @@ -276,7 +276,7 @@ static QSqlRecord buildDummyRecord() QSqlRecord r; for (const QString &c : cols) { - QSqlField f(c, QMetaType::fromType()); + QSqlField f(c, QVariant::String); r.append(f); } r.setValue("callsign", QStringLiteral("W1AW")); From ec404ab83a092066b9201ecf33bce52f935193ed Mon Sep 17 00:00:00 2001 From: Michael Morgan <84428382+aa5sh@users.noreply.github.com> Date: Thu, 19 Mar 2026 19:10:20 -0500 Subject: [PATCH 04/21] Refactor macOS build workflow and add notarization Updated macOS build workflow to use macos-latest and modified installation steps for Qt libraries. Added notarization and signing steps for the app and DMG. --- .github/workflows/macOSBuild.yml | 286 +++++++++++++++++++++++++++++-- 1 file changed, 267 insertions(+), 19 deletions(-) diff --git a/.github/workflows/macOSBuild.yml b/.github/workflows/macOSBuild.yml index 9c03d618..f7b61a11 100644 --- a/.github/workflows/macOSBuild.yml +++ b/.github/workflows/macOSBuild.yml @@ -13,7 +13,7 @@ jobs: name: MacOS Build strategy: matrix: - os: [macos-12, macos-13] + os: [macos-latest] runs-on: ${{ matrix.os }} @@ -24,9 +24,8 @@ jobs: brew update brew upgrade || true brew install qt6 - brew install qt6-webengine + brew install qtvirtualkeyboard brew link qt6 --force - brew link qt6-webengine --force brew install hamlib brew link hamlib --force brew install qtkeychain @@ -38,6 +37,7 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 + submodules: recursive - name: Get version from tag run : | TAGVERSION=$(git describe --tags) @@ -49,24 +49,272 @@ jobs: cd build qmake -config release .. make -j4 - - name: Build dmg + - name: Build app run: | + set -euo pipefail cd build - macdeployqt qlog.app -executable=./qlog.app/Contents/MacOS/qlog - cp `brew --prefix`/lib/libhamlib.dylib qlog.app/Contents/Frameworks/libhamlib.dylib - cp `brew --prefix`/lib/libqt6keychain.dylib qlog.app/Contents/Frameworks/libqt6keychain.dylib - cp `brew --prefix`/lib/libdbus-1.dylib qlog.app/Contents/Frameworks/libdbus-1.dylib - cp `brew --prefix brotli`/lib/libbrotlicommon.1.dylib qlog.app/Contents/Frameworks/libbrotlicommon.1.dylib - cp `brew --prefix`/opt/icu4c/lib/libicui18n.74.dylib qlog.app/Contents/Frameworks/libicui18n.74.dylib - install_name_tool -change `brew --prefix`/lib/libhamlib.dylib @executable_path/../Frameworks/libhamlib.dylib qlog.app/Contents/MacOS/qlog - install_name_tool -change `brew --prefix`/lib/libqt6keychain.dylib @executable_path/../Frameworks/libqt6keychain.dylib qlog.app/Contents/MacOS/qlog - install_name_tool -change @loader_path/libbrotlicommon.1.dylib @executable_path/../Frameworks/libbrotlicommon.1.dylib qlog.app/Contents/MacOS/qlog - install_name_tool -change /usr/local/opt/icu4c/lib/libicui18n.74.dylib @executable_path/../Frameworks/libicui18n.74.dylib qlog.app/Contents/MacOS/qlog - otool -L qlog.app/Contents/MacOS/qlog - macdeployqt qlog.app -dmg + APP="qlog.app" + + # 1) Deploy first (creates Contents/Frameworks etc.) + macdeployqt "$APP" \ + -always-overwrite \ + -verbose=2 \ + -executable="$APP/Contents/MacOS/qlog" \ + -qmldir=.. + + # 2) Ensure Frameworks folder exists (macdeployqt should do this, but guard anyway) + mkdir -p "$APP/Contents/Frameworks" + + # 3) (Optional) copy extra non-Qt dylibs AFTER deploy + ICU_PATH="$(brew --prefix icu4c)/lib" + cp "$(brew --prefix)"/lib/libhamlib.dylib "$APP/Contents/Frameworks/" || true + cp "$(brew --prefix)"/lib/libqt6keychain.dylib "$APP/Contents/Frameworks/" || true + cp "$ICU_PATH"/libicui18n*.dylib "$APP/Contents/Frameworks/" || true + + # 4) Patch QtWebEngineProcess helper to stop using /opt/homebrew + HELPER_BIN="$APP/Contents/Frameworks/QtWebEngineCore.framework/Versions/A/Helpers/QtWebEngineProcess.app/Contents/MacOS/QtWebEngineProcess" + + echo "Helper deps BEFORE:" + otool -L "$HELPER_BIN" | sed 's/^/ /' + + # Add rpath so @rpath/Qt*.framework resolves to app Frameworks + install_name_tool -add_rpath "@executable_path/../../../../../../../../Frameworks" "$HELPER_BIN" || true + + # Rewrite any hardcoded Homebrew Qt framework references to @rpath equivalents + # (This handles QtOpenGL, QtQuick, etc.) + while read -r dep; do + # dep looks like: /opt/homebrew/opt/qtbase/lib/QtOpenGL.framework/Versions/A/QtOpenGL + fw="$(basename "$dep")" # QtOpenGL + fwdir="$(basename "$(dirname "$(dirname "$(dirname "$dep")")")")" # QtOpenGL.framework + install_name_tool -change "$dep" "@rpath/$fwdir/Versions/A/$fw" "$HELPER_BIN" || true + done < <(otool -L "$HELPER_BIN" | awk '{print $1}' | grep '^/opt/homebrew/opt/qt.*/lib/Qt.*\.framework/') + + # Remove brew rpaths if present + install_name_tool -delete_rpath "/opt/homebrew/opt/qtbase/lib" "$HELPER_BIN" || true + install_name_tool -delete_rpath "/opt/homebrew/opt/qtdeclarative/lib" "$HELPER_BIN" || true + + echo "Helper deps AFTER:" + otool -L "$HELPER_BIN" | sed 's/^/ /' + otool -l "$HELPER_BIN" | grep -A2 LC_RPATH || true + + - name: Codesign app bundle + env: + MACOS_CERTIFICATE: ${{ secrets.PROD_MACOS_CERTIFICATE }} # base64 p12 + MACOS_CERTIFICATE_PWD: ${{ secrets.PROD_MACOS_CERTIFICATE_PWD }} # p12 password + MACOS_CERTIFICATE_NAME: ${{ secrets.PROD_MACOS_CERTIFICATE_NAME }} # "Developer ID Application: ... (TEAMID)" + MACOS_CI_KEYCHAIN_PWD: ${{ secrets.PROD_MACOS_CI_KEYCHAIN_PWD }} + run: | + set -euo pipefail + + APP="$GITHUB_WORKSPACE/build/qlog.app" + ENT="$GITHUB_WORKSPACE/entitlements.xml" + KEYCHAIN="$RUNNER_TEMP/build.keychain-db" + + echo "Entitlements:" + ls -l "$ENT" + + echo "== Create+unlock CI keychain ==" + security create-keychain -p "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" + security set-keychain-settings -lut 21600 "$KEYCHAIN" + security unlock-keychain -p "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" + security list-keychains -d user -s "$KEYCHAIN" + security default-keychain -d user -s "$KEYCHAIN" + + echo "== Import signing cert (.p12) ==" + echo "$MACOS_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/cert.p12" + security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" -P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign -T /usr/bin/security + + # allow codesign to access the key without UI prompts + security set-key-partition-list -S apple-tool:,apple: -s -k "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" + + echo "== Confirm identity ==" + security find-identity -v -p codesigning "$KEYCHAIN" || true + + echo "== 1) Sign frameworks/plugins/dylibs first (NO entitlements) ==" + while IFS= read -r -d '' F; do + /usr/bin/codesign --force --timestamp --options runtime \ + -s "$MACOS_CERTIFICATE_NAME" "$F" + done < <( + find "$APP" -type f -print0 | while IFS= read -r -d '' F; do + if file "$F" | grep -q "Mach-O"; then + # skip QtWebEngineProcess here; we'll sign it with entitlements below + if [[ "$F" == *"/QtWebEngineProcess.app/Contents/MacOS/QtWebEngineProcess" ]]; then + continue + fi + printf '%s\0' "$F" + fi + done + ) + + echo "== 2) Sign ALL QtWebEngineProcess binaries WITH entitlements ==" + mapfile -t WEBENGINE_BINS < <(find "$APP" -path "*QtWebEngineProcess.app/Contents/MacOS/QtWebEngineProcess" -type f) + if [[ "${#WEBENGINE_BINS[@]}" -eq 0 ]]; then + echo "ERROR: no QtWebEngineProcess binary found" + exit 1 + fi + + for BIN in "${WEBENGINE_BINS[@]}"; do + echo "Signing WebEngine helper bin: $BIN" + /usr/bin/codesign --force --timestamp --options runtime \ + --entitlements "$ENT" \ + -s "$MACOS_CERTIFICATE_NAME" "$BIN" + done + + echo "== 3) Sign ALL QtWebEngineProcess.app bundles WITH entitlements ==" + mapfile -t WEBENGINE_APPS < <(find "$APP" -path "*QtWebEngineProcess.app" -type d) + for HAPP in "${WEBENGINE_APPS[@]}"; do + echo "Signing WebEngine helper app: $HAPP" + /usr/bin/codesign --force --timestamp --options runtime \ + --entitlements "$ENT" \ + -s "$MACOS_CERTIFICATE_NAME" "$HAPP" + done + + echo "== 4) Sign top-level app WITH entitlements ==" + /usr/bin/codesign --force --timestamp --options runtime \ + --entitlements "$ENT" \ + -s "$MACOS_CERTIFICATE_NAME" "$APP" + + echo "== 5) Verify + prove entitlements stuck ==" + /usr/bin/codesign --verify --deep --strict --verbose=4 "$APP" + + echo "Entitlements on one QtWebEngineProcess binary (should NOT be empty):" + /usr/bin/codesign -d --entitlements :- "${WEBENGINE_BINS[0]}" || true + + spctl -a -t exec -vv "$APP" + + - name: "Notarize app bundle" + env: + PROD_MACOS_NOTARIZATION_APPLE_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_APPLE_ID }} + PROD_MACOS_NOTARIZATION_TEAM_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_TEAM_ID }} + PROD_MACOS_NOTARIZATION_PWD: ${{ secrets.PROD_MACOS_NOTARIZATION_PWD }} + run: | + set -euo pipefail + + echo "Create keychain profile" + xcrun notarytool store-credentials "notarytool-profile" \ + --apple-id "$PROD_MACOS_NOTARIZATION_APPLE_ID" \ + --team-id "$PROD_MACOS_NOTARIZATION_TEAM_ID" \ + --password "$PROD_MACOS_NOTARIZATION_PWD" + + echo "Creating temp notarization archive" + ditto -c -k --keepParent "/Users/runner/work/QLog/QLog/build/qlog.app" "notarization.zip" + + echo "Notarize app (submit + wait)" + xcrun notarytool submit "notarization.zip" \ + --keychain-profile "notarytool-profile" \ + --wait \ + --output-format json > notarization_log.json + + cat notarization_log.json + + # Extract id + status (no jq needed) + NOTARY_ID=$(python3 -c 'import json; print(json.load(open("notarization_log.json"))["id"])') + NOTARY_STATUS=$(python3 -c 'import json; print(json.load(open("notarization_log.json"))["status"])') + + echo "Notary Request ID: $NOTARY_ID" + echo "Notary Status: $NOTARY_STATUS" + + echo "Fetching detailed notary log..." + xcrun notarytool log "$NOTARY_ID" --keychain-profile "notarytool-profile" || true + + if [ "$NOTARY_STATUS" != "Accepted" ]; then + echo "Notarization failed with status: $NOTARY_STATUS" + exit 1 + fi + + echo "Attach staple" + xcrun stapler staple "/Users/runner/work/QLog/QLog/build/qlog.app" + xcrun stapler validate "/Users/runner/work/QLog/QLog/build/qlog.app" + - name: make dmg + run: | + set -euo pipefail + # use TAGVERSION, but make a nicer filename (strip the "-14-g...." if you want) + VERSION="${TAGVERSION%%-*}" # => "0.47.1" from "0.47.1-14-g0022e427" + DMG_NAME="QLog.v${VERSION}.dmg" + echo "DMG_NAME=$DMG_NAME" >> "$GITHUB_ENV" + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + + mkdir -p out + cp -R "/Users/runner/work/QLog/QLog/build/qlog.app" out/ + ln -s /Applications out/Applications + + hdiutil create \ + -volname "QLog Installer" \ + -srcfolder out \ + -ov -format UDZO \ + "/Users/runner/work/QLog/QLog/build/$DMG_NAME" + + ls -lh "/Users/runner/work/QLog/QLog/build/$DMG_NAME" + + - name: Codesign dmg bundle + env: + MACOS_CERTIFICATE: ${{ secrets.PROD_MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PWD: ${{ secrets.PROD_MACOS_CERTIFICATE_PWD }} + MACOS_CI_KEYCHAIN_PWD: ${{ secrets.PROD_MACOS_CI_KEYCHAIN_PWD }} + run: | + set -euo pipefail + + DMG="$GITHUB_WORKSPACE/build/$DMG_NAME" + test -f "$DMG" + + KEYCHAIN="$RUNNER_TEMP/build.keychain-db" + + # If the prior step already created/imported, this will work as-is. + # But on Actions, each step is a new shell: re-create+import to be safe. + security create-keychain -p "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" || true + security unlock-keychain -p "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" + security list-keychains -d user -s "$KEYCHAIN" + security default-keychain -d user -s "$KEYCHAIN" + + echo "$MACOS_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/cert.p12" + security import "$RUNNER_TEMP/cert.p12" \ + -k "$KEYCHAIN" \ + -P "$MACOS_CERTIFICATE_PWD" \ + -T /usr/bin/codesign \ + -T /usr/bin/security || true + + security set-key-partition-list -S apple-tool:,apple:,codesign: \ + -s -k "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" + + SIGN_ID_SHA=$(security find-identity -v -p codesigning "$KEYCHAIN" \ + | awk '/Developer ID Application/ {print $2; exit}') + if [[ -z "${SIGN_ID_SHA:-}" ]]; then + echo "ERROR: No Developer ID Application identity found in keychain." + exit 1 + fi + + /usr/bin/codesign --force --timestamp -s "$SIGN_ID_SHA" "$DMG" + /usr/bin/codesign -v --verbose=4 "$DMG" + + - name: Notarize dmg + env: + PROD_MACOS_NOTARIZATION_APPLE_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_APPLE_ID }} + PROD_MACOS_NOTARIZATION_TEAM_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_TEAM_ID }} + PROD_MACOS_NOTARIZATION_PWD: ${{ secrets.PROD_MACOS_NOTARIZATION_PWD }} + run: | + set -euo pipefail + DMG="/Users/runner/work/QLog/QLog/build/$DMG_NAME" + test -f "$DMG" + + xcrun notarytool store-credentials "notarytool-profile" \ + --apple-id "$PROD_MACOS_NOTARIZATION_APPLE_ID" \ + --team-id "$PROD_MACOS_NOTARIZATION_TEAM_ID" \ + --password "$PROD_MACOS_NOTARIZATION_PWD" + + ditto -c -k "$DMG" "notarization.zip" + + xcrun notarytool submit "notarization.zip" \ + --keychain-profile "notarytool-profile" \ + --wait --output-format json > notarization_dmg.json + cat notarization_dmg.json + + NOTARY_ID=$(python3 -c 'import json; print(json.load(open("notarization_dmg.json"))["id"])') + xcrun notarytool log "$NOTARY_ID" --keychain-profile "notarytool-profile" || true + + xcrun stapler staple "$DMG" + xcrun stapler validate "$DMG" - name: Copy artifact uses: actions/upload-artifact@v4 with: - name: QLog-${{ env.TAGVERSION }}-${{ matrix.os }} - path: /Users/runner/work/QLog/QLog/build/qlog.dmg - + name: QLog-${{ env.TAGVERSION }}-macos + path: /Users/runner/work/QLog/QLog/build/${{ env.DMG_NAME }} From 68bc32878ff3d397c3fc1ff830f99c444e0d0a39 Mon Sep 17 00:00:00 2001 From: Michael Morgan <84428382+aa5sh@users.noreply.github.com> Date: Thu, 19 Mar 2026 19:25:14 -0500 Subject: [PATCH 05/21] Use mkdir -p to create build directory --- .github/workflows/macOSBuild.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/macOSBuild.yml b/.github/workflows/macOSBuild.yml index f7b61a11..9b8dd7b0 100644 --- a/.github/workflows/macOSBuild.yml +++ b/.github/workflows/macOSBuild.yml @@ -45,7 +45,7 @@ jobs: - name: Configure and compile run: | - mkdir build + mkdir -p build cd build qmake -config release .. make -j4 From 0b3ba52ee4cec10ea991aac5c398fba0e6335a22 Mon Sep 17 00:00:00 2001 From: Michael Morgan <84428382+aa5sh@users.noreply.github.com> Date: Fri, 1 May 2026 08:36:33 -0500 Subject: [PATCH 06/21] Refactor macOS CI workflow for better build process Updated macOS CI workflow to include additional dependencies, improved tagging support, and enhanced error handling for notarization and codesigning processes. --- .github/workflows/macOSBuild.yml | 669 +++++++++++++++++-------------- 1 file changed, 357 insertions(+), 312 deletions(-) diff --git a/.github/workflows/macOSBuild.yml b/.github/workflows/macOSBuild.yml index 9b8dd7b0..af3cdff8 100644 --- a/.github/workflows/macOSBuild.yml +++ b/.github/workflows/macOSBuild.yml @@ -1,320 +1,365 @@ name: macOS deployment -#on: [push, pull_request] - on: workflow_dispatch: push: - branches: - - master + branches: + - master + tags: + - 'v*' jobs: macos-build: - name: MacOS Build - strategy: - matrix: - os: [macos-latest] - - runs-on: ${{ matrix.os }} - - steps: - - name: Install Dependencies - run: | - unset HOMEBREW_NO_INSTALL_FROM_API - brew update - brew upgrade || true - brew install qt6 - brew install qtvirtualkeyboard - brew link qt6 --force - brew install hamlib - brew link hamlib --force - brew install qtkeychain - brew install dbus-glib - brew install brotli - brew install icu4c - brew install pkg-config - - name: Checkout Code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - submodules: recursive - - name: Get version from tag - run : | - TAGVERSION=$(git describe --tags) - echo "TAGVERSION=${TAGVERSION:1}" >> $GITHUB_ENV - - - name: Configure and compile - run: | - mkdir -p build - cd build - qmake -config release .. - make -j4 - - name: Build app - run: | - set -euo pipefail - cd build - APP="qlog.app" - - # 1) Deploy first (creates Contents/Frameworks etc.) - macdeployqt "$APP" \ - -always-overwrite \ - -verbose=2 \ - -executable="$APP/Contents/MacOS/qlog" \ - -qmldir=.. - - # 2) Ensure Frameworks folder exists (macdeployqt should do this, but guard anyway) - mkdir -p "$APP/Contents/Frameworks" - - # 3) (Optional) copy extra non-Qt dylibs AFTER deploy - ICU_PATH="$(brew --prefix icu4c)/lib" - cp "$(brew --prefix)"/lib/libhamlib.dylib "$APP/Contents/Frameworks/" || true - cp "$(brew --prefix)"/lib/libqt6keychain.dylib "$APP/Contents/Frameworks/" || true - cp "$ICU_PATH"/libicui18n*.dylib "$APP/Contents/Frameworks/" || true - - # 4) Patch QtWebEngineProcess helper to stop using /opt/homebrew - HELPER_BIN="$APP/Contents/Frameworks/QtWebEngineCore.framework/Versions/A/Helpers/QtWebEngineProcess.app/Contents/MacOS/QtWebEngineProcess" - - echo "Helper deps BEFORE:" - otool -L "$HELPER_BIN" | sed 's/^/ /' - - # Add rpath so @rpath/Qt*.framework resolves to app Frameworks - install_name_tool -add_rpath "@executable_path/../../../../../../../../Frameworks" "$HELPER_BIN" || true - - # Rewrite any hardcoded Homebrew Qt framework references to @rpath equivalents - # (This handles QtOpenGL, QtQuick, etc.) - while read -r dep; do - # dep looks like: /opt/homebrew/opt/qtbase/lib/QtOpenGL.framework/Versions/A/QtOpenGL - fw="$(basename "$dep")" # QtOpenGL - fwdir="$(basename "$(dirname "$(dirname "$(dirname "$dep")")")")" # QtOpenGL.framework - install_name_tool -change "$dep" "@rpath/$fwdir/Versions/A/$fw" "$HELPER_BIN" || true - done < <(otool -L "$HELPER_BIN" | awk '{print $1}' | grep '^/opt/homebrew/opt/qt.*/lib/Qt.*\.framework/') - - # Remove brew rpaths if present - install_name_tool -delete_rpath "/opt/homebrew/opt/qtbase/lib" "$HELPER_BIN" || true - install_name_tool -delete_rpath "/opt/homebrew/opt/qtdeclarative/lib" "$HELPER_BIN" || true - - echo "Helper deps AFTER:" - otool -L "$HELPER_BIN" | sed 's/^/ /' - otool -l "$HELPER_BIN" | grep -A2 LC_RPATH || true - - - name: Codesign app bundle - env: - MACOS_CERTIFICATE: ${{ secrets.PROD_MACOS_CERTIFICATE }} # base64 p12 - MACOS_CERTIFICATE_PWD: ${{ secrets.PROD_MACOS_CERTIFICATE_PWD }} # p12 password - MACOS_CERTIFICATE_NAME: ${{ secrets.PROD_MACOS_CERTIFICATE_NAME }} # "Developer ID Application: ... (TEAMID)" - MACOS_CI_KEYCHAIN_PWD: ${{ secrets.PROD_MACOS_CI_KEYCHAIN_PWD }} - run: | - set -euo pipefail - - APP="$GITHUB_WORKSPACE/build/qlog.app" - ENT="$GITHUB_WORKSPACE/entitlements.xml" - KEYCHAIN="$RUNNER_TEMP/build.keychain-db" - - echo "Entitlements:" - ls -l "$ENT" - - echo "== Create+unlock CI keychain ==" - security create-keychain -p "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" - security set-keychain-settings -lut 21600 "$KEYCHAIN" - security unlock-keychain -p "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" - security list-keychains -d user -s "$KEYCHAIN" - security default-keychain -d user -s "$KEYCHAIN" - - echo "== Import signing cert (.p12) ==" - echo "$MACOS_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/cert.p12" - security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" -P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign -T /usr/bin/security - - # allow codesign to access the key without UI prompts - security set-key-partition-list -S apple-tool:,apple: -s -k "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" - - echo "== Confirm identity ==" - security find-identity -v -p codesigning "$KEYCHAIN" || true - - echo "== 1) Sign frameworks/plugins/dylibs first (NO entitlements) ==" - while IFS= read -r -d '' F; do - /usr/bin/codesign --force --timestamp --options runtime \ - -s "$MACOS_CERTIFICATE_NAME" "$F" - done < <( - find "$APP" -type f -print0 | while IFS= read -r -d '' F; do - if file "$F" | grep -q "Mach-O"; then - # skip QtWebEngineProcess here; we'll sign it with entitlements below - if [[ "$F" == *"/QtWebEngineProcess.app/Contents/MacOS/QtWebEngineProcess" ]]; then - continue - fi - printf '%s\0' "$F" - fi - done - ) - - echo "== 2) Sign ALL QtWebEngineProcess binaries WITH entitlements ==" - mapfile -t WEBENGINE_BINS < <(find "$APP" -path "*QtWebEngineProcess.app/Contents/MacOS/QtWebEngineProcess" -type f) - if [[ "${#WEBENGINE_BINS[@]}" -eq 0 ]]; then - echo "ERROR: no QtWebEngineProcess binary found" - exit 1 - fi - - for BIN in "${WEBENGINE_BINS[@]}"; do - echo "Signing WebEngine helper bin: $BIN" - /usr/bin/codesign --force --timestamp --options runtime \ - --entitlements "$ENT" \ - -s "$MACOS_CERTIFICATE_NAME" "$BIN" - done - - echo "== 3) Sign ALL QtWebEngineProcess.app bundles WITH entitlements ==" - mapfile -t WEBENGINE_APPS < <(find "$APP" -path "*QtWebEngineProcess.app" -type d) - for HAPP in "${WEBENGINE_APPS[@]}"; do - echo "Signing WebEngine helper app: $HAPP" - /usr/bin/codesign --force --timestamp --options runtime \ - --entitlements "$ENT" \ - -s "$MACOS_CERTIFICATE_NAME" "$HAPP" - done - - echo "== 4) Sign top-level app WITH entitlements ==" - /usr/bin/codesign --force --timestamp --options runtime \ - --entitlements "$ENT" \ - -s "$MACOS_CERTIFICATE_NAME" "$APP" - - echo "== 5) Verify + prove entitlements stuck ==" - /usr/bin/codesign --verify --deep --strict --verbose=4 "$APP" - - echo "Entitlements on one QtWebEngineProcess binary (should NOT be empty):" - /usr/bin/codesign -d --entitlements :- "${WEBENGINE_BINS[0]}" || true - - spctl -a -t exec -vv "$APP" - - - name: "Notarize app bundle" - env: - PROD_MACOS_NOTARIZATION_APPLE_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_APPLE_ID }} - PROD_MACOS_NOTARIZATION_TEAM_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_TEAM_ID }} - PROD_MACOS_NOTARIZATION_PWD: ${{ secrets.PROD_MACOS_NOTARIZATION_PWD }} - run: | - set -euo pipefail - - echo "Create keychain profile" - xcrun notarytool store-credentials "notarytool-profile" \ - --apple-id "$PROD_MACOS_NOTARIZATION_APPLE_ID" \ - --team-id "$PROD_MACOS_NOTARIZATION_TEAM_ID" \ - --password "$PROD_MACOS_NOTARIZATION_PWD" - - echo "Creating temp notarization archive" - ditto -c -k --keepParent "/Users/runner/work/QLog/QLog/build/qlog.app" "notarization.zip" - - echo "Notarize app (submit + wait)" - xcrun notarytool submit "notarization.zip" \ - --keychain-profile "notarytool-profile" \ - --wait \ - --output-format json > notarization_log.json - - cat notarization_log.json - - # Extract id + status (no jq needed) - NOTARY_ID=$(python3 -c 'import json; print(json.load(open("notarization_log.json"))["id"])') - NOTARY_STATUS=$(python3 -c 'import json; print(json.load(open("notarization_log.json"))["status"])') - - echo "Notary Request ID: $NOTARY_ID" - echo "Notary Status: $NOTARY_STATUS" - - echo "Fetching detailed notary log..." - xcrun notarytool log "$NOTARY_ID" --keychain-profile "notarytool-profile" || true - - if [ "$NOTARY_STATUS" != "Accepted" ]; then - echo "Notarization failed with status: $NOTARY_STATUS" - exit 1 - fi - - echo "Attach staple" - xcrun stapler staple "/Users/runner/work/QLog/QLog/build/qlog.app" - xcrun stapler validate "/Users/runner/work/QLog/QLog/build/qlog.app" - - name: make dmg - run: | - set -euo pipefail - # use TAGVERSION, but make a nicer filename (strip the "-14-g...." if you want) - VERSION="${TAGVERSION%%-*}" # => "0.47.1" from "0.47.1-14-g0022e427" - DMG_NAME="QLog.v${VERSION}.dmg" - echo "DMG_NAME=$DMG_NAME" >> "$GITHUB_ENV" - echo "VERSION=$VERSION" >> "$GITHUB_ENV" - - mkdir -p out - cp -R "/Users/runner/work/QLog/QLog/build/qlog.app" out/ - ln -s /Applications out/Applications - - hdiutil create \ - -volname "QLog Installer" \ - -srcfolder out \ - -ov -format UDZO \ - "/Users/runner/work/QLog/QLog/build/$DMG_NAME" - - ls -lh "/Users/runner/work/QLog/QLog/build/$DMG_NAME" - - - name: Codesign dmg bundle - env: - MACOS_CERTIFICATE: ${{ secrets.PROD_MACOS_CERTIFICATE }} - MACOS_CERTIFICATE_PWD: ${{ secrets.PROD_MACOS_CERTIFICATE_PWD }} - MACOS_CI_KEYCHAIN_PWD: ${{ secrets.PROD_MACOS_CI_KEYCHAIN_PWD }} - run: | - set -euo pipefail - - DMG="$GITHUB_WORKSPACE/build/$DMG_NAME" - test -f "$DMG" - - KEYCHAIN="$RUNNER_TEMP/build.keychain-db" - - # If the prior step already created/imported, this will work as-is. - # But on Actions, each step is a new shell: re-create+import to be safe. - security create-keychain -p "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" || true - security unlock-keychain -p "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" - security list-keychains -d user -s "$KEYCHAIN" - security default-keychain -d user -s "$KEYCHAIN" - - echo "$MACOS_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/cert.p12" - security import "$RUNNER_TEMP/cert.p12" \ - -k "$KEYCHAIN" \ - -P "$MACOS_CERTIFICATE_PWD" \ - -T /usr/bin/codesign \ - -T /usr/bin/security || true - - security set-key-partition-list -S apple-tool:,apple:,codesign: \ - -s -k "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" - - SIGN_ID_SHA=$(security find-identity -v -p codesigning "$KEYCHAIN" \ - | awk '/Developer ID Application/ {print $2; exit}') - if [[ -z "${SIGN_ID_SHA:-}" ]]; then - echo "ERROR: No Developer ID Application identity found in keychain." - exit 1 - fi - - /usr/bin/codesign --force --timestamp -s "$SIGN_ID_SHA" "$DMG" - /usr/bin/codesign -v --verbose=4 "$DMG" - - - name: Notarize dmg - env: - PROD_MACOS_NOTARIZATION_APPLE_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_APPLE_ID }} - PROD_MACOS_NOTARIZATION_TEAM_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_TEAM_ID }} - PROD_MACOS_NOTARIZATION_PWD: ${{ secrets.PROD_MACOS_NOTARIZATION_PWD }} - run: | - set -euo pipefail - DMG="/Users/runner/work/QLog/QLog/build/$DMG_NAME" - test -f "$DMG" - - xcrun notarytool store-credentials "notarytool-profile" \ - --apple-id "$PROD_MACOS_NOTARIZATION_APPLE_ID" \ - --team-id "$PROD_MACOS_NOTARIZATION_TEAM_ID" \ - --password "$PROD_MACOS_NOTARIZATION_PWD" - - ditto -c -k "$DMG" "notarization.zip" - - xcrun notarytool submit "notarization.zip" \ - --keychain-profile "notarytool-profile" \ - --wait --output-format json > notarization_dmg.json - cat notarization_dmg.json - - NOTARY_ID=$(python3 -c 'import json; print(json.load(open("notarization_dmg.json"))["id"])') - xcrun notarytool log "$NOTARY_ID" --keychain-profile "notarytool-profile" || true - - xcrun stapler staple "$DMG" - xcrun stapler validate "$DMG" - - name: Copy artifact - uses: actions/upload-artifact@v4 - with: - name: QLog-${{ env.TAGVERSION }}-macos - path: /Users/runner/work/QLog/QLog/build/${{ env.DMG_NAME }} + name: macOS Build + runs-on: macos-latest + + env: + APP_NAME: QLog + BUNDLE_BIN: qlog + QT_PREFIX: /opt/homebrew + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: recursive + + - name: Install Dependencies + run: | + set -euo pipefail + unset HOMEBREW_NO_INSTALL_FROM_API + brew update + brew upgrade || true + brew install \ + qt6 \ + hamlib \ + qtkeychain \ + dbus-glib \ + brotli \ + icu4c \ + pkg-config \ + autoconf \ + automake \ + libtool \ + libusb + # qt6 is keg-only; many Qt-aware tools rely on the linkage being visible + brew link qt6 --force || true + brew link hamlib --force || true + + - name: Get version from tag + run: | + set -euo pipefail + # If we're on a tag like v0.50.0, use that; otherwise fall back to describe. + if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then + TAGVERSION="${GITHUB_REF#refs/tags/v}" + else + RAW="$(git describe --tags --always 2>/dev/null || echo dev)" + TAGVERSION="${RAW#v}" + fi + echo "TAGVERSION=${TAGVERSION}" >> "$GITHUB_ENV" + echo "Version: ${TAGVERSION}" + + - name: Ensure QtSvg module is enabled + run: | + set -euo pipefail + PRO="qlog.pro" + if ! grep -q ' svg' "$PRO"; then + echo "Adding svg to QT modules" + awk ' + BEGIN { added=0 } + /^[[:space:]]*QT[[:space:]]/ && !added { + print $0 " svg"; added=1; next + } + { print } + ' "$PRO" > "$PRO.tmp" && mv "$PRO.tmp" "$PRO" + else + echo "QtSvg already present" + fi + + - name: Configure and compile + run: | + set -euo pipefail + mkdir -p build + cd build + qmake CONFIG+=release .. + make -j"$(sysctl -n hw.ncpu)" + + - name: Run macdeployqt + run: | + set -euo pipefail + cd build + APP="$APP_NAME.app" + # macdeployqt builds Contents/Frameworks and rewrites Qt links. + macdeployqt "$APP" \ + -always-overwrite \ + -verbose=2 \ + -executable="$APP/Contents/MacOS/$BUNDLE_BIN" \ + -libpath="$QT_PREFIX/opt/qtbase/lib" \ + -libpath="$QT_PREFIX/opt/qtdeclarative/lib" \ + -libpath="$QT_PREFIX/opt/qtwebengine/lib" \ + -libpath="$QT_PREFIX/opt/qtsvg/lib" + + - name: Bundle rigctld and fix linkage + run: | + set -euo pipefail + cd build + APP="$APP_NAME.app" + MACOS_DIR="$APP/Contents/MacOS" + FRAMEWORKS_DIR="$APP/Contents/Frameworks" + mkdir -p "$MACOS_DIR" "$FRAMEWORKS_DIR" + + RIGCTLD_SRC="$(command -v rigctld)" + [ -n "$RIGCTLD_SRC" ] || { echo "ERROR: rigctld not found"; exit 1; } + echo "Using rigctld from: $RIGCTLD_SRC" + cp -f "$RIGCTLD_SRC" "$MACOS_DIR/rigctld" + chmod 0755 "$MACOS_DIR/rigctld" + + install_name_tool -add_rpath "@executable_path/../Frameworks" \ + "$MACOS_DIR/rigctld" 2>/dev/null || true + + # Recursively bundle any /opt/homebrew or /usr/local dependency, fix + # install IDs to @rpath, and rewrite the parent's link. + bundle_deps() { + local target="$1" + local dep base + otool -L "$target" | awk 'NR>1 {print $1}' | while read -r dep; do + case "$dep" in + /opt/homebrew/*|/usr/local/*) + base="$(basename "$dep")" + if [ ! -f "$FRAMEWORKS_DIR/$base" ]; then + echo " -> bundling $base (from $dep)" + cp -f "$dep" "$FRAMEWORKS_DIR/$base" + chmod u+w "$FRAMEWORKS_DIR/$base" + install_name_tool -id "@rpath/$base" "$FRAMEWORKS_DIR/$base" + bundle_deps "$FRAMEWORKS_DIR/$base" + fi + install_name_tool -change "$dep" "@rpath/$base" "$target" + ;; + esac + done + } + + bundle_deps "$MACOS_DIR/rigctld" + bundle_deps "$MACOS_DIR/$BUNDLE_BIN" + + echo "rigctld linkage:" + otool -L "$MACOS_DIR/rigctld" + echo "$BUNDLE_BIN linkage:" + otool -L "$MACOS_DIR/$BUNDLE_BIN" + + - name: Fix QtWebEngineProcess rpaths + run: | + set -euo pipefail + cd build + APP="$APP_NAME.app" + QWEP="$APP/Contents/Frameworks/QtWebEngineCore.framework/Versions/A/Helpers/QtWebEngineProcess.app/Contents/MacOS/QtWebEngineProcess" + [ -f "$QWEP" ] || { echo "QtWebEngineProcess not found at $QWEP"; exit 1; } + + install_name_tool -add_rpath \ + "@executable_path/../../../../Frameworks" "$QWEP" 2>/dev/null || true + + # Rewrite any remaining hardcoded Qt framework references to @rpath. + otool -L "$QWEP" | awk 'NR>1 {print $1}' \ + | grep '^/opt/homebrew/.*\.framework/' \ + | while read -r dep; do + fw="$(basename "$dep")" + fwdir="$(basename "$(dirname "$(dirname "$(dirname "$dep")")")")" + install_name_tool -change "$dep" \ + "@rpath/$fwdir/Versions/A/$fw" "$QWEP" || true + done + + echo "QtWebEngineProcess after fixup:" + otool -L "$QWEP" + + - name: Scan for hardcoded Homebrew paths + run: | + set -euo pipefail + cd build + APP="$APP_NAME.app" + BAD=0 + while IFS= read -r -d '' BIN; do + if otool -L "$BIN" 2>/dev/null \ + | awk 'NR>1 {print $1}' \ + | grep -E '^(/opt/homebrew/|/usr/local/Cellar/|/usr/local/opt/)' >/dev/null; then + echo "Hardcoded Homebrew path in: $BIN" + otool -L "$BIN" | awk 'NR>1 {print " " $1}' \ + | grep -E '^[[:space:]]+(/opt/homebrew/|/usr/local/Cellar/|/usr/local/opt/)' + BAD=1 + fi + done < <(find "$APP" -type f -perm +111 -print0) + if [ "$BAD" -eq 1 ]; then + echo "ERROR: hardcoded Homebrew paths remain. Bundle would fail on user machines." + exit 1 + fi + + - name: Codesign app bundle + env: + MACOS_CERTIFICATE: ${{ secrets.PROD_MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PWD: ${{ secrets.PROD_MACOS_CERTIFICATE_PWD }} + MACOS_CERTIFICATE_NAME: ${{ secrets.PROD_MACOS_CERTIFICATE_NAME }} + MACOS_CI_KEYCHAIN_PWD: ${{ secrets.PROD_MACOS_CI_KEYCHAIN_PWD }} + run: | + set -euo pipefail + APP="$GITHUB_WORKSPACE/build/$APP_NAME.app" + ENT="$GITHUB_WORKSPACE/entitlements.xml" + KEYCHAIN="$RUNNER_TEMP/build.keychain-db" + + test -f "$ENT" || { echo "Missing entitlements.xml at $ENT"; exit 1; } + + security create-keychain -p "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" + security set-keychain-settings -lut 21600 "$KEYCHAIN" + security unlock-keychain -p "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" + security list-keychains -d user -s "$KEYCHAIN" + security default-keychain -d user -s "$KEYCHAIN" + + echo "$MACOS_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/cert.p12" + security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" \ + -P "$MACOS_CERTIFICATE_PWD" \ + -T /usr/bin/codesign -T /usr/bin/security + security set-key-partition-list -S apple-tool:,apple: \ + -s -k "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" + + security find-identity -v -p codesigning "$KEYCHAIN" || true + + # 1) Sign every Mach-O inside the bundle (no entitlements), skipping + # QtWebEngineProcess which we sign with entitlements below. + while IFS= read -r -d '' F; do + if file "$F" | grep -q "Mach-O"; then + if [[ "$F" == *"/QtWebEngineProcess.app/Contents/MacOS/QtWebEngineProcess" ]]; then + continue + fi + /usr/bin/codesign --force --timestamp --options runtime \ + -s "$MACOS_CERTIFICATE_NAME" "$F" + fi + done < <(find "$APP" -type f -print0) + + # 2) Sign QtWebEngineProcess binaries WITH entitlements + while IFS= read -r -d '' BIN; do + echo "Signing WebEngine helper: $BIN" + /usr/bin/codesign --force --timestamp --options runtime \ + --entitlements "$ENT" \ + -s "$MACOS_CERTIFICATE_NAME" "$BIN" + done < <(find "$APP" -path "*QtWebEngineProcess.app/Contents/MacOS/QtWebEngineProcess" -type f -print0) + + # 3) Sign QtWebEngineProcess.app bundles WITH entitlements + while IFS= read -r -d '' HAPP; do + echo "Signing WebEngine helper app: $HAPP" + /usr/bin/codesign --force --timestamp --options runtime \ + --entitlements "$ENT" \ + -s "$MACOS_CERTIFICATE_NAME" "$HAPP" + done < <(find "$APP" -path "*QtWebEngineProcess.app" -type d -print0) + + # 4) Sign the outer app bundle WITH entitlements + /usr/bin/codesign --force --timestamp --options runtime \ + --entitlements "$ENT" \ + -s "$MACOS_CERTIFICATE_NAME" "$APP" + + /usr/bin/codesign --verify --deep --strict --verbose=2 "$APP" + spctl -a -t exec -vv "$APP" || true + + - name: Notarize app bundle + env: + PROD_MACOS_NOTARIZATION_APPLE_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_APPLE_ID }} + PROD_MACOS_NOTARIZATION_TEAM_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_TEAM_ID }} + PROD_MACOS_NOTARIZATION_PWD: ${{ secrets.PROD_MACOS_NOTARIZATION_PWD }} + run: | + set -euo pipefail + APP="$GITHUB_WORKSPACE/build/$APP_NAME.app" + + xcrun notarytool store-credentials "notarytool-profile" \ + --apple-id "$PROD_MACOS_NOTARIZATION_APPLE_ID" \ + --team-id "$PROD_MACOS_NOTARIZATION_TEAM_ID" \ + --password "$PROD_MACOS_NOTARIZATION_PWD" + + ditto -c -k --sequesterRsrc --keepParent "$APP" "notarization.zip" + + xcrun notarytool submit "notarization.zip" \ + --keychain-profile "notarytool-profile" \ + --wait --output-format json > notarization_log.json + cat notarization_log.json + + NOTARY_ID=$(python3 -c 'import json; print(json.load(open("notarization_log.json"))["id"])') + NOTARY_STATUS=$(python3 -c 'import json; print(json.load(open("notarization_log.json"))["status"])') + + xcrun notarytool log "$NOTARY_ID" --keychain-profile "notarytool-profile" || true + + if [ "$NOTARY_STATUS" != "Accepted" ]; then + echo "Notarization failed: $NOTARY_STATUS" + exit 1 + fi + + xcrun stapler staple "$APP" + xcrun stapler validate "$APP" + + - name: Build DMG + run: | + set -euo pipefail + VERSION="${TAGVERSION%%-*}" + DMG_NAME="$APP_NAME.v${VERSION}.dmg" + echo "DMG_NAME=$DMG_NAME" >> "$GITHUB_ENV" + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + + STAGING="$GITHUB_WORKSPACE/build/dmg-staging" + rm -rf "$STAGING" + mkdir -p "$STAGING" + cp -R "$GITHUB_WORKSPACE/build/$APP_NAME.app" "$STAGING/" + ln -s /Applications "$STAGING/Applications" + + hdiutil create \ + -volname "$APP_NAME Installer" \ + -srcfolder "$STAGING" \ + -ov -format UDZO \ + "$GITHUB_WORKSPACE/build/$DMG_NAME" + ls -lh "$GITHUB_WORKSPACE/build/$DMG_NAME" + + - name: Codesign DMG + env: + MACOS_CERTIFICATE_NAME: ${{ secrets.PROD_MACOS_CERTIFICATE_NAME }} + MACOS_CI_KEYCHAIN_PWD: ${{ secrets.PROD_MACOS_CI_KEYCHAIN_PWD }} + run: | + set -euo pipefail + DMG="$GITHUB_WORKSPACE/build/$DMG_NAME" + KEYCHAIN="$RUNNER_TEMP/build.keychain-db" + security unlock-keychain -p "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" + /usr/bin/codesign --force --timestamp \ + -s "$MACOS_CERTIFICATE_NAME" "$DMG" + /usr/bin/codesign --verify --verbose=2 "$DMG" + + - name: Notarize DMG + env: + PROD_MACOS_NOTARIZATION_APPLE_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_APPLE_ID }} + PROD_MACOS_NOTARIZATION_TEAM_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_TEAM_ID }} + PROD_MACOS_NOTARIZATION_PWD: ${{ secrets.PROD_MACOS_NOTARIZATION_PWD }} + run: | + set -euo pipefail + DMG="$GITHUB_WORKSPACE/build/$DMG_NAME" + + xcrun notarytool store-credentials "notarytool-profile" \ + --apple-id "$PROD_MACOS_NOTARIZATION_APPLE_ID" \ + --team-id "$PROD_MACOS_NOTARIZATION_TEAM_ID" \ + --password "$PROD_MACOS_NOTARIZATION_PWD" + + xcrun notarytool submit "$DMG" \ + --keychain-profile "notarytool-profile" \ + --wait --output-format json > notarization_dmg.json + cat notarization_dmg.json + + NOTARY_ID=$(python3 -c 'import json; print(json.load(open("notarization_dmg.json"))["id"])') + NOTARY_STATUS=$(python3 -c 'import json; print(json.load(open("notarization_dmg.json"))["status"])') + xcrun notarytool log "$NOTARY_ID" --keychain-profile "notarytool-profile" || true + + if [ "$NOTARY_STATUS" != "Accepted" ]; then + echo "DMG notarization failed: $NOTARY_STATUS" + exit 1 + fi + + xcrun stapler staple "$DMG" + xcrun stapler validate "$DMG" + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: QLog-${{ env.TAGVERSION }}-macos + path: ${{ github.workspace }}/build/${{ env.DMG_NAME }} + if-no-files-found: error From 089db1d723f385884d7964fce5186dec079afdaa Mon Sep 17 00:00:00 2001 From: Michael Morgan <84428382+aa5sh@users.noreply.github.com> Date: Fri, 1 May 2026 08:53:04 -0500 Subject: [PATCH 07/21] Update macOSBuild.yml --- .github/workflows/macOSBuild.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/macOSBuild.yml b/.github/workflows/macOSBuild.yml index af3cdff8..da9b9138 100644 --- a/.github/workflows/macOSBuild.yml +++ b/.github/workflows/macOSBuild.yml @@ -250,7 +250,16 @@ jobs: -s "$MACOS_CERTIFICATE_NAME" "$HAPP" done < <(find "$APP" -path "*QtWebEngineProcess.app" -type d -print0) - # 4) Sign the outer app bundle WITH entitlements + # 4) Re-seal every framework so the framework's seal includes any + # nested helper apps we just re-signed. Without this, signing the + # outer app fails with "nested code is modified or invalid". + while IFS= read -r -d '' FW; do + echo "Re-signing framework: $FW" + /usr/bin/codesign --force --timestamp --options runtime \ + -s "$MACOS_CERTIFICATE_NAME" "$FW" + done < <(find "$APP/Contents/Frameworks" -maxdepth 1 -type d -name "*.framework" -print0) + + # 5) Sign the outer app bundle WITH entitlements /usr/bin/codesign --force --timestamp --options runtime \ --entitlements "$ENT" \ -s "$MACOS_CERTIFICATE_NAME" "$APP" From 057ada9de7529fa28af476007d7783881ad1b4d7 Mon Sep 17 00:00:00 2001 From: Michael Morgan <84428382+aa5sh@users.noreply.github.com> Date: Fri, 1 May 2026 14:41:38 -0500 Subject: [PATCH 08/21] Add GitHub Actions workflow for Windows build This workflow builds QLog for Windows using GitHub Actions, creating both a portable ZIP and a Qt IFW installer. It includes steps for setting up the environment, installing dependencies, building the application, and packaging the outputs. --- .github/workflows/windows.yml | 343 ++++++++++++++++++++++++++++++++++ 1 file changed, 343 insertions(+) create mode 100644 .github/workflows/windows.yml diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml new file mode 100644 index 00000000..f13cc4e7 --- /dev/null +++ b/.github/workflows/windows.yml @@ -0,0 +1,343 @@ +# ============================================================ +# QLog Windows Build — GitHub Actions +# +# Orignal from HB9VQQ +# +# Builds QLog for Windows (MSVC 2022 x64) and creates: +# - A portable ZIP (QLog-Portable-Windows) +# - A Qt IFW installer (QLog-Installer-Windows) +# +# Triggers: +# - push to master → build + artifacts (for testing) +# - push a tag v* → build + artifacts + GitHub Release +# - manual via Actions tab +# ============================================================ +name: Windows Build + +on: + push: + branches: [master] + tags: ['v*'] + paths-ignore: + - '*.md' + - 'doc/**' + - 'LICENSE' + - '.gitignore' + workflow_dispatch: # manual trigger button in Actions tab + +env: + QT_VERSION: '6.10.2' + HAMLIB_VERSION: '4.7.1' + +jobs: + build: + runs-on: windows-2022 + timeout-minutes: 60 + + steps: + # —— 1. Checkout source ——————————————————————————————————— + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # —— 2. MSVC 2022 x64 environment ———————————————————————— + - name: Setup MSVC + uses: TheMrMilchmann/setup-msvc-dev@v4 + with: + arch: x64 + + # —— 3. Install Qt 6 with required modules ——————————————— + - name: Install Qt + uses: jurplel/install-qt-action@v4 + with: + version: ${{ env.QT_VERSION }} + host: windows + target: desktop + arch: win64_msvc2022_64 + modules: >- + qtwebengine qtcharts qtserialport + qtwebsockets qtwebchannel qtpositioning + tools: tools_ifw + source: true + src-archives: qtbase + cache: true + + # —— 4. Download Hamlib w64 binary ———————————————————————— + - name: Download Hamlib + shell: pwsh + run: | + $zip = "hamlib-w64-${{ env.HAMLIB_VERSION }}.zip" + $url = "https://github.com/Hamlib/Hamlib/releases/download/${{ env.HAMLIB_VERSION }}/$zip" + Invoke-WebRequest $url -OutFile $zip + Expand-Archive $zip -DestinationPath C:\ + Rename-Item "C:\hamlib-w64-${{ env.HAMLIB_VERSION }}" C:\hamlib + + $msvc = "C:\hamlib\lib\msvc" + if (!(Test-Path $msvc)) { New-Item -ItemType Directory $msvc | Out-Null } + if (!(Test-Path "$msvc\libhamlib-4.lib")) { + $def = Get-ChildItem C:\hamlib -Recurse -Filter "libhamlib-4.def" | Select -First 1 + if ($def) { + $dest = "$msvc\libhamlib-4.def" + if ($def.FullName -ne $dest) { Copy-Item $def.FullName $dest } + Push-Location $msvc + lib /machine:X64 /def:libhamlib-4.def /out:libhamlib-4.lib + Pop-Location + } + } + + # —— 5. pthreads + zlib via fresh vcpkg clone ————————————— + - name: Install vcpkg dependencies + shell: cmd + run: | + git clone --depth 1 https://github.com/microsoft/vcpkg.git C:\vcpkg + cd /d C:\vcpkg + call bootstrap-vcpkg.bat + vcpkg install pthreads:x64-windows zlib:x64-windows openssl:x64-windows + + # —— 6. Build QtKeychain from source —————————————————————— + - name: Build QtKeychain + shell: cmd + run: | + git clone --depth 1 https://github.com/frankosterfeld/qtkeychain.git C:\qtkeychain-src + cd /d C:\qtkeychain-src + cmake -B build -G "NMake Makefiles" ^ + -DCMAKE_BUILD_TYPE=Release ^ + -DBUILD_WITH_QT6=ON ^ + -DCMAKE_PREFIX_PATH="%QT_ROOT_DIR%" ^ + -DCMAKE_INSTALL_PREFIX=C:\qtkeychain + cmake --build build --config Release + cmake --install build + + # —— 7. Locate OpenSSL ———————————————————————————————————— + - name: Locate OpenSSL + shell: pwsh + run: | + $candidates = @( + "C:\Program Files\OpenSSL-Win64", + "C:\Program Files\OpenSSL", + "C:\OpenSSL-Win64" + ) + $ssl = $candidates | Where-Object { Test-Path $_ } | Select -First 1 + if (!$ssl) { + choco install openssl -y --no-progress + $ssl = $candidates | Where-Object { Test-Path $_ } | Select -First 1 + } + if ($ssl) { + echo "OPENSSL_DIR=$ssl" >> $env:GITHUB_ENV + } else { + echo "OPENSSL_DIR=" >> $env:GITHUB_ENV + } + + # —— 7c. Install OmniRig v1 + v2 ————————————————————————— + - name: Install OmniRig + shell: pwsh + run: | + # --- OmniRig v1: InnoSetup installer (works silently) --- + Write-Host "=== OmniRig v1 ===" + Invoke-WebRequest "http://www.dxatlas.com/OmniRig/Files/OmniRig.zip" -OutFile OmniRig.zip + Expand-Archive OmniRig.zip -DestinationPath C:\omnirig-v1-tmp + $setup = Get-ChildItem C:\omnirig-v1-tmp -Recurse -Filter "OmniRigSetup.exe" | Select -First 1 + if ($setup) { + Start-Process -FilePath $setup.FullName -ArgumentList "/VERYSILENT","/SUPPRESSMSGBOXES","/NORESTART" -Wait + Start-Sleep -Seconds 2 + } + $v1path = "C:\Program Files (x86)\Afreet\OmniRig\OmniRig.exe" + if (Test-Path $v1path) { + Write-Host "v1 OK: $v1path" + } else { + Write-Error "v1 FAILED" + } + + # --- OmniRig v2: try installer with timeout, fall back to v1 .tlb --- + Write-Host "=== OmniRig v2 ===" + $v2installed = $false + Invoke-WebRequest "https://www.hb9ryz.ch/downloads/install_omnirigv21.zip" -OutFile omnirigv2.zip + Expand-Archive omnirigv2.zip -DestinationPath C:\omnirig-v2-tmp + + $installer = Get-ChildItem C:\omnirig-v2-tmp -Recurse -Filter "*.exe" | Select -First 1 + if ($installer) { + Write-Host "Trying /S (NSIS) with 30s timeout..." + $proc = Start-Process -FilePath $installer.FullName -ArgumentList "/S" -PassThru + $finished = $proc.WaitForExit(30000) + if (!$finished) { + Write-Host "Installer timed out — killing" + $proc.Kill() + } + $v2path = "C:\Program Files (x86)\Omni-Rig V2\omnirig2.exe" + if (Test-Path $v2path) { + Write-Host "v2 installed via /S" + $v2installed = $true + } + } + + if (!$v2installed) { + # Fallback: use v1 .tlb and patch source to remove v2-only features + Write-Host "v2 installer failed — using v1 .tlb fallback with Rig3/Rig4 patch" + Invoke-WebRequest "https://raw.githubusercontent.com/VE3NEA/OmniRig/master/OmniRig.tlb" -OutFile "C:\omnirig-v1.tlb" + + $v2file = "rig\drivers\Omnirigv2RigDrv.cpp" + $content = Get-Content $v2file -Raw + + # Patch #import to use v1 .tlb + $content = $content.Replace( + 'C:\\Program Files (x86)\\Omni-Rig V2\\omnirig2.exe', + 'C:\\omnirig-v1.tlb') + + # Remove get_Rig3/get_Rig4 calls (v1 only has Rig1+Rig2) + # Change case 3/4 to fall through to default (E_INVALIDARG) + $content = $content.Replace( + 'case 3: hr = omniInterface->get_Rig3(&rig); break;', + 'case 3: /* Rig3 not available in v1 fallback */') + $content = $content.Replace( + 'case 4: hr = omniInterface->get_Rig4(&rig); break;', + 'case 4: /* Rig4 not available in v1 fallback */') + + Set-Content $v2file $content -NoNewline + + Write-Host "Patched v2 source:" + Select-String '#import' $v2file | ForEach-Object { $_.Line.Trim() } + Select-String 'case 3:|case 4:' $v2file | ForEach-Object { $_.Line.Trim() } + } + + # —— 8. Build QLog ———————————————————————————————————————— + - name: Build QLog + shell: cmd + run: | + set "QTKC_INC=C:\qtkeychain\include" + set "VCPKG_INC=C:\vcpkg\installed\x64-windows\include" + set "VCPKG_LIB=C:\vcpkg\installed\x64-windows\lib" + + mkdir build + cd build + qmake ..\QLog.pro -spec win32-msvc ^ + "CONFIG+=release" ^ + "HAMLIBINCLUDEPATH=C:\hamlib\include" ^ + "HAMLIBLIBPATH=C:\hamlib\lib\msvc" ^ + "HAMLIBVERSION_MAJOR=4" ^ + "HAMLIBVERSION_MINOR=7" ^ + "HAMLIBVERSION_PATCH=1" ^ + "QTKEYCHAININCLUDEPATH=%QTKC_INC%" ^ + "QTKEYCHAINLIBPATH=C:\qtkeychain\lib" ^ + "PTHREADINCLUDEPATH=%VCPKG_INC%" ^ + "PTHREADLIBPATH=%VCPKG_LIB%" ^ + "ZLIBINCLUDEPATH=%VCPKG_INC%" ^ + "ZLIBLIBPATH=%VCPKG_LIB%" ^ + "OPENSSLINCLUDEPATH=%VCPKG_INC%" ^ + "OPENSSLLIBPATH=%VCPKG_LIB%" + nmake + + # —— 9. Package with windeployqt —————————————————————————— + - name: Deploy + shell: pwsh + run: | + $deploy = "C:\qlog-deploy" + New-Item -ItemType Directory $deploy -Force | Out-Null + + $exe = Get-ChildItem build -Recurse -Filter "qlog.exe" | Select -First 1 + if (!$exe) { Write-Error "qlog.exe not found!"; exit 1 } + Copy-Item $exe.FullName $deploy\ + + # Copy qt6keychain.dll to Qt bin dir so windeployqt can resolve it + $qtBin = Join-Path $env:QT_ROOT_DIR "bin" + Copy-Item C:\qtkeychain\bin\qt6keychain.dll "$qtBin\" -Force -ErrorAction SilentlyContinue + Copy-Item C:\qtkeychain\lib\qt6keychain.dll "$qtBin\" -Force -ErrorAction SilentlyContinue + + Copy-Item C:\hamlib\bin\*.dll $deploy\ + Copy-Item C:\qtkeychain\bin\*.dll $deploy\ -ErrorAction SilentlyContinue + Copy-Item C:\qtkeychain\lib\*.dll $deploy\ -ErrorAction SilentlyContinue + + $vcpkgBin = "C:\vcpkg\installed\x64-windows\bin" + if (Test-Path $vcpkgBin) { + Copy-Item "$vcpkgBin\*.dll" $deploy\ -ErrorAction SilentlyContinue + } + + if ($env:OPENSSL_DIR -and (Test-Path $env:OPENSSL_DIR)) { + $sslBin = Join-Path $env:OPENSSL_DIR "bin" + if (Test-Path $sslBin) { + Copy-Item "$sslBin\libssl*.dll" $deploy\ -ErrorAction SilentlyContinue + Copy-Item "$sslBin\libcrypto*.dll" $deploy\ -ErrorAction SilentlyContinue + } + } + + Push-Location $deploy + windeployqt --release --no-translations qlog.exe + Pop-Location + + Write-Host "Deploy contents:" + Get-ChildItem $deploy | Format-Table Name, Length + + # —— 10. Create Qt IFW installer —————————————————————————— + - name: Create Installer + shell: pwsh + run: | + $bc = $null + if ($env:IQTA_TOOLS) { + $bc = Get-ChildItem $env:IQTA_TOOLS -Recurse -Filter "binarycreator.exe" -ErrorAction SilentlyContinue | Select -First 1 + } + if (!$bc) { + $bc = Get-ChildItem "$env:RUNNER_TOOL_CACHE" -Recurse -Filter "binarycreator.exe" -ErrorAction SilentlyContinue | Select -First 1 + } + if (!$bc) { + Write-Warning "binarycreator not found — skipping installer" + exit 0 + } + + Copy-Item installer -Destination installer-build -Recurse + $pkgData = "installer-build\packages\de.dl2ic.qlog\data" + New-Item -ItemType Directory $pkgData -Force | Out-Null + Copy-Item C:\qlog-deploy\* $pkgData\ -Recurse + + & $bc.FullName -f ` + -c installer-build\config\config.xml ` + -p installer-build\packages ` + qlog-installer.exe + + if (Test-Path qlog-installer.exe) { + Write-Host "Installer created: qlog-installer.exe" + } + + # —— 11. Create portable ZIP —————————————————————————————— + - name: Create Portable ZIP + if: startsWith(github.ref, 'refs/tags/v') + shell: pwsh + run: | + $tag = "${{ github.ref_name }}" + Compress-Archive -Path C:\qlog-deploy\* -DestinationPath "QLog-Portable-Windows-${tag}.zip" + Write-Host "Portable ZIP: QLog-Portable-Windows-${tag}.zip" + + # —— 12. Upload artifacts (always — for testing) —————————— + - name: Upload Installer + if: ${{ hashFiles('qlog-installer.exe') != '' }} + uses: actions/upload-artifact@v4 + with: + name: QLog-Installer-Windows + path: qlog-installer.exe + + - name: Upload Portable + uses: actions/upload-artifact@v4 + with: + name: QLog-Portable-Windows + path: C:\qlog-deploy\ + + # —— 13. Create GitHub Release (only on tag push) ————————— + - name: Create GitHub Release + if: startsWith(github.ref, 'refs/tags/v') + uses: softprops/action-gh-release@v2 + with: + name: "QLog ${{ github.ref_name }}" + body: | + ## QLog ${{ github.ref_name }} + + **Downloads:** + - **Installer** (recommended) — run `qlog-installer.exe` + - **Portable ZIP** — extract anywhere and run `qlog.exe` + + Built with Qt ${{ env.QT_VERSION }}, Hamlib ${{ env.HAMLIB_VERSION }}, MSVC 2022 x64. + draft: false + prerelease: false + files: | + qlog-installer.exe + QLog-Portable-Windows-${{ github.ref_name }}.zip + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 3008ba280b5135def2240e23d4f8b2ffd3fbd7ed Mon Sep 17 00:00:00 2001 From: Michael Morgan <84428382+aa5sh@users.noreply.github.com> Date: Sat, 30 May 2026 09:57:17 -0500 Subject: [PATCH 09/21] rebase to testing_0.51 --- core/LogParam.cpp | 100 +++++++++++++++++++++++++++ core/LogParam.h | 24 +++++++ core/Migration.cpp | 34 +++++++++ core/Migration.h | 3 +- res/res.qrc | 1 + res/sql/migration_040.sql | 0 service/emailqsl/EmailQSLService.cpp | 70 ++++++++----------- service/emailqsl/EmailQSLService.h | 4 +- ui/EmailQSLSettingsWidget.cpp | 2 +- ui/EmailQSLSettingsWidget.h | 2 +- 10 files changed, 194 insertions(+), 46 deletions(-) create mode 100644 res/sql/migration_040.sql diff --git a/core/LogParam.cpp b/core/LogParam.cpp index 2e4421f9..9ef6a591 100644 --- a/core/LogParam.cpp +++ b/core/LogParam.cpp @@ -622,6 +622,106 @@ void LogParam::setKSTChatUsername(const QString &username) setParam("services/kst/chat/username", username); } +QString LogParam::getEmailQSLSmtpHost() +{ + return getParam("services/emailqsl/smtpHost").toString(); +} + +void LogParam::setEmailQSLSmtpHost(const QString &host) +{ + setParam("services/emailqsl/smtpHost", host); +} + +int LogParam::getEmailQSLSmtpPort() +{ + return getParam("services/emailqsl/smtpPort", 587).toInt(); +} + +void LogParam::setEmailQSLSmtpPort(int port) +{ + setParam("services/emailqsl/smtpPort", port); +} + +int LogParam::getEmailQSLSmtpEncryption() +{ + return getParam("services/emailqsl/smtpEncryption", 2).toInt(); +} + +void LogParam::setEmailQSLSmtpEncryption(int enc) +{ + setParam("services/emailqsl/smtpEncryption", enc); +} + +QString LogParam::getEmailQSLSmtpUsername() +{ + return getParam("services/emailqsl/smtpUsername").toString(); +} + +void LogParam::setEmailQSLSmtpUsername(const QString &username) +{ + setParam("services/emailqsl/smtpUsername", username); +} + +QString LogParam::getEmailQSLFromAddress() +{ + return getParam("services/emailqsl/fromAddress").toString(); +} + +void LogParam::setEmailQSLFromAddress(const QString &addr) +{ + setParam("services/emailqsl/fromAddress", addr); +} + +QString LogParam::getEmailQSLFromName() +{ + return getParam("services/emailqsl/fromName").toString(); +} + +void LogParam::setEmailQSLFromName(const QString &name) +{ + setParam("services/emailqsl/fromName", name); +} + +QString LogParam::getEmailQSLSubjectTemplate(const QString &defaultValue) +{ + return getParam("services/emailqsl/subjectTemplate", defaultValue).toString(); +} + +void LogParam::setEmailQSLSubjectTemplate(const QString &tmpl) +{ + setParam("services/emailqsl/subjectTemplate", tmpl); +} + +QString LogParam::getEmailQSLBodyTemplate(const QString &defaultValue) +{ + return getParam("services/emailqsl/bodyTemplate", defaultValue).toString(); +} + +void LogParam::setEmailQSLBodyTemplate(const QString &tmpl) +{ + setParam("services/emailqsl/bodyTemplate", tmpl); +} + +QString LogParam::getEmailQSLCardImagePath() +{ + return getParam("services/emailqsl/cardImagePath").toString(); +} + +void LogParam::setEmailQSLCardImagePath(const QString &path) +{ + setParam("services/emailqsl/cardImagePath", path); +} + +QByteArray LogParam::getEmailQSLCardOverlays() +{ + return getParam("services/emailqsl/cardOverlays").toByteArray(); +} + +void LogParam::setEmailQSLCardOverlays(const QByteArray &json) +{ + setParam("services/emailqsl/cardOverlays", json); +} + QString LogParam::getLoTWCallbookUsername() { return getParam("services/lotw/callbook/username").toString().trimmed(); diff --git a/core/LogParam.h b/core/LogParam.h index a1f5bacc..70070686 100644 --- a/core/LogParam.h +++ b/core/LogParam.h @@ -201,6 +201,30 @@ class LogParam : public QObject static QString getKSTChatUsername(); static void setKSTChatUsername(const QString& username); + /********* + * Email QSL + ********/ + static QString getEmailQSLSmtpHost(); + static void setEmailQSLSmtpHost(const QString &host); + static int getEmailQSLSmtpPort(); + static void setEmailQSLSmtpPort(int port); + static int getEmailQSLSmtpEncryption(); + static void setEmailQSLSmtpEncryption(int enc); + static QString getEmailQSLSmtpUsername(); + static void setEmailQSLSmtpUsername(const QString &username); + static QString getEmailQSLFromAddress(); + static void setEmailQSLFromAddress(const QString &addr); + static QString getEmailQSLFromName(); + static void setEmailQSLFromName(const QString &name); + static QString getEmailQSLSubjectTemplate(const QString &defaultValue); + static void setEmailQSLSubjectTemplate(const QString &tmpl); + static QString getEmailQSLBodyTemplate(const QString &defaultValue); + static void setEmailQSLBodyTemplate(const QString &tmpl); + static QString getEmailQSLCardImagePath(); + static void setEmailQSLCardImagePath(const QString &path); + static QByteArray getEmailQSLCardOverlays(); + static void setEmailQSLCardOverlays(const QByteArray &json); + /********* * LoTW ********/ diff --git a/core/Migration.cpp b/core/Migration.cpp index 0c2efc64..93bc9339 100644 --- a/core/Migration.cpp +++ b/core/Migration.cpp @@ -329,6 +329,9 @@ bool DBSchemaMigration::functionMigration(int version) case 35: ret = removeSettings2DB(); break; + case 40: + ret = emailQSLSettings2DB(); + break; default: ret = true; } @@ -907,6 +910,37 @@ bool DBSchemaMigration::removeSettings2DB() return true; } +bool DBSchemaMigration::emailQSLSettings2DB() +{ + FCT_IDENTIFICATION; + + QSettings settings; + + if (settings.contains("emailqsl/smtpHost")) + LogParam::setEmailQSLSmtpHost(settings.value("emailqsl/smtpHost").toString()); + if (settings.contains("emailqsl/smtpPort")) + LogParam::setEmailQSLSmtpPort(settings.value("emailqsl/smtpPort").toInt()); + if (settings.contains("emailqsl/smtpEncryption")) + LogParam::setEmailQSLSmtpEncryption(settings.value("emailqsl/smtpEncryption").toInt()); + if (settings.contains("emailqsl/smtpUsername")) + LogParam::setEmailQSLSmtpUsername(settings.value("emailqsl/smtpUsername").toString()); + if (settings.contains("emailqsl/fromAddress")) + LogParam::setEmailQSLFromAddress(settings.value("emailqsl/fromAddress").toString()); + if (settings.contains("emailqsl/fromName")) + LogParam::setEmailQSLFromName(settings.value("emailqsl/fromName").toString()); + if (settings.contains("emailqsl/subjectTemplate")) + LogParam::setEmailQSLSubjectTemplate(settings.value("emailqsl/subjectTemplate").toString()); + if (settings.contains("emailqsl/bodyTemplate")) + LogParam::setEmailQSLBodyTemplate(settings.value("emailqsl/bodyTemplate").toString()); + if (settings.contains("emailqsl/cardImagePath")) + LogParam::setEmailQSLCardImagePath(settings.value("emailqsl/cardImagePath").toString()); + if (settings.contains("emailqsl/cardOverlays")) + LogParam::setEmailQSLCardOverlays(settings.value("emailqsl/cardOverlays").toByteArray()); + + settings.remove("emailqsl"); + return true; +} + bool DBSchemaMigration::settings2DB() { FCT_IDENTIFICATION; diff --git a/core/Migration.h b/core/Migration.h index d4b4ec72..b688da0b 100644 --- a/core/Migration.h +++ b/core/Migration.h @@ -14,7 +14,7 @@ class DBSchemaMigration : public QObject bool run(bool force = false); static bool backupAllQSOsToADX(bool force = false); - static constexpr int latestVersion = 39; + static constexpr int latestVersion = 40; private: bool functionMigration(int version); @@ -39,6 +39,7 @@ class DBSchemaMigration : public QObject bool profiles2DB(); bool settings2DB(); bool removeSettings2DB(); + bool emailQSLSettings2DB(); bool setSelectedProfile(const QString &tablename, const QString &profileName); QString fixIntlField(const QSqlQuery &query, const QString &columName, const QString &columnNameIntl); bool refreshUploadStatusTrigger(); diff --git a/res/res.qrc b/res/res.qrc index 8ca4af25..c4b53a44 100644 --- a/res/res.qrc +++ b/res/res.qrc @@ -52,5 +52,6 @@ sql/migration_037.sql sql/migration_038.sql sql/migration_039.sql + sql/migration_040.sql diff --git a/res/sql/migration_040.sql b/res/sql/migration_040.sql new file mode 100644 index 00000000..e69de29b diff --git a/service/emailqsl/EmailQSLService.cpp b/service/emailqsl/EmailQSLService.cpp index 60240fff..7a80effe 100644 --- a/service/emailqsl/EmailQSLService.cpp +++ b/service/emailqsl/EmailQSLService.cpp @@ -1,4 +1,3 @@ -#include #include #include #include @@ -13,6 +12,7 @@ #include "EmailQSLService.h" #include "core/debug.h" +#include "core/LogParam.h" MODULE_IDENTIFICATION("qlog.service.emailqsl"); @@ -79,56 +79,47 @@ void EmailQSLBase::registerCredentials() } // --------------------------------------------------------------------------- -// EmailQSLBase — QSettings helpers +// EmailQSLBase — settings persisted via LogParam (log_param DB table) // --------------------------------------------------------------------------- -#define EMAILQSL_SETTINGS_GROUP "emailqsl" - -static QSettings &cfg() -{ - static QSettings s; - return s; -} - QString EmailQSLBase::getSmtpHost() { - return cfg().value(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/smtpHost")).toString(); + return LogParam::getEmailQSLSmtpHost(); } void EmailQSLBase::setSmtpHost(const QString &host) { - cfg().setValue(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/smtpHost"), host); + LogParam::setEmailQSLSmtpHost(host); } int EmailQSLBase::getSmtpPort() { - return cfg().value(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/smtpPort"), 587).toInt(); + return LogParam::getEmailQSLSmtpPort(); } void EmailQSLBase::setSmtpPort(int port) { - cfg().setValue(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/smtpPort"), port); + LogParam::setEmailQSLSmtpPort(port); } int EmailQSLBase::getSmtpEncryption() { - return cfg().value(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/smtpEncryption"), - ENCRYPTION_STARTTLS).toInt(); + return LogParam::getEmailQSLSmtpEncryption(); } void EmailQSLBase::setSmtpEncryption(int enc) { - cfg().setValue(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/smtpEncryption"), enc); + LogParam::setEmailQSLSmtpEncryption(enc); } QString EmailQSLBase::getSmtpUsername() { - return cfg().value(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/smtpUsername")).toString(); + return LogParam::getEmailQSLSmtpUsername(); } void EmailQSLBase::setSmtpUsername(const QString &username) { - cfg().setValue(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/smtpUsername"), username); + LogParam::setEmailQSLSmtpUsername(username); } QString EmailQSLBase::getSmtpPassword() @@ -147,67 +138,65 @@ void EmailQSLBase::saveSmtpCredentials(const QString &username, const QString &p QString EmailQSLBase::getFromAddress() { - return cfg().value(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/fromAddress")).toString(); + return LogParam::getEmailQSLFromAddress(); } void EmailQSLBase::setFromAddress(const QString &addr) { - cfg().setValue(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/fromAddress"), addr); + LogParam::setEmailQSLFromAddress(addr); } QString EmailQSLBase::getFromName() { - return cfg().value(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/fromName")).toString(); + return LogParam::getEmailQSLFromName(); } void EmailQSLBase::setFromName(const QString &name) { - cfg().setValue(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/fromName"), name); + LogParam::setEmailQSLFromName(name); } QString EmailQSLBase::getSubjectTemplate() { - return cfg().value(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/subjectTemplate"), - QStringLiteral("QSL Card from {MY_CALLSIGN} for our QSO on {QSO_DATE}")).toString(); + return LogParam::getEmailQSLSubjectTemplate( + QStringLiteral("QSL Card from {MY_CALLSIGN} for our QSO on {QSO_DATE}")); } void EmailQSLBase::setSubjectTemplate(const QString &tmpl) { - cfg().setValue(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/subjectTemplate"), tmpl); + LogParam::setEmailQSLSubjectTemplate(tmpl); } QString EmailQSLBase::getBodyTemplate() { - return cfg().value(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/bodyTemplate"), - QStringLiteral("Dear {NAME},\n\nPlease find my QSL card attached confirming our contact.\n\n" - "Callsign: {MY_CALLSIGN}\n" - "Date: {QSO_DATE}\nTime: {TIME_ON} UTC\n" - "Band: {BAND}\nMode: {MODE}\n" - "RST Sent: {RST_SENT}\nRST Rcvd: {RST_RCVD}\n\n" - "73,\n{MY_CALLSIGN}")).toString(); + return LogParam::getEmailQSLBodyTemplate( + QStringLiteral("Dear {NAME},\n\nPlease find my QSL card attached confirming our contact.\n\n" + "Callsign: {MY_CALLSIGN}\n" + "Date: {QSO_DATE}\nTime: {TIME_ON} UTC\n" + "Band: {BAND}\nMode: {MODE}\n" + "RST Sent: {RST_SENT}\nRST Rcvd: {RST_RCVD}\n\n" + "73,\n{MY_CALLSIGN}")); } void EmailQSLBase::setBodyTemplate(const QString &tmpl) { - cfg().setValue(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/bodyTemplate"), tmpl); + LogParam::setEmailQSLBodyTemplate(tmpl); } QString EmailQSLBase::getCardImagePath() { - return cfg().value(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/cardImagePath")).toString(); + return LogParam::getEmailQSLCardImagePath(); } void EmailQSLBase::setCardImagePath(const QString &path) { - cfg().setValue(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/cardImagePath"), path); + LogParam::setEmailQSLCardImagePath(path); } QList EmailQSLBase::getCardFieldOverlays() { QList result; - const QByteArray json = cfg().value( - QStringLiteral(EMAILQSL_SETTINGS_GROUP "/cardOverlays")).toByteArray(); - const QJsonArray arr = QJsonDocument::fromJson(json).array(); + const QJsonArray arr = QJsonDocument::fromJson(LogParam::getEmailQSLCardOverlays()).array(); for (const QJsonValue &v : arr) result.append(EmailQSLFieldOverlay::fromJson(v.toObject())); return result; @@ -218,8 +207,7 @@ void EmailQSLBase::setCardFieldOverlays(const QList &overl QJsonArray arr; for (const EmailQSLFieldOverlay &o : overlays) arr.append(o.toJson()); - cfg().setValue(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/cardOverlays"), - QJsonDocument(arr).toJson(QJsonDocument::Compact)); + LogParam::setEmailQSLCardOverlays(QJsonDocument(arr).toJson(QJsonDocument::Compact)); } // --------------------------------------------------------------------------- diff --git a/service/emailqsl/EmailQSLService.h b/service/emailqsl/EmailQSLService.h index 12d9e8d8..73a6f213 100644 --- a/service/emailqsl/EmailQSLService.h +++ b/service/emailqsl/EmailQSLService.h @@ -44,7 +44,7 @@ struct EmailQSLFieldOverlay // Static helpers for reading/writing all Email QSL settings. // The SMTP password is kept in the secure credential store; -// everything else lives in QSettings under the "emailqsl/" group. +// everything else is persisted via LogParam (log_param DB table). class EmailQSLBase : public SecureServiceBase { public: @@ -92,7 +92,7 @@ class EmailQSLBase : public SecureServiceBase static void recordEmailSent(int contactId, const QSqlRecord ¤tRecord); // --- Rendering helpers --- - // Full render using saved QSettings (used when sending). + // Full render using saved settings from LogParam (used when sending). static QPixmap renderCard(const QSqlRecord &record); // Full render using an explicit image path and overlay list // (used by settings preview so unsaved changes are shown). diff --git a/ui/EmailQSLSettingsWidget.cpp b/ui/EmailQSLSettingsWidget.cpp index 35c0bd39..0163da9a 100644 --- a/ui/EmailQSLSettingsWidget.cpp +++ b/ui/EmailQSLSettingsWidget.cpp @@ -903,7 +903,7 @@ void EmailQSLSettingsWidget::removeOverlay() } // --------------------------------------------------------------------------- -// Full-size preview — renders using current UI state (not QSettings) +// Full-size preview — renders using current UI state (not saved LogParam values) // --------------------------------------------------------------------------- QPixmap EmailQSLSettingsWidget::renderPreviewPixmap(const QSqlRecord &record) diff --git a/ui/EmailQSLSettingsWidget.h b/ui/EmailQSLSettingsWidget.h index 7f991175..a987e0f0 100644 --- a/ui/EmailQSLSettingsWidget.h +++ b/ui/EmailQSLSettingsWidget.h @@ -24,7 +24,7 @@ class EmailQSLSettingsWidget : public QWidget void readSettings(); void writeSettings(); - // Render card using the current UI state (not saved QSettings). + // Render card using the current UI state (not saved LogParam values). // Used by the preview dialog so the user can see results before saving. QPixmap renderPreviewPixmap(const QSqlRecord &record); From 1d8a54c396629406481530bee3bb2e77d5d4e25a Mon Sep 17 00:00:00 2001 From: Michael Morgan <84428382+aa5sh@users.noreply.github.com> Date: Sat, 30 May 2026 11:05:59 -0500 Subject: [PATCH 10/21] m --- QLog.pro | 12 ++++++++++- res/macos/Info.plist | 50 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 res/macos/Info.plist diff --git a/QLog.pro b/QLog.pro index 3a4a31ad..be890b2f 100644 --- a/QLog.pro +++ b/QLog.pro @@ -650,7 +650,17 @@ macx: { LIBS += -L/usr/local/lib -L/opt/homebrew/lib -lhamlib -lsqlite3 -lz -L/opt/local/lib -lssl -lcrypto equals(QT_MAJOR_VERSION, 6): LIBS += -lqt6keychain equals(QT_MAJOR_VERSION, 5): LIBS += -lqt5keychain - DISTFILES += + + # Custom Info.plist — sets a real bundle identifier + # (io.github.foldynl.qlog) and declares NSLocalNetworkUsageDescription + # so macOS 14+ will actually prompt for / grant LAN access (needed + # for WSJT-X UDP, network rigs, rotators, etc.). Without this, qmake + # generates a default Info.plist with CFBundleIdentifier = + # com.yourcompany.qlog and no local-network key. + QMAKE_INFO_PLIST = $$PWD/res/macos/Info.plist + QMAKE_TARGET_BUNDLE_PREFIX = io.github.foldynl + + DISTFILES += res/macos/Info.plist } win32: { diff --git a/res/macos/Info.plist b/res/macos/Info.plist new file mode 100644 index 00000000..31b854a6 --- /dev/null +++ b/res/macos/Info.plist @@ -0,0 +1,50 @@ + + + + + CFBundleAllowMixedLocalizations + + CFBundleDevelopmentRegion + en + CFBundleExecutable + @EXECUTABLE@ + CFBundleIconFile + @ICON@ + CFBundleIdentifier + io.github.foldynl.qlog + CFBundleName + QLog + CFBundleDisplayName + QLog + CFBundlePackageType + APPL + CFBundleSignature + ???? + CFBundleShortVersionString + @SHORT_VERSION@ + CFBundleVersion + @FULL_VERSION@ + LSMinimumSystemVersion + 14.0 + LSApplicationCategoryType + public.app-category.utilities + NSHumanReadableCopyright + Copyright © OK1MLG. Licensed under the GNU GPL v3. + NSPrincipalClass + NSApplication + NSSupportsAutomaticGraphicsSwitching + + NSHighResolutionCapable + + + + NSLocalNetworkUsageDescription + QLog needs local network access to communicate with WSJT-X, network-attached rigs, rotators, and other amateur-radio software running on devices on your network. + + From b15bbddb400f11db4817899363ed901afbeba6e6 Mon Sep 17 00:00:00 2001 From: Michael Morgan <84428382+aa5sh@users.noreply.github.com> Date: Wed, 8 Apr 2026 21:47:17 -0500 Subject: [PATCH 11/21] Add Email QSL sending feature Implements Email QSL functionality: adds an EmailQSL service (SMTP worker, MIME builder, card rendering and merge-fields), UI for sending and previewing (EmailQSLDialog and .ui), and settings/overlays storage. Introduces a CardEditorWidget component and settings widget for configuring card image/overlays and SMTP credentials (uses secure credential store and QSettings). Tracks sent timestamps in contacts.fields JSON and performs async SMTP operations with STARTTLS/SSL and AUTH support. Wires all new sources, headers and forms into QLog.pro and updates logbook/settings UI to integrate the new feature. --- QLog.pro | 10 + service/emailqsl/EmailQSLService.cpp | 825 +++++++++++++++++++++ service/emailqsl/EmailQSLService.h | 169 +++++ ui/EmailQSLDialog.cpp | 234 ++++++ ui/EmailQSLDialog.h | 37 + ui/EmailQSLDialog.ui | 135 ++++ ui/EmailQSLSettingsWidget.cpp | 1007 ++++++++++++++++++++++++++ ui/EmailQSLSettingsWidget.h | 63 ++ ui/EmailQSLSettingsWidget.ui | 384 ++++++++++ ui/LogbookWidget.cpp | 129 ++++ ui/LogbookWidget.h | 2 + ui/LogbookWidget.ui | 19 + ui/SettingsDialog.cpp | 11 + ui/SettingsDialog.ui | 15 + ui/component/CardEditorWidget.cpp | 380 ++++++++++ ui/component/CardEditorWidget.h | 68 ++ 16 files changed, 3488 insertions(+) create mode 100644 service/emailqsl/EmailQSLService.cpp create mode 100644 service/emailqsl/EmailQSLService.h create mode 100644 ui/EmailQSLDialog.cpp create mode 100644 ui/EmailQSLDialog.h create mode 100644 ui/EmailQSLDialog.ui create mode 100644 ui/EmailQSLSettingsWidget.cpp create mode 100644 ui/EmailQSLSettingsWidget.h create mode 100644 ui/EmailQSLSettingsWidget.ui create mode 100644 ui/component/CardEditorWidget.cpp create mode 100644 ui/component/CardEditorWidget.h diff --git a/QLog.pro b/QLog.pro index f0097da1..3a4a31ad 100644 --- a/QLog.pro +++ b/QLog.pro @@ -163,6 +163,7 @@ SOURCES += \ service/GenericCallbook.cpp \ service/GenericQSLDownloader.cpp \ service/GenericQSOUploader.cpp \ + service/emailqsl/EmailQSLService.cpp \ service/cloudlog/Cloudlog.cpp \ service/clublog/ClubLog.cpp \ service/eqsl/Eqsl.cpp \ @@ -212,6 +213,8 @@ SOURCES += \ ui/ModeSelectionController.cpp \ ui/NewContactWidget.cpp \ ui/OnlineMapWidget.cpp \ + ui/EmailQSLDialog.cpp \ + ui/EmailQSLSettingsWidget.cpp \ ui/PaperQSLDialog.cpp \ ui/ProfileImageWidget.cpp \ ui/QSLImportStatDialog.cpp \ @@ -229,6 +232,7 @@ SOURCES += \ ui/WsjtxFilterDialog.cpp \ ui/WsjtxWidget.cpp \ ui/component/BaseDoubleSpinBox.cpp \ + ui/component/CardEditorWidget.cpp \ ui/component/EditLine.cpp \ ui/component/FreqQSpinBox.cpp \ ui/component/ModeSubmodeDelegate.cpp \ @@ -366,6 +370,7 @@ HEADERS += \ service/GenericCallbook.h \ service/GenericQSLDownloader.h \ service/GenericQSOUploader.h \ + service/emailqsl/EmailQSLService.h \ service/cloudlog/Cloudlog.h \ service/clublog/ClubLog.h \ service/eqsl/Eqsl.h \ @@ -391,6 +396,8 @@ HEADERS += \ ui/DevToolsDialog.h \ ui/DownloadQSLDialog.h \ ui/DxFilterDialog.h \ + ui/EmailQSLDialog.h \ + ui/EmailQSLSettingsWidget.h \ ui/DxWidget.h \ ui/DxccTableWidget.h \ ui/EditActivitiesDialog.h \ @@ -437,6 +444,7 @@ HEADERS += \ i18n/datastrings.tri \ ui/component/BaseDoubleSpinBox.h \ ui/component/ButtonStyle.h \ + ui/component/CardEditorWidget.h \ ui/component/EditLine.h \ ui/component/FreqQSpinBox.h \ ui/component/ModeSubmodeDelegate.h \ @@ -466,6 +474,8 @@ FORMS += \ ui/DevToolsDialog.ui \ ui/DownloadQSLDialog.ui \ ui/DxFilterDialog.ui \ + ui/EmailQSLDialog.ui \ + ui/EmailQSLSettingsWidget.ui \ ui/DxWidget.ui \ ui/EditActivitiesDialog.ui \ ui/CabrilloExportDialog.ui \ diff --git a/service/emailqsl/EmailQSLService.cpp b/service/emailqsl/EmailQSLService.cpp new file mode 100644 index 00000000..94aba7ae --- /dev/null +++ b/service/emailqsl/EmailQSLService.cpp @@ -0,0 +1,825 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "EmailQSLService.h" +#include "core/debug.h" + +MODULE_IDENTIFICATION("qlog.service.emailqsl"); + +// --------------------------------------------------------------------------- +// EmailQSLFieldOverlay +// --------------------------------------------------------------------------- + +QJsonObject EmailQSLFieldOverlay::toJson() const +{ + QJsonObject o; + o["type"] = type; + o["fieldName"] = fieldName; + o["x"] = x; + o["y"] = y; + o["fontFamily"] = fontFamily; + o["fontSize"] = fontSize; + o["color"] = color; + o["bold"] = bold; + o["italic"] = italic; + o["width"] = width; + o["height"] = height; + o["fillColor"] = fillColor; + o["opacity"] = opacity; + o["cornerRadius"] = cornerRadius; + return o; +} + +EmailQSLFieldOverlay EmailQSLFieldOverlay::fromJson(const QJsonObject &obj) +{ + EmailQSLFieldOverlay f; + f.type = obj.value("type").toString(QStringLiteral("TEXT")); + f.fieldName = obj.value("fieldName").toString(); + f.x = obj.value("x").toInt(); + f.y = obj.value("y").toInt(); + f.fontFamily = obj.value("fontFamily").toString(QStringLiteral("Arial")); + f.fontSize = obj.value("fontSize").toInt(14); + f.color = obj.value("color").toString(QStringLiteral("#000000")); + f.bold = obj.value("bold").toBool(); + f.italic = obj.value("italic").toBool(); + f.width = obj.value("width").toInt(120); + f.height = obj.value("height").toInt(60); + f.fillColor = obj.value("fillColor").toString(QStringLiteral("#FFFF99")); + f.opacity = obj.value("opacity").toInt(80); + f.cornerRadius = obj.value("cornerRadius").toInt(8); + return f; +} + +// --------------------------------------------------------------------------- +// EmailQSLBase — credential registration +// --------------------------------------------------------------------------- + +const QString EmailQSLBase::SECURE_STORAGE_KEY = QStringLiteral("EmailQSL"); +REGISTRATION_SECURE_SERVICE(EmailQSLBase); + +void EmailQSLBase::registerCredentials() +{ + CredentialRegistry::instance().add(SECURE_STORAGE_KEY, []() + { + return QList + { + { SECURE_STORAGE_KEY, []() { return getSmtpUsername(); } } + }; + }); +} + +// --------------------------------------------------------------------------- +// EmailQSLBase — QSettings helpers +// --------------------------------------------------------------------------- + +#define EMAILQSL_SETTINGS_GROUP "emailqsl" + +static QSettings &cfg() +{ + static QSettings s; + return s; +} + +QString EmailQSLBase::getSmtpHost() +{ + return cfg().value(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/smtpHost")).toString(); +} + +void EmailQSLBase::setSmtpHost(const QString &host) +{ + cfg().setValue(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/smtpHost"), host); +} + +int EmailQSLBase::getSmtpPort() +{ + return cfg().value(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/smtpPort"), 587).toInt(); +} + +void EmailQSLBase::setSmtpPort(int port) +{ + cfg().setValue(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/smtpPort"), port); +} + +int EmailQSLBase::getSmtpEncryption() +{ + return cfg().value(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/smtpEncryption"), + ENCRYPTION_STARTTLS).toInt(); +} + +void EmailQSLBase::setSmtpEncryption(int enc) +{ + cfg().setValue(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/smtpEncryption"), enc); +} + +QString EmailQSLBase::getSmtpUsername() +{ + return cfg().value(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/smtpUsername")).toString(); +} + +void EmailQSLBase::setSmtpUsername(const QString &username) +{ + cfg().setValue(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/smtpUsername"), username); +} + +QString EmailQSLBase::getSmtpPassword() +{ + return getPassword(SECURE_STORAGE_KEY, getSmtpUsername()); +} + +void EmailQSLBase::saveSmtpCredentials(const QString &username, const QString &password) +{ + const QString oldUsername = getSmtpUsername(); + if (oldUsername != username && !oldUsername.isEmpty()) + deletePassword(SECURE_STORAGE_KEY, oldUsername); + setSmtpUsername(username); + savePassword(SECURE_STORAGE_KEY, username, password); +} + +QString EmailQSLBase::getFromAddress() +{ + return cfg().value(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/fromAddress")).toString(); +} + +void EmailQSLBase::setFromAddress(const QString &addr) +{ + cfg().setValue(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/fromAddress"), addr); +} + +QString EmailQSLBase::getFromName() +{ + return cfg().value(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/fromName")).toString(); +} + +void EmailQSLBase::setFromName(const QString &name) +{ + cfg().setValue(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/fromName"), name); +} + +QString EmailQSLBase::getSubjectTemplate() +{ + return cfg().value(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/subjectTemplate"), + QStringLiteral("QSL Card from {MY_CALLSIGN} for our QSO on {QSO_DATE}")).toString(); +} + +void EmailQSLBase::setSubjectTemplate(const QString &tmpl) +{ + cfg().setValue(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/subjectTemplate"), tmpl); +} + +QString EmailQSLBase::getBodyTemplate() +{ + return cfg().value(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/bodyTemplate"), + QStringLiteral("Dear {NAME},\n\nPlease find my QSL card attached confirming our contact.\n\n" + "Callsign: {MY_CALLSIGN}\n" + "Date: {QSO_DATE}\nTime: {TIME_ON} UTC\n" + "Band: {BAND}\nMode: {MODE}\n" + "RST Sent: {RST_SENT}\nRST Rcvd: {RST_RCVD}\n\n" + "73,\n{MY_CALLSIGN}")).toString(); +} + +void EmailQSLBase::setBodyTemplate(const QString &tmpl) +{ + cfg().setValue(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/bodyTemplate"), tmpl); +} + +QString EmailQSLBase::getCardImagePath() +{ + return cfg().value(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/cardImagePath")).toString(); +} + +void EmailQSLBase::setCardImagePath(const QString &path) +{ + cfg().setValue(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/cardImagePath"), path); +} + +QList EmailQSLBase::getCardFieldOverlays() +{ + QList result; + const QByteArray json = cfg().value( + QStringLiteral(EMAILQSL_SETTINGS_GROUP "/cardOverlays")).toByteArray(); + const QJsonArray arr = QJsonDocument::fromJson(json).array(); + for (const QJsonValue &v : arr) + result.append(EmailQSLFieldOverlay::fromJson(v.toObject())); + return result; +} + +void EmailQSLBase::setCardFieldOverlays(const QList &overlays) +{ + QJsonArray arr; + for (const EmailQSLFieldOverlay &o : overlays) + arr.append(o.toJson()); + cfg().setValue(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/cardOverlays"), + QJsonDocument(arr).toJson(QJsonDocument::Compact)); +} + +// --------------------------------------------------------------------------- +// EmailQSLBase — sent-tracking via contacts.fields JSON +// --------------------------------------------------------------------------- + +static const QString EMAIL_QSL_SENT_KEY = QStringLiteral("email_qsl_sent_dt"); + +QDateTime EmailQSLBase::getEmailSentDateTime(const QSqlRecord &record) +{ + const QByteArray raw = record.value(QStringLiteral("fields")).toByteArray(); + const QJsonObject fields = QJsonDocument::fromJson(raw).object(); + const QString dtStr = fields.value(EMAIL_QSL_SENT_KEY).toString(); + return dtStr.isEmpty() ? QDateTime() : QDateTime::fromString(dtStr, Qt::ISODate); +} + +bool EmailQSLBase::hasEmailBeenSentToCallsign(const QString &callsign, int excludeId) +{ + QSqlQuery q; + q.prepare(QStringLiteral("SELECT fields FROM contacts WHERE callsign = :cs AND id != :ex")); + q.bindValue(":cs", callsign.toUpper()); + q.bindValue(":ex", excludeId); + if (!q.exec()) + return false; + + while (q.next()) + { + const QByteArray raw = q.value(0).toByteArray(); + const QJsonObject fields = QJsonDocument::fromJson(raw).object(); + if (fields.contains(EMAIL_QSL_SENT_KEY)) + return true; + } + return false; +} + +void EmailQSLBase::recordEmailSent(int contactId, const QSqlRecord ¤tRecord) +{ + FCT_IDENTIFICATION; + + const QByteArray raw = currentRecord.value(QStringLiteral("fields")).toByteArray(); + QJsonObject fields = QJsonDocument::fromJson(raw).object(); + fields[EMAIL_QSL_SENT_KEY] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate); + + QSqlQuery q; + q.prepare(QStringLiteral("UPDATE contacts SET fields = :f WHERE id = :id")); + q.bindValue(":f", QJsonDocument(fields).toJson(QJsonDocument::Compact)); + q.bindValue(":id", contactId); + if (!q.exec()) + qCWarning(runtime) << "recordEmailSent update failed:" << q.lastError().text(); +} + +// --------------------------------------------------------------------------- +// EmailQSLBase — merge fields +// --------------------------------------------------------------------------- + +QString EmailQSLBase::fieldValue(const QString &key, const QSqlRecord &record) +{ + if (key == QLatin1String("CALLSIGN")) + return record.value(QStringLiteral("callsign")).toString().toUpper(); + + if (key == QLatin1String("QSO_DATE")) + { + const QDateTime dt = record.value(QStringLiteral("start_time")).toDateTime(); + return dt.isValid() ? dt.toUTC().toString(QStringLiteral("dd-MMM-yyyy")).toUpper() : QString(); + } + if (key == QLatin1String("QSO_DATE_ISO")) + { + const QDateTime dt = record.value(QStringLiteral("start_time")).toDateTime(); + return dt.isValid() ? dt.toUTC().toString(QStringLiteral("yyyyMMdd")) : QString(); + } + if (key == QLatin1String("TIME_ON")) + { + const QDateTime dt = record.value(QStringLiteral("start_time")).toDateTime(); + return dt.isValid() ? dt.toUTC().toString(QStringLiteral("HHmm")) : QString(); + } + if (key == QLatin1String("FREQ")) + { + bool ok; + double mhz = record.value(QStringLiteral("freq")).toDouble(&ok); + return ok ? QString::number(mhz, 'f', 3) : QString(); + } + if (key == QLatin1String("BAND")) return record.value(QStringLiteral("band")).toString().toUpper(); + if (key == QLatin1String("MODE")) return record.value(QStringLiteral("mode")).toString().toUpper(); + if (key == QLatin1String("SUBMODE")) return record.value(QStringLiteral("submode")).toString().toUpper(); + if (key == QLatin1String("RST_SENT")) return record.value(QStringLiteral("rst_sent")).toString(); + if (key == QLatin1String("RST_RCVD")) return record.value(QStringLiteral("rst_rcvd")).toString(); + if (key == QLatin1String("NAME")) + { + const QString n = record.value(QStringLiteral("name_intl")).toString(); + return n.isEmpty() ? record.value(QStringLiteral("name")).toString() : n; + } + if (key == QLatin1String("QTH")) + { + const QString q = record.value(QStringLiteral("qth_intl")).toString(); + return q.isEmpty() ? record.value(QStringLiteral("qth")).toString() : q; + } + if (key == QLatin1String("COUNTRY")) + { + const QString c = record.value(QStringLiteral("country_intl")).toString(); + return c.isEmpty() ? record.value(QStringLiteral("country")).toString() : c; + } + if (key == QLatin1String("GRIDSQUARE")) return record.value(QStringLiteral("gridsquare")).toString().toUpper(); + if (key == QLatin1String("DXCC")) return record.value(QStringLiteral("dxcc")).toString(); + if (key == QLatin1String("CQZ")) return record.value(QStringLiteral("cqz")).toString(); + if (key == QLatin1String("ITUZ")) return record.value(QStringLiteral("ituz")).toString(); + if (key == QLatin1String("TX_PWR")) return record.value(QStringLiteral("tx_pwr")).toString(); + if (key == QLatin1String("EMAIL")) return record.value(QStringLiteral("email")).toString(); + if (key == QLatin1String("MY_CALLSIGN")) return record.value(QStringLiteral("station_callsign")).toString().toUpper(); + if (key == QLatin1String("MY_GRIDSQUARE")) return record.value(QStringLiteral("my_gridsquare")).toString().toUpper(); + if (key == QLatin1String("OPERATOR")) return record.value(QStringLiteral("operator")).toString().toUpper(); + if (key == QLatin1String("COMMENT")) + { + const QString c = record.value(QStringLiteral("comment_intl")).toString(); + return c.isEmpty() ? record.value(QStringLiteral("comment")).toString() : c; + } + if (key == QLatin1String("SOTA_REF")) return record.value(QStringLiteral("sota_ref")).toString(); + if (key == QLatin1String("POTA_REF")) return record.value(QStringLiteral("pota_ref")).toString(); + if (key == QLatin1String("WWFF_REF")) return record.value(QStringLiteral("wwff_ref")).toString(); + if (key == QLatin1String("IOTA")) return record.value(QStringLiteral("iota")).toString(); + if (key == QLatin1String("SIG")) return record.value(QStringLiteral("sig_intl")).toString(); + if (key == QLatin1String("CONTEST_ID")) return record.value(QStringLiteral("contest_id")).toString(); + + // Fallback: try direct lowercase column name + const QString col = key.toLower(); + if (record.indexOf(col) >= 0) + return record.value(col).toString(); + + return QString(); +} + +QString EmailQSLBase::applyMergeFields(const QString &tmpl, const QSqlRecord &record) +{ + QString result = tmpl; + static const QRegularExpression rx(QStringLiteral("\\{([A-Z0-9_]+)\\}")); + QRegularExpressionMatchIterator it = rx.globalMatch(tmpl); + while (it.hasNext()) + { + const QRegularExpressionMatch m = it.next(); + const QString key = m.captured(1); + const QString value = fieldValue(key, record); + result.replace(QLatin1Char('{') + key + QLatin1Char('}'), value); + } + return result; +} + +QList EmailQSLBase::availableMergeFields() +{ + return { + { "CALLSIGN", QObject::tr("Contact callsign") }, + { "QSO_DATE", QObject::tr("QSO date (dd-MMM-yyyy)") }, + { "QSO_DATE_ISO", QObject::tr("QSO date (YYYYMMDD)") }, + { "TIME_ON", QObject::tr("QSO start time UTC (HHmm)") }, + { "FREQ", QObject::tr("Frequency (MHz)") }, + { "BAND", QObject::tr("Band") }, + { "MODE", QObject::tr("Mode") }, + { "SUBMODE", QObject::tr("Sub-mode") }, + { "RST_SENT", QObject::tr("RST sent") }, + { "RST_RCVD", QObject::tr("RST received") }, + { "NAME", QObject::tr("Contact name") }, + { "QTH", QObject::tr("Contact QTH") }, + { "COUNTRY", QObject::tr("Country") }, + { "GRIDSQUARE", QObject::tr("Grid square") }, + { "DXCC", QObject::tr("DXCC entity number") }, + { "CQZ", QObject::tr("CQ zone") }, + { "ITUZ", QObject::tr("ITU zone") }, + { "TX_PWR", QObject::tr("TX power") }, + { "EMAIL", QObject::tr("Contact email address") }, + { "MY_CALLSIGN", QObject::tr("My callsign") }, + { "MY_GRIDSQUARE", QObject::tr("My grid square") }, + { "OPERATOR", QObject::tr("Operator callsign") }, + { "COMMENT", QObject::tr("Comment") }, + { "SOTA_REF", QObject::tr("SOTA reference") }, + { "POTA_REF", QObject::tr("POTA reference") }, + { "WWFF_REF", QObject::tr("WWFF reference") }, + { "IOTA", QObject::tr("IOTA reference") }, + { "SIG", QObject::tr("Special interest group") }, + { "CONTEST_ID", QObject::tr("Contest ID") }, + }; +} + +// --------------------------------------------------------------------------- +// EmailQSLBase — card rendering +// --------------------------------------------------------------------------- + +QPixmap EmailQSLBase::renderCard(const QSqlRecord &record) +{ + FCT_IDENTIFICATION; + return renderCard(getCardImagePath(), record, getCardFieldOverlays()); +} + +QPixmap EmailQSLBase::renderCard(const QString &imagePath, + const QSqlRecord &record, + const QList &overlays) +{ + FCT_IDENTIFICATION; + + QPixmap pixmap(imagePath); + if (pixmap.isNull()) + { + qCWarning(runtime) << "renderCard: could not load image:" << imagePath; + return QPixmap(); + } + + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing); + painter.setRenderHint(QPainter::TextAntialiasing); + + for (const EmailQSLFieldOverlay &ov : overlays) + { + if (ov.type == QLatin1String("BOX")) + { + QColor fill(ov.fillColor); + fill.setAlphaF(ov.opacity / 100.0); + painter.setPen(QPen(QColor(ov.color), 1.5)); + painter.setBrush(fill); + painter.drawRoundedRect(ov.x, ov.y, ov.width, ov.height, + ov.cornerRadius, ov.cornerRadius); + + // Optional caption above the box + if (!ov.fieldName.isEmpty()) + { + QFont font(ov.fontFamily, ov.fontSize > 0 ? ov.fontSize : 11); + font.setBold(ov.bold); + painter.setFont(font); + painter.setPen(QColor(ov.color)); + painter.setBrush(Qt::NoBrush); + QFontMetrics fm(font); + painter.drawText(ov.x, ov.y - fm.descent() - 2, ov.fieldName); + } + } + else if (ov.type == QLatin1String("LABEL")) + { + // Render fieldName as literal static text (no merge substitution) + if (ov.fieldName.isEmpty()) + continue; + + QFont font(ov.fontFamily, ov.fontSize); + font.setBold(ov.bold); + font.setItalic(ov.italic); + painter.setFont(font); + painter.setPen(QColor(ov.color)); + painter.setBrush(Qt::NoBrush); + painter.drawText(ov.x, ov.y, ov.fieldName); + } + else // TEXT — merge-field substitution + { + const QString value = fieldValue(ov.fieldName, record); + if (value.isEmpty()) + continue; + + QFont font(ov.fontFamily, ov.fontSize); + font.setBold(ov.bold); + font.setItalic(ov.italic); + painter.setFont(font); + painter.setPen(QColor(ov.color)); + painter.setBrush(Qt::NoBrush); + painter.drawText(ov.x, ov.y, value); + } + } + painter.end(); + return pixmap; +} + +// --------------------------------------------------------------------------- +// SmtpWorker +// --------------------------------------------------------------------------- + +SmtpWorker::SmtpWorker(const QString &host, int port, int encryption, + const QString &username, const QString &password, + const QString &fromAddress, const QString &fromName, + const QString &toAddress, + const QString &subject, const QString &body, + const QByteArray &imageData, const QString &imageName, + QObject *parent) + : QObject(parent), + m_host(host), m_port(port), m_encryption(encryption), + m_username(username), m_password(password), + m_fromAddress(fromAddress), m_fromName(fromName), + m_toAddress(toAddress), + m_subject(subject), m_body(body), + m_imageData(imageData), m_imageName(imageName) +{ +} + +bool SmtpWorker::waitForResponse(QSslSocket *socket, QByteArray &out, int timeoutMs) +{ + out.clear(); + // Multi-line responses end with "NNN " (space after code); single with "NNN " + // Keep reading until we get a line without a dash after the code. + do { + if (!socket->waitForReadyRead(timeoutMs)) + return false; + out += socket->readAll(); + } while (out.size() > 3 && out[out.size()-2] != '\r' && + (out.size() < 4 || out[3] == '-')); + // More robust: check if last complete line starts with "NNN " (no dash) + // Parse the last CRLF-terminated line + const QList lines = out.split('\n'); + for (const QByteArray &line : lines) + { + if (line.length() >= 4 && line[3] == ' ') + return true; // found a final response line + if (line.length() >= 4 && line[3] == '-') + continue; // continuation line + } + return !out.isEmpty(); +} + +int SmtpWorker::responseCode(const QByteArray &response) +{ + // Find the last "NNN " line + const QList lines = response.split('\n'); + for (int i = lines.size() - 1; i >= 0; --i) + { + const QByteArray &line = lines.at(i).trimmed(); + if (line.length() >= 3) + { + bool ok; + int code = line.left(3).toInt(&ok); + if (ok) + return code; + } + } + return -1; +} + +bool SmtpWorker::sendCommand(QSslSocket *socket, const QByteArray &cmd, + int expectedCode, QByteArray &response) +{ + socket->write(cmd); + if (!socket->waitForBytesWritten(10000)) + return false; + if (!waitForResponse(socket, response)) + return false; + return responseCode(response) == expectedCode; +} + +QByteArray SmtpWorker::buildMimeMessage() +{ + const QString boundary = QStringLiteral("----QLogEmailQSL_%1") + .arg(QDateTime::currentMSecsSinceEpoch()); + + QByteArray msg; + + // Encode From display name safely + const QByteArray fromDisplay = ("\"" + m_fromName + "\" <" + m_fromAddress + ">").toUtf8(); + msg += "MIME-Version: 1.0\r\n"; + msg += "From: " + fromDisplay + "\r\n"; + msg += "To: <" + m_toAddress.toUtf8() + ">\r\n"; + // RFC 2047 encoded subject + msg += "Subject: =?UTF-8?B?" + m_subject.toUtf8().toBase64() + "?=\r\n"; + msg += "Content-Type: multipart/mixed; boundary=\"" + boundary.toUtf8() + "\"\r\n"; + msg += "\r\n"; + + // --- Text part --- + msg += "--" + boundary.toUtf8() + "\r\n"; + msg += "Content-Type: text/plain; charset=UTF-8\r\n"; + msg += "Content-Transfer-Encoding: base64\r\n"; + msg += "\r\n"; + const QByteArray bodyB64 = m_body.toUtf8().toBase64(); + for (int i = 0; i < bodyB64.size(); i += 76) + msg += bodyB64.mid(i, 76) + "\r\n"; + + // --- Image attachment (if present) --- + if (!m_imageData.isEmpty()) + { + msg += "\r\n--" + boundary.toUtf8() + "\r\n"; + const QString mimeType = m_imageName.endsWith(QLatin1String(".png"), Qt::CaseInsensitive) + ? QStringLiteral("image/png") + : QStringLiteral("image/jpeg"); + msg += "Content-Type: " + mimeType.toUtf8() + + "; name=\"" + m_imageName.toUtf8() + "\"\r\n"; + msg += "Content-Transfer-Encoding: base64\r\n"; + msg += "Content-Disposition: attachment; filename=\"" + + m_imageName.toUtf8() + "\"\r\n"; + msg += "\r\n"; + const QByteArray imgB64 = m_imageData.toBase64(); + for (int i = 0; i < imgB64.size(); i += 76) + msg += imgB64.mid(i, 76) + "\r\n"; + } + + msg += "\r\n--" + boundary.toUtf8() + "--\r\n"; + return msg; +} + +void SmtpWorker::run() +{ + FCT_IDENTIFICATION; + + QSslSocket socket; + socket.setProtocol(QSsl::AnyProtocol); + socket.setPeerVerifyMode(QSslSocket::VerifyNone); // tolerate self-signed certs + + // ---- Connect ---- + if (m_encryption == EmailQSLBase::ENCRYPTION_SSL_TLS) + { + socket.connectToHostEncrypted(m_host, static_cast(m_port)); + if (!socket.waitForEncrypted(15000)) + { + emit finished(false, tr("SSL/TLS connection failed: %1").arg(socket.errorString())); + return; + } + } + else + { + socket.connectToHost(m_host, static_cast(m_port)); + if (!socket.waitForConnected(15000)) + { + emit finished(false, tr("Could not connect to %1:%2 — %3") + .arg(m_host).arg(m_port).arg(socket.errorString())); + return; + } + } + + // ---- Read greeting (220) ---- + QByteArray resp; + if (!waitForResponse(&socket, resp) || responseCode(resp) != 220) + { + emit finished(false, tr("Server did not send a valid greeting (expected 220).")); + return; + } + + // ---- EHLO ---- + const QByteArray localHost = QHostInfo::localHostName().toUtf8(); + if (!sendCommand(&socket, "EHLO " + localHost + "\r\n", 250, resp)) + { + // Try HELO as fallback + if (!sendCommand(&socket, "HELO " + localHost + "\r\n", 250, resp)) + { + emit finished(false, tr("EHLO/HELO rejected by server.")); + return; + } + } + + // ---- STARTTLS upgrade ---- + if (m_encryption == EmailQSLBase::ENCRYPTION_STARTTLS) + { + if (!sendCommand(&socket, "STARTTLS\r\n", 220, resp)) + { + emit finished(false, tr("STARTTLS not accepted by server.")); + return; + } + socket.startClientEncryption(); + if (!socket.waitForEncrypted(15000)) + { + emit finished(false, tr("TLS handshake failed: %1").arg(socket.errorString())); + return; + } + // Re-EHLO after TLS negotiation + if (!sendCommand(&socket, "EHLO " + localHost + "\r\n", 250, resp)) + { + emit finished(false, tr("EHLO after STARTTLS rejected.")); + return; + } + } + + // ---- AUTH LOGIN ---- + if (!m_username.isEmpty()) + { + if (!sendCommand(&socket, "AUTH LOGIN\r\n", 334, resp)) + { + emit finished(false, tr("AUTH LOGIN not supported by server.")); + return; + } + if (!sendCommand(&socket, m_username.toUtf8().toBase64() + "\r\n", 334, resp)) + { + emit finished(false, tr("Username rejected by server.")); + return; + } + if (!sendCommand(&socket, m_password.toUtf8().toBase64() + "\r\n", 235, resp)) + { + emit finished(false, tr("Authentication failed — check your username and password.")); + return; + } + } + + // ---- MAIL FROM ---- + if (!sendCommand(&socket, "MAIL FROM:<" + m_fromAddress.toUtf8() + ">\r\n", 250, resp)) + { + emit finished(false, tr("MAIL FROM rejected: %1").arg(QString::fromUtf8(resp))); + return; + } + + // ---- RCPT TO ---- + if (!sendCommand(&socket, "RCPT TO:<" + m_toAddress.toUtf8() + ">\r\n", 250, resp)) + { + emit finished(false, tr("Recipient address not accepted: %1").arg(QString::fromUtf8(resp))); + return; + } + + // ---- DATA ---- + if (!sendCommand(&socket, "DATA\r\n", 354, resp)) + { + emit finished(false, tr("DATA command rejected.")); + return; + } + + // ---- Send message body ---- + QByteArray mime = buildMimeMessage(); + mime += "\r\n.\r\n"; + socket.write(mime); + socket.flush(); + + if (!waitForResponse(&socket, resp) || responseCode(resp) != 250) + { + emit finished(false, tr("Message rejected by server: %1").arg(QString::fromUtf8(resp))); + return; + } + + // ---- QUIT ---- + socket.write("QUIT\r\n"); + socket.waitForBytesWritten(5000); + socket.disconnectFromHost(); + socket.waitForDisconnected(5000); + + emit finished(true, QString()); +} + +// --------------------------------------------------------------------------- +// EmailQSLService +// --------------------------------------------------------------------------- + +EmailQSLService::EmailQSLService(QObject *parent) + : QObject(parent) +{ +} + +void EmailQSLService::sendEmailQSL(const QSqlRecord &record) +{ + FCT_IDENTIFICATION; + + // Render card + const QPixmap cardPixmap = EmailQSLBase::renderCard(record); + QByteArray imageData; + QString imageName; + if (!cardPixmap.isNull()) + { + QBuffer buf(&imageData); + buf.open(QIODevice::WriteOnly); + cardPixmap.save(&buf, "JPEG", 90); + imageName = QStringLiteral("qsl_card.jpg"); + } + + // Merge email fields + const QString subject = EmailQSLBase::applyMergeFields( + EmailQSLBase::getSubjectTemplate(), record); + const QString body = EmailQSLBase::applyMergeFields( + EmailQSLBase::getBodyTemplate(), record); + + const QString toAddress = record.value(QStringLiteral("email")).toString().trimmed(); + + QThread *thread = new QThread(this); + SmtpWorker *worker = new SmtpWorker( + EmailQSLBase::getSmtpHost(), + EmailQSLBase::getSmtpPort(), + EmailQSLBase::getSmtpEncryption(), + EmailQSLBase::getSmtpUsername(), + EmailQSLBase::getSmtpPassword(), + EmailQSLBase::getFromAddress(), + EmailQSLBase::getFromName(), + toAddress, + subject, body, + imageData, imageName); + + worker->moveToThread(thread); + + connect(thread, &QThread::started, worker, &SmtpWorker::run); + connect(worker, &SmtpWorker::finished, this, + [this](bool ok, const QString &msg) { emit sendFinished(ok, msg); }); + connect(worker, &SmtpWorker::finished, worker, &QObject::deleteLater); + connect(worker, &SmtpWorker::finished, thread, &QThread::quit); + connect(thread, &QThread::finished, thread, &QObject::deleteLater); + + thread->start(); +} + +void EmailQSLService::testConnection(const QString &host, int port, int encryption, + const QString &username, const QString &password) +{ + FCT_IDENTIFICATION; + + // Send a dummy message to a no-op address just to verify auth works — + // actually we just connect + EHLO + AUTH and then QUIT. + QThread *thread = new QThread(this); + SmtpWorker *worker = new SmtpWorker( + host, port, encryption, username, password, + username, QStringLiteral("Test"), + username, // to = from (won't actually be sent) + QStringLiteral("QLog connection test"), + QStringLiteral("Connection test only — no message will be sent."), + QByteArray(), QString()); + + worker->moveToThread(thread); + + connect(thread, &QThread::started, worker, &SmtpWorker::run); + connect(worker, &SmtpWorker::finished, this, + [this](bool ok, const QString &msg) { emit testFinished(ok, msg); }); + connect(worker, &SmtpWorker::finished, worker, &QObject::deleteLater); + connect(worker, &SmtpWorker::finished, thread, &QThread::quit); + connect(thread, &QThread::finished, thread, &QObject::deleteLater); + + thread->start(); +} diff --git a/service/emailqsl/EmailQSLService.h b/service/emailqsl/EmailQSLService.h new file mode 100644 index 00000000..12d9e8d8 --- /dev/null +++ b/service/emailqsl/EmailQSLService.h @@ -0,0 +1,169 @@ +#ifndef QLOG_SERVICE_EMAILQSLSERVICE_H +#define QLOG_SERVICE_EMAILQSLSERVICE_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/CredentialStore.h" + +// Describes a single overlay drawn on the QSL card image. +// type == "TEXT" → renders a merge-field value as text +// type == "BOX" → renders a filled rounded rectangle (label is optional caption) +struct EmailQSLFieldOverlay +{ + // Common + QString type = QStringLiteral("TEXT"); // "TEXT" or "BOX" + QString fieldName; // TEXT: merge key; BOX: optional caption label + int x = 0; + int y = 0; + + // TEXT-only + QString fontFamily = QStringLiteral("Arial"); + int fontSize = 14; + QString color = QStringLiteral("#000000"); // text color / BOX border color + bool bold = false; + bool italic = false; + + // BOX-only + int width = 120; + int height = 60; + QString fillColor = QStringLiteral("#FFFF99"); // box fill (no alpha — use opacity) + int opacity = 80; // fill opacity 0–100 + int cornerRadius = 8; + + QJsonObject toJson() const; + static EmailQSLFieldOverlay fromJson(const QJsonObject &obj); +}; + +// Static helpers for reading/writing all Email QSL settings. +// The SMTP password is kept in the secure credential store; +// everything else lives in QSettings under the "emailqsl/" group. +class EmailQSLBase : public SecureServiceBase +{ +public: + DECLARE_SECURE_SERVICE(EmailQSLBase) + static const QString SECURE_STORAGE_KEY; + + enum EncryptionType + { + ENCRYPTION_NONE = 0, + ENCRYPTION_SSL_TLS = 1, + ENCRYPTION_STARTTLS = 2 + }; + + // --- SMTP connection --- + static QString getSmtpHost(); + static void setSmtpHost(const QString &host); + static int getSmtpPort(); + static void setSmtpPort(int port); + static int getSmtpEncryption(); + static void setSmtpEncryption(int enc); + static QString getSmtpUsername(); + static void setSmtpUsername(const QString &username); + static QString getSmtpPassword(); + static void saveSmtpCredentials(const QString &username, const QString &password); + + // --- Envelope / headers --- + static QString getFromAddress(); + static void setFromAddress(const QString &addr); + static QString getFromName(); + static void setFromName(const QString &name); + static QString getSubjectTemplate(); + static void setSubjectTemplate(const QString &tmpl); + static QString getBodyTemplate(); + static void setBodyTemplate(const QString &tmpl); + + // --- QSL card image & overlays --- + static QString getCardImagePath(); + static void setCardImagePath(const QString &path); + static QList getCardFieldOverlays(); + static void setCardFieldOverlays(const QList &overlays); + + // --- Sent-tracking (stored in contacts.fields JSON) --- + static QDateTime getEmailSentDateTime(const QSqlRecord &record); + static bool hasEmailBeenSentToCallsign(const QString &callsign, int excludeId = -1); + static void recordEmailSent(int contactId, const QSqlRecord ¤tRecord); + + // --- Rendering helpers --- + // Full render using saved QSettings (used when sending). + static QPixmap renderCard(const QSqlRecord &record); + // Full render using an explicit image path and overlay list + // (used by settings preview so unsaved changes are shown). + static QPixmap renderCard(const QString &imagePath, + const QSqlRecord &record, + const QList &overlays); + static QString applyMergeFields(const QString &tmpl, const QSqlRecord &record); + + // Available merge keys (for display in settings UI) + struct MergeField { QString key; QString description; }; + static QList availableMergeFields(); + +private: + static QString fieldValue(const QString &key, const QSqlRecord &record); +}; + +// Worker object that runs the SMTP protocol on a background thread. +class SmtpWorker : public QObject +{ + Q_OBJECT +public: + explicit SmtpWorker(const QString &host, int port, int encryption, + const QString &username, const QString &password, + const QString &fromAddress, const QString &fromName, + const QString &toAddress, + const QString &subject, const QString &body, + const QByteArray &imageData, const QString &imageName, + QObject *parent = nullptr); + +public slots: + void run(); + +signals: + void finished(bool success, const QString &errorMessage); + +private: + bool waitForResponse(QSslSocket *socket, QByteArray &out, int timeoutMs = 15000); + int responseCode(const QByteArray &response); + bool sendCommand(QSslSocket *socket, const QByteArray &cmd, int expectedCode, + QByteArray &response); + QByteArray buildMimeMessage(); + + QString m_host; + int m_port; + int m_encryption; + QString m_username; + QString m_password; + QString m_fromAddress; + QString m_fromName; + QString m_toAddress; + QString m_subject; + QString m_body; + QByteArray m_imageData; + QString m_imageName; +}; + +// High-level service used by the UI. Call sendEmailQSL() and connect to +// sendFinished() for result notification. +class EmailQSLService : public QObject +{ + Q_OBJECT +public: + explicit EmailQSLService(QObject *parent = nullptr); + + void sendEmailQSL(const QSqlRecord &record); + void testConnection(const QString &host, int port, int encryption, + const QString &username, const QString &password); + +signals: + void sendFinished(bool success, const QString &message); + void testFinished(bool success, const QString &message); +}; + +#endif // QLOG_SERVICE_EMAILQSLSERVICE_H diff --git a/ui/EmailQSLDialog.cpp b/ui/EmailQSLDialog.cpp new file mode 100644 index 00000000..3eb4fe98 --- /dev/null +++ b/ui/EmailQSLDialog.cpp @@ -0,0 +1,234 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "EmailQSLDialog.h" +#include "ui_EmailQSLDialog.h" +#include "core/debug.h" + +MODULE_IDENTIFICATION("qlog.ui.emailqsldialog"); + +EmailQSLDialog::EmailQSLDialog(const QSqlRecord &record, QWidget *parent) + : QDialog(parent), + ui(new Ui::EmailQSLDialog), + m_record(record), + m_service(new EmailQSLService(this)) +{ + FCT_IDENTIFICATION; + + ui->setupUi(this); + + // Add action buttons next to Cancel + QPushButton *previewBtn = ui->buttonBox->addButton(tr("Preview Card…"), QDialogButtonBox::ActionRole); + connect(previewBtn, &QPushButton::clicked, this, &EmailQSLDialog::previewAndPrintCard); + + QPushButton *sendBtn = ui->buttonBox->addButton(tr("Send"), QDialogButtonBox::AcceptRole); + connect(sendBtn, &QPushButton::clicked, this, &EmailQSLDialog::sendClicked); + connect(m_service, &EmailQSLService::sendFinished, + this, &EmailQSLDialog::onSendFinished); + + populateInfo(); + buildWarnings(); +} + +EmailQSLDialog::~EmailQSLDialog() +{ + delete ui; +} + +void EmailQSLDialog::populateInfo() +{ + FCT_IDENTIFICATION; + + const QString callsign = m_record.value(QStringLiteral("callsign")).toString().toUpper(); + const QString email = m_record.value(QStringLiteral("email")).toString(); + const QDateTime dt = m_record.value(QStringLiteral("start_time")).toDateTime().toUTC(); + const QString band = m_record.value(QStringLiteral("band")).toString().toUpper(); + const QString mode = m_record.value(QStringLiteral("mode")).toString().toUpper(); + + ui->callValueLabel->setText(callsign.isEmpty() ? tr("(unknown)") : callsign); + ui->emailValueLabel->setText(email.isEmpty() + ? tr("No email address on record") + : email); + ui->dateValueLabel->setText(dt.isValid() ? dt.toString(Qt::RFC2822Date) : tr("(unknown)")); + ui->bandModeValueLabel->setText( + QString("%1 / %2").arg(band.isEmpty() ? QStringLiteral("?") : band, + mode.isEmpty() ? QStringLiteral("?") : mode)); + + // Subject / body previews + ui->subjectPreviewLabel->setText( + EmailQSLBase::applyMergeFields(EmailQSLBase::getSubjectTemplate(), m_record)); + ui->bodyPreviewEdit->setPlainText( + EmailQSLBase::applyMergeFields(EmailQSLBase::getBodyTemplate(), m_record)); + + // Card thumbnail + const QPixmap card = EmailQSLBase::renderCard(m_record); + if (!card.isNull()) + ui->cardPreviewLabel->setPixmap(card.scaled( + ui->cardPreviewLabel->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation)); + else + ui->cardPreviewLabel->setText(tr("No card image")); +} + +void EmailQSLDialog::buildWarnings() +{ + FCT_IDENTIFICATION; + + QStringList warnings; + + // 1) No email address + const QString emailAddr = m_record.value(QStringLiteral("email")).toString().trimmed(); + if (emailAddr.isEmpty()) + { + warnings << tr("This contact has no email address — the message cannot be sent."); + // Disable the Send button + for (QAbstractButton *btn : ui->buttonBox->buttons()) + if (ui->buttonBox->buttonRole(btn) == QDialogButtonBox::AcceptRole) + btn->setEnabled(false); + } + + // 2) Already sent for this specific QSO + const QDateTime prevSent = EmailQSLBase::getEmailSentDateTime(m_record); + if (prevSent.isValid()) + { + warnings << tr("An Email QSL was already sent for this QSO on %1 UTC.") + .arg(prevSent.toString(Qt::RFC2822Date)); + } + + // 3) Previously sent to the same callsign (different QSO) + const QString callsign = m_record.value(QStringLiteral("callsign")).toString().toUpper(); + const int id = m_record.value(QStringLiteral("id")).toInt(); + if (!callsign.isEmpty() && EmailQSLBase::hasEmailBeenSentToCallsign(callsign, id)) + { + warnings << tr("Note: you have previously sent an Email QSL to %1 for a different QSO.") + .arg(callsign); + } + + // 4) No SMTP host configured + if (EmailQSLBase::getSmtpHost().isEmpty()) + warnings << tr("SMTP server is not configured. Go to Settings → Email QSL."); + + if (!warnings.isEmpty()) + ui->warningLabel->setText(warnings.join(QStringLiteral("\n"))); +} + +void EmailQSLDialog::sendClicked() +{ + FCT_IDENTIFICATION; + + // Disable buttons while sending + for (QAbstractButton *btn : ui->buttonBox->buttons()) + btn->setEnabled(false); + + ui->statusLabel->setText(tr("Sending…")); + + m_service->sendEmailQSL(m_record); +} + +void EmailQSLDialog::onSendFinished(bool success, const QString &message) +{ + FCT_IDENTIFICATION; + + if (success) + { + // Record the sent timestamp in contacts.fields + const int id = m_record.value(QStringLiteral("id")).toInt(); + EmailQSLBase::recordEmailSent(id, m_record); + + // Auto-close — the log table refresh is handled by the finished() signal + accept(); + } + else + { + ui->statusLabel->setStyleSheet(QStringLiteral("color: red; font-weight: bold;")); + ui->statusLabel->setText(tr("Send failed: %1").arg(message)); + + // Rename Cancel → Close and disable Send so the user can only dismiss + for (QAbstractButton *btn : ui->buttonBox->buttons()) + { + const QDialogButtonBox::ButtonRole role = ui->buttonBox->buttonRole(btn); + if (role == QDialogButtonBox::RejectRole) + { + btn->setText(tr("Close")); + btn->setEnabled(true); + } + else + { + btn->setEnabled(false); // keep Send / Preview disabled + } + } + } +} + +void EmailQSLDialog::previewAndPrintCard() +{ + FCT_IDENTIFICATION; + + const QPixmap card = EmailQSLBase::renderCard(m_record); + if (card.isNull()) + { + QMessageBox::warning(this, tr("Preview"), + tr("Could not render the card image.\n" + "Please check Settings → Email QSL and make sure a card image is selected.")); + return; + } + + QDialog *dlg = new QDialog(this); + dlg->setAttribute(Qt::WA_DeleteOnClose); + dlg->setWindowTitle(tr("Card Preview — %1") + .arg(m_record.value(QStringLiteral("callsign")).toString().toUpper())); + QVBoxLayout *lay = new QVBoxLayout(dlg); + + QScrollArea *scroll = new QScrollArea(dlg); + QLabel *lbl = new QLabel(scroll); + const QPixmap scaled = card.scaled(QSize(800, 600), Qt::KeepAspectRatio, Qt::SmoothTransformation); + lbl->setPixmap(scaled); + lbl->adjustSize(); + scroll->setWidget(lbl); + scroll->setMinimumSize(qMin(scaled.width() + 20, 820), + qMin(scaled.height() + 20, 620)); + lay->addWidget(scroll); + + QDialogButtonBox *bb = new QDialogButtonBox(QDialogButtonBox::Close, dlg); + QPushButton *saveBtn = bb->addButton(tr("Save Card…"), QDialogButtonBox::ActionRole); + + connect(saveBtn, &QPushButton::clicked, dlg, [card, this]() + { + const QString callsign = m_record.value(QStringLiteral("callsign")).toString().toUpper(); + const QString rawDate = m_record.value(QStringLiteral("start_time")).toString(); + const QDateTime dt = QDateTime::fromString(rawDate, Qt::ISODate); + const QString date = dt.isValid() ? dt.toUTC().toString(QStringLiteral("yyyyMMdd")) : QStringLiteral("date"); + const QString time = dt.isValid() ? dt.toUTC().toString(QStringLiteral("HHmm")) : QStringLiteral("time"); + const QString band = m_record.value(QStringLiteral("band")).toString().toUpper().replace(QLatin1Char(' '), QLatin1Char('_')); + const QString mode = m_record.value(QStringLiteral("mode")).toString().toUpper(); + const QString defaultName = QStringLiteral("QSL_%1_%2_%3_%4_%5.png") + .arg(callsign, date, time, band, mode); + const QString defaultDir = QStandardPaths::writableLocation(QStandardPaths::PicturesLocation); + const QString path = QFileDialog::getSaveFileName( + this, + tr("Save QSL Card"), + defaultDir + QStringLiteral("/") + defaultName, + tr("PNG Image (*.png);;JPEG Image (*.jpg *.jpeg)")); + + if (path.isEmpty()) + return; + + if (!card.save(path)) + { + QMessageBox::warning(this, tr("Save Failed"), + tr("Could not save the card image to:\n%1").arg(path)); + } + }); + + connect(bb, &QDialogButtonBox::rejected, dlg, &QDialog::close); + lay->addWidget(bb); + dlg->show(); // non-modal +} diff --git a/ui/EmailQSLDialog.h b/ui/EmailQSLDialog.h new file mode 100644 index 00000000..c2a609bb --- /dev/null +++ b/ui/EmailQSLDialog.h @@ -0,0 +1,37 @@ +#ifndef QLOG_UI_EMAILQSLDIALOG_H +#define QLOG_UI_EMAILQSLDIALOG_H + +#include +#include + +#include "service/emailqsl/EmailQSLService.h" + +namespace Ui { +class EmailQSLDialog; +} + +// Shown before sending an Email QSL. Displays contact details, the rendered +// card thumbnail, warnings about duplicate sends, and lets the user confirm. +class EmailQSLDialog : public QDialog +{ + Q_OBJECT + +public: + explicit EmailQSLDialog(const QSqlRecord &record, QWidget *parent = nullptr); + ~EmailQSLDialog(); + +private slots: + void sendClicked(); + void onSendFinished(bool success, const QString &message); + void previewAndPrintCard(); + +private: + void populateInfo(); + void buildWarnings(); + + Ui::EmailQSLDialog *ui; + QSqlRecord m_record; + EmailQSLService *m_service; +}; + +#endif // QLOG_UI_EMAILQSLDIALOG_H diff --git a/ui/EmailQSLDialog.ui b/ui/EmailQSLDialog.ui new file mode 100644 index 00000000..1489fbae --- /dev/null +++ b/ui/EmailQSLDialog.ui @@ -0,0 +1,135 @@ + + + EmailQSLDialog + + + 00520500 + + + Send Email QSL Card + + + + + + + + + 200130 + 260170 + QFrame::Box + Qt::AlignCenter + No card image + true + + + + + + QSO Details + + + Callsign: + + + + + + To address: + + + + + true + + + + Date / Time: + + + + + + Band / Mode: + + + + + + + + + + + + + + + true + + QLabel { color: #cc6600; font-weight: bold; } + + + + + + + + Subject + + + + + true + + + + + + + + + + Email Body (preview) + + + + true + 0100 + + + + + + + + + + + Qt::AlignCenter + + + + + + + + QDialogButtonBox::Cancel + + + + + + + + + + buttonBoxrejected() + EmailQSLDialogreject() + + 260480 + 260240 + + + + diff --git a/ui/EmailQSLSettingsWidget.cpp b/ui/EmailQSLSettingsWidget.cpp new file mode 100644 index 00000000..14e0127e --- /dev/null +++ b/ui/EmailQSLSettingsWidget.cpp @@ -0,0 +1,1007 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "EmailQSLSettingsWidget.h" +#include "ui_EmailQSLSettingsWidget.h" +#include "service/emailqsl/EmailQSLService.h" +#include "ui/component/CardEditorWidget.h" +#include "core/debug.h" + +MODULE_IDENTIFICATION("qlog.ui.emailqslsettingswidget"); + +// --------------------------------------------------------------------------- +// Column indices for the overlay table +// TEXT columns: TYPE FIELD X Y FONT SIZE COLOR BOLD ITALIC (BOX cols greyed) +// BOX columns: TYPE FIELD X Y W H FILL OPACITY RADIUS BORDER +// We use one wide model and show/grey non-applicable cells per row. +// --------------------------------------------------------------------------- +enum OverlayCol +{ + OC_TYPE = 0, + OC_FIELD = 1, // TEXT: merge key; BOX: optional caption + OC_X = 2, + OC_Y = 3, + OC_FONT = 4, // TEXT only + OC_SIZE = 5, // TEXT only (also used for BOX caption font size) + OC_COLOR = 6, // TEXT: text color; BOX: border color + OC_BOLD = 7, // TEXT only + OC_ITALIC = 8, // TEXT only + OC_W = 9, // BOX only + OC_H = 10, // BOX only + OC_FILL = 11, // BOX only + OC_OPACITY = 12, // BOX only + OC_RADIUS = 13, // BOX only + OC_COUNT = 14 +}; + +// --------------------------------------------------------------------------- +// Local delegate: merge-field combobox for the Field column +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Delegate: Type combo (TEXT / BOX) +// --------------------------------------------------------------------------- +class TypeDelegate : public QStyledItemDelegate +{ +public: + using QStyledItemDelegate::QStyledItemDelegate; + QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem &, + const QModelIndex &) const override + { + QComboBox *cb = new QComboBox(parent); + cb->addItem(QStringLiteral("TEXT")); + cb->addItem(QStringLiteral("BOX")); + cb->addItem(QStringLiteral("LABEL")); + return cb; + } + void setEditorData(QWidget *editor, const QModelIndex &index) const override + { + QComboBox *cb = static_cast(editor); + cb->setCurrentIndex(cb->findText(index.data(Qt::DisplayRole).toString())); + } + void setModelData(QWidget *editor, QAbstractItemModel *model, + const QModelIndex &index) const override + { + model->setData(index, static_cast(editor)->currentText(), Qt::EditRole); + } + void updateEditorGeometry(QWidget *editor, const QStyleOptionViewItem &option, + const QModelIndex &) const override + { editor->setGeometry(option.rect); } +}; + +// --------------------------------------------------------------------------- +// Delegate: merge-field combobox (TEXT Field column) +// --------------------------------------------------------------------------- +class MergeFieldDelegate : public QStyledItemDelegate +{ +public: + using QStyledItemDelegate::QStyledItemDelegate; + QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem &, + const QModelIndex &) const override + { + QComboBox *cb = new QComboBox(parent); + cb->setEditable(true); // also allow free text (for BOX captions) + for (const EmailQSLBase::MergeField &f : EmailQSLBase::availableMergeFields()) + cb->addItem(f.key); + return cb; + } + void setEditorData(QWidget *editor, const QModelIndex &index) const override + { + QComboBox *cb = static_cast(editor); + const QString v = index.data(Qt::DisplayRole).toString(); + const int idx = cb->findText(v); + if (idx >= 0) cb->setCurrentIndex(idx); + else cb->setEditText(v); + } + void setModelData(QWidget *editor, QAbstractItemModel *model, + const QModelIndex &index) const override + { + model->setData(index, static_cast(editor)->currentText(), Qt::EditRole); + } + void updateEditorGeometry(QWidget *editor, const QStyleOptionViewItem &option, + const QModelIndex &) const override + { editor->setGeometry(option.rect); } +}; + +// --------------------------------------------------------------------------- +// Delegate: QFontComboBox for the Font column +// --------------------------------------------------------------------------- +class FontFamilyDelegate : public QStyledItemDelegate +{ +public: + using QStyledItemDelegate::QStyledItemDelegate; + QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem &, + const QModelIndex &) const override + { return new QFontComboBox(parent); } + void setEditorData(QWidget *editor, const QModelIndex &index) const override + { + static_cast(editor)->setCurrentFont( + QFont(index.data(Qt::DisplayRole).toString())); + } + void setModelData(QWidget *editor, QAbstractItemModel *model, + const QModelIndex &index) const override + { + model->setData(index, + static_cast(editor)->currentFont().family(), Qt::EditRole); + } + void updateEditorGeometry(QWidget *editor, const QStyleOptionViewItem &option, + const QModelIndex &) const override + { editor->setGeometry(option.rect); } +}; + +// --------------------------------------------------------------------------- +// Delegate: colored swatch — double-click opens QColorDialog +// --------------------------------------------------------------------------- +class ColorSwatchDelegate : public QStyledItemDelegate +{ +public: + using QStyledItemDelegate::QStyledItemDelegate; + + void paint(QPainter *painter, const QStyleOptionViewItem &option, + const QModelIndex &index) const override + { + const QString hex = index.data(Qt::DisplayRole).toString(); + const QColor c(hex.isEmpty() ? QStringLiteral("#000000") : hex); + const QRect r = option.rect.adjusted(2, 2, -2, -2); + painter->fillRect(r, c); + painter->setPen(QPen(Qt::gray, 1)); + painter->drawRect(r); + painter->setPen(c.lightness() > 128 ? Qt::black : Qt::white); + QFont f = painter->font(); f.setPointSize(8); painter->setFont(f); + painter->drawText(option.rect, Qt::AlignCenter, hex); + } + + QWidget *createEditor(QWidget *, const QStyleOptionViewItem &, + const QModelIndex &) const override { return nullptr; } + + bool editorEvent(QEvent *event, QAbstractItemModel *model, + const QStyleOptionViewItem &, const QModelIndex &index) override + { + if (event->type() != QEvent::MouseButtonDblClick) return false; + const QString cur = index.data(Qt::DisplayRole).toString(); + const QColor chosen = QColorDialog::getColor( + QColor(cur.isEmpty() ? QStringLiteral("#000000") : cur), + nullptr, tr("Choose Color")); + if (chosen.isValid()) + model->setData(index, chosen.name(), Qt::EditRole); + return true; + } +}; + +// --------------------------------------------------------------------------- +// Delegate: compact QSpinBox — editor width constrained to cell +// --------------------------------------------------------------------------- +class SpinBoxDelegate : public QStyledItemDelegate +{ + int m_min, m_max; +public: + SpinBoxDelegate(int min, int max, QObject *parent) + : QStyledItemDelegate(parent), m_min(min), m_max(max) {} + + QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem &option, + const QModelIndex &) const override + { + QSpinBox *sb = new QSpinBox(parent); + sb->setRange(m_min, m_max); + // Constrain width so the spinbox doesn't expand beyond the cell + sb->setFixedWidth(option.rect.width()); + sb->setButtonSymbols(QAbstractSpinBox::UpDownArrows); + return sb; + } + void setEditorData(QWidget *editor, const QModelIndex &index) const override + { + static_cast(editor)->setValue(index.data(Qt::DisplayRole).toInt()); + } + void setModelData(QWidget *editor, QAbstractItemModel *model, + const QModelIndex &index) const override + { + model->setData(index, static_cast(editor)->value(), Qt::EditRole); + } + void updateEditorGeometry(QWidget *editor, const QStyleOptionViewItem &option, + const QModelIndex &) const override + { editor->setGeometry(option.rect); } +}; + +// --------------------------------------------------------------------------- +// SMTP presets +// --------------------------------------------------------------------------- +struct SmtpPreset +{ + QString label; + QString host; + int port; + int encryption; + QString hint; +}; + +static QList smtpPresets() +{ + return { + { QObject::tr("-- Select preset --"), {}, 587, 2, {} }, + { QStringLiteral("Gmail"), + QStringLiteral("smtp.gmail.com"), 587, 2, + QObject::tr("Gmail: go to myaccount.google.com → Security → 2-Step Verification → App Passwords.\n" + "Create an App Password for 'Mail'. Use the 16-character code as your password here.") }, + { QStringLiteral("Gmail (SSL/TLS port 465)"), + QStringLiteral("smtp.gmail.com"), 465, 1, + QObject::tr("Gmail SSL variant (port 465). Use your Gmail App Password.") }, + { QStringLiteral("Outlook / Hotmail / Microsoft 365"), + QStringLiteral("smtp-mail.outlook.com"), 587, 2, + QObject::tr("Outlook: use your full Microsoft email and password.\n" + "If MFA is on, generate an App Password at account.microsoft.com → Security.") }, + { QStringLiteral("Yahoo Mail"), + QStringLiteral("smtp.mail.yahoo.com"), 587, 2, + QObject::tr("Yahoo: go to Account Security and click 'Generate app password'.") }, + { QStringLiteral("iCloud Mail"), + QStringLiteral("smtp.mail.me.com"), 587, 2, + QObject::tr("iCloud: generate an App-Specific Password at appleid.apple.com → Sign-In and Security.") }, + { QStringLiteral("Zoho Mail"), + QStringLiteral("smtp.zoho.com"), 587, 2, + QObject::tr("Zoho: enable SMTP in Zoho Mail Settings → Mail Accounts → IMAP/POP/SMTP.") }, + { QStringLiteral("Custom"), {}, 587, 2, {} }, + }; +} + +// --------------------------------------------------------------------------- +// Helper — build a dummy QSqlRecord for previewing merge fields +// --------------------------------------------------------------------------- +static QSqlRecord buildDummyRecord() +{ + static const QStringList cols = { + "callsign","start_time","freq","band","mode","submode", + "rst_sent","rst_rcvd","name_intl","name","qth_intl","qth", + "country_intl","country","gridsquare","dxcc","cqz","ituz", + "tx_pwr","email","station_callsign","my_gridsquare","operator", + "comment_intl","comment","sota_ref","pota_ref","wwff_ref", + "iota","sig_intl","contest_id" + }; + QSqlRecord r; + for (const QString &c : cols) + { + QSqlField f(c, QMetaType::fromType()); + r.append(f); + } + r.setValue("callsign", QStringLiteral("W1AW")); + r.setValue("start_time", QDateTime::currentDateTimeUtc().toString(Qt::ISODate)); + r.setValue("freq", QStringLiteral("14.225")); + r.setValue("band", QStringLiteral("20M")); + r.setValue("mode", QStringLiteral("SSB")); + r.setValue("rst_sent", QStringLiteral("59")); + r.setValue("rst_rcvd", QStringLiteral("59")); + r.setValue("name_intl", QStringLiteral("ARRL HQ")); + r.setValue("station_callsign", QStringLiteral("AA5SH")); + r.setValue("my_gridsquare", QStringLiteral("EM20")); + r.setValue("gridsquare", QStringLiteral("FN31")); + r.setValue("country_intl", QStringLiteral("United States")); + return r; +} + +// --------------------------------------------------------------------------- +// Helper — build a default overlay +// --------------------------------------------------------------------------- +static EmailQSLFieldOverlay makeOverlay(const QString &field, + int x, int y, + int fontSize, + bool bold, + const QString &color = QStringLiteral("#000000"), + const QString &font = QStringLiteral("Arial")) +{ + EmailQSLFieldOverlay ov; + ov.fieldName = field; + ov.x = x; + ov.y = y; + ov.fontSize = fontSize; + ov.bold = bold; + ov.fontFamily = font; + ov.color = color; + return ov; +} + +// --------------------------------------------------------------------------- +// EmailQSLSettingsWidget +// --------------------------------------------------------------------------- + +EmailQSLSettingsWidget::EmailQSLSettingsWidget(QWidget *parent) + : QWidget(parent), + ui(new Ui::EmailQSLSettingsWidget), + m_overlayModel(new QStandardItemModel(0, OC_COUNT, this)), + m_testService(new EmailQSLService(this)), + m_syncing(false) +{ + FCT_IDENTIFICATION; + + ui->setupUi(this); + + // ---- Overlay table ---- + m_overlayModel->setHorizontalHeaderLabels({ + tr("Type"), tr("Field / Caption"), + tr("X"), tr("Y"), + tr("Font"), tr("Pt"), + tr("Color"), tr("B"), tr("I"), + tr("W"), tr("H"), tr("Fill"), tr("Opacity%"), tr("Radius") + }); + ui->overlayTableView->setModel(m_overlayModel); + + // Delegates + auto *spinCoord = new SpinBoxDelegate(0, 99999, this); + auto *spinSz = new SpinBoxDelegate(4, 300, this); + auto *spinDim = new SpinBoxDelegate(1, 99999, this); + auto *spinOpac = new SpinBoxDelegate(0, 100, this); + auto *spinRadius = new SpinBoxDelegate(0, 500, this); + + ui->overlayTableView->setItemDelegateForColumn(OC_TYPE, new TypeDelegate(this)); + ui->overlayTableView->setItemDelegateForColumn(OC_FIELD, new MergeFieldDelegate(this)); + ui->overlayTableView->setItemDelegateForColumn(OC_X, spinCoord); + ui->overlayTableView->setItemDelegateForColumn(OC_Y, spinCoord); + ui->overlayTableView->setItemDelegateForColumn(OC_FONT, new FontFamilyDelegate(this)); + ui->overlayTableView->setItemDelegateForColumn(OC_SIZE, spinSz); + ui->overlayTableView->setItemDelegateForColumn(OC_COLOR, new ColorSwatchDelegate(this)); + ui->overlayTableView->setItemDelegateForColumn(OC_W, spinDim); + ui->overlayTableView->setItemDelegateForColumn(OC_H, spinDim); + ui->overlayTableView->setItemDelegateForColumn(OC_FILL, new ColorSwatchDelegate(this)); + ui->overlayTableView->setItemDelegateForColumn(OC_OPACITY, spinOpac); + ui->overlayTableView->setItemDelegateForColumn(OC_RADIUS, spinRadius); + + // Column widths — fixed-width numeric columns stay compact + auto *hdr = ui->overlayTableView->horizontalHeader(); + hdr->setSectionResizeMode(OC_TYPE, QHeaderView::ResizeToContents); + hdr->setSectionResizeMode(OC_FIELD, QHeaderView::Stretch); + hdr->setSectionResizeMode(OC_X, QHeaderView::Fixed); + hdr->setSectionResizeMode(OC_Y, QHeaderView::Fixed); + hdr->setSectionResizeMode(OC_FONT, QHeaderView::Stretch); + hdr->setSectionResizeMode(OC_SIZE, QHeaderView::Fixed); + hdr->setSectionResizeMode(OC_COLOR, QHeaderView::Fixed); + hdr->setSectionResizeMode(OC_BOLD, QHeaderView::Fixed); + hdr->setSectionResizeMode(OC_ITALIC, QHeaderView::Fixed); + hdr->setSectionResizeMode(OC_W, QHeaderView::Fixed); + hdr->setSectionResizeMode(OC_H, QHeaderView::Fixed); + hdr->setSectionResizeMode(OC_FILL, QHeaderView::Fixed); + hdr->setSectionResizeMode(OC_OPACITY, QHeaderView::Fixed); + hdr->setSectionResizeMode(OC_RADIUS, QHeaderView::Fixed); + // Set pixel widths for fixed columns + ui->overlayTableView->setColumnWidth(OC_X, 55); + ui->overlayTableView->setColumnWidth(OC_Y, 55); + ui->overlayTableView->setColumnWidth(OC_SIZE, 40); + ui->overlayTableView->setColumnWidth(OC_COLOR, 60); + ui->overlayTableView->setColumnWidth(OC_BOLD, 24); + ui->overlayTableView->setColumnWidth(OC_ITALIC, 24); + ui->overlayTableView->setColumnWidth(OC_W, 55); + ui->overlayTableView->setColumnWidth(OC_H, 55); + ui->overlayTableView->setColumnWidth(OC_FILL, 60); + ui->overlayTableView->setColumnWidth(OC_OPACITY, 55); + ui->overlayTableView->setColumnWidth(OC_RADIUS, 50); + + // ---- Merge field reference table ---- + const auto fields = EmailQSLBase::availableMergeFields(); + ui->mergeFieldRefTable->setRowCount(fields.size()); + for (int i = 0; i < fields.size(); ++i) + { + ui->mergeFieldRefTable->setItem( + i, 0, new QTableWidgetItem(QLatin1Char('{') + fields[i].key + QLatin1Char('}'))); + ui->mergeFieldRefTable->setItem( + i, 1, new QTableWidgetItem(fields[i].description)); + } + ui->mergeFieldRefTable->horizontalHeader()->setSectionResizeMode(0, QHeaderView::ResizeToContents); + ui->mergeFieldRefTable->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch); + + // ---- Merge field combos (subject/body insert) ---- + for (const EmailQSLBase::MergeField &f : fields) + { + const QString display = QLatin1Char('{') + f.key + QLatin1Char('}'); + ui->bodyFieldCombo->addItem(display); + ui->subjectFieldCombo->addItem(display); + } + + // ---- SMTP presets ---- + ui->smtpPresetCombo->blockSignals(true); + for (const SmtpPreset &p : smtpPresets()) + ui->smtpPresetCombo->addItem(p.label); + ui->smtpPresetCombo->blockSignals(false); + + // ---- Connections ---- + connect(ui->browseCardImageButton, &QPushButton::clicked, + this, &EmailQSLSettingsWidget::browseCardImage); + connect(ui->cardImagePathEdit, &QLineEdit::textChanged, + this, &EmailQSLSettingsWidget::onCardImageChanged); + + connect(ui->smtpPresetCombo, QOverload::of(&QComboBox::currentIndexChanged), + this, &EmailQSLSettingsWidget::onSmtpPresetChanged); + connect(ui->testConnectionButton, &QPushButton::clicked, + this, &EmailQSLSettingsWidget::testConnection); + connect(m_testService, &EmailQSLService::testFinished, + this, &EmailQSLSettingsWidget::onTestFinished); + + connect(ui->addOverlayButton, &QPushButton::clicked, + this, &EmailQSLSettingsWidget::addOverlay); + connect(ui->addBoxButton, &QPushButton::clicked, + this, &EmailQSLSettingsWidget::addBox); + connect(ui->addLabelButton, &QPushButton::clicked, + this, &EmailQSLSettingsWidget::addLabel); + connect(ui->addDefaultFieldsButton, &QPushButton::clicked, + this, &EmailQSLSettingsWidget::addDefaultOverlays); + connect(ui->removeOverlayButton, &QPushButton::clicked, + this, &EmailQSLSettingsWidget::removeOverlay); + connect(ui->previewCardButton, &QPushButton::clicked, + this, &EmailQSLSettingsWidget::previewCard); + + connect(ui->insertBodyFieldButton, &QPushButton::clicked, + this, &EmailQSLSettingsWidget::insertMergeFieldBody); + connect(ui->insertSubjectFieldButton, &QPushButton::clicked, + this, &EmailQSLSettingsWidget::insertMergeFieldSubject); + + // Table ↔ editor bidirectional sync + connect(m_overlayModel, &QStandardItemModel::dataChanged, + this, &EmailQSLSettingsWidget::onTableDataChanged); + connect(m_overlayModel, &QStandardItemModel::rowsRemoved, + this, &EmailQSLSettingsWidget::onTableDataChanged); + + connect(ui->cardEditorWidget, &CardEditorWidget::overlayPositionChanged, + this, &EmailQSLSettingsWidget::onEditorPositionChanged); + connect(ui->cardEditorWidget, &CardEditorWidget::overlaySizeChanged, + this, &EmailQSLSettingsWidget::onEditorSizeChanged); + connect(ui->cardEditorWidget, &CardEditorWidget::overlaySelected, + this, &EmailQSLSettingsWidget::onEditorOverlaySelected); + + connect(ui->overlayTableView->selectionModel(), + &QItemSelectionModel::currentRowChanged, + this, [this](const QModelIndex ¤t, const QModelIndex &) { + ui->cardEditorWidget->setSelectedIndex( + current.isValid() ? current.row() : -1); + ui->removeOverlayButton->setEnabled(current.isValid()); + }); +} + +EmailQSLSettingsWidget::~EmailQSLSettingsWidget() +{ + delete ui; +} + +// --------------------------------------------------------------------------- +// readSettings / writeSettings +// --------------------------------------------------------------------------- + +void EmailQSLSettingsWidget::readSettings() +{ + FCT_IDENTIFICATION; + + ui->smtpHostEdit->setText(EmailQSLBase::getSmtpHost()); + ui->smtpPortSpin->setValue(EmailQSLBase::getSmtpPort()); + ui->encryptionCombo->setCurrentIndex(EmailQSLBase::getSmtpEncryption()); + ui->smtpUsernameEdit->setText(EmailQSLBase::getSmtpUsername()); + ui->smtpPasswordEdit->setText(EmailQSLBase::getSmtpPassword()); + ui->fromAddressEdit->setText(EmailQSLBase::getFromAddress()); + ui->fromNameEdit->setText(EmailQSLBase::getFromName()); + ui->subjectTemplateEdit->setText(EmailQSLBase::getSubjectTemplate()); + ui->bodyTemplateEdit->setPlainText(EmailQSLBase::getBodyTemplate()); + ui->cardImagePathEdit->setText(EmailQSLBase::getCardImagePath()); + + m_overlays = EmailQSLBase::getCardFieldOverlays(); + listToOverlayTable(); + + // Trigger image load into the editor (textChanged may not fire if value unchanged) + onCardImageChanged(ui->cardImagePathEdit->text()); +} + +void EmailQSLSettingsWidget::writeSettings() +{ + FCT_IDENTIFICATION; + + EmailQSLBase::setSmtpHost(ui->smtpHostEdit->text().trimmed()); + EmailQSLBase::setSmtpPort(ui->smtpPortSpin->value()); + EmailQSLBase::setSmtpEncryption(ui->encryptionCombo->currentIndex()); + EmailQSLBase::saveSmtpCredentials(ui->smtpUsernameEdit->text().trimmed(), + ui->smtpPasswordEdit->text()); + EmailQSLBase::setFromAddress(ui->fromAddressEdit->text().trimmed()); + EmailQSLBase::setFromName(ui->fromNameEdit->text().trimmed()); + EmailQSLBase::setSubjectTemplate(ui->subjectTemplateEdit->text()); + EmailQSLBase::setBodyTemplate(ui->bodyTemplateEdit->toPlainText()); + EmailQSLBase::setCardImagePath(ui->cardImagePathEdit->text().trimmed()); + + overlayTableToList(); + EmailQSLBase::setCardFieldOverlays(m_overlays); +} + +// --------------------------------------------------------------------------- +// Overlay table ↔ m_overlays ↔ card editor +// --------------------------------------------------------------------------- + +// Helper — make a greyed-out non-editable placeholder for N/A cells +static QStandardItem *naItem() +{ + QStandardItem *si = new QStandardItem(QStringLiteral("—")); + si->setFlags(si->flags() & ~Qt::ItemIsEditable); + si->setForeground(QColor(0xbb, 0xbb, 0xbb)); + si->setTextAlignment(Qt::AlignCenter); + return si; +} + +void EmailQSLSettingsWidget::listToOverlayTable() +{ + // Guard against re-entrant onTableDataChanged() firing during setRowCount(0) + // which would wipe m_overlays before we finish rebuilding the table. + m_syncing = true; + m_overlayModel->setRowCount(0); + for (const EmailQSLFieldOverlay &ov : m_overlays) + { + const bool isBox = (ov.type == QLatin1String("BOX")); + + auto item = [](const QString &text) { return new QStandardItem(text); }; + auto checkItem = [](bool checked) { + QStandardItem *si = new QStandardItem(); + si->setCheckable(true); + si->setCheckState(checked ? Qt::Checked : Qt::Unchecked); + si->setTextAlignment(Qt::AlignCenter); + return si; + }; + + QList row; + row << item(ov.type); // OC_TYPE + row << item(ov.fieldName); // OC_FIELD + row << item(QString::number(ov.x)); // OC_X + row << item(QString::number(ov.y)); // OC_Y + // TEXT-only + row << (isBox ? naItem() : item(ov.fontFamily)); // OC_FONT + row << (isBox && ov.fieldName.isEmpty() + ? naItem() : item(QString::number(ov.fontSize))); // OC_SIZE + row << item(ov.color); // OC_COLOR (border/text) + row << (isBox ? naItem() : checkItem(ov.bold)); // OC_BOLD + row << (isBox ? naItem() : checkItem(ov.italic)); // OC_ITALIC + // BOX-only + row << (isBox ? item(QString::number(ov.width)) : naItem()); // OC_W + row << (isBox ? item(QString::number(ov.height)) : naItem()); // OC_H + row << (isBox ? item(ov.fillColor) : naItem()); // OC_FILL + row << (isBox ? item(QString::number(ov.opacity)): naItem()); // OC_OPACITY + row << (isBox ? item(QString::number(ov.cornerRadius)) : naItem()); // OC_RADIUS + + m_overlayModel->appendRow(row); + } + m_syncing = false; + ui->cardEditorWidget->setOverlays(m_overlays); +} + +void EmailQSLSettingsWidget::overlayTableToList() +{ + m_overlays.clear(); + for (int r = 0; r < m_overlayModel->rowCount(); ++r) + { + EmailQSLFieldOverlay ov; + auto txt = [&](int col) { return m_overlayModel->item(r, col)->text(); }; + + ov.type = txt(OC_TYPE); + ov.fieldName = txt(OC_FIELD); + ov.x = txt(OC_X).toInt(); + ov.y = txt(OC_Y).toInt(); + ov.color = txt(OC_COLOR); + + if (ov.type == QLatin1String("BOX")) + { + ov.fontFamily = QStringLiteral("Arial"); + ov.fontSize = txt(OC_SIZE) == QStringLiteral("—") ? 11 : txt(OC_SIZE).toInt(); + ov.width = txt(OC_W).toInt(); + ov.height = txt(OC_H).toInt(); + ov.fillColor = txt(OC_FILL); + ov.opacity = txt(OC_OPACITY).toInt(); + ov.cornerRadius = txt(OC_RADIUS).toInt(); + } + else + { + ov.fontFamily = txt(OC_FONT); + ov.fontSize = txt(OC_SIZE).toInt(); + ov.bold = m_overlayModel->item(r, OC_BOLD)->checkState() == Qt::Checked; + ov.italic = m_overlayModel->item(r, OC_ITALIC)->checkState() == Qt::Checked; + } + m_overlays.append(ov); + } +} + +void EmailQSLSettingsWidget::onTableDataChanged() +{ + FCT_IDENTIFICATION; + + if (m_syncing) return; + m_syncing = true; + overlayTableToList(); + ui->cardEditorWidget->setOverlays(m_overlays); + m_syncing = false; +} + +void EmailQSLSettingsWidget::onEditorPositionChanged(int index, int x, int y) +{ + FCT_IDENTIFICATION; + + if (m_syncing || index < 0 || index >= m_overlayModel->rowCount()) return; + m_syncing = true; + m_overlayModel->item(index, OC_X)->setText(QString::number(x)); + m_overlayModel->item(index, OC_Y)->setText(QString::number(y)); + if (index < m_overlays.size()) + { m_overlays[index].x = x; m_overlays[index].y = y; } + m_syncing = false; +} + +void EmailQSLSettingsWidget::onEditorSizeChanged(int index, int w, int h) +{ + FCT_IDENTIFICATION; + + if (m_syncing || index < 0 || index >= m_overlayModel->rowCount()) return; + m_syncing = true; + auto *wi = m_overlayModel->item(index, OC_W); + auto *hi = m_overlayModel->item(index, OC_H); + if (wi && wi->text() != QStringLiteral("—")) wi->setText(QString::number(w)); + if (hi && hi->text() != QStringLiteral("—")) hi->setText(QString::number(h)); + if (index < m_overlays.size()) + { m_overlays[index].width = w; m_overlays[index].height = h; } + m_syncing = false; +} + +void EmailQSLSettingsWidget::onEditorOverlaySelected(int index) +{ + if (index >= 0) + ui->overlayTableView->selectRow(index); + else + ui->overlayTableView->clearSelection(); + ui->removeOverlayButton->setEnabled(index >= 0); +} + +// --------------------------------------------------------------------------- +// Slots: image +// --------------------------------------------------------------------------- + +void EmailQSLSettingsWidget::browseCardImage() +{ + FCT_IDENTIFICATION; + + const QString path = QFileDialog::getOpenFileName( + this, tr("Select QSL Card Image"), + ui->cardImagePathEdit->text(), + tr("Images (*.jpg *.jpeg *.png *.bmp);;All files (*)")); + if (!path.isEmpty()) + ui->cardImagePathEdit->setText(path); +} + +void EmailQSLSettingsWidget::onCardImageChanged(const QString &path) +{ + FCT_IDENTIFICATION; + + QPixmap pm(path); + ui->cardEditorWidget->setImage(pm); +} + +// --------------------------------------------------------------------------- +// Slots: overlays +// --------------------------------------------------------------------------- + +void EmailQSLSettingsWidget::addOverlay() +{ + FCT_IDENTIFICATION; + + // Default position: top-left area of image (or 50,50 if no image) + const QPixmap &img = ui->cardEditorWidget->image(); + const int cx = img.isNull() ? 50 : img.width() / 2; + const int cy = img.isNull() ? 50 : img.height() / 4; + + overlayTableToList(); + m_overlays.append(makeOverlay(QStringLiteral("CALLSIGN"), cx, cy, 14, false)); + listToOverlayTable(); + + const int newRow = m_overlayModel->rowCount() - 1; + ui->overlayTableView->setCurrentIndex(m_overlayModel->index(newRow, 0)); + ui->overlayTableView->scrollToBottom(); + ui->cardEditorWidget->setSelectedIndex(newRow); +} + +void EmailQSLSettingsWidget::addBox() +{ + FCT_IDENTIFICATION; + + const QPixmap &img = ui->cardEditorWidget->image(); + const int W = img.isNull() ? 1000 : img.width(); + const int H = img.isNull() ? 700 : img.height(); + + EmailQSLFieldOverlay ov; + ov.type = QStringLiteral("BOX"); + ov.fieldName = QString(); // no caption by default + ov.x = qRound(W * 0.05); + ov.y = qRound(H * 0.60); + ov.width = qRound(W * 0.25); + ov.height = qRound(H * 0.22); + ov.fillColor = QStringLiteral("#FFFF99"); + ov.color = QStringLiteral("#888800"); + ov.opacity = 80; + ov.cornerRadius = qMax(4, qRound(H * 0.02)); + ov.fontSize = 0; // no caption + + overlayTableToList(); + m_overlays.append(ov); + listToOverlayTable(); + + const int newRow = m_overlayModel->rowCount() - 1; + ui->overlayTableView->setCurrentIndex(m_overlayModel->index(newRow, 0)); + ui->overlayTableView->scrollToBottom(); + ui->cardEditorWidget->setSelectedIndex(newRow); +} + +void EmailQSLSettingsWidget::addLabel() +{ + FCT_IDENTIFICATION; + + const QPixmap &img = ui->cardEditorWidget->image(); + const int cx = img.isNull() ? 50 : img.width() / 2; + const int cy = img.isNull() ? 50 : img.height() / 4; + + EmailQSLFieldOverlay ov; + ov.type = QStringLiteral("LABEL"); + ov.fieldName = tr("Label Text"); + ov.x = cx; + ov.y = cy; + ov.fontFamily = QStringLiteral("Arial"); + ov.fontSize = 14; + ov.color = QStringLiteral("#000000"); + ov.bold = false; + ov.italic = false; + + overlayTableToList(); + m_overlays.append(ov); + listToOverlayTable(); + + const int newRow = m_overlayModel->rowCount() - 1; + ui->overlayTableView->setCurrentIndex(m_overlayModel->index(newRow, 0)); + ui->overlayTableView->scrollToBottom(); + ui->cardEditorWidget->setSelectedIndex(newRow); +} + +void EmailQSLSettingsWidget::addDefaultOverlays() +{ + FCT_IDENTIFICATION; + + const QPixmap &img = ui->cardEditorWidget->image(); + const int W = img.isNull() ? 1000 : img.width(); + const int H = img.isNull() ? 700 : img.height(); + + // Helper lambda to make a LABEL (static text) overlay + auto makeLabel = [](const QString &text, int x, int y, int fontSize, bool bold, + const QString &color = QStringLiteral("#333333")) -> EmailQSLFieldOverlay + { + EmailQSLFieldOverlay ov; + ov.type = QStringLiteral("LABEL"); + ov.fieldName = text; + ov.x = x; + ov.y = y; + ov.fontSize = fontSize; + ov.bold = bold; + ov.fontFamily = QStringLiteral("Arial"); + ov.color = color; + return ov; + }; + + // Positions expressed as fractions of card dimensions. + // Each data field is preceded by a static label one line above it. + const int labelSz = 9; + const int dataSz = 14; + const int labelOffY = qRound(H * 0.04); // label sits this many px above the data row + + const int rowName = qRound(H * 0.60); + const int rowCall = qRound(H * 0.72); + const int rowData = qRound(H * 0.82); + + const int col1 = qRound(W * 0.08); + const int col2 = qRound(W * 0.35); + const int col3 = qRound(W * 0.55); + const int col4 = qRound(W * 0.68); + const int col5 = qRound(W * 0.80); + const int col6 = qRound(W * 0.90); + const int colGrid = qRound(W * 0.40); + + const QList defaults = { + // My callsign centred near top — no label needed + makeOverlay("MY_CALLSIGN", qRound(W * 0.50), qRound(H * 0.18), 28, true), + + // Name row + makeLabel(tr("Name:"), col1, rowName - labelOffY, labelSz, false), + makeOverlay("NAME", col1, rowName, dataSz, false), + + // Callsign + grid row + makeLabel(tr("Callsign:"), col1, rowCall - labelOffY, labelSz, false), + makeOverlay("CALLSIGN", col1, rowCall, dataSz, true), + makeLabel(tr("Grid:"), colGrid, rowCall - labelOffY, labelSz, false), + makeOverlay("GRIDSQUARE", colGrid, rowCall, dataSz, false), + + // QSO detail row: Date / Time / Band / Mode / RST S / RST R + makeLabel(tr("Date:"), col1, rowData - labelOffY, labelSz, false), + makeOverlay("QSO_DATE", col1, rowData, dataSz, false), + makeLabel(tr("Time (UTC):"), col2, rowData - labelOffY, labelSz, false), + makeOverlay("TIME_ON", col2, rowData, dataSz, false), + makeLabel(tr("Band:"), col3, rowData - labelOffY, labelSz, false), + makeOverlay("BAND", col3, rowData, dataSz, false), + makeLabel(tr("Mode:"), col4, rowData - labelOffY, labelSz, false), + makeOverlay("MODE", col4, rowData, dataSz, false), + makeLabel(tr("RST Snt:"), col5, rowData - labelOffY, labelSz, false), + makeOverlay("RST_SENT", col5, rowData, dataSz, false), + makeLabel(tr("RST Rcvd:"), col6, rowData - labelOffY, labelSz, false), + makeOverlay("RST_RCVD", col6, rowData, dataSz, false), + }; + + overlayTableToList(); + for (const EmailQSLFieldOverlay &ov : defaults) + m_overlays.append(ov); + listToOverlayTable(); + ui->overlayTableView->scrollToBottom(); +} + +void EmailQSLSettingsWidget::removeOverlay() +{ + FCT_IDENTIFICATION; + + const QModelIndexList sel = ui->overlayTableView->selectionModel()->selectedRows(); + if (sel.isEmpty()) + return; + + overlayTableToList(); + m_overlays.removeAt(sel.first().row()); + listToOverlayTable(); +} + +// --------------------------------------------------------------------------- +// Full-size preview — renders using current UI state (not QSettings) +// --------------------------------------------------------------------------- + +QPixmap EmailQSLSettingsWidget::renderPreviewPixmap(const QSqlRecord &record) +{ + FCT_IDENTIFICATION; + + overlayTableToList(); + return EmailQSLBase::renderCard(ui->cardImagePathEdit->text().trimmed(), + record, m_overlays); +} + +void EmailQSLSettingsWidget::previewCard() +{ + FCT_IDENTIFICATION; + + const QSqlRecord dummy = buildDummyRecord(); + const QPixmap preview = renderPreviewPixmap(dummy); + + if (preview.isNull()) + { + QMessageBox::warning(this, tr("Preview"), + tr("Could not load the card image.\n" + "Please check the path and make sure the file exists.")); + return; + } + + QDialog *dlg = new QDialog(this); + dlg->setAttribute(Qt::WA_DeleteOnClose); + dlg->setWindowTitle(tr("Card Preview (test data)")); + QVBoxLayout *lay = new QVBoxLayout(dlg); + + QScrollArea *scroll = new QScrollArea(dlg); + QLabel *lbl = new QLabel(scroll); + const QPixmap scaled = preview.scaled( + QSize(800, 600), Qt::KeepAspectRatio, Qt::SmoothTransformation); + lbl->setPixmap(scaled); + lbl->adjustSize(); + scroll->setWidget(lbl); + scroll->setMinimumSize(qMin(scaled.width() + 20, 820), + qMin(scaled.height() + 20, 620)); + lay->addWidget(scroll); + + QDialogButtonBox *bb = new QDialogButtonBox(QDialogButtonBox::Close, dlg); + QPushButton *saveBtn = bb->addButton(tr("Save Card…"), QDialogButtonBox::ActionRole); + + connect(saveBtn, &QPushButton::clicked, this, [preview, this]() + { + const QString defaultDir = QStandardPaths::writableLocation(QStandardPaths::PicturesLocation); + const QString path = QFileDialog::getSaveFileName( + this, + tr("Save QSL Card"), + defaultDir + QStringLiteral("/QSL_preview.png"), + tr("PNG Image (*.png);;JPEG Image (*.jpg *.jpeg)")); + + if (path.isEmpty()) + return; + + if (!preview.save(path)) + { + QMessageBox::warning(this, tr("Save Failed"), + tr("Could not save the card image to:\n%1").arg(path)); + } + }); + + connect(bb, &QDialogButtonBox::rejected, dlg, &QDialog::close); + lay->addWidget(bb); + dlg->show(); // non-modal +} + +// --------------------------------------------------------------------------- +// SMTP +// --------------------------------------------------------------------------- + +void EmailQSLSettingsWidget::onSmtpPresetChanged(int index) +{ + FCT_IDENTIFICATION; + + const QList presets = smtpPresets(); + if (index <= 0 || index >= presets.size()) + return; + + const SmtpPreset &p = presets.at(index); + if (!p.host.isEmpty()) + { + ui->smtpHostEdit->setText(p.host); + ui->smtpPortSpin->setValue(p.port); + ui->encryptionCombo->setCurrentIndex(p.encryption); + } + if (!p.hint.isEmpty()) + QMessageBox::information(this, tr("Provider Notes"), p.hint); +} + +void EmailQSLSettingsWidget::testConnection() +{ + FCT_IDENTIFICATION; + + ui->testResultLabel->setText(tr("Testing…")); + ui->testConnectionButton->setEnabled(false); + + m_testService->testConnection( + ui->smtpHostEdit->text().trimmed(), + ui->smtpPortSpin->value(), + ui->encryptionCombo->currentIndex(), + ui->smtpUsernameEdit->text().trimmed(), + ui->smtpPasswordEdit->text()); +} + +void EmailQSLSettingsWidget::onTestFinished(bool success, const QString &message) +{ + FCT_IDENTIFICATION; + + ui->testConnectionButton->setEnabled(true); + if (success) + { + ui->testResultLabel->setStyleSheet(QStringLiteral("color: green; font-weight: bold;")); + ui->testResultLabel->setText(tr("Connection OK")); + } + else + { + ui->testResultLabel->setStyleSheet(QStringLiteral("color: red;")); + ui->testResultLabel->setText(tr("Failed: %1").arg(message)); + } +} + +// --------------------------------------------------------------------------- +// Template insertion +// --------------------------------------------------------------------------- + +void EmailQSLSettingsWidget::insertMergeFieldBody() +{ + FCT_IDENTIFICATION; + + const QString text = ui->bodyFieldCombo->currentText(); + if (!text.isEmpty()) + ui->bodyTemplateEdit->insertPlainText(text); +} + +void EmailQSLSettingsWidget::insertMergeFieldSubject() +{ + FCT_IDENTIFICATION; + + const QString text = ui->subjectFieldCombo->currentText(); + if (!text.isEmpty()) + ui->subjectTemplateEdit->insert(text); +} diff --git a/ui/EmailQSLSettingsWidget.h b/ui/EmailQSLSettingsWidget.h new file mode 100644 index 00000000..7f991175 --- /dev/null +++ b/ui/EmailQSLSettingsWidget.h @@ -0,0 +1,63 @@ +#ifndef QLOG_UI_EMAILQSLSETTINGSWIDGET_H +#define QLOG_UI_EMAILQSLSETTINGSWIDGET_H + +#include +#include +#include +#include +#include + +#include "service/emailqsl/EmailQSLService.h" + +namespace Ui { +class EmailQSLSettingsWidget; +} + +class EmailQSLSettingsWidget : public QWidget +{ + Q_OBJECT + +public: + explicit EmailQSLSettingsWidget(QWidget *parent = nullptr); + ~EmailQSLSettingsWidget(); + + void readSettings(); + void writeSettings(); + + // Render card using the current UI state (not saved QSettings). + // Used by the preview dialog so the user can see results before saving. + QPixmap renderPreviewPixmap(const QSqlRecord &record); + +public slots: + void browseCardImage(); + void onCardImageChanged(const QString &path); + void onSmtpPresetChanged(int index); + void testConnection(); + void onTestFinished(bool success, const QString &message); + void addOverlay(); + void addBox(); + void addLabel(); + void addDefaultOverlays(); + void removeOverlay(); + void insertMergeFieldBody(); + void insertMergeFieldSubject(); + void previewCard(); + + // Bidirectional sync between the overlay table and the card editor widget + void onTableDataChanged(); + void onEditorPositionChanged(int index, int newX, int newY); + void onEditorSizeChanged(int index, int newW, int newH); + void onEditorOverlaySelected(int index); + +private: + void overlayTableToList(); + void listToOverlayTable(); + + Ui::EmailQSLSettingsWidget *ui; + QList m_overlays; + QStandardItemModel *m_overlayModel; + EmailQSLService *m_testService; + bool m_syncing; +}; + +#endif // QLOG_UI_EMAILQSLSETTINGSWIDGET_H diff --git a/ui/EmailQSLSettingsWidget.ui b/ui/EmailQSLSettingsWidget.ui new file mode 100644 index 00000000..ae3d019c --- /dev/null +++ b/ui/EmailQSLSettingsWidget.ui @@ -0,0 +1,384 @@ + + + EmailQSLSettingsWidget + + + 00820720 + + Email QSL Settings + + + + + + Configure Email QSL card sending. Use {FIELD} placeholders in subject, body and card overlays. + + true + + + + + + + + + + + SMTP Server + + + + + Quick Presets + + + + Provider preset: + + + + + + Select a preset to auto-fill host, port and encryption. + +Gmail: enable 2-FA, then create an App Password at myaccount.google.com → Security → App Passwords. Use the 16-character app password (not your Google password). +Outlook / Hotmail / Microsoft 365: use your Microsoft account email and password. If MFA is enabled generate an App Password at account.microsoft.com → Security. +Yahoo Mail: go to Account Security and generate an App Password. +iCloud Mail: generate an App-Specific Password at appleid.apple.com → Sign-In and Security. + + + + + + Qt::Horizontal + + + + + + + + + Server + + + SMTP host: + + + + e.g. smtp.gmail.com + + + + Port: + + + + 1 + 65535 + 587 + + + + Encryption: + + + + None + SSL/TLS (implicit, port 465) + STARTTLS (explicit, port 587) + + + + + + + + + Authentication + + + Username: + + + + your.address@gmail.com + + + + Password / App password: + + + + QLineEdit::Password + App password recommended (16 chars for Gmail) + + + + From address: + + + + your.address@gmail.com + + + + From display name: + + + + AA5SH Amateur Radio + + + + + + + + + + + Test Connection + + + + + + + + + Qt::Horizontal + + + + + + Qt::Vertical + + + + + + + + + + Card Image & Overlays + + + + + + + Card image: + + + + Path to base QSL card image (JPEG or PNG) + + + + + Browse… + + + + + + + + + + 300220 + + + Click an overlay to select it. Drag to reposition it on the card. + + + + + + + + Click to select · Drag to move · BOX: drag bottom-right handle to resize. + + QLabel { color: #555; font-style: italic; } + + + + + + + Overlays + + + + 16777215170 + QAbstractItemView::SingleSelection + QAbstractItemView::SelectRows + + QAbstractItemView::DoubleClicked|QAbstractItemView::SelectedClicked|QAbstractItemView::EditKeyPressed + + true + + + + + + + Add Text + Add a merge-field text overlay + + + + + Add Box + Add a filled rounded rectangle (like the yellow boxes on traditional QSL cards) + + + + + Add Static Text + Add a free-form text label (literal text, not a merge field) + + + + + Default QSL Fields + + Insert a pre-built set of text overlays (callsign, date, time, band, mode, RST, name, grid) positioned across the lower area of the card. + + + + + + Remove + false + + + + Qt::Horizontal + + + + Full-Size Preview… + + + + + + + + TEXT: Field=merge key. BOX: Field=optional caption, drag corner to resize. LABEL: Field=literal text printed as-is. Double-click Color/Fill to pick color. + + true + QLabel { color: #555; } + + + + + + + + + + + + + + Email Template + + + + + Subject Line + + + + QSL Card from {MY_CALLSIGN} for our QSO on {QSO_DATE} + + + + + Choose a merge field to insert + + + + + Insert + + + + + + + + + Email Body + + + + + Insert field: + + + + Choose a merge field to insert at the cursor + + + + + Insert + + + + Qt::Horizontal + + + + + + 0180 + Enter email body here. Use {FIELD} placeholders. + + + + + + + + + Available Merge Fields + + + + 16777215150 + 2 + QAbstractItemView::NoEditTriggers + QAbstractItemView::NoSelection + Placeholder + Description + + + + + + + + + + + + + + + + + CardEditorWidget + QWidget +
ui/component/CardEditorWidget.h
+
+
+ + +
diff --git a/ui/LogbookWidget.cpp b/ui/LogbookWidget.cpp index 2e11b35c..56c4ffbc 100644 --- a/ui/LogbookWidget.cpp +++ b/ui/LogbookWidget.cpp @@ -1,6 +1,8 @@ #include #include #include +#include +#include #include #include #include @@ -33,6 +35,7 @@ #include "service/GenericCallbook.h" #include "core/QSOFilterManager.h" #include "core/LogParam.h" +#include "ui/EmailQSLDialog.h" MODULE_IDENTIFICATION("qlog.ui.logbookwidget"); @@ -133,6 +136,7 @@ LogbookWidget::LogbookWidget(QWidget *parent) : ui->contactTable->addAction(ui->actionFilter); ui->contactTable->addAction(ui->actionLookup); ui->contactTable->addAction(ui->actionSendDXCSpot); + ui->contactTable->addAction(ui->actionSendEmailQSL); ui->contactTable->addAction(separator); ui->contactTable->addAction(ui->actionExportAs); ui->contactTable->addAction(ui->actionCallbookLookup); @@ -1256,6 +1260,131 @@ void LogbookWidget::sendDXCSpot() emit sendDXSpotContactReq(model->record(selectedIndexes.at(0).row())); } +void LogbookWidget::sendEmailQSL() +{ + FCT_IDENTIFICATION; + + const QModelIndexList selectedIndexes = ui->contactTable->selectionModel()->selectedRows(); + if (selectedIndexes.isEmpty()) + return; + + // Collect records, skip those without an email address silently (report at end) + QList toSend; + int skippedNoEmail = 0; + + // Single resend decision covers both "already sent for this QSO" and + // "already sent to this callsign for a different QSO" — one prompt per + // batch regardless of the mix of duplicate types. + // -1 = undecided, 0 = skip all duplicates, 1 = resend all duplicates + int resendDecision = -1; + + const bool batchMode = (selectedIndexes.count() > 1); + + for (const QModelIndex &idx : selectedIndexes) + { + const QSqlRecord rec = model->record(idx.row()); + const QString email = rec.value(QStringLiteral("email")).toString().trimmed(); + + if (email.isEmpty()) + { + ++skippedNoEmail; + continue; + } + + const QString callsign = rec.value(QStringLiteral("callsign")).toString().toUpper(); + const int id = rec.value(QStringLiteral("id")).toInt(); + const QDateTime prevSent = EmailQSLBase::getEmailSentDateTime(rec); + const bool sentQso = prevSent.isValid(); + const bool sentCall = !sentQso && EmailQSLBase::hasEmailBeenSentToCallsign(callsign, id); + + if (sentQso || sentCall) + { + if (resendDecision == 0) + { + continue; // "No to All" already chosen + } + else if (resendDecision == -1) + { + QString detail = sentQso + ? tr("An Email QSL was already sent for %1 on %2 UTC.") + .arg(callsign, prevSent.toString(Qt::RFC2822Date)) + : tr("An Email QSL was previously sent to %1 for a different QSO.") + .arg(callsign); + + QString msg = detail + QStringLiteral("

") + tr("Do you want to send again?"); + if (batchMode) + msg += QStringLiteral("
") + + tr("\"Yes to All\" / \"No to All\" applies to every duplicate in this batch.") + + QStringLiteral(""); + + QMessageBox mb(this); + mb.setWindowTitle(tr("Already Sent — Send Again?")); + mb.setTextFormat(Qt::RichText); + mb.setText(msg); + mb.setIcon(QMessageBox::Question); + + QPushButton *yesBtn = mb.addButton(tr("Yes"), QMessageBox::YesRole); + QPushButton *yesAllBtn = batchMode ? mb.addButton(tr("Yes to All"), QMessageBox::YesRole) : nullptr; + QPushButton *noBtn = mb.addButton(tr("No"), QMessageBox::NoRole); + QPushButton *noAllBtn = batchMode ? mb.addButton(tr("No to All"), QMessageBox::NoRole) : nullptr; + Q_UNUSED(yesBtn) + + mb.exec(); + QAbstractButton *clicked = mb.clickedButton(); + + if (clicked == noAllBtn) { resendDecision = 0; continue; } + else if (clicked == noBtn) { continue; } + else if (clicked == yesAllBtn) { resendDecision = 1; } + // else "Yes" — include this one, leave decision open for next + } + // resendDecision == 1 → fall through and add + } + + toSend.append(rec); + } + + if (toSend.isEmpty()) + { + if (skippedNoEmail > 0) + QMessageBox::information(this, tr("Email QSL"), + tr("%n contact(s) skipped — no email address on file.", "", skippedNoEmail)); + return; + } + + // Show dialogs one at a time so they never pile up behind the main window + showNextEmailQSLDialog(new QList(toSend)); + + if (skippedNoEmail > 0) + QMessageBox::information(this, tr("Email QSL"), + tr("%n contact(s) skipped — no email address on file.", "", skippedNoEmail)); +} + +void LogbookWidget::showNextEmailQSLDialog(QList *queue) +{ + FCT_IDENTIFICATION; + + if (queue->isEmpty()) + { + delete queue; + model->select(); // refresh the log table once the whole batch is done + return; + } + + const QSqlRecord rec = queue->takeFirst(); + EmailQSLDialog *dialog = new EmailQSLDialog(rec, this); + dialog->setAttribute(Qt::WA_DeleteOnClose); + + // When this dialog closes (for any reason), open the next one + connect(dialog, &QDialog::finished, this, [this, queue]() + { + showNextEmailQSLDialog(queue); + }); + + dialog->show(); + dialog->raise(); + dialog->activateWindow(); +} + void LogbookWidget::setDefaultSort() { FCT_IDENTIFICATION; diff --git a/ui/LogbookWidget.h b/ui/LogbookWidget.h index 304562e0..3f8bce2f 100644 --- a/ui/LogbookWidget.h +++ b/ui/LogbookWidget.h @@ -87,6 +87,7 @@ public slots: void focusSearchCallsign(); void reloadSetting(); void sendDXCSpot(); + void sendEmailQSL(); void setDefaultSort(); void actionCallbookLookup(); void callsignFound(const CallbookResponseData &data); @@ -129,6 +130,7 @@ public slots: void updateQSORecordFromCallbook(const CallbookResponseData &data); void queryNextQSOLookupBatch(); void finishQSOLookupBatch(); + void showNextEmailQSLDialog(QList *queue); void clearSearchText(); void setupSearchMenu(); void setContactTableColumnVisible(int columnIndex, bool visible); diff --git a/ui/LogbookWidget.ui b/ui/LogbookWidget.ui index cca6bd5f..1ab80107 100644 --- a/ui/LogbookWidget.ui +++ b/ui/LogbookWidget.ui @@ -333,6 +333,14 @@ Logbook - Send DX Spot
+ + + Send Email QSL Card + + + Send an Email QSL card to this contact + + true @@ -567,6 +575,16 @@ + + actionSendEmailQSL + triggered() + LogbookWidget + sendEmailQSL() + + -1-1 + 404168 + + actionSendDXCSpot triggered() @@ -715,6 +733,7 @@ exportContact() clubFilterChanged() sendDXCSpot() + sendEmailQSL() setCallsignSearch() setGridsquareSearch() setPotaSearch() diff --git a/ui/SettingsDialog.cpp b/ui/SettingsDialog.cpp index 9d2cf0b2..339c49d5 100644 --- a/ui/SettingsDialog.cpp +++ b/ui/SettingsDialog.cpp @@ -54,6 +54,7 @@ #include "ui/BandmapWidget.h" #include "ui/RigctldAdvancedDialog.h" #include "cwkey/drivers/CWWinKey.h" +#include "ui/EmailQSLSettingsWidget.h" MODULE_IDENTIFICATION("qlog.ui.settingsdialog"); @@ -2779,6 +2780,11 @@ void SettingsDialog::readSettings() ui->unitFormatImperialRadioButton->setChecked(!unitFormatMetric); loadQsoStatusColors(); + /***************/ + /* Email QSL */ + /***************/ + ui->emailQSLSettingsWidget->readSettings(); + /******************/ /* END OF Reading */ /******************/ @@ -2912,6 +2918,11 @@ void SettingsDialog::writeSettings() locale.setSettingUseMetric(ui->unitFormatMetricRadioButton->isChecked()); saveQsoStatusColors(); Data::reloadQsoStatusColors(); + + /***************/ + /* Email QSL */ + /***************/ + ui->emailQSLSettingsWidget->writeSettings(); } void SettingsDialog::setupAdifRecoveryTab() diff --git a/ui/SettingsDialog.ui b/ui/SettingsDialog.ui index a5486d68..ccb7c4a9 100644 --- a/ui/SettingsDialog.ui +++ b/ui/SettingsDialog.ui @@ -5224,6 +5224,16 @@ + + + Email QSL + + + + + + + @@ -5239,6 +5249,11 @@ QLineEdit
ui/component/EditLine.h
+ + EmailQSLSettingsWidget + QWidget +
ui/EmailQSLSettingsWidget.h
+
stationProfileNameEdit diff --git a/ui/component/CardEditorWidget.cpp b/ui/component/CardEditorWidget.cpp new file mode 100644 index 00000000..c6c04b53 --- /dev/null +++ b/ui/component/CardEditorWidget.cpp @@ -0,0 +1,380 @@ +#include +#include +#include +#include +#include + +#include "CardEditorWidget.h" + +CardEditorWidget::CardEditorWidget(QWidget *parent) + : QWidget(parent) +{ + setMouseTracking(true); + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + setMinimumSize(300, 200); +} + +// --------------------------------------------------------------------------- +// Public interface +// --------------------------------------------------------------------------- + +void CardEditorWidget::setImage(const QPixmap &pm) +{ + m_image = pm; + update(); +} + +void CardEditorWidget::setOverlays(const QList &overlays) +{ + m_overlays = overlays; + if (m_selectedIndex >= m_overlays.size()) + m_selectedIndex = -1; + update(); +} + +void CardEditorWidget::updateOverlay(int index, const EmailQSLFieldOverlay &ov) +{ + if (index < 0 || index >= m_overlays.size()) + return; + m_overlays[index] = ov; + update(); +} + +void CardEditorWidget::setSelectedIndex(int index) +{ + if (m_selectedIndex == index) + return; + m_selectedIndex = index; + update(); +} + +QSize CardEditorWidget::sizeHint() const +{ + if (m_image.isNull()) + return QSize(500, 320); + return m_image.size().scaled(640, 420, Qt::KeepAspectRatio); +} + +QSize CardEditorWidget::minimumSizeHint() const { return QSize(300, 200); } + +// --------------------------------------------------------------------------- +// Coordinate helpers +// --------------------------------------------------------------------------- + +QRectF CardEditorWidget::imageRect() const +{ + if (m_image.isNull()) + return QRectF(rect()); + QSizeF sz = m_image.size(); + sz.scale(size(), Qt::KeepAspectRatio); + return QRectF(QPointF((width() - sz.width()) / 2.0, + (height() - sz.height()) / 2.0), sz); +} + +double CardEditorWidget::displayScale() const +{ + if (m_image.isNull() || m_image.width() == 0) return 1.0; + return imageRect().width() / m_image.width(); +} + +QPointF CardEditorWidget::imageToWidget(const QPointF &ip) const +{ + const QRectF r = imageRect(); + const double sx = r.width() / (m_image.isNull() ? 1 : m_image.width()); + const double sy = r.height() / (m_image.isNull() ? 1 : m_image.height()); + return QPointF(r.left() + ip.x() * sx, r.top() + ip.y() * sy); +} + +QPointF CardEditorWidget::widgetToImage(const QPointF &wp) const +{ + const QRectF r = imageRect(); + if (r.width() == 0 || r.height() == 0) return wp; + const double sx = (m_image.isNull() ? 1 : m_image.width()) / r.width(); + const double sy = (m_image.isNull() ? 1 : m_image.height()) / r.height(); + return QPointF((wp.x() - r.left()) * sx, (wp.y() - r.top()) * sy); +} + +QRectF CardEditorWidget::overlayWidgetRect(int idx) const +{ + if (idx < 0 || idx >= m_overlays.size()) return {}; + const EmailQSLFieldOverlay &ov = m_overlays.at(idx); + const QPointF tl = imageToWidget(QPointF(ov.x, ov.y)); + const double sc = displayScale(); + + if (ov.type == QLatin1String("BOX")) + { + return QRectF(tl, QSizeF(ov.width * sc, ov.height * sc)); + } + + // TEXT / LABEL: bounding box of the displayed string + QFont f(ov.fontFamily, qMax(8, qRound(ov.fontSize * sc))); + f.setBold(ov.bold); f.setItalic(ov.italic); + const QFontMetrics fm(f); + const QString lbl = (ov.type == QLatin1String("LABEL")) + ? ov.fieldName + : (QLatin1Char('{') + ov.fieldName + QLatin1Char('}')); + return QRectF(tl.x(), tl.y() - fm.ascent(), fm.horizontalAdvance(lbl), fm.height()); +} + +QRectF CardEditorWidget::resizeHandleRect(int idx) const +{ + const QRectF r = overlayWidgetRect(idx); + if (r.isNull()) return {}; + const double h = RESIZE_HANDLE_PX; + return QRectF(r.right() - h, r.bottom() - h, h * 2, h * 2); +} + +bool CardEditorWidget::isOnResizeHandle(int idx, const QPointF &wPt) const +{ + if (idx < 0 || idx >= m_overlays.size()) return false; + if (m_overlays.at(idx).type != QLatin1String("BOX")) return false; + return resizeHandleRect(idx).contains(wPt); +} + +int CardEditorWidget::overlayAt(const QPointF &wPt) const +{ + // Iterate in reverse so top-drawn items (higher index) are hit first + for (int i = m_overlays.size() - 1; i >= 0; --i) + { + const QRectF r = overlayWidgetRect(i).adjusted(-4, -4, 4, 4); + if (r.contains(wPt)) return i; + } + return -1; +} + +// --------------------------------------------------------------------------- +// Paint +// --------------------------------------------------------------------------- + +void CardEditorWidget::paintEvent(QPaintEvent *) +{ + QPainter p(this); + p.setRenderHint(QPainter::SmoothPixmapTransform); + p.setRenderHint(QPainter::Antialiasing); + p.setRenderHint(QPainter::TextAntialiasing); + + // Background + p.fillRect(rect(), palette().window()); + + if (m_image.isNull()) + { + p.setPen(QColor(0x88, 0x88, 0x88)); + p.drawText(rect(), Qt::AlignCenter, + tr("No card image selected.\nClick \"Browse…\" to choose a QSL card image.")); + p.setPen(QPen(QColor(0x88, 0x88, 0x88), 1, Qt::DashLine)); + p.drawRect(rect().adjusted(0, 0, -1, -1)); + return; + } + + // Card image + p.drawPixmap(imageRect().toRect(), m_image); + + const double sc = displayScale(); + + for (int i = 0; i < m_overlays.size(); ++i) + { + const EmailQSLFieldOverlay &ov = m_overlays.at(i); + const bool sel = (i == m_selectedIndex); + const QRectF wr = overlayWidgetRect(i); + + if (ov.type == QLatin1String("BOX")) + { + // --- Fill --- + QColor fill(ov.fillColor); + fill.setAlphaF(ov.opacity / 100.0); + const double cr = ov.cornerRadius * sc; + + p.setPen(QPen(QColor(ov.color), sel ? 2.0 : 1.5)); + p.setBrush(fill); + p.drawRoundedRect(wr, cr, cr); + + // --- Caption above box --- + if (!ov.fieldName.isEmpty()) + { + const int dispSz = qMax(8, qRound((ov.fontSize > 0 ? ov.fontSize : 11) * sc)); + QFont f(ov.fontFamily, dispSz); + f.setBold(ov.bold); + p.setFont(f); + p.setPen(QColor(ov.color)); + p.setBrush(Qt::NoBrush); + QFontMetrics fm(f); + p.drawText(QPointF(wr.left(), wr.top() - fm.descent() - 2), ov.fieldName); + } + + // --- Selection decoration --- + if (sel) + { + p.save(); + p.setPen(QPen(QColor(0, 120, 215), 1.5, Qt::DashLine)); + p.setBrush(Qt::NoBrush); + p.drawRoundedRect(wr.adjusted(-3, -3, 3, 3), cr + 3, cr + 3); + + // Resize handle (bottom-right corner) + const QRectF rh = resizeHandleRect(i); + p.setPen(Qt::NoPen); + p.setBrush(QColor(0, 120, 215)); + p.drawEllipse(rh.center(), RESIZE_HANDLE_PX * 0.5, RESIZE_HANDLE_PX * 0.5); + + // Size info label + p.setPen(QColor(0, 80, 180)); + p.setFont(QFont(QStringLiteral("Arial"), 8)); + p.drawText(QPointF(wr.left() + 3, wr.bottom() - 3), + QString("%1×%2").arg(ov.width).arg(ov.height)); + p.restore(); + } + } + else // TEXT or LABEL + { + const int dispSz = qMax(8, qRound(ov.fontSize * sc)); + QFont f(ov.fontFamily, dispSz); + f.setBold(ov.bold); f.setItalic(ov.italic); + p.setFont(f); + + // LABEL shows literal text; TEXT shows {FIELDNAME} placeholder + const QString lbl = (ov.type == QLatin1String("LABEL")) + ? ov.fieldName + : (QLatin1Char('{') + ov.fieldName + QLatin1Char('}')); + const QFontMetrics fm(f); + const QPointF anchor(wr.left(), wr.top() + fm.ascent()); + + // Selection box + if (sel) + { + p.save(); + p.setPen(QPen(QColor(0, 120, 215), 1.5, Qt::DashLine)); + p.setBrush(QColor(0, 120, 215, 25)); + p.drawRect(wr.adjusted(-3, -3, 3, 3)); + // Drag-handle dot + p.setPen(Qt::NoPen); + p.setBrush(QColor(0, 120, 215)); + p.drawEllipse(QPointF(wr.left() - 7, wr.center().y()), 4, 4); + p.restore(); + } + else + { + p.save(); + p.setPen(QPen(QColor(0, 0, 0, 35), 1, Qt::DotLine)); + p.setBrush(Qt::NoBrush); + p.drawRect(wr.adjusted(-2, -2, 2, 2)); + p.restore(); + } + + // Shadow + { + QColor shadow = QColor(ov.color).lightness() > 128 + ? QColor(0, 0, 0, 90) : QColor(255, 255, 255, 90); + p.save(); + p.setPen(shadow); + p.drawText(anchor + QPointF(1, 1), lbl); + p.restore(); + } + + p.setPen(QColor(ov.color)); + p.drawText(anchor, lbl); + } + } + + // Border + p.setPen(QPen(QColor(0x88, 0x88, 0x88), 1)); + p.setBrush(Qt::NoBrush); + p.drawRect(rect().adjusted(0, 0, -1, -1)); +} + +// --------------------------------------------------------------------------- +// Mouse events +// --------------------------------------------------------------------------- + +void CardEditorWidget::mousePressEvent(QMouseEvent *event) +{ + if (event->button() != Qt::LeftButton) { QWidget::mousePressEvent(event); return; } + + const QPointF wPos = event->pos(); + const int idx = overlayAt(wPos); + + m_selectedIndex = idx; + m_dragIndex = -1; + m_resizing = false; + + if (idx >= 0) + { + m_dragIndex = idx; + m_dragStartImg = widgetToImage(wPos); + m_dragAnchorImg = QPointF(m_overlays.at(idx).x, m_overlays.at(idx).y); + + if (isOnResizeHandle(idx, wPos)) + { + m_resizing = true; + m_dragSizeAtPress = QSizeF(m_overlays.at(idx).width, m_overlays.at(idx).height); + setCursor(Qt::SizeFDiagCursor); + } + else + { + setCursor(Qt::ClosedHandCursor); + } + } + + emit overlaySelected(m_selectedIndex); + update(); +} + +void CardEditorWidget::mouseMoveEvent(QMouseEvent *event) +{ + const QPointF wPos = event->pos(); + + if (m_dragIndex >= 0 && (event->buttons() & Qt::LeftButton)) + { + const QPointF curImg = widgetToImage(wPos); + const QPointF delta = curImg - m_dragStartImg; + + EmailQSLFieldOverlay &ov = m_overlays[m_dragIndex]; + const int imgW = m_image.isNull() ? 99999 : m_image.width(); + const int imgH = m_image.isNull() ? 99999 : m_image.height(); + + if (m_resizing) + { + const int newW = qMax(20, qRound(m_dragSizeAtPress.width() + delta.x())); + const int newH = qMax(10, qRound(m_dragSizeAtPress.height() + delta.y())); + ov.width = qMin(newW, imgW - ov.x); + ov.height = qMin(newH, imgH - ov.y); + } + else + { + ov.x = qBound(0, qRound(m_dragAnchorImg.x() + delta.x()), imgW - 1); + ov.y = qBound(0, qRound(m_dragAnchorImg.y() + delta.y()), imgH - 1); + } + update(); + } + else + { + // Cursor feedback + const int hov = overlayAt(wPos); + if (hov >= 0) + { + if (isOnResizeHandle(hov, wPos)) + setCursor(Qt::SizeFDiagCursor); + else + setCursor(Qt::OpenHandCursor); + } + else + { + setCursor(Qt::ArrowCursor); + } + } +} + +void CardEditorWidget::mouseReleaseEvent(QMouseEvent *event) +{ + if (event->button() == Qt::LeftButton && m_dragIndex >= 0) + { + const EmailQSLFieldOverlay &ov = m_overlays.at(m_dragIndex); + if (m_resizing) + emit overlaySizeChanged(m_dragIndex, ov.width, ov.height); + else + emit overlayPositionChanged(m_dragIndex, ov.x, ov.y); + + m_dragIndex = -1; + m_resizing = false; + setCursor(Qt::ArrowCursor); + update(); + } +} diff --git a/ui/component/CardEditorWidget.h b/ui/component/CardEditorWidget.h new file mode 100644 index 00000000..c55d16d1 --- /dev/null +++ b/ui/component/CardEditorWidget.h @@ -0,0 +1,68 @@ +#ifndef QLOG_UI_COMPONENT_CARDEDITORWIDGET_H +#define QLOG_UI_COMPONENT_CARDEDITORWIDGET_H + +#include +#include + +#include "service/emailqsl/EmailQSLService.h" + +// Interactive QSL card editor widget. +// Supports two overlay types: +// TEXT — displays a {FIELD} placeholder; drag to reposition. +// BOX — filled rounded rectangle; drag to move, drag bottom-right handle to resize. +class CardEditorWidget : public QWidget +{ + Q_OBJECT + +public: + explicit CardEditorWidget(QWidget *parent = nullptr); + + void setImage(const QPixmap &pm); + const QPixmap &image() const { return m_image; } + + void setOverlays(const QList &overlays); + const QList &overlays() const { return m_overlays; } + + void updateOverlay(int index, const EmailQSLFieldOverlay &ov); + void setSelectedIndex(int index); + int selectedIndex() const { return m_selectedIndex; } + + QSize sizeHint() const override; + QSize minimumSizeHint() const override; + +signals: + void overlayPositionChanged(int index, int newX, int newY); + void overlaySizeChanged(int index, int newW, int newH); // BOX resize + void overlaySelected(int index); + +protected: + void paintEvent(QPaintEvent *) override; + void mousePressEvent(QMouseEvent *) override; + void mouseMoveEvent(QMouseEvent *) override; + void mouseReleaseEvent(QMouseEvent *) override; + +private: + static constexpr int RESIZE_HANDLE_PX = 10; // half-size of resize grip in widget px + + QRectF imageRect() const; + double displayScale() const; + QPointF imageToWidget(const QPointF &imgPt) const; + QPointF widgetToImage(const QPointF &wPt) const; + QRectF overlayWidgetRect(int index) const; // widget-coords bounding rect + QRectF resizeHandleRect(int index) const; // widget-coords resize grip + int overlayAt(const QPointF &wPt) const; // -1 = none + bool isOnResizeHandle(int index, const QPointF &wPt) const; + + QPixmap m_image; + QList m_overlays; + int m_selectedIndex = -1; + + // Drag state + int m_dragIndex = -1; + bool m_resizing = false; + QPointF m_dragStartImg; // image-coord of press point + QPointF m_dragAnchorImg; // image-coord anchor (overlay x,y at press) + QSizeF m_dragSizeAtPress; // overlay w,h at press (resize only) +}; + +#endif // QLOG_UI_COMPONENT_CARDEDITORWIDGET_H From 186e97a989ea638ebc6ddd6b3681465ac9bc3deb Mon Sep 17 00:00:00 2001 From: Michael Morgan <84428382+aa5sh@users.noreply.github.com> Date: Thu, 9 Apr 2026 11:12:18 -0500 Subject: [PATCH 12/21] Scale overlays and downscale email attachment Keep overlay positions, font sizes and box dims proportional when the card background changes and produce smaller email attachments. EmailQSLSettingsWidget: rescale stored overlay pixel values when a new image with different size is loaded; use a 1280px reference width for default fonts/positions and scale horizontal metrics accordingly; add minimum sizes for fonts and box dims. EmailQSLService: render the card at full resolution but scale the exported JPEG to a fixed EMAIL_WIDTH (1280) for email attachments to reduce file size. Also add clarifying comments about how overlay coordinates are stored and used. --- service/emailqsl/EmailQSLService.cpp | 19 ++++- ui/EmailQSLSettingsWidget.cpp | 122 ++++++++++++++++++--------- 2 files changed, 98 insertions(+), 43 deletions(-) diff --git a/service/emailqsl/EmailQSLService.cpp b/service/emailqsl/EmailQSLService.cpp index 94aba7ae..60240fff 100644 --- a/service/emailqsl/EmailQSLService.cpp +++ b/service/emailqsl/EmailQSLService.cpp @@ -423,6 +423,11 @@ QPixmap EmailQSLBase::renderCard(const QString &imagePath, return QPixmap(); } + // Overlay x, y, fontSize and BOX width/height are all stored as absolute + // pixel values scaled to the source image dimensions. No extra scaling is + // applied here — the values are used as-is. addDefaultOverlays() and + // onCardImageChanged() are responsible for keeping them proportional whenever + // the background image changes. QPainter painter(&pixmap); painter.setRenderHint(QPainter::Antialiasing); painter.setRenderHint(QPainter::TextAntialiasing); @@ -438,7 +443,6 @@ QPixmap EmailQSLBase::renderCard(const QString &imagePath, painter.drawRoundedRect(ov.x, ov.y, ov.width, ov.height, ov.cornerRadius, ov.cornerRadius); - // Optional caption above the box if (!ov.fieldName.isEmpty()) { QFont font(ov.fontFamily, ov.fontSize > 0 ? ov.fontSize : 11); @@ -452,7 +456,6 @@ QPixmap EmailQSLBase::renderCard(const QString &imagePath, } else if (ov.type == QLatin1String("LABEL")) { - // Render fieldName as literal static text (no merge substitution) if (ov.fieldName.isEmpty()) continue; @@ -751,15 +754,23 @@ void EmailQSLService::sendEmailQSL(const QSqlRecord &record) { FCT_IDENTIFICATION; - // Render card + // Render card at full source resolution (fonts scale with image size) const QPixmap cardPixmap = EmailQSLBase::renderCard(record); QByteArray imageData; QString imageName; if (!cardPixmap.isNull()) { + // Scale down to EMAIL_WIDTH for the attachment — keeps file size reasonable + // while still looking sharp on any screen. The full-res pixmap is only + // used when the user explicitly saves via "Save Card…". + static constexpr int EMAIL_WIDTH = 1280; + const QPixmap emailPixmap = (cardPixmap.width() > EMAIL_WIDTH) + ? cardPixmap.scaledToWidth(EMAIL_WIDTH, Qt::SmoothTransformation) + : cardPixmap; + QBuffer buf(&imageData); buf.open(QIODevice::WriteOnly); - cardPixmap.save(&buf, "JPEG", 90); + emailPixmap.save(&buf, "JPEG", 90); imageName = QStringLiteral("qsl_card.jpg"); } diff --git a/ui/EmailQSLSettingsWidget.cpp b/ui/EmailQSLSettingsWidget.cpp index 14e0127e..58bb0fe4 100644 --- a/ui/EmailQSLSettingsWidget.cpp +++ b/ui/EmailQSLSettingsWidget.cpp @@ -681,6 +681,38 @@ void EmailQSLSettingsWidget::onCardImageChanged(const QString &path) FCT_IDENTIFICATION; QPixmap pm(path); + + // If there are existing overlays and a previous image, rescale all stored + // pixel values (positions, font sizes, box dimensions) proportionally so + // the layout stays in the same visual position on the new image. + const QPixmap &prev = ui->cardEditorWidget->image(); + if (!prev.isNull() && !pm.isNull() + && prev.size() != pm.size()) + { + overlayTableToList(); // make sure m_overlays is current + + if (!m_overlays.isEmpty()) + { + const double sx = static_cast(pm.width()) / prev.width(); + const double sy = static_cast(pm.height()) / prev.height(); + + for (EmailQSLFieldOverlay &ov : m_overlays) + { + ov.x = qRound(ov.x * sx); + ov.y = qRound(ov.y * sy); + // Font size tracks horizontal scale (same axis as card width) + ov.fontSize = qMax(1, qRound(ov.fontSize * sx)); + if (ov.type == QLatin1String("BOX")) + { + ov.width = qMax(20, qRound(ov.width * sx)); + ov.height = qMax(10, qRound(ov.height * sy)); + } + } + + listToOverlayTable(); + } + } + ui->cardEditorWidget->setImage(pm); } @@ -772,70 +804,82 @@ void EmailQSLSettingsWidget::addDefaultOverlays() FCT_IDENTIFICATION; const QPixmap &img = ui->cardEditorWidget->image(); - const int W = img.isNull() ? 1000 : img.width(); - const int H = img.isNull() ? 700 : img.height(); + const int W = img.isNull() ? 1280 : img.width(); + const int H = img.isNull() ? 896 : img.height(); + + // Font sizes are designed for a 1280-px-wide reference card and scaled + // linearly to the actual image width so they always look proportional. + // The same scale is applied to horizontal positions; vertical positions + // use the image height fraction directly. + static constexpr double REF_W = 1280.0; + const double fs = W / REF_W; // font scale (also used for x/horizontal) + + auto scalePt = [&](int refPt) { return qMax(1, qRound(refPt * fs)); }; // Helper lambda to make a LABEL (static text) overlay - auto makeLabel = [](const QString &text, int x, int y, int fontSize, bool bold, - const QString &color = QStringLiteral("#333333")) -> EmailQSLFieldOverlay + auto makeLabel = [&](const QString &text, int x, int y, int refFontPt, bool bold, + const QString &color = QStringLiteral("#333333")) -> EmailQSLFieldOverlay { EmailQSLFieldOverlay ov; ov.type = QStringLiteral("LABEL"); ov.fieldName = text; ov.x = x; ov.y = y; - ov.fontSize = fontSize; + ov.fontSize = scalePt(refFontPt); ov.bold = bold; ov.fontFamily = QStringLiteral("Arial"); ov.color = color; return ov; }; - // Positions expressed as fractions of card dimensions. - // Each data field is preceded by a static label one line above it. - const int labelSz = 9; - const int dataSz = 14; - const int labelOffY = qRound(H * 0.04); // label sits this many px above the data row + // Reference font sizes at 1280 px width + const int refCallsignPt = 80; // MY_CALLSIGN prominent header + const int refDxCallPt = 40; // DX station callsign + const int refDataPt = 20; // QSO data values + const int refLabelPt = 12; // small caption labels above each field + + // Positions as fractions of card dimensions — same as before + const int labelOffY = qRound(H * 0.045); const int rowName = qRound(H * 0.60); const int rowCall = qRound(H * 0.72); - const int rowData = qRound(H * 0.82); - - const int col1 = qRound(W * 0.08); - const int col2 = qRound(W * 0.35); - const int col3 = qRound(W * 0.55); - const int col4 = qRound(W * 0.68); - const int col5 = qRound(W * 0.80); - const int col6 = qRound(W * 0.90); + const int rowData = qRound(H * 0.84); + + const int col1 = qRound(W * 0.08); + const int col2 = qRound(W * 0.35); + const int col3 = qRound(W * 0.55); + const int col4 = qRound(W * 0.68); + const int col5 = qRound(W * 0.80); + const int col6 = qRound(W * 0.90); const int colGrid = qRound(W * 0.40); const QList defaults = { // My callsign centred near top — no label needed - makeOverlay("MY_CALLSIGN", qRound(W * 0.50), qRound(H * 0.18), 28, true), + makeOverlay("MY_CALLSIGN", qRound(W * 0.50), qRound(H * 0.18), scalePt(refCallsignPt), true), // Name row - makeLabel(tr("Name:"), col1, rowName - labelOffY, labelSz, false), - makeOverlay("NAME", col1, rowName, dataSz, false), + makeLabel(tr("Name:"), col1, rowName - labelOffY, refLabelPt, false), + makeOverlay("NAME", col1, rowName, scalePt(refDataPt), false), // Callsign + grid row - makeLabel(tr("Callsign:"), col1, rowCall - labelOffY, labelSz, false), - makeOverlay("CALLSIGN", col1, rowCall, dataSz, true), - makeLabel(tr("Grid:"), colGrid, rowCall - labelOffY, labelSz, false), - makeOverlay("GRIDSQUARE", colGrid, rowCall, dataSz, false), - - // QSO detail row: Date / Time / Band / Mode / RST S / RST R - makeLabel(tr("Date:"), col1, rowData - labelOffY, labelSz, false), - makeOverlay("QSO_DATE", col1, rowData, dataSz, false), - makeLabel(tr("Time (UTC):"), col2, rowData - labelOffY, labelSz, false), - makeOverlay("TIME_ON", col2, rowData, dataSz, false), - makeLabel(tr("Band:"), col3, rowData - labelOffY, labelSz, false), - makeOverlay("BAND", col3, rowData, dataSz, false), - makeLabel(tr("Mode:"), col4, rowData - labelOffY, labelSz, false), - makeOverlay("MODE", col4, rowData, dataSz, false), - makeLabel(tr("RST Snt:"), col5, rowData - labelOffY, labelSz, false), - makeOverlay("RST_SENT", col5, rowData, dataSz, false), - makeLabel(tr("RST Rcvd:"), col6, rowData - labelOffY, labelSz, false), - makeOverlay("RST_RCVD", col6, rowData, dataSz, false), + makeLabel(tr("Callsign:"), col1, rowCall - labelOffY, refLabelPt, false), + makeOverlay("CALLSIGN", col1, rowCall, scalePt(refDxCallPt), true), + makeLabel(tr("Grid:"), colGrid, rowCall - labelOffY, refLabelPt, false), + makeOverlay("GRIDSQUARE", colGrid, rowCall, scalePt(refDataPt), false), + + // QSO detail row + makeLabel(tr("Date:"), col1, rowData - labelOffY, refLabelPt, false), + makeOverlay("QSO_DATE", col1, rowData, scalePt(refDataPt), false), + makeLabel(tr("Time (UTC):"), col2, rowData - labelOffY, refLabelPt, false), + makeOverlay("TIME_ON", col2, rowData, scalePt(refDataPt), false), + makeLabel(tr("Band:"), col3, rowData - labelOffY, refLabelPt, false), + makeOverlay("BAND", col3, rowData, scalePt(refDataPt), false), + makeLabel(tr("Mode:"), col4, rowData - labelOffY, refLabelPt, false), + makeOverlay("MODE", col4, rowData, scalePt(refDataPt), false), + makeLabel(tr("RST Snt:"), col5, rowData - labelOffY, refLabelPt, false), + makeOverlay("RST_SENT", col5, rowData, scalePt(refDataPt), false), + makeLabel(tr("RST Rcvd:"), col6, rowData - labelOffY, refLabelPt, false), + makeOverlay("RST_RCVD", col6, rowData, scalePt(refDataPt), false), }; overlayTableToList(); From c2ea0ff177513533af7eeed39c519c2dd7ff3a2a Mon Sep 17 00:00:00 2001 From: Michael Morgan <84428382+aa5sh@users.noreply.github.com> Date: Thu, 9 Apr 2026 11:23:59 -0500 Subject: [PATCH 13/21] Update EmailQSLSettingsWidget.cpp Fix GitHub CL Error --- ui/EmailQSLSettingsWidget.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/EmailQSLSettingsWidget.cpp b/ui/EmailQSLSettingsWidget.cpp index 58bb0fe4..35c0bd39 100644 --- a/ui/EmailQSLSettingsWidget.cpp +++ b/ui/EmailQSLSettingsWidget.cpp @@ -276,7 +276,7 @@ static QSqlRecord buildDummyRecord() QSqlRecord r; for (const QString &c : cols) { - QSqlField f(c, QMetaType::fromType()); + QSqlField f(c, QVariant::String); r.append(f); } r.setValue("callsign", QStringLiteral("W1AW")); From 980251f522207111acac2f42e72e9d98d53ca251 Mon Sep 17 00:00:00 2001 From: Michael Morgan <84428382+aa5sh@users.noreply.github.com> Date: Thu, 19 Mar 2026 19:10:20 -0500 Subject: [PATCH 14/21] Refactor macOS build workflow and add notarization Updated macOS build workflow to use macos-latest and modified installation steps for Qt libraries. Added notarization and signing steps for the app and DMG. --- .github/workflows/macOSBuild.yml | 286 +++++++++++++++++++++++++++++-- 1 file changed, 267 insertions(+), 19 deletions(-) diff --git a/.github/workflows/macOSBuild.yml b/.github/workflows/macOSBuild.yml index 9c03d618..f7b61a11 100644 --- a/.github/workflows/macOSBuild.yml +++ b/.github/workflows/macOSBuild.yml @@ -13,7 +13,7 @@ jobs: name: MacOS Build strategy: matrix: - os: [macos-12, macos-13] + os: [macos-latest] runs-on: ${{ matrix.os }} @@ -24,9 +24,8 @@ jobs: brew update brew upgrade || true brew install qt6 - brew install qt6-webengine + brew install qtvirtualkeyboard brew link qt6 --force - brew link qt6-webengine --force brew install hamlib brew link hamlib --force brew install qtkeychain @@ -38,6 +37,7 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 + submodules: recursive - name: Get version from tag run : | TAGVERSION=$(git describe --tags) @@ -49,24 +49,272 @@ jobs: cd build qmake -config release .. make -j4 - - name: Build dmg + - name: Build app run: | + set -euo pipefail cd build - macdeployqt qlog.app -executable=./qlog.app/Contents/MacOS/qlog - cp `brew --prefix`/lib/libhamlib.dylib qlog.app/Contents/Frameworks/libhamlib.dylib - cp `brew --prefix`/lib/libqt6keychain.dylib qlog.app/Contents/Frameworks/libqt6keychain.dylib - cp `brew --prefix`/lib/libdbus-1.dylib qlog.app/Contents/Frameworks/libdbus-1.dylib - cp `brew --prefix brotli`/lib/libbrotlicommon.1.dylib qlog.app/Contents/Frameworks/libbrotlicommon.1.dylib - cp `brew --prefix`/opt/icu4c/lib/libicui18n.74.dylib qlog.app/Contents/Frameworks/libicui18n.74.dylib - install_name_tool -change `brew --prefix`/lib/libhamlib.dylib @executable_path/../Frameworks/libhamlib.dylib qlog.app/Contents/MacOS/qlog - install_name_tool -change `brew --prefix`/lib/libqt6keychain.dylib @executable_path/../Frameworks/libqt6keychain.dylib qlog.app/Contents/MacOS/qlog - install_name_tool -change @loader_path/libbrotlicommon.1.dylib @executable_path/../Frameworks/libbrotlicommon.1.dylib qlog.app/Contents/MacOS/qlog - install_name_tool -change /usr/local/opt/icu4c/lib/libicui18n.74.dylib @executable_path/../Frameworks/libicui18n.74.dylib qlog.app/Contents/MacOS/qlog - otool -L qlog.app/Contents/MacOS/qlog - macdeployqt qlog.app -dmg + APP="qlog.app" + + # 1) Deploy first (creates Contents/Frameworks etc.) + macdeployqt "$APP" \ + -always-overwrite \ + -verbose=2 \ + -executable="$APP/Contents/MacOS/qlog" \ + -qmldir=.. + + # 2) Ensure Frameworks folder exists (macdeployqt should do this, but guard anyway) + mkdir -p "$APP/Contents/Frameworks" + + # 3) (Optional) copy extra non-Qt dylibs AFTER deploy + ICU_PATH="$(brew --prefix icu4c)/lib" + cp "$(brew --prefix)"/lib/libhamlib.dylib "$APP/Contents/Frameworks/" || true + cp "$(brew --prefix)"/lib/libqt6keychain.dylib "$APP/Contents/Frameworks/" || true + cp "$ICU_PATH"/libicui18n*.dylib "$APP/Contents/Frameworks/" || true + + # 4) Patch QtWebEngineProcess helper to stop using /opt/homebrew + HELPER_BIN="$APP/Contents/Frameworks/QtWebEngineCore.framework/Versions/A/Helpers/QtWebEngineProcess.app/Contents/MacOS/QtWebEngineProcess" + + echo "Helper deps BEFORE:" + otool -L "$HELPER_BIN" | sed 's/^/ /' + + # Add rpath so @rpath/Qt*.framework resolves to app Frameworks + install_name_tool -add_rpath "@executable_path/../../../../../../../../Frameworks" "$HELPER_BIN" || true + + # Rewrite any hardcoded Homebrew Qt framework references to @rpath equivalents + # (This handles QtOpenGL, QtQuick, etc.) + while read -r dep; do + # dep looks like: /opt/homebrew/opt/qtbase/lib/QtOpenGL.framework/Versions/A/QtOpenGL + fw="$(basename "$dep")" # QtOpenGL + fwdir="$(basename "$(dirname "$(dirname "$(dirname "$dep")")")")" # QtOpenGL.framework + install_name_tool -change "$dep" "@rpath/$fwdir/Versions/A/$fw" "$HELPER_BIN" || true + done < <(otool -L "$HELPER_BIN" | awk '{print $1}' | grep '^/opt/homebrew/opt/qt.*/lib/Qt.*\.framework/') + + # Remove brew rpaths if present + install_name_tool -delete_rpath "/opt/homebrew/opt/qtbase/lib" "$HELPER_BIN" || true + install_name_tool -delete_rpath "/opt/homebrew/opt/qtdeclarative/lib" "$HELPER_BIN" || true + + echo "Helper deps AFTER:" + otool -L "$HELPER_BIN" | sed 's/^/ /' + otool -l "$HELPER_BIN" | grep -A2 LC_RPATH || true + + - name: Codesign app bundle + env: + MACOS_CERTIFICATE: ${{ secrets.PROD_MACOS_CERTIFICATE }} # base64 p12 + MACOS_CERTIFICATE_PWD: ${{ secrets.PROD_MACOS_CERTIFICATE_PWD }} # p12 password + MACOS_CERTIFICATE_NAME: ${{ secrets.PROD_MACOS_CERTIFICATE_NAME }} # "Developer ID Application: ... (TEAMID)" + MACOS_CI_KEYCHAIN_PWD: ${{ secrets.PROD_MACOS_CI_KEYCHAIN_PWD }} + run: | + set -euo pipefail + + APP="$GITHUB_WORKSPACE/build/qlog.app" + ENT="$GITHUB_WORKSPACE/entitlements.xml" + KEYCHAIN="$RUNNER_TEMP/build.keychain-db" + + echo "Entitlements:" + ls -l "$ENT" + + echo "== Create+unlock CI keychain ==" + security create-keychain -p "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" + security set-keychain-settings -lut 21600 "$KEYCHAIN" + security unlock-keychain -p "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" + security list-keychains -d user -s "$KEYCHAIN" + security default-keychain -d user -s "$KEYCHAIN" + + echo "== Import signing cert (.p12) ==" + echo "$MACOS_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/cert.p12" + security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" -P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign -T /usr/bin/security + + # allow codesign to access the key without UI prompts + security set-key-partition-list -S apple-tool:,apple: -s -k "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" + + echo "== Confirm identity ==" + security find-identity -v -p codesigning "$KEYCHAIN" || true + + echo "== 1) Sign frameworks/plugins/dylibs first (NO entitlements) ==" + while IFS= read -r -d '' F; do + /usr/bin/codesign --force --timestamp --options runtime \ + -s "$MACOS_CERTIFICATE_NAME" "$F" + done < <( + find "$APP" -type f -print0 | while IFS= read -r -d '' F; do + if file "$F" | grep -q "Mach-O"; then + # skip QtWebEngineProcess here; we'll sign it with entitlements below + if [[ "$F" == *"/QtWebEngineProcess.app/Contents/MacOS/QtWebEngineProcess" ]]; then + continue + fi + printf '%s\0' "$F" + fi + done + ) + + echo "== 2) Sign ALL QtWebEngineProcess binaries WITH entitlements ==" + mapfile -t WEBENGINE_BINS < <(find "$APP" -path "*QtWebEngineProcess.app/Contents/MacOS/QtWebEngineProcess" -type f) + if [[ "${#WEBENGINE_BINS[@]}" -eq 0 ]]; then + echo "ERROR: no QtWebEngineProcess binary found" + exit 1 + fi + + for BIN in "${WEBENGINE_BINS[@]}"; do + echo "Signing WebEngine helper bin: $BIN" + /usr/bin/codesign --force --timestamp --options runtime \ + --entitlements "$ENT" \ + -s "$MACOS_CERTIFICATE_NAME" "$BIN" + done + + echo "== 3) Sign ALL QtWebEngineProcess.app bundles WITH entitlements ==" + mapfile -t WEBENGINE_APPS < <(find "$APP" -path "*QtWebEngineProcess.app" -type d) + for HAPP in "${WEBENGINE_APPS[@]}"; do + echo "Signing WebEngine helper app: $HAPP" + /usr/bin/codesign --force --timestamp --options runtime \ + --entitlements "$ENT" \ + -s "$MACOS_CERTIFICATE_NAME" "$HAPP" + done + + echo "== 4) Sign top-level app WITH entitlements ==" + /usr/bin/codesign --force --timestamp --options runtime \ + --entitlements "$ENT" \ + -s "$MACOS_CERTIFICATE_NAME" "$APP" + + echo "== 5) Verify + prove entitlements stuck ==" + /usr/bin/codesign --verify --deep --strict --verbose=4 "$APP" + + echo "Entitlements on one QtWebEngineProcess binary (should NOT be empty):" + /usr/bin/codesign -d --entitlements :- "${WEBENGINE_BINS[0]}" || true + + spctl -a -t exec -vv "$APP" + + - name: "Notarize app bundle" + env: + PROD_MACOS_NOTARIZATION_APPLE_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_APPLE_ID }} + PROD_MACOS_NOTARIZATION_TEAM_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_TEAM_ID }} + PROD_MACOS_NOTARIZATION_PWD: ${{ secrets.PROD_MACOS_NOTARIZATION_PWD }} + run: | + set -euo pipefail + + echo "Create keychain profile" + xcrun notarytool store-credentials "notarytool-profile" \ + --apple-id "$PROD_MACOS_NOTARIZATION_APPLE_ID" \ + --team-id "$PROD_MACOS_NOTARIZATION_TEAM_ID" \ + --password "$PROD_MACOS_NOTARIZATION_PWD" + + echo "Creating temp notarization archive" + ditto -c -k --keepParent "/Users/runner/work/QLog/QLog/build/qlog.app" "notarization.zip" + + echo "Notarize app (submit + wait)" + xcrun notarytool submit "notarization.zip" \ + --keychain-profile "notarytool-profile" \ + --wait \ + --output-format json > notarization_log.json + + cat notarization_log.json + + # Extract id + status (no jq needed) + NOTARY_ID=$(python3 -c 'import json; print(json.load(open("notarization_log.json"))["id"])') + NOTARY_STATUS=$(python3 -c 'import json; print(json.load(open("notarization_log.json"))["status"])') + + echo "Notary Request ID: $NOTARY_ID" + echo "Notary Status: $NOTARY_STATUS" + + echo "Fetching detailed notary log..." + xcrun notarytool log "$NOTARY_ID" --keychain-profile "notarytool-profile" || true + + if [ "$NOTARY_STATUS" != "Accepted" ]; then + echo "Notarization failed with status: $NOTARY_STATUS" + exit 1 + fi + + echo "Attach staple" + xcrun stapler staple "/Users/runner/work/QLog/QLog/build/qlog.app" + xcrun stapler validate "/Users/runner/work/QLog/QLog/build/qlog.app" + - name: make dmg + run: | + set -euo pipefail + # use TAGVERSION, but make a nicer filename (strip the "-14-g...." if you want) + VERSION="${TAGVERSION%%-*}" # => "0.47.1" from "0.47.1-14-g0022e427" + DMG_NAME="QLog.v${VERSION}.dmg" + echo "DMG_NAME=$DMG_NAME" >> "$GITHUB_ENV" + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + + mkdir -p out + cp -R "/Users/runner/work/QLog/QLog/build/qlog.app" out/ + ln -s /Applications out/Applications + + hdiutil create \ + -volname "QLog Installer" \ + -srcfolder out \ + -ov -format UDZO \ + "/Users/runner/work/QLog/QLog/build/$DMG_NAME" + + ls -lh "/Users/runner/work/QLog/QLog/build/$DMG_NAME" + + - name: Codesign dmg bundle + env: + MACOS_CERTIFICATE: ${{ secrets.PROD_MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PWD: ${{ secrets.PROD_MACOS_CERTIFICATE_PWD }} + MACOS_CI_KEYCHAIN_PWD: ${{ secrets.PROD_MACOS_CI_KEYCHAIN_PWD }} + run: | + set -euo pipefail + + DMG="$GITHUB_WORKSPACE/build/$DMG_NAME" + test -f "$DMG" + + KEYCHAIN="$RUNNER_TEMP/build.keychain-db" + + # If the prior step already created/imported, this will work as-is. + # But on Actions, each step is a new shell: re-create+import to be safe. + security create-keychain -p "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" || true + security unlock-keychain -p "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" + security list-keychains -d user -s "$KEYCHAIN" + security default-keychain -d user -s "$KEYCHAIN" + + echo "$MACOS_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/cert.p12" + security import "$RUNNER_TEMP/cert.p12" \ + -k "$KEYCHAIN" \ + -P "$MACOS_CERTIFICATE_PWD" \ + -T /usr/bin/codesign \ + -T /usr/bin/security || true + + security set-key-partition-list -S apple-tool:,apple:,codesign: \ + -s -k "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" + + SIGN_ID_SHA=$(security find-identity -v -p codesigning "$KEYCHAIN" \ + | awk '/Developer ID Application/ {print $2; exit}') + if [[ -z "${SIGN_ID_SHA:-}" ]]; then + echo "ERROR: No Developer ID Application identity found in keychain." + exit 1 + fi + + /usr/bin/codesign --force --timestamp -s "$SIGN_ID_SHA" "$DMG" + /usr/bin/codesign -v --verbose=4 "$DMG" + + - name: Notarize dmg + env: + PROD_MACOS_NOTARIZATION_APPLE_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_APPLE_ID }} + PROD_MACOS_NOTARIZATION_TEAM_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_TEAM_ID }} + PROD_MACOS_NOTARIZATION_PWD: ${{ secrets.PROD_MACOS_NOTARIZATION_PWD }} + run: | + set -euo pipefail + DMG="/Users/runner/work/QLog/QLog/build/$DMG_NAME" + test -f "$DMG" + + xcrun notarytool store-credentials "notarytool-profile" \ + --apple-id "$PROD_MACOS_NOTARIZATION_APPLE_ID" \ + --team-id "$PROD_MACOS_NOTARIZATION_TEAM_ID" \ + --password "$PROD_MACOS_NOTARIZATION_PWD" + + ditto -c -k "$DMG" "notarization.zip" + + xcrun notarytool submit "notarization.zip" \ + --keychain-profile "notarytool-profile" \ + --wait --output-format json > notarization_dmg.json + cat notarization_dmg.json + + NOTARY_ID=$(python3 -c 'import json; print(json.load(open("notarization_dmg.json"))["id"])') + xcrun notarytool log "$NOTARY_ID" --keychain-profile "notarytool-profile" || true + + xcrun stapler staple "$DMG" + xcrun stapler validate "$DMG" - name: Copy artifact uses: actions/upload-artifact@v4 with: - name: QLog-${{ env.TAGVERSION }}-${{ matrix.os }} - path: /Users/runner/work/QLog/QLog/build/qlog.dmg - + name: QLog-${{ env.TAGVERSION }}-macos + path: /Users/runner/work/QLog/QLog/build/${{ env.DMG_NAME }} From b545cad8f03746e6f56419c36daf262df05f9138 Mon Sep 17 00:00:00 2001 From: Michael Morgan <84428382+aa5sh@users.noreply.github.com> Date: Thu, 19 Mar 2026 19:25:14 -0500 Subject: [PATCH 15/21] Use mkdir -p to create build directory --- .github/workflows/macOSBuild.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/macOSBuild.yml b/.github/workflows/macOSBuild.yml index f7b61a11..9b8dd7b0 100644 --- a/.github/workflows/macOSBuild.yml +++ b/.github/workflows/macOSBuild.yml @@ -45,7 +45,7 @@ jobs: - name: Configure and compile run: | - mkdir build + mkdir -p build cd build qmake -config release .. make -j4 From 5bfb3f91a4b4a597bfb8c8841dc74e3154085512 Mon Sep 17 00:00:00 2001 From: Michael Morgan <84428382+aa5sh@users.noreply.github.com> Date: Fri, 1 May 2026 08:36:33 -0500 Subject: [PATCH 16/21] Refactor macOS CI workflow for better build process Updated macOS CI workflow to include additional dependencies, improved tagging support, and enhanced error handling for notarization and codesigning processes. --- .github/workflows/macOSBuild.yml | 669 +++++++++++++++++-------------- 1 file changed, 357 insertions(+), 312 deletions(-) diff --git a/.github/workflows/macOSBuild.yml b/.github/workflows/macOSBuild.yml index 9b8dd7b0..af3cdff8 100644 --- a/.github/workflows/macOSBuild.yml +++ b/.github/workflows/macOSBuild.yml @@ -1,320 +1,365 @@ name: macOS deployment -#on: [push, pull_request] - on: workflow_dispatch: push: - branches: - - master + branches: + - master + tags: + - 'v*' jobs: macos-build: - name: MacOS Build - strategy: - matrix: - os: [macos-latest] - - runs-on: ${{ matrix.os }} - - steps: - - name: Install Dependencies - run: | - unset HOMEBREW_NO_INSTALL_FROM_API - brew update - brew upgrade || true - brew install qt6 - brew install qtvirtualkeyboard - brew link qt6 --force - brew install hamlib - brew link hamlib --force - brew install qtkeychain - brew install dbus-glib - brew install brotli - brew install icu4c - brew install pkg-config - - name: Checkout Code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - submodules: recursive - - name: Get version from tag - run : | - TAGVERSION=$(git describe --tags) - echo "TAGVERSION=${TAGVERSION:1}" >> $GITHUB_ENV - - - name: Configure and compile - run: | - mkdir -p build - cd build - qmake -config release .. - make -j4 - - name: Build app - run: | - set -euo pipefail - cd build - APP="qlog.app" - - # 1) Deploy first (creates Contents/Frameworks etc.) - macdeployqt "$APP" \ - -always-overwrite \ - -verbose=2 \ - -executable="$APP/Contents/MacOS/qlog" \ - -qmldir=.. - - # 2) Ensure Frameworks folder exists (macdeployqt should do this, but guard anyway) - mkdir -p "$APP/Contents/Frameworks" - - # 3) (Optional) copy extra non-Qt dylibs AFTER deploy - ICU_PATH="$(brew --prefix icu4c)/lib" - cp "$(brew --prefix)"/lib/libhamlib.dylib "$APP/Contents/Frameworks/" || true - cp "$(brew --prefix)"/lib/libqt6keychain.dylib "$APP/Contents/Frameworks/" || true - cp "$ICU_PATH"/libicui18n*.dylib "$APP/Contents/Frameworks/" || true - - # 4) Patch QtWebEngineProcess helper to stop using /opt/homebrew - HELPER_BIN="$APP/Contents/Frameworks/QtWebEngineCore.framework/Versions/A/Helpers/QtWebEngineProcess.app/Contents/MacOS/QtWebEngineProcess" - - echo "Helper deps BEFORE:" - otool -L "$HELPER_BIN" | sed 's/^/ /' - - # Add rpath so @rpath/Qt*.framework resolves to app Frameworks - install_name_tool -add_rpath "@executable_path/../../../../../../../../Frameworks" "$HELPER_BIN" || true - - # Rewrite any hardcoded Homebrew Qt framework references to @rpath equivalents - # (This handles QtOpenGL, QtQuick, etc.) - while read -r dep; do - # dep looks like: /opt/homebrew/opt/qtbase/lib/QtOpenGL.framework/Versions/A/QtOpenGL - fw="$(basename "$dep")" # QtOpenGL - fwdir="$(basename "$(dirname "$(dirname "$(dirname "$dep")")")")" # QtOpenGL.framework - install_name_tool -change "$dep" "@rpath/$fwdir/Versions/A/$fw" "$HELPER_BIN" || true - done < <(otool -L "$HELPER_BIN" | awk '{print $1}' | grep '^/opt/homebrew/opt/qt.*/lib/Qt.*\.framework/') - - # Remove brew rpaths if present - install_name_tool -delete_rpath "/opt/homebrew/opt/qtbase/lib" "$HELPER_BIN" || true - install_name_tool -delete_rpath "/opt/homebrew/opt/qtdeclarative/lib" "$HELPER_BIN" || true - - echo "Helper deps AFTER:" - otool -L "$HELPER_BIN" | sed 's/^/ /' - otool -l "$HELPER_BIN" | grep -A2 LC_RPATH || true - - - name: Codesign app bundle - env: - MACOS_CERTIFICATE: ${{ secrets.PROD_MACOS_CERTIFICATE }} # base64 p12 - MACOS_CERTIFICATE_PWD: ${{ secrets.PROD_MACOS_CERTIFICATE_PWD }} # p12 password - MACOS_CERTIFICATE_NAME: ${{ secrets.PROD_MACOS_CERTIFICATE_NAME }} # "Developer ID Application: ... (TEAMID)" - MACOS_CI_KEYCHAIN_PWD: ${{ secrets.PROD_MACOS_CI_KEYCHAIN_PWD }} - run: | - set -euo pipefail - - APP="$GITHUB_WORKSPACE/build/qlog.app" - ENT="$GITHUB_WORKSPACE/entitlements.xml" - KEYCHAIN="$RUNNER_TEMP/build.keychain-db" - - echo "Entitlements:" - ls -l "$ENT" - - echo "== Create+unlock CI keychain ==" - security create-keychain -p "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" - security set-keychain-settings -lut 21600 "$KEYCHAIN" - security unlock-keychain -p "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" - security list-keychains -d user -s "$KEYCHAIN" - security default-keychain -d user -s "$KEYCHAIN" - - echo "== Import signing cert (.p12) ==" - echo "$MACOS_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/cert.p12" - security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" -P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign -T /usr/bin/security - - # allow codesign to access the key without UI prompts - security set-key-partition-list -S apple-tool:,apple: -s -k "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" - - echo "== Confirm identity ==" - security find-identity -v -p codesigning "$KEYCHAIN" || true - - echo "== 1) Sign frameworks/plugins/dylibs first (NO entitlements) ==" - while IFS= read -r -d '' F; do - /usr/bin/codesign --force --timestamp --options runtime \ - -s "$MACOS_CERTIFICATE_NAME" "$F" - done < <( - find "$APP" -type f -print0 | while IFS= read -r -d '' F; do - if file "$F" | grep -q "Mach-O"; then - # skip QtWebEngineProcess here; we'll sign it with entitlements below - if [[ "$F" == *"/QtWebEngineProcess.app/Contents/MacOS/QtWebEngineProcess" ]]; then - continue - fi - printf '%s\0' "$F" - fi - done - ) - - echo "== 2) Sign ALL QtWebEngineProcess binaries WITH entitlements ==" - mapfile -t WEBENGINE_BINS < <(find "$APP" -path "*QtWebEngineProcess.app/Contents/MacOS/QtWebEngineProcess" -type f) - if [[ "${#WEBENGINE_BINS[@]}" -eq 0 ]]; then - echo "ERROR: no QtWebEngineProcess binary found" - exit 1 - fi - - for BIN in "${WEBENGINE_BINS[@]}"; do - echo "Signing WebEngine helper bin: $BIN" - /usr/bin/codesign --force --timestamp --options runtime \ - --entitlements "$ENT" \ - -s "$MACOS_CERTIFICATE_NAME" "$BIN" - done - - echo "== 3) Sign ALL QtWebEngineProcess.app bundles WITH entitlements ==" - mapfile -t WEBENGINE_APPS < <(find "$APP" -path "*QtWebEngineProcess.app" -type d) - for HAPP in "${WEBENGINE_APPS[@]}"; do - echo "Signing WebEngine helper app: $HAPP" - /usr/bin/codesign --force --timestamp --options runtime \ - --entitlements "$ENT" \ - -s "$MACOS_CERTIFICATE_NAME" "$HAPP" - done - - echo "== 4) Sign top-level app WITH entitlements ==" - /usr/bin/codesign --force --timestamp --options runtime \ - --entitlements "$ENT" \ - -s "$MACOS_CERTIFICATE_NAME" "$APP" - - echo "== 5) Verify + prove entitlements stuck ==" - /usr/bin/codesign --verify --deep --strict --verbose=4 "$APP" - - echo "Entitlements on one QtWebEngineProcess binary (should NOT be empty):" - /usr/bin/codesign -d --entitlements :- "${WEBENGINE_BINS[0]}" || true - - spctl -a -t exec -vv "$APP" - - - name: "Notarize app bundle" - env: - PROD_MACOS_NOTARIZATION_APPLE_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_APPLE_ID }} - PROD_MACOS_NOTARIZATION_TEAM_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_TEAM_ID }} - PROD_MACOS_NOTARIZATION_PWD: ${{ secrets.PROD_MACOS_NOTARIZATION_PWD }} - run: | - set -euo pipefail - - echo "Create keychain profile" - xcrun notarytool store-credentials "notarytool-profile" \ - --apple-id "$PROD_MACOS_NOTARIZATION_APPLE_ID" \ - --team-id "$PROD_MACOS_NOTARIZATION_TEAM_ID" \ - --password "$PROD_MACOS_NOTARIZATION_PWD" - - echo "Creating temp notarization archive" - ditto -c -k --keepParent "/Users/runner/work/QLog/QLog/build/qlog.app" "notarization.zip" - - echo "Notarize app (submit + wait)" - xcrun notarytool submit "notarization.zip" \ - --keychain-profile "notarytool-profile" \ - --wait \ - --output-format json > notarization_log.json - - cat notarization_log.json - - # Extract id + status (no jq needed) - NOTARY_ID=$(python3 -c 'import json; print(json.load(open("notarization_log.json"))["id"])') - NOTARY_STATUS=$(python3 -c 'import json; print(json.load(open("notarization_log.json"))["status"])') - - echo "Notary Request ID: $NOTARY_ID" - echo "Notary Status: $NOTARY_STATUS" - - echo "Fetching detailed notary log..." - xcrun notarytool log "$NOTARY_ID" --keychain-profile "notarytool-profile" || true - - if [ "$NOTARY_STATUS" != "Accepted" ]; then - echo "Notarization failed with status: $NOTARY_STATUS" - exit 1 - fi - - echo "Attach staple" - xcrun stapler staple "/Users/runner/work/QLog/QLog/build/qlog.app" - xcrun stapler validate "/Users/runner/work/QLog/QLog/build/qlog.app" - - name: make dmg - run: | - set -euo pipefail - # use TAGVERSION, but make a nicer filename (strip the "-14-g...." if you want) - VERSION="${TAGVERSION%%-*}" # => "0.47.1" from "0.47.1-14-g0022e427" - DMG_NAME="QLog.v${VERSION}.dmg" - echo "DMG_NAME=$DMG_NAME" >> "$GITHUB_ENV" - echo "VERSION=$VERSION" >> "$GITHUB_ENV" - - mkdir -p out - cp -R "/Users/runner/work/QLog/QLog/build/qlog.app" out/ - ln -s /Applications out/Applications - - hdiutil create \ - -volname "QLog Installer" \ - -srcfolder out \ - -ov -format UDZO \ - "/Users/runner/work/QLog/QLog/build/$DMG_NAME" - - ls -lh "/Users/runner/work/QLog/QLog/build/$DMG_NAME" - - - name: Codesign dmg bundle - env: - MACOS_CERTIFICATE: ${{ secrets.PROD_MACOS_CERTIFICATE }} - MACOS_CERTIFICATE_PWD: ${{ secrets.PROD_MACOS_CERTIFICATE_PWD }} - MACOS_CI_KEYCHAIN_PWD: ${{ secrets.PROD_MACOS_CI_KEYCHAIN_PWD }} - run: | - set -euo pipefail - - DMG="$GITHUB_WORKSPACE/build/$DMG_NAME" - test -f "$DMG" - - KEYCHAIN="$RUNNER_TEMP/build.keychain-db" - - # If the prior step already created/imported, this will work as-is. - # But on Actions, each step is a new shell: re-create+import to be safe. - security create-keychain -p "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" || true - security unlock-keychain -p "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" - security list-keychains -d user -s "$KEYCHAIN" - security default-keychain -d user -s "$KEYCHAIN" - - echo "$MACOS_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/cert.p12" - security import "$RUNNER_TEMP/cert.p12" \ - -k "$KEYCHAIN" \ - -P "$MACOS_CERTIFICATE_PWD" \ - -T /usr/bin/codesign \ - -T /usr/bin/security || true - - security set-key-partition-list -S apple-tool:,apple:,codesign: \ - -s -k "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" - - SIGN_ID_SHA=$(security find-identity -v -p codesigning "$KEYCHAIN" \ - | awk '/Developer ID Application/ {print $2; exit}') - if [[ -z "${SIGN_ID_SHA:-}" ]]; then - echo "ERROR: No Developer ID Application identity found in keychain." - exit 1 - fi - - /usr/bin/codesign --force --timestamp -s "$SIGN_ID_SHA" "$DMG" - /usr/bin/codesign -v --verbose=4 "$DMG" - - - name: Notarize dmg - env: - PROD_MACOS_NOTARIZATION_APPLE_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_APPLE_ID }} - PROD_MACOS_NOTARIZATION_TEAM_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_TEAM_ID }} - PROD_MACOS_NOTARIZATION_PWD: ${{ secrets.PROD_MACOS_NOTARIZATION_PWD }} - run: | - set -euo pipefail - DMG="/Users/runner/work/QLog/QLog/build/$DMG_NAME" - test -f "$DMG" - - xcrun notarytool store-credentials "notarytool-profile" \ - --apple-id "$PROD_MACOS_NOTARIZATION_APPLE_ID" \ - --team-id "$PROD_MACOS_NOTARIZATION_TEAM_ID" \ - --password "$PROD_MACOS_NOTARIZATION_PWD" - - ditto -c -k "$DMG" "notarization.zip" - - xcrun notarytool submit "notarization.zip" \ - --keychain-profile "notarytool-profile" \ - --wait --output-format json > notarization_dmg.json - cat notarization_dmg.json - - NOTARY_ID=$(python3 -c 'import json; print(json.load(open("notarization_dmg.json"))["id"])') - xcrun notarytool log "$NOTARY_ID" --keychain-profile "notarytool-profile" || true - - xcrun stapler staple "$DMG" - xcrun stapler validate "$DMG" - - name: Copy artifact - uses: actions/upload-artifact@v4 - with: - name: QLog-${{ env.TAGVERSION }}-macos - path: /Users/runner/work/QLog/QLog/build/${{ env.DMG_NAME }} + name: macOS Build + runs-on: macos-latest + + env: + APP_NAME: QLog + BUNDLE_BIN: qlog + QT_PREFIX: /opt/homebrew + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: recursive + + - name: Install Dependencies + run: | + set -euo pipefail + unset HOMEBREW_NO_INSTALL_FROM_API + brew update + brew upgrade || true + brew install \ + qt6 \ + hamlib \ + qtkeychain \ + dbus-glib \ + brotli \ + icu4c \ + pkg-config \ + autoconf \ + automake \ + libtool \ + libusb + # qt6 is keg-only; many Qt-aware tools rely on the linkage being visible + brew link qt6 --force || true + brew link hamlib --force || true + + - name: Get version from tag + run: | + set -euo pipefail + # If we're on a tag like v0.50.0, use that; otherwise fall back to describe. + if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then + TAGVERSION="${GITHUB_REF#refs/tags/v}" + else + RAW="$(git describe --tags --always 2>/dev/null || echo dev)" + TAGVERSION="${RAW#v}" + fi + echo "TAGVERSION=${TAGVERSION}" >> "$GITHUB_ENV" + echo "Version: ${TAGVERSION}" + + - name: Ensure QtSvg module is enabled + run: | + set -euo pipefail + PRO="qlog.pro" + if ! grep -q ' svg' "$PRO"; then + echo "Adding svg to QT modules" + awk ' + BEGIN { added=0 } + /^[[:space:]]*QT[[:space:]]/ && !added { + print $0 " svg"; added=1; next + } + { print } + ' "$PRO" > "$PRO.tmp" && mv "$PRO.tmp" "$PRO" + else + echo "QtSvg already present" + fi + + - name: Configure and compile + run: | + set -euo pipefail + mkdir -p build + cd build + qmake CONFIG+=release .. + make -j"$(sysctl -n hw.ncpu)" + + - name: Run macdeployqt + run: | + set -euo pipefail + cd build + APP="$APP_NAME.app" + # macdeployqt builds Contents/Frameworks and rewrites Qt links. + macdeployqt "$APP" \ + -always-overwrite \ + -verbose=2 \ + -executable="$APP/Contents/MacOS/$BUNDLE_BIN" \ + -libpath="$QT_PREFIX/opt/qtbase/lib" \ + -libpath="$QT_PREFIX/opt/qtdeclarative/lib" \ + -libpath="$QT_PREFIX/opt/qtwebengine/lib" \ + -libpath="$QT_PREFIX/opt/qtsvg/lib" + + - name: Bundle rigctld and fix linkage + run: | + set -euo pipefail + cd build + APP="$APP_NAME.app" + MACOS_DIR="$APP/Contents/MacOS" + FRAMEWORKS_DIR="$APP/Contents/Frameworks" + mkdir -p "$MACOS_DIR" "$FRAMEWORKS_DIR" + + RIGCTLD_SRC="$(command -v rigctld)" + [ -n "$RIGCTLD_SRC" ] || { echo "ERROR: rigctld not found"; exit 1; } + echo "Using rigctld from: $RIGCTLD_SRC" + cp -f "$RIGCTLD_SRC" "$MACOS_DIR/rigctld" + chmod 0755 "$MACOS_DIR/rigctld" + + install_name_tool -add_rpath "@executable_path/../Frameworks" \ + "$MACOS_DIR/rigctld" 2>/dev/null || true + + # Recursively bundle any /opt/homebrew or /usr/local dependency, fix + # install IDs to @rpath, and rewrite the parent's link. + bundle_deps() { + local target="$1" + local dep base + otool -L "$target" | awk 'NR>1 {print $1}' | while read -r dep; do + case "$dep" in + /opt/homebrew/*|/usr/local/*) + base="$(basename "$dep")" + if [ ! -f "$FRAMEWORKS_DIR/$base" ]; then + echo " -> bundling $base (from $dep)" + cp -f "$dep" "$FRAMEWORKS_DIR/$base" + chmod u+w "$FRAMEWORKS_DIR/$base" + install_name_tool -id "@rpath/$base" "$FRAMEWORKS_DIR/$base" + bundle_deps "$FRAMEWORKS_DIR/$base" + fi + install_name_tool -change "$dep" "@rpath/$base" "$target" + ;; + esac + done + } + + bundle_deps "$MACOS_DIR/rigctld" + bundle_deps "$MACOS_DIR/$BUNDLE_BIN" + + echo "rigctld linkage:" + otool -L "$MACOS_DIR/rigctld" + echo "$BUNDLE_BIN linkage:" + otool -L "$MACOS_DIR/$BUNDLE_BIN" + + - name: Fix QtWebEngineProcess rpaths + run: | + set -euo pipefail + cd build + APP="$APP_NAME.app" + QWEP="$APP/Contents/Frameworks/QtWebEngineCore.framework/Versions/A/Helpers/QtWebEngineProcess.app/Contents/MacOS/QtWebEngineProcess" + [ -f "$QWEP" ] || { echo "QtWebEngineProcess not found at $QWEP"; exit 1; } + + install_name_tool -add_rpath \ + "@executable_path/../../../../Frameworks" "$QWEP" 2>/dev/null || true + + # Rewrite any remaining hardcoded Qt framework references to @rpath. + otool -L "$QWEP" | awk 'NR>1 {print $1}' \ + | grep '^/opt/homebrew/.*\.framework/' \ + | while read -r dep; do + fw="$(basename "$dep")" + fwdir="$(basename "$(dirname "$(dirname "$(dirname "$dep")")")")" + install_name_tool -change "$dep" \ + "@rpath/$fwdir/Versions/A/$fw" "$QWEP" || true + done + + echo "QtWebEngineProcess after fixup:" + otool -L "$QWEP" + + - name: Scan for hardcoded Homebrew paths + run: | + set -euo pipefail + cd build + APP="$APP_NAME.app" + BAD=0 + while IFS= read -r -d '' BIN; do + if otool -L "$BIN" 2>/dev/null \ + | awk 'NR>1 {print $1}' \ + | grep -E '^(/opt/homebrew/|/usr/local/Cellar/|/usr/local/opt/)' >/dev/null; then + echo "Hardcoded Homebrew path in: $BIN" + otool -L "$BIN" | awk 'NR>1 {print " " $1}' \ + | grep -E '^[[:space:]]+(/opt/homebrew/|/usr/local/Cellar/|/usr/local/opt/)' + BAD=1 + fi + done < <(find "$APP" -type f -perm +111 -print0) + if [ "$BAD" -eq 1 ]; then + echo "ERROR: hardcoded Homebrew paths remain. Bundle would fail on user machines." + exit 1 + fi + + - name: Codesign app bundle + env: + MACOS_CERTIFICATE: ${{ secrets.PROD_MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PWD: ${{ secrets.PROD_MACOS_CERTIFICATE_PWD }} + MACOS_CERTIFICATE_NAME: ${{ secrets.PROD_MACOS_CERTIFICATE_NAME }} + MACOS_CI_KEYCHAIN_PWD: ${{ secrets.PROD_MACOS_CI_KEYCHAIN_PWD }} + run: | + set -euo pipefail + APP="$GITHUB_WORKSPACE/build/$APP_NAME.app" + ENT="$GITHUB_WORKSPACE/entitlements.xml" + KEYCHAIN="$RUNNER_TEMP/build.keychain-db" + + test -f "$ENT" || { echo "Missing entitlements.xml at $ENT"; exit 1; } + + security create-keychain -p "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" + security set-keychain-settings -lut 21600 "$KEYCHAIN" + security unlock-keychain -p "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" + security list-keychains -d user -s "$KEYCHAIN" + security default-keychain -d user -s "$KEYCHAIN" + + echo "$MACOS_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/cert.p12" + security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" \ + -P "$MACOS_CERTIFICATE_PWD" \ + -T /usr/bin/codesign -T /usr/bin/security + security set-key-partition-list -S apple-tool:,apple: \ + -s -k "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" + + security find-identity -v -p codesigning "$KEYCHAIN" || true + + # 1) Sign every Mach-O inside the bundle (no entitlements), skipping + # QtWebEngineProcess which we sign with entitlements below. + while IFS= read -r -d '' F; do + if file "$F" | grep -q "Mach-O"; then + if [[ "$F" == *"/QtWebEngineProcess.app/Contents/MacOS/QtWebEngineProcess" ]]; then + continue + fi + /usr/bin/codesign --force --timestamp --options runtime \ + -s "$MACOS_CERTIFICATE_NAME" "$F" + fi + done < <(find "$APP" -type f -print0) + + # 2) Sign QtWebEngineProcess binaries WITH entitlements + while IFS= read -r -d '' BIN; do + echo "Signing WebEngine helper: $BIN" + /usr/bin/codesign --force --timestamp --options runtime \ + --entitlements "$ENT" \ + -s "$MACOS_CERTIFICATE_NAME" "$BIN" + done < <(find "$APP" -path "*QtWebEngineProcess.app/Contents/MacOS/QtWebEngineProcess" -type f -print0) + + # 3) Sign QtWebEngineProcess.app bundles WITH entitlements + while IFS= read -r -d '' HAPP; do + echo "Signing WebEngine helper app: $HAPP" + /usr/bin/codesign --force --timestamp --options runtime \ + --entitlements "$ENT" \ + -s "$MACOS_CERTIFICATE_NAME" "$HAPP" + done < <(find "$APP" -path "*QtWebEngineProcess.app" -type d -print0) + + # 4) Sign the outer app bundle WITH entitlements + /usr/bin/codesign --force --timestamp --options runtime \ + --entitlements "$ENT" \ + -s "$MACOS_CERTIFICATE_NAME" "$APP" + + /usr/bin/codesign --verify --deep --strict --verbose=2 "$APP" + spctl -a -t exec -vv "$APP" || true + + - name: Notarize app bundle + env: + PROD_MACOS_NOTARIZATION_APPLE_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_APPLE_ID }} + PROD_MACOS_NOTARIZATION_TEAM_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_TEAM_ID }} + PROD_MACOS_NOTARIZATION_PWD: ${{ secrets.PROD_MACOS_NOTARIZATION_PWD }} + run: | + set -euo pipefail + APP="$GITHUB_WORKSPACE/build/$APP_NAME.app" + + xcrun notarytool store-credentials "notarytool-profile" \ + --apple-id "$PROD_MACOS_NOTARIZATION_APPLE_ID" \ + --team-id "$PROD_MACOS_NOTARIZATION_TEAM_ID" \ + --password "$PROD_MACOS_NOTARIZATION_PWD" + + ditto -c -k --sequesterRsrc --keepParent "$APP" "notarization.zip" + + xcrun notarytool submit "notarization.zip" \ + --keychain-profile "notarytool-profile" \ + --wait --output-format json > notarization_log.json + cat notarization_log.json + + NOTARY_ID=$(python3 -c 'import json; print(json.load(open("notarization_log.json"))["id"])') + NOTARY_STATUS=$(python3 -c 'import json; print(json.load(open("notarization_log.json"))["status"])') + + xcrun notarytool log "$NOTARY_ID" --keychain-profile "notarytool-profile" || true + + if [ "$NOTARY_STATUS" != "Accepted" ]; then + echo "Notarization failed: $NOTARY_STATUS" + exit 1 + fi + + xcrun stapler staple "$APP" + xcrun stapler validate "$APP" + + - name: Build DMG + run: | + set -euo pipefail + VERSION="${TAGVERSION%%-*}" + DMG_NAME="$APP_NAME.v${VERSION}.dmg" + echo "DMG_NAME=$DMG_NAME" >> "$GITHUB_ENV" + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + + STAGING="$GITHUB_WORKSPACE/build/dmg-staging" + rm -rf "$STAGING" + mkdir -p "$STAGING" + cp -R "$GITHUB_WORKSPACE/build/$APP_NAME.app" "$STAGING/" + ln -s /Applications "$STAGING/Applications" + + hdiutil create \ + -volname "$APP_NAME Installer" \ + -srcfolder "$STAGING" \ + -ov -format UDZO \ + "$GITHUB_WORKSPACE/build/$DMG_NAME" + ls -lh "$GITHUB_WORKSPACE/build/$DMG_NAME" + + - name: Codesign DMG + env: + MACOS_CERTIFICATE_NAME: ${{ secrets.PROD_MACOS_CERTIFICATE_NAME }} + MACOS_CI_KEYCHAIN_PWD: ${{ secrets.PROD_MACOS_CI_KEYCHAIN_PWD }} + run: | + set -euo pipefail + DMG="$GITHUB_WORKSPACE/build/$DMG_NAME" + KEYCHAIN="$RUNNER_TEMP/build.keychain-db" + security unlock-keychain -p "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" + /usr/bin/codesign --force --timestamp \ + -s "$MACOS_CERTIFICATE_NAME" "$DMG" + /usr/bin/codesign --verify --verbose=2 "$DMG" + + - name: Notarize DMG + env: + PROD_MACOS_NOTARIZATION_APPLE_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_APPLE_ID }} + PROD_MACOS_NOTARIZATION_TEAM_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_TEAM_ID }} + PROD_MACOS_NOTARIZATION_PWD: ${{ secrets.PROD_MACOS_NOTARIZATION_PWD }} + run: | + set -euo pipefail + DMG="$GITHUB_WORKSPACE/build/$DMG_NAME" + + xcrun notarytool store-credentials "notarytool-profile" \ + --apple-id "$PROD_MACOS_NOTARIZATION_APPLE_ID" \ + --team-id "$PROD_MACOS_NOTARIZATION_TEAM_ID" \ + --password "$PROD_MACOS_NOTARIZATION_PWD" + + xcrun notarytool submit "$DMG" \ + --keychain-profile "notarytool-profile" \ + --wait --output-format json > notarization_dmg.json + cat notarization_dmg.json + + NOTARY_ID=$(python3 -c 'import json; print(json.load(open("notarization_dmg.json"))["id"])') + NOTARY_STATUS=$(python3 -c 'import json; print(json.load(open("notarization_dmg.json"))["status"])') + xcrun notarytool log "$NOTARY_ID" --keychain-profile "notarytool-profile" || true + + if [ "$NOTARY_STATUS" != "Accepted" ]; then + echo "DMG notarization failed: $NOTARY_STATUS" + exit 1 + fi + + xcrun stapler staple "$DMG" + xcrun stapler validate "$DMG" + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: QLog-${{ env.TAGVERSION }}-macos + path: ${{ github.workspace }}/build/${{ env.DMG_NAME }} + if-no-files-found: error From df50c6a3d4fabf225937f3c31bede8495cb7b8f6 Mon Sep 17 00:00:00 2001 From: Michael Morgan <84428382+aa5sh@users.noreply.github.com> Date: Fri, 1 May 2026 08:53:04 -0500 Subject: [PATCH 17/21] Update macOSBuild.yml --- .github/workflows/macOSBuild.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/macOSBuild.yml b/.github/workflows/macOSBuild.yml index af3cdff8..da9b9138 100644 --- a/.github/workflows/macOSBuild.yml +++ b/.github/workflows/macOSBuild.yml @@ -250,7 +250,16 @@ jobs: -s "$MACOS_CERTIFICATE_NAME" "$HAPP" done < <(find "$APP" -path "*QtWebEngineProcess.app" -type d -print0) - # 4) Sign the outer app bundle WITH entitlements + # 4) Re-seal every framework so the framework's seal includes any + # nested helper apps we just re-signed. Without this, signing the + # outer app fails with "nested code is modified or invalid". + while IFS= read -r -d '' FW; do + echo "Re-signing framework: $FW" + /usr/bin/codesign --force --timestamp --options runtime \ + -s "$MACOS_CERTIFICATE_NAME" "$FW" + done < <(find "$APP/Contents/Frameworks" -maxdepth 1 -type d -name "*.framework" -print0) + + # 5) Sign the outer app bundle WITH entitlements /usr/bin/codesign --force --timestamp --options runtime \ --entitlements "$ENT" \ -s "$MACOS_CERTIFICATE_NAME" "$APP" From f3a8a42a8f8f6877b69ca3989e81cdf084f85890 Mon Sep 17 00:00:00 2001 From: Michael Morgan <84428382+aa5sh@users.noreply.github.com> Date: Fri, 1 May 2026 14:41:38 -0500 Subject: [PATCH 18/21] Add GitHub Actions workflow for Windows build This workflow builds QLog for Windows using GitHub Actions, creating both a portable ZIP and a Qt IFW installer. It includes steps for setting up the environment, installing dependencies, building the application, and packaging the outputs. --- .github/workflows/windows.yml | 343 ++++++++++++++++++++++++++++++++++ 1 file changed, 343 insertions(+) create mode 100644 .github/workflows/windows.yml diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml new file mode 100644 index 00000000..f13cc4e7 --- /dev/null +++ b/.github/workflows/windows.yml @@ -0,0 +1,343 @@ +# ============================================================ +# QLog Windows Build — GitHub Actions +# +# Orignal from HB9VQQ +# +# Builds QLog for Windows (MSVC 2022 x64) and creates: +# - A portable ZIP (QLog-Portable-Windows) +# - A Qt IFW installer (QLog-Installer-Windows) +# +# Triggers: +# - push to master → build + artifacts (for testing) +# - push a tag v* → build + artifacts + GitHub Release +# - manual via Actions tab +# ============================================================ +name: Windows Build + +on: + push: + branches: [master] + tags: ['v*'] + paths-ignore: + - '*.md' + - 'doc/**' + - 'LICENSE' + - '.gitignore' + workflow_dispatch: # manual trigger button in Actions tab + +env: + QT_VERSION: '6.10.2' + HAMLIB_VERSION: '4.7.1' + +jobs: + build: + runs-on: windows-2022 + timeout-minutes: 60 + + steps: + # —— 1. Checkout source ——————————————————————————————————— + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # —— 2. MSVC 2022 x64 environment ———————————————————————— + - name: Setup MSVC + uses: TheMrMilchmann/setup-msvc-dev@v4 + with: + arch: x64 + + # —— 3. Install Qt 6 with required modules ——————————————— + - name: Install Qt + uses: jurplel/install-qt-action@v4 + with: + version: ${{ env.QT_VERSION }} + host: windows + target: desktop + arch: win64_msvc2022_64 + modules: >- + qtwebengine qtcharts qtserialport + qtwebsockets qtwebchannel qtpositioning + tools: tools_ifw + source: true + src-archives: qtbase + cache: true + + # —— 4. Download Hamlib w64 binary ———————————————————————— + - name: Download Hamlib + shell: pwsh + run: | + $zip = "hamlib-w64-${{ env.HAMLIB_VERSION }}.zip" + $url = "https://github.com/Hamlib/Hamlib/releases/download/${{ env.HAMLIB_VERSION }}/$zip" + Invoke-WebRequest $url -OutFile $zip + Expand-Archive $zip -DestinationPath C:\ + Rename-Item "C:\hamlib-w64-${{ env.HAMLIB_VERSION }}" C:\hamlib + + $msvc = "C:\hamlib\lib\msvc" + if (!(Test-Path $msvc)) { New-Item -ItemType Directory $msvc | Out-Null } + if (!(Test-Path "$msvc\libhamlib-4.lib")) { + $def = Get-ChildItem C:\hamlib -Recurse -Filter "libhamlib-4.def" | Select -First 1 + if ($def) { + $dest = "$msvc\libhamlib-4.def" + if ($def.FullName -ne $dest) { Copy-Item $def.FullName $dest } + Push-Location $msvc + lib /machine:X64 /def:libhamlib-4.def /out:libhamlib-4.lib + Pop-Location + } + } + + # —— 5. pthreads + zlib via fresh vcpkg clone ————————————— + - name: Install vcpkg dependencies + shell: cmd + run: | + git clone --depth 1 https://github.com/microsoft/vcpkg.git C:\vcpkg + cd /d C:\vcpkg + call bootstrap-vcpkg.bat + vcpkg install pthreads:x64-windows zlib:x64-windows openssl:x64-windows + + # —— 6. Build QtKeychain from source —————————————————————— + - name: Build QtKeychain + shell: cmd + run: | + git clone --depth 1 https://github.com/frankosterfeld/qtkeychain.git C:\qtkeychain-src + cd /d C:\qtkeychain-src + cmake -B build -G "NMake Makefiles" ^ + -DCMAKE_BUILD_TYPE=Release ^ + -DBUILD_WITH_QT6=ON ^ + -DCMAKE_PREFIX_PATH="%QT_ROOT_DIR%" ^ + -DCMAKE_INSTALL_PREFIX=C:\qtkeychain + cmake --build build --config Release + cmake --install build + + # —— 7. Locate OpenSSL ———————————————————————————————————— + - name: Locate OpenSSL + shell: pwsh + run: | + $candidates = @( + "C:\Program Files\OpenSSL-Win64", + "C:\Program Files\OpenSSL", + "C:\OpenSSL-Win64" + ) + $ssl = $candidates | Where-Object { Test-Path $_ } | Select -First 1 + if (!$ssl) { + choco install openssl -y --no-progress + $ssl = $candidates | Where-Object { Test-Path $_ } | Select -First 1 + } + if ($ssl) { + echo "OPENSSL_DIR=$ssl" >> $env:GITHUB_ENV + } else { + echo "OPENSSL_DIR=" >> $env:GITHUB_ENV + } + + # —— 7c. Install OmniRig v1 + v2 ————————————————————————— + - name: Install OmniRig + shell: pwsh + run: | + # --- OmniRig v1: InnoSetup installer (works silently) --- + Write-Host "=== OmniRig v1 ===" + Invoke-WebRequest "http://www.dxatlas.com/OmniRig/Files/OmniRig.zip" -OutFile OmniRig.zip + Expand-Archive OmniRig.zip -DestinationPath C:\omnirig-v1-tmp + $setup = Get-ChildItem C:\omnirig-v1-tmp -Recurse -Filter "OmniRigSetup.exe" | Select -First 1 + if ($setup) { + Start-Process -FilePath $setup.FullName -ArgumentList "/VERYSILENT","/SUPPRESSMSGBOXES","/NORESTART" -Wait + Start-Sleep -Seconds 2 + } + $v1path = "C:\Program Files (x86)\Afreet\OmniRig\OmniRig.exe" + if (Test-Path $v1path) { + Write-Host "v1 OK: $v1path" + } else { + Write-Error "v1 FAILED" + } + + # --- OmniRig v2: try installer with timeout, fall back to v1 .tlb --- + Write-Host "=== OmniRig v2 ===" + $v2installed = $false + Invoke-WebRequest "https://www.hb9ryz.ch/downloads/install_omnirigv21.zip" -OutFile omnirigv2.zip + Expand-Archive omnirigv2.zip -DestinationPath C:\omnirig-v2-tmp + + $installer = Get-ChildItem C:\omnirig-v2-tmp -Recurse -Filter "*.exe" | Select -First 1 + if ($installer) { + Write-Host "Trying /S (NSIS) with 30s timeout..." + $proc = Start-Process -FilePath $installer.FullName -ArgumentList "/S" -PassThru + $finished = $proc.WaitForExit(30000) + if (!$finished) { + Write-Host "Installer timed out — killing" + $proc.Kill() + } + $v2path = "C:\Program Files (x86)\Omni-Rig V2\omnirig2.exe" + if (Test-Path $v2path) { + Write-Host "v2 installed via /S" + $v2installed = $true + } + } + + if (!$v2installed) { + # Fallback: use v1 .tlb and patch source to remove v2-only features + Write-Host "v2 installer failed — using v1 .tlb fallback with Rig3/Rig4 patch" + Invoke-WebRequest "https://raw.githubusercontent.com/VE3NEA/OmniRig/master/OmniRig.tlb" -OutFile "C:\omnirig-v1.tlb" + + $v2file = "rig\drivers\Omnirigv2RigDrv.cpp" + $content = Get-Content $v2file -Raw + + # Patch #import to use v1 .tlb + $content = $content.Replace( + 'C:\\Program Files (x86)\\Omni-Rig V2\\omnirig2.exe', + 'C:\\omnirig-v1.tlb') + + # Remove get_Rig3/get_Rig4 calls (v1 only has Rig1+Rig2) + # Change case 3/4 to fall through to default (E_INVALIDARG) + $content = $content.Replace( + 'case 3: hr = omniInterface->get_Rig3(&rig); break;', + 'case 3: /* Rig3 not available in v1 fallback */') + $content = $content.Replace( + 'case 4: hr = omniInterface->get_Rig4(&rig); break;', + 'case 4: /* Rig4 not available in v1 fallback */') + + Set-Content $v2file $content -NoNewline + + Write-Host "Patched v2 source:" + Select-String '#import' $v2file | ForEach-Object { $_.Line.Trim() } + Select-String 'case 3:|case 4:' $v2file | ForEach-Object { $_.Line.Trim() } + } + + # —— 8. Build QLog ———————————————————————————————————————— + - name: Build QLog + shell: cmd + run: | + set "QTKC_INC=C:\qtkeychain\include" + set "VCPKG_INC=C:\vcpkg\installed\x64-windows\include" + set "VCPKG_LIB=C:\vcpkg\installed\x64-windows\lib" + + mkdir build + cd build + qmake ..\QLog.pro -spec win32-msvc ^ + "CONFIG+=release" ^ + "HAMLIBINCLUDEPATH=C:\hamlib\include" ^ + "HAMLIBLIBPATH=C:\hamlib\lib\msvc" ^ + "HAMLIBVERSION_MAJOR=4" ^ + "HAMLIBVERSION_MINOR=7" ^ + "HAMLIBVERSION_PATCH=1" ^ + "QTKEYCHAININCLUDEPATH=%QTKC_INC%" ^ + "QTKEYCHAINLIBPATH=C:\qtkeychain\lib" ^ + "PTHREADINCLUDEPATH=%VCPKG_INC%" ^ + "PTHREADLIBPATH=%VCPKG_LIB%" ^ + "ZLIBINCLUDEPATH=%VCPKG_INC%" ^ + "ZLIBLIBPATH=%VCPKG_LIB%" ^ + "OPENSSLINCLUDEPATH=%VCPKG_INC%" ^ + "OPENSSLLIBPATH=%VCPKG_LIB%" + nmake + + # —— 9. Package with windeployqt —————————————————————————— + - name: Deploy + shell: pwsh + run: | + $deploy = "C:\qlog-deploy" + New-Item -ItemType Directory $deploy -Force | Out-Null + + $exe = Get-ChildItem build -Recurse -Filter "qlog.exe" | Select -First 1 + if (!$exe) { Write-Error "qlog.exe not found!"; exit 1 } + Copy-Item $exe.FullName $deploy\ + + # Copy qt6keychain.dll to Qt bin dir so windeployqt can resolve it + $qtBin = Join-Path $env:QT_ROOT_DIR "bin" + Copy-Item C:\qtkeychain\bin\qt6keychain.dll "$qtBin\" -Force -ErrorAction SilentlyContinue + Copy-Item C:\qtkeychain\lib\qt6keychain.dll "$qtBin\" -Force -ErrorAction SilentlyContinue + + Copy-Item C:\hamlib\bin\*.dll $deploy\ + Copy-Item C:\qtkeychain\bin\*.dll $deploy\ -ErrorAction SilentlyContinue + Copy-Item C:\qtkeychain\lib\*.dll $deploy\ -ErrorAction SilentlyContinue + + $vcpkgBin = "C:\vcpkg\installed\x64-windows\bin" + if (Test-Path $vcpkgBin) { + Copy-Item "$vcpkgBin\*.dll" $deploy\ -ErrorAction SilentlyContinue + } + + if ($env:OPENSSL_DIR -and (Test-Path $env:OPENSSL_DIR)) { + $sslBin = Join-Path $env:OPENSSL_DIR "bin" + if (Test-Path $sslBin) { + Copy-Item "$sslBin\libssl*.dll" $deploy\ -ErrorAction SilentlyContinue + Copy-Item "$sslBin\libcrypto*.dll" $deploy\ -ErrorAction SilentlyContinue + } + } + + Push-Location $deploy + windeployqt --release --no-translations qlog.exe + Pop-Location + + Write-Host "Deploy contents:" + Get-ChildItem $deploy | Format-Table Name, Length + + # —— 10. Create Qt IFW installer —————————————————————————— + - name: Create Installer + shell: pwsh + run: | + $bc = $null + if ($env:IQTA_TOOLS) { + $bc = Get-ChildItem $env:IQTA_TOOLS -Recurse -Filter "binarycreator.exe" -ErrorAction SilentlyContinue | Select -First 1 + } + if (!$bc) { + $bc = Get-ChildItem "$env:RUNNER_TOOL_CACHE" -Recurse -Filter "binarycreator.exe" -ErrorAction SilentlyContinue | Select -First 1 + } + if (!$bc) { + Write-Warning "binarycreator not found — skipping installer" + exit 0 + } + + Copy-Item installer -Destination installer-build -Recurse + $pkgData = "installer-build\packages\de.dl2ic.qlog\data" + New-Item -ItemType Directory $pkgData -Force | Out-Null + Copy-Item C:\qlog-deploy\* $pkgData\ -Recurse + + & $bc.FullName -f ` + -c installer-build\config\config.xml ` + -p installer-build\packages ` + qlog-installer.exe + + if (Test-Path qlog-installer.exe) { + Write-Host "Installer created: qlog-installer.exe" + } + + # —— 11. Create portable ZIP —————————————————————————————— + - name: Create Portable ZIP + if: startsWith(github.ref, 'refs/tags/v') + shell: pwsh + run: | + $tag = "${{ github.ref_name }}" + Compress-Archive -Path C:\qlog-deploy\* -DestinationPath "QLog-Portable-Windows-${tag}.zip" + Write-Host "Portable ZIP: QLog-Portable-Windows-${tag}.zip" + + # —— 12. Upload artifacts (always — for testing) —————————— + - name: Upload Installer + if: ${{ hashFiles('qlog-installer.exe') != '' }} + uses: actions/upload-artifact@v4 + with: + name: QLog-Installer-Windows + path: qlog-installer.exe + + - name: Upload Portable + uses: actions/upload-artifact@v4 + with: + name: QLog-Portable-Windows + path: C:\qlog-deploy\ + + # —— 13. Create GitHub Release (only on tag push) ————————— + - name: Create GitHub Release + if: startsWith(github.ref, 'refs/tags/v') + uses: softprops/action-gh-release@v2 + with: + name: "QLog ${{ github.ref_name }}" + body: | + ## QLog ${{ github.ref_name }} + + **Downloads:** + - **Installer** (recommended) — run `qlog-installer.exe` + - **Portable ZIP** — extract anywhere and run `qlog.exe` + + Built with Qt ${{ env.QT_VERSION }}, Hamlib ${{ env.HAMLIB_VERSION }}, MSVC 2022 x64. + draft: false + prerelease: false + files: | + qlog-installer.exe + QLog-Portable-Windows-${{ github.ref_name }}.zip + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 248dd44ff5340ab8ae99e01a79a3f1bbaf3b2654 Mon Sep 17 00:00:00 2001 From: Michael Morgan <84428382+aa5sh@users.noreply.github.com> Date: Sat, 30 May 2026 09:57:17 -0500 Subject: [PATCH 19/21] rebase to testing_0.51 --- core/LogParam.cpp | 100 +++++++++++++++++++++++++++ core/LogParam.h | 24 +++++++ core/Migration.cpp | 34 +++++++++ core/Migration.h | 3 +- res/res.qrc | 1 + res/sql/migration_040.sql | 0 service/emailqsl/EmailQSLService.cpp | 70 ++++++++----------- service/emailqsl/EmailQSLService.h | 4 +- ui/EmailQSLSettingsWidget.cpp | 2 +- ui/EmailQSLSettingsWidget.h | 2 +- 10 files changed, 194 insertions(+), 46 deletions(-) create mode 100644 res/sql/migration_040.sql diff --git a/core/LogParam.cpp b/core/LogParam.cpp index 2e4421f9..9ef6a591 100644 --- a/core/LogParam.cpp +++ b/core/LogParam.cpp @@ -622,6 +622,106 @@ void LogParam::setKSTChatUsername(const QString &username) setParam("services/kst/chat/username", username); } +QString LogParam::getEmailQSLSmtpHost() +{ + return getParam("services/emailqsl/smtpHost").toString(); +} + +void LogParam::setEmailQSLSmtpHost(const QString &host) +{ + setParam("services/emailqsl/smtpHost", host); +} + +int LogParam::getEmailQSLSmtpPort() +{ + return getParam("services/emailqsl/smtpPort", 587).toInt(); +} + +void LogParam::setEmailQSLSmtpPort(int port) +{ + setParam("services/emailqsl/smtpPort", port); +} + +int LogParam::getEmailQSLSmtpEncryption() +{ + return getParam("services/emailqsl/smtpEncryption", 2).toInt(); +} + +void LogParam::setEmailQSLSmtpEncryption(int enc) +{ + setParam("services/emailqsl/smtpEncryption", enc); +} + +QString LogParam::getEmailQSLSmtpUsername() +{ + return getParam("services/emailqsl/smtpUsername").toString(); +} + +void LogParam::setEmailQSLSmtpUsername(const QString &username) +{ + setParam("services/emailqsl/smtpUsername", username); +} + +QString LogParam::getEmailQSLFromAddress() +{ + return getParam("services/emailqsl/fromAddress").toString(); +} + +void LogParam::setEmailQSLFromAddress(const QString &addr) +{ + setParam("services/emailqsl/fromAddress", addr); +} + +QString LogParam::getEmailQSLFromName() +{ + return getParam("services/emailqsl/fromName").toString(); +} + +void LogParam::setEmailQSLFromName(const QString &name) +{ + setParam("services/emailqsl/fromName", name); +} + +QString LogParam::getEmailQSLSubjectTemplate(const QString &defaultValue) +{ + return getParam("services/emailqsl/subjectTemplate", defaultValue).toString(); +} + +void LogParam::setEmailQSLSubjectTemplate(const QString &tmpl) +{ + setParam("services/emailqsl/subjectTemplate", tmpl); +} + +QString LogParam::getEmailQSLBodyTemplate(const QString &defaultValue) +{ + return getParam("services/emailqsl/bodyTemplate", defaultValue).toString(); +} + +void LogParam::setEmailQSLBodyTemplate(const QString &tmpl) +{ + setParam("services/emailqsl/bodyTemplate", tmpl); +} + +QString LogParam::getEmailQSLCardImagePath() +{ + return getParam("services/emailqsl/cardImagePath").toString(); +} + +void LogParam::setEmailQSLCardImagePath(const QString &path) +{ + setParam("services/emailqsl/cardImagePath", path); +} + +QByteArray LogParam::getEmailQSLCardOverlays() +{ + return getParam("services/emailqsl/cardOverlays").toByteArray(); +} + +void LogParam::setEmailQSLCardOverlays(const QByteArray &json) +{ + setParam("services/emailqsl/cardOverlays", json); +} + QString LogParam::getLoTWCallbookUsername() { return getParam("services/lotw/callbook/username").toString().trimmed(); diff --git a/core/LogParam.h b/core/LogParam.h index a1f5bacc..70070686 100644 --- a/core/LogParam.h +++ b/core/LogParam.h @@ -201,6 +201,30 @@ class LogParam : public QObject static QString getKSTChatUsername(); static void setKSTChatUsername(const QString& username); + /********* + * Email QSL + ********/ + static QString getEmailQSLSmtpHost(); + static void setEmailQSLSmtpHost(const QString &host); + static int getEmailQSLSmtpPort(); + static void setEmailQSLSmtpPort(int port); + static int getEmailQSLSmtpEncryption(); + static void setEmailQSLSmtpEncryption(int enc); + static QString getEmailQSLSmtpUsername(); + static void setEmailQSLSmtpUsername(const QString &username); + static QString getEmailQSLFromAddress(); + static void setEmailQSLFromAddress(const QString &addr); + static QString getEmailQSLFromName(); + static void setEmailQSLFromName(const QString &name); + static QString getEmailQSLSubjectTemplate(const QString &defaultValue); + static void setEmailQSLSubjectTemplate(const QString &tmpl); + static QString getEmailQSLBodyTemplate(const QString &defaultValue); + static void setEmailQSLBodyTemplate(const QString &tmpl); + static QString getEmailQSLCardImagePath(); + static void setEmailQSLCardImagePath(const QString &path); + static QByteArray getEmailQSLCardOverlays(); + static void setEmailQSLCardOverlays(const QByteArray &json); + /********* * LoTW ********/ diff --git a/core/Migration.cpp b/core/Migration.cpp index 0c2efc64..93bc9339 100644 --- a/core/Migration.cpp +++ b/core/Migration.cpp @@ -329,6 +329,9 @@ bool DBSchemaMigration::functionMigration(int version) case 35: ret = removeSettings2DB(); break; + case 40: + ret = emailQSLSettings2DB(); + break; default: ret = true; } @@ -907,6 +910,37 @@ bool DBSchemaMigration::removeSettings2DB() return true; } +bool DBSchemaMigration::emailQSLSettings2DB() +{ + FCT_IDENTIFICATION; + + QSettings settings; + + if (settings.contains("emailqsl/smtpHost")) + LogParam::setEmailQSLSmtpHost(settings.value("emailqsl/smtpHost").toString()); + if (settings.contains("emailqsl/smtpPort")) + LogParam::setEmailQSLSmtpPort(settings.value("emailqsl/smtpPort").toInt()); + if (settings.contains("emailqsl/smtpEncryption")) + LogParam::setEmailQSLSmtpEncryption(settings.value("emailqsl/smtpEncryption").toInt()); + if (settings.contains("emailqsl/smtpUsername")) + LogParam::setEmailQSLSmtpUsername(settings.value("emailqsl/smtpUsername").toString()); + if (settings.contains("emailqsl/fromAddress")) + LogParam::setEmailQSLFromAddress(settings.value("emailqsl/fromAddress").toString()); + if (settings.contains("emailqsl/fromName")) + LogParam::setEmailQSLFromName(settings.value("emailqsl/fromName").toString()); + if (settings.contains("emailqsl/subjectTemplate")) + LogParam::setEmailQSLSubjectTemplate(settings.value("emailqsl/subjectTemplate").toString()); + if (settings.contains("emailqsl/bodyTemplate")) + LogParam::setEmailQSLBodyTemplate(settings.value("emailqsl/bodyTemplate").toString()); + if (settings.contains("emailqsl/cardImagePath")) + LogParam::setEmailQSLCardImagePath(settings.value("emailqsl/cardImagePath").toString()); + if (settings.contains("emailqsl/cardOverlays")) + LogParam::setEmailQSLCardOverlays(settings.value("emailqsl/cardOverlays").toByteArray()); + + settings.remove("emailqsl"); + return true; +} + bool DBSchemaMigration::settings2DB() { FCT_IDENTIFICATION; diff --git a/core/Migration.h b/core/Migration.h index d4b4ec72..b688da0b 100644 --- a/core/Migration.h +++ b/core/Migration.h @@ -14,7 +14,7 @@ class DBSchemaMigration : public QObject bool run(bool force = false); static bool backupAllQSOsToADX(bool force = false); - static constexpr int latestVersion = 39; + static constexpr int latestVersion = 40; private: bool functionMigration(int version); @@ -39,6 +39,7 @@ class DBSchemaMigration : public QObject bool profiles2DB(); bool settings2DB(); bool removeSettings2DB(); + bool emailQSLSettings2DB(); bool setSelectedProfile(const QString &tablename, const QString &profileName); QString fixIntlField(const QSqlQuery &query, const QString &columName, const QString &columnNameIntl); bool refreshUploadStatusTrigger(); diff --git a/res/res.qrc b/res/res.qrc index 8ca4af25..c4b53a44 100644 --- a/res/res.qrc +++ b/res/res.qrc @@ -52,5 +52,6 @@ sql/migration_037.sql sql/migration_038.sql sql/migration_039.sql + sql/migration_040.sql diff --git a/res/sql/migration_040.sql b/res/sql/migration_040.sql new file mode 100644 index 00000000..e69de29b diff --git a/service/emailqsl/EmailQSLService.cpp b/service/emailqsl/EmailQSLService.cpp index 60240fff..7a80effe 100644 --- a/service/emailqsl/EmailQSLService.cpp +++ b/service/emailqsl/EmailQSLService.cpp @@ -1,4 +1,3 @@ -#include #include #include #include @@ -13,6 +12,7 @@ #include "EmailQSLService.h" #include "core/debug.h" +#include "core/LogParam.h" MODULE_IDENTIFICATION("qlog.service.emailqsl"); @@ -79,56 +79,47 @@ void EmailQSLBase::registerCredentials() } // --------------------------------------------------------------------------- -// EmailQSLBase — QSettings helpers +// EmailQSLBase — settings persisted via LogParam (log_param DB table) // --------------------------------------------------------------------------- -#define EMAILQSL_SETTINGS_GROUP "emailqsl" - -static QSettings &cfg() -{ - static QSettings s; - return s; -} - QString EmailQSLBase::getSmtpHost() { - return cfg().value(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/smtpHost")).toString(); + return LogParam::getEmailQSLSmtpHost(); } void EmailQSLBase::setSmtpHost(const QString &host) { - cfg().setValue(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/smtpHost"), host); + LogParam::setEmailQSLSmtpHost(host); } int EmailQSLBase::getSmtpPort() { - return cfg().value(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/smtpPort"), 587).toInt(); + return LogParam::getEmailQSLSmtpPort(); } void EmailQSLBase::setSmtpPort(int port) { - cfg().setValue(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/smtpPort"), port); + LogParam::setEmailQSLSmtpPort(port); } int EmailQSLBase::getSmtpEncryption() { - return cfg().value(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/smtpEncryption"), - ENCRYPTION_STARTTLS).toInt(); + return LogParam::getEmailQSLSmtpEncryption(); } void EmailQSLBase::setSmtpEncryption(int enc) { - cfg().setValue(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/smtpEncryption"), enc); + LogParam::setEmailQSLSmtpEncryption(enc); } QString EmailQSLBase::getSmtpUsername() { - return cfg().value(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/smtpUsername")).toString(); + return LogParam::getEmailQSLSmtpUsername(); } void EmailQSLBase::setSmtpUsername(const QString &username) { - cfg().setValue(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/smtpUsername"), username); + LogParam::setEmailQSLSmtpUsername(username); } QString EmailQSLBase::getSmtpPassword() @@ -147,67 +138,65 @@ void EmailQSLBase::saveSmtpCredentials(const QString &username, const QString &p QString EmailQSLBase::getFromAddress() { - return cfg().value(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/fromAddress")).toString(); + return LogParam::getEmailQSLFromAddress(); } void EmailQSLBase::setFromAddress(const QString &addr) { - cfg().setValue(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/fromAddress"), addr); + LogParam::setEmailQSLFromAddress(addr); } QString EmailQSLBase::getFromName() { - return cfg().value(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/fromName")).toString(); + return LogParam::getEmailQSLFromName(); } void EmailQSLBase::setFromName(const QString &name) { - cfg().setValue(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/fromName"), name); + LogParam::setEmailQSLFromName(name); } QString EmailQSLBase::getSubjectTemplate() { - return cfg().value(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/subjectTemplate"), - QStringLiteral("QSL Card from {MY_CALLSIGN} for our QSO on {QSO_DATE}")).toString(); + return LogParam::getEmailQSLSubjectTemplate( + QStringLiteral("QSL Card from {MY_CALLSIGN} for our QSO on {QSO_DATE}")); } void EmailQSLBase::setSubjectTemplate(const QString &tmpl) { - cfg().setValue(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/subjectTemplate"), tmpl); + LogParam::setEmailQSLSubjectTemplate(tmpl); } QString EmailQSLBase::getBodyTemplate() { - return cfg().value(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/bodyTemplate"), - QStringLiteral("Dear {NAME},\n\nPlease find my QSL card attached confirming our contact.\n\n" - "Callsign: {MY_CALLSIGN}\n" - "Date: {QSO_DATE}\nTime: {TIME_ON} UTC\n" - "Band: {BAND}\nMode: {MODE}\n" - "RST Sent: {RST_SENT}\nRST Rcvd: {RST_RCVD}\n\n" - "73,\n{MY_CALLSIGN}")).toString(); + return LogParam::getEmailQSLBodyTemplate( + QStringLiteral("Dear {NAME},\n\nPlease find my QSL card attached confirming our contact.\n\n" + "Callsign: {MY_CALLSIGN}\n" + "Date: {QSO_DATE}\nTime: {TIME_ON} UTC\n" + "Band: {BAND}\nMode: {MODE}\n" + "RST Sent: {RST_SENT}\nRST Rcvd: {RST_RCVD}\n\n" + "73,\n{MY_CALLSIGN}")); } void EmailQSLBase::setBodyTemplate(const QString &tmpl) { - cfg().setValue(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/bodyTemplate"), tmpl); + LogParam::setEmailQSLBodyTemplate(tmpl); } QString EmailQSLBase::getCardImagePath() { - return cfg().value(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/cardImagePath")).toString(); + return LogParam::getEmailQSLCardImagePath(); } void EmailQSLBase::setCardImagePath(const QString &path) { - cfg().setValue(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/cardImagePath"), path); + LogParam::setEmailQSLCardImagePath(path); } QList EmailQSLBase::getCardFieldOverlays() { QList result; - const QByteArray json = cfg().value( - QStringLiteral(EMAILQSL_SETTINGS_GROUP "/cardOverlays")).toByteArray(); - const QJsonArray arr = QJsonDocument::fromJson(json).array(); + const QJsonArray arr = QJsonDocument::fromJson(LogParam::getEmailQSLCardOverlays()).array(); for (const QJsonValue &v : arr) result.append(EmailQSLFieldOverlay::fromJson(v.toObject())); return result; @@ -218,8 +207,7 @@ void EmailQSLBase::setCardFieldOverlays(const QList &overl QJsonArray arr; for (const EmailQSLFieldOverlay &o : overlays) arr.append(o.toJson()); - cfg().setValue(QStringLiteral(EMAILQSL_SETTINGS_GROUP "/cardOverlays"), - QJsonDocument(arr).toJson(QJsonDocument::Compact)); + LogParam::setEmailQSLCardOverlays(QJsonDocument(arr).toJson(QJsonDocument::Compact)); } // --------------------------------------------------------------------------- diff --git a/service/emailqsl/EmailQSLService.h b/service/emailqsl/EmailQSLService.h index 12d9e8d8..73a6f213 100644 --- a/service/emailqsl/EmailQSLService.h +++ b/service/emailqsl/EmailQSLService.h @@ -44,7 +44,7 @@ struct EmailQSLFieldOverlay // Static helpers for reading/writing all Email QSL settings. // The SMTP password is kept in the secure credential store; -// everything else lives in QSettings under the "emailqsl/" group. +// everything else is persisted via LogParam (log_param DB table). class EmailQSLBase : public SecureServiceBase { public: @@ -92,7 +92,7 @@ class EmailQSLBase : public SecureServiceBase static void recordEmailSent(int contactId, const QSqlRecord ¤tRecord); // --- Rendering helpers --- - // Full render using saved QSettings (used when sending). + // Full render using saved settings from LogParam (used when sending). static QPixmap renderCard(const QSqlRecord &record); // Full render using an explicit image path and overlay list // (used by settings preview so unsaved changes are shown). diff --git a/ui/EmailQSLSettingsWidget.cpp b/ui/EmailQSLSettingsWidget.cpp index 35c0bd39..0163da9a 100644 --- a/ui/EmailQSLSettingsWidget.cpp +++ b/ui/EmailQSLSettingsWidget.cpp @@ -903,7 +903,7 @@ void EmailQSLSettingsWidget::removeOverlay() } // --------------------------------------------------------------------------- -// Full-size preview — renders using current UI state (not QSettings) +// Full-size preview — renders using current UI state (not saved LogParam values) // --------------------------------------------------------------------------- QPixmap EmailQSLSettingsWidget::renderPreviewPixmap(const QSqlRecord &record) diff --git a/ui/EmailQSLSettingsWidget.h b/ui/EmailQSLSettingsWidget.h index 7f991175..a987e0f0 100644 --- a/ui/EmailQSLSettingsWidget.h +++ b/ui/EmailQSLSettingsWidget.h @@ -24,7 +24,7 @@ class EmailQSLSettingsWidget : public QWidget void readSettings(); void writeSettings(); - // Render card using the current UI state (not saved QSettings). + // Render card using the current UI state (not saved LogParam values). // Used by the preview dialog so the user can see results before saving. QPixmap renderPreviewPixmap(const QSqlRecord &record); From 543fc5dc9b71087e5a22ee7a19de4e7aa0b89a7e Mon Sep 17 00:00:00 2001 From: Michael Morgan <84428382+aa5sh@users.noreply.github.com> Date: Sat, 30 May 2026 11:05:59 -0500 Subject: [PATCH 20/21] m --- QLog.pro | 12 ++++++++++- res/macos/Info.plist | 50 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 res/macos/Info.plist diff --git a/QLog.pro b/QLog.pro index 3a4a31ad..be890b2f 100644 --- a/QLog.pro +++ b/QLog.pro @@ -650,7 +650,17 @@ macx: { LIBS += -L/usr/local/lib -L/opt/homebrew/lib -lhamlib -lsqlite3 -lz -L/opt/local/lib -lssl -lcrypto equals(QT_MAJOR_VERSION, 6): LIBS += -lqt6keychain equals(QT_MAJOR_VERSION, 5): LIBS += -lqt5keychain - DISTFILES += + + # Custom Info.plist — sets a real bundle identifier + # (io.github.foldynl.qlog) and declares NSLocalNetworkUsageDescription + # so macOS 14+ will actually prompt for / grant LAN access (needed + # for WSJT-X UDP, network rigs, rotators, etc.). Without this, qmake + # generates a default Info.plist with CFBundleIdentifier = + # com.yourcompany.qlog and no local-network key. + QMAKE_INFO_PLIST = $$PWD/res/macos/Info.plist + QMAKE_TARGET_BUNDLE_PREFIX = io.github.foldynl + + DISTFILES += res/macos/Info.plist } win32: { diff --git a/res/macos/Info.plist b/res/macos/Info.plist new file mode 100644 index 00000000..31b854a6 --- /dev/null +++ b/res/macos/Info.plist @@ -0,0 +1,50 @@ + + + + + CFBundleAllowMixedLocalizations + + CFBundleDevelopmentRegion + en + CFBundleExecutable + @EXECUTABLE@ + CFBundleIconFile + @ICON@ + CFBundleIdentifier + io.github.foldynl.qlog + CFBundleName + QLog + CFBundleDisplayName + QLog + CFBundlePackageType + APPL + CFBundleSignature + ???? + CFBundleShortVersionString + @SHORT_VERSION@ + CFBundleVersion + @FULL_VERSION@ + LSMinimumSystemVersion + 14.0 + LSApplicationCategoryType + public.app-category.utilities + NSHumanReadableCopyright + Copyright © OK1MLG. Licensed under the GNU GPL v3. + NSPrincipalClass + NSApplication + NSSupportsAutomaticGraphicsSwitching + + NSHighResolutionCapable + + + + NSLocalNetworkUsageDescription + QLog needs local network access to communicate with WSJT-X, network-attached rigs, rotators, and other amateur-radio software running on devices on your network. + + From 5a6ee10371f1482ade9a0eaf4d536282a7cb2fc1 Mon Sep 17 00:00:00 2001 From: Michael Morgan <84428382+aa5sh@users.noreply.github.com> Date: Mon, 8 Jun 2026 09:07:41 -0500 Subject: [PATCH 21/21] Remove migration_040 and set latestVersion to 39 Delete sql/migration_040.sql and remove its entry from res.qrc, and update Migration::latestVersion from 40 to 39. This ensures the application no longer expects or attempts to run the removed migration (040). --- core/Migration.h | 2 +- res/res.qrc | 1 - res/sql/migration_040.sql | 0 3 files changed, 1 insertion(+), 2 deletions(-) delete mode 100644 res/sql/migration_040.sql diff --git a/core/Migration.h b/core/Migration.h index b688da0b..92f787ca 100644 --- a/core/Migration.h +++ b/core/Migration.h @@ -14,7 +14,7 @@ class DBSchemaMigration : public QObject bool run(bool force = false); static bool backupAllQSOsToADX(bool force = false); - static constexpr int latestVersion = 40; + static constexpr int latestVersion = 39; private: bool functionMigration(int version); diff --git a/res/res.qrc b/res/res.qrc index c4b53a44..8ca4af25 100644 --- a/res/res.qrc +++ b/res/res.qrc @@ -52,6 +52,5 @@ sql/migration_037.sql sql/migration_038.sql sql/migration_039.sql - sql/migration_040.sql diff --git a/res/sql/migration_040.sql b/res/sql/migration_040.sql deleted file mode 100644 index e69de29b..00000000