Compare commits

...
10 Commits
Author SHA1 Message Date
jwinterm 5a9605c87b release: wowlet 5.1.3
Carries the macOS QML bundling fix, so the mining tab loads on mac instead of
painting grey, and the mining offer restricted to a node that is ours.
2026-09-12 20:57:13 -04:00
jwinterm db151c2920 fix(ci/macos): bundle the QML modules the app actually imports
5.1.2 shipped a grey mining tab on macOS while Linux was fine. The app said
why: 'qrc:/qml/mining.qml:2:1: module "QtQuick.Layouts" is not installed',
and Contents/Resources held only appicon.icns and qt.conf, with no qml
directory at all. macdeployqt cannot scan .qrc resources, and it was called
without -qmldir, so the bundle shipped no QML modules; scene.qml survived only
because it imports nothing beyond QtQuick.

Pass -qmldir, then blanket-copy the QtQuick and QtQml trees as the AppImage
build already does, because qmlimportscanner does not follow a module's own
qmldir depends chain. Both happen before the dylib passes so new plugin deps
resolve, and before signing, which already sweeps every non-framework Mach-O.
A guard fails the build when a module is missing, because an absent QML module
is invisible until someone opens the tab.
2026-09-12 20:49:23 -04:00
jwinterm c7fca31653 fix(mining): only offer to mine on a node that is ours
Start Mining was live regardless of which daemon the wallet was talking
to. It is a `start_mining` RPC to the connected node, so on a remote one
it is either refused — the button appearing to do nothing — or, if the
operator left the RPC open, it sets someone else's machine mining to our
address. Neither is a thing a wallet should offer.

The axe now greys out unless `Nodes::connection().isLocal()`, hovering it
explains why, and clicking anyway flashes the same notice rather than
silently doing nothing. `onStartMining` refuses too: the QML disables the
control, but a signal is not a promise.

isLocal() counts localhost, .local, 127/8 and the RFC1918 ranges, so the
embedded node and a daemon on the LAN both qualify.

Claude-Session: https://claude.ai/code/session_01VLQ5DsgKv23osGANoFT3zF
2026-09-04 05:36:45 -04:00
jwinterm 081c01ada9 release: wowlet 5.1.2
Claude-Session: https://claude.ai/code/session_01VLQ5DsgKv23osGANoFT3zF
2026-09-03 15:12:27 -04:00
jwinterm 613d4e7f61 Merge scene work for 5.1.2
The ambient scene grows up: the hidden-tab bug that rained +1s out of an
empty sky is fixed at both ends, the wipeout is a skid rather than a
stunt, clearing a field says WIN, and each tab gets its own amount of
scene — History runs, Receive sits, Send is money going past.

Claude-Session: https://claude.ai/code/session_01VLQ5DsgKv23osGANoFT3zF
2026-09-03 15:12:18 -04:00
jwinterm 79daae6796 fix(send): the scene sits in the middle of the space below the form
It was butted straight up against the Clear/Send row, so a band of night
sky touched a button and read as part of the form. Now there is a fixed
14px gap under the buttons and a stretch either side of the scene, which
centres it in whatever space the form leaves.

And the coins come in handfuls. One at a time reads as a metronome, so
Send now has three of them: usually one goes past, sometimes two, and
about one time in seven all three, staggered by a beat so they arrive
together rather than in a rank. Each rolls its own height, size, duration
and spin direction.

Claude-Session: https://claude.ai/code/session_01VLQ5DsgKv23osGANoFT3zF
2026-09-03 15:10:48 -04:00
jwinterm b1e751a6b7 fix(scene): the sitting doge belongs to Receive only
He was filling History's empty beat as well, left over from before
Receive had a scene of its own. Two tabs showing the same sitting doge
reads as one tab's scene leaking into the other, and History is meant to
be the tab where he runs.

History's opening beat is now sky, grass, clouds and the sun — the scene
breathing before the coins come out — and the sitting doge appears on
Receive and nowhere else. The per-lap idle roll goes with him.

