Compare commits

..
Author SHA1 Message Date
jwinterm a526061d97 ci(macos): place appicon.icns into the bundle 2026-08-29 13:15:24 -04:00
jwinterm f4c5ec3c25 ci(macos): non-interactive brew 2026-08-29 13:10:03 -04:00
jwinterm 1217ce174c ci(macos): sign + notarize with Developer ID cert via desktop-lane secrets 2026-08-29 12:17:21 -04:00
jwinterm 914fa1ed32 ci: native macOS lane for the Gitea self-hosted runner
Dispatch-only workflow in .gitea/workflows (GitHub ignores the
directory): brew dependencies, native cmake build with the embedded
wownerod fetched per-arch, unsigned app bundle artifact. Runs on the
mac runner whose work dir lives on DataSSD and is outside every backup
path. Signing/notarization follows once a Developer ID Application
certificate is provisioned as repo secrets.
2026-08-29 07:04:39 -04:00
3 changed files with 139 additions and 126 deletions
+139
View File
@@ -0,0 +1,139 @@
name: macos build (gitea self-hosted)
# Runs ONLY on the Gitea mac runner (GitHub ignores .gitea/workflows).
# Dispatch-only: the mac runner has capacity 1 and also serves the Hash Bags
# iOS lane, so macOS wowlet builds are an explicit button, not a push hook.
# All build state lands under the runner's work dir, which lives on
# /Volumes/DataSSD (never backed up: the fleet backup pulls an allowlist of
# gitea data only).
#
# Signing/notarization uses the SAME seven secrets as the smirk-monorepo
# desktop lane, set by ~/keys/suchsoftware/set-gitea-desktop-secrets.sh:
# APPLE_CERTIFICATE (b64 Developer ID Application .p12),
# APPLE_CERTIFICATE_PASSWORD, APPLE_SIGNING_IDENTITY, APPLE_TEAM_ID,
# APPLE_API_ISSUER, APPLE_API_KEY, APPLE_API_KEY_P8_BASE64 (notarytool).
# If APPLE_CERTIFICATE is unset the build ships unsigned instead of failing.
on: [workflow_dispatch]
jobs:
build-macos:
name: "macOS (brew, native)"
runs-on: macos-latest
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_P8_BASE64: ${{ secrets.APPLE_API_KEY_P8_BASE64 }}
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: install dependencies (brew)
run: |
export PATH="/opt/homebrew/bin:$PATH"
export NONINTERACTIVE=1 HOMEBREW_NO_ENV_HINTS=1
brew install --quiet qt libsodium libzip qrencode unbound cmake boost hidapi openssl expat protobuf pkgconfig zxing-cpp
- name: fetch wownerod to embed
run: |
set -e
ARCH=$(uname -m)
case "$ARCH" in
arm64) WOWTAR=wownero-aarch64-apple-darwin11-v0.11.4.0.tar.bz2 ;;
*) WOWTAR=wownero-x86_64-apple-darwin11-v0.11.4.0.tar.bz2 ;;
esac
mkdir -p /tmp/wownerod-embed
curl -L -o /tmp/wownero.tar.bz2 \
"https://github.com/wownero/wownero-codeberg-mirror/releases/download/v0.11.4.0/$WOWTAR"
tar xjf /tmp/wownero.tar.bz2 -C /tmp
find /tmp -name wownerod -type f -exec cp {} /tmp/wownerod-embed/ \;
chmod +x /tmp/wownerod-embed/wownerod
ls -la /tmp/wownerod-embed
- name: build
run: |
export PATH="/opt/homebrew/bin:$PATH"
mkdir -p build && cd build
cmake -DWITH_SCANNER=OFF -DWOWNEROD_DIR=/tmp/wownerod-embed ..
cmake --build . -j "$(sysctl -n hw.ncpu)"
- name: package app bundle
run: |
set -e
cd build
APP=$(find . -maxdepth 3 -name "*.app" -type d | head -1)
[ -n "$APP" ] || { echo "no .app bundle produced"; ls -la bin/; exit 1; }
mkdir -p dist
cp -R "$APP" dist/Wowlet.app
# cmake never places the icon in the bundle (upstream only copies it
# for the dormant `installed/` layout)
mkdir -p dist/Wowlet.app/Contents/Resources
cp ../src/assets/images/appicons/appicon.icns dist/Wowlet.app/Contents/Resources/
# bundle Qt frameworks/plugins so the app runs off-machine
/opt/homebrew/opt/qt/bin/macdeployqt dist/Wowlet.app
ls -la dist/
- name: sign and notarize (skipped when secrets absent)
run: |
set -e
cd build
if [ -z "$APPLE_CERTIFICATE" ]; then
echo "APPLE_CERTIFICATE not set — shipping UNSIGNED build"
exit 0
fi
KC=/tmp/wowlet-ci.keychain-db
KCPW=$(/usr/bin/uuidgen)
echo "$KCPW" > /tmp/wowlet-ci-kcpw # for the cleanup step only
security create-keychain -p "$KCPW" "$KC"
security set-keychain-settings -lut 3600 "$KC"
security unlock-keychain -p "$KCPW" "$KC"
printf '%s' "$APPLE_CERTIFICATE" | base64 -d > /tmp/dev_id.p12
security import /tmp/dev_id.p12 -k "$KC" -P "$APPLE_CERTIFICATE_PASSWORD" \
-T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple: -s -k "$KCPW" "$KC" >/dev/null
security list-keychains -d user -s "$KC" login.keychain-db
# sign nested Mach-O executables first (embedded wownerod etc.),
# then frameworks, then the bundle — all hardened-runtime + timestamp
SIGN() { codesign --force --options runtime --timestamp --keychain "$KC" \
--sign "$APPLE_SIGNING_IDENTITY" "$@"; }
find dist/Wowlet.app -type f -perm +111 ! -path "*/Frameworks/*" \
| while read -r f; do file "$f" | grep -q Mach-O && SIGN "$f" || true; done
find dist/Wowlet.app/Contents/Frameworks -maxdepth 1 \( -name "*.framework" -o -name "*.dylib" \) 2>/dev/null \
| while read -r fw; do SIGN "$fw"; done
SIGN dist/Wowlet.app
codesign --verify --deep --strict dist/Wowlet.app
printf '%s' "$APPLE_API_KEY_P8_BASE64" | base64 -d > /tmp/notary.p8
ditto -c -k --keepParent dist/Wowlet.app /tmp/wowlet-notarize.zip
xcrun notarytool submit /tmp/wowlet-notarize.zip \
--key /tmp/notary.p8 --key-id "$APPLE_API_KEY" --issuer "$APPLE_API_ISSUER" \
--wait --timeout 30m
xcrun stapler staple dist/Wowlet.app
echo "signed + notarized + stapled"
- name: zip artifact
run: |
set -e
cd build/dist
ditto -c -k --keepParent Wowlet.app ../wowlet-macos-$(uname -m).zip
ls -la ../wowlet-macos-*.zip
- name: cleanup signing material
if: always()
run: |
if [ -f /tmp/wowlet-ci-kcpw ]; then
security delete-keychain /tmp/wowlet-ci.keychain-db || true
fi
rm -f /tmp/dev_id.p12 /tmp/notary.p8 /tmp/wowlet-ci-kcpw /tmp/wowlet-notarize.zip
- name: upload artifact
uses: actions/upload-artifact@v3
with:
name: wowlet-macos
path: build/wowlet-macos-*.zip
retention-days: 14
-112
View File
@@ -542,24 +542,6 @@ void MainWindow::initWalletContext() {
connect(m_wallet, &Wallet::deviceError, this, &MainWindow::onDeviceError);
connect(m_wallet, &Wallet::multiBroadcast, this, &MainWindow::onMultiBroadcast);
// wowlet: count multi-broadcast relay results so a total failure (Tor not
// bootstrapped, every node unreachable) is surfaced instead of silent.
connect(m_rpc, &DaemonRpc::ApiResponse, this, [this](const DaemonRpc::DaemonResponse &resp){
if (resp.endpoint == DaemonRpc::Endpoint::SEND_RAW_TRANSACTION && resp.ok) {
m_broadcastBatchSuccesses++;
}
});
// wowlet: when the embedded daemon reports synced after a (re)start,
// re-announce any still-pending outgoing txs; a restarted pool has
// forgotten them.
connect(daemonManager(), &DaemonManager::synced, this, &MainWindow::relayPendingTransactions);
// wowlet: periodic local check for outgoing txs stuck unconfirmed.
m_stuckTxTimer = new QTimer(this);
connect(m_stuckTxTimer, &QTimer::timeout, this, &MainWindow::checkStuckTransactions);
m_stuckTxTimer->start(2 * 60 * 1000);
}
void MainWindow::menuToggleTabVisible(const QString &key){
@@ -778,7 +760,6 @@ void MainWindow::onSubtractFeeFromAmountEnabled(bool enabled) {
}
void MainWindow::onMultiBroadcast(const QMap<QString, QString> &txHexMap) {
m_broadcastBatchSuccesses = 0;
QMapIterator<QString, QString> i(txHexMap);
while (i.hasNext()) {
i.next();
@@ -789,99 +770,6 @@ void MainWindow::onMultiBroadcast(const QMap<QString, QString> &txHexMap) {
m_rpc->sendRawTransaction(i.value());
}
}
// wowlet: if not a single node accepted the relay, say so. The commit to
// the local daemon may still confirm on its own, but when Tor is down and
// the daemon runs with an exclusive --tx-proxy, nothing will ever leave
// this machine; the user should know within a minute, not after two days.
const QStringList txids = txHexMap.keys();
QTimer::singleShot(45 * 1000, this, [this, txids]{
if (m_broadcastBatchSuccesses > 0 || txids.isEmpty()) {
return;
}
QMessageBox box(this);
box.setWindowTitle("Transaction relay may have failed");
box.setText("No node accepted the transaction broadcast. If Tor is "
"still starting (or unavailable), the transaction may not "
"reach the network at all.");
box.setInformativeText("Rebroadcasting later is always safe: a "
"transaction can never be spent twice.");
QPushButton *retry = box.addButton("Rebroadcast now", QMessageBox::YesRole);
box.addButton("Later", QMessageBox::RejectRole);
box.exec();
if (box.clickedButton() == retry) {
this->onResendTransaction(txids[0]);
}
});
}
void MainWindow::checkStuckTransactions() {
// wowlet: a pending outgoing tx unconfirmed for this long has, in
// practice, not reached the network (Tor down at send time, embedded
// node restarted mid-relay). Local history scan only; no network calls,
// and no txid is ever looked up against any external service. The remedy
// reuses the existing rebroadcast path, which reconnects to a fresh node
// first.
constexpr qint64 kStuckSecs = 25 * 60;
if (m_wallet == nullptr || m_wallet->history() == nullptr) {
return;
}
TransactionHistory *history = m_wallet->history();
for (quint64 i = 0; i < history->count(); i++) {
const TransactionRow &row = history->transaction(static_cast<int>(i));
if (!row.pending || row.direction == TransactionRow::Direction_In) {
continue;
}
if (row.timestamp.secsTo(QDateTime::currentDateTime()) < kStuckSecs) {
continue;
}
if (m_stuckTxWarned.contains(row.hash)) {
continue;
}
m_stuckTxWarned.insert(row.hash);
QMessageBox box(this);
box.setWindowTitle("Transaction appears stuck");
box.setText(QString("An outgoing transaction has been unconfirmed for "
"more than %1 minutes:\n\n%2")
.arg(kStuckSecs / 60).arg(row.hash));
box.setInformativeText("It may never have reached the network. "
"Rebroadcasting is safe: a transaction can "
"never be spent twice, and your change stays "
"locked until this either confirms or fails.");
QPushButton *retry = box.addButton("Rebroadcast now", QMessageBox::YesRole);
box.addButton("Later", QMessageBox::RejectRole);
box.exec();
if (box.clickedButton() == retry) {
this->onResendTransaction(row.hash);
}
}
}
void MainWindow::relayPendingTransactions() {
// wowlet: rebuild the multi-broadcast map from the wallet's own tx cache
// for every pending outgoing tx and re-announce it. Strictly local
// sources; the announcement itself goes through the normal (Tor-routed)
// broadcast path.
if (m_wallet == nullptr || m_wallet->history() == nullptr) {
return;
}
TransactionHistory *history = m_wallet->history();
QMap<QString, QString> txHexMap;
for (quint64 i = 0; i < history->count(); i++) {
const TransactionRow &row = history->transaction(static_cast<int>(i));
if (!row.pending || row.direction == TransactionRow::Direction_In) {
continue;
}
QString txHex = m_wallet->getCacheTransaction(row.hash);
if (!txHex.isEmpty()) {
txHexMap[row.hash] = txHex;
}
}
if (txHexMap.isEmpty()) {
return;
}
qDebug() << QString("Re-relaying %1 pending transaction(s) after daemon sync").arg(txHexMap.size());
this->onMultiBroadcast(txHexMap);
}
void MainWindow::onSyncStatus(quint64 height, quint64 target, bool daemonSync) {
-14
View File
@@ -51,9 +51,6 @@ namespace Ui {
class MainWindow;
}
#include <QSet>
#include <QTimer>
class ToggleTab : QObject {
Q_OBJECT
@@ -217,23 +214,12 @@ private:
QIcon hardwareDevicePairedIcon();
QIcon hardwareDeviceUnpairedIcon();
// wowlet: stuck-transaction detection and pending re-relay. Both are
// strictly local: history rows and the wallet's own tx cache; no txid is
// ever queried against any external service.
void checkStuckTransactions();
void relayPendingTransactions();
QScopedPointer<Ui::MainWindow> ui;
WindowManager *m_windowManager;
Wallet *m_wallet = nullptr;
Nodes *m_nodes;
DaemonRpc *m_rpc;
// wowlet: see checkStuckTransactions / relayPendingTransactions
QTimer *m_stuckTxTimer = nullptr;
QSet<QString> m_stuckTxWarned;
int m_broadcastBatchSuccesses = 0;
SplashDialog *m_splashDialog = nullptr;
AccountSwitcherDialog *m_accountSwitcherDialog = nullptr;
TxPoolViewerDialog *m_txPoolViewerDialog = nullptr;