Compare commits

...
Author SHA1 Message Date
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 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
46 changed files with 1222 additions and 112 deletions
+149
View File
@@ -0,0 +1,149 @@
name: linux appimage (gitea self-hosted)
# Runs ONLY on Gitea (GitHub ignores .gitea/workflows), in the private Builds
# repository. Gitea is master; GitHub is a mirror and a release surface.
#
# 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]
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
- name: fetch wownerod to embed
run: |
rm -rf /tmp/wownerod-embed && mkdir -p /tmp/wownerod-embed
curl -fL -o /tmp/wownero.tar.bz2 \
https://github.com/wownero/wownero-codeberg-mirror/releases/download/v0.11.4.0/wownero-x86_64-linux-gnu-v0.11.4.0.tar.bz2
rm -rf /tmp/wow-x && mkdir -p /tmp/wow-x
tar xjf /tmp/wownero.tar.bz2 -C /tmp/wow-x
find /tmp/wow-x -name wownerod -type f -exec cp {} /tmp/wownerod-embed/ \;
chmod +x /tmp/wownerod-embed/wownerod
ls -la /tmp/wownerod-embed
- name: fetch tor to embed
run: |
rm -rf /tmp/tor-embed && mkdir -p /tmp/tor-embed
curl -fL -o /tmp/teb.tgz \
https://archive.torproject.org/tor-package-archive/torbrowser/15.0/tor-expert-bundle-linux-x86_64-15.0.tar.gz
rm -rf /tmp/teb && mkdir -p /tmp/teb && tar xzf /tmp/teb.tgz -C /tmp/teb
cp /tmp/teb/tor/tor /tmp/teb/tor/libssl.so.3 /tmp/teb/tor/libevent-2.1.so.7 \
/tmp/teb/tor/libcrypto.so.3 /tmp/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' /tmp/tor-embed/$f
done
chmod +x /tmp/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.
TORVER=$(/tmp/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=/tmp/wownerod-embed \
-DTOR_DIR=/tmp/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/feather 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: 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
if-no-files-found: error
+89 -10
View File
@@ -35,8 +35,9 @@ jobs:
- name: install dependencies (brew)
run: |
export PATH="/opt/homebrew/bin:$PATH"
export NONINTERACTIVE=1 HOMEBREW_NO_ENV_HINTS=1
brew install --quiet qt libsodium libzip qrencode unbound cmake boost hidapi openssl expat protobuf pkgconfig zxing-cpp
set +o pipefail # yes(1) takes SIGPIPE when brew has nothing to ask
export NONINTERACTIVE=1 HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_INSTALL_UPGRADE=1
yes | brew install --quiet qt libsodium libzip qrencode unbound cmake boost hidapi openssl expat protobuf pkgconfig zxing-cpp
- name: fetch wownerod to embed
run: |
@@ -46,19 +47,48 @@ jobs:
arm64) WOWTAR=wownero-aarch64-apple-darwin11-v0.11.4.0.tar.bz2 ;;
*) WOWTAR=wownero-x86_64-apple-darwin11-v0.11.4.0.tar.bz2 ;;
esac
mkdir -p /tmp/wownerod-embed
mkdir -p /tmp/wownerod-embed /tmp/wow-extract
curl -L -o /tmp/wownero.tar.bz2 \
"https://github.com/wownero/wownero-codeberg-mirror/releases/download/v0.11.4.0/$WOWTAR"
tar xjf /tmp/wownero.tar.bz2 -C /tmp
find /tmp -name wownerod -type f -exec cp {} /tmp/wownerod-embed/ \;
# /tmp is a symlink on macOS — find(1) won't descend it as a bare argument
tar xjf /tmp/wownero.tar.bz2 -C /tmp/wow-extract
find /tmp/wow-extract -name wownerod -type f -exec cp {} /tmp/wownerod-embed/ \;
chmod +x /tmp/wownerod-embed/wownerod
ls -la /tmp/wownerod-embed
- 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 -DWOWNEROD_DIR=/tmp/wownerod-embed ..
cmake -DWITH_SCANNER=OFF -DUSE_DEVICE_TREZOR=OFF -DBoost_USE_STATIC_LIBS=ON -DWOWNEROD_DIR=/tmp/wownerod-embed -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
@@ -75,6 +105,52 @@ jobs:
cp ../src/assets/images/appicons/appicon.icns dist/Wowlet.app/Contents/Resources/
# bundle Qt frameworks/plugins so the app runs off-machine
/opt/homebrew/opt/qt/bin/macdeployqt dist/Wowlet.app
# 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 (skipped when secrets absent)
@@ -97,13 +173,16 @@ jobs:
security set-key-partition-list -S apple-tool:,apple: -s -k "$KCPW" "$KC" >/dev/null
security list-keychains -d user -s "$KC" login.keychain-db
# sign nested Mach-O executables first (embedded wownerod etc.),
# then frameworks, then the bundle — all hardened-runtime + timestamp
# Sign inside-out: every Mach-O NOT inside a .framework (embedded
# wownerod/tor, the bundled webp/sharpyuv/brotli dylibs, and the
# image-format plugins whose signatures install_name_tool just
# invalidated — keyed on `file`, not exec bit, since plugins/dylibs
# aren't +x), then each Qt framework as a unit, then the bundle.
SIGN() { codesign --force --options runtime --timestamp --keychain "$KC" \
--sign "$APPLE_SIGNING_IDENTITY" "$@"; }
find dist/Wowlet.app -type f -perm +111 ! -path "*/Frameworks/*" \
find dist/Wowlet.app -type f ! -path "*/*.framework/*" \
| while read -r f; do file "$f" | grep -q Mach-O && SIGN "$f" || true; done
find dist/Wowlet.app/Contents/Frameworks -maxdepth 1 \( -name "*.framework" -o -name "*.dylib" \) 2>/dev/null \
find dist/Wowlet.app/Contents/Frameworks -maxdepth 1 -name "*.framework" 2>/dev/null \
| while read -r fw; do SIGN "$fw"; done
SIGN dist/Wowlet.app
codesign --verify --deep --strict dist/Wowlet.app
+268
View File
@@ -0,0 +1,268 @@
name: windows build + sign (gitea self-hosted)
# Runs ONLY on Gitea (GitHub ignores .gitea/workflows), in the private Builds
# repository. Gitea is master; GitHub is a mirror and a release surface.
#
# 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]
env:
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
# 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 wownerod.exe to embed
run: |
rm -rf /tmp/wownerod-embed && mkdir -p /tmp/wownerod-embed
curl -fL -o /tmp/wownero-win.zip \
https://github.com/wownero/wownero-codeberg-mirror/releases/download/v0.11.4.0/wownero-x86_64-w64-mingw32-v0.11.4.0.zip
# -j flattens the versioned subdir so wownerod.exe is a DIRECT child
# (WownerodQrcGenerator globs ${WOWNEROD_DIR}/* non-recursively).
unzip -o -j /tmp/wownero-win.zip 'wownero-x86_64-w64-mingw32-v0.11.4.0/wownerod.exe' -d /tmp/wownerod-embed
ls -la /tmp/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/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/feather.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: 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: download unsigned staging tree
uses: actions/download-artifact@v3
with:
name: wowlet-x86_64-windows-unsigned
path: stage
- 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: 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
if-no-files-found: error
+2
View File
@@ -14,9 +14,11 @@ feather_autogen/
feather.cbp
src/assets_tor.qrc
src/assets_docs.qrc
src/assets_wownerod.qrc
feather.AppDir/*
src/assets/tor/*
src/assets/docs/*
src/assets/wownerod/*
!src/assets/tor/.gitkeep
!src/assets/docs/.gitkeep
contrib/installers/windows/setup.nsi
+9 -5
View File
@@ -1,7 +1,7 @@
cmake_minimum_required(VERSION 3.18)
project(feather
VERSION "5.0.3"
VERSION "5.1.1"
DESCRIPTION "A free Monero desktop wallet"
LANGUAGES CXX C ASM
)
@@ -148,15 +148,19 @@ if(TOR_DIR)
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
if (REPRODUCIBLE)
set(TOR_COPY_CMD "cp -a ${TOR_DIR}/* ${CMAKE_CURRENT_SOURCE_DIR}/src/assets/tor")
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()
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}")
endif()
@@ -172,11 +176,11 @@ if(WOWNEROD_DIR)
if (REPRODUCIBLE)
set(WOWNEROD_COPY_CMD "cp -a ${WOWNEROD_DIR}/* ${CMAKE_CURRENT_SOURCE_DIR}/src/assets/wownerod")
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()
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}")
endif()
+1 -1
View File
@@ -34,7 +34,7 @@ if (CHECK_UPDATES)
add_subdirectory(openpgp)
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)
file(GLOB SOURCE_FILES
+8
View File
@@ -14,6 +14,7 @@
#include "utils/config.h"
#include "utils/Icons.h"
#include "WebsocketNotifier.h"
#include "widgets/SceneWidget.h"
HistoryWidget::HistoryWidget(Wallet *wallet, QWidget *parent)
: QWidget(parent)
@@ -24,6 +25,13 @@ HistoryWidget::HistoryWidget(Wallet *wallet, QWidget *parent)
, m_model(wallet->historyModel())
{
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->addAction(icons()->icon("info2.svg"), "Show details", this, &HistoryWidget::showTxDetails);
m_contextMenu->addAction("View on block explorer", this, &HistoryWidget::onViewOnBlockExplorer);
+112
View File
@@ -542,6 +542,24 @@ void MainWindow::initWalletContext() {
connect(m_wallet, &Wallet::deviceError, this, &MainWindow::onDeviceError);
connect(m_wallet, &Wallet::multiBroadcast, this, &MainWindow::onMultiBroadcast);
// wowlet: count multi-broadcast relay results so a total failure (Tor not
// bootstrapped, every node unreachable) is surfaced instead of silent.
connect(m_rpc, &DaemonRpc::ApiResponse, this, [this](const DaemonRpc::DaemonResponse &resp){
if (resp.endpoint == DaemonRpc::Endpoint::SEND_RAW_TRANSACTION && resp.ok) {
m_broadcastBatchSuccesses++;
}
});
// wowlet: when the embedded daemon reports synced after a (re)start,
// re-announce any still-pending outgoing txs; a restarted pool has
// forgotten them.
connect(daemonManager(), &DaemonManager::synced, this, &MainWindow::relayPendingTransactions);
// wowlet: periodic local check for outgoing txs stuck unconfirmed.
m_stuckTxTimer = new QTimer(this);
connect(m_stuckTxTimer, &QTimer::timeout, this, &MainWindow::checkStuckTransactions);
m_stuckTxTimer->start(2 * 60 * 1000);
}
void MainWindow::menuToggleTabVisible(const QString &key){
@@ -760,6 +778,7 @@ void MainWindow::onSubtractFeeFromAmountEnabled(bool enabled) {
}
void MainWindow::onMultiBroadcast(const QMap<QString, QString> &txHexMap) {
m_broadcastBatchSuccesses = 0;
QMapIterator<QString, QString> i(txHexMap);
while (i.hasNext()) {
i.next();
@@ -770,6 +789,99 @@ void MainWindow::onMultiBroadcast(const QMap<QString, QString> &txHexMap) {
m_rpc->sendRawTransaction(i.value());
}
}
// wowlet: if not a single node accepted the relay, say so. The commit to
// the local daemon may still confirm on its own, but when Tor is down and
// the daemon runs with an exclusive --tx-proxy, nothing will ever leave
// this machine; the user should know within a minute, not after two days.
const QStringList txids = txHexMap.keys();
QTimer::singleShot(45 * 1000, this, [this, txids]{
if (m_broadcastBatchSuccesses > 0 || txids.isEmpty()) {
return;
}
QMessageBox box(this);
box.setWindowTitle("Transaction relay may have failed");
box.setText("No node accepted the transaction broadcast. If Tor is "
"still starting (or unavailable), the transaction may not "
"reach the network at all.");
box.setInformativeText("Rebroadcasting later is always safe: a "
"transaction can never be spent twice.");
QPushButton *retry = box.addButton("Rebroadcast now", QMessageBox::YesRole);
box.addButton("Later", QMessageBox::RejectRole);
box.exec();
if (box.clickedButton() == retry) {
this->onResendTransaction(txids[0]);
}
});
}
void MainWindow::checkStuckTransactions() {
// wowlet: a pending outgoing tx unconfirmed for this long has, in
// practice, not reached the network (Tor down at send time, embedded
// node restarted mid-relay). Local history scan only; no network calls,
// and no txid is ever looked up against any external service. The remedy
// reuses the existing rebroadcast path, which reconnects to a fresh node
// first.
constexpr qint64 kStuckSecs = 25 * 60;
if (m_wallet == nullptr || m_wallet->history() == nullptr) {
return;
}
TransactionHistory *history = m_wallet->history();
for (quint64 i = 0; i < history->count(); i++) {
const TransactionRow &row = history->transaction(static_cast<int>(i));
if (!row.pending || row.direction == TransactionRow::Direction_In) {
continue;
}
if (row.timestamp.secsTo(QDateTime::currentDateTime()) < kStuckSecs) {
continue;
}
if (m_stuckTxWarned.contains(row.hash)) {
continue;
}
m_stuckTxWarned.insert(row.hash);
QMessageBox box(this);
box.setWindowTitle("Transaction appears stuck");
box.setText(QString("An outgoing transaction has been unconfirmed for "
"more than %1 minutes:\n\n%2")
.arg(kStuckSecs / 60).arg(row.hash));
box.setInformativeText("It may never have reached the network. "
"Rebroadcasting is safe: a transaction can "
"never be spent twice, and your change stays "
"locked until this either confirms or fails.");
QPushButton *retry = box.addButton("Rebroadcast now", QMessageBox::YesRole);
box.addButton("Later", QMessageBox::RejectRole);
box.exec();
if (box.clickedButton() == retry) {
this->onResendTransaction(row.hash);
}
}
}
void MainWindow::relayPendingTransactions() {
// wowlet: rebuild the multi-broadcast map from the wallet's own tx cache
// for every pending outgoing tx and re-announce it. Strictly local
// sources; the announcement itself goes through the normal (Tor-routed)
// broadcast path.
if (m_wallet == nullptr || m_wallet->history() == nullptr) {
return;
}
TransactionHistory *history = m_wallet->history();
QMap<QString, QString> txHexMap;
for (quint64 i = 0; i < history->count(); i++) {
const TransactionRow &row = history->transaction(static_cast<int>(i));
if (!row.pending || row.direction == TransactionRow::Direction_In) {
continue;
}
QString txHex = m_wallet->getCacheTransaction(row.hash);
if (!txHex.isEmpty()) {
txHexMap[row.hash] = txHex;
}
}
if (txHexMap.isEmpty()) {
return;
}
qDebug() << QString("Re-relaying %1 pending transaction(s) after daemon sync").arg(txHexMap.size());
this->onMultiBroadcast(txHexMap);
}
void MainWindow::onSyncStatus(quint64 height, quint64 target, bool daemonSync) {
+14
View File
@@ -51,6 +51,9 @@ namespace Ui {
class MainWindow;
}
#include <QSet>
#include <QTimer>
class ToggleTab : QObject {
Q_OBJECT
@@ -214,12 +217,23 @@ private:
QIcon hardwareDevicePairedIcon();
QIcon hardwareDeviceUnpairedIcon();
// wowlet: stuck-transaction detection and pending re-relay. Both are
// strictly local: history rows and the wallet's own tx cache; no txid is
// ever queried against any external service.
void checkStuckTransactions();
void relayPendingTransactions();
QScopedPointer<Ui::MainWindow> ui;
WindowManager *m_windowManager;
Wallet *m_wallet = nullptr;
Nodes *m_nodes;
DaemonRpc *m_rpc;
// wowlet: see checkStuckTransactions / relayPendingTransactions
QTimer *m_stuckTxTimer = nullptr;
QSet<QString> m_stuckTxWarned;
int m_broadcastBatchSuccesses = 0;
SplashDialog *m_splashDialog = nullptr;
AccountSwitcherDialog *m_accountSwitcherDialog = nullptr;
TxPoolViewerDialog *m_txPoolViewerDialog = nullptr;
+8
View File
@@ -12,6 +12,7 @@
#include "utils/config.h"
#include "utils/Icons.h"
#include "utils/Utils.h"
#include "widgets/SceneWidget.h"
ReceiveWidget::ReceiveWidget(Wallet *wallet, QWidget *parent)
: QWidget(parent)
@@ -20,6 +21,13 @@ ReceiveWidget::ReceiveWidget(Wallet *wallet, QWidget *parent)
{
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);
scene->setMinimumHeight(110);
scene->setMaximumHeight(130);
ui->verticalLayout->addWidget(scene);
m_model = m_wallet->subaddressModel();
m_proxyModel = new SubaddressProxyModel(this, m_wallet->subaddress());
m_proxyModel->setSourceModel(m_model);
+9
View File
@@ -11,6 +11,7 @@
#include "Icons.h"
#include "libwalletqt/Wallet.h"
#include "libwalletqt/WalletManager.h"
#include "widgets/SceneWidget.h"
#if defined(WITH_SCANNER)
#include "wizard/offline_tx_signing/OfflineTxSigningWizard.h"
@@ -25,6 +26,14 @@ SendWidget::SendWidget(Wallet *wallet, QWidget *parent)
{
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.
auto *scene = new SceneWidget(this, SceneWidget::Night);
scene->setMinimumHeight(160);
scene->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
ui->verticalLayoutOuter->addWidget(scene, 1);
QString amount_rx = R"(^\d{0,8}[\.,]\d{0,12}|(all)$)";
QRegularExpression rx;
rx.setPattern(amount_rx);
+21 -2
View File
@@ -11,7 +11,7 @@
</rect>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Maximum">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
@@ -19,7 +19,24 @@
<property name="windowTitle">
<string>Form</string>
</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">
<enum>QLayout::SizeConstraint::SetMinimumSize</enum>
</property>
@@ -274,6 +291,8 @@
</item>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<customwidgets>
+6
View File
@@ -139,6 +139,12 @@ void Settings::setupAppearanceTab() {
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]
QStringList availableFiatCurrencies = appData()->prices.rates.keys();
for (const auto &currency : availableFiatCurrencies) {
+7
View File
@@ -159,6 +159,13 @@
</property>
</widget>
</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>
</item>
</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

+76 -92
View File
@@ -35,6 +35,7 @@ Rectangle {
}
AnimatedImage {
playing: !mining.reducedMotion // wowlet: respect reduced-motion
//visible: mining.daemonMiningState === 0
source: "qrc:/mining/bg1.gif"
fillMode: Image.PreserveAspectCrop
@@ -91,7 +92,9 @@ Rectangle {
RowLayout {
spacing: 0
Layout.fillWidth: true
Layout.fillHeight: false
Layout.preferredHeight: 128
Layout.maximumHeight: 128
Image {
source: "qrc:/mining/topleft.png"
@@ -134,10 +137,12 @@ Rectangle {
Text {
id: hashRateText
text: "-"
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.fill: parent
anchors.leftMargin: 4
font.pointSize: 16
verticalAlignment: Text.AlignVCenter
fontSizeMode: Text.HorizontalFit
minimumPointSize: 8
font.pointSize: 14
font.family: comicMono.name;
antialiasing: false
color: "#41FF00"
@@ -160,7 +165,7 @@ Rectangle {
Layout.fillHeight: true
Text {
text: "uptime"
text: "Uptime"
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: 4
@@ -179,10 +184,12 @@ Rectangle {
Text {
id: miningUptime
text: "-"
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.fill: parent
anchors.leftMargin: 4
font.pointSize: 12
verticalAlignment: Text.AlignVCenter
fontSizeMode: Text.HorizontalFit
minimumPointSize: 8
font.pointSize: 14
font.family: comicMono.name;
antialiasing: false
color: "#41FF00"
@@ -191,6 +198,7 @@ Rectangle {
}
AnimatedImage {
playing: !mining.reducedMotion // wowlet: respect reduced-motion
visible: mining.daemonMiningState !== 0
source: "qrc:/mining/mining.webp"
fillMode: Image.PreserveAspectCrop
@@ -206,7 +214,9 @@ Rectangle {
ColumnLayout {
Layout.fillWidth: true
Layout.fillHeight: false
Layout.preferredHeight: 128
Layout.maximumHeight: 128
spacing: 0
Image {
@@ -227,126 +237,98 @@ Rectangle {
RowLayout {
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.minimumHeight: 102
Layout.maximumHeight: 102
Image {
Layout.preferredWidth: 5
Layout.preferredHeight: parent.height
Layout.preferredHeight: 102 // console bezel is a fixed 102px tall
source: "qrc:/mining/topright_left.png"
smooth: false
}
Image {
Layout.fillWidth: true
Layout.preferredHeight: parent.height
Layout.preferredHeight: 102 // console bezel is a fixed 102px tall
source: "qrc:/mining/topright_middle.png"
fillMode: Image.TileHorizontally
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.topMargin: 16
anchors.left: parent.left
anchors.leftMargin: 6
anchors.leftMargin: 14
anchors.right: parent.right
anchors.rightMargin: 8
anchors.topMargin: 4
height: 52
spacing: 24
height: 60
spacing: 16
Column {
width: Math.min(260, (parent.width - parent.spacing) / 2)
spacing: 2
ColumnLayout {
Layout.minimumWidth: 200
Layout.maximumWidth: 260
Layout.fillHeight: true
Rectangle {
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
}
Text {
text: "Block Height"
font.pointSize: 14
font.family: comicMonoBold.name;
color: "#41FF00"
antialiasing: false
}
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
color: "transparent"
Text {
id: heightText
text: "-"
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: 8
font.pointSize: 18
font.family: comicMonoBold.name;
color: "#41FF00"
antialiasing: false
}
Text {
id: heightText
text: "-"
width: parent.width
fontSizeMode: Text.HorizontalFit
minimumPointSize: 9
font.pointSize: 14
font.family: comicMonoBold.name;
color: "#41FF00"
antialiasing: false
}
}
ColumnLayout {
Layout.minimumWidth: 200
Layout.maximumWidth: 260
Layout.fillHeight: true
Column {
width: Math.min(260, (parent.width - parent.spacing) / 2)
spacing: 2
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
color: "transparent"
Text {
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
}
Text {
text: "Sync Progress"
font.pointSize: 14
font.family: comicMonoBold.name;
color: "#41FF00"
antialiasing: false
}
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
color: "transparent"
Text {
id: syncPctText
text: "-"
anchors.verticalCenter: parent.verticalCenter
anchors.horizontalCenter: parent.horizontalCenter
font.pointSize: 18
font.family: comicMonoBold.name;
color: "#41FF00"
antialiasing: false
}
Text {
id: syncPctText
text: "-"
width: parent.width
fontSizeMode: Text.HorizontalFit
minimumPointSize: 9
font.pointSize: 14
font.family: comicMonoBold.name;
color: "#41FF00"
antialiasing: false
}
}
Item {
Layout.fillHeight: true
Layout.fillWidth: true
}
}
}
Image {
Layout.preferredWidth: 5
Layout.preferredHeight: parent.height
Layout.preferredHeight: 102 // console bezel is a fixed 102px tall
source: "qrc:/mining/topright_right.png"
smooth: false
}
@@ -428,6 +410,7 @@ Rectangle {
smooth: false
AnimatedImage {
playing: !mining.reducedMotion // wowlet: respect reduced-motion
source: mining.daemonMiningState === 0 ? "qrc:/assets/images/dog_sitting.gif" : "qrc:/assets/images/dog_running.gif"
width: 80
height: 60
@@ -538,6 +521,7 @@ Rectangle {
}
AnimatedImage {
playing: !mining.reducedMotion // wowlet: respect reduced-motion
visible: mining.daemonMiningState !== 0
source: "qrc:/mining/elmo.gif"
width: 106
+290
View File
@@ -0,0 +1,290 @@
import QtQuick
// wowlet ambient scene — a doge running a field of WOW coins under a big sky.
// All motion gates on `scene.reducedMotion` (exposed by the host SceneBackend);
// frozen it is the reduced-motion still.
//
// One cycle is three beats, so the scene breathes instead of running flat out:
//
// 1. an empty field for 3-5s (nothing but sky and grass)
// 2. the coins pop in, one at a time
// 3. the doge and the money go by, and the doge collects the coins
//
// Every lap re-rolls its own timing — how long the field stays empty, when the
// money enters relative to the doge and how fast it crosses — so the loop does
// not read as the same eight seconds forever. Roughly one lap in six, the doge
// eats it halfway down the field and the lap ends in GAME OVER with the rest of
// the coins uncollected.
//
// Motion is driven by a unitless 0..1 `runProgress` rather than by animating
// straight to `scene.width`: an animation whose `to:` is bound to the width
// restarts every time the host widget resizes, which showed up as a slow first
// lap that snapped back to the start once the real width arrived.
Rectangle {
id: scene
property bool reducedMotion: (typeof sceneCtx !== 'undefined' && sceneCtx) ? sceneCtx.reducedMotion : false
// 0 dusk · 1 dawn · 2 night — so the tabs are not all the same field.
property int variant: (typeof sceneCtx !== 'undefined' && sceneCtx) ? sceneCtx.variant : 0
// --- sky ----------------------------------------------------------------
readonly property var skies: [
["#160d2b", "#5b2f6e", "#c96aa0", "#f2adc9"], // dusk
["#123a63", "#2f6fa8", "#e8a06a", "#ffd9a8"], // dawn
["#04060f", "#0d1330", "#1d2450", "#39406e"] // night
]
readonly property var sky: skies[Math.max(0, Math.min(skies.length - 1, variant))]
readonly property bool night: variant === 2
// --- cycle timing (re-rolled every lap) ---------------------------------
// emptyBeat / moneyDelay / moneyBeat are rolled onto the animations directly
// (see the ScriptAction at the foot of the file).
readonly property int revealStep: 320 // per coin, popping in one by one
readonly property int settleBeat: 500 // coins all out, before the run
readonly property int runBeat: 8000 // the doge crossing
// --- the trip -----------------------------------------------------------
property bool tripLap: false // does the doge eat it this lap?
property real tripAt: 0.55 // ...and how far along
readonly property bool tripped: tripLap && runProgress >= tripAt
// --- ground plane and the doge's run ------------------------------------
readonly property int grassHeight: 46
readonly property int dogWidth: 104
readonly property int dogHeight: 83
readonly property real groundY: scene.height - grassHeight
readonly property real runStart: -dogWidth - 10
readonly property real runEnd: scene.width + 10
property real runProgress: 0.0
property real moneyProgress: 0.0
// A tripped doge stops where he fell instead of sliding on to the far edge.
readonly property real dogProgress: tripped ? tripAt : runProgress
readonly property real dogX: runStart + dogProgress * (runEnd - runStart)
// The doge collects a coin once its snout reaches the coin's centre.
readonly property real snoutX: dogX + dogWidth * 0.78
// --- the coins ----------------------------------------------------------
readonly property int coinSize: 34
readonly property real coinRestY: groundY - coinSize + 8
readonly property int coinCount: Math.max(3, Math.min(7, Math.floor(scene.width / 190)))
// Rises 0 -> coinCount during beat 2; coin `index` is out once it passes index+1.
property real coinReveal: 0.0
gradient: Gradient {
GradientStop { position: 0.0; color: scene.sky[0] }
GradientStop { position: 0.5; color: scene.sky[1] }
GradientStop { position: 0.82; color: scene.sky[2] }
GradientStop { position: 1.0; color: scene.sky[3] }
}
// Stars, night only. Positions are a fixed hash of the index so they do not
// jump around on every repaint.
Repeater {
model: scene.night ? 26 : 0
Rectangle {
width: (index % 4 === 0) ? 3 : 2
height: width
radius: 1
color: "#fdf6d8"
opacity: 0.45 + ((index * 37) % 50) / 100
x: scene.width * (((index * 73) % 100) / 100)
y: scene.height * 0.06 + scene.height * 0.42 * (((index * 41) % 100) / 100)
}
}
// Sun, or a paler moon at night.
Rectangle {
x: scene.width * 0.74
y: scene.height * (scene.night ? 0.10 : 0.14)
width: scene.night ? 46 : 62
height: width
radius: width / 2
color: scene.night ? "#e8ecff" : "#ffe9b0"
opacity: scene.night ? 0.85 : 0.9
}
// Money crosses on its own roll, so it does not shadow the doge every lap.
AnimatedImage {
source: "qrc:/scene/flyingmoney.gif"
playing: !scene.reducedMotion
width: 60; height: 55
y: Math.max(4, Math.min(scene.height * 0.10, 60))
x: scene.reducedMotion ? scene.width * 0.30
: -80 + scene.moneyProgress * (scene.width + 240)
opacity: 0.9
}
Row {
anchors.bottom: parent.bottom
Repeater {
model: Math.ceil(scene.width / 180) + 1
Image { source: "qrc:/scene/grass.png"; width: 180; height: scene.grassHeight; smooth: false }
}
}
Repeater {
model: scene.coinCount
Item {
id: coin
width: scene.coinSize; height: scene.coinSize
// Spread the coins over the field, clear of both ends of the run.
x: scene.width * (0.22 + 0.66 * (scene.coinCount > 1 ? index / (scene.coinCount - 1) : 0.5)) - width / 2
y: scene.coinRestY
// Both states are derived from the cycle, so a lap needs no reset
// bookkeeping: `coinReveal` dropping to 0 hides them, and the doge
// returning to the left edge un-collects them. A tripped doge never
// reaches the rest, so they are still sitting there at GAME OVER.
readonly property bool revealed: scene.reducedMotion || scene.coinReveal >= index + 1
readonly property bool collected: revealed && !scene.reducedMotion
&& scene.snoutX >= x + width / 2
AnimatedImage {
id: coinImage
width: parent.width; height: parent.height
source: "qrc:/scene/goldcoin.gif"
playing: !scene.reducedMotion && coin.revealed && !coin.collected
opacity: (coin.revealed && !coin.collected) ? 1.0 : 0.0
scale: !coin.revealed ? 0.2 : (coin.collected ? 1.6 : 1.0)
// Idle bob, low over the grass.
SequentialAnimation on y {
running: !scene.reducedMotion && coin.revealed && !coin.collected
loops: Animation.Infinite
NumberAnimation { to: -11; duration: 950 + index * 130; easing.type: Easing.OutQuad }
NumberAnimation { to: 0; duration: 950 + index * 130; easing.type: Easing.InQuad }
}
Behavior on opacity { NumberAnimation { duration: 240 } }
Behavior on scale { NumberAnimation { duration: 300; easing.type: Easing.OutBack } }
}
// Pickup pop.
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: "+1"
color: "#ffd257"
style: Text.Outline
styleColor: "#2a1636"
font.bold: true
font.pixelSize: 18
opacity: 0.0
y: coin.collected ? -44 : -6
Behavior on y { NumberAnimation { duration: 900; easing.type: Easing.OutQuad } }
SequentialAnimation on opacity {
running: coin.collected
NumberAnimation { to: 1.0; duration: 90 }
PauseAnimation { duration: 420 }
NumberAnimation { to: 0.0; duration: 340 }
}
}
}
}
AnimatedImage {
id: doge
source: "qrc:/scene/dog.gif"
playing: !scene.reducedMotion && !scene.tripped
width: scene.dogWidth; height: scene.dogHeight
y: scene.groundY - scene.dogHeight + 21
x: scene.reducedMotion ? 8 : scene.dogX
// Face-plant: tips forward over his own feet rather than sinking
// through the grass, so he ends up nose-down on the ground.
transformOrigin: Item.Bottom
rotation: scene.tripped ? 58 : 0
Behavior on rotation { NumberAnimation { duration: 260; easing.type: Easing.OutQuad } }
}
Text {
anchors.centerIn: parent
text: "GAME OVER"
color: "#ff6b6b"
style: Text.Outline
styleColor: "#1a0b1f"
font.bold: true
font.pixelSize: Math.max(20, Math.min(scene.height * 0.20, 34))
opacity: scene.tripped ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 320 } }
}
// The three beats, on a loop, re-rolled each time round.
//
// This is a timer-driven state machine rather than one big
// SequentialAnimation on purpose: each lap rolls its own durations, and
// assigning a duration to a child of a *running* SequentialAnimation makes
// the group recompute and re-enter its own ScriptAction, which recurses
// until the JS stack blows.
property int beat: 0
NumberAnimation {
id: revealAnim
target: scene; property: "coinReveal"
from: 0.0; to: scene.coinCount
duration: scene.coinCount * scene.revealStep
}
NumberAnimation {
id: runAnim
target: scene; property: "runProgress"
from: 0.0; to: 1.0
duration: scene.runBeat
}
NumberAnimation {
id: moneyAnim
target: scene; property: "moneyProgress"
from: 0.0; to: 1.0
duration: 6000
}
Timer { id: beatTimer; repeat: false; onTriggered: scene.advance() }
Timer { id: moneyTimer; repeat: false; onTriggered: moneyAnim.start() }
// Beat 0 is the empty field, 1 is the coins coming out, 2 is the run.
function startLap() {
beatTimer.stop(); moneyTimer.stop();
revealAnim.stop(); runAnim.stop(); moneyAnim.stop();
scene.beat = 0;
scene.runProgress = 0.0;
scene.moneyProgress = 0.0;
scene.coinReveal = 0.0;
moneyAnim.duration = 5000 + Math.floor(Math.random() * 2000);
moneyTimer.interval = Math.floor(Math.random() * Math.max(1, scene.runBeat - moneyAnim.duration));
scene.tripLap = Math.random() < 0.17;
scene.tripAt = 0.34 + Math.random() * 0.34;
beatTimer.interval = 3000 + Math.floor(Math.random() * 2000); // 3-5s of empty field
beatTimer.start();
}
function advance() {
if (scene.reducedMotion)
return;
if (scene.beat === 0) {
scene.beat = 1;
revealAnim.start();
beatTimer.interval = scene.coinCount * scene.revealStep + scene.settleBeat;
beatTimer.start();
} else if (scene.beat === 1) {
scene.beat = 2;
runAnim.start();
moneyTimer.start();
beatTimer.interval = scene.runBeat + 900; // 900 holds on GAME OVER
beatTimer.start();
} else {
scene.startLap();
}
}
Component.onCompleted: if (!scene.reducedMotion) scene.startLap();
onReducedMotionChanged: {
if (scene.reducedMotion) {
beatTimer.stop(); moneyTimer.stop();
revealAnim.stop(); runAnim.stop(); moneyAnim.stop();
scene.tripLap = false;
} else {
scene.startLap();
}
}
}
+23 -2
View File
@@ -4,6 +4,10 @@
#include "SplashDialog.h"
#include "ui_SplashDialog.h"
#include <QDir>
#include <QMovie>
#include <QRandomGenerator>
#include "utils/Icons.h"
SplashDialog::SplashDialog(QWidget *parent)
@@ -14,8 +18,25 @@ SplashDialog::SplashDialog(QWidget *parent)
this->setWindowIcon(icons()->icon("appicon/64x64"));
QPixmap pixmap = QPixmap(":/assets/images/key.png");
ui->icon->setPixmap(pixmap.scaledToWidth(32, Qt::SmoothTransformation));
// wowlet: a random silly loading GIF (wow-lite-wallet tradition) instead of
// 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);
+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>
+1
View File
@@ -30,6 +30,7 @@ static const QHash<Config::ConfigKey, ConfigDirective> configStrings = {
{Config::homeWidget,{QS("homeWidget"), "ccs"}},
{Config::donateBeg,{QS("donateBeg"), 1}},
{Config::showHistorySyncNotice, {QS("showHistorySyncNotice"), true}},
{Config::reducedMotion, {QS("reducedMotion"), false}},
{Config::geometry, {QS("geometry"), {}}},
{Config::windowState, {QS("windowState"), {}}},
+1
View File
@@ -29,6 +29,7 @@ public:
homeWidget,
donateBeg,
showHistorySyncNotice,
reducedMotion, // wowlet: disable playful animations (accessibility / preference)
geometry,
windowState,
+3
View File
@@ -11,6 +11,8 @@
#include <QThread>
#include <QDateTime>
#include "utils/config.h"
static QString formatUptime(qint64 secs) {
return QString("%1:%2:%3")
.arg(secs / 3600, 2, 10, QChar('0'))
@@ -29,6 +31,7 @@ MiningWidget::MiningWidget(Wallet *wallet, QWidget *parent)
layout->setContentsMargins(0, 0, 0, 0);
layout->addWidget(m_quickWidget);
m_backend->setReducedMotion(conf()->get(Config::reducedMotion).toBool());
m_quickWidget->rootContext()->setContextProperty("mining", m_backend);
m_quickWidget->setResizeMode(QQuickWidget::SizeRootObjectToView);
m_quickWidget->setSource(QUrl("qrc:/qml/mining.qml"));
+5
View File
@@ -15,13 +15,17 @@
class MiningBackend : public QObject {
Q_OBJECT
Q_PROPERTY(int daemonMiningState READ daemonMiningState NOTIFY daemonMiningStateChanged)
Q_PROPERTY(bool reducedMotion READ reducedMotion NOTIFY reducedMotionChanged)
public:
explicit MiningBackend(QObject *parent = nullptr) : QObject(parent) {}
int daemonMiningState() const { return m_state; }
void setState(int s) { if (m_state != s) { m_state = s; emit daemonMiningStateChanged(); } }
bool reducedMotion() const { return m_reducedMotion; }
void setReducedMotion(bool r) { if (m_reducedMotion != r) { m_reducedMotion = r; emit reducedMotionChanged(); } }
signals:
void daemonMiningStateChanged();
void reducedMotionChanged();
void daemonOutput(const QString &line);
void syncStatus(int from, int to, int pct);
void uptimeChanged(const QString &uptime);
@@ -29,6 +33,7 @@ signals:
private:
int m_state = 0; // 0:idle 1:startup 2:syncing 3:mining
bool m_reducedMotion = false;
};
// wowlet: the pixel-art mining tab. Hosts mining.qml and wires its pick-axe to the wallet's solo
+23
View File
@@ -0,0 +1,23 @@
// SPDX-License-Identifier: BSD-3-Clause
// SPDX-FileCopyrightText: The Monero Project
#include "SceneWidget.h"
#include <QQmlContext>
#include "utils/config.h"
SceneWidget::SceneWidget(QWidget *parent, Variant variant)
: QQuickWidget(parent)
, m_backend(new SceneBackend(this))
{
m_backend->setReducedMotion(conf()->get(Config::reducedMotion).toBool());
m_backend->setVariant(static_cast<int>(variant));
this->rootContext()->setContextProperty("sceneCtx", m_backend);
this->setResizeMode(QQuickWidget::SizeRootObjectToView);
this->setSource(QUrl("qrc:/qml/scene.qml"));
}
void SceneWidget::refreshReducedMotion() {
m_backend->setReducedMotion(conf()->get(Config::reducedMotion).toBool());
}
+47
View File
@@ -0,0 +1,47 @@
// SPDX-License-Identifier: BSD-3-Clause
// SPDX-FileCopyrightText: The Monero Project
#ifndef WOWLET_SCENEWIDGET_H
#define WOWLET_SCENEWIDGET_H
#include <QObject>
#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)
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(); } }
signals:
void reducedMotionChanged();
void variantChanged();
private:
bool m_reducedMotion = false;
int m_variant = 0;
};
// 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 };
explicit SceneWidget(QWidget *parent = nullptr, Variant variant = Dusk);
void refreshReducedMotion();
private:
SceneBackend *m_backend;
};
#endif //WOWLET_SCENEWIDGET_H
+9
View File
@@ -9,6 +9,7 @@
#include "utils/Utils.h"
#include "libwalletqt/Wallet.h"
#include "widgets/SceneWidget.h"
WalletUnlockWidget::WalletUnlockWidget(QWidget *parent, Wallet *wallet)
: QWidget(parent)
@@ -18,6 +19,14 @@ WalletUnlockWidget::WalletUnlockWidget(QWidget *parent, Wallet *wallet)
ui->setupUi(this);
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);
connect(ui->buttonBox, &QDialogButtonBox::accepted, this, &WalletUnlockWidget::tryUnlock);
+7
View File
@@ -27,6 +27,13 @@ PageMenu::PageMenu(WizardFields *fields, WalletKeysFilesModel *wallets, QWidget
});
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() {
+7
View File
@@ -192,6 +192,13 @@
</layout>
</widget>
</item>
<item>
<widget class="QCheckBox" name="checkBox_reducedMotion">
<property name="text">
<string>Prefer reduced motion (disable playful animations)</string>
</property>
</widget>
</item>
<item>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>