Claude-Session: https://claude.ai/code/session_01VLQ5DsgKv23osGANoFT3zF
2026-09-03 15:04:41 -04:00
jwinterm 068e4c2aa5 fix(scene): money no longer vanishes mid-flight, and the quiet tabs have something to watch
Send's bills were being cut off halfway across. Money mode reused the
run's lap machinery, so every 9-14 seconds the cycle restarted and reset
every bill to the start of its flight — and a roll whose delay plus
duration ran past the cycle simply disappeared wherever it had got to.

Bills now keep their own clock: cross, re-roll, wait, cross again, with
the re-roll deciding afresh whether that bill flies at all. Nothing
resets them from outside, so nothing can cut them short, and the sky
thins and fills instead of carrying identical traffic for ever. 26
seconds of frames now contain no mid-screen disappearance.

Both quiet tabs get an ambient clock, because a form is a long time to
look at a dog breathing:

  * Send: a gold coin arcs across on its own trajectory now and then,
    bills tumble in proportion to their speed, and at night the odd
    shooting star.
  * Receive: a coin drops out of the sky, bounces, and settles in the
    grass beside the doge with a small sparkle burst — a coin arriving is
    the entire point of that tab — and he perks up when it lands. Birds
    fly by in daylight. It lands to one side of him and clear of the sun,
    which is where the first version put it.

Claude-Session: https://claude.ai/code/session_01VLQ5DsgKv23osGANoFT3zF
2026-09-03 14:17:54 -04:00
jwinterm f1144c483e fix(scene): the wipeout is a skid, the win says so, and each tab gets its own amount of scene
The crash launched him. Rotating 66 degrees about a pivot inside the
sprite, shearing him through the middle and scaling him about the same
point left him diagonal in mid-air, elongated, nowhere near the ground —
a stunt, not a trip.

It is now a face-plant and a skid: 26 degrees, a 21px drop so the snout
reaches the grass, and 26px of forward slide along the ground with the
dust coming up. The shear is gone entirely. The pivot is the snout at the
grass line rather than a point inside the body.

The other half of looking airborne was the frozen frame: stopping a run
cycle leaves whatever mid-stride pose the gif was on, legs extended. He
now lands on frame 4, the one with the legs tucked under him.

Clearing the field and getting across now says WIN, with the tally, the
way eating it says GAME OVER. Before, a won lap just ended and the next
one opened with a doge sitting down, which reads as nothing having
happened.

And the scene comes in three amounts, because a form should not have to
compete with a dog: History runs the whole thing, Receive is a doge
sitting in the field, Send is money blowing past and nothing else. One
QML file, gated by mode; the beat machine only runs where there is a run.

Claude-Session: https://claude.ai/code/session_01VLQ5DsgKv23osGANoFT3zF
2026-09-03 13:07:04 -04:00
jwinterm e5b8ef6a00 fix(scene): park hidden tabs, and put some weight behind the doge
The +1s raining out of an empty sky came from a scene running on a tab
that had never been shown. An unshown QQuickWidget is 0px wide, and at
0px every coin sits at x=0, so the doge "collected" the whole field in
one step; the pickup pops from that phantom lap were still mid-flight
when the tab was finally selected and the real width arrived. Their
`SequentialAnimation on opacity { running: <bound> }` was cut off by the
width change, which leaves the property wherever it stopped — so a +1
hung in the air for the rest of the lap, next to a coin nobody had
taken.

Both halves are fixed:

  * SceneWidget parks its scene on hide and starts a fresh lap on show,
    and the QML refuses to run at all until it has been laid out. A
    background tab now costs nothing, too.
  * Every one-shot flourish (+1, bark, dust, GAME OVER) is started and
    reset from an explicit handler, so an interrupted lap cannot strand
    one on screen.

The rest is the ambient scene earning its keep:

  * The face-plant has weight. He tips over his front paws — pivoting on
    the paw at the grass line, not the middle of the sprite, which used
    to swing his snout through the ground — squashes at the nose, bends
    through the body, throws dust off the impact, and lies there
    breathing. GAME OVER now lands a beat later so the crash reads on
    its own, and the lap ends shortly after instead of running out the
    clock on a wreck.
  * He has opinions: HRNMMROOF, BONK, SNOOT.EXE and friends on a
    wipeout; MUCH COIN, AMAZE, VERY WIN when he clears the field, with a
    victory hop to go with it.
  * More variety per lap: zero to three bills at their own speeds and
    heights, a 1-in-3 fat +5 coin with a halo, a doge sitting in the
    field during the empty beat, drifting clouds, a soft corona on the
    sun, blinking stars and the odd shooting star at night, birds by
    day, and a run speed that is no longer always eight seconds.

Verified by rendering scene.qml headless (qml6 + xvfb, frame grabs) at
the real widget sizes, and by driving two scenes through a QTabWidget to
watch the hidden-tab case before and after.

Claude-Session: https://claude.ai/code/session_01VLQ5DsgKv23osGANoFT3zF
2026-09-02 22:34:23 -04:00
11 changed files with 976 additions and 99 deletions
+33 -2
View File
@@ -103,8 +103,39 @@ jobs:
# 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
# bundle Qt frameworks/plugins so the app runs off-machine.
# -qmldir is not optional: our QML lives in .qrc resources, which
# macdeployqt cannot scan, so without it the bundle ships no QML
# modules at all and every page importing anything beyond QtQuick
# fails to load. That is how 5.1.2 shipped a grey mining tab on mac
# while Linux was fine: Contents/Resources held no qml directory, and
# the app logged 'module "QtQuick.Layouts" is not installed'.
/opt/homebrew/opt/qt/bin/macdeployqt dist/Wowlet.app -qmldir=../src/assets/qml
# qmlimportscanner reads only the `import` lines in our QML and does
# not follow a module's own qmldir depends chain, so transitive
# modules (QtQuick -> QtQml.WorkerScript/Models) are silently skipped.
# The AppImage build hit this twice and blanket-copies the trees; do
# the same here, before the dylib passes below so their deps resolve.
# Ask Qt where its QML modules live rather than assuming a brew
# layout; the AppImage build asks qmake the same question.
QTPREFIX="$(brew --prefix qt)"
QMLSRC="$("$QTPREFIX/bin/qmake6" -query QT_INSTALL_QML 2>/dev/null || echo "$QTPREFIX/share/qt/qml")"
[ -d "$QMLSRC" ] || { echo "ERROR: Qt QML modules not found at $QMLSRC" >&2; exit 1; }
mkdir -p dist/Wowlet.app/Contents/Resources/qml
for m in QtQuick QtQml; do
cp -Rn "$QMLSRC/$m" dist/Wowlet.app/Contents/Resources/qml/ 2>/dev/null || true
done
# Guard: the module the mining tab needs has to be in the bundle. A
# missing QML module is invisible until someone opens the tab, so it
# fails the build instead of shipping a page that paints grey.
for m in QtQuick QtQuick/Layouts; do
[ -d "dist/Wowlet.app/Contents/Resources/qml/$m" ] || {
echo "ERROR: $m missing from the bundle; QML pages will not load" >&2
exit 1
}
done
# macdeployqt bundles Qt frameworks but not the plain-dylib brew deps
# of Qt's image plugins (libwebp/libsharpyuv/libbrotli...), which are
+1 -1
View File
@@ -1,7 +1,7 @@
cmake_minimum_required(VERSION 3.18)
project(feather
VERSION "5.1.1"
VERSION "5.1.3"
DESCRIPTION "A free Monero desktop wallet"
LANGUAGES CXX C ASM
)
+1 -1
View File
@@ -252,7 +252,7 @@ void MainWindow::initWidgets() {
});
// [Mining] — wowlet: the pixel-art mining tab (mining.qml + embedded-node solo miner)
m_miningWidget = new MiningWidget(m_wallet, this);
m_miningWidget = new MiningWidget(m_wallet, m_nodes, this);
ui->tabWidget->addTab(m_miningWidget, "Mining");
// [Notes]
+1 -1
View File
@@ -23,7 +23,7 @@ ReceiveWidget::ReceiveWidget(Wallet *wallet, QWidget *parent)
// wowlet: ambient scene as a short banner under the address table. A layout
// item, never an overlay over the table.
auto *scene = new SceneWidget(this, SceneWidget::Dawn);
auto *scene = new SceneWidget(this, SceneWidget::Dawn, SceneWidget::Sit);
scene->setMinimumHeight(110);
scene->setMaximumHeight(130);
ui->verticalLayout->addWidget(scene);
+10 -3
View File
@@ -29,10 +29,17 @@ SendWidget::SendWidget(Wallet *wallet, QWidget *parent)
// wowlet: the form only needs the top of the tab — give the rest of it to
// the ambient scene. A layout item under the form, never an overlay: the
// send form itself stays untouched.
auto *scene = new SceneWidget(this, SceneWidget::Night);
//
// Centred in the space below the form rather than stacked straight under
// it: butted up against the Clear/Send row the scene reads as part of the
// form, and a band of night sky touching a button looks like a mistake.
auto *scene = new SceneWidget(this, SceneWidget::Night, SceneWidget::Money);
scene->setMinimumHeight(160);
scene->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
ui->verticalLayoutOuter->addWidget(scene, 1);
scene->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
ui->verticalLayoutOuter->addSpacing(14); // never touches the Send row
ui->verticalLayoutOuter->addStretch(1);
ui->verticalLayoutOuter->addWidget(scene);
ui->verticalLayoutOuter->addStretch(1);
QString amount_rx = R"(^\d{0,8}[\.,]\d{0,12}|(all)$)";
QRegularExpression rx;
+53 -3
View File
@@ -512,6 +512,10 @@ Rectangle {
Image {
id: imgAxe
visible: mining.daemonMiningState === 0
// Greyed out while the wallet is on someone else's node:
// solo mining is an RPC to the daemon we are connected
// to, and that daemon has to be ours.
opacity: mining.canMine ? 1.0 : 0.35
source: "qrc:/mining/axe.png"
width: 73
height: 75
@@ -551,7 +555,11 @@ Rectangle {
cursorShape: Qt.PointingHandCursor
onClicked: {
if(mining.daemonMiningState === 0) {
if (mining.daemonMiningState === 0) {
if (!mining.canMine) {
remoteNodeNotice.flash();
return;
}
root.startMining();
root.calcAvailableHeightConsoleLines();
}
@@ -559,14 +567,56 @@ Rectangle {
root.stopMining();
}
onEntered: {
imgAxe.height = 64
imgAxe.width = 64
if (mining.canMine) {
imgAxe.height = 64
imgAxe.width = 64
} else {
remoteNodeNotice.show();
}
}
onExited: {
imgAxe.height = 75
imgAxe.width = 73
remoteNodeNotice.hideSoon();
}
}
// Why the axe is greyed out. Shown on hover, and flashed if
// the axe is clicked anyway.
Rectangle {
id: remoteNodeNotice
z: parent.z + 5
visible: opacity > 0.01
opacity: 0.0
width: 250
height: noticeText.implicitHeight + 16
radius: 6
color: "#231a10"
border.color: "#e0b062"
border.width: 1
anchors.right: parent.left
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
Text {
id: noticeText
anchors.centerIn: parent
width: parent.width - 16
wrapMode: Text.WordWrap
horizontalAlignment: Text.AlignHCenter
color: "#f2dcb4"
font.pointSize: 9
text: "Mining needs your own node.\nYou are connected to a remote one — " +
"start the built-in node or point the wallet at a local daemon " +
"under Settings to mine."
}
Behavior on opacity { NumberAnimation { duration: 160 } }
Timer { id: noticeHide; interval: 2600; onTriggered: remoteNodeNotice.opacity = 0.0 }
function show() { noticeHide.stop(); opacity = 1.0 }
function hideSoon() { noticeHide.restart() }
function flash() { show(); noticeHide.restart() }
}
}
}
}
+797 -84
View File
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -3,6 +3,8 @@
#include "MiningWidget.h"
#include "utils/nodes.h"
#include <algorithm>
#include <QVBoxLayout>
@@ -20,9 +22,10 @@ static QString formatUptime(qint64 secs) {
.arg(secs % 60, 2, 10, QChar('0'));
}
MiningWidget::MiningWidget(Wallet *wallet, QWidget *parent)
MiningWidget::MiningWidget(Wallet *wallet, Nodes *nodes, QWidget *parent)
: QWidget(parent)
, m_wallet(wallet)
, m_nodes(nodes)
, m_quickWidget(new QQuickWidget(this))
, m_backend(new MiningBackend(this))
, m_timer(new QTimer(this))
@@ -46,6 +49,10 @@ MiningWidget::MiningWidget(Wallet *wallet, QWidget *parent)
}
void MiningWidget::onStartMining() {
// The QML disables the control, but the signal is not a promise: refuse
// here too rather than send start_mining to a stranger's daemon.
if (!m_backend->canMine())
return;
int threads = std::max(1, QThread::idealThreadCount() / 2);
if (m_wallet->startMining(threads))
m_backend->setState(3);
@@ -57,6 +64,8 @@ void MiningWidget::onStopMining() {
}
void MiningWidget::poll() {
m_backend->setCanMine(m_nodes && m_nodes->connection().isLocal());
bool mining = m_wallet->isMining();
m_backend->setState(mining ? 3 : 0);
+13 -1
View File
@@ -5,6 +5,8 @@
#define WOWLET_MININGWIDGET_H
#include <QWidget>
class Nodes;
#include <QTimer>
#include <QQuickWidget>
@@ -16,16 +18,24 @@ class MiningBackend : public QObject {
Q_OBJECT
Q_PROPERTY(int daemonMiningState READ daemonMiningState NOTIFY daemonMiningStateChanged)
Q_PROPERTY(bool reducedMotion READ reducedMotion NOTIFY reducedMotionChanged)
Q_PROPERTY(bool canMine READ canMine NOTIFY canMineChanged)
public:
explicit MiningBackend(QObject *parent = nullptr) : QObject(parent) {}
int daemonMiningState() const { return m_state; }
void setState(int s) { if (m_state != s) { m_state = s; emit daemonMiningStateChanged(); } }
bool reducedMotion() const { return m_reducedMotion; }
void setReducedMotion(bool r) { if (m_reducedMotion != r) { m_reducedMotion = r; emit reducedMotionChanged(); } }
// Solo mining is a start_mining RPC to whichever daemon the wallet is
// connected to. Against a remote node that is either refused outright or —
// worse, if the operator left the RPC open — it puts someone else's machine
// to work on our address. It is only ever ours to ask of our own node.
bool canMine() const { return m_canMine; }
void setCanMine(bool c) { if (m_canMine != c) { m_canMine = c; emit canMineChanged(); } }
signals:
void daemonMiningStateChanged();
void reducedMotionChanged();
void canMineChanged();
void daemonOutput(const QString &line);
void syncStatus(int from, int to, int pct);
void uptimeChanged(const QString &uptime);
@@ -34,6 +44,7 @@ signals:
private:
int m_state = 0; // 0:idle 1:startup 2:syncing 3:mining
bool m_reducedMotion = false;
bool m_canMine = false;
};
// wowlet: the pixel-art mining tab. Hosts mining.qml and wires its pick-axe to the wallet's solo
@@ -41,7 +52,7 @@ private:
class MiningWidget : public QWidget {
Q_OBJECT
public:
explicit MiningWidget(Wallet *wallet, QWidget *parent = nullptr);
explicit MiningWidget(Wallet *wallet, Nodes *nodes, QWidget *parent = nullptr);
private slots:
void onStartMining();
@@ -50,6 +61,7 @@ private slots:
private:
Wallet *m_wallet;
Nodes *m_nodes;
QQuickWidget *m_quickWidget;
MiningBackend *m_backend;
QTimer *m_timer;
+27 -1
View File
@@ -4,18 +4,44 @@
#include "SceneWidget.h"
#include <QQmlContext>
#include <QQuickItem>
#include "utils/config.h"
SceneWidget::SceneWidget(QWidget *parent, Variant variant)
SceneWidget::SceneWidget(QWidget *parent, Variant variant, Mode mode)
: QQuickWidget(parent)
, m_backend(new SceneBackend(this))
{
m_backend->setReducedMotion(conf()->get(Config::reducedMotion).toBool());
m_backend->setVariant(static_cast<int>(variant));
m_backend->setMode(mode == Sit ? QStringLiteral("sit")
: mode == Money ? QStringLiteral("money")
: QStringLiteral("run"));
this->rootContext()->setContextProperty("sceneCtx", m_backend);
this->setResizeMode(QQuickWidget::SizeRootObjectToView);
this->setSource(QUrl("qrc:/qml/scene.qml"));
this->callScene("suspend");
}
void SceneWidget::showEvent(QShowEvent *event) {
QQuickWidget::showEvent(event);
this->callScene("restart");
}
void SceneWidget::hideEvent(QHideEvent *event) {
this->callScene("suspend");
QQuickWidget::hideEvent(event);
}
void SceneWidget::resizeEvent(QResizeEvent *event) {
QQuickWidget::resizeEvent(event);
if (this->isVisible())
this->callScene("restart");
}
void SceneWidget::callScene(const char *method) {
if (auto *root = this->rootObject())
QMetaObject::invokeMethod(root, method);
}
void SceneWidget::refreshReducedMotion() {
+30 -1
View File
@@ -5,6 +5,7 @@
#define WOWLET_SCENEWIDGET_H
#include <QObject>
#include <QString>
#include <QQuickWidget>
// Context object exposed to scene.qml as `sceneCtx`. Carries the reduced-motion
@@ -14,18 +15,23 @@ class SceneBackend : public QObject {
Q_OBJECT
Q_PROPERTY(bool reducedMotion READ reducedMotion NOTIFY reducedMotionChanged)
Q_PROPERTY(int variant READ variant NOTIFY variantChanged)
Q_PROPERTY(QString mode READ mode NOTIFY modeChanged)
public:
explicit SceneBackend(QObject *parent = nullptr) : QObject(parent) {}
bool reducedMotion() const { return m_reducedMotion; }
void setReducedMotion(bool r) { if (m_reducedMotion != r) { m_reducedMotion = r; emit reducedMotionChanged(); } }
int variant() const { return m_variant; }
void setVariant(int v) { if (m_variant != v) { m_variant = v; emit variantChanged(); } }
QString mode() const { return m_mode; }
void setMode(const QString &m) { if (m_mode != m) { m_mode = m; emit modeChanged(); } }
signals:
void reducedMotionChanged();
void variantChanged();
void modeChanged();
private:
bool m_reducedMotion = false;
int m_variant = 0;
QString m_mode = QStringLiteral("run");
};
// wowlet: reusable ambient-scene surface. Hosts scene.qml and reads the
@@ -38,9 +44,32 @@ public:
// same field. Must match the `skies` table in scene.qml.
enum Variant { Dusk = 0, Dawn = 1, Night = 2 };
explicit SceneWidget(QWidget *parent = nullptr, Variant variant = Dusk);
// How much scene a surface wants. History has room for the whole run;
// Receive gets a doge sitting in the field; Send gets money blowing past and
// nothing else, so the form it sits under is not competing with a dog.
enum Mode { Run, Sit, Money };
explicit SceneWidget(QWidget *parent = nullptr, Variant variant = Dusk,
Mode mode = Run);
void refreshReducedMotion();
protected:
// A scene on a hidden tab is parked rather than left running: an unshown
// QQuickWidget is 0px wide, and a lap run against a 0px field collects every
// coin at once and leaves its litter (+1s, barks) to surface on the real lap
// when the tab is finally shown. Parking also keeps a background tab from
// burning CPU on animation nobody is looking at.
void showEvent(QShowEvent *event) override;
void hideEvent(QHideEvent *event) override;
// Belt and braces: a scene that is on screen and has just been given a real
// size is running, whether or not a show event was the thing that got it
// there. `restart` only sets a flag, so re-arming an already-running scene
// costs nothing and does not interrupt its lap.
void resizeEvent(QResizeEvent *event) override;
private:
void callScene(const char *method);
SceneBackend *m_backend;
};