Compare commits

...
8 Commits
Author SHA1 Message Date
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
10 changed files with 943 additions and 97 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
cmake_minimum_required(VERSION 3.18)
project(feather
VERSION "5.1.1"
VERSION "5.1.2"
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;
};