Compare commits

...
78 Commits
Author SHA1 Message Date
John Winter MurphyandGitHub 64529590a5 Merge pull request #5 from wownero/fix/appimage-libcom-err-20261024
fix(appimage): bundle libcom_err runtime
2026-10-24 12:10:01 -04:00
such-gitea 3c5aaed087 fix(appimage): bundle libcom_err runtime 2026-10-24 12:09:17 -04:00
John Winter MurphyandGitHub 020b177bd1 Merge pull request #4 from wownero/fix/macos-signing-keychain-515
fix(release): restore Mac signing keychain search
2026-10-17 08:29:38 -04:00
such-gitea fb0c34cd70 fix(release): restore Mac signing keychain search 2026-10-17 08:27:14 -04:00
John Winter MurphyandGitHub e0670bede5 Merge pull request #3: retain candidate source and signing evidence
fix(release): bind candidate files and signing evidence to each run
2026-10-17 06:21:55 -04:00
such-gitea ea04f585aa fix(release): bind candidate hashes and signing evidence to each run 2026-10-17 06:20:40 -04:00
John Winter MurphyandGitHub 25a341fd10 Merge pull request #2: prepare Wowlet 5.1.5 security release
chore(release): prepare Wowlet 5.1.5
2026-10-17 06:04:13 -04:00
such-gitea ec8bdf4dfe fix(wallet): include the Wownero 0.11.5.0 security library 2026-10-17 06:03:15 -04:00
such-gitea 34bdecf5ac fix(release): embed the verified Wownero 0.11.5.0 daemon 2026-10-17 05:59:54 -04:00
such-gitea d9cd8e328e fix(release): check macOS signing inputs before compilation 2026-10-17 05:52:26 -04:00
such-gitea 998684c1a7 ci(release): bind private Wowlet candidates to reviewed source 2026-10-17 05:51:46 -04:00
such-gitea bf455e06f1 chore(release): prepare Wowlet 5.1.5 2026-10-17 05:46:57 -04:00
John Winter MurphyandGitHub 4e3f74a38a fix: use Wownero support contacts
Use Wownero support contacts and preserve them when regenerating bundled guides. Run lightweight source checks on GitHub; release builds remain in the private Builds system.
2026-10-17 05:32:51 -04:00
such-gitea 4050d51760 ci: keep GitHub source checks separate from private release builds 2026-10-17 05:15:39 -04:00
such-gitea 8dacc3ba5a ci: gate Wowlet documentation changes 2026-10-16 14:43:04 -04:00
such-gitea 66c16d152d fix(docs): route Wowlet support to Wownero contacts 2026-10-16 14:27:59 -04:00
jwinterm f4bbfd49ad fix(ci): publish the signed Windows zips to the tag's release
The Linux and macOS jobs already attach their tag-build output to the release,
because this Gitea reports zero artifacts for every run through its REST API and
its web download route answers "Not found." under every auth style. The Windows
job was left out, so even a green signed build produced an exe nobody could
fetch back.

The step runs after the Authenticode, RFC 3161 timestamp and signer checks, so
an unsigned or untimestamped binary can never reach a release. Both archives go
up: the signed one users should download, and the unsigned one that stays
byte-reproducible for verifiers.

curl.exe rather than Invoke-WebRequest, because `shell: powershell` is Windows
PowerShell 5.1 and its Invoke-WebRequest has no -Form for multipart uploads.
Fail-safe like the others: no token, no resolvable release or a non-201 upload
reports itself and exits 0 rather than reddening a build that produced a
correctly signed binary.
2026-09-13 18:09:17 -04:00
jwinterm 142b79ee27 fix(depends): tell the consuming build where the host Qt is
A cross Qt carrying qtdeclarative makes find_package(Qt6) demand a host Qt,
because QML needs host-side tools: qmlcachegen, qmlimportscanner and
qmltyperegistrar. depends already builds one. native_qt installs it to
native/qt-host, and qt.mk passes that path to the cross Qt's own configure.

What nobody passed it to was wowlet. Qt6Config re-runs the host-path setup when
our project calls find_package, finds nothing, and stops with "To use a
cross-compiled Qt, please set the QT_HOST_PATH cache variable". The Windows
cross build has failed that way on every tag since qtdeclarative landed in
June, which is why 5.1.4 shipped macOS and Linux but no exe.

Stating it in the toolchain rather than in the workflow fixes every cross
target and anyone building locally through depends, and the built-cache key
already hashes this file, so the stale mingw tree is rebuilt rather than
restored without the host Qt.
2026-09-13 18:06:55 -04:00
jwinterm a43e9fe122 fix(ci): publish tag builds to the release, where they can be fetched back
Run artifacts upload fine, but this Gitea reports zero artifacts for every run
through its REST API, and the web download route answers "Not found." under
token, bearer and basic auth alike. A signed build was therefore unreachable
from anywhere except the browser session that produced it.

Release assets do work: create, upload, list and download all round-trip with
an ordinary token, so tag builds now attach their output to the tag's release
as well as to the run. Retrieval must use browser_download_url; the asset-id
API path returns the asset's JSON metadata even when asked for octet-stream.

The step is fail-safe on purpose. With no token, no resolvable release, or a
non-201 upload it explains itself and exits 0 rather than turning a green
build red. The macOS upload also gains if-no-files-found, without which an
empty publish passes silently.
2026-09-13 13:18:22 -04:00
jwinterm ce84b1d062 fix(ci): scope the embed extracts to the run, not to a shared /tmp
ubuntu-latest and windows-latest both resolve to the same host in host mode,
and both jobs began with `rm -rf /tmp/wownerod-embed`. Dispatched together for
5.1.4, the Windows fetch deleted the Linux job's binary in the eight seconds
between its own `ls` showing the file present and cmake configuring, so the
Linux build died on "WOWNEROD_DIR was specified but the wownerod binary could
not be found" while the identical workflow had passed minutes earlier when it
ran alone.

Both jobs now extract under /tmp/wowlet-$GITHUB_RUN_ID, so concurrent runs stop
sharing state. macOS is left alone: it runs on its own host, it never rm -rf's
the directory, and it is the one platform currently building green.
2026-09-13 13:13:35 -04:00
jwinterm 0e6113729d chore(release): 5.1.4
Carries the binary rename: the shipped executable, the macOS bundle and the
Linux desktop entry are all wowlet now, where 5.1.3 still shipped feather.
2026-09-13 13:03:50 -04:00
such-gitea 27d7b346c0 fix(build): ship the binary as wowlet, not feather 2026-09-13 10:57:32 -04:00
jwinterm ea9c73826b fix(build): ship the binary as wowlet, not feather
The CMake target keeps its upstream name so the twenty-odd target_* calls stay
put, but OUTPUT_NAME now produces build/bin/wowlet, and on macOS a wowlet.app
whose CFBundleExecutable matches it.

Linux and Windows already renamed the artifact as they copied it, so their cp
lines just move to the new path. macOS copied the whole bundle instead, so it
shipped Contents/MacOS/feather with a Feather name in the Info.plist, which is
what anyone installing it actually saw. The Linux install rules had the same
problem one level down: they installed feather.desktop, which sets Exec=feather
and Name=Feather Wallet, while the correct wowlet.desktop sat unused beside it.

CFBundleIdentifier moves to org.wownero.wowlet. Nothing in signing or
notarization reads it, but macOS keys per-app state by bundle id, so an existing
install starts with fresh window state.
2026-09-13 10:49:00 -04:00
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
jwinterm b200d997b0 fix(ci): write ZIP entry names with forward slashes
Compress-Archive on Windows PowerShell writes the OS path separator into entry
names, so the archives came out with "tor\tor.exe". The ZIP format mandates
forward slashes, and 5.1.0 shipped "tor/tor.exe" from `zip -X` on Linux -- this
was a regression introduced when the packaging moved to the signing host.

It is not cosmetic for this application. Extractors that take the backslash
literally produce a file named "tor\tor.exe" in the archive root rather than a
tor directory, which puts the bundled Tor somewhere wowlet does not look. The
daemon is given an exclusive --tx-proxy, so a wallet that cannot find its Tor
cannot relay a transaction at all.

Archives are now built through System.IO.Compression with entry names
normalised explicitly, and the job asserts the result: a backslash in any entry
name, or a missing tor/tor.exe, fails the build rather than shipping.

Caught by extracting the artifact and diffing it against the 5.1.0 release
before publishing.
2026-09-01 10:23:07 -04:00
jwinterm 11e8e3241c ci(gitea): move the Linux AppImage off GitHub, and ship both Windows archives
Linux: the x86_64 AppImage now builds on the Gitea runner. Unlike the Windows
lane, which builds a static toolchain of its own through contrib/depends, it
links against the host's Qt6 -- so those dependencies are owned by such-fleet
(roles/wowlet_build_host) and the job asserts them rather than apt-installing.
A workflow that installs needs sudo on a shared runner and makes the build
host's contents a property of whichever job ran last.

Windows: the lane now publishes two archives instead of one. Authenticode
mutates the exe, so a signed binary can never byte-match a build someone
reproduces from source. Shipping only the signed one quietly costs the project
its reproducibility story; shipping only the unsigned one costs every normal
user a SmartScreen warning. Both, with both hashes in the PGP-signed
hashes.txt, costs neither -- and keeps one canonical download page rather than
splitting the project across a fork.

The AppImage lane carries the packaging knowledge that a plain linuxdeploy run
does not: qmlimportscanner does not follow a module's own qmldir dependency
chain, so the QtQuick and QtQml trees are copied wholesale and each copied
plugin's Qt libraries resolved by hand, and a guard fails the build if
mining.qml ever imports QtQuick.Controls again rather than shipping a white
mining tab.
2026-08-31 18:23:46 -04:00
jwinterm f5e40ab5b2 ci(gitea): pin the artifact actions to v3
The first Gitea run built cleanly all the way through -- depends, wowlet.exe,
staging -- and then died on the last step:

  ::error::@actions/artifact v2.0.0+, upload-artifact@v4+ and
  download-artifact@v4+ are not currently supported on GHES.

Gitea Actions implements the older artifact protocol, so the v4 actions (which
use @actions/artifact v2) refuse to talk to it. Hash Bags' Windows lane already
uses v3 for the same reason.

Pinned with a comment so nobody upgrades it back and loses a 22 minute build to
the same error.
2026-08-31 18:15:46 -04:00
jwinterm 3231bc5df2 release: wowlet 5.1.1 2026-08-31 18:13:05 -04:00
jwinterm 0ce230de3c ci(gitea): move the Windows build and signing off GitHub
Gitea is master and GitHub is a mirror, but the Windows and Linux jobs still
only existed in .github/workflows, so GitHub was doing real build work. This
moves Windows.

Two jobs on two machines, which is the point rather than an accident:

  build  runs on `deb` (windows-latest:host — it carries the MinGW
         cross-compiler) and produces an unsigned wowlet.exe.
  sign   runs on desktop-iim2fs2 (windows:host), the machine with the EV
         code-signing token physically in it.

The signing job does not itself sign. Its runner is NT SERVICE\act_runner,
which cannot use the token: SafeNet holds the login per user and the login is
established interactively by an operator. So the job queues the binary for the
Fleet-installed broker (roles/windows_code_signing) and waits. CI never holds
the key and never holds the token password, so it cannot spend one of the
token's three password attempts.

Differences from the GitHub lane, all because this is a real fleet host rather
than a disposable hosted runner:

- No apt install and no sudo. Every dependency is already present on deb, and a
  first step asserts the toolchain — including that mingw is the posix
  threading variant — and fails pointing at such-fleet rather than half
  building.
- No disk-freeing or fallocated swap. Those existed to survive a hosted
  runner's 16G/14G budget; deb has 30G and 3.1T free.
- depends builds at -j4 and wowlet at -j6 rather than -j2 for the same reason.

The exe is staged unzipped between the jobs because the broker signs one PE at
a time; the published zip is assembled after signing, so the archive contains
the signed binary. The signature, its RFC 3161 timestamp and the expected
signer are all re-verified on the artifact that actually ships.

Dispatch-only: both runners are capacity 1 and shared with other lanes.
2026-08-31 17:54:02 -04:00
jwinterm 252b358baa feat(scene): per-lap variety, the occasional face-plant, and a sky per tab
Every tab was running the identical eight seconds forever. Three changes:

- Each lap rolls its own timing: 3-5s of empty field, and the money now enters
  on its own delay at its own pace instead of shadowing the doge every time.
- Roughly one lap in six the doge eats it partway down the field: he tips
  nose-first onto the grass, GAME OVER comes up, and the coins he never reached
  are still sitting there. Costs no new art — it is the running sprite pivoted
  on its own feet.
- A `variant` on SceneWidget picks the sky: dusk (History, lock screen), dawn
  (Receive), night (Send, with stars and a moon instead of the sun).

The cycle is now a timer-driven state machine rather than one SequentialAnimation.
That is not stylistic: a lap that rolls its own durations has to assign them
somewhere, and assigning a duration to a child of a *running* SequentialAnimation
makes the group recompute and re-enter its own ScriptAction until the JS stack
blows. Qt flags the binding form of the same mistake as a binding loop.

Verified by headless qml6 render: a forced trip lap, and a clean run with no QML
warnings.
2026-08-31 13:23:41 -04:00
jwinterm dd76a7c4bf chore: gitignore the generated wownerod embed
The build stages the wownerod binary into src/assets/wownerod/ and generates
src/assets_wownerod.qrc, exactly as it does for tor and docs — but only the tor
and docs paths were ignored. A `git add -A` in a build tree therefore offered a
24MB binary and a generated qrc for commit. Ignore them the same way.
2026-08-31 12:49:06 -04:00
jwinterm 9bd8cb0676 feat(send,receive): the ambient scene, as a layout item this time
Send had a large empty region under the form doing nothing, and Receive had
nothing at all. Both get the scene back — but as real layout items, not as the
overlay that painted over the forms last time.

Send: the form only ever needed the top of the tab, so the QFormLayout is now
wrapped in a vbox and the scene takes the rest. The widget's vertical size
policy was Maximum, which made it hug the form; it owns the whole tab now. The
form itself is untouched, and nothing is drawn on top of the address or amount
fields.

Receive: a short banner under the address table.

Built and smoke-tested from the AppImage lane.
2026-08-31 12:48:41 -04:00
jwinterm 48a8c36135 feat(scene): give the loop three beats — empty, coins pop in, then the run
The lap ran flat out and reset the moment the doge left the frame. It now
breathes:

  1. ~4s of empty field, nothing but sky and grass
  2. the coins pop in one at a time (320ms apart)
  3. the doge and the money cross, and the doge collects them

Both coin states stay derived from the cycle rather than tracked: a coin is out
once `coinReveal` passes its index, and collected once the doge's snout reaches
its centre. Dropping `coinReveal` to 0 and `runProgress` to 0 at the top of the
loop therefore restores the whole field with no reset bookkeeping, and parks
the doge and the money off the left edge for the empty beat at the same time.

The money now crosses with the doge instead of drifting on its own clock, a
little quicker so it leads him off screen, and it sits proportionally in the
sky so a tall scene does not pin it to the top edge.

Verified by headless qml6 render across a full cycle at 1000x200.
2026-08-31 12:48:41 -04:00
jwinterm 19c946c705 fix(mining): pin the console so Block Height / Sync Progress stay inside it
The metrics kept sliding out of the bottom of the node console. The cause was
free height, not margins: the top bar's RowLayout, its right-hand ColumnLayout
and the console RowLayout all defaulted to fillHeight, so any slack in the
window was absorbed by the console. The 102px bezel graphic stretched with it
(topright_middle is a 1px column tiled horizontally), the label/value rows —
two fillHeight halves — spread apart with it, and a full block height ended up
rendered below the panel. The three `Layout.preferredHeight: parent.height`
bindings, which asked a layout child for its own parent layout's height, made
the result depend on resolution order.

Now the top bar, its column and the console are pinned to their design heights
(128 / 128 / 102), the bezel images take the literal 102, and the metrics are a
plain anchored Row of two Columns at a fixed offset inside the console's dark
interior band (rows 7..94 of the bezel) with the value directly under its
label.

Verified by headless qml6 render at 1029x{500,597,650,720,800} with a full-width
block height ("369918/369918"): the pair sits in the same place at every height
and stays inside the panel.
2026-08-31 12:15:11 -04:00
jwinterm 1828353d52 fix(send,receive): drop the corner doge that painted over the forms
IdleDoge scaled its movie from `currentImage()` after `jumpToFrame(0)`, but the
first frame is not decoded at that point, so the size came back null, the
scaling was skipped, and the QLabel ended up at the GIF's full 500x397. Parked
at the bottom-right of the form it painted an opaque 500x397 slab of window
background over the lower half of Send and over the Receive address table.

Send is the screen where money leaves the wallet; it does not get an overlay
sitting on top of the address and amount fields at all. Receive loses it for
the same reason. SendWidget.cpp and ReceiveWidget.cpp are back to exactly their
pre-ambient state, and the widget is removed rather than left unused.

The ambient scene keeps the places where it has its own real estate: the lock
screen, the History banner, and the mining tab.
2026-08-31 12:15:00 -04:00
jwinterm 2a54663f76 fix(scene): the doge collects the coins, and no more slow first lap
Three things you spotted on the History banner:

- Slow first lap that restarted at speed. The dog and the money animated
  `to: scene.width`, and the QQuickWidget starts at a placeholder width: the
  first lap crawled across the wrong distance, then the real width arrived,
  re-bound `to`, and restarted the animation. Motion is now driven by a
  unitless 0..1 progress and mapped onto the current width each frame, so a
  resize repositions without restarting.

- Coins sat mid-sky and clipped the top border. They now rest on the grass
  with a low bob, at the doge's pickup height.

- The doge collects them. A coin is collected once the doge's snout reaches
  its centre: it pops, fades, and floats a "+1". The collected state is
  derived from the run position, so the coins simply come back when the lap
  restarts, with nothing to reset.

Reduced motion still gets a static field: doge parked at the left, every coin
present, money parked in the sky.

Verified by headless qml6 render at banner (1000x140, 620x120) and lock-screen
(900x380) sizes, motion and reduced-motion, with no QML warnings.
2026-08-31 12:14:46 -04:00
jwinterm 6942cbb459 feat: ambient silliness across the app (reduced-motion aware)
- Lock screen: full ambient scene above the unlock form (the idle 'screensaver'
  — wallet auto-locks here) instead of a thin banner.
- History ('home'): full scene as a top banner.
- Send/Receive: IdleDoge — a small click-through sitting doge in the corner,
  the 'most quiet' idle for task screens.
- Mining keeps its own scene.
All gated by Config::reducedMotion (scenes freeze, doge shows a still frame).
2026-08-31 11:10:25 -04:00
jwinterm df4711d1c1 feat: ambient scene system + unlock-screen banner (reduced-motion aware)
Reusable SceneWidget (QQuickWidget host) loads scene.qml — dusk sky, tiled
grass, running doge, bouncing WOW coins (slowed per feedback), drifting
money — and feeds it Config::reducedMotion so it freezes to a still when
that's set. First placement: a banner atop the unlock form. Scene rendered
& verified at banner + screensaver aspects via local qml6 harness. Assets
from wow-lite-wallet (friend's). Home banner / idle screensaver / per-tab
idle to follow on the same SceneWidget.
2026-08-31 10:22:47 -04:00
jwinterm 7b71f5dc9a feat: 'Prefer reduced motion' setting (accessibility / animation opt-out)
Config::reducedMotion (default false = playful animations ON). Surfaced as a
checkbox on first run (wizard PageMenu) and in Settings → Appearance —
unchecked by default, matching the OS 'Reduce Motion' convention. The mining
tab's AnimatedImages now freeze when it's set (MiningBackend exposes it to
mining.qml). Foundation for gating the ambient silliness to come.
2026-08-30 12:36:15 -04:00
jwinterm 0ed8c005c5 mining tab: tighten Block Height/Sync Progress label+value grouping
The value sat ~30px below its label (label centered in top cell, value
centered in bottom cell), so long values like the block height floated low
in the console. Anchor labels to the bottom of their cell and values to the
top of theirs so each metric reads as a tight pair. Verified via local
qml6 render.
2026-08-30 12:00:44 -04:00
jwinterm 1c81365247 feat(splash): random silly loading GIF (wow-lite-wallet tradition)
The wallet-open splash now shows a random loader from a bundled set (13
curated GIFs from wownero/wow-lite-wallet, ~2.2MB) instead of the static
key icon — auto-discovered from :/silly, aspect-scaled to 56px, falls back
to the key pixmap if none present. Assets by a friend (same Wownero family).
2026-08-30 10:56:37 -04:00
jwinterm c65c12d328 release: wowlet 5.1.0
- macOS: first native signed + notarized build (new platform → minor bump)
- fix: stuck-tx detection + rebroadcast + re-relay on daemon sync
  (resolves the 'unconfirmed for days' field report)
- mining tab: top text layout fixes
2026-08-30 06:36:44 -04:00
jwinterm 38c63088b5 Merge macOS build lane: native signed+notarized Gitea build
Brings .gitea/workflows/build-macos.yml (brew-native compile, embedded
wownerod+Tor, static boost, bundled image codecs, Developer ID sign +
notarize) and the portable cmake tor/wownerod copy fixes.
2026-08-30 06:36:23 -04:00
jwinterm 3d38fdd135 ci(macos): bundle Qt image-plugin dylib deps (webp/sharpyuv/brotli) for clean-Mac runtime
macdeployqt bundles Qt frameworks but not the plain @rpath/*.dylib brew
deps of its image plugins, so a no-Homebrew Mac couldn't load webp. Add a
Qt-safe pass that resolves @rpath/*.dylib (never @rpath/*.framework) from
brew, copies them in, rewrites load paths, and fails the build if any
remain. Sign step now re-signs by Mach-O type (not exec bit) so the
install_name_tool-modified plugins get valid signatures.
2026-08-30 05:34:04 -04:00
jwinterm f2c3ce45a0 ci(macos): link boost statically (Boost_USE_STATIC_LIBS=ON)
The 100%-linked app dynamically referenced brew's libboost_*.dylib, which
macdeployqt failed to bundle — the app would fail to launch on any Mac
without Homebrew (notarization doesn't check runtime dylib resolution).
Static boost matches how guix/depends release builds work and removes the
runtime dependency entirely.
2026-08-30 05:08:01 -04:00
jwinterm 87d5977243 ci(macos): CPATH/LIBRARY_PATH to brew prefix so feather finds boost headers
monero's static libs built fine, but the feather target includes monero
headers pulling boost/variant.hpp, and clang doesn't search
/opt/homebrew/include by default (modern BoostConfig leaves
Boost_INCLUDE_DIRS empty).
2026-08-30 04:55:20 -04:00
jwinterm 9e6d85c246 ci(macos): disable Trezor (USE_DEVICE_TREZOR=OFF)
brew's protobuf pulls Abseil headers that require C++17, breaking the
feather compile (DEVICE_TREZOR_READY gates the protobuf link). Trezor
hardware does not support Wownero, so this removes a useless dependency
rather than working around it — the Linux lane only tolerates it because
its depends system pins an old protobuf.
2026-08-30 04:42:14 -04:00
jwinterm 49b3264b0e cmake: portable tor/wownerod copies — BSD cp lacks -u (exit 64 evaded the ret==1 check, silently shipping without embedded node/tor); fail on ANY nonzero cp exit 2026-08-29 14:38:24 -04:00
jwinterm 3cf58f0313 cmake: mkdir src/assets/tor before the TOR_DIR copy (untracked dir; BSD cp usage error evades the ret==1 check) 2026-08-29 14:28:09 -04:00
jwinterm 0f6892c123 ci(macos): ad-hoc sign tor before embedding — arm64 macOS SIGKILLs unsigned binaries, including the runtime-unpacked copy on user machines 2026-08-29 14:17:26 -04:00
jwinterm 1fedbf0faa ci(macos): ship libevent dylib next to tor (@executable_path linkage) 2026-08-29 14:06:15 -04:00
jwinterm 410df0c8ff ci(macos): extract wownerod into an explicit dir — find(1) does not descend the /tmp symlink 2026-08-29 13:55:16 -04:00
jwinterm 1853ebf558 ci(macos): disable pipefail around yes|brew (SIGPIPE 141 when brew asks nothing) 2026-08-29 13:47:37 -04:00
jwinterm a2b34f5ebc ci(macos): embed tor (expert bundle 15.0) — arms daemon --tx-proxy via HAS_TOR_BIN, parity with linux lane 2026-08-29 13:44:28 -04:00
jwinterm 27f694c4bd mining tab: fix top text layout — anchor conflict on sync value, oversized fonts, bezel overflow
- Sync Progress value had both left and horizontalCenter anchors (drifted center)
- 20pt labels in 30px rows collided; now 14pt matching the top-left monitors
- hashrate/uptime/height values auto-fit their pixel-art frames (HorizontalFit)
- 'uptime' -> 'Uptime'
2026-08-29 13:43:05 -04:00
jwinterm c7333a775c Merge fix/stuck-tx-detection: broadcast-failure detection, stuck-tx warning + rebroadcast, re-relay on daemon sync 2026-08-29 13:41:32 -04:00
jwinterm 8b846a8dc0 ci(macos): pin CMAKE_PREFIX_PATH to brew qt6 (qt@5 keg coexists unlinked on the runner) 2026-08-29 13:37:41 -04:00
jwinterm dc5f940801 ci(macos): pipe yes into brew — Homebrew 6 prompts for dependency installs regardless of NONINTERACTIVE 2026-08-29 13:27:05 -04:00
jwinterm 36d878e7bf ci(macos): also skip dependent upgrades in brew install 2026-08-29 13:16:13 -04:00
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
jwinterm a3509a6c3b Detect stuck outgoing transactions and re-relay after daemon restart
Field report: a send sat 'in mempool' for two days while the network had
never seen it (Tor down at send time defeats both the multi-broadcast and
the embedded daemon's exclusive --tx-proxy relay), locking the change.

Three additive changes, all strictly local (no txid is ever queried
against an external service):

- onMultiBroadcast now counts relay responses; if no node accepted the
  broadcast within 45s the user is told immediately and offered the
  existing rebroadcast path, instead of silence.
- A periodic local history scan warns once per outgoing tx that has been
  pending for more than 25 minutes, again offering rebroadcast (which
  already reconnects to a fresh node first).
- On DaemonManager::synced, still-pending outgoing txs are re-announced
  from the wallet's own tx cache; a restarted embedded node's pool has
  forgotten them.
2026-08-29 06:13:06 -04:00
jwinterm 93d13a6568 v5.0.3: harden embedded-node restart handling + version bump
On top of Lucas's #119 (--db-sync-mode safe):
- DaemonManager: age out the crash-retry budget (m_restarts) so legit node
  toggles/restarts don't trip "maximum retries exceeded" after ~4 spawns over a
  session; the cap now only bites a genuine rapid crash-loop.
- DaemonManager: clear the m_stopping latch on start(), so the crash auto-restart
  keeps working after the node has been toggled off then on (it was a one-shot
  before).
- bump version 5.0.2 -> 5.0.3.
2026-06-27 13:10:54 -04:00
Lucas 64326119eb Updated DaemonManager to ensure new blocks are
committed immediately by disabling LMDB batch write mode.
2026-06-26 21:57:42 +10:00
jwinterm e3ba9c7bad v5.0.2: hide hardware-restore option + version bump
- wizard: hide the hardware-device wallet option (wownero has no Ledger/Trezor
  support; inherited Feather cruft).
- bump version 5.0.1 -> 5.0.2.

v5.0.2 also rebuilds the Linux AppImage in an ubuntu:24.04 container (glibc 2.39)
so it runs on Ubuntu 24.04 / Mint / Debian — v5.0.1's AppImage was built on
glibc 2.43 and would not start elsewhere. No wallet-code change: wallets created
on pre-table-fix builds carry a stale restore height (blank balance); the fix is
to re-restore from seed.
2026-06-25 09:19:19 -04:00
jwinterm 6cd7e8bad9 fix: regenerate stale restore-height table (fixes vanishing balance)
restore_heights_wownero_mainnet.txt was last updated 2023-12-15 (h587048).
For wallets created after that, RestoreHeightLookup::dateToHeight extrapolates
forward at 288 blk/day; 2.5y past the last anchor overshoots the chain tip, so
the computed restore height lands ABOVE the current height. The wallet then
skips output-scan on every real block => received funds are never recorded,
while the tx pool still shows them: coins appear pending, then vanish.

Same bug class as 8686e9bf (which fixed the block-time CONSTANT from monero's
120s/720-per-day to wownero's 300s/288); here the constant was right but the
TABLE went stale. Regenerated with contrib/generate-restore-heights/heights.py
to h849000 (2026-06-19). Verified dateToHeight < tip for all dates 2019..today.
2026-06-23 21:25:18 -04:00
jwinterm 0d5d745311 v5.0.1: Windows QtQuick crash fix + node/Tor lifecycle overhaul
Windows crash chain (exposed by the embedded-node default):
- main.cpp: force the QtQuick software renderer. The Mining tab's QQuickWidget
  drove the default RHI/GL path and faulted (0xc0000005) on broken-GL hosts
  (VirtualBox VMSVGA), crashing the app and orphaning the embedded node.
  Real hardware was unaffected.
- ChildProcessGuard + DaemonManager/TorManager: kernel-level die-with-parent
  (Windows Job Object KILL_ON_JOB_CLOSE / Linux PR_SET_PDEATHSIG) so the
  embedded wownerod and bundled tor never survive a wowlet exit or crash.
- DaemonManager: verify-before-adopt via get_info (nettype/height) instead of
  trusting a bare open RPC port, so a stale/orphan node is never adopted.

Node/Tor lifecycle + remote-node connectivity:
- config: torPrivacyLevel default -> allTorExceptNode. NEVER sync blocks over
  Tor; the wallet's node connection is always clearnet. Tor is only for tx
  broadcast, so remote nodes work with or without Tor.
- nodes: add Nodes::reconnect() (clear failure blocklist + force reconnect);
  node/Tor toggle handlers use it so a transient outage no longer strands the
  wallet at "Disconnected".
- MainWindow/SettingsDialog: reconnect on node/Tor toggle and on the async
  daemonStateChanged (closes the start() race; a freshly-toggled local node
  connects immediately).
- TorManager: reset retry/unpack state in init() so a system<->bundled Tor
  re-toggle can start again; quiesce on stop(). Linux unpackBins() rework
  (mkpath + presence-based unpack + graceful return-false), getVersion() arg
  fix, and a missing-binary start guard.
- build.yml: derive TOR_VERSION from the embedded tor binary (a hardcoded
  bundle number made unpackBins rewrite Tor every launch).
- depends/funcs.mk: confine cross-cmake find_package to the depends sysroot so
  host -dev libraries don't leak into the build.
2026-06-23 17:45:39 -04:00
85 changed files with 3992 additions and 607 deletions
+164
View File
@@ -0,0 +1,164 @@
name: linux appimage (gitea self-hosted)
# Runs ONLY on Gitea (GitHub ignores .gitea/workflows), in the private Builds
# repository. GitHub wownero/wowlet owns source and release publication.
#
# Unlike the Windows lane, which builds a static toolchain of its own through
# contrib/depends, the x86_64 AppImage links against the host's Qt6. Those
# dependencies are owned by such-fleet (roles/wowlet_build_host) rather than
# installed here: a workflow that apt-installs needs sudo on a shared runner and
# makes the build host's contents a property of whichever job ran last.
#
# Dispatch-only. The runner is capacity 1 and shared with the fleet gates.
on:
workflow_dispatch:
inputs:
expected_sha:
description: Exact reviewed Builds commit
required: true
type: string
env:
EXPECTED_SHA: ${{ inputs.expected_sha }}
jobs:
appimage:
name: "Linux x86_64 (host Qt6 AppImage)"
runs-on: ubuntu-latest # gitea label ubuntu-latest:host -> deb, host mode
steps:
- name: verify the build host has its dependencies
run: |
missing=0
for t in cmake make curl bzip2 git patchelf pkg-config qmake6; do
command -v "$t" >/dev/null || { echo "MISSING TOOL: $t"; missing=1; }
done
for h in /usr/include/boost/version.hpp; do
[ -f "$h" ] || { echo "MISSING HEADER: $h"; missing=1; }
done
pkg-config --exists libsodium || { echo "MISSING: libsodium"; missing=1; }
[ "$missing" = 0 ] || {
echo "Run playbooks/wowlet-build-host.yml in such-fleet; do not apt-install from CI."
exit 1; }
echo "qt $(qmake6 -query QT_VERSION)"; nproc; free -g | head -2
- name: configure git
run: git config --global --add safe.directory '*'
- uses: actions/checkout@v4
with:
submodules: recursive
ref: ${{ github.sha }}
- name: Verify exact reviewed source
run: python3 -B contrib/release/check_dispatch.py
- name: fetch verified wownerod to embed
run: |
python3 -B contrib/release/fetch_wownerod.py \
--target x86_64-linux-gnu \
--destination "/tmp/wowlet-${GITHUB_RUN_ID}/wownerod-embed"
- name: fetch tor to embed
run: |
EMBED="/tmp/wowlet-${GITHUB_RUN_ID:-local}"
rm -rf "$EMBED/tor-embed" && mkdir -p "$EMBED/tor-embed"
curl -fL -o "$EMBED/teb.tgz" \
https://archive.torproject.org/tor-package-archive/torbrowser/15.0/tor-expert-bundle-linux-x86_64-15.0.tar.gz
rm -rf "$EMBED/teb" && mkdir -p "$EMBED/teb" && tar xzf "$EMBED/teb.tgz" -C "$EMBED/teb"
cp "$EMBED/teb/tor/tor" "$EMBED/teb/tor/libssl.so.3" "$EMBED/teb/tor/libevent-2.1.so.7" \
"$EMBED/teb/tor/libcrypto.so.3" "$EMBED/tor-embed/"
# The expert bundle's tor carries no rpath; point it and its libs at
# $ORIGIN so the embedded copy resolves them from the extract dir on
# any distro.
for f in tor libssl.so.3 libevent-2.1.so.7 libcrypto.so.3; do
patchelf --set-rpath '$ORIGIN' "$EMBED/tor-embed/$f"
done
chmod +x "$EMBED/tor-embed/tor"
- name: build
run: |
# TOR_VERSION must EXACTLY match `tor --version` of the embedded
# binary, or TorManager::unpackBins() sees embedded > unpacked and
# rewrites Tor on every launch. The bundle's release number (15.0) is
# not its tor version, so derive it.
EMBED="/tmp/wowlet-${GITHUB_RUN_ID:-local}"
TORVER=$("$EMBED/tor-embed/tor" --version | grep -oE '[0-9]+(\.[0-9]+){3}' | head -1)
echo "embedded tor version: $TORVER"
# ARCH=x86-64, never native: the build host's CPU is newer than many
# users' and native bakes in unsupported instructions -> SIGILL.
cmake -S . -B build \
-DWITH_SCANNER=OFF \
-DARCH=x86-64 \
-DWOWNEROD_DIR="$EMBED/wownerod-embed" \
-DTOR_DIR="$EMBED/tor-embed" \
-DTOR_VERSION="$TORVER"
cmake --build build -j6
ls -la build/bin/
- name: package AppImage
run: |
rm -rf AppDir
mkdir -p AppDir/usr/bin AppDir/usr/share/applications \
AppDir/usr/share/icons/hicolor/256x256/apps
cp build/bin/wowlet AppDir/usr/bin/wowlet
chmod +x AppDir/usr/bin/wowlet
cp src/assets/wowlet.desktop AppDir/usr/share/applications/wowlet.desktop
cp src/assets/images/appicons/256x256.png \
AppDir/usr/share/icons/hicolor/256x256/apps/wowlet.png
curl -sL -o linuxdeploy https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage
curl -sL -o linuxdeploy-plugin-qt https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/continuous/linuxdeploy-plugin-qt-x86_64.AppImage
curl -sL -o appimagetool https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
chmod +x linuxdeploy linuxdeploy-plugin-qt appimagetool
export APPIMAGE_EXTRACT_AND_RUN=1
export QMAKE="$(command -v qmake6)"
export EXTRA_QT_PLUGINS="svg;multimedia;tls"
export QML_SOURCES_PATHS="$PWD/src/assets/qml"
export PATH="$PWD:$PATH"
./linuxdeploy --appdir AppDir --plugin qt \
-d AppDir/usr/share/applications/wowlet.desktop \
-i AppDir/usr/share/icons/hicolor/256x256/apps/wowlet.png
# qmlimportscanner reads only the `import` lines in our QML; it does
# not follow a module's own qmldir depends/default-import chain, so
# transitive modules (QtQuick -> QtQml.WorkerScript/Models) are
# silently skipped. Blanket-copy the trees, then resolve each copied
# plugin's Qt libs ourselves -- linuxdeploy only does that for modules
# it deployed, not ones we copied by hand.
QMLDIR=$(qmake6 -query QT_INSTALL_QML)
mkdir -p AppDir/usr/qml AppDir/usr/lib
for m in QtQuick QtQml; do cp -rn "$QMLDIR/$m" AppDir/usr/qml/ 2>/dev/null || true; done
find AppDir/usr/qml -name '*.so' -print0 | while IFS= read -r -d '' so; do
ldd "$so" 2>/dev/null | awk '/=> \//{print $3}' \
| grep -E '/(libQt6|libicu|libpcre2|libdouble-conversion|libzstd|libb2|libpng|libharfbuzz|libmd4c|libglib|libgraphite)' \
| while read -r lib; do cp -L -n "$lib" AppDir/usr/lib/ 2>/dev/null || true; done
done
echo "=== qt.conf ==="; cat AppDir/usr/bin/qt.conf
# Guard: mining.qml must not pull in QtQuick.Controls. If a future
# edit re-adds a Controls type this fails loudly instead of shipping
# a white mining tab.
if grep -q 'import QtQuick.Controls' src/assets/qml/mining.qml; then
echo "ERROR: mining.qml imports QtQuick.Controls again." >&2
exit 1
fi
ARCH=x86_64 ./appimagetool AppDir wowlet-x86_64.AppImage
ls -la wowlet-x86_64.AppImage
- name: Record candidate source and hashes
run: python3 -B contrib/release/write_receipt.py --platform linux --file wowlet-x86_64.AppImage --output wowlet-linux-receipt.json
- name: upload wowlet AppImage
# v3: Gitea implements the older artifact protocol and rejects
# @actions/artifact v2 clients (upload-artifact@v4+) as unsupported.
uses: actions/upload-artifact@v3
with:
name: wowlet-x86_64-linux-AppImage
path: |
wowlet-x86_64.AppImage
wowlet-linux-receipt.json
if-no-files-found: error
+225
View File
@@ -0,0 +1,225 @@
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).
# Missing signing or notarization inputs refuse before compilation.
on:
workflow_dispatch:
inputs:
expected_sha:
description: Exact reviewed Builds commit
required: true
type: string
env:
EXPECTED_SHA: ${{ inputs.expected_sha }}
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
ref: ${{ github.sha }}
- name: Verify exact reviewed source
run: python3 -B contrib/release/check_dispatch.py
- name: Check signing inputs before compiling
run: python3 -B contrib/release/sign_macos.py --check-inputs
- name: Verify Fleet-managed build dependencies
run: |
set -e
export PATH="/opt/homebrew/bin:$PATH"
for formula in qt libsodium libzip qrencode unbound cmake boost hidapi openssl expat protobuf zxing-cpp; do
brew --prefix "$formula" >/dev/null || {
echo "Missing Fleet-managed build dependency: $formula" >&2
exit 1
}
done
- name: fetch verified wownerod to embed
run: |
set -e
case "$(uname -m)" in
arm64) target=aarch64-apple-darwin11 ;;
x86_64) target=x86_64-apple-darwin11 ;;
*) echo "Unsupported macOS build architecture" >&2; exit 1 ;;
esac
EMBED="/tmp/wowlet-${GITHUB_RUN_ID}/wownerod-embed"
python3 -B contrib/release/fetch_wownerod.py --target "$target" --destination "$EMBED"
echo "WOWNEROD_DIR=$EMBED" >> "$GITHUB_ENV"
- name: fetch tor to embed
run: |
set -e
ARCH=$(uname -m)
case "$ARCH" in
arm64) TORTAR=tor-expert-bundle-macos-aarch64-15.0.tar.gz ;;
*) TORTAR=tor-expert-bundle-macos-x86_64-15.0.tar.gz ;;
esac
mkdir -p /tmp/teb /tmp/tor-embed
curl -L -o /tmp/teb.tgz "https://archive.torproject.org/tor-package-archive/torbrowser/15.0/$TORTAR"
tar xzf /tmp/teb.tgz -C /tmp/teb
cp /tmp/teb/tor/tor /tmp/teb/tor/libevent-*.dylib /tmp/tor-embed/
# tor ships UNSIGNED; arm64 macOS SIGKILLs unsigned binaries. Ad-hoc sign
# BEFORE embedding — the signature travels inside the file, so the copy
# the app unpacks at runtime on user machines is runnable too.
codesign -s - -f /tmp/tor-embed/tor /tmp/tor-embed/libevent-*.dylib
chmod +x /tmp/tor-embed/tor
# expert-bundle RELEASE number (15.0) is NOT the tor version — derive it
TORVER=$(/tmp/tor-embed/tor --version | grep -oE '[0-9]+(\.[0-9]+){3}' | head -1)
echo "TOR_VERSION=$TORVER" >> "$GITHUB_ENV"
echo "embedded tor version: $TORVER"
- name: build
run: |
export PATH="/opt/homebrew/bin:$PATH"
# clang does not search /opt/homebrew/include by default; the feather
# target pulls monero headers that include boost, and modern Boost's
# CMake config leaves Boost_INCLUDE_DIRS empty. CPATH/LIBRARY_PATH make
# every linked brew header/lib findable without per-dep flag surgery.
export CPATH="$(brew --prefix)/include"
export LIBRARY_PATH="$(brew --prefix)/lib"
mkdir -p build && cd build
cmake -DWITH_SCANNER=OFF -DUSE_DEVICE_TREZOR=OFF -DBoost_USE_STATIC_LIBS=ON -DWOWNEROD_DIR="$WOWNEROD_DIR" -DTOR_DIR=/tmp/tor-embed -DTOR_VERSION="$TOR_VERSION" -DCMAKE_PREFIX_PATH="$(brew --prefix qt)" ..
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.
# -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
# referenced as @rpath/lib*.dylib. Without them a clean (no-Homebrew)
# Mac can't load those image formats. Qt frameworks use a different
# ref form (@rpath/Qt*.framework/...), so keying strictly on
# `@rpath/*.dylib` bundles the stragglers WITHOUT touching Qt.
# Several passes converge (a bundled lib pulls in further deps).
APP=dist/Wowlet.app
FW="$APP/Contents/Frameworks"
mkdir -p "$FW"
for pass in 1 2 3 4; do
while IFS= read -r macho; do
file "$macho" | grep -q Mach-O || continue
chmod u+w "$macho"
otool -L "$macho" | awk 'NR>1{print $1}' | while IFS= read -r dep; do
case "$dep" in
@rpath/*.dylib)
name=$(basename "$dep")
if [ -f "$FW/$name" ]; then
install_name_tool -change "$dep" "@executable_path/../Frameworks/$name" "$macho" 2>/dev/null || true
continue
fi
real=$(find /opt/homebrew/lib /opt/homebrew/opt/*/lib -maxdepth 1 -name "$name" 2>/dev/null | head -1)
if [ -n "$real" ]; then
cp -n "$real" "$FW/$name"
chmod u+w "$FW/$name"
install_name_tool -id "@executable_path/../Frameworks/$name" "$FW/$name" 2>/dev/null || true
install_name_tool -change "$dep" "@executable_path/../Frameworks/$name" "$macho" 2>/dev/null || true
fi
;;
esac
done
done < <(find "$APP" -type f)
done
# Fail if any @rpath/*.dylib (non-Qt) remains unresolved in the bundle
leftover=$(find "$APP" -type f | while IFS= read -r m; do
file "$m" | grep -q Mach-O || continue
otool -L "$m" | awk 'NR>1{print $1}' | grep -E '^@rpath/[^/]+\.dylib$' || true
done | sort -u)
if [ -n "$leftover" ]; then
echo "UNBUNDLED @rpath dylibs remain:"; echo "$leftover"; exit 1
fi
echo "all plain-dylib deps bundled"
ls -la "$FW" | grep -iE "webp|sharpyuv|brotli" || true
ls -la dist/
- name: Sign and notarize the private candidate
run: python3 -B contrib/release/sign_macos.py
- 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: Record signed candidate source and hashes
run: python3 -B contrib/release/write_receipt.py --platform macos --file build/wowlet-macos-$(uname -m).zip --output build/wowlet-macos-receipt.json
- name: upload artifact
uses: actions/upload-artifact@v3
with:
name: wowlet-macos
path: |
build/wowlet-macos-*.zip
build/wowlet-macos-receipt.json
retention-days: 14
# Without this an empty upload passes silently, which is how a signed
# build can appear to publish while shipping nothing.
if-no-files-found: error
+329
View File
@@ -0,0 +1,329 @@
name: windows build + sign (gitea self-hosted)
# Runs ONLY on Gitea (GitHub ignores .gitea/workflows), in the private Builds
# repository. GitHub wownero/wowlet owns source and release publication.
#
# Two jobs on two different machines, and that split is the whole point:
#
# build -> `deb`, which advertises windows-latest:host because it carries the
# MinGW cross-compiler. Produces an UNSIGNED wowlet.exe.
# sign -> `desktop-iim2fs2` (windows:host), the only machine with the EV
# code-signing token plugged into it.
#
# The signing host does not sign in this job. The runner there is
# NT SERVICE\act_runner, which cannot use the token: SafeNet holds the token
# login per user, and the login is established interactively by an operator.
# So the job queues the artifact for the signing broker that Fleet installs
# (roles/windows_code_signing) and waits for it. CI never sees the key and never
# sees the token password, so it can never spend one of the token's three
# password attempts.
#
# Dispatch-only. Both runners are capacity 1 and shared with other lanes.
on:
workflow_dispatch:
inputs:
expected_sha:
description: Exact reviewed Builds commit
required: true
type: string
env:
EXPECTED_SHA: ${{ inputs.expected_sha }}
HOST: x86_64-w64-mingw32
jobs:
build:
name: "Windows x86_64 (mingw cross, depends)"
runs-on: windows-latest # gitea label windows-latest:host -> deb, host mode
steps:
- name: verify the cross toolchain is present
# Host mode on a shared fleet runner: this job installs nothing and
# needs no sudo. Fleet owns the toolchain; if something is missing the
# job says exactly what rather than half-building.
run: |
missing=0
for t in curl bzip2 unzip zip git cmake make ccache python3 perl pkg-config \
x86_64-w64-mingw32-gcc-posix x86_64-w64-mingw32-g++-posix; do
command -v "$t" >/dev/null || { echo "MISSING: $t"; missing=1; }
done
# mingw32.mk needs the posix threading variant (std::thread/mutex/cv).
x86_64-w64-mingw32-g++ -v 2>&1 | grep -q 'Thread model: posix' || {
echo "MISSING: x86_64-w64-mingw32-g++ is not the posix threading variant"; missing=1; }
[ "$missing" = 0 ] || { echo "Fix the build host in such-fleet, not here."; exit 1; }
echo "toolchain ok"; nproc; free -g | head -2
- name: configure git
run: git config --global --add safe.directory '*'
- uses: actions/checkout@v4
with:
submodules: recursive
ref: ${{ github.sha }}
- name: Verify exact reviewed source
run: python3 -B contrib/release/check_dispatch.py
# depends is the slow part (static Qt6/boost/openssl/tor from source).
# Cache the source tarballs and the compiled packages so only a cold run
# pays the ~1h cost.
- name: restore depends sources cache
id: cache-sources
uses: actions/cache/restore@v4
with:
path: contrib/depends/sources
key: depends-sources-${{ hashFiles('contrib/depends/packages/*') }}
restore-keys: |
depends-sources-
- name: restore depends built cache (mingw)
id: cache-built
uses: actions/cache/restore@v4
with:
path: contrib/depends/built
key: depends-built-mingw32-${{ hashFiles('contrib/depends/packages/*', 'contrib/depends/hosts/*', 'contrib/depends/toolchain.cmake.in') }}
restore-keys: |
depends-built-mingw32-
- name: build depends (static Qt6 + tor + boost + openssl for mingw)
# -j4, not the -j2 the GitHub lane needs: that limit existed because a
# parallel static Qt6 compile OOMs a 16G hosted runner. deb has 30G.
run: make -C contrib/depends HOST="$HOST" -j4
# Save with if:always() so an expensive toolchain banks even when the
# later wowlet.exe build fails; the automatic post-save is skipped on
# job failure, which otherwise re-pays hours every time.
- name: save depends sources cache
if: always() && steps.cache-sources.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
with:
path: contrib/depends/sources
key: depends-sources-${{ hashFiles('contrib/depends/packages/*') }}
- name: save depends built cache (mingw)
if: always() && steps.cache-built.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
with:
path: contrib/depends/built
key: depends-built-mingw32-${{ hashFiles('contrib/depends/packages/*', 'contrib/depends/hosts/*', 'contrib/depends/toolchain.cmake.in') }}
- name: fetch verified wownerod to embed
run: |
python3 -B contrib/release/fetch_wownerod.py \
--target x86_64-w64-mingw32 \
--destination "/tmp/wowlet-${GITHUB_RUN_ID}/wownerod-embed"
- name: build wowlet.exe
run: |
rm -rf build && mkdir build && cd build
# ARCH=x86-64: portable baseline. wownero's CMake defaults ARCH=native,
# which under a cross compiler bakes the BUILD HOST's instructions into
# the Windows binary -> SIGILL on older CPUs.
cmake --toolchain "$GITHUB_WORKSPACE/contrib/depends/$HOST/share/toolchain.cmake" \
-DARCH=x86-64 \
-DWITH_SCANNER=OFF \
-DCHECK_UPDATES=OFF \
-DTOR_INSTALLED=On \
-DFEATHER_TARGET_TRIPLET="$HOST" \
-DWOWNEROD_DIR="/tmp/wowlet-${GITHUB_RUN_ID:-local}/wownerod-embed" \
..
cmake --build . -j6
ls -la bin/
- name: stage the unsigned exe for signing
# The signing job needs the bare exe, not the zip: the broker signs one
# PE file at a time. The zip is assembled after signing so the published
# archive contains the signed binary.
run: |
mkdir -p stage/tor
cp build/bin/wowlet.exe stage/wowlet.exe
cp "contrib/depends/$HOST/Tor/tor.exe" stage/tor/tor.exe
(cp LICENSE stage/LICENSE.txt 2>/dev/null || cp COPYING stage/LICENSE.txt 2>/dev/null) || true
ls -la stage stage/tor
- name: Record unsigned build source and hash
run: python3 -B contrib/release/write_receipt.py --platform windows-build --file stage/wowlet.exe --output stage/wowlet-build.json
- name: upload unsigned staging tree
# v3, not v4: Gitea Actions implements the older artifact protocol and
# rejects @actions/artifact v2 clients with
# "GHESNotSupportedError ... not currently supported on GHES".
uses: actions/upload-artifact@v3
with:
name: wowlet-x86_64-windows-unsigned
path: stage/
if-no-files-found: error
sign:
name: "Authenticode sign (hardware token broker)"
needs: build
runs-on: windows # gitea label windows:host -> desktop-iim2fs2
steps:
- name: Prepare the exact signing run workspace
shell: powershell
run: |
$ErrorActionPreference = 'Stop'
if ($env:EXPECTED_SHA -notmatch '^[0-9a-f]{40}$' -or $env:GITHUB_SHA -ne $env:EXPECTED_SHA) {
throw "Signing run differs from the reviewed source"
}
foreach ($path in @('stage', 'unsigned', 'wowlet-build.json', 'wowlet-windows-receipt.json', 'wowlet-x86_64-windows.zip', 'wowlet-x86_64-windows-unsigned.zip')) {
if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force }
}
- name: download unsigned staging tree
uses: actions/download-artifact@v3
with:
name: wowlet-x86_64-windows-unsigned
path: stage
- name: Verify exact signing source and unsigned input
shell: powershell
run: |
$ErrorActionPreference = 'Stop'
if ($env:EXPECTED_SHA -notmatch '^[0-9a-f]{40}$' -or $env:GITHUB_SHA -ne $env:EXPECTED_SHA) {
throw "Signing run differs from the reviewed source"
}
$build = Get-Content 'stage/wowlet-build.json' -Raw | ConvertFrom-Json
if ($build.dispatch_commit -ne $env:EXPECTED_SHA -or $build.platform -ne 'windows-build' -or $build.artifacts.Count -ne 1) {
throw "Unsigned build receipt differs from the reviewed signing run"
}
if ($build.artifacts[0].name -ne 'wowlet.exe' -or $build.artifacts[0].sha256 -ne (Get-FileHash 'stage/wowlet.exe' -Algorithm SHA256).Hash.ToLowerInvariant()) {
throw "Unsigned executable differs from its build receipt"
}
Move-Item 'stage/wowlet-build.json' 'wowlet-build.json'
- name: keep the unsigned tree for the reproducible archive
shell: powershell
run: |
$ErrorActionPreference = 'Stop'
Copy-Item 'stage' 'unsigned' -Recurse -Force
- name: queue wowlet.exe for the signing broker and wait
shell: powershell
run: |
$ErrorActionPreference = 'Stop'
$queued = "wowlet-${env:GITHUB_RUN_ID}.exe"
$inPath = "C:\signing\in\$queued"
$outPath = "C:\signing\out\$queued"
if (Test-Path 'C:\signing\SIGNING_LOCKED') {
Get-Content 'C:\signing\SIGNING_LOCKED'
throw "The signing broker's circuit breaker is tripped. An operator must clear it."
}
Copy-Item 'stage\wowlet.exe' $inPath -Force
Set-Content -LiteralPath "$inPath.ready" -Value $env:GITHUB_RUN_ID
Write-Host "queued $queued"
$deadline = (Get-Date).AddMinutes(10)
while ((Get-Date) -lt $deadline) {
if (Test-Path "$outPath.done") { break }
if (Test-Path 'C:\signing\SIGNING_LOCKED') {
Get-Content 'C:\signing\SIGNING_LOCKED'
throw "The broker tripped its circuit breaker while signing this artifact."
}
Start-Sleep -Seconds 5
}
if (-not (Test-Path "$outPath.done")) { throw "Timed out waiting for the signing broker." }
Move-Item $outPath 'stage\wowlet.exe' -Force
Remove-Item "$outPath.done" -Force -ErrorAction SilentlyContinue
- name: verify the published binary is signed and timestamped
# The broker verifies before releasing; this verifies again on the
# artifact that is actually about to ship. A signature or timestamp
# failure blocks distribution.
shell: powershell
run: |
$ErrorActionPreference = 'Stop'
$sig = Get-AuthenticodeSignature 'stage\wowlet.exe'
$sig | Format-List Status, StatusMessage,
@{n='Signer';e={$_.SignerCertificate.Subject}},
@{n='TimeStamper';e={$_.TimeStamperCertificate.Subject}}
if ($sig.Status -ne 'Valid') { throw "Authenticode status is $($sig.Status)" }
if (-not $sig.TimeStamperCertificate) { throw "Signature carries no RFC 3161 timestamp" }
if ($sig.SignerCertificate.Subject -notmatch 'Such Software LLC') {
throw "Unexpected signer: $($sig.SignerCertificate.Subject)"
}
Write-Host "SHA256 $((Get-FileHash 'stage\wowlet.exe' -Algorithm SHA256).Hash)"
- name: package both zips
# Two archives ship, deliberately. Authenticode mutates the exe, so a
# signed binary can never byte-match what someone reproduces from
# source. The unsigned archive stays reproducible and is what verifiers
# check; the signed one is what a normal user should download. Both
# hashes go in the PGP-signed hashes.txt.
shell: powershell
run: |
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.IO.Compression.FileSystem
# Not Compress-Archive: Windows PowerShell writes the OS separator
# into entry names, producing "tor\tor.exe". The ZIP format mandates
# forward slashes, and extractors that take the backslash literally
# leave a file called "tor\tor.exe" in the root instead of a tor
# directory -- at which point the bundled Tor is not where wowlet
# looks for it, and this wallet gives its daemon an exclusive
# --tx-proxy, so no Tor means no transaction relay at all.
# v5.1.0 shipped tor/tor.exe from `zip -X`; keep that shape.
function New-Archive([string] $Source, [string] $Destination) {
if (Test-Path $Destination) { Remove-Item $Destination -Force }
$root = (Resolve-Path $Source).Path.TrimEnd('\')
$zip = [System.IO.Compression.ZipFile]::Open($Destination, 'Create')
try {
foreach ($file in Get-ChildItem $root -Recurse -File | Sort-Object FullName) {
$entry = $file.FullName.Substring($root.Length + 1).Replace('\', '/')
[void][System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile(
$zip, $file.FullName, $entry, 'Optimal')
}
} finally { $zip.Dispose() }
}
New-Archive 'unsigned' 'wowlet-x86_64-windows-unsigned.zip'
New-Archive 'stage' 'wowlet-x86_64-windows.zip'
# Prove the shape rather than trusting it: a backslash in any entry
# name fails the build.
foreach ($archive in @('wowlet-x86_64-windows.zip', 'wowlet-x86_64-windows-unsigned.zip')) {
$zip = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path $archive))
try {
$names = $zip.Entries | ForEach-Object { $_.FullName }
if ($names -match '\\') { throw "$archive contains a backslash entry name: $($names -join ', ')" }
if ($names -notcontains 'tor/tor.exe') { throw "$archive is missing tor/tor.exe (got: $($names -join ', '))" }
} finally { $zip.Dispose() }
"{0} {1} {2}" -f $archive, (Get-Item $archive).Length,
(Get-FileHash $archive -Algorithm SHA256).Hash
}
- name: Record signed candidate source, hashes and certificate
shell: powershell
run: |
$ErrorActionPreference = 'Stop'
$build = Get-Content 'wowlet-build.json' -Raw | ConvertFrom-Json
$sig = Get-AuthenticodeSignature 'stage/wowlet.exe'
if ($sig.Status -ne 'Valid' -or -not $sig.TimeStamperCertificate -or $sig.SignerCertificate.Subject -notmatch 'Such Software LLC') {
throw "Final executable signature is not valid, timestamped and from the expected publisher"
}
$files = @('wowlet-x86_64-windows.zip', 'wowlet-x86_64-windows-unsigned.zip') | ForEach-Object {
@{name=$_;size=(Get-Item $_).Length;sha256=(Get-FileHash $_ -Algorithm SHA256).Hash.ToLowerInvariant()}
}
$receipt = @{
schema_version=1;app='wowlet';version=$build.version;platform='windows';
dispatch_commit=$env:GITHUB_SHA;wallet_library_commit=$build.wallet_library_commit;
environment_run_id=$env:GITHUB_RUN_ID;environment_run_attempt=$env:GITHUB_RUN_ATTEMPT;
artifacts=@($files);unsigned_executable=$build.artifacts[0];
signed_executable_sha256=(Get-FileHash 'stage/wowlet.exe' -Algorithm SHA256).Hash.ToLowerInvariant();
signing=@{status=$sig.Status.ToString();timestamped=$true;subject=$sig.SignerCertificate.Subject;
certificate_thumbprint=$sig.SignerCertificate.Thumbprint;certificate_not_after=$sig.SignerCertificate.NotAfter.ToUniversalTime().ToString('o')}
}
$receipt | ConvertTo-Json -Depth 6 | Set-Content -Encoding UTF8 'wowlet-windows-receipt.json'
- name: upload Windows zips
uses: actions/upload-artifact@v3
with:
name: wowlet-x86_64-windows
path: |
wowlet-x86_64-windows.zip
wowlet-x86_64-windows-unsigned.zip
wowlet-windows-receipt.json
if-no-files-found: error
+15
View File
@@ -0,0 +1,15 @@
name: Wowlet verify
on:
push:
branches: [master]
pull_request:
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- name: Check support guides and release source admission
run: make check
+19 -3
View File
@@ -1,6 +1,7 @@
name: ci/gh-actions/build name: ci/gh-actions/build
on: [push, pull_request, workflow_dispatch] # Legacy builds are manual only. Release artifacts are built in Builds/wowlet.
on: [workflow_dispatch]
# NOTE (wownero-rebase branch): the Arch + macOS jobs from upstream Feather's build.yml are # NOTE (wownero-rebase branch): the Arch + macOS jobs from upstream Feather's build.yml are
# temporarily disabled to get a fast, cheap iteration loop while bringing up the build against the # temporarily disabled to get a fast, cheap iteration loop while bringing up the build against the
@@ -21,7 +22,7 @@ jobs:
qt6-wayland-dev qt6-declarative-dev libzip-dev libsodium-dev libgcrypt20-dev libx11-xcb-dev qt6-wayland-dev qt6-declarative-dev libzip-dev libsodium-dev libgcrypt20-dev libx11-xcb-dev
qml6-module-qtquick qml6-module-qtquick-layouts qml6-module-qtquick-window qml6-module-qtqml-workerscript qml6-module-qtqml-models qml6-module-qtquick qml6-module-qtquick-layouts qml6-module-qtquick-window qml6-module-qtqml-workerscript qml6-module-qtqml-models
protobuf-compiler libprotobuf-dev libhidapi-dev libzxing-dev libusb-dev protobuf-compiler libprotobuf-dev libhidapi-dev libzxing-dev libusb-dev
libusb-1.0-0-dev patchelf libusb-1.0-0-dev libcom-err2 patchelf
- name: configure git - name: configure git
run: git config --global --add safe.directory '*' run: git config --global --add safe.directory '*'
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -54,7 +55,12 @@ jobs:
cd build cd build
# ARCH=x86-64: portable generic baseline. NEVER 'native' for a release — the CI runner's # ARCH=x86-64: portable generic baseline. NEVER 'native' for a release — the CI runner's
# CPU (often newer than users') would bake in unsupported instructions → SIGILL on launch. # CPU (often newer than users') would bake in unsupported instructions → SIGILL on launch.
cmake -DWITH_SCANNER=OFF -DARCH=x86-64 -DWOWNEROD_DIR=/tmp/wownerod-embed -DTOR_DIR=/tmp/tor-embed -DTOR_VERSION=15.0 .. # wowlet: TOR_VERSION must EXACTLY match `tor --version` of the embedded binary, else
# TorManager::unpackBins() sees embedded > the unpacked copy and rewrites Tor every launch.
# The tor-expert-bundle's RELEASE number (15.0) is NOT its tor version (0.4.8.x) — derive it.
TORVER=$(/tmp/tor-embed/tor --version | grep -oE '[0-9]+(\.[0-9]+){3}' | head -1)
echo "embedded tor version: $TORVER"
cmake -DWITH_SCANNER=OFF -DARCH=x86-64 -DWOWNEROD_DIR=/tmp/wownerod-embed -DTOR_DIR=/tmp/tor-embed -DTOR_VERSION="$TORVER" ..
cmake --build . -j $(nproc) cmake --build . -j $(nproc)
- name: package AppImage - name: package AppImage
run: | run: |
@@ -71,10 +77,20 @@ jobs:
export EXTRA_QT_PLUGINS="svg;multimedia;tls" export EXTRA_QT_PLUGINS="svg;multimedia;tls"
export QML_SOURCES_PATHS="$PWD/src/assets/qml" export QML_SOURCES_PATHS="$PWD/src/assets/qml"
export PATH="$PWD:$PATH" export PATH="$PWD:$PATH"
# linuxdeploy's AppImage excludelist assumes libcom_err.so.2 is present on the host.
# Minimal Debian and openSUSE systems may omit it, but it is a transitive runtime dependency.
LIBCOM_ERR="$(ldconfig -p | awk '$1 == "libcom_err.so.2" { print $NF; exit }')"
if [[ -z "$LIBCOM_ERR" || ! -f "$LIBCOM_ERR" ]]; then
echo "ERROR: libcom_err.so.2 is unavailable in the build image; cannot make a portable AppImage." >&2
exit 1
fi
# Populate AppDir (libs + Qt plugins + QML modules) — no --output yet, so we can patch QML first. # Populate AppDir (libs + Qt plugins + QML modules) — no --output yet, so we can patch QML first.
./linuxdeploy --appdir AppDir --plugin qt \ ./linuxdeploy --appdir AppDir --plugin qt \
--library "$LIBCOM_ERR" \
-d AppDir/usr/share/applications/wowlet.desktop \ -d AppDir/usr/share/applications/wowlet.desktop \
-i AppDir/usr/share/icons/hicolor/256x256/apps/wowlet.png -i AppDir/usr/share/icons/hicolor/256x256/apps/wowlet.png
# Guard this packaging regression: the AppImage must carry the excluded system library.
bash contrib/AppImage/check-libcom-err.sh AppDir
# --------------------------------------------------------------------------------------- # ---------------------------------------------------------------------------------------
# Make the QML bundle COMPLETE and deterministic (no whack-a-mole). # Make the QML bundle COMPLETE and deterministic (no whack-a-mole).
# #
+16
View File
@@ -0,0 +1,16 @@
name: source checks
on: [push, pull_request]
permissions:
contents: read
jobs:
docs:
name: Wowlet documentation gate
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- name: Check generated documentation and support contacts
run: make check
+2 -1
View File
@@ -1,6 +1,7 @@
name: ci/gh-actions/guix name: ci/gh-actions/guix
on: [push, pull_request] # Legacy builds are manual only. Release artifacts are built in Builds/wowlet.
on: [workflow_dispatch]
jobs: jobs:
cache-sources: cache-sources:
+2
View File
@@ -14,9 +14,11 @@ feather_autogen/
feather.cbp feather.cbp
src/assets_tor.qrc src/assets_tor.qrc
src/assets_docs.qrc src/assets_docs.qrc
src/assets_wownerod.qrc
feather.AppDir/* feather.AppDir/*
src/assets/tor/* src/assets/tor/*
src/assets/docs/* src/assets/docs/*
src/assets/wownerod/*
!src/assets/tor/.gitkeep !src/assets/tor/.gitkeep
!src/assets/docs/.gitkeep !src/assets/docs/.gitkeep
contrib/installers/windows/setup.nsi contrib/installers/windows/setup.nsi
+1 -1
View File
@@ -1,6 +1,6 @@
[submodule "monero"] [submodule "monero"]
path = monero path = monero
url = https://codeberg.org/wownero/wownero.git url = https://github.com/wownero/wownero.git
branch = gui branch = gui
[submodule "src/third-party/polyseed"] [submodule "src/third-party/polyseed"]
path = src/third-party/polyseed path = src/third-party/polyseed
+9 -5
View File
@@ -1,7 +1,7 @@
cmake_minimum_required(VERSION 3.18) cmake_minimum_required(VERSION 3.18)
project(feather project(feather
VERSION "5.0.0" VERSION "5.1.5"
DESCRIPTION "A free Monero desktop wallet" DESCRIPTION "A free Monero desktop wallet"
LANGUAGES CXX C ASM LANGUAGES CXX C ASM
) )
@@ -148,15 +148,19 @@ if(TOR_DIR)
message(STATUS "Embedded Tor version: ${TOR_VERSION}") message(STATUS "Embedded Tor version: ${TOR_VERSION}")
# wowlet: the target dir is not tracked in git — create it (BSD cp's usage
# error exits 64, which the ret check below does not catch)
execute_process(COMMAND bash -c "mkdir -p ${CMAKE_CURRENT_SOURCE_DIR}/src/assets/tor")
# Always copy Tor when doing a reproducible build to prevent old versions from getting included # Always copy Tor when doing a reproducible build to prevent old versions from getting included
if (REPRODUCIBLE) if (REPRODUCIBLE)
set(TOR_COPY_CMD "cp -a ${TOR_DIR}/* ${CMAKE_CURRENT_SOURCE_DIR}/src/assets/tor") set(TOR_COPY_CMD "cp -a ${TOR_DIR}/* ${CMAKE_CURRENT_SOURCE_DIR}/src/assets/tor")
else() else()
set(TOR_COPY_CMD "cp -au ${TOR_DIR}/* ${CMAKE_CURRENT_SOURCE_DIR}/src/assets/tor") set(TOR_COPY_CMD "cp -a ${TOR_DIR}/* ${CMAKE_CURRENT_SOURCE_DIR}/src/assets/tor") # wowlet: BSD cp has no -u (macOS: illegal option, exit 64, silent no-op)
endif() endif()
execute_process(COMMAND bash -c "${TOR_COPY_CMD}" RESULT_VARIABLE ret) execute_process(COMMAND bash -c "${TOR_COPY_CMD}" RESULT_VARIABLE ret)
if(ret EQUAL "1") if(NOT ret EQUAL "0")
message(FATAL_ERROR "Tor copy failure: ${TOR_COPY_CMD}") message(FATAL_ERROR "Tor copy failure: ${TOR_COPY_CMD}")
endif() endif()
@@ -172,11 +176,11 @@ if(WOWNEROD_DIR)
if (REPRODUCIBLE) if (REPRODUCIBLE)
set(WOWNEROD_COPY_CMD "cp -a ${WOWNEROD_DIR}/* ${CMAKE_CURRENT_SOURCE_DIR}/src/assets/wownerod") set(WOWNEROD_COPY_CMD "cp -a ${WOWNEROD_DIR}/* ${CMAKE_CURRENT_SOURCE_DIR}/src/assets/wownerod")
else() else()
set(WOWNEROD_COPY_CMD "cp -au ${WOWNEROD_DIR}/* ${CMAKE_CURRENT_SOURCE_DIR}/src/assets/wownerod") set(WOWNEROD_COPY_CMD "cp -a ${WOWNEROD_DIR}/* ${CMAKE_CURRENT_SOURCE_DIR}/src/assets/wownerod") # wowlet: BSD cp has no -u
endif() endif()
execute_process(COMMAND bash -c "${WOWNEROD_COPY_CMD}" RESULT_VARIABLE ret) execute_process(COMMAND bash -c "${WOWNEROD_COPY_CMD}" RESULT_VARIABLE ret)
if(ret EQUAL "1") if(NOT ret EQUAL "0")
message(FATAL_ERROR "wownerod copy failure: ${WOWNEROD_COPY_CMD}") message(FATAL_ERROR "wownerod copy failure: ${WOWNEROD_COPY_CMD}")
endif() endif()
+5 -1
View File
@@ -13,4 +13,8 @@ clean:
DEFAULT_GOAL := default DEFAULT_GOAL := default
default: build default: build
.PHONY: default build attest verify clean check:
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s contrib/docs/tests -v
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s contrib/release/tests -v
.PHONY: default build attest verify clean check
+28 -44
View File
@@ -1,60 +1,44 @@
# Feather Wallet # Wowlet
Feather is a free Monero desktop wallet for Linux, Tails, macOS and Windows. It is written in C++ with the Qt framework. Wowlet is a Wownero desktop wallet for Linux, macOS and Windows, built with C++ and
Qt. It is based on [Feather Wallet](https://github.com/feather-wallet/feather).
- **easy-to-use**, **small** and **fast** - Feather runs well on any modern hardware, including virtual machines and live operating systems.
- **beginner friendly**, but also caters to advanced Monero users by providing a [feature set](https://docs.featherwallet.org/guides/features) that is on par with the official CLI.
- ships with **sane defaults** that suit most users, but can also be configured for high or uncommon threat models.
- serves as a testing grounds for **experimental features** that may later be adopted in the reference wallets.
## Download ## Download
You can download Feather from **[featherwallet.org](https://featherwallet.org/download/)** or **[GitHub](https://github.com/feather-wallet/feather/releases)**. Download Wowlet and its signed release hashes from
[Wowlet releases](https://github.com/wownero/wowlet/releases).
If you need help installing Feather, check the [installation documentation](https://docs.featherwallet.org/). ## Support
We recommend that you verify downloads with GPG. Releases are signed with our [release signing key](https://docs.featherwallet.org/guides/release-signing-key). The fingerprint is: - Matrix: [#wownero-gen:wowne.ro](https://matrix.to/#/#wownero-gen:wowne.ro)
- IRC: `#wownero` on [OFTC](https://www.oftc.net/), or use
[webchat](https://webchat.oftc.net/?randomnick=1&channels=wownero)
- Email: [wownero@wownero.org](mailto:wownero@wownero.org)
- Bug reports: [Wowlet issues](https://github.com/wownero/wowlet/issues)
``` If nobody replies immediately, stay in the room and give volunteers time to respond.
8185 E158 A333 30C7 FD61 BC0D 1F76 E155 CEFB A71C Include your Wowlet version, operating system, reproduction steps and any error
``` message. Never share your wallet seed, private keys, password or wallet files.
Windows releases are code-signed for free by [SignPath.io](https://about.signpath.io/), certificate by [SignPath Foundation](https://signpath.org/). Report vulnerabilities privately using [SECURITY.md](SECURITY.md).
## Resources
* [Official Site](https://featherwallet.org)
* [Documentation](https://docs.featherwallet.org)
* [Git Repository](https://github.com/feather-wallet/feather)
* [Matrix](https://matrix.to/#/#feather:monero.social)
* IRC: `#feather` on [OFTC](https://www.oftc.net/)
* Mail: dev@featherwallet.org
If you need help with your wallet, please contact us via Matrix or IRC.
If you don't have an IRC client, you can join the room via [webchat](https://webchat.oftc.net/?randomnick=1&channels=feather).
If you don’t receive a response immediately please idle in the room.
## Release Builds
To learn how to run a bootstrappable release build, see: [contrib/guix/README.md](https://github.com/feather-wallet/feather/blob/master/contrib/guix/README.md)
For release attestations, see the [feather-sigs](http://github.com/feather-wallet/feather-sigs) repo.
For release policy, see: [RELEASE.md](https://github.com/feather-wallet/feather/blob/master/RELEASE.md)
## Development ## Development
If you are looking to set up a development environment for Feather, see [HACKING.md](https://github.com/feather-wallet/feather/blob/master/HACKING.md). [GitHub](https://github.com/wownero/wowlet) is the canonical source repository.
Release builds run in our private Builds system. Signed release artifacts and
hashes are published on GitHub Releases. GitHub runs lightweight source checks;
its legacy build workflows require an explicit manual dispatch.
It is highly recommended that you join our Matrix or IRC channel if you are hacking on Feather. The application includes an offline documentation browser under Help. Its guides
Idling in this channel is the best way to stay updated on best practices and new developments. are generated by `contrib/docs/generate.py` from the Feather documentation submodule.
Wowlet-specific guides in `contrib/docs/overrides` take precedence over upstream
guides with the same filename. Edit those sources, then regenerate; files under
`src/assets/docs` are generated and must not be edited directly.
For information on how Feather is maintained, see: [MAINTENANCE.md](https://github.com/feather-wallet/feather/blob/master/MAINTENANCE.md) Run the documentation regression checks with `make check`. The inherited build
instructions are in [HACKING.md](HACKING.md).
To report a security vulnerability, see: [SECURITY.md](https://github.com/feather-wallet/feather/blob/master/SECURITY.md)
## License ## License
Feather is free and open-source software, [licensed under BSD-3](https://raw.githubusercontent.com/feather-wallet/feather/master/LICENSE). Wowlet is free and open-source software under [BSD-3-Clause](LICENSE).
Upstream copyright notices are retained in the source.
Copyright (c) 2020-2025, The Monero Project
+40 -45
View File
@@ -1,46 +1,41 @@
# Release process # Wowlet release process
- Before a minor release: GitHub `wownero/wowlet` owns source and public releases. Private `Builds/wowlet`
- Update `src/assets/restore_heights_monero_{mainnet,stagenet}.txt` is the build ingress on our hardware. Fleet owns dispatch and publication plans.
- To obtain values, run `contrib/generate-restore-heights/heights.py` A source merge, a build, a code signature and a published release are separate outcomes.
- Update default node lists in `src/assets/nodes.json`
- Bump `openssl`, `qt`, `tor_*` packages in `contrib/depends/packages` 1. Prepare and review a GitHub pull request with the version, wallet-library pin,
- Update or patch any statically linked dependencies that have known vulnerabilities support documentation and release inputs. Publish a new library commit to its
- Run `feather-utils/depends/vulns.py` to check canonical repository before pinning it. Keep source and dependency revisions exact.
- Rebase on top of latest Monero version 2. `make check` runs the cheap source checks. Run the private Linux, Windows and
- Update `src/assets/ack.txt` macOS workflows through reviewed Fleet admission with `expected_sha` equal to
- Update documentation (`feather-wallet/feather-docs`) and `external/feather-docs` submodule the protected Builds commit. Preserve canonical source and Builds commit identities
- Update the version number in `CMakeLists.txt` separately. Do not run release builds on GitHub.
- A hardfork-ready release must bump major version 3. The build helpers verify embedded Wownero archives against `contrib/release/wownerod.json`.
- Create an annotated tag (`git tag x.x.x -a`) That file records the reviewed checksum provenance. Do not describe an unsigned
- Tag must match version in `CMakeLists.txt` checksum list as a PGP verification.
- Only commits that update the version number in `CMakeLists.txt` may be tagged 4. Collect artifacts by their run and artifact identities. Require a Linux AppImage,
- Push the master branch and tags macOS archive, signed Windows archive and unsigned Windows archive. Check archive
- `git push --tags origin master` paths, bundled runtimes and packaging requirements. Never select an artifact by size.
- Run `guix` builds in a clean repo: 5. Require Developer ID signing, accepted notarization and stapling for macOS.
- ```bash Require valid, timestamped Such Software LLC Authenticode signing for Windows.
git clone https://github.com/feather-wallet/feather.git The EV token stays on its signing host; CI never receives its password or key.
cd feather 6. Record any manual wallet checks actually performed. Manual testing and independent
git checkout <TAG> reproducibility are separate evidence; successful builds do not prove either.
git submodule update --init --recursive The unsigned Windows archive is provided for reproduction, without claiming an
./contrib/guix/guix-build independent reproduction occurred.
``` 7. Create `hashes.txt` over the four final archives. Sign it on the owner's signing
- Alternatively, run `feather-utils/guix/run-build.sh` machine with the existing release key `9F9930300530DF724761B7055A881347310EB79F`.
- Attest the builds (`contrib/guix/guix-attest`) and PR to `feather/feather-sigs`. Verify the detached signature and all hashes before publication. Never transfer
- Wait until at least one other person has independently verified reproducibility the private key or put a passphrase in a command, environment or log.
- If builds are not reproducible: fix any reproducibility defects and bump patch version. Do not sign or release non-reproducible builds. 8. Create an annotated `vX.Y.Z` tag from the exact reviewed canonical source, matching
- Sign release artifacts and hashlists. `CMakeLists.txt`. Use Fleet's reviewed publication plan to upload the four archives,
- Transfer files in `guix-build-x.x.x/output` to release signing machine `hashes.txt` and `hashes.txt.asc` to GitHub Releases. Refuse replacement of existing
- Run `feather-utils/release/make-release.sh` tags or assets; recover partial publication from retained intent.
- Update the site (`feather-wallet/feather-site`) 9. Download the public assets again and verify their hashes and PGP signature. Record
- Run the `feather-utils/site/bump-version.py` script to create a template commit. the tag, source commit, Builds commit, run identities, signing evidence and public
- Edit the changelog in `content/changelog` URL in the private journal. Release notes should say when security fixes are included.
- If a release includes security fixes add "(includes security fixes)" to the title.
- Update the version number, file sizes and paths in `data/release.json` Additional architectures and Guix builds remain separate lanes. Do not claim a
- Upload releases, signatures and signed hashlists. platform is released merely because its workflow exists. Announcements are an
- Follow the directory structure defined in `MainWindow::onShowUpdateCheck`. owner action, separate from publishing the release files.
- Make `depends` source files [available](https://featherwallet.org/files/sources/):
- `make -C contrib/depends download`
- Announce release on irc/Matrix
- Update websocket servers to notify clients of new release
- Wait up to 7 days to allow for bug reports before major rollout
+7 -39
View File
@@ -2,43 +2,11 @@
## Reporting a Vulnerability ## Reporting a Vulnerability
Please do not open an issue to report security issues. Report Wowlet security vulnerabilities privately to
[wownero@wownero.org](mailto:wownero@wownero.org).
Please do not disclose them in public issues or community chat before the
maintainers have had time to investigate.
To report a vulnerability email to dev@featherwallet.org Include the affected Wowlet version, operating system, expected impact and steps
to reproduce the issue. Use a disposable wallet for any reproduction and never
The following keys may be used to communicate sensitive information to developers: send a real wallet seed, private keys, password or wallet files.
| Name | Fingerprint |
|---------|---------------------------------------------------|
| tobtoht | E87B D921 CDD8 85C9 D78A 38C5 E45B 10DD 027D 2472 |
Public keys can be found in [`utils/pubkeys`](utils/pubkeys).
## Bug Bounty Program
A bounty may be rewarded to a vulnerability report **if and only if** the issue can result in **a loss of funds**.
You must describe a **plausible scenario** in which a loss of funds can occur (or has occurred) that **isn't solely attributable to user error**.
Only **the code** of the **latest tagged release** of **[this repository](https://github.com/feather-wallet/feather/)** is in scope.
**The bounty can only be rewarded in XMR**. The bounty amount for your report is determined by the maintainers and ranges from USD 150 to USD 3000 (in terms of XMR) and depends on the severity of the issue and other factors.
Clarifications on scope:
- The issue **must be present in a [signed](https://docs.featherwallet.org/guides/release-signing-key) [release build](https://github.com/feather-wallet/feather/blob/master/contrib/guix/README.md)**. Custom builds, including distribution packages, are out of scope.
- The developers **must be able to reproduce and fix the issue**. If the issue cannot be fixed **in our code** for any reason, it is out of scope.
- The live [websites](https://github.com/feather-wallet/feather-site/blob/master/mirrors.txt) and their repositories are out of scope.
- Loss of funds due to malware on the user's machine is out of scope.
- Memory imaging, including cold boot attacks, is out of scope.
- Social engineering against users is out of scope. This includes messages received from the [websocket server](https://docs.featherwallet.org/guides/websocket).
- Any form of coercion, physical or psychological, is out of scope.
- Vulnerabilities that are attributable to hardware are out of scope.
- If the issue was fixed in the `master` branch before we receive your report, it is invalid and not eligible for a bounty from this program.
- If the vulnerability involves binary exploitation, we may ask you to provide a proof of concept of secret key exfiltration.
- Vulnerabilities that are present in the monero submodule but were not introduced in patches made by the Feather developers must
be reported [upstream](https://github.com/monero-project/meta/blob/master/VULNERABILITY_RESPONSE_PROCESS.md) and are not eligible for a bounty from this program.
- Vulnerabilities that are present in any of our third-party dependencies must be reported upstream and are not eligible for a bounty from this program.
- Vulnerabilities that are present in [supported hardware wallets](https://docs.featherwallet.org/guides/hardware-wallet-support) must be reported upstream and are not eligible for a bounty from this program.
- A bounty will not be awarded if the reported vulnerability was already known. We may make an exception if you demonstrate that the severity of the issue was underestimated and no immediate fix was planned.
- If, during your research, you disrupt Feather's release infrastructure or services, or attempt to coerce its developers, you will not be awarded a bounty.
+1 -1
View File
@@ -5,4 +5,4 @@ echo $HERE
# Tails 5.8 sets QT_QPA_PLATFORM to wayland which breaks our app # Tails 5.8 sets QT_QPA_PLATFORM to wayland which breaks our app
export QT_QPA_PLATFORM=xcb export QT_QPA_PLATFORM=xcb
${APPDIR}/usr/bin/feather $* ${APPDIR}/usr/bin/wowlet $*
+13 -13
View File
@@ -5,29 +5,29 @@ unset SOURCE_DATE_EPOCH
# Manually create the AppImage (reproducibly) since linuxdeployqt is not able to create cross-compiled AppImages # Manually create the AppImage (reproducibly) since linuxdeployqt is not able to create cross-compiled AppImages
APPDIR="$PWD/feather.AppDir" APPDIR="$PWD/wowlet.AppDir"
mkdir -p "$APPDIR" mkdir -p "$APPDIR"
mkdir -p "$APPDIR/usr/share/applications/" mkdir -p "$APPDIR/usr/share/applications/"
mkdir -p "$APPDIR/usr/bin" mkdir -p "$APPDIR/usr/bin"
cp "src/assets/feather.desktop" "$APPDIR/usr/share/applications/feather.desktop" cp "src/assets/wowlet.desktop" "$APPDIR/usr/share/applications/wowlet.desktop"
cp "src/assets/feather.desktop" "$APPDIR/feather.desktop" cp "src/assets/wowlet.desktop" "$APPDIR/wowlet.desktop"
cp "src/assets/images/appicons/64x64.png" "$APPDIR/feather.png" cp "src/assets/images/appicons/64x64.png" "$APPDIR/wowlet.png"
cp "build/bin/feather" "$APPDIR/usr/bin/feather" cp "build/bin/wowlet" "$APPDIR/usr/bin/wowlet"
chmod +x "$APPDIR/usr/bin/feather" chmod +x "$APPDIR/usr/bin/wowlet"
cp "contrib/AppImage/AppRun" "$APPDIR/" cp "contrib/AppImage/AppRun" "$APPDIR/"
chmod +x "$APPDIR/AppRun" chmod +x "$APPDIR/AppRun"
find feather.AppDir/ -exec touch -h -a -m -t 202101010100.00 {} \; find wowlet.AppDir/ -exec touch -h -a -m -t 202101010100.00 {} \;
mksquashfs feather.AppDir feather.squashfs -comp zstd -info -root-owned -no-xattrs -noappend -fstime 0 mksquashfs wowlet.AppDir wowlet.squashfs -comp zstd -info -root-owned -no-xattrs -noappend -fstime 0
# mksquashfs writes a timestamp to the header # mksquashfs writes a timestamp to the header
printf '\x00\x00\x00\x00' | dd conv=notrunc of=feather.squashfs bs=1 seek=$((0x8)) printf '\x00\x00\x00\x00' | dd conv=notrunc of=wowlet.squashfs bs=1 seek=$((0x8))
rm -f feather.AppImage rm -f wowlet.AppImage
cat "${GITHUB_WORKSPACE:-/feather}/contrib/depends/${HOST}/runtime" >> feather.AppImage cat "${GITHUB_WORKSPACE:-/feather}/contrib/depends/${HOST}/runtime" >> wowlet.AppImage
cat feather.squashfs >> feather.AppImage cat wowlet.squashfs >> wowlet.AppImage
chmod a+x feather.AppImage chmod a+x wowlet.AppImage
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
appdir="${1:?usage: check-libcom-err.sh APPDIR}"
library="$(find "$appdir/usr/lib" -name libcom_err.so.2 -print -quit 2>/dev/null || true)"
if [[ -z "$library" || ! -e "$library" ]]; then
echo "ERROR: AppDir is missing the bundled libcom_err.so.2 runtime library." >&2
exit 1
fi
+8
View File
@@ -198,6 +198,14 @@ ifneq ($(host),$(build))
$(1)_cmake += -DCMAKE_SYSTEM_NAME=$($(host_os)_cmake_system) $(1)_cmake += -DCMAKE_SYSTEM_NAME=$($(host_os)_cmake_system)
$(1)_cmake += -DCMAKE_C_COMPILER_TARGET=$(host) $(1)_cmake += -DCMAKE_C_COMPILER_TARGET=$(host)
$(1)_cmake += -DCMAKE_CXX_COMPILER_TARGET=$(host) $(1)_cmake += -DCMAKE_CXX_COMPILER_TARGET=$(host)
# wowlet: confine find_package to the depends sysroot so cross cmake builds don't grab host
# /usr -dev packages (libzip's find_package(ZLIB) was picking up host zlib1g-dev). Mirrors
# toolchain.cmake.in; no-op on clean CI, needed on dev boxes with -dev libs. Upstreamable.
$(1)_cmake += -DCMAKE_FIND_ROOT_PATH=$(host_prefix)
$(1)_cmake += -DCMAKE_FIND_ROOT_PATH_MODE_PROGRAM=NEVER
$(1)_cmake += -DCMAKE_FIND_ROOT_PATH_MODE_LIBRARY=ONLY
$(1)_cmake += -DCMAKE_FIND_ROOT_PATH_MODE_INCLUDE=ONLY
$(1)_cmake += -DCMAKE_FIND_ROOT_PATH_MODE_PACKAGE=ONLY
endif endif
endif endif
endef endef
+9
View File
@@ -43,6 +43,15 @@ SET(absl_ROOT @prefix@)
SET(NATIVE_BIN_PATH @prefix@/native/bin CACHE FILEPATH "path to native binaries" FORCE) SET(NATIVE_BIN_PATH @prefix@/native/bin CACHE FILEPATH "path to native binaries" FORCE)
SET(LRELEASE_PATH @prefix@/native/bin CACHE FILEPATH "path to lrelease" FORCE) SET(LRELEASE_PATH @prefix@/native/bin CACHE FILEPATH "path to lrelease" FORCE)
# A cross Qt that carries qtdeclarative makes find_package(Qt6) demand a HOST Qt,
# because QML needs host-side tools (qmlcachegen, qmlimportscanner,
# qmltyperegistrar). depends already builds one: native_qt installs to
# native/qt-host and qt.mk hands that path to the cross Qt's own configure. Our
# build never heard about it, so Qt6Config refused with "please set the
# QT_HOST_PATH cache variable" and the Windows cross build has failed since
# qtdeclarative landed. Same path, stated once more for the consuming project.
SET(QT_HOST_PATH @prefix@/native/qt-host CACHE PATH "host Qt for cross QML tooling")
if(NOT CMAKE_SYSTEM_NAME STREQUAL "Android") if(NOT CMAKE_SYSTEM_NAME STREQUAL "Android")
SET(LIBUNWIND_INCLUDE_DIR @prefix@/include) SET(LIBUNWIND_INCLUDE_DIR @prefix@/include)
SET(LIBUNWIND_LIBRARIES @prefix@/lib/libunwind.a) SET(LIBUNWIND_LIBRARIES @prefix@/lib/libunwind.a)
+6 -2
View File
@@ -2,6 +2,7 @@ import os
import glob import glob
DOCS_DIR = "external/feather-docs/content/_guides" DOCS_DIR = "external/feather-docs/content/_guides"
OVERRIDES_DIR = "contrib/docs/overrides"
OUT_DIR = "src/assets/docs" OUT_DIR = "src/assets/docs"
CATEGORY_MAP = { CATEGORY_MAP = {
@@ -21,9 +22,12 @@ outfiles = glob.glob(f"{OUT_DIR}/*.md")
for file in outfiles: for file in outfiles:
os.remove(file) os.remove(file)
files = glob.glob(f"{DOCS_DIR}/*.md") # Wowlet owns its support contacts. Local sources take precedence so regenerating
# the bundled docs cannot restore Feather's contact details.
files = {os.path.basename(path): path for path in glob.glob(f"{DOCS_DIR}/*.md")}
files.update({os.path.basename(path): path for path in glob.glob(f"{OVERRIDES_DIR}/*.md")})
for file in files: for file in sorted(files.values()):
with open(file) as f: with open(file) as f:
doc = f.read() doc = f.read()
+29
View File
@@ -0,0 +1,29 @@
---
title: "How to report an issue"
nav_title: "Report an issue"
category: "help"
---
For help with Wowlet, contact the Wownero community:
- Matrix: [#wownero-gen:wowne.ro](https://matrix.to/#/#wownero-gen:wowne.ro)
- IRC: `#wownero` on [OFTC](https://www.oftc.net/)
- Email: [wownero@wownero.org](mailto:wownero@wownero.org)
- Bug reports: [Wowlet issues](https://github.com/wownero/wowlet/issues)
For live support, use Matrix or IRC. If you do not have an IRC client, join
[#wownero in your browser](https://webchat.oftc.net/?randomnick=1&channels=wownero).
If nobody replies immediately, stay in the room and give volunteers time to respond.
Please include your Wowlet version, operating system, the steps that caused the
problem, and any error message. Review **Help → Debug Info** before sharing it and
remove any personal information. Never share your wallet seed, private keys or
password, and do not upload your wallet files.
Report security vulnerabilities privately to
[wownero@wownero.org](mailto:wownero@wownero.org), rather than in a public issue or
chat. See the [Wowlet security policy](https://github.com/wownero/wowlet/blob/master/SECURITY.md).
Downloads and signed release hashes are available from
[Wowlet releases](https://github.com/wownero/wowlet/releases), also linked from
[wownero.org](https://wownero.org/).
+85
View File
@@ -0,0 +1,85 @@
import re
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from urllib.parse import parse_qs, urlsplit
ROOT = Path(__file__).resolve().parents[3]
class GenerateDocsTests(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
self.root = Path(self.directory.name)
self.upstream = self.root / "external/feather-docs/content/_guides"
self.upstream.mkdir(parents=True)
self.output = self.root / "src/assets/docs"
self.output.mkdir(parents=True)
shutil.copytree(ROOT / "contrib/docs/overrides", self.root / "contrib/docs/overrides")
def write_upstream(self, name, body):
(self.upstream / name).write_text(
'---\ntitle: "Upstream guide"\nnav_title: "Upstream"\ncategory: "help"\n---\n'
+ body
)
def generate(self):
result = subprocess.run(
[sys.executable, "-B", str(ROOT / "contrib/docs/generate.py")],
cwd=self.root, capture_output=True, text=True,
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def assert_wownero_contacts(self, document):
urls = [urlsplit(url) for url in re.findall(r"\]\(([^)]+)\)", document)]
self.assertTrue(any(
url.hostname == "matrix.to" and url.fragment == "/#wownero-gen:wowne.ro"
for url in urls
), "Matrix must lead to Wownero's published room")
self.assertTrue(any(
url.hostname == "webchat.oftc.net"
and "wownero" in parse_qs(url.query).get("channels", [])
for url in urls
), "Webchat must join Wownero's IRC channel")
self.assertTrue(any(
url.hostname == "github.com" and url.path == "/wownero/wowlet/issues"
for url in urls
), "Bug reports must go to the Wowlet tracker")
self.assertTrue(any(
url.scheme == "mailto" and url.path == "wownero@wownero.org"
for url in urls
), "Email must reach the published Wownero contact")
for url in urls:
self.assertNotIn("feather", url.geturl().lower(), "Support must not route to Feather")
def test_support_override_survives_regeneration(self):
self.write_upstream("report_an_issue.md", "Upstream support contacts")
self.write_upstream("accounts.md", "An upstream guide without an override")
(self.output / "stale.md").write_text("Obsolete generated guide")
for body in ("Upstream support contacts", "Changed upstream support contacts"):
self.write_upstream("report_an_issue.md", body)
self.generate()
support = (self.output / "report_an_issue.md").read_text()
self.assert_wownero_contacts(support)
self.assertNotIn(body, support)
self.assertRegex(support, r"(?m)^\[nav_title\]: # \(.+\)$")
self.assertRegex(support, r"(?m)^\[category\]: # \(.+\)$")
self.assertIn("An upstream guide without an override", (self.output / "accounts.md").read_text())
self.assertFalse((self.output / "stale.md").exists())
def test_readme_and_security_contacts_agree_with_bundled_help(self):
readme = (ROOT / "README.md").read_text()
support = readme.split("## Support\n", 1)[1].split("\n## ", 1)[0]
self.assert_wownero_contacts(support)
policy = (ROOT / "SECURITY.md").read_text()
self.assertIn("mailto:wownero@wownero.org", policy)
self.assertNotIn("featherwallet.org", policy)
if __name__ == "__main__":
unittest.main()
+3 -3
View File
@@ -34,13 +34,13 @@
<string>????</string> <string>????</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>feather</string> <string>wowlet</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>Feather</string> <string>Wowlet</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
<string>org.monero-project.feather</string> <string>org.wownero.wowlet</string>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>Camera is used to scan QR codes on demand.</string> <string>Camera is used to scan QR codes on demand.</string>
+22
View File
@@ -0,0 +1,22 @@
"""Refuse a build whose resolved source differs from its reviewed request."""
import os
import re
import subprocess
def check(expected, reported, checked_out):
if not re.fullmatch(r"[0-9a-f]{40}", expected or ""):
raise ValueError("expected_sha must identify the exact reviewed commit")
if reported != expected:
raise ValueError("provider run head differs from expected_sha")
if checked_out != expected:
raise ValueError("checked-out source differs from expected_sha")
if __name__ == "__main__":
head = subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip()
try:
check(os.environ.get("EXPECTED_SHA"), os.environ.get("GITHUB_SHA"), head)
except ValueError as error:
raise SystemExit(str(error)) from None
print("Exact requested build source verified")
+73
View File
@@ -0,0 +1,73 @@
"""Fetch the pinned daemon archive and copy only its verified daemon binary."""
import argparse
import hashlib
import json
from pathlib import Path, PurePosixPath
import shutil
import tarfile
import tempfile
import urllib.request
import zipfile
MAX_ARCHIVE = 300 * 1024 * 1024
MAX_BINARY = 128 * 1024 * 1024
TARGETS = {"x86_64-linux-gnu", "x86_64-w64-mingw32", "aarch64-apple-darwin11", "x86_64-apple-darwin11"}
def copy_daemon(archive, destination, windows):
name = "wownerod.exe" if windows else "wownerod"
if windows:
container = zipfile.ZipFile(archive)
members = [m for m in container.infolist() if PurePosixPath(m.filename).name == name]
valid = lambda m: not m.is_dir() and (m.external_attr >> 16 & 0o170000) in (0, 0o100000) and 0 < m.file_size <= MAX_BINARY
reader = container.open
else:
container = tarfile.open(archive, "r:bz2")
members = [m for m in container.getmembers() if PurePosixPath(m.name).name == name]
valid = lambda m: m.isfile() and 0 < m.size <= MAX_BINARY
reader = container.extractfile
with container:
if len(members) != 1 or not valid(members[0]):
raise ValueError("Pinned archive must contain exactly one regular daemon binary")
# Do not extract archive paths, symlinks or unrelated files.
with reader(members[0]) as source, (destination / name).open("xb") as output:
shutil.copyfileobj(source, output)
(destination / name).chmod(0o755)
def fetch(target, destination):
manifest = json.loads(Path(__file__).with_name("wownerod.json").read_text())
suffix = ".zip" if target.endswith("mingw32") else ".tar.bz2"
filename = f"wownero-{target}-{manifest['version']}{suffix}"
expected = manifest["archives"][filename]
destination.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="wowlet-daemon-") as staging:
archive = Path(staging) / filename
digest = hashlib.sha256()
size = 0
url = f"https://github.com/wownero/wownero/releases/download/{manifest['version']}/{filename}"
with urllib.request.urlopen(url, timeout=120) as response, archive.open("xb") as output:
if not response.geturl().startswith("https://"):
raise ValueError("Daemon download redirected outside HTTPS")
while chunk := response.read(1024 * 1024):
size += len(chunk)
if size > MAX_ARCHIVE:
raise ValueError("Daemon archive exceeds size bound")
digest.update(chunk)
output.write(chunk)
if digest.hexdigest() != expected:
raise ValueError("Daemon archive SHA-256 differs from the reviewed release pin")
copy_daemon(archive, destination, target.endswith("mingw32"))
print(f"Verified {filename}: {expected}")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--target", required=True, choices=sorted(TARGETS))
parser.add_argument("--destination", required=True, type=Path)
args = parser.parse_args()
fetch(args.target, args.destination)
if __name__ == "__main__":
main()
+204
View File
@@ -0,0 +1,204 @@
"""Sign one private candidate without placing passwords in process arguments.
Apple SecurityTool reads one quoted operation from stdin in interactive mode:
https://github.com/apple-oss-distributions/SecurityTool/blob/main/security.c
Output stays captured because native diagnostics may repeat supplied input.
"""
import base64
from contextlib import contextmanager
import json
import os
from pathlib import Path
import secrets
import shlex
import subprocess
import sys
import tempfile
import uuid
FIELDS = (
"APPLE_CERTIFICATE", "APPLE_CERTIFICATE_PASSWORD", "APPLE_SIGNING_IDENTITY",
"APPLE_API_ISSUER", "APPLE_API_KEY", "APPLE_API_KEY_P8_BASE64",
)
def argument(value):
if any(ord(char) < 32 or ord(char) == 127 for char in value):
raise ValueError("Keychain input contains an unsupported control character")
return '"' + value.replace('\\', '\\\\').replace('"', '\\"') + '"'
def child_environment():
return {key: os.environ[key] for key in ("HOME", "PATH", "TMPDIR", "LANG", "DEVELOPER_DIR") if key in os.environ}
def security(operation, *values):
if operation not in {
"create-keychain", "set-keychain-settings", "unlock-keychain", "import",
"set-key-partition-list", "delete-keychain", "list-keychains",
}:
raise ValueError("Keychain operation is outside candidate signing")
command = " ".join(argument(value) for value in (operation, *values)) + "\n"
if len(command.encode()) > 1024:
raise ValueError("Keychain input exceeds the bounded operation size")
result = subprocess.run(
["/usr/bin/security", "-i", "-q"], input=command,
text=True, capture_output=True, env=child_environment(), timeout=90,
)
if result.returncode:
# Never forward diagnostics or operation data. Report recognized causes.
causes = {
"MAC verification failed": "PKCS12 encoding or password verification failed",
"User interaction is not allowed": "keychain requires interactive access",
"already exists": "ephemeral keychain or imported identity already exists",
"could not be found": "requested keychain item was not found",
}
cause = next((cause for marker, cause in causes.items() if marker in result.stderr),
"native keychain operation returned an error")
raise RuntimeError(f"{operation}: {cause} (exit {result.returncode}); raw diagnostics withheld")
return result.stdout
def run(arguments, *, timeout=120):
result = subprocess.run(arguments, text=True, capture_output=True,
env=child_environment(), timeout=timeout)
if result.returncode:
causes = {
"no identity found": "expected signing identity was not found",
"User interaction is not allowed": "signing key requires interactive access",
"errSecInternalComponent": "keychain denied access to the signing identity",
"not signed at all": "candidate contains an unsigned component",
"resource fork, Finder information": "candidate contains metadata forbidden by code signing",
"unable to build chain": "signing certificate chain could not be built",
"unsealed contents present": "embedded bundle has unsealed root contents",
"bundle format unrecognized": "component has an unsupported bundle format",
"timestamp service": "Apple timestamp service failed",
"Timestamping service": "Apple timestamp service failed",
"certificate has expired": "signing certificate has expired",
"CSSMERR_TP_CERT_EXPIRED": "signing certificate has expired",
}
cause = next((cause for marker, cause in causes.items() if marker in result.stderr),
"native signing operation returned an error")
raise RuntimeError(f"{Path(arguments[0]).name}: {cause} (exit {result.returncode}); raw diagnostics withheld")
return result.stdout
@contextmanager
def keychain_search(keychain):
previous = [value for value in shlex.split(security("list-keychains", "-d", "user"))
if Path(value) != keychain]
if not previous or any(not Path(value).is_absolute() for value in previous):
raise RuntimeError("Existing user keychain search list is absent or malformed")
try:
security("list-keychains", "-d", "user", "-s", str(keychain), *previous)
yield
finally:
security("list-keychains", "-d", "user", "-s", *previous)
def signable_files(app):
for path in sorted(app.rglob("*")):
if path.is_symlink() or not path.is_file() or any(part.endswith(".framework") for part in path.parts):
continue
if "Mach-O" in run(["/usr/bin/file", "-b", str(path)]):
yield path
def private_file(path, content):
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(descriptor, "wb") as stream:
stream.write(content)
def signing_inputs():
credentials = {name: os.environ.get(name, "") for name in FIELDS}
missing = [name for name, value in credentials.items() if not value]
if missing:
raise ValueError("Signing input is missing: " + ", ".join(missing))
if "Such Software" not in credentials["APPLE_SIGNING_IDENTITY"]:
raise ValueError("Developer ID identity does not name the expected publisher")
return credentials
def main():
credentials = signing_inputs()
app = Path("build/dist/Wowlet.app").resolve()
if not app.is_dir():
raise ValueError("Packaged Wowlet.app is missing")
old_umask = os.umask(0o077)
try:
with tempfile.TemporaryDirectory(prefix=".wowlet-signing-", dir=app.parent) as temporary:
workspace = Path(temporary)
keychain = workspace / "candidate.keychain-db"
certificate = workspace / "identity.p12"
notary_key = workspace / "notary.p8"
private_file(certificate, base64.b64decode(credentials["APPLE_CERTIFICATE"]))
private_file(notary_key, base64.b64decode(credentials["APPLE_API_KEY_P8_BASE64"]))
password = secrets.token_hex(32)
created = False
try:
security("create-keychain", "-p", password, str(keychain))
created = True
security("set-keychain-settings", "-lut", "3600", str(keychain))
security("unlock-keychain", "-p", password, str(keychain))
security("import", str(certificate), "-k", str(keychain), "-P",
credentials["APPLE_CERTIFICATE_PASSWORD"], "-T", "/usr/bin/codesign")
security("set-key-partition-list", "-S", "apple-tool:,apple:", "-s",
"-k", password, str(keychain))
with keychain_search(keychain):
def sign(path):
print("Signing component: " + (str(path.relative_to(app)) if path != app else "Wowlet.app"), flush=True)
run(["/usr/bin/codesign", "--force", "--options", "runtime", "--timestamp",
"--keychain", str(keychain), "--sign",
credentials["APPLE_SIGNING_IDENTITY"], str(path)])
for path in signable_files(app):
sign(path)
frameworks = app / "Contents/Frameworks"
for path in sorted(frameworks.glob("*.framework")):
sign(path)
sign(app)
run(["/usr/bin/codesign", "--verify", "--deep", "--strict", str(app)])
archive = workspace / "candidate.zip"
run(["/usr/bin/ditto", "-c", "-k", "--keepParent", str(app), str(archive)])
raw = run(["/usr/bin/xcrun", "notarytool", "submit", str(archive),
"--key", str(notary_key), "--key-id", credentials["APPLE_API_KEY"],
"--issuer", credentials["APPLE_API_ISSUER"], "--wait", "--timeout", "30m",
"--output-format", "json"], timeout=1900)
response = json.loads(raw)
if response.get("status") != "Accepted":
raise RuntimeError("Apple notarization did not return Accepted")
try:
uuid.UUID(response.get("id", ""))
except (ValueError, TypeError, AttributeError):
raise RuntimeError("Apple notarization submission identity is missing or malformed") from None
run(["/usr/bin/xcrun", "stapler", "staple", str(app)])
run(["/usr/bin/xcrun", "stapler", "validate", str(app)])
private_file(app.parent / "macos-signing.json", json.dumps({
"developer_id_verified": True, "notarization_status": "Accepted",
"notary_submission_id": response.get("id"), "staple_validated": True,
}, sort_keys=True).encode())
print("Developer ID signature verified; notarization accepted; staple validated")
finally:
if created:
security("delete-keychain", str(keychain))
finally:
os.umask(old_umask)
if __name__ == "__main__":
try:
if sys.argv[1:] == ["--check-inputs"]:
signing_inputs()
print("Required signing inputs are present")
elif sys.argv[1:]:
raise ValueError("Use no arguments to sign or --check-inputs for the cheap input guard")
else:
main()
except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired) as error:
# These exceptions contain logical fields, native result classes or
# public file paths. Captured child output is never included.
raise SystemExit(str(error)) from None
@@ -0,0 +1,40 @@
from pathlib import Path
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[3]
CHECKER = ROOT / "contrib/AppImage/check-libcom-err.sh"
class AppImageDependencyBundleTest(unittest.TestCase):
def test_accepts_appdir_with_libcom_err(self):
with tempfile.TemporaryDirectory() as temporary:
appdir = Path(temporary) / "AppDir"
library = appdir / "usr/lib/libcom_err.so.2"
library.parent.mkdir(parents=True)
library.write_bytes(b"fixture")
result = subprocess.run(["bash", str(CHECKER), str(appdir)], check=False)
self.assertEqual(result.returncode, 0)
def test_refuses_appdir_without_libcom_err(self):
with tempfile.TemporaryDirectory() as temporary:
appdir = Path(temporary) / "AppDir"
(appdir / "usr/lib").mkdir(parents=True)
result = subprocess.run(
["bash", str(CHECKER), str(appdir)],
check=False,
capture_output=True,
text=True,
)
self.assertNotEqual(result.returncode, 0)
self.assertTrue(result.stderr.strip())
if __name__ == "__main__":
unittest.main()
+20
View File
@@ -0,0 +1,20 @@
import importlib.util
from pathlib import Path
import unittest
spec = importlib.util.spec_from_file_location("dispatch", Path(__file__).parents[1] / "check_dispatch.py")
dispatch = importlib.util.module_from_spec(spec)
spec.loader.exec_module(dispatch)
class DispatchTests(unittest.TestCase):
def test_exact_source_is_required_before_building(self):
sha = "a" * 40
dispatch.check(sha, sha, sha)
for expected, reported, checked_out in (
(None, sha, sha), ("master", sha, sha),
(sha, "b" * 40, sha), (sha, sha, "b" * 40),
):
with self.subTest(expected=expected, reported=reported, checked_out=checked_out):
with self.assertRaises(ValueError):
dispatch.check(expected, reported, checked_out)
@@ -0,0 +1,49 @@
import importlib.util
import io
from pathlib import Path
import tarfile
import tempfile
import unittest
from unittest.mock import patch
SPEC = importlib.util.spec_from_file_location("fetch_wownerod", Path(__file__).parents[1] / "fetch_wownerod.py")
fetcher = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(fetcher)
class DaemonTests(unittest.TestCase):
def test_checksum_failure_never_extracts_a_daemon(self):
response = io.BytesIO(b"untrusted archive bytes")
response.geturl = lambda: "https://release-assets.githubusercontent.com/archive"
with tempfile.TemporaryDirectory() as tmp, patch.object(fetcher.urllib.request, "urlopen", return_value=response), patch.object(fetcher, "copy_daemon") as extract:
with self.assertRaisesRegex(ValueError, "SHA-256"):
fetcher.fetch("x86_64-linux-gnu", Path(tmp) / "daemon")
extract.assert_not_called()
self.assertFalse((Path(tmp) / "daemon/wownerod").exists())
def test_archive_paths_cannot_write_outside_destination(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
archive = root / "daemon.tar.bz2"
with tarfile.open(archive, "w:bz2") as output:
info = tarfile.TarInfo("../../wownerod")
info.size = 3
output.addfile(info, io.BytesIO(b"exe"))
destination = root / "stage"
destination.mkdir()
fetcher.copy_daemon(archive, destination, False)
self.assertEqual((destination / "wownerod").read_bytes(), b"exe")
self.assertFalse((root / "wownerod").exists())
def test_symlink_daemon_is_refused(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
archive = root / "daemon.tar.bz2"
with tarfile.open(archive, "w:bz2") as output:
info = tarfile.TarInfo("wownerod")
info.type = tarfile.SYMTYPE
info.linkname = "/etc/passwd"
output.addfile(info)
with self.assertRaisesRegex(ValueError, "regular daemon"):
fetcher.copy_daemon(archive, root, False)
self.assertFalse((root / "wownerod").exists())
+26
View File
@@ -0,0 +1,26 @@
import importlib.util
from pathlib import Path
import sys
import tempfile
import unittest
sys.path.insert(0, str(Path(__file__).parents[1]))
SPEC = importlib.util.spec_from_file_location("write_receipt", Path(__file__).parents[1] / "write_receipt.py")
receipt = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(receipt)
class ReceiptTests(unittest.TestCase):
def test_hash_changes_with_artifact_bytes_and_symlinks_refuse(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "candidate.zip"
path.write_bytes(b"candidate")
first = receipt.file_record(path)
path.write_bytes(b"different candidate")
second = receipt.file_record(path)
self.assertNotEqual(first["sha256"], second["sha256"])
self.assertEqual(second["size"], path.stat().st_size)
link = Path(tmp) / "link.zip"
link.symlink_to(path)
with self.assertRaisesRegex(ValueError, "regular file"):
receipt.file_record(link)
+95
View File
@@ -0,0 +1,95 @@
import importlib.util
from pathlib import Path
from types import SimpleNamespace
import unittest
import tempfile
from unittest.mock import patch
spec = importlib.util.spec_from_file_location("sign_macos", Path(__file__).parents[1] / "sign_macos.py")
signing = importlib.util.module_from_spec(spec)
spec.loader.exec_module(signing)
class MacSigningTests(unittest.TestCase):
def test_password_uses_private_input_and_diagnostics_never_echo_it(self):
synthetic = 'synthetic "quoted" value \\ with spaces'
captured = []
def execute(arguments, **options):
captured.append((arguments, options))
return SimpleNamespace(returncode=1, stdout=synthetic,
stderr="MAC verification failed " + synthetic)
with patch.object(signing.subprocess, "run", execute):
with self.assertRaises(RuntimeError) as caught:
signing.security("import", "candidate.p12", "-P", synthetic)
self.assertNotIn(synthetic, str(caught.exception))
arguments, options = captured[0]
self.assertNotIn(synthetic, " ".join(arguments))
self.assertIn("quoted", options["input"])
self.assertTrue(options["capture_output"])
self.assertNotIn("APPLE_CERTIFICATE_PASSWORD", options["env"])
def test_missing_inputs_refuse_without_native_operations(self):
with patch.dict(signing.os.environ, {}, clear=True), patch.object(signing.subprocess, "run") as execute:
with self.assertRaises(ValueError):
signing.signing_inputs()
execute.assert_not_called()
def test_control_characters_and_unrelated_keychain_operations_refuse(self):
with patch.object(signing.subprocess, "run") as execute:
for value in ("line\nbreak", "nul\x00byte", "carriage\rreturn"):
with self.assertRaises(ValueError):
signing.security("unlock-keychain", "-p", value, "candidate.keychain")
with self.assertRaises(ValueError):
signing.security("dump-keychain", "login.keychain")
execute.assert_not_called()
def test_user_keychain_search_is_restored_after_signing_failure(self):
previous = ['/Users/runner/Library/Keychains/login.keychain-db', '/Library/Keychains/System.keychain']
calls = []
def security(operation, *values):
calls.append((operation, values))
return ' '.join('"' + value + '"' for value in previous)
with patch.object(signing, "security", security):
with self.assertRaisesRegex(RuntimeError, "synthetic signing failure"):
with signing.keychain_search(Path('/private/candidate.keychain-db')):
self.assertEqual(calls[-1][1][-len(previous):], tuple(previous))
self.assertIn('/private/candidate.keychain-db', calls[-1][1])
raise RuntimeError('synthetic signing failure')
self.assertEqual(calls[-1], ('list-keychains', ('-d', 'user', '-s', *previous)))
def test_failed_search_list_write_still_restores_original_list(self):
calls = []
previous = '/Users/runner/Library/Keychains/login.keychain-db'
def security(operation, *values):
calls.append((operation, values))
if '/private/candidate.keychain-db' in values:
raise RuntimeError('synthetic search write failure')
return '"' + previous + '"'
with patch.object(signing, "security", security):
with self.assertRaises(RuntimeError):
with signing.keychain_search(Path('/private/candidate.keychain-db')):
self.fail('failed search-list setup entered signing')
self.assertEqual(calls[-1][1], ('-d', 'user', '-s', previous))
def test_signing_walk_skips_symlink_aliases_and_framework_internals(self):
with tempfile.TemporaryDirectory() as temporary:
app = Path(temporary)
executable = app / 'actual.dylib'
executable.write_bytes(b'mach-o fixture')
(app / 'alias.dylib').symlink_to(executable.name)
framework = app / 'QtCore.framework'
framework.mkdir()
(framework / 'QtCore').write_bytes(b'framework fixture')
with patch.object(signing, 'run', return_value='Mach-O arm64'):
self.assertEqual(list(signing.signable_files(app)), [executable])
def test_native_failure_cause_does_not_echo_untrusted_diagnostics(self):
sentinel = 'must-not-echo-native-input'
result = SimpleNamespace(returncode=1, stdout='', stderr='unable to build chain ' + sentinel)
with patch.object(signing.subprocess, 'run', return_value=result):
with self.assertRaises(RuntimeError) as caught:
signing.run(['/usr/bin/codesign', 'public-component'])
self.assertIn('certificate chain', str(caught.exception))
self.assertNotIn(sentinel, str(caught.exception))
+12
View File
@@ -0,0 +1,12 @@
{
"version": "v0.11.5.0",
"source_commit": "b22382c55fa2a97cd70aca450026b8e2bb8711a6",
"checksum_evidence": "https://github.com/wownero/wownero/actions/runs/37402323531",
"checksum_signature": "The release tag build replaced the archives after SHA256SUMS.txt.asc was signed. These hashes match the successful tag build and current release metadata; that signature does not cover these archives.",
"archives": {
"wownero-x86_64-w64-mingw32-v0.11.5.0.zip": "46dffbd313de1df4667516acf23737f986eb2e131c06a8dde1d11dcff2eb06e2",
"wownero-aarch64-apple-darwin11-v0.11.5.0.tar.bz2": "bad8cc7f1c78d026e63dbe3ffc0f2175d50b986bbde3fc18826a04ff693866d4",
"wownero-x86_64-apple-darwin11-v0.11.5.0.tar.bz2": "ae562c90a10856fcdd183dc9530dff73e46ed6eece9fb8dcfaa77f1e07174787",
"wownero-x86_64-linux-gnu-v0.11.5.0.tar.bz2": "2d0cc62c24255d45c51936616559651378011501c55a8caadb9a125bb875849b"
}
}
+59
View File
@@ -0,0 +1,59 @@
"""Bind candidate archive hashes to the checked source and provider run."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import subprocess
from check_dispatch import check
def file_record(path):
if not path.is_file() or path.is_symlink() or path.stat().st_size == 0:
raise ValueError("Candidate artifact must be a nonempty regular file")
digest = hashlib.sha256()
with path.open("rb") as stream:
while chunk := stream.read(1024 * 1024):
digest.update(chunk)
return {"name": path.name, "size": path.stat().st_size, "sha256": digest.hexdigest()}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--platform", required=True, choices=("linux", "macos", "windows-build"))
parser.add_argument("--file", type=Path, required=True, action="append")
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
head = subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip()
check(os.environ.get("EXPECTED_SHA"), os.environ.get("GITHUB_SHA"), head)
run_id = os.environ.get("GITHUB_RUN_ID", "")
if not re.fullmatch(r"[1-9][0-9]*", run_id):
raise ValueError("Provider run identity is absent")
version = re.findall(r'^\s*VERSION "([0-9]+\.[0-9]+\.[0-9]+)"\s*$', Path("CMakeLists.txt").read_text(), re.M)
if len(version) != 1:
raise ValueError("Committed Wowlet version is ambiguous")
module = subprocess.check_output(["git", "ls-tree", "HEAD", "monero"], text=True).split()
if len(module) != 4 or module[:2] != ["160000", "commit"] or module[3] != "monero":
raise ValueError("Committed wallet-library pin is absent")
files = [file_record(path) for path in args.file]
if len({row["name"] for row in files}) != len(files):
raise ValueError("Candidate artifact filenames repeat")
receipt = {"schema_version": 1, "app": "wowlet", "version": version[0], "platform": args.platform,
"dispatch_commit": head, "wallet_library_commit": module[2],
"environment_run_id": run_id, "environment_run_attempt": os.environ.get("GITHUB_RUN_ATTEMPT"),
"artifacts": files}
if args.platform == "macos":
signing = json.loads(Path("build/dist/macos-signing.json").read_text())
if signing.get("developer_id_verified") is not True or signing.get("notarization_status") != "Accepted" or signing.get("staple_validated") is not True:
raise ValueError("macOS signing evidence is incomplete")
receipt["signing"] = signing
with args.output.open("x") as stream:
json.dump(receipt, stream, indent=2)
stream.write("\n")
print("Candidate source and artifact hashes recorded")
if __name__ == "__main__":
main()
+1 -1
Submodule monero updated: 8b05051b57...324867ad42
+14 -10
View File
@@ -34,7 +34,7 @@ if (CHECK_UPDATES)
add_subdirectory(openpgp) add_subdirectory(openpgp)
endif() endif()
qt_add_resources(RESOURCES assets.qrc assets_tor.qrc assets_wownerod.qrc assets_docs.qrc mining.qrc) qt_add_resources(RESOURCES assets.qrc assets_tor.qrc assets_wownerod.qrc assets_docs.qrc mining.qrc silly.qrc scene.qrc)
# Compile source files (.h/.cpp) # Compile source files (.h/.cpp)
file(GLOB SOURCE_FILES file(GLOB SOURCE_FILES
@@ -148,7 +148,10 @@ add_executable(feather ${EXECUTABLE_FLAG} main.cpp
${ASSETS_TOR} ${ASSETS_TOR}
) )
# The CMake target keeps its upstream name so the target_* calls below stay put;
# only what we ship is renamed.
set_target_properties(feather PROPERTIES set_target_properties(feather PROPERTIES
OUTPUT_NAME wowlet
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin" RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin"
LINK_FLAGS_RELEASE -s LINK_FLAGS_RELEASE -s
) )
@@ -370,14 +373,14 @@ else()
) )
if (UNIX AND NOT APPLE) if (UNIX AND NOT APPLE)
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/feather.desktop DESTINATION ${CMAKE_INSTALL_PREFIX}/share/applications) install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/wowlet.desktop DESTINATION ${CMAKE_INSTALL_PREFIX}/share/applications)
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/images/appicons/32x32.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/32x32/apps RENAME "feather.png") install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/images/appicons/32x32.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/32x32/apps RENAME "wowlet.png")
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/images/appicons/48x48.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/48x48/apps RENAME "feather.png") install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/images/appicons/48x48.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/48x48/apps RENAME "wowlet.png")
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/images/appicons/64x64.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/64x64/apps RENAME "feather.png") install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/images/appicons/64x64.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/64x64/apps RENAME "wowlet.png")
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/images/appicons/96x96.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/96x96/apps RENAME "feather.png") install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/images/appicons/96x96.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/96x96/apps RENAME "wowlet.png")
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/images/appicons/128x128.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/128x128/apps RENAME "feather.png") install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/images/appicons/128x128.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/128x128/apps RENAME "wowlet.png")
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/images/appicons/256x256.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/256x256/apps RENAME "feather.png") install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/images/appicons/256x256.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/256x256/apps RENAME "wowlet.png")
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/images/appicons/512x512.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/512x512/apps RENAME "feather.png") install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/images/appicons/512x512.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/512x512/apps RENAME "wowlet.png")
endif() endif()
endif() endif()
@@ -385,13 +388,14 @@ if(APPLE)
configure_file(${CMAKE_SOURCE_DIR}/contrib/macdeploy/Info.plist.in ${CMAKE_SOURCE_DIR}/contrib/macdeploy/Info.plist @ONLY) configure_file(${CMAKE_SOURCE_DIR}/contrib/macdeploy/Info.plist.in ${CMAKE_SOURCE_DIR}/contrib/macdeploy/Info.plist @ONLY)
set_target_properties(feather PROPERTIES set_target_properties(feather PROPERTIES
OUTPUT_NAME wowlet
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin" RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin"
MACOSX_BUNDLE TRUE MACOSX_BUNDLE TRUE
MACOSX_BUNDLE_INFO_PLIST "${CMAKE_SOURCE_DIR}/contrib/macdeploy/Info.plist" MACOSX_BUNDLE_INFO_PLIST "${CMAKE_SOURCE_DIR}/contrib/macdeploy/Info.plist"
LINK_FLAGS_RELEASE -s LINK_FLAGS_RELEASE -s
) )
file(COPY "${CMAKE_SOURCE_DIR}/src/assets/images/appicons/appicon.icns" DESTINATION "${CMAKE_SOURCE_DIR}/installed/feather.app/Contents/Resources/" ) file(COPY "${CMAKE_SOURCE_DIR}/src/assets/images/appicons/appicon.icns" DESTINATION "${CMAKE_SOURCE_DIR}/installed/wowlet.app/Contents/Resources/" )
endif() endif()
qt_finalize_executable(feather) qt_finalize_executable(feather)
+8
View File
@@ -14,6 +14,7 @@
#include "utils/config.h" #include "utils/config.h"
#include "utils/Icons.h" #include "utils/Icons.h"
#include "WebsocketNotifier.h" #include "WebsocketNotifier.h"
#include "widgets/SceneWidget.h"
HistoryWidget::HistoryWidget(Wallet *wallet, QWidget *parent) HistoryWidget::HistoryWidget(Wallet *wallet, QWidget *parent)
: QWidget(parent) : QWidget(parent)
@@ -24,6 +25,13 @@ HistoryWidget::HistoryWidget(Wallet *wallet, QWidget *parent)
, m_model(wallet->historyModel()) , m_model(wallet->historyModel())
{ {
ui->setupUi(this); ui->setupUi(this);
// wowlet: History is "home" — the full ambient scene as a top banner.
auto *scene = new SceneWidget(this);
scene->setMinimumHeight(120);
scene->setMaximumHeight(150);
ui->verticalLayout->insertWidget(0, scene);
m_contextMenu->addMenu(m_copyMenu); m_contextMenu->addMenu(m_copyMenu);
m_contextMenu->addAction(icons()->icon("info2.svg"), "Show details", this, &HistoryWidget::showTxDetails); m_contextMenu->addAction(icons()->icon("info2.svg"), "Show details", this, &HistoryWidget::showTxDetails);
m_contextMenu->addAction("View on block explorer", this, &HistoryWidget::onViewOnBlockExplorer); m_contextMenu->addAction("View on block explorer", this, &HistoryWidget::onViewOnBlockExplorer);
+122 -2
View File
@@ -36,6 +36,7 @@
#include "utils/AppData.h" #include "utils/AppData.h"
#include "utils/AsyncTask.h" #include "utils/AsyncTask.h"
#include "utils/ColorScheme.h" #include "utils/ColorScheme.h"
#include "utils/DaemonManager.h" // wowlet: for daemonStateChanged reconnect (node-toggle race)
#include "utils/Icons.h" #include "utils/Icons.h"
#include "utils/TorManager.h" #include "utils/TorManager.h"
#include "utils/WebsocketNotifier.h" #include "utils/WebsocketNotifier.h"
@@ -251,7 +252,7 @@ void MainWindow::initWidgets() {
}); });
// [Mining] — wowlet: the pixel-art mining tab (mining.qml + embedded-node solo miner) // [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"); ui->tabWidget->addTab(m_miningWidget, "Mining");
// [Notes] // [Notes]
@@ -512,6 +513,13 @@ void MainWindow::initWalletContext() {
this->onConnectionStatusChanged(status); this->onConnectionStatusChanged(status);
m_nodes->autoConnect(); m_nodes->autoConnect();
}); });
// wowlet: the embedded node's RPC port comes up asynchronously after daemonManager()->start(), so a
// freshly-toggled local node would otherwise sit Disconnected until the next status tick. Reconnect the
// instant its RPC is up. (autoConnect's runLocalNode branch routes to 127.0.0.1; start() is idempotent.)
connect(daemonManager(), &DaemonManager::daemonStateChanged, this, [this](bool up){
if (up && conf()->get(Config::runLocalNode).toBool())
m_nodes->autoConnect(true);
});
connect(m_wallet, &Wallet::currentSubaddressAccountChanged, this, &MainWindow::updateTitle); connect(m_wallet, &Wallet::currentSubaddressAccountChanged, this, &MainWindow::updateTitle);
connect(m_wallet, &Wallet::walletPassphraseNeeded, this, &MainWindow::onWalletPassphraseNeeded); connect(m_wallet, &Wallet::walletPassphraseNeeded, this, &MainWindow::onWalletPassphraseNeeded);
@@ -534,6 +542,24 @@ void MainWindow::initWalletContext() {
connect(m_wallet, &Wallet::deviceError, this, &MainWindow::onDeviceError); connect(m_wallet, &Wallet::deviceError, this, &MainWindow::onDeviceError);
connect(m_wallet, &Wallet::multiBroadcast, this, &MainWindow::onMultiBroadcast); 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){ void MainWindow::menuToggleTabVisible(const QString &key){
@@ -703,7 +729,7 @@ void MainWindow::onWebsocketStatusChanged(bool enabled) {
} }
void MainWindow::onProxySettingsChangedConnect() { void MainWindow::onProxySettingsChangedConnect() {
m_nodes->connectToNode(); m_nodes->reconnect(); // wowlet: clear blocklist + force-reconnect after a live Tor/proxy switch (test 2)
this->onProxySettingsChanged(); this->onProxySettingsChanged();
} }
@@ -752,6 +778,7 @@ void MainWindow::onSubtractFeeFromAmountEnabled(bool enabled) {
} }
void MainWindow::onMultiBroadcast(const QMap<QString, QString> &txHexMap) { void MainWindow::onMultiBroadcast(const QMap<QString, QString> &txHexMap) {
m_broadcastBatchSuccesses = 0;
QMapIterator<QString, QString> i(txHexMap); QMapIterator<QString, QString> i(txHexMap);
while (i.hasNext()) { while (i.hasNext()) {
i.next(); i.next();
@@ -762,6 +789,99 @@ void MainWindow::onMultiBroadcast(const QMap<QString, QString> &txHexMap) {
m_rpc->sendRawTransaction(i.value()); 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) { void MainWindow::onSyncStatus(quint64 height, quint64 target, bool daemonSync) {
+14
View File
@@ -51,6 +51,9 @@ namespace Ui {
class MainWindow; class MainWindow;
} }
#include <QSet>
#include <QTimer>
class ToggleTab : QObject { class ToggleTab : QObject {
Q_OBJECT Q_OBJECT
@@ -214,12 +217,23 @@ private:
QIcon hardwareDevicePairedIcon(); QIcon hardwareDevicePairedIcon();
QIcon hardwareDeviceUnpairedIcon(); 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; QScopedPointer<Ui::MainWindow> ui;
WindowManager *m_windowManager; WindowManager *m_windowManager;
Wallet *m_wallet = nullptr; Wallet *m_wallet = nullptr;
Nodes *m_nodes; Nodes *m_nodes;
DaemonRpc *m_rpc; DaemonRpc *m_rpc;
// wowlet: see checkStuckTransactions / relayPendingTransactions
QTimer *m_stuckTxTimer = nullptr;
QSet<QString> m_stuckTxWarned;
int m_broadcastBatchSuccesses = 0;
SplashDialog *m_splashDialog = nullptr; SplashDialog *m_splashDialog = nullptr;
AccountSwitcherDialog *m_accountSwitcherDialog = nullptr; AccountSwitcherDialog *m_accountSwitcherDialog = nullptr;
TxPoolViewerDialog *m_txPoolViewerDialog = nullptr; TxPoolViewerDialog *m_txPoolViewerDialog = nullptr;
+8
View File
@@ -12,6 +12,7 @@
#include "utils/config.h" #include "utils/config.h"
#include "utils/Icons.h" #include "utils/Icons.h"
#include "utils/Utils.h" #include "utils/Utils.h"
#include "widgets/SceneWidget.h"
ReceiveWidget::ReceiveWidget(Wallet *wallet, QWidget *parent) ReceiveWidget::ReceiveWidget(Wallet *wallet, QWidget *parent)
: QWidget(parent) : QWidget(parent)
@@ -20,6 +21,13 @@ ReceiveWidget::ReceiveWidget(Wallet *wallet, QWidget *parent)
{ {
ui->setupUi(this); ui->setupUi(this);
// 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, SceneWidget::Sit);
scene->setMinimumHeight(110);
scene->setMaximumHeight(130);
ui->verticalLayout->addWidget(scene);
m_model = m_wallet->subaddressModel(); m_model = m_wallet->subaddressModel();
m_proxyModel = new SubaddressProxyModel(this, m_wallet->subaddress()); m_proxyModel = new SubaddressProxyModel(this, m_wallet->subaddress());
m_proxyModel->setSourceModel(m_model); m_proxyModel->setSourceModel(m_model);
+16
View File
@@ -11,6 +11,7 @@
#include "Icons.h" #include "Icons.h"
#include "libwalletqt/Wallet.h" #include "libwalletqt/Wallet.h"
#include "libwalletqt/WalletManager.h" #include "libwalletqt/WalletManager.h"
#include "widgets/SceneWidget.h"
#if defined(WITH_SCANNER) #if defined(WITH_SCANNER)
#include "wizard/offline_tx_signing/OfflineTxSigningWizard.h" #include "wizard/offline_tx_signing/OfflineTxSigningWizard.h"
@@ -25,6 +26,21 @@ SendWidget::SendWidget(Wallet *wallet, QWidget *parent)
{ {
ui->setupUi(this); ui->setupUi(this);
// 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.
//
// 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::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)$)"; QString amount_rx = R"(^\d{0,8}[\.,]\d{0,12}|(all)$)";
QRegularExpression rx; QRegularExpression rx;
rx.setPattern(amount_rx); rx.setPattern(amount_rx);
+21 -2
View File
@@ -11,7 +11,7 @@
</rect> </rect>
</property> </property>
<property name="sizePolicy"> <property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Maximum"> <sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch> <horstretch>0</horstretch>
<verstretch>0</verstretch> <verstretch>0</verstretch>
</sizepolicy> </sizepolicy>
@@ -19,7 +19,24 @@
<property name="windowTitle"> <property name="windowTitle">
<string>Form</string> <string>Form</string>
</property> </property>
<layout class="QFormLayout" name="formLayout"> <layout class="QVBoxLayout" name="verticalLayoutOuter">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<layout class="QFormLayout" name="formLayout">
<property name="sizeConstraint"> <property name="sizeConstraint">
<enum>QLayout::SizeConstraint::SetMinimumSize</enum> <enum>QLayout::SizeConstraint::SetMinimumSize</enum>
</property> </property>
@@ -274,6 +291,8 @@
</item> </item>
</widget> </widget>
</item> </item>
</layout>
</item>
</layout> </layout>
</widget> </widget>
<customwidgets> <customwidgets>
+7 -1
View File
@@ -139,6 +139,12 @@ void Settings::setupAppearanceTab() {
emit updateBalance(); emit updateBalance();
}); });
// [Prefer reduced motion] wowlet: gate playful animations (mining tab, loaders, ...)
ui->checkBox_reducedMotion->setChecked(conf()->get(Config::reducedMotion).toBool());
connect(ui->checkBox_reducedMotion, &QCheckBox::toggled, [this](bool toggled){
conf()->set(Config::reducedMotion, toggled);
});
// [Preferred fiat currency] // [Preferred fiat currency]
QStringList availableFiatCurrencies = appData()->prices.rates.keys(); QStringList availableFiatCurrencies = appData()->prices.rates.keys();
for (const auto &currency : availableFiatCurrencies) { for (const auto &currency : availableFiatCurrencies) {
@@ -177,7 +183,7 @@ void Settings::setupNetworkTab() {
daemonManager()->start(); daemonManager()->start();
else else
daemonManager()->stop(false); // non-blocking: don't freeze Settings while wownerod flushes daemonManager()->stop(false); // non-blocking: don't freeze Settings while wownerod flushes
m_nodes->autoConnect(true); m_nodes->reconnect(); // wowlet: clear blocklist + force-reconnect (handles a previously-failed remote)
}); });
} else { } else {
m_nodes = new Nodes(this, nullptr); m_nodes = new Nodes(this, nullptr);
+7
View File
@@ -159,6 +159,13 @@
</property> </property>
</widget> </widget>
</item> </item>
<item row="6" column="1">
<widget class="QCheckBox" name="checkBox_reducedMotion">
<property name="text">
<string>Prefer reduced motion (disable playful animations)</string>
</property>
</widget>
</item>
</layout> </layout>
</item> </item>
</layout> </layout>
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+129 -95
View File
@@ -35,6 +35,7 @@ Rectangle {
} }
AnimatedImage { AnimatedImage {
playing: !mining.reducedMotion // wowlet: respect reduced-motion
//visible: mining.daemonMiningState === 0 //visible: mining.daemonMiningState === 0
source: "qrc:/mining/bg1.gif" source: "qrc:/mining/bg1.gif"
fillMode: Image.PreserveAspectCrop fillMode: Image.PreserveAspectCrop
@@ -91,7 +92,9 @@ Rectangle {
RowLayout { RowLayout {
spacing: 0 spacing: 0
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: false
Layout.preferredHeight: 128 Layout.preferredHeight: 128
Layout.maximumHeight: 128
Image { Image {
source: "qrc:/mining/topleft.png" source: "qrc:/mining/topleft.png"
@@ -134,10 +137,12 @@ Rectangle {
Text { Text {
id: hashRateText id: hashRateText
text: "-" text: "-"
anchors.verticalCenter: parent.verticalCenter anchors.fill: parent
anchors.left: parent.left
anchors.leftMargin: 4 anchors.leftMargin: 4
font.pointSize: 16 verticalAlignment: Text.AlignVCenter
fontSizeMode: Text.HorizontalFit
minimumPointSize: 8
font.pointSize: 14
font.family: comicMono.name; font.family: comicMono.name;
antialiasing: false antialiasing: false
color: "#41FF00" color: "#41FF00"
@@ -160,7 +165,7 @@ Rectangle {
Layout.fillHeight: true Layout.fillHeight: true
Text { Text {
text: "uptime" text: "Uptime"
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left anchors.left: parent.left
anchors.leftMargin: 4 anchors.leftMargin: 4
@@ -179,10 +184,12 @@ Rectangle {
Text { Text {
id: miningUptime id: miningUptime
text: "-" text: "-"
anchors.verticalCenter: parent.verticalCenter anchors.fill: parent
anchors.left: parent.left
anchors.leftMargin: 4 anchors.leftMargin: 4
font.pointSize: 12 verticalAlignment: Text.AlignVCenter
fontSizeMode: Text.HorizontalFit
minimumPointSize: 8
font.pointSize: 14
font.family: comicMono.name; font.family: comicMono.name;
antialiasing: false antialiasing: false
color: "#41FF00" color: "#41FF00"
@@ -191,6 +198,7 @@ Rectangle {
} }
AnimatedImage { AnimatedImage {
playing: !mining.reducedMotion // wowlet: respect reduced-motion
visible: mining.daemonMiningState !== 0 visible: mining.daemonMiningState !== 0
source: "qrc:/mining/mining.webp" source: "qrc:/mining/mining.webp"
fillMode: Image.PreserveAspectCrop fillMode: Image.PreserveAspectCrop
@@ -206,7 +214,9 @@ Rectangle {
ColumnLayout { ColumnLayout {
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: false
Layout.preferredHeight: 128 Layout.preferredHeight: 128
Layout.maximumHeight: 128
spacing: 0 spacing: 0
Image { Image {
@@ -227,126 +237,98 @@ Rectangle {
RowLayout { RowLayout {
spacing: 0 spacing: 0
Layout.fillHeight: true // Pinned: with fillHeight the console absorbed any slack in the
// top bar, stretching the bezel and dropping the metrics out of
// the bottom of the panel.
Layout.fillHeight: false
Layout.preferredHeight: 102 Layout.preferredHeight: 102
Layout.minimumHeight: 102
Layout.maximumHeight: 102
Image { Image {
Layout.preferredWidth: 5 Layout.preferredWidth: 5
Layout.preferredHeight: parent.height Layout.preferredHeight: 102 // console bezel is a fixed 102px tall
source: "qrc:/mining/topright_left.png" source: "qrc:/mining/topright_left.png"
smooth: false smooth: false
} }
Image { Image {
Layout.fillWidth: true Layout.fillWidth: true
Layout.preferredHeight: parent.height Layout.preferredHeight: 102 // console bezel is a fixed 102px tall
source: "qrc:/mining/topright_middle.png" source: "qrc:/mining/topright_middle.png"
fillMode: Image.TileHorizontally fillMode: Image.TileHorizontally
smooth: false smooth: false
RowLayout { // The node metrics live inside the console's dark interior
// band (rows 7..94 of the 102px topright_middle bezel).
// Anchored at a fixed offset with fixed row heights so a
// long value (a full block height) can never be pushed
// out of the bottom of the console.
Row {
anchors.top: parent.top anchors.top: parent.top
anchors.topMargin: 16
anchors.left: parent.left anchors.left: parent.left
anchors.leftMargin: 6 anchors.leftMargin: 14
anchors.right: parent.right anchors.right: parent.right
anchors.rightMargin: 8 anchors.rightMargin: 8
anchors.topMargin: 4 height: 52
spacing: 24
height: 60 Column {
spacing: 16 width: Math.min(260, (parent.width - parent.spacing) / 2)
spacing: 2
ColumnLayout { Text {
Layout.minimumWidth: 200 text: "Block Height"
Layout.maximumWidth: 260 font.pointSize: 14
Layout.fillHeight: true font.family: comicMonoBold.name;
color: "#41FF00"
Rectangle { antialiasing: false
Layout.fillWidth: true
Layout.fillHeight: true
color: "transparent"
Text {
text: "Block Height"
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: 8
font.pointSize: 20
font.family: comicMonoBold.name;
color: "#41FF00"
antialiasing: false
}
} }
Rectangle { Text {
Layout.fillWidth: true id: heightText
Layout.fillHeight: true text: "-"
color: "transparent" width: parent.width
fontSizeMode: Text.HorizontalFit
Text { minimumPointSize: 9
id: heightText font.pointSize: 14
text: "-" font.family: comicMonoBold.name;
anchors.verticalCenter: parent.verticalCenter color: "#41FF00"
anchors.left: parent.left antialiasing: false
anchors.leftMargin: 8
font.pointSize: 18
font.family: comicMonoBold.name;
color: "#41FF00"
antialiasing: false
}
} }
} }
ColumnLayout { Column {
Layout.minimumWidth: 200 width: Math.min(260, (parent.width - parent.spacing) / 2)
Layout.maximumWidth: 260 spacing: 2
Layout.fillHeight: true
Rectangle { Text {
Layout.fillWidth: true text: "Sync Progress"
Layout.fillHeight: true font.pointSize: 14
color: "transparent" font.family: comicMonoBold.name;
color: "#41FF00"
Text { antialiasing: false
text: "Sync Progress"
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: 8
font.pointSize: 20
font.family: comicMonoBold.name;
color: "#41FF00"
antialiasing: false
}
} }
Rectangle { Text {
Layout.fillWidth: true id: syncPctText
Layout.fillHeight: true text: "-"
color: "transparent" width: parent.width
fontSizeMode: Text.HorizontalFit
Text { minimumPointSize: 9
id: syncPctText font.pointSize: 14
text: "-" font.family: comicMonoBold.name;
anchors.verticalCenter: parent.verticalCenter color: "#41FF00"
anchors.horizontalCenter: parent.horizontalCenter antialiasing: false
font.pointSize: 18
font.family: comicMonoBold.name;
color: "#41FF00"
antialiasing: false
}
} }
} }
Item {
Layout.fillHeight: true
Layout.fillWidth: true
}
} }
} }
Image { Image {
Layout.preferredWidth: 5 Layout.preferredWidth: 5
Layout.preferredHeight: parent.height Layout.preferredHeight: 102 // console bezel is a fixed 102px tall
source: "qrc:/mining/topright_right.png" source: "qrc:/mining/topright_right.png"
smooth: false smooth: false
} }
@@ -428,6 +410,7 @@ Rectangle {
smooth: false smooth: false
AnimatedImage { AnimatedImage {
playing: !mining.reducedMotion // wowlet: respect reduced-motion
source: mining.daemonMiningState === 0 ? "qrc:/assets/images/dog_sitting.gif" : "qrc:/assets/images/dog_running.gif" source: mining.daemonMiningState === 0 ? "qrc:/assets/images/dog_sitting.gif" : "qrc:/assets/images/dog_running.gif"
width: 80 width: 80
height: 60 height: 60
@@ -529,6 +512,10 @@ Rectangle {
Image { Image {
id: imgAxe id: imgAxe
visible: mining.daemonMiningState === 0 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" source: "qrc:/mining/axe.png"
width: 73 width: 73
height: 75 height: 75
@@ -538,6 +525,7 @@ Rectangle {
} }
AnimatedImage { AnimatedImage {
playing: !mining.reducedMotion // wowlet: respect reduced-motion
visible: mining.daemonMiningState !== 0 visible: mining.daemonMiningState !== 0
source: "qrc:/mining/elmo.gif" source: "qrc:/mining/elmo.gif"
width: 106 width: 106
@@ -567,7 +555,11 @@ Rectangle {
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onClicked: { onClicked: {
if(mining.daemonMiningState === 0) { if (mining.daemonMiningState === 0) {
if (!mining.canMine) {
remoteNodeNotice.flash();
return;
}
root.startMining(); root.startMining();
root.calcAvailableHeightConsoleLines(); root.calcAvailableHeightConsoleLines();
} }
@@ -575,14 +567,56 @@ Rectangle {
root.stopMining(); root.stopMining();
} }
onEntered: { onEntered: {
imgAxe.height = 64 if (mining.canMine) {
imgAxe.width = 64 imgAxe.height = 64
imgAxe.width = 64
} else {
remoteNodeNotice.show();
}
} }
onExited: { onExited: {
imgAxe.height = 75 imgAxe.height = 75
imgAxe.width = 73 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() }
}
} }
} }
} }
File diff suppressed because it is too large Load Diff
+397 -309
View File
@@ -168,312 +168,400 @@
1601257433:250500 1601257433:250500
1601710572:252000 1601710572:252000
1602154921:253500 1602154921:253500
1609825674:279148 1602596617:255000
1609816860:279048 1603053507:256500
1610116800:280048 1603501419:258000
1610420700:281048 1603951028:259500
1610715120:282048 1604397604:261000
1611017160:283048 1604844178:262500
1611314940:284048 1605289509:264000
1611616080:285048 1605742395:265500
1611911760:286048 1606192218:267000
1612211940:287048 1606638968:268500
1612515660:288048 1607083718:270000
1612810500:289048 1607539961:271500
1613115540:290048 1607991764:273000
1613414820:291048 1608439952:274500
1613709300:292048 1608890342:276000
1614008280:293048 1609334849:277500
1614307380:294048 1609777306:279000
1614603060:295048 1610229306:280500
1614902280:296048 1610678028:282000
1615204740:297048 1611129205:283500
1615505100:298048 1611578787:285000
1615807020:299048 1612024723:286500
1616108820:300048 1612479368:288000
1616402280:301048 1612925285:289500
1616706720:302048 1613378445:291000
1617003960:303048 1613818784:292500
1617611100:305048 1614269634:294000
1617911640:306048 1614718724:295500
1618191900:307048 1615167437:297000
1618522560:308048 1615619299:298500
1618816620:309048 1616071879:300000
1619098740:310048 1616517919:301500
1619390520:311048 1616969191:303000
1619695500:312048 1617419137:304500
1619985120:313048 1617878649:306000
1620290220:314048 1618306800:307500
1620586260:315048 1618784409:309000
1620885360:316048 1619210159:310500
1621183620:317048 1619658329:312000
1621482300:318048 1620103337:313500
1621782840:319048 1620558729:315000
1622079960:320048 1621002710:316500
1622380260:321048 1621453789:318000
1622678460:322048 1621895697:319500
1622974380:323048 1622347075:321000
1623269760:324048 1622790315:322500
1623567420:325048 1623238613:324000
1623869820:326048 1623686218:325500
1624167180:327048 1624135035:327000
1624473000:328048 1624588247:328500
1624767600:329048 1625034084:330000
1625065980:330048 1625387596:331500
1625366940:331048 1625683505:333000
1625439000:332048 1626206520:334500
1625706420:333048 1626634887:336000
1626101160:334048 1627067110:337500
1626365940:335048 1627510005:339000
1626660360:336048 1627915694:340500
1626966840:337048 1628363606:342000
1627231740:338048 1628827297:343500
1627541700:339048 1629250950:345000
1627810440:340048 1629688820:346500
1628078820:341048 1630145454:348000
1628406780:342048 1630591816:349500
1628709420:343048 1631025927:351000
1629011100:344048 1631515284:352500
1629283260:345048 1631986172:354000
1629582540:346048 1632422703:355500
1629874620:347048 1632890624:357000
1630168440:348048 1633326321:358500
1630465980:349048 1633775768:360000
1630756800:350048 1634233650:361500
1631060460:351048 1634693889:363000
1631393400:352048 1635139294:364500
1631699460:353048 1635617950:366000
1632012480:354048 1636079519:367500
1632289920:355048 1636497795:369000
1632602820:356048 1636997495:370500
1632923220:357048 1637418065:372000
1633203180:358048 1637897948:373500
1633491000:359048 1638376658:375000
1633811400:360048 1638790941:376500
1634113140:361048 1639235224:378000
1634430300:362048 1639757854:379500
1634722260:363048 1640218491:381000
1635035460:364048 1640646944:382500
1635368760:365048 1641106951:384000
1635639660:366048 1641544811:385500
1635957840:367048 1641989328:387000
1636245240:368048 1642443489:388500
1636535820:369048 1643009648:390000
1636863900:370048 1643455341:391500
1637160360:371048 1643876044:393000
1637457780:372048 1644333115:394500
1637793240:373048 1644747412:396000
1638062460:374048 1645196998:397500
1638407580:375048 1645649589:399000
1638675240:376048 1646107975:400500
1638981360:377048 1646543179:402000
1639273140:378048 1647017449:403500
1639612740:379048 1647475207:405000
1639928760:380048 1647881079:406500
1640242500:381048 1648354637:408000
1640530620:382048 1648816244:409500
1640838000:383048 1649261814:411000
1641136800:384048 1649734559:412500
1641437400:385048 1650212215:414000
1641716040:386048 1650656774:415500
1642030680:387048 1651081594:417000
1642330500:388048 1651543047:418500
1642714260:389048 1651999579:420000
1643040360:390048 1652464011:421500
1643344560:391048 1652926487:423000
1643612040:392048 1653376589:424500
1643913360:393048 1653829783:426000
1644223920:394048 1654287342:427500
1644491820:395048 1654747411:429000
1644794760:396048 1655193349:430500
1645110660:397048 1655710653:432000
1645379400:398048 1656134720:433500
1645693500:399048 1656592570:435000
1645981080:400048 1657054346:436500
1646301360:401048 1657494432:438000
1646576220:402048 1657939752:439500
1646919420:403048 1658390870:441000
1647208320:404048 1658822838:442500
1647506640:405048 1659286786:444000
1647768840:406048 1659718822:445500
1648082520:407048 1660176142:447000
1648391700:408048 1660639818:448500
1648686240:409048 1661083112:450000
1649002860:410048 1661517325:451500
1649282820:411048 1661970076:453000
1649599860:412048 1662446938:454500
1649959980:413048 1662873827:456000
1650248280:414048 1663336599:457500
1650528840:415048 1663789611:459000
1650809340:416048 1664221220:460500
1651118280:417048 1664685037:462000
1651432260:418048 1665126666:463500
1651740360:419048 1665557872:465000
1652031600:420048 1666034414:466500
1652351400:421048 1666495833:468000
1652639520:422048 1666929111:469500
1652954640:423048 1667394182:471000
1653252600:424048 1667835975:472500
1653549840:425048 1668288340:474000
1653862740:426048 1668766046:475500
1654161060:427048 1669214111:477000
1654461900:428048 1669665943:478500
1654782600:429048 1670117908:480000
1655076960:430048 1670586033:481500
1655441820:431048 1671029494:483000
1655740500:432048 1671465279:484500
1656030240:433048 1671904526:486000
1656324720:434048 1672376519:487500
1656624060:435048 1672822751:489000
1656925920:436048 1673269600:490500
1657236840:437048 1673719076:492000
1657526700:438048 1674182057:493500
1657816740:439048 1674626828:495000
1658135940:440048 1675085613:496500
1658434440:441048 1675521264:498000
1658715780:442048 1675977630:499500
1659010080:443048 1676426332:501000
1659318120:444048 1676890583:502500
1659611040:445048 1677344883:504000
1659914940:446048 1677795450:505500
1660204260:447048 1678241156:507000
1660505220:448048 1678685201:508500
1660831740:449048 1679136670:510000
1661114940:450048 1679592119:511500
1661397720:451048 1680041240:513000
1661708760:452048 1680537447:514500
1661997180:453048 1680981131:516000
1662335640:454048 1681434379:517500
1662630120:455048 1681894205:519000
1662908820:456048 1682345821:520500
1663213380:457048 1682805333:522000
1663501140:458048 1683258937:523500
1663832400:459048 1683714088:525000
1664109480:460048 1684168676:526500
1664412600:461048 1684616632:528000
1664724720:462048 1685076954:529500
1665004620:463048 1685528631:531000
1665305460:464048 1685980354:532500
1665600480:465048 1686442998:534000
1665894960:466048 1686893463:535500
1666213620:467048 1687341403:537000
1666527600:468048 1687800345:538500
1666816380:469048 1688260852:540000
1667123160:470048 1688713555:541500
1667427180:471048 1689164934:543000
1667720640:472048 1689616311:544500
1668021000:473048 1690075687:546000
1668334740:474048 1690533856:547500
1668649440:475048 1690984681:549000
1668946680:476048 1691444970:550500
1669247100:477048 1691897459:552000
1669560960:478048 1692352014:553500
1669852800:479048 1692807402:555000
1670164020:480048 1693259665:556500
1670467860:481048 1693718708:558000
1670767440:482048 1694169620:559500
1671063480:483048 1694621215:561000
1671363960:484048 1695073751:562500
1671639900:485048 1695544139:564000
1671950940:486048 1696010948:565500
1672248180:487048 1696480073:567000
1672566480:488048 1696934545:568500
1672859400:489048 1697383183:570000
1673179080:490048 1697850119:571500
1673451120:491048 1698298303:573000
1673759520:492048 1698763478:574500
1674053100:493048 1699224283:576000
1674374100:494048 1699683095:577500
1674658440:495048 1700138177:579000
1674978420:496048 1700593190:580500
1675249380:497048 1701074336:582000
1675559460:498048 1701528794:583500
1675847160:499048 1701994567:585000
1676178900:500048 1702441199:586500
1676463600:501048 1702920637:588000
1676782680:502048 1703388368:589500
1677074640:503048 1703830741:591000
1677385020:504048 1704284418:592500
1677686160:505048 1704734515:594000
1677996420:506048 1705184380:595500
1678277700:507048 1705643726:597000
1678564680:508048 1706087617:598500
1678852920:509048 1706539628:600000
1679178960:510048 1706999159:601500
1679473500:511048 1707446328:603000
1679780220:512048 1707920134:604500
1680075120:513048 1708380012:606000
1680415620:514048 1708845016:607500
1680715500:515048 1709293588:609000
1681014240:516048 1709748870:610500
1681314240:517048 1710202528:612000
1681628940:518048 1710647834:613500
1681919940:519048 1711098409:615000
1682226660:520048 1711550139:616500
1682528460:521048 1712000662:618000
1682838000:522048 1712465889:619500
1683149040:523048 1712914193:621000
1683448680:524048 1713366831:622500
1683750540:525048 1713821520:624000
1684050540:526048 1714275415:625500
1684346940:527048 1714718068:627000
1684659780:528048 1715165016:628500
1684960560:529048 1715625858:630000
1685261880:530048 1716087656:631500
1685565420:531048 1716542805:633000
1685865660:532048 1716996296:634500
1686178020:533048 1717442953:636000
1686480060:534048 1717899613:637500
1686776820:535048 1718343607:639000
1687078860:536048 1718798893:640500
1687379580:537048 1719259600:642000
1687685100:538048 1719715208:643500
1687993320:539048 1720171971:645000
1688293380:540048 1720619357:646500
1688598420:541048 1721076296:648000
1688898120:542048 1721532239:649500
1689208500:543048 1721987052:651000
1689503940:544048 1722437031:652500
1689802800:545048 1722888953:654000
1690109040:546048 1723343110:655500
1690414320:547048 1723795513:657000
1690721280:548048 1724247189:658500
1691025480:549048 1724705009:660000
1691325720:550048 1725165021:661500
1691633940:551048 1725617360:663000
1691931480:552048 1726071946:664500
1692233280:553048 1726522439:666000
1692532620:554048 1726979451:667500
1692843420:555048 1727430507:669000
1693147020:556048 1727884081:670500
1693453140:557048 1728334947:672000
1693749540:558048 1728789282:673500
1694054460:559048 1729231059:675000
1694348940:560048 1729687775:676500
1694661120:561048 1730150813:678000
1694961240:562048 1730607377:679500
1695270600:563048 1731052869:681000
1695572880:564048 1731502177:682500
1695891600:565048 1731953584:684000
1696201200:566048 1732397467:685500
1696506300:567048 1732857103:687000
1696833360:568048 1733305492:688500
1697119860:569048 1733757932:690000
1697424660:570048 1734213761:691500
1697728620:571048 1734665858:693000
1698030720:572048 1735109323:694500
1698328680:573048 1735571098:696000
1698640800:574048 1736017391:697500
1698955800:575048 1736470274:699000
1699261560:576048 1736922413:700500
1699561560:577048 1737375374:702000
1699873800:578048 1737822863:703500
1700185680:579048 1738278318:705000
1700481900:580048 1738734744:706500
1700801820:581048 1739188167:708000
1701103860:582048 1739646442:709500
1701414900:583048 1740096952:711000
1701721080:584048 1740548052:712500
1702027260:585048 1741000180:714000
1702332300:586048 1741453599:715500
1702641960:587048 1741905136:717000
1742364662:718500
1742812126:720000
1743264843:721500
1743714525:723000
1744175390:724500
1744626378:726000
1745080531:727500
1745536519:729000
1745985232:730500
1746429926:732000
1746893280:733500
1747344244:735000
1747800382:736500
1748256803:738000
1748720133:739500
1749171722:741000
1749626761:742500
1750075168:744000
1750529452:745500
1750980136:747000
1751430178:748500
1751884163:750000
1752336519:751500
1752789487:753000
1753244800:754500
1753695473:756000
1754167218:757500
1754620759:759000
1755063742:760500
1755512182:762000
1755966558:763500
1756408673:765000
1756867066:766500
1757321935:768000
1757781405:769500
1758227963:771000
1758690444:772500
1759142623:774000
1759597441:775500
1760050166:777000
1760517211:778500
1760966094:780000
1761425868:781500
1761871754:783000
1762332424:784500
1762788746:786000
1763241559:787500
1763691073:789000
1764148322:790500
1764600720:792000
1765052466:793500
1765508447:795000
1765967616:796500
1766426715:798000
1766881106:799500
1767332242:801000
1767779604:802500
1768229063:804000
1768689528:805500
1769142523:807000
1769587539:808500
1770045670:810000
1770505705:811500
1770956798:813000
1771407979:814500
1771860479:816000
1772315752:817500
1772768810:819000
1773214642:820500
1773666005:822000
1774124830:823500
1774579737:825000
1775029537:826500
1775486297:828000
1775943681:829500
1776404619:831000
1776859398:832500
1777309533:834000
1777768990:835500
1778220072:837000
1778676940:838500
1779131063:840000
1779582902:841500
1780038251:843000
1780489721:844500
1780940799:846000
1781397311:847500
1781853697:849000
+1 -1
View File
@@ -137,7 +137,7 @@
<item row="3" column="1"> <item row="3" column="1">
<widget class="QLabel" name="label_11"> <widget class="QLabel" name="label_11">
<property name="text"> <property name="text">
<string>codeberg.org/wownero/wowlet</string> <string>github.com/wownero/wowlet</string>
</property> </property>
<property name="textInteractionFlags"> <property name="textInteractionFlags">
<set>Qt::TextInteractionFlag::LinksAccessibleByMouse|Qt::TextInteractionFlag::TextSelectableByMouse</set> <set>Qt::TextInteractionFlag::LinksAccessibleByMouse|Qt::TextInteractionFlag::TextSelectableByMouse</set>
+23 -2
View File
@@ -4,6 +4,10 @@
#include "SplashDialog.h" #include "SplashDialog.h"
#include "ui_SplashDialog.h" #include "ui_SplashDialog.h"
#include <QDir>
#include <QMovie>
#include <QRandomGenerator>
#include "utils/Icons.h" #include "utils/Icons.h"
SplashDialog::SplashDialog(QWidget *parent) SplashDialog::SplashDialog(QWidget *parent)
@@ -14,8 +18,25 @@ SplashDialog::SplashDialog(QWidget *parent)
this->setWindowIcon(icons()->icon("appicon/64x64")); this->setWindowIcon(icons()->icon("appicon/64x64"));
QPixmap pixmap = QPixmap(":/assets/images/key.png"); // wowlet: a random silly loading GIF (wow-lite-wallet tradition) instead of
ui->icon->setPixmap(pixmap.scaledToWidth(32, Qt::SmoothTransformation)); // a static key icon. Auto-discovers whatever loaders are bundled under
// :/silly so the set can grow without touching this code; falls back to the
// key pixmap if none are present.
const QStringList loaders = QDir(":/silly").entryList(QStringList() << "*.gif", QDir::Files);
if (!loaders.isEmpty()) {
const QString pick = ":/silly/" + loaders.at(QRandomGenerator::global()->bounded(loaders.size()));
auto *movie = new QMovie(pick, QByteArray(), this);
movie->jumpToFrame(0);
const QSize frame = movie->currentImage().size();
const int h = 56;
if (frame.height() > 0)
movie->setScaledSize(QSize(frame.width() * h / frame.height(), h));
ui->icon->setMovie(movie);
movie->start();
} else {
QPixmap pixmap = QPixmap(":/assets/images/key.png");
ui->icon->setPixmap(pixmap.scaledToWidth(32, Qt::SmoothTransformation));
}
this->setWindowFlags(Qt::Dialog | Qt::FramelessWindowHint); this->setWindowFlags(Qt::Dialog | Qt::FramelessWindowHint);
+9
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: BSD-3-Clause // SPDX-License-Identifier: BSD-3-Clause
// SPDX-FileCopyrightText: The Monero Project // SPDX-FileCopyrightText: The Monero Project
#include <QQuickWindow>
#include <QSslSocket> #include <QSslSocket>
#include "Application.h" #include "Application.h"
@@ -88,6 +89,14 @@ if (AttachConsole(ATTACH_PARENT_PROCESS)) {
QApplication::setHighDpiScaleFactorRoundingPolicy(Qt::HighDpiScaleFactorRoundingPolicy::Round); QApplication::setHighDpiScaleFactorRoundingPolicy(Qt::HighDpiScaleFactorRoundingPolicy::Round);
#endif #endif
// wowlet: force the QtQuick scene graph to the software renderer. The app's only QtQuick surface
// (the Mining tab's QQuickWidget) otherwise drives the default RHI/GL path, which faults (0xc0000005)
// on broken-GL environments — notably VirtualBox's VMSVGA — crashing the whole app and orphaning the
// embedded node. The mining widget is small + static, so software rendering costs nothing meaningful
// and guarantees no GL-init crash on any user's hardware. (Confirmed in a VMSVGA Win11 VM: the software
// backend launches cleanly where the default GL backend crashed.)
QQuickWindow::setGraphicsApi(QSGRendererInterface::Software);
Application app(argc, argv); Application app(argc, argv);
QApplication::setApplicationName("wowlet"); QApplication::setApplicationName("wowlet");
+10
View File
@@ -0,0 +1,10 @@
<!DOCTYPE RCC><RCC version="1.0">
<qresource prefix="/">
<file alias="qml/scene.qml">assets/qml/scene.qml</file>
<file alias="scene/dog2.gif">assets/images/scene/dog2.gif</file>
<file alias="scene/dog.gif">assets/images/scene/dog.gif</file>
<file alias="scene/flyingmoney.gif">assets/images/scene/flyingmoney.gif</file>
<file alias="scene/goldcoin.gif">assets/images/scene/goldcoin.gif</file>
<file alias="scene/grass.png">assets/images/scene/grass.png</file>
</qresource>
</RCC>
+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE RCC><RCC version="1.0">
<qresource prefix="/">
<file alias="silly/l11.gif">assets/images/silly/l11.gif</file>
<file alias="silly/l12.gif">assets/images/silly/l12.gif</file>
<file alias="silly/l19.gif">assets/images/silly/l19.gif</file>
<file alias="silly/l1.gif">assets/images/silly/l1.gif</file>
<file alias="silly/l21.gif">assets/images/silly/l21.gif</file>
<file alias="silly/l22.gif">assets/images/silly/l22.gif</file>
<file alias="silly/l26.gif">assets/images/silly/l26.gif</file>
<file alias="silly/l27.gif">assets/images/silly/l27.gif</file>
<file alias="silly/l3.gif">assets/images/silly/l3.gif</file>
<file alias="silly/l5.gif">assets/images/silly/l5.gif</file>
<file alias="silly/l6.gif">assets/images/silly/l6.gif</file>
<file alias="silly/l7.gif">assets/images/silly/l7.gif</file>
<file alias="silly/loading.gif">assets/images/silly/loading.gif</file>
</qresource>
</RCC>
+115
View File
@@ -0,0 +1,115 @@
// SPDX-License-Identifier: BSD-3-Clause
// SPDX-FileCopyrightText: The Monero Project
#include "utils/ChildProcessGuard.h"
#include <QDebug>
#if defined(Q_OS_WIN)
#include <windows.h>
#elif defined(Q_OS_UNIX)
#include <csignal>
#if defined(Q_OS_LINUX)
#include <sys/prctl.h>
#include <unistd.h>
#endif
#endif
namespace ChildProcessGuard
{
#if defined(Q_OS_WIN)
// One process-wide job for all wowlet children. Created lazily on first use; the HANDLE is
// intentionally never closed — it must stay open for wowlet's entire lifetime so the job persists.
// On process death (clean OR crash) the kernel closes it, fires KILL_ON_JOB_CLOSE, and reaps the kids.
static HANDLE g_job = nullptr;
static HANDLE jobHandle()
{
if (g_job)
return g_job;
HANDLE job = CreateJobObjectW(nullptr, nullptr);
if (!job) {
qWarning() << "ChildProcessGuard: CreateJobObject failed:" << GetLastError();
return nullptr;
}
JOBOBJECT_EXTENDED_LIMIT_INFORMATION info{};
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, &info, sizeof(info))) {
qWarning() << "ChildProcessGuard: SetInformationJobObject failed:" << GetLastError();
CloseHandle(job);
return nullptr;
}
g_job = job;
return g_job;
}
void installPreStart(QProcess *) { /* no-op on Windows: assignment happens post-start */ }
void adoptAfterStart(QProcess *process)
{
HANDLE job = jobHandle();
if (!job)
return;
// QProcess::start() is asynchronous: right after it returns the child often has no PID yet. Wait
// (bounded) for it to actually start, else the assign is silently skipped and the child survives a
// parent crash (orphaned) — this is exactly what stranded wownerod in testing. Returns the instant
// the process is created, so the UI-thread cost is tiny.
if (process->state() == QProcess::Starting)
process->waitForStarted(5000);
const qint64 pid = process->processId(); // 0 if it still isn't running
if (pid == 0) {
qWarning() << "ChildProcessGuard: child has no PID; not assigned to job";
return;
}
// AssignProcessToJobObject needs PROCESS_SET_QUOTA | PROCESS_TERMINATE. QProcess does not expose
// the child HANDLE, so re-open it by PID.
HANDLE proc = OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, FALSE,
static_cast<DWORD>(pid));
if (!proc) {
qWarning() << "ChildProcessGuard: OpenProcess failed for pid" << pid << ":" << GetLastError();
return;
}
if (!AssignProcessToJobObject(job, proc))
qWarning() << "ChildProcessGuard: AssignProcessToJobObject failed:" << GetLastError();
else
qInfo() << "ChildProcessGuard: bound child pid" << pid << "to kill-on-close job";
CloseHandle(proc); // closing this per-child handle does NOT remove the process from the job
}
#elif defined(Q_OS_UNIX)
void installPreStart(QProcess *process)
{
#if defined(Q_OS_LINUX)
// Runs in the forked child between fork() and exec(). Keep it async-signal-safe.
process->setChildProcessModifier([] {
::prctl(PR_SET_PDEATHSIG, SIGKILL);
// Fork-race guard: if the parent already died before prctl ran, self-terminate.
if (::getppid() == 1)
::raise(SIGKILL);
});
#else
Q_UNUSED(process) // macOS/BSD: no PR_SET_PDEATHSIG; rely on aboutToQuit + destructor stop()
#endif
}
void adoptAfterStart(QProcess *) { /* no-op on Unix: handled pre-start */ }
#else
void installPreStart(QProcess *) {}
void adoptAfterStart(QProcess *) {}
#endif
} // namespace ChildProcessGuard
+33
View File
@@ -0,0 +1,33 @@
// SPDX-License-Identifier: BSD-3-Clause
// SPDX-FileCopyrightText: The Monero Project
#ifndef WOWLET_CHILDPROCESSGUARD_H
#define WOWLET_CHILDPROCESSGUARD_H
#include <QProcess>
// wowlet: OS-level "die with parent" for spawned child processes (embedded wownerod, bundled tor).
//
// QProcess does NOT kill its child when the parent exits or crashes; it only detaches. Feather's
// TorManager/DaemonManager relied solely on an explicit stop() in the normal-quit path, so an
// unhandled crash (e.g. a 0xc0000005 access violation) orphaned the children. This binds the
// child's lifetime to wowlet's at the kernel level, which survives a hard crash where no C++
// destructor and no Qt teardown runs.
//
// Windows: a single process-wide Job Object with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE. Assign each
// child after start(); when wowlet's process dies the job's last handle closes and the
// kernel terminates every process still in the job.
// Unix: prctl(PR_SET_PDEATHSIG, SIGKILL) installed in the child (via setChildProcessModifier),
// so the kernel SIGKILLs the child when the parent dies.
namespace ChildProcessGuard
{
// Unix: arrange for `process` to receive SIGKILL when wowlet dies. Call BEFORE process->start().
// No-op on Windows (the Job Object handles it post-start).
void installPreStart(QProcess *process);
// Windows: assign the (now-running) child to wowlet's kill-on-close job. Call right AFTER start()
// returns and the child has a PID. No-op on Unix.
void adoptAfterStart(QProcess *process);
}
#endif //WOWLET_CHILDPROCESSGUARD_H
+143 -4
View File
@@ -4,12 +4,24 @@
#include "utils/DaemonManager.h" #include "utils/DaemonManager.h"
#include <QCoreApplication> #include <QCoreApplication>
#include <QDateTime>
#include <QDir> #include <QDir>
#include <QDirIterator> #include <QDirIterator>
#include <QEventLoop>
#include <QFileInfo> #include <QFileInfo>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QRegularExpression> #include <QRegularExpression>
#include <QUrl>
#include "constants.h"
#include "utils/AppData.h"
#include "utils/ChildProcessGuard.h" // wowlet: OS-level die-with-parent for the embedded node
#include "utils/config.h" #include "utils/config.h"
#include "utils/networktype.h"
#include "utils/TorManager.h" #include "utils/TorManager.h"
#include "utils/Utils.h" #include "utils/Utils.h"
@@ -25,6 +37,11 @@ DaemonManager::DaemonManager(QObject *parent)
connect(m_process, &QProcess::readyReadStandardOutput, this, &DaemonManager::handleProcessOutput); connect(m_process, &QProcess::readyReadStandardOutput, this, &DaemonManager::handleProcessOutput);
connect(m_process, &QProcess::errorOccurred, this, &DaemonManager::handleProcessError); connect(m_process, &QProcess::errorOccurred, this, &DaemonManager::handleProcessError);
connect(m_process, &QProcess::stateChanged, this, &DaemonManager::onStateChanged); connect(m_process, &QProcess::stateChanged, this, &DaemonManager::onStateChanged);
// wowlet: SIGKILL wownerod if wowlet dies (Unix); Windows binds it to a kill-on-close job post-start.
ChildProcessGuard::installPreStart(m_process);
// wowlet: graceful stop on any clean quit, not just the last-window-closed path (flushes LMDB).
connect(QCoreApplication::instance(), &QCoreApplication::aboutToQuit, this, [this]{ this->stop(); });
} }
QPointer<DaemonManager> DaemonManager::m_instance(nullptr); QPointer<DaemonManager> DaemonManager::m_instance(nullptr);
@@ -46,18 +63,42 @@ void DaemonManager::start() {
if (state == QProcess::ProcessState::Running || state == QProcess::ProcessState::Starting) if (state == QProcess::ProcessState::Running || state == QProcess::ProcessState::Starting)
return; return;
// wowlet: a deliberate (re)start clears the stop latch, so a later UNEXPECTED exit can auto-restart
// again. Without this, m_stopping stays true after the first node toggle-off and the crash
// auto-restart is silently dead for the rest of the session.
m_stopping = false;
if (!this->unpackBins()) { if (!this->unpackBins()) {
this->setErrorMessage("Unable to unpack the embedded wownerod binary."); this->setErrorMessage("Unable to unpack the embedded wownerod binary.");
return; return;
} }
// wowlet: don't trust a bare open port. Before adopting whatever is listening on our RPC port,
// verify via get_info that it's a healthy wownero-mainnet wownerod (right nettype, sane height,
// making progress) — not a stale/stuck orphan from a prior crash or an unrelated squatter.
if (Utils::portOpen(this->rpcHost, this->rpcPort)) { if (Utils::portOpen(this->rpcHost, this->rpcPort)) {
// Something is already listening on our RPC port; assume it is a usable local node. if (this->verifyAdoptableNode()) {
m_alreadyRunning = true; qDebug() << "Adopting existing wownerod on" << this->rpcAddress();
emit daemonStateChanged(true); m_alreadyRunning = true;
return; emit daemonStateChanged(true);
return;
}
qWarning() << "Refusing to adopt unverified listener on" << this->rpcAddress()
<< "- will spawn a managed wownerod instead";
this->setErrorMessage(
QString("Found an unrecognized process on %1; starting a fresh node.").arg(this->rpcAddress()));
// fall through to spawn; if the port/data-dir is genuinely held, the spawn fails to bind
// and surfaces a clear error via handleProcessError().
} }
// wowlet: the retry cap guards against a rapid crash-loop, not legit restarts. If the node had been
// up a while before this (re)start, clear the budget — otherwise a few node toggles over a session
// trip "maximum retries exceeded" and it won't start again until wowlet is relaunched.
const qint64 nowMs = QDateTime::currentMSecsSinceEpoch();
if (m_lastSpawnMs != 0 && (nowMs - m_lastSpawnMs) > 60000)
m_restarts = 0;
m_lastSpawnMs = nowMs;
m_restarts += 1; m_restarts += 1;
if (m_restarts > 4) { if (m_restarts > 4) {
this->setErrorMessage("wownerod failed to start: maximum retries exceeded"); this->setErrorMessage("wownerod failed to start: maximum retries exceeded");
@@ -81,6 +122,14 @@ void DaemonManager::start() {
arguments << "--no-igd"; // no UPnP port mapping arguments << "--no-igd"; // no UPnP port mapping
arguments << "--out-peers" << "16"; arguments << "--out-peers" << "16";
arguments << "--log-file" << QDir(dataDir).filePath("wownerod.log"); arguments << "--log-file" << QDir(dataDir).filePath("wownerod.log");
// wowlet: disable LMDB batch write mode so every block is committed in its own transaction.
// Without this, wownero accumulates blocks in a single large write transaction that is ABORTED
// (not committed) when the daemon shuts down mid-sync — causing all progress since the last
// batch boundary to be silently lost. This is visible in wownerod.log: sessions consistently
// revert to a stale height despite a full graceful shutdown sequence. Using "safe" mode is
// slower for initial sync but is the only correct choice for an embedded node that starts and
// stops with the wallet; the wownero chain is small enough that the overhead is acceptable.
arguments << "--db-sync-mode" << "safe";
// Full archival by default (wownero's chain is small); prune is an explicit opt-in. // Full archival by default (wownero's chain is small); prune is an explicit opt-in.
if (conf()->get(Config::pruneBlockchain).toBool()) if (conf()->get(Config::pruneBlockchain).toBool())
@@ -106,6 +155,7 @@ void DaemonManager::start() {
qDebug() << QString("Starting wownerod: %1 %2").arg(this->daemonPath, arguments.join(" ")); qDebug() << QString("Starting wownerod: %1 %2").arg(this->daemonPath, arguments.join(" "));
m_process->start(this->daemonPath, arguments); m_process->start(this->daemonPath, arguments);
ChildProcessGuard::adoptAfterStart(m_process); // wowlet: bind to kill-on-crash job (Windows)
m_started = true; m_started = true;
} }
@@ -238,6 +288,95 @@ bool DaemonManager::shouldStartDaemon() {
return true; return true;
} }
// wowlet: synchronous, bounded get_info probe used by start() to decide whether an already-open RPC
// port is a node we can safely adopt. Returns true ONLY for a healthy wownero-mainnet wownerod.
// Self-contained (own QNetworkAccessManager + local QEventLoop with a hard timeout) so it can run
// inside the synchronous start() at boot, before any wallet/DaemonRpc exists. Mirrors TorManager's
// blocking getVersion() probe idiom.
bool DaemonManager::verifyAdoptableNode() {
if (conf()->get(Config::offlineMode).toBool())
return false;
QNetworkAccessManager nam;
QNetworkRequest req(QUrl(QString("http://%1/get_info").arg(this->rpcAddress())));
req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
QEventLoop loop;
QTimer timeout;
timeout.setSingleShot(true);
QObject::connect(&timeout, &QTimer::timeout, &loop, &QEventLoop::quit);
QNetworkReply *reply = nam.post(req, QByteArray("{}"));
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
timeout.start(1500); // hard cap on UI-thread block
loop.exec();
if (!reply->isFinished()) { // timed out
reply->abort();
reply->deleteLater();
qWarning() << "get_info verify: no response from" << this->rpcAddress() << "within 1.5s";
return false;
}
bool netOk = reply->error() == QNetworkReply::NoError;
QByteArray data = reply->readAll();
reply->deleteLater();
if (!netOk || data.isEmpty() || !Utils::validateJSON(data)) {
qWarning() << "get_info verify: not a JSON-RPC daemon on" << this->rpcAddress();
return false;
}
QJsonObject obj = QJsonDocument::fromJson(data).object();
if (obj.value("status").toString() != "OK") { // 1. real wownerod RPC
qWarning() << "get_info verify: bad status" << obj.value("status").toString();
return false;
}
const QString wantNet = constants::networkType == NetworkType::MAINNET ? "mainnet" // 2. nettype
: constants::networkType == NetworkType::TESTNET ? "testnet"
: "stagenet";
if (obj.value("nettype").toString() != wantNet) {
qWarning() << "get_info verify: wrong nettype" << obj.value("nettype").toString()
<< "(want" << wantNet << ")";
return false;
}
const quint64 height = obj.value("height").toVariant().toULongLong(); // 3. height sanity
if (height == 0) {
qWarning() << "get_info verify: node reports height 0";
return false;
}
quint64 knownHeight = 0;
if (appData()->heights.contains(constants::networkType))
knownHeight = static_cast<quint64>(appData()->heights[constants::networkType]);
const quint64 peers = obj.value("outgoing_connections_count").toVariant().toULongLong()
+ obj.value("incoming_connections_count").toVariant().toULongLong();
const bool progressing = obj.value("busy_syncing").toBool() || obj.value("synchronized").toBool();
if (knownHeight > 0) {
const quint64 slack = 720; // ~1 day of wownero blocks; live-but-lagging is still adoptable
if (height + slack < knownHeight) {
if (peers == 0 && !progressing) { // 4. stuck-orphan signature: behind AND peerless AND idle
qWarning() << "get_info verify: stale/stuck node — height" << height
<< "<< known" << knownHeight << "with 0 peers, not syncing";
return false;
}
qWarning() << "get_info verify: node behind (" << height << "/" << knownHeight
<< ") but live; adopting and letting it catch up";
}
}
if (peers == 0) // soft: a freshly-spawned-but-orphaned node sits at 0 peers for minutes
qWarning() << "get_info verify: adopting node with 0 peers (likely still bootstrapping)";
qDebug() << "get_info verify: OK — nettype" << wantNet << "height" << height << "peers" << peers;
return true;
}
void DaemonManager::setErrorMessage(const QString &msg) { void DaemonManager::setErrorMessage(const QString &msg) {
this->errorMsg = msg; this->errorMsg = msg;
if (!msg.isEmpty()) if (!msg.isEmpty())
+6
View File
@@ -59,6 +59,11 @@ private slots:
private: private:
bool shouldStartDaemon(); bool shouldStartDaemon();
// wowlet: verify an already-open RPC port really is a healthy wownero-mainnet wownerod before
// adopting it, instead of trusting a bare TCP port-open probe. Prevents the wallet from silently
// syncing against a stale/stuck orphan (e.g. left by a prior crash) or a process squatting
// 127.0.0.1:34568. Synchronous, bounded (~1.5s), UI-thread-safe.
bool verifyAdoptableNode();
void setErrorMessage(const QString &msg); void setErrorMessage(const QString &msg);
QProcess *m_process; QProcess *m_process;
@@ -68,6 +73,7 @@ private:
bool m_synced = false; bool m_synced = false;
bool m_stopping = false; bool m_stopping = false;
int m_restarts = 0; int m_restarts = 0;
qint64 m_lastSpawnMs = 0; // wowlet: wall-clock of last spawn — ages out the crash-retry budget
static QPointer<DaemonManager> m_instance; static QPointer<DaemonManager> m_instance;
}; };
+62 -19
View File
@@ -6,6 +6,7 @@
#include <QCoreApplication> #include <QCoreApplication>
#include <QDirIterator> #include <QDirIterator>
#include "utils/ChildProcessGuard.h" // wowlet: OS-level die-with-parent for bundled tor
#include "utils/config.h" #include "utils/config.h"
#include "utils/Utils.h" #include "utils/Utils.h"
#include "utils/os/tails.h" #include "utils/os/tails.h"
@@ -37,15 +38,26 @@ TorManager::TorManager(QObject *parent)
connect(m_process, &QProcess::readyReadStandardOutput, this, &TorManager::handleProcessOutput); connect(m_process, &QProcess::readyReadStandardOutput, this, &TorManager::handleProcessOutput);
connect(m_process, &QProcess::errorOccurred, this, &TorManager::handleProcessError); connect(m_process, &QProcess::errorOccurred, this, &TorManager::handleProcessError);
connect(m_process, &QProcess::stateChanged, this, &TorManager::stateChanged); connect(m_process, &QProcess::stateChanged, this, &TorManager::stateChanged);
ChildProcessGuard::installPreStart(m_process); // wowlet: SIGKILL tor if wowlet dies (Unix)
connect(QCoreApplication::instance(), &QCoreApplication::aboutToQuit, this, [this]{ this->stop(); }); // wowlet
} }
QPointer<TorManager> TorManager::m_instance(nullptr); QPointer<TorManager> TorManager::m_instance(nullptr);
void TorManager::init() { void TorManager::init() {
// wowlet: a live proxy/useLocalTor toggle re-runs init(). Reset failure/retry state so switching
// system->bundled Tor after a prior failure (Linux unpack, or a FailedToStart that latched
// m_stopRetries in handleProcessError) can actually start again instead of staying silently dead.
m_restarts = 0;
m_stopRetries = false;
m_unpacked = false; // re-evaluate the unpack on the next start()
m_localTor = !shouldStartTorDaemon(); m_localTor = !shouldStartTorDaemon();
auto state = m_process->state(); auto state = m_process->state();
if (m_localTor && (state == QProcess::ProcessState::Running || state == QProcess::ProcessState::Starting)) { if (m_localTor && (state == QProcess::ProcessState::Running || state == QProcess::ProcessState::Starting)) {
m_stopRetries = true; // wowlet: intentional kill (switching to system Tor); don't auto-restart it
m_process->kill(); m_process->kill();
m_started = false; m_started = false;
} }
@@ -54,6 +66,8 @@ void TorManager::init() {
} }
void TorManager::stop() { void TorManager::stop() {
m_stopRetries = true; // wowlet: intentional stop — suppress the stateChanged() auto-restart
m_checkConnectionTimer->stop(); // wowlet: stop probing the connection after an intentional stop
m_process->kill(); m_process->kill();
m_started = false; m_started = false;
} }
@@ -82,6 +96,17 @@ void TorManager::start() {
this->torPath = alternativeTorFile; this->torPath = alternativeTorFile;
} }
// wowlet: never hand QProcess a non-existent path (the Linux "empty folder" symptom). If the bundled
// binary isn't on disk, degrade to local/external-Tor mode + surface an error instead of burning the
// 4-retry budget on FailedToStart. With clearnet sync, node sync is unaffected; only broadcast-over-Tor
// is lost, which DaemonManager tolerates via clearnet relay.
if (!QFileInfo(this->torPath).isFile()) {
this->setErrorMessage("Bundled Tor binary not found; using external/local Tor if available: " + this->torPath);
m_localTor = true;
this->checkConnection();
return;
}
qDebug() << QString("Start process: %1").arg(this->torPath); qDebug() << QString("Start process: %1").arg(this->torPath);
m_restarts += 1; m_restarts += 1;
@@ -102,6 +127,7 @@ void TorManager::start() {
qDebug() << QString("%1 %2").arg(this->torPath, arguments.join(" ")); qDebug() << QString("%1 %2").arg(this->torPath, arguments.join(" "));
m_process->start(this->torPath, arguments); m_process->start(this->torPath, arguments);
ChildProcessGuard::adoptAfterStart(m_process); // wowlet: bind to kill-on-crash job (Windows)
m_started = true; m_started = true;
} }
@@ -201,37 +227,54 @@ bool TorManager::unpackBins() {
return true; return true;
} }
SemanticVersion embeddedVersion = SemanticVersion::fromString(QString(TOR_VERSION)); // wowlet: QFile::copy never creates parent dirs; without this the copy loop can silently no-op on
SemanticVersion filesystemVersion = this->getVersion(torPath); // a fresh profile, leaving an empty tor/ dir and "tor failed to start" (Lucas/Fedora). main.cpp
qDebug() << QString("Tor versions: embedded %1, filesystem %2").arg(embeddedVersion.toString(), filesystemVersion.toString()); // also mkpaths this, but make unpackBins() self-sufficient. Mirrors DaemonManager::unpackBins().
if (SemanticVersion::isValid(filesystemVersion) && (embeddedVersion > filesystemVersion)) { QDir().mkpath(this->torDir);
qInfo() << "Embedded version is newer, overwriting.";
QFile::setPermissions(torPath, QFile::ReadOther | QFile::WriteOther);
if (!QFile::remove(torPath)) {
qWarning() << "Unable to remove old Tor binary";
return false;
}
}
if (embeddedVersion > filesystemVersion) { // wowlet: unpack when the binary is MISSING, not only when a version compare says "newer". Overwrite
// only when the embedded build is a VALID newer version (getVersion() of a non-existent file returns
// an invalid SemanticVersion, so it must not gate the first unpack).
bool present = QFileInfo(this->torPath).isFile();
SemanticVersion embeddedVersion = SemanticVersion::fromString(QString(TOR_VERSION));
SemanticVersion filesystemVersion = present ? this->getVersion(this->torPath) : SemanticVersion();
qDebug() << QString("Tor versions: embedded %1, filesystem %2").arg(embeddedVersion.toString(), filesystemVersion.toString());
bool needsOverwrite = present && SemanticVersion::isValid(filesystemVersion)
&& (embeddedVersion > filesystemVersion);
if (!present || needsOverwrite) {
if (needsOverwrite) {
qInfo() << "Embedded Tor is newer, overwriting.";
QFile::setPermissions(this->torPath, QFile::ReadOther | QFile::WriteOther);
QFile::remove(this->torPath);
}
QDirIterator it(":/assets/tor", QDirIterator::Subdirectories); QDirIterator it(":/assets/tor", QDirIterator::Subdirectories);
while (it.hasNext()) { while (it.hasNext()) {
QString assetFile = it.next(); QString assetFile = it.next();
QFileInfo assetFileInfo = QFileInfo(assetFile); QString filePath = QDir(this->torDir).filePath(QFileInfo(assetFile).fileName());
QFile f(assetFile); QFile f(assetFile);
QString filePath = QDir(this->torDir).filePath(assetFileInfo.fileName());
f.copy(filePath); f.copy(filePath);
f.close(); f.close();
} }
qInfo() << "Wrote Tor binaries to: " << this->torDir; qInfo() << "Wrote Tor binaries to:" << this->torDir;
} }
#if defined(Q_OS_UNIX) #if defined(Q_OS_UNIX)
QFile tor(this->torPath); // wowlet: only chmod if the binary actually landed.
tor.setPermissions(QFile::ExeUser | QFile::ExeGroup | QFile::ExeOther if (QFileInfo(this->torPath).isFile()) {
| QFile::ReadOwner | QFile::ReadGroup | QFile::ReadOther); QFile tor(this->torPath);
tor.setPermissions(QFile::ExeUser | QFile::ExeGroup | QFile::ExeOther
| QFile::ReadOwner | QFile::ReadGroup | QFile::ReadOther);
}
#endif #endif
// wowlet: do NOT report success if nothing landed — return false so shouldStartTorDaemon() falls
// back to --use-local-tor cleanly (see :297-303) instead of start()ing a missing binary.
if (!QFileInfo(this->torPath).isFile()) {
qWarning() << "Tor was not unpacked (no embedded Tor, or copy failed):" << this->torPath;
return false;
}
m_unpacked = true; m_unpacked = true;
return true; return true;
} }
@@ -310,7 +353,7 @@ bool TorManager::shouldStartTorDaemon() {
SemanticVersion TorManager::getVersion(const QString &fileName) { SemanticVersion TorManager::getVersion(const QString &fileName) {
QProcess process; QProcess process;
process.setProcessChannelMode(QProcess::MergedChannels); process.setProcessChannelMode(QProcess::MergedChannels);
process.start(this->torPath, QStringList() << "--version"); process.start(fileName, QStringList() << "--version"); // wowlet: use the passed path, not this->torPath
process.waitForFinished(-1); process.waitForFinished(-1);
QString output = process.readAllStandardOutput(); QString output = process.readAllStandardOutput();
+5 -1
View File
@@ -30,6 +30,7 @@ static const QHash<Config::ConfigKey, ConfigDirective> configStrings = {
{Config::homeWidget,{QS("homeWidget"), "ccs"}}, {Config::homeWidget,{QS("homeWidget"), "ccs"}},
{Config::donateBeg,{QS("donateBeg"), 1}}, {Config::donateBeg,{QS("donateBeg"), 1}},
{Config::showHistorySyncNotice, {QS("showHistorySyncNotice"), true}}, {Config::showHistorySyncNotice, {QS("showHistorySyncNotice"), true}},
{Config::reducedMotion, {QS("reducedMotion"), false}},
{Config::geometry, {QS("geometry"), {}}}, {Config::geometry, {QS("geometry"), {}}},
{Config::windowState, {QS("windowState"), {}}}, {Config::windowState, {QS("windowState"), {}}},
@@ -115,7 +116,10 @@ static const QHash<Config::ConfigKey, ConfigDirective> configStrings = {
// Tor // Tor
{Config::proxy, {QS("proxy"), Config::Proxy::Tor}}, {Config::proxy, {QS("proxy"), Config::Proxy::Tor}},
{Config::torPrivacyLevel, {QS("torPrivacyLevel"), 1}}, // wowlet: allTorExceptNode(0) — NEVER sync blocks over Tor. Block sync is ALWAYS clearnet (speed,
// the standard monero model). Tor is ONLY for tx broadcast (via DaemonManager's --tx-proxy), which
// is independent of this. This is what makes remote nodes work with OR without Tor.
{Config::torPrivacyLevel, {QS("torPrivacyLevel"), 0}},
{Config::torOnlyAllowOnion, {QS("torOnlyAllowOnion"), false}}, {Config::torOnlyAllowOnion, {QS("torOnlyAllowOnion"), false}},
{Config::socks5Host, {QS("socks5Host"), "127.0.0.1"}}, {Config::socks5Host, {QS("socks5Host"), "127.0.0.1"}},
{Config::socks5Port, {QS("socks5Port"), "9050"}}, {Config::socks5Port, {QS("socks5Port"), "9050"}},
+1
View File
@@ -29,6 +29,7 @@ public:
homeWidget, homeWidget,
donateBeg, donateBeg,
showHistorySyncNotice, showHistorySyncNotice,
reducedMotion, // wowlet: disable playful animations (accessibility / preference)
geometry, geometry,
windowState, windowState,
+9
View File
@@ -330,6 +330,15 @@ void Nodes::autoConnect(bool forceReconnect) {
this->updateModels(); this->updateModels();
} }
// wowlet: clear the recent-failure blocklist then force a reconnect. autoConnect(true) alone is not
// enough — pickEligibleNode() still filters the failed node out of m_recentFailures, so a node that
// dropped during a transient Tor/proxy outage would stay blocklisted ("permanent Disconnected"). The
// node/Tor live-toggle handlers call this to recover.
void Nodes::reconnect() {
m_recentFailures.clear();
this->autoConnect(true);
}
FeatherNode Nodes::pickEligibleNode() { FeatherNode Nodes::pickEligibleNode() {
// Pick a node at random to connect to // Pick a node at random to connect to
auto rtn = FeatherNode(); auto rtn = FeatherNode();
+3
View File
@@ -173,6 +173,9 @@ public slots:
void onNodeSourceChanged(NodeSource nodeSource); void onNodeSourceChanged(NodeSource nodeSource);
void setCustomNodes(const QList<FeatherNode>& nodes); void setCustomNodes(const QList<FeatherNode>& nodes);
void autoConnect(bool forceReconnect = false); void autoConnect(bool forceReconnect = false);
// wowlet: clear the recent-failure blocklist and force-reconnect. Used by the node/Tor live-toggle
// handlers so a node blocklisted during a transient outage (e.g. a Tor toggle) is actually retried.
void reconnect();
private slots: private slots:
void onWalletRefreshed(); void onWalletRefreshed();
+13 -1
View File
@@ -3,6 +3,8 @@
#include "MiningWidget.h" #include "MiningWidget.h"
#include "utils/nodes.h"
#include <algorithm> #include <algorithm>
#include <QVBoxLayout> #include <QVBoxLayout>
@@ -11,6 +13,8 @@
#include <QThread> #include <QThread>
#include <QDateTime> #include <QDateTime>
#include "utils/config.h"
static QString formatUptime(qint64 secs) { static QString formatUptime(qint64 secs) {
return QString("%1:%2:%3") return QString("%1:%2:%3")
.arg(secs / 3600, 2, 10, QChar('0')) .arg(secs / 3600, 2, 10, QChar('0'))
@@ -18,9 +22,10 @@ static QString formatUptime(qint64 secs) {
.arg(secs % 60, 2, 10, QChar('0')); .arg(secs % 60, 2, 10, QChar('0'));
} }
MiningWidget::MiningWidget(Wallet *wallet, QWidget *parent) MiningWidget::MiningWidget(Wallet *wallet, Nodes *nodes, QWidget *parent)
: QWidget(parent) : QWidget(parent)
, m_wallet(wallet) , m_wallet(wallet)
, m_nodes(nodes)
, m_quickWidget(new QQuickWidget(this)) , m_quickWidget(new QQuickWidget(this))
, m_backend(new MiningBackend(this)) , m_backend(new MiningBackend(this))
, m_timer(new QTimer(this)) , m_timer(new QTimer(this))
@@ -29,6 +34,7 @@ MiningWidget::MiningWidget(Wallet *wallet, QWidget *parent)
layout->setContentsMargins(0, 0, 0, 0); layout->setContentsMargins(0, 0, 0, 0);
layout->addWidget(m_quickWidget); layout->addWidget(m_quickWidget);
m_backend->setReducedMotion(conf()->get(Config::reducedMotion).toBool());
m_quickWidget->rootContext()->setContextProperty("mining", m_backend); m_quickWidget->rootContext()->setContextProperty("mining", m_backend);
m_quickWidget->setResizeMode(QQuickWidget::SizeRootObjectToView); m_quickWidget->setResizeMode(QQuickWidget::SizeRootObjectToView);
m_quickWidget->setSource(QUrl("qrc:/qml/mining.qml")); m_quickWidget->setSource(QUrl("qrc:/qml/mining.qml"));
@@ -43,6 +49,10 @@ MiningWidget::MiningWidget(Wallet *wallet, QWidget *parent)
} }
void MiningWidget::onStartMining() { 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); int threads = std::max(1, QThread::idealThreadCount() / 2);
if (m_wallet->startMining(threads)) if (m_wallet->startMining(threads))
m_backend->setState(3); m_backend->setState(3);
@@ -54,6 +64,8 @@ void MiningWidget::onStopMining() {
} }
void MiningWidget::poll() { void MiningWidget::poll() {
m_backend->setCanMine(m_nodes && m_nodes->connection().isLocal());
bool mining = m_wallet->isMining(); bool mining = m_wallet->isMining();
m_backend->setState(mining ? 3 : 0); m_backend->setState(mining ? 3 : 0);
+18 -1
View File
@@ -5,6 +5,8 @@
#define WOWLET_MININGWIDGET_H #define WOWLET_MININGWIDGET_H
#include <QWidget> #include <QWidget>
class Nodes;
#include <QTimer> #include <QTimer>
#include <QQuickWidget> #include <QQuickWidget>
@@ -15,13 +17,25 @@
class MiningBackend : public QObject { class MiningBackend : public QObject {
Q_OBJECT Q_OBJECT
Q_PROPERTY(int daemonMiningState READ daemonMiningState NOTIFY daemonMiningStateChanged) 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: public:
explicit MiningBackend(QObject *parent = nullptr) : QObject(parent) {} explicit MiningBackend(QObject *parent = nullptr) : QObject(parent) {}
int daemonMiningState() const { return m_state; } int daemonMiningState() const { return m_state; }
void setState(int s) { if (m_state != s) { m_state = s; emit daemonMiningStateChanged(); } } 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: signals:
void daemonMiningStateChanged(); void daemonMiningStateChanged();
void reducedMotionChanged();
void canMineChanged();
void daemonOutput(const QString &line); void daemonOutput(const QString &line);
void syncStatus(int from, int to, int pct); void syncStatus(int from, int to, int pct);
void uptimeChanged(const QString &uptime); void uptimeChanged(const QString &uptime);
@@ -29,6 +43,8 @@ signals:
private: private:
int m_state = 0; // 0:idle 1:startup 2:syncing 3:mining 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 // wowlet: the pixel-art mining tab. Hosts mining.qml and wires its pick-axe to the wallet's solo
@@ -36,7 +52,7 @@ private:
class MiningWidget : public QWidget { class MiningWidget : public QWidget {
Q_OBJECT Q_OBJECT
public: public:
explicit MiningWidget(Wallet *wallet, QWidget *parent = nullptr); explicit MiningWidget(Wallet *wallet, Nodes *nodes, QWidget *parent = nullptr);
private slots: private slots:
void onStartMining(); void onStartMining();
@@ -45,6 +61,7 @@ private slots:
private: private:
Wallet *m_wallet; Wallet *m_wallet;
Nodes *m_nodes;
QQuickWidget *m_quickWidget; QQuickWidget *m_quickWidget;
MiningBackend *m_backend; MiningBackend *m_backend;
QTimer *m_timer; QTimer *m_timer;
+49
View File
@@ -0,0 +1,49 @@
// SPDX-License-Identifier: BSD-3-Clause
// SPDX-FileCopyrightText: The Monero Project
#include "SceneWidget.h"
#include <QQmlContext>
#include <QQuickItem>
#include "utils/config.h"
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() {
m_backend->setReducedMotion(conf()->get(Config::reducedMotion).toBool());
}
+76
View File
@@ -0,0 +1,76 @@
// SPDX-License-Identifier: BSD-3-Clause
// SPDX-FileCopyrightText: The Monero Project
#ifndef WOWLET_SCENEWIDGET_H
#define WOWLET_SCENEWIDGET_H
#include <QObject>
#include <QString>
#include <QQuickWidget>
// Context object exposed to scene.qml as `sceneCtx`. Carries the reduced-motion
// setting so the scene freezes into its still (reduced-motion) form, and the
// sky variant so the tabs are not all showing the same field.
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
// reduced-motion preference from Config. Drop it behind a form, into a banner,
// or over the window as an idle screensaver.
class SceneWidget : public QQuickWidget {
Q_OBJECT
public:
// Sky variants, one per host, so History/Send/Receive do not all show the
// same field. Must match the `skies` table in scene.qml.
enum Variant { Dusk = 0, Dawn = 1, Night = 2 };
// 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;
};
#endif //WOWLET_SCENEWIDGET_H
+9
View File
@@ -9,6 +9,7 @@
#include "utils/Utils.h" #include "utils/Utils.h"
#include "libwalletqt/Wallet.h" #include "libwalletqt/Wallet.h"
#include "widgets/SceneWidget.h"
WalletUnlockWidget::WalletUnlockWidget(QWidget *parent, Wallet *wallet) WalletUnlockWidget::WalletUnlockWidget(QWidget *parent, Wallet *wallet)
: QWidget(parent) : QWidget(parent)
@@ -18,6 +19,14 @@ WalletUnlockWidget::WalletUnlockWidget(QWidget *parent, Wallet *wallet)
ui->setupUi(this); ui->setupUi(this);
this->reset(); this->reset();
// wowlet: the lock screen shows the full ambient scene above the unlock form
// (this is the idle "screensaver" — the wallet auto-locks here on inactivity).
// It freezes into a still when "Prefer reduced motion" is set.
auto *scene = new SceneWidget(this);
scene->setMinimumHeight(200);
scene->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
ui->verticalLayout->insertWidget(0, scene, 1);
ui->buttonBox->button(QDialogButtonBox::Ok)->setAutoDefault(true); ui->buttonBox->button(QDialogButtonBox::Ok)->setAutoDefault(true);
connect(ui->buttonBox, &QDialogButtonBox::accepted, this, &WalletUnlockWidget::tryUnlock); connect(ui->buttonBox, &QDialogButtonBox::accepted, this, &WalletUnlockWidget::tryUnlock);
+11
View File
@@ -15,6 +15,10 @@ PageMenu::PageMenu(WizardFields *fields, WalletKeysFilesModel *wallets, QWidget
, m_fields(fields) , m_fields(fields)
{ {
ui->setupUi(this); ui->setupUi(this);
// wowlet: hide the hardware-device option — wownero has no Ledger/Trezor support
ui->radioCreateFromDevice->setVisible(false);
this->setButtonText(QWizard::FinishButton, "Open recent wallet"); this->setButtonText(QWizard::FinishButton, "Open recent wallet");
ui->label_version->setText(QString("wowlet %1 — by wownero.org").arg(FEATHER_VERSION)); ui->label_version->setText(QString("wowlet %1 — by wownero.org").arg(FEATHER_VERSION));
@@ -23,6 +27,13 @@ PageMenu::PageMenu(WizardFields *fields, WalletKeysFilesModel *wallets, QWidget
}); });
QString settingsSkin = conf()->get(Config::skin).toString(); QString settingsSkin = conf()->get(Config::skin).toString();
// wowlet: reduced-motion opt-out, surfaced on first run (unchecked = playful
// animations on, the default). Also lives in Settings → Appearance.
ui->checkBox_reducedMotion->setChecked(conf()->get(Config::reducedMotion).toBool());
connect(ui->checkBox_reducedMotion, &QCheckBox::toggled, [](bool toggled){
conf()->set(Config::reducedMotion, toggled);
});
} }
void PageMenu::initializePage() { void PageMenu::initializePage() {
+7
View File
@@ -192,6 +192,13 @@
</layout> </layout>
</widget> </widget>
</item> </item>
<item>
<widget class="QCheckBox" name="checkBox_reducedMotion">
<property name="text">
<string>Prefer reduced motion (disable playful animations)</string>
</property>
</widget>
</item>
<item> <item>
<layout class="QVBoxLayout" name="verticalLayout_2"> <layout class="QVBoxLayout" name="verticalLayout_2">
<item> <item>