Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b200d997b0 | ||
|
|
11e8e3241c | ||
|
|
f5e40ab5b2 | ||
|
|
3231bc5df2 | ||
|
|
0ce230de3c | ||
|
|
252b358baa | ||
|
|
dd76a7c4bf | ||
|
|
9bd8cb0676 | ||
|
|
48a8c36135 | ||
|
|
19c946c705 | ||
|
|
1828353d52 | ||
|
|
2a54663f76 | ||
|
|
6942cbb459 | ||
|
|
df4711d1c1 | ||
|
|
7b71f5dc9a | ||
|
|
0ed8c005c5 | ||
|
|
1c81365247 | ||
|
|
c65c12d328 | ||
|
|
38c63088b5 | ||
|
|
3d38fdd135 | ||
|
|
f2c3ce45a0 | ||
|
|
87d5977243 | ||
|
|
9e6d85c246 | ||
|
|
49b3264b0e | ||
|
|
3cf58f0313 | ||
|
|
0f6892c123 | ||
|
|
1fedbf0faa | ||
|
|
410df0c8ff | ||
|
|
1853ebf558 | ||
|
|
a2b34f5ebc | ||
|
|
27f694c4bd | ||
|
|
c7333a775c | ||
|
|
8b846a8dc0 | ||
|
|
dc5f940801 | ||
|
|
36d878e7bf | ||
|
|
a526061d97 | ||
|
|
f4c5ec3c25 | ||
|
|
1217ce174c | ||
|
|
914fa1ed32 | ||
|
|
a3509a6c3b |
@@ -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
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
name: macos build (gitea self-hosted)
|
||||||
|
|
||||||
|
# Runs ONLY on the Gitea mac runner (GitHub ignores .gitea/workflows).
|
||||||
|
# Dispatch-only: the mac runner has capacity 1 and also serves the Hash Bags
|
||||||
|
# iOS lane, so macOS wowlet builds are an explicit button, not a push hook.
|
||||||
|
# All build state lands under the runner's work dir, which lives on
|
||||||
|
# /Volumes/DataSSD (never backed up: the fleet backup pulls an allowlist of
|
||||||
|
# gitea data only).
|
||||||
|
#
|
||||||
|
# Signing/notarization uses the SAME seven secrets as the smirk-monorepo
|
||||||
|
# desktop lane, set by ~/keys/suchsoftware/set-gitea-desktop-secrets.sh:
|
||||||
|
# APPLE_CERTIFICATE (b64 Developer ID Application .p12),
|
||||||
|
# APPLE_CERTIFICATE_PASSWORD, APPLE_SIGNING_IDENTITY, APPLE_TEAM_ID,
|
||||||
|
# APPLE_API_ISSUER, APPLE_API_KEY, APPLE_API_KEY_P8_BASE64 (notarytool).
|
||||||
|
# If APPLE_CERTIFICATE is unset the build ships unsigned instead of failing.
|
||||||
|
|
||||||
|
on: [workflow_dispatch]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-macos:
|
||||||
|
name: "macOS (brew, native)"
|
||||||
|
runs-on: macos-latest
|
||||||
|
env:
|
||||||
|
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||||
|
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||||
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||||
|
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||||
|
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||||
|
APPLE_API_KEY_P8_BASE64: ${{ secrets.APPLE_API_KEY_P8_BASE64 }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: install dependencies (brew)
|
||||||
|
run: |
|
||||||
|
export PATH="/opt/homebrew/bin:$PATH"
|
||||||
|
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: |
|
||||||
|
set -e
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
case "$ARCH" in
|
||||||
|
arm64) WOWTAR=wownero-aarch64-apple-darwin11-v0.11.4.0.tar.bz2 ;;
|
||||||
|
*) WOWTAR=wownero-x86_64-apple-darwin11-v0.11.4.0.tar.bz2 ;;
|
||||||
|
esac
|
||||||
|
mkdir -p /tmp/wownerod-embed /tmp/wow-extract
|
||||||
|
curl -L -o /tmp/wownero.tar.bz2 \
|
||||||
|
"https://github.com/wownero/wownero-codeberg-mirror/releases/download/v0.11.4.0/$WOWTAR"
|
||||||
|
# /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 -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
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
cd build
|
||||||
|
APP=$(find . -maxdepth 3 -name "*.app" -type d | head -1)
|
||||||
|
[ -n "$APP" ] || { echo "no .app bundle produced"; ls -la bin/; exit 1; }
|
||||||
|
mkdir -p dist
|
||||||
|
cp -R "$APP" dist/Wowlet.app
|
||||||
|
# cmake never places the icon in the bundle (upstream only copies it
|
||||||
|
# for the dormant `installed/` layout)
|
||||||
|
mkdir -p dist/Wowlet.app/Contents/Resources
|
||||||
|
cp ../src/assets/images/appicons/appicon.icns dist/Wowlet.app/Contents/Resources/
|
||||||
|
# bundle Qt frameworks/plugins so the app runs off-machine
|
||||||
|
/opt/homebrew/opt/qt/bin/macdeployqt dist/Wowlet.app
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
cd build
|
||||||
|
if [ -z "$APPLE_CERTIFICATE" ]; then
|
||||||
|
echo "APPLE_CERTIFICATE not set — shipping UNSIGNED build"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
KC=/tmp/wowlet-ci.keychain-db
|
||||||
|
KCPW=$(/usr/bin/uuidgen)
|
||||||
|
echo "$KCPW" > /tmp/wowlet-ci-kcpw # for the cleanup step only
|
||||||
|
security create-keychain -p "$KCPW" "$KC"
|
||||||
|
security set-keychain-settings -lut 3600 "$KC"
|
||||||
|
security unlock-keychain -p "$KCPW" "$KC"
|
||||||
|
printf '%s' "$APPLE_CERTIFICATE" | base64 -d > /tmp/dev_id.p12
|
||||||
|
security import /tmp/dev_id.p12 -k "$KC" -P "$APPLE_CERTIFICATE_PASSWORD" \
|
||||||
|
-T /usr/bin/codesign
|
||||||
|
security set-key-partition-list -S apple-tool:,apple: -s -k "$KCPW" "$KC" >/dev/null
|
||||||
|
security list-keychains -d user -s "$KC" login.keychain-db
|
||||||
|
|
||||||
|
# Sign 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 ! -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" 2>/dev/null \
|
||||||
|
| while read -r fw; do SIGN "$fw"; done
|
||||||
|
SIGN dist/Wowlet.app
|
||||||
|
codesign --verify --deep --strict dist/Wowlet.app
|
||||||
|
|
||||||
|
printf '%s' "$APPLE_API_KEY_P8_BASE64" | base64 -d > /tmp/notary.p8
|
||||||
|
ditto -c -k --keepParent dist/Wowlet.app /tmp/wowlet-notarize.zip
|
||||||
|
xcrun notarytool submit /tmp/wowlet-notarize.zip \
|
||||||
|
--key /tmp/notary.p8 --key-id "$APPLE_API_KEY" --issuer "$APPLE_API_ISSUER" \
|
||||||
|
--wait --timeout 30m
|
||||||
|
xcrun stapler staple dist/Wowlet.app
|
||||||
|
echo "signed + notarized + stapled"
|
||||||
|
|
||||||
|
- name: zip artifact
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
cd build/dist
|
||||||
|
ditto -c -k --keepParent Wowlet.app ../wowlet-macos-$(uname -m).zip
|
||||||
|
ls -la ../wowlet-macos-*.zip
|
||||||
|
|
||||||
|
- name: cleanup signing material
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [ -f /tmp/wowlet-ci-kcpw ]; then
|
||||||
|
security delete-keychain /tmp/wowlet-ci.keychain-db || true
|
||||||
|
fi
|
||||||
|
rm -f /tmp/dev_id.p12 /tmp/notary.p8 /tmp/wowlet-ci-kcpw /tmp/wowlet-notarize.zip
|
||||||
|
|
||||||
|
- name: upload artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: wowlet-macos
|
||||||
|
path: build/wowlet-macos-*.zip
|
||||||
|
retention-days: 14
|
||||||
@@ -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
|
||||||
@@ -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,7 +1,7 @@
|
|||||||
cmake_minimum_required(VERSION 3.18)
|
cmake_minimum_required(VERSION 3.18)
|
||||||
|
|
||||||
project(feather
|
project(feather
|
||||||
VERSION "5.0.3"
|
VERSION "5.1.1"
|
||||||
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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -542,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){
|
||||||
@@ -760,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();
|
||||||
@@ -770,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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
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);
|
||||||
|
|||||||
@@ -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,14 @@ 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.
|
||||||
|
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)$)";
|
QString amount_rx = R"(^\d{0,8}[\.,]\d{0,12}|(all)$)";
|
||||||
QRegularExpression rx;
|
QRegularExpression rx;
|
||||||
rx.setPattern(amount_rx);
|
rx.setPattern(amount_rx);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 ¤cy : availableFiatCurrencies) {
|
for (const auto ¤cy : availableFiatCurrencies) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 293 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 265 KiB |
|
After Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 311 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 24 KiB |
@@ -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
|
||||||
@@ -538,6 +521,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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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"), {}}},
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ public:
|
|||||||
homeWidget,
|
homeWidget,
|
||||||
donateBeg,
|
donateBeg,
|
||||||
showHistorySyncNotice,
|
showHistorySyncNotice,
|
||||||
|
reducedMotion, // wowlet: disable playful animations (accessibility / preference)
|
||||||
|
|
||||||
geometry,
|
geometry,
|
||||||
windowState,
|
windowState,
|
||||||
|
|||||||
@@ -11,6 +11,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'))
|
||||||
@@ -29,6 +31,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"));
|
||||||
|
|||||||
@@ -15,13 +15,17 @@
|
|||||||
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)
|
||||||
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(); } }
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void daemonMiningStateChanged();
|
void daemonMiningStateChanged();
|
||||||
|
void reducedMotionChanged();
|
||||||
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 +33,7 @@ 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;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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,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);
|
||||||
|
|||||||
@@ -27,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() {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||