Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64529590a5 | ||
|
|
3c5aaed087 | ||
|
|
020b177bd1 | ||
|
|
fb0c34cd70 | ||
|
|
e0670bede5 | ||
|
|
ea04f585aa | ||
|
|
25a341fd10 | ||
|
|
ec8bdf4dfe | ||
|
|
34bdecf5ac | ||
|
|
d9cd8e328e | ||
|
|
998684c1a7 | ||
|
|
bf455e06f1 | ||
|
|
4e3f74a38a | ||
|
|
4050d51760 | ||
|
|
8dacc3ba5a | ||
|
|
66c16d152d | ||
|
|
f4bbfd49ad | ||
|
|
142b79ee27 | ||
|
|
a43e9fe122 | ||
|
|
ce84b1d062 | ||
|
|
0e6113729d | ||
|
|
27d7b346c0 | ||
|
|
ea9c73826b | ||
|
|
5a9605c87b | ||
|
|
db151c2920 | ||
|
|
c7fca31653 | ||
|
|
081c01ada9 | ||
|
|
613d4e7f61 | ||
|
|
79daae6796 | ||
|
|
b1e751a6b7 | ||
|
|
068e4c2aa5 | ||
|
|
f1144c483e | ||
|
|
e5b8ef6a00 | ||
|
|
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 | ||
|
|
93d13a6568 | ||
|
|
64326119eb | ||
|
|
e3ba9c7bad | ||
|
|
6cd7e8bad9 | ||
|
|
0d5d745311 |
@@ -0,0 +1,164 @@
|
|||||||
|
name: linux appimage (gitea self-hosted)
|
||||||
|
|
||||||
|
# Runs ONLY on Gitea (GitHub ignores .gitea/workflows), in the private Builds
|
||||||
|
# repository. GitHub wownero/wowlet owns source and release publication.
|
||||||
|
#
|
||||||
|
# Unlike the Windows lane, which builds a static toolchain of its own through
|
||||||
|
# contrib/depends, the x86_64 AppImage links against the host's Qt6. Those
|
||||||
|
# dependencies are owned by such-fleet (roles/wowlet_build_host) rather than
|
||||||
|
# installed here: a workflow that apt-installs needs sudo on a shared runner and
|
||||||
|
# makes the build host's contents a property of whichever job ran last.
|
||||||
|
#
|
||||||
|
# Dispatch-only. The runner is capacity 1 and shared with the fleet gates.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
expected_sha:
|
||||||
|
description: Exact reviewed Builds commit
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
env:
|
||||||
|
EXPECTED_SHA: ${{ inputs.expected_sha }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
appimage:
|
||||||
|
name: "Linux x86_64 (host Qt6 AppImage)"
|
||||||
|
runs-on: ubuntu-latest # gitea label ubuntu-latest:host -> deb, host mode
|
||||||
|
steps:
|
||||||
|
- name: verify the build host has its dependencies
|
||||||
|
run: |
|
||||||
|
missing=0
|
||||||
|
for t in cmake make curl bzip2 git patchelf pkg-config qmake6; do
|
||||||
|
command -v "$t" >/dev/null || { echo "MISSING TOOL: $t"; missing=1; }
|
||||||
|
done
|
||||||
|
for h in /usr/include/boost/version.hpp; do
|
||||||
|
[ -f "$h" ] || { echo "MISSING HEADER: $h"; missing=1; }
|
||||||
|
done
|
||||||
|
pkg-config --exists libsodium || { echo "MISSING: libsodium"; missing=1; }
|
||||||
|
[ "$missing" = 0 ] || {
|
||||||
|
echo "Run playbooks/wowlet-build-host.yml in such-fleet; do not apt-install from CI."
|
||||||
|
exit 1; }
|
||||||
|
echo "qt $(qmake6 -query QT_VERSION)"; nproc; free -g | head -2
|
||||||
|
|
||||||
|
- name: configure git
|
||||||
|
run: git config --global --add safe.directory '*'
|
||||||
|
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
ref: ${{ github.sha }}
|
||||||
|
|
||||||
|
- name: Verify exact reviewed source
|
||||||
|
run: python3 -B contrib/release/check_dispatch.py
|
||||||
|
|
||||||
|
- name: fetch verified wownerod to embed
|
||||||
|
run: |
|
||||||
|
python3 -B contrib/release/fetch_wownerod.py \
|
||||||
|
--target x86_64-linux-gnu \
|
||||||
|
--destination "/tmp/wowlet-${GITHUB_RUN_ID}/wownerod-embed"
|
||||||
|
|
||||||
|
- name: fetch tor to embed
|
||||||
|
run: |
|
||||||
|
EMBED="/tmp/wowlet-${GITHUB_RUN_ID:-local}"
|
||||||
|
rm -rf "$EMBED/tor-embed" && mkdir -p "$EMBED/tor-embed"
|
||||||
|
curl -fL -o "$EMBED/teb.tgz" \
|
||||||
|
https://archive.torproject.org/tor-package-archive/torbrowser/15.0/tor-expert-bundle-linux-x86_64-15.0.tar.gz
|
||||||
|
rm -rf "$EMBED/teb" && mkdir -p "$EMBED/teb" && tar xzf "$EMBED/teb.tgz" -C "$EMBED/teb"
|
||||||
|
cp "$EMBED/teb/tor/tor" "$EMBED/teb/tor/libssl.so.3" "$EMBED/teb/tor/libevent-2.1.so.7" \
|
||||||
|
"$EMBED/teb/tor/libcrypto.so.3" "$EMBED/tor-embed/"
|
||||||
|
# The expert bundle's tor carries no rpath; point it and its libs at
|
||||||
|
# $ORIGIN so the embedded copy resolves them from the extract dir on
|
||||||
|
# any distro.
|
||||||
|
for f in tor libssl.so.3 libevent-2.1.so.7 libcrypto.so.3; do
|
||||||
|
patchelf --set-rpath '$ORIGIN' "$EMBED/tor-embed/$f"
|
||||||
|
done
|
||||||
|
chmod +x "$EMBED/tor-embed/tor"
|
||||||
|
|
||||||
|
- name: build
|
||||||
|
run: |
|
||||||
|
# TOR_VERSION must EXACTLY match `tor --version` of the embedded
|
||||||
|
# binary, or TorManager::unpackBins() sees embedded > unpacked and
|
||||||
|
# rewrites Tor on every launch. The bundle's release number (15.0) is
|
||||||
|
# not its tor version, so derive it.
|
||||||
|
EMBED="/tmp/wowlet-${GITHUB_RUN_ID:-local}"
|
||||||
|
TORVER=$("$EMBED/tor-embed/tor" --version | grep -oE '[0-9]+(\.[0-9]+){3}' | head -1)
|
||||||
|
echo "embedded tor version: $TORVER"
|
||||||
|
# ARCH=x86-64, never native: the build host's CPU is newer than many
|
||||||
|
# users' and native bakes in unsupported instructions -> SIGILL.
|
||||||
|
cmake -S . -B build \
|
||||||
|
-DWITH_SCANNER=OFF \
|
||||||
|
-DARCH=x86-64 \
|
||||||
|
-DWOWNEROD_DIR="$EMBED/wownerod-embed" \
|
||||||
|
-DTOR_DIR="$EMBED/tor-embed" \
|
||||||
|
-DTOR_VERSION="$TORVER"
|
||||||
|
cmake --build build -j6
|
||||||
|
ls -la build/bin/
|
||||||
|
|
||||||
|
- name: package AppImage
|
||||||
|
run: |
|
||||||
|
rm -rf AppDir
|
||||||
|
mkdir -p AppDir/usr/bin AppDir/usr/share/applications \
|
||||||
|
AppDir/usr/share/icons/hicolor/256x256/apps
|
||||||
|
cp build/bin/wowlet AppDir/usr/bin/wowlet
|
||||||
|
chmod +x AppDir/usr/bin/wowlet
|
||||||
|
cp src/assets/wowlet.desktop AppDir/usr/share/applications/wowlet.desktop
|
||||||
|
cp src/assets/images/appicons/256x256.png \
|
||||||
|
AppDir/usr/share/icons/hicolor/256x256/apps/wowlet.png
|
||||||
|
|
||||||
|
curl -sL -o linuxdeploy https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage
|
||||||
|
curl -sL -o linuxdeploy-plugin-qt https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/continuous/linuxdeploy-plugin-qt-x86_64.AppImage
|
||||||
|
curl -sL -o appimagetool https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
|
||||||
|
chmod +x linuxdeploy linuxdeploy-plugin-qt appimagetool
|
||||||
|
|
||||||
|
export APPIMAGE_EXTRACT_AND_RUN=1
|
||||||
|
export QMAKE="$(command -v qmake6)"
|
||||||
|
export EXTRA_QT_PLUGINS="svg;multimedia;tls"
|
||||||
|
export QML_SOURCES_PATHS="$PWD/src/assets/qml"
|
||||||
|
export PATH="$PWD:$PATH"
|
||||||
|
|
||||||
|
./linuxdeploy --appdir AppDir --plugin qt \
|
||||||
|
-d AppDir/usr/share/applications/wowlet.desktop \
|
||||||
|
-i AppDir/usr/share/icons/hicolor/256x256/apps/wowlet.png
|
||||||
|
|
||||||
|
# qmlimportscanner reads only the `import` lines in our QML; it does
|
||||||
|
# not follow a module's own qmldir depends/default-import chain, so
|
||||||
|
# transitive modules (QtQuick -> QtQml.WorkerScript/Models) are
|
||||||
|
# silently skipped. Blanket-copy the trees, then resolve each copied
|
||||||
|
# plugin's Qt libs ourselves -- linuxdeploy only does that for modules
|
||||||
|
# it deployed, not ones we copied by hand.
|
||||||
|
QMLDIR=$(qmake6 -query QT_INSTALL_QML)
|
||||||
|
mkdir -p AppDir/usr/qml AppDir/usr/lib
|
||||||
|
for m in QtQuick QtQml; do cp -rn "$QMLDIR/$m" AppDir/usr/qml/ 2>/dev/null || true; done
|
||||||
|
find AppDir/usr/qml -name '*.so' -print0 | while IFS= read -r -d '' so; do
|
||||||
|
ldd "$so" 2>/dev/null | awk '/=> \//{print $3}' \
|
||||||
|
| grep -E '/(libQt6|libicu|libpcre2|libdouble-conversion|libzstd|libb2|libpng|libharfbuzz|libmd4c|libglib|libgraphite)' \
|
||||||
|
| while read -r lib; do cp -L -n "$lib" AppDir/usr/lib/ 2>/dev/null || true; done
|
||||||
|
done
|
||||||
|
echo "=== qt.conf ==="; cat AppDir/usr/bin/qt.conf
|
||||||
|
|
||||||
|
# Guard: mining.qml must not pull in QtQuick.Controls. If a future
|
||||||
|
# edit re-adds a Controls type this fails loudly instead of shipping
|
||||||
|
# a white mining tab.
|
||||||
|
if grep -q 'import QtQuick.Controls' src/assets/qml/mining.qml; then
|
||||||
|
echo "ERROR: mining.qml imports QtQuick.Controls again." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ARCH=x86_64 ./appimagetool AppDir wowlet-x86_64.AppImage
|
||||||
|
ls -la wowlet-x86_64.AppImage
|
||||||
|
|
||||||
|
- name: Record candidate source and hashes
|
||||||
|
run: python3 -B contrib/release/write_receipt.py --platform linux --file wowlet-x86_64.AppImage --output wowlet-linux-receipt.json
|
||||||
|
|
||||||
|
- name: upload wowlet AppImage
|
||||||
|
# v3: Gitea implements the older artifact protocol and rejects
|
||||||
|
# @actions/artifact v2 clients (upload-artifact@v4+) as unsupported.
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: wowlet-x86_64-linux-AppImage
|
||||||
|
path: |
|
||||||
|
wowlet-x86_64.AppImage
|
||||||
|
wowlet-linux-receipt.json
|
||||||
|
if-no-files-found: error
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
name: macos build (gitea self-hosted)
|
||||||
|
|
||||||
|
# Runs ONLY on the Gitea mac runner (GitHub ignores .gitea/workflows).
|
||||||
|
# Dispatch-only: the mac runner has capacity 1 and also serves the Hash Bags
|
||||||
|
# iOS lane, so macOS wowlet builds are an explicit button, not a push hook.
|
||||||
|
# All build state lands under the runner's work dir, which lives on
|
||||||
|
# /Volumes/DataSSD (never backed up: the fleet backup pulls an allowlist of
|
||||||
|
# gitea data only).
|
||||||
|
#
|
||||||
|
# Signing/notarization uses the SAME seven secrets as the smirk-monorepo
|
||||||
|
# desktop lane, set by ~/keys/suchsoftware/set-gitea-desktop-secrets.sh:
|
||||||
|
# APPLE_CERTIFICATE (b64 Developer ID Application .p12),
|
||||||
|
# APPLE_CERTIFICATE_PASSWORD, APPLE_SIGNING_IDENTITY, APPLE_TEAM_ID,
|
||||||
|
# APPLE_API_ISSUER, APPLE_API_KEY, APPLE_API_KEY_P8_BASE64 (notarytool).
|
||||||
|
# Missing signing or notarization inputs refuse before compilation.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
expected_sha:
|
||||||
|
description: Exact reviewed Builds commit
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
env:
|
||||||
|
EXPECTED_SHA: ${{ inputs.expected_sha }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-macos:
|
||||||
|
name: "macOS (brew, native)"
|
||||||
|
runs-on: macos-latest
|
||||||
|
env:
|
||||||
|
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||||
|
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||||
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||||
|
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||||
|
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||||
|
APPLE_API_KEY_P8_BASE64: ${{ secrets.APPLE_API_KEY_P8_BASE64 }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
ref: ${{ github.sha }}
|
||||||
|
|
||||||
|
- name: Verify exact reviewed source
|
||||||
|
run: python3 -B contrib/release/check_dispatch.py
|
||||||
|
|
||||||
|
- name: Check signing inputs before compiling
|
||||||
|
run: python3 -B contrib/release/sign_macos.py --check-inputs
|
||||||
|
|
||||||
|
- name: Verify Fleet-managed build dependencies
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
export PATH="/opt/homebrew/bin:$PATH"
|
||||||
|
for formula in qt libsodium libzip qrencode unbound cmake boost hidapi openssl expat protobuf zxing-cpp; do
|
||||||
|
brew --prefix "$formula" >/dev/null || {
|
||||||
|
echo "Missing Fleet-managed build dependency: $formula" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: fetch verified wownerod to embed
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
case "$(uname -m)" in
|
||||||
|
arm64) target=aarch64-apple-darwin11 ;;
|
||||||
|
x86_64) target=x86_64-apple-darwin11 ;;
|
||||||
|
*) echo "Unsupported macOS build architecture" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
EMBED="/tmp/wowlet-${GITHUB_RUN_ID}/wownerod-embed"
|
||||||
|
python3 -B contrib/release/fetch_wownerod.py --target "$target" --destination "$EMBED"
|
||||||
|
echo "WOWNEROD_DIR=$EMBED" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: fetch tor to embed
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
case "$ARCH" in
|
||||||
|
arm64) TORTAR=tor-expert-bundle-macos-aarch64-15.0.tar.gz ;;
|
||||||
|
*) TORTAR=tor-expert-bundle-macos-x86_64-15.0.tar.gz ;;
|
||||||
|
esac
|
||||||
|
mkdir -p /tmp/teb /tmp/tor-embed
|
||||||
|
curl -L -o /tmp/teb.tgz "https://archive.torproject.org/tor-package-archive/torbrowser/15.0/$TORTAR"
|
||||||
|
tar xzf /tmp/teb.tgz -C /tmp/teb
|
||||||
|
cp /tmp/teb/tor/tor /tmp/teb/tor/libevent-*.dylib /tmp/tor-embed/
|
||||||
|
# tor ships UNSIGNED; arm64 macOS SIGKILLs unsigned binaries. Ad-hoc sign
|
||||||
|
# BEFORE embedding — the signature travels inside the file, so the copy
|
||||||
|
# the app unpacks at runtime on user machines is runnable too.
|
||||||
|
codesign -s - -f /tmp/tor-embed/tor /tmp/tor-embed/libevent-*.dylib
|
||||||
|
chmod +x /tmp/tor-embed/tor
|
||||||
|
# expert-bundle RELEASE number (15.0) is NOT the tor version — derive it
|
||||||
|
TORVER=$(/tmp/tor-embed/tor --version | grep -oE '[0-9]+(\.[0-9]+){3}' | head -1)
|
||||||
|
echo "TOR_VERSION=$TORVER" >> "$GITHUB_ENV"
|
||||||
|
echo "embedded tor version: $TORVER"
|
||||||
|
|
||||||
|
- name: build
|
||||||
|
run: |
|
||||||
|
export PATH="/opt/homebrew/bin:$PATH"
|
||||||
|
# clang does not search /opt/homebrew/include by default; the feather
|
||||||
|
# target pulls monero headers that include boost, and modern Boost's
|
||||||
|
# CMake config leaves Boost_INCLUDE_DIRS empty. CPATH/LIBRARY_PATH make
|
||||||
|
# every linked brew header/lib findable without per-dep flag surgery.
|
||||||
|
export CPATH="$(brew --prefix)/include"
|
||||||
|
export LIBRARY_PATH="$(brew --prefix)/lib"
|
||||||
|
mkdir -p build && cd build
|
||||||
|
cmake -DWITH_SCANNER=OFF -DUSE_DEVICE_TREZOR=OFF -DBoost_USE_STATIC_LIBS=ON -DWOWNEROD_DIR="$WOWNEROD_DIR" -DTOR_DIR=/tmp/tor-embed -DTOR_VERSION="$TOR_VERSION" -DCMAKE_PREFIX_PATH="$(brew --prefix qt)" ..
|
||||||
|
cmake --build . -j "$(sysctl -n hw.ncpu)"
|
||||||
|
|
||||||
|
- name: package app bundle
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
cd build
|
||||||
|
APP=$(find . -maxdepth 3 -name "*.app" -type d | head -1)
|
||||||
|
[ -n "$APP" ] || { echo "no .app bundle produced"; ls -la bin/; exit 1; }
|
||||||
|
mkdir -p dist
|
||||||
|
cp -R "$APP" dist/Wowlet.app
|
||||||
|
# cmake never places the icon in the bundle (upstream only copies it
|
||||||
|
# for the dormant `installed/` layout)
|
||||||
|
mkdir -p dist/Wowlet.app/Contents/Resources
|
||||||
|
cp ../src/assets/images/appicons/appicon.icns dist/Wowlet.app/Contents/Resources/
|
||||||
|
# bundle Qt frameworks/plugins so the app runs off-machine.
|
||||||
|
# -qmldir is not optional: our QML lives in .qrc resources, which
|
||||||
|
# macdeployqt cannot scan, so without it the bundle ships no QML
|
||||||
|
# modules at all and every page importing anything beyond QtQuick
|
||||||
|
# fails to load. That is how 5.1.2 shipped a grey mining tab on mac
|
||||||
|
# while Linux was fine: Contents/Resources held no qml directory, and
|
||||||
|
# the app logged 'module "QtQuick.Layouts" is not installed'.
|
||||||
|
/opt/homebrew/opt/qt/bin/macdeployqt dist/Wowlet.app -qmldir=../src/assets/qml
|
||||||
|
|
||||||
|
# qmlimportscanner reads only the `import` lines in our QML and does
|
||||||
|
# not follow a module's own qmldir depends chain, so transitive
|
||||||
|
# modules (QtQuick -> QtQml.WorkerScript/Models) are silently skipped.
|
||||||
|
# The AppImage build hit this twice and blanket-copies the trees; do
|
||||||
|
# the same here, before the dylib passes below so their deps resolve.
|
||||||
|
# Ask Qt where its QML modules live rather than assuming a brew
|
||||||
|
# layout; the AppImage build asks qmake the same question.
|
||||||
|
QTPREFIX="$(brew --prefix qt)"
|
||||||
|
QMLSRC="$("$QTPREFIX/bin/qmake6" -query QT_INSTALL_QML 2>/dev/null || echo "$QTPREFIX/share/qt/qml")"
|
||||||
|
[ -d "$QMLSRC" ] || { echo "ERROR: Qt QML modules not found at $QMLSRC" >&2; exit 1; }
|
||||||
|
mkdir -p dist/Wowlet.app/Contents/Resources/qml
|
||||||
|
for m in QtQuick QtQml; do
|
||||||
|
cp -Rn "$QMLSRC/$m" dist/Wowlet.app/Contents/Resources/qml/ 2>/dev/null || true
|
||||||
|
done
|
||||||
|
|
||||||
|
# Guard: the module the mining tab needs has to be in the bundle. A
|
||||||
|
# missing QML module is invisible until someone opens the tab, so it
|
||||||
|
# fails the build instead of shipping a page that paints grey.
|
||||||
|
for m in QtQuick QtQuick/Layouts; do
|
||||||
|
[ -d "dist/Wowlet.app/Contents/Resources/qml/$m" ] || {
|
||||||
|
echo "ERROR: $m missing from the bundle; QML pages will not load" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
done
|
||||||
|
|
||||||
|
# macdeployqt bundles Qt frameworks but not the plain-dylib brew deps
|
||||||
|
# of Qt's image plugins (libwebp/libsharpyuv/libbrotli...), which are
|
||||||
|
# referenced as @rpath/lib*.dylib. Without them a clean (no-Homebrew)
|
||||||
|
# Mac can't load those image formats. Qt frameworks use a different
|
||||||
|
# ref form (@rpath/Qt*.framework/...), so keying strictly on
|
||||||
|
# `@rpath/*.dylib` bundles the stragglers WITHOUT touching Qt.
|
||||||
|
# Several passes converge (a bundled lib pulls in further deps).
|
||||||
|
APP=dist/Wowlet.app
|
||||||
|
FW="$APP/Contents/Frameworks"
|
||||||
|
mkdir -p "$FW"
|
||||||
|
for pass in 1 2 3 4; do
|
||||||
|
while IFS= read -r macho; do
|
||||||
|
file "$macho" | grep -q Mach-O || continue
|
||||||
|
chmod u+w "$macho"
|
||||||
|
otool -L "$macho" | awk 'NR>1{print $1}' | while IFS= read -r dep; do
|
||||||
|
case "$dep" in
|
||||||
|
@rpath/*.dylib)
|
||||||
|
name=$(basename "$dep")
|
||||||
|
if [ -f "$FW/$name" ]; then
|
||||||
|
install_name_tool -change "$dep" "@executable_path/../Frameworks/$name" "$macho" 2>/dev/null || true
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
real=$(find /opt/homebrew/lib /opt/homebrew/opt/*/lib -maxdepth 1 -name "$name" 2>/dev/null | head -1)
|
||||||
|
if [ -n "$real" ]; then
|
||||||
|
cp -n "$real" "$FW/$name"
|
||||||
|
chmod u+w "$FW/$name"
|
||||||
|
install_name_tool -id "@executable_path/../Frameworks/$name" "$FW/$name" 2>/dev/null || true
|
||||||
|
install_name_tool -change "$dep" "@executable_path/../Frameworks/$name" "$macho" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
done < <(find "$APP" -type f)
|
||||||
|
done
|
||||||
|
|
||||||
|
# Fail if any @rpath/*.dylib (non-Qt) remains unresolved in the bundle
|
||||||
|
leftover=$(find "$APP" -type f | while IFS= read -r m; do
|
||||||
|
file "$m" | grep -q Mach-O || continue
|
||||||
|
otool -L "$m" | awk 'NR>1{print $1}' | grep -E '^@rpath/[^/]+\.dylib$' || true
|
||||||
|
done | sort -u)
|
||||||
|
if [ -n "$leftover" ]; then
|
||||||
|
echo "UNBUNDLED @rpath dylibs remain:"; echo "$leftover"; exit 1
|
||||||
|
fi
|
||||||
|
echo "all plain-dylib deps bundled"
|
||||||
|
ls -la "$FW" | grep -iE "webp|sharpyuv|brotli" || true
|
||||||
|
ls -la dist/
|
||||||
|
|
||||||
|
- name: Sign and notarize the private candidate
|
||||||
|
run: python3 -B contrib/release/sign_macos.py
|
||||||
|
|
||||||
|
- name: zip artifact
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
cd build/dist
|
||||||
|
ditto -c -k --keepParent Wowlet.app ../wowlet-macos-$(uname -m).zip
|
||||||
|
ls -la ../wowlet-macos-*.zip
|
||||||
|
|
||||||
|
- name: Record signed candidate source and hashes
|
||||||
|
run: python3 -B contrib/release/write_receipt.py --platform macos --file build/wowlet-macos-$(uname -m).zip --output build/wowlet-macos-receipt.json
|
||||||
|
|
||||||
|
- name: upload artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: wowlet-macos
|
||||||
|
path: |
|
||||||
|
build/wowlet-macos-*.zip
|
||||||
|
build/wowlet-macos-receipt.json
|
||||||
|
retention-days: 14
|
||||||
|
# Without this an empty upload passes silently, which is how a signed
|
||||||
|
# build can appear to publish while shipping nothing.
|
||||||
|
if-no-files-found: error
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
name: windows build + sign (gitea self-hosted)
|
||||||
|
|
||||||
|
# Runs ONLY on Gitea (GitHub ignores .gitea/workflows), in the private Builds
|
||||||
|
# repository. GitHub wownero/wowlet owns source and release publication.
|
||||||
|
#
|
||||||
|
# Two jobs on two different machines, and that split is the whole point:
|
||||||
|
#
|
||||||
|
# build -> `deb`, which advertises windows-latest:host because it carries the
|
||||||
|
# MinGW cross-compiler. Produces an UNSIGNED wowlet.exe.
|
||||||
|
# sign -> `desktop-iim2fs2` (windows:host), the only machine with the EV
|
||||||
|
# code-signing token plugged into it.
|
||||||
|
#
|
||||||
|
# The signing host does not sign in this job. The runner there is
|
||||||
|
# NT SERVICE\act_runner, which cannot use the token: SafeNet holds the token
|
||||||
|
# login per user, and the login is established interactively by an operator.
|
||||||
|
# So the job queues the artifact for the signing broker that Fleet installs
|
||||||
|
# (roles/windows_code_signing) and waits for it. CI never sees the key and never
|
||||||
|
# sees the token password, so it can never spend one of the token's three
|
||||||
|
# password attempts.
|
||||||
|
#
|
||||||
|
# Dispatch-only. Both runners are capacity 1 and shared with other lanes.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
expected_sha:
|
||||||
|
description: Exact reviewed Builds commit
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
env:
|
||||||
|
EXPECTED_SHA: ${{ inputs.expected_sha }}
|
||||||
|
HOST: x86_64-w64-mingw32
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: "Windows x86_64 (mingw cross, depends)"
|
||||||
|
runs-on: windows-latest # gitea label windows-latest:host -> deb, host mode
|
||||||
|
steps:
|
||||||
|
- name: verify the cross toolchain is present
|
||||||
|
# Host mode on a shared fleet runner: this job installs nothing and
|
||||||
|
# needs no sudo. Fleet owns the toolchain; if something is missing the
|
||||||
|
# job says exactly what rather than half-building.
|
||||||
|
run: |
|
||||||
|
missing=0
|
||||||
|
for t in curl bzip2 unzip zip git cmake make ccache python3 perl pkg-config \
|
||||||
|
x86_64-w64-mingw32-gcc-posix x86_64-w64-mingw32-g++-posix; do
|
||||||
|
command -v "$t" >/dev/null || { echo "MISSING: $t"; missing=1; }
|
||||||
|
done
|
||||||
|
# mingw32.mk needs the posix threading variant (std::thread/mutex/cv).
|
||||||
|
x86_64-w64-mingw32-g++ -v 2>&1 | grep -q 'Thread model: posix' || {
|
||||||
|
echo "MISSING: x86_64-w64-mingw32-g++ is not the posix threading variant"; missing=1; }
|
||||||
|
[ "$missing" = 0 ] || { echo "Fix the build host in such-fleet, not here."; exit 1; }
|
||||||
|
echo "toolchain ok"; nproc; free -g | head -2
|
||||||
|
|
||||||
|
- name: configure git
|
||||||
|
run: git config --global --add safe.directory '*'
|
||||||
|
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
ref: ${{ github.sha }}
|
||||||
|
|
||||||
|
- name: Verify exact reviewed source
|
||||||
|
run: python3 -B contrib/release/check_dispatch.py
|
||||||
|
|
||||||
|
# depends is the slow part (static Qt6/boost/openssl/tor from source).
|
||||||
|
# Cache the source tarballs and the compiled packages so only a cold run
|
||||||
|
# pays the ~1h cost.
|
||||||
|
- name: restore depends sources cache
|
||||||
|
id: cache-sources
|
||||||
|
uses: actions/cache/restore@v4
|
||||||
|
with:
|
||||||
|
path: contrib/depends/sources
|
||||||
|
key: depends-sources-${{ hashFiles('contrib/depends/packages/*') }}
|
||||||
|
restore-keys: |
|
||||||
|
depends-sources-
|
||||||
|
|
||||||
|
- name: restore depends built cache (mingw)
|
||||||
|
id: cache-built
|
||||||
|
uses: actions/cache/restore@v4
|
||||||
|
with:
|
||||||
|
path: contrib/depends/built
|
||||||
|
key: depends-built-mingw32-${{ hashFiles('contrib/depends/packages/*', 'contrib/depends/hosts/*', 'contrib/depends/toolchain.cmake.in') }}
|
||||||
|
restore-keys: |
|
||||||
|
depends-built-mingw32-
|
||||||
|
|
||||||
|
- name: build depends (static Qt6 + tor + boost + openssl for mingw)
|
||||||
|
# -j4, not the -j2 the GitHub lane needs: that limit existed because a
|
||||||
|
# parallel static Qt6 compile OOMs a 16G hosted runner. deb has 30G.
|
||||||
|
run: make -C contrib/depends HOST="$HOST" -j4
|
||||||
|
|
||||||
|
# Save with if:always() so an expensive toolchain banks even when the
|
||||||
|
# later wowlet.exe build fails; the automatic post-save is skipped on
|
||||||
|
# job failure, which otherwise re-pays hours every time.
|
||||||
|
- name: save depends sources cache
|
||||||
|
if: always() && steps.cache-sources.outputs.cache-hit != 'true'
|
||||||
|
uses: actions/cache/save@v4
|
||||||
|
with:
|
||||||
|
path: contrib/depends/sources
|
||||||
|
key: depends-sources-${{ hashFiles('contrib/depends/packages/*') }}
|
||||||
|
|
||||||
|
- name: save depends built cache (mingw)
|
||||||
|
if: always() && steps.cache-built.outputs.cache-hit != 'true'
|
||||||
|
uses: actions/cache/save@v4
|
||||||
|
with:
|
||||||
|
path: contrib/depends/built
|
||||||
|
key: depends-built-mingw32-${{ hashFiles('contrib/depends/packages/*', 'contrib/depends/hosts/*', 'contrib/depends/toolchain.cmake.in') }}
|
||||||
|
|
||||||
|
- name: fetch verified wownerod to embed
|
||||||
|
run: |
|
||||||
|
python3 -B contrib/release/fetch_wownerod.py \
|
||||||
|
--target x86_64-w64-mingw32 \
|
||||||
|
--destination "/tmp/wowlet-${GITHUB_RUN_ID}/wownerod-embed"
|
||||||
|
|
||||||
|
- name: build wowlet.exe
|
||||||
|
run: |
|
||||||
|
rm -rf build && mkdir build && cd build
|
||||||
|
# ARCH=x86-64: portable baseline. wownero's CMake defaults ARCH=native,
|
||||||
|
# which under a cross compiler bakes the BUILD HOST's instructions into
|
||||||
|
# the Windows binary -> SIGILL on older CPUs.
|
||||||
|
cmake --toolchain "$GITHUB_WORKSPACE/contrib/depends/$HOST/share/toolchain.cmake" \
|
||||||
|
-DARCH=x86-64 \
|
||||||
|
-DWITH_SCANNER=OFF \
|
||||||
|
-DCHECK_UPDATES=OFF \
|
||||||
|
-DTOR_INSTALLED=On \
|
||||||
|
-DFEATHER_TARGET_TRIPLET="$HOST" \
|
||||||
|
-DWOWNEROD_DIR="/tmp/wowlet-${GITHUB_RUN_ID:-local}/wownerod-embed" \
|
||||||
|
..
|
||||||
|
cmake --build . -j6
|
||||||
|
ls -la bin/
|
||||||
|
|
||||||
|
- name: stage the unsigned exe for signing
|
||||||
|
# The signing job needs the bare exe, not the zip: the broker signs one
|
||||||
|
# PE file at a time. The zip is assembled after signing so the published
|
||||||
|
# archive contains the signed binary.
|
||||||
|
run: |
|
||||||
|
mkdir -p stage/tor
|
||||||
|
cp build/bin/wowlet.exe stage/wowlet.exe
|
||||||
|
cp "contrib/depends/$HOST/Tor/tor.exe" stage/tor/tor.exe
|
||||||
|
(cp LICENSE stage/LICENSE.txt 2>/dev/null || cp COPYING stage/LICENSE.txt 2>/dev/null) || true
|
||||||
|
ls -la stage stage/tor
|
||||||
|
|
||||||
|
- name: Record unsigned build source and hash
|
||||||
|
run: python3 -B contrib/release/write_receipt.py --platform windows-build --file stage/wowlet.exe --output stage/wowlet-build.json
|
||||||
|
|
||||||
|
- name: upload unsigned staging tree
|
||||||
|
# v3, not v4: Gitea Actions implements the older artifact protocol and
|
||||||
|
# rejects @actions/artifact v2 clients with
|
||||||
|
# "GHESNotSupportedError ... not currently supported on GHES".
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: wowlet-x86_64-windows-unsigned
|
||||||
|
path: stage/
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
sign:
|
||||||
|
name: "Authenticode sign (hardware token broker)"
|
||||||
|
needs: build
|
||||||
|
runs-on: windows # gitea label windows:host -> desktop-iim2fs2
|
||||||
|
steps:
|
||||||
|
- name: Prepare the exact signing run workspace
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
if ($env:EXPECTED_SHA -notmatch '^[0-9a-f]{40}$' -or $env:GITHUB_SHA -ne $env:EXPECTED_SHA) {
|
||||||
|
throw "Signing run differs from the reviewed source"
|
||||||
|
}
|
||||||
|
foreach ($path in @('stage', 'unsigned', 'wowlet-build.json', 'wowlet-windows-receipt.json', 'wowlet-x86_64-windows.zip', 'wowlet-x86_64-windows-unsigned.zip')) {
|
||||||
|
if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force }
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: download unsigned staging tree
|
||||||
|
uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: wowlet-x86_64-windows-unsigned
|
||||||
|
path: stage
|
||||||
|
|
||||||
|
- name: Verify exact signing source and unsigned input
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
if ($env:EXPECTED_SHA -notmatch '^[0-9a-f]{40}$' -or $env:GITHUB_SHA -ne $env:EXPECTED_SHA) {
|
||||||
|
throw "Signing run differs from the reviewed source"
|
||||||
|
}
|
||||||
|
$build = Get-Content 'stage/wowlet-build.json' -Raw | ConvertFrom-Json
|
||||||
|
if ($build.dispatch_commit -ne $env:EXPECTED_SHA -or $build.platform -ne 'windows-build' -or $build.artifacts.Count -ne 1) {
|
||||||
|
throw "Unsigned build receipt differs from the reviewed signing run"
|
||||||
|
}
|
||||||
|
if ($build.artifacts[0].name -ne 'wowlet.exe' -or $build.artifacts[0].sha256 -ne (Get-FileHash 'stage/wowlet.exe' -Algorithm SHA256).Hash.ToLowerInvariant()) {
|
||||||
|
throw "Unsigned executable differs from its build receipt"
|
||||||
|
}
|
||||||
|
Move-Item 'stage/wowlet-build.json' 'wowlet-build.json'
|
||||||
|
|
||||||
|
- name: keep the unsigned tree for the reproducible archive
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
Copy-Item 'stage' 'unsigned' -Recurse -Force
|
||||||
|
|
||||||
|
- name: queue wowlet.exe for the signing broker and wait
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$queued = "wowlet-${env:GITHUB_RUN_ID}.exe"
|
||||||
|
$inPath = "C:\signing\in\$queued"
|
||||||
|
$outPath = "C:\signing\out\$queued"
|
||||||
|
|
||||||
|
if (Test-Path 'C:\signing\SIGNING_LOCKED') {
|
||||||
|
Get-Content 'C:\signing\SIGNING_LOCKED'
|
||||||
|
throw "The signing broker's circuit breaker is tripped. An operator must clear it."
|
||||||
|
}
|
||||||
|
|
||||||
|
Copy-Item 'stage\wowlet.exe' $inPath -Force
|
||||||
|
Set-Content -LiteralPath "$inPath.ready" -Value $env:GITHUB_RUN_ID
|
||||||
|
Write-Host "queued $queued"
|
||||||
|
|
||||||
|
$deadline = (Get-Date).AddMinutes(10)
|
||||||
|
while ((Get-Date) -lt $deadline) {
|
||||||
|
if (Test-Path "$outPath.done") { break }
|
||||||
|
if (Test-Path 'C:\signing\SIGNING_LOCKED') {
|
||||||
|
Get-Content 'C:\signing\SIGNING_LOCKED'
|
||||||
|
throw "The broker tripped its circuit breaker while signing this artifact."
|
||||||
|
}
|
||||||
|
Start-Sleep -Seconds 5
|
||||||
|
}
|
||||||
|
if (-not (Test-Path "$outPath.done")) { throw "Timed out waiting for the signing broker." }
|
||||||
|
|
||||||
|
Move-Item $outPath 'stage\wowlet.exe' -Force
|
||||||
|
Remove-Item "$outPath.done" -Force -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
- name: verify the published binary is signed and timestamped
|
||||||
|
# The broker verifies before releasing; this verifies again on the
|
||||||
|
# artifact that is actually about to ship. A signature or timestamp
|
||||||
|
# failure blocks distribution.
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$sig = Get-AuthenticodeSignature 'stage\wowlet.exe'
|
||||||
|
$sig | Format-List Status, StatusMessage,
|
||||||
|
@{n='Signer';e={$_.SignerCertificate.Subject}},
|
||||||
|
@{n='TimeStamper';e={$_.TimeStamperCertificate.Subject}}
|
||||||
|
if ($sig.Status -ne 'Valid') { throw "Authenticode status is $($sig.Status)" }
|
||||||
|
if (-not $sig.TimeStamperCertificate) { throw "Signature carries no RFC 3161 timestamp" }
|
||||||
|
if ($sig.SignerCertificate.Subject -notmatch 'Such Software LLC') {
|
||||||
|
throw "Unexpected signer: $($sig.SignerCertificate.Subject)"
|
||||||
|
}
|
||||||
|
Write-Host "SHA256 $((Get-FileHash 'stage\wowlet.exe' -Algorithm SHA256).Hash)"
|
||||||
|
|
||||||
|
- name: package both zips
|
||||||
|
# Two archives ship, deliberately. Authenticode mutates the exe, so a
|
||||||
|
# signed binary can never byte-match what someone reproduces from
|
||||||
|
# source. The unsigned archive stays reproducible and is what verifiers
|
||||||
|
# check; the signed one is what a normal user should download. Both
|
||||||
|
# hashes go in the PGP-signed hashes.txt.
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||||
|
|
||||||
|
# Not Compress-Archive: Windows PowerShell writes the OS separator
|
||||||
|
# into entry names, producing "tor\tor.exe". The ZIP format mandates
|
||||||
|
# forward slashes, and extractors that take the backslash literally
|
||||||
|
# leave a file called "tor\tor.exe" in the root instead of a tor
|
||||||
|
# directory -- at which point the bundled Tor is not where wowlet
|
||||||
|
# looks for it, and this wallet gives its daemon an exclusive
|
||||||
|
# --tx-proxy, so no Tor means no transaction relay at all.
|
||||||
|
# v5.1.0 shipped tor/tor.exe from `zip -X`; keep that shape.
|
||||||
|
function New-Archive([string] $Source, [string] $Destination) {
|
||||||
|
if (Test-Path $Destination) { Remove-Item $Destination -Force }
|
||||||
|
$root = (Resolve-Path $Source).Path.TrimEnd('\')
|
||||||
|
$zip = [System.IO.Compression.ZipFile]::Open($Destination, 'Create')
|
||||||
|
try {
|
||||||
|
foreach ($file in Get-ChildItem $root -Recurse -File | Sort-Object FullName) {
|
||||||
|
$entry = $file.FullName.Substring($root.Length + 1).Replace('\', '/')
|
||||||
|
[void][System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile(
|
||||||
|
$zip, $file.FullName, $entry, 'Optimal')
|
||||||
|
}
|
||||||
|
} finally { $zip.Dispose() }
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Archive 'unsigned' 'wowlet-x86_64-windows-unsigned.zip'
|
||||||
|
New-Archive 'stage' 'wowlet-x86_64-windows.zip'
|
||||||
|
|
||||||
|
# Prove the shape rather than trusting it: a backslash in any entry
|
||||||
|
# name fails the build.
|
||||||
|
foreach ($archive in @('wowlet-x86_64-windows.zip', 'wowlet-x86_64-windows-unsigned.zip')) {
|
||||||
|
$zip = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path $archive))
|
||||||
|
try {
|
||||||
|
$names = $zip.Entries | ForEach-Object { $_.FullName }
|
||||||
|
if ($names -match '\\') { throw "$archive contains a backslash entry name: $($names -join ', ')" }
|
||||||
|
if ($names -notcontains 'tor/tor.exe') { throw "$archive is missing tor/tor.exe (got: $($names -join ', '))" }
|
||||||
|
} finally { $zip.Dispose() }
|
||||||
|
"{0} {1} {2}" -f $archive, (Get-Item $archive).Length,
|
||||||
|
(Get-FileHash $archive -Algorithm SHA256).Hash
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Record signed candidate source, hashes and certificate
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$build = Get-Content 'wowlet-build.json' -Raw | ConvertFrom-Json
|
||||||
|
$sig = Get-AuthenticodeSignature 'stage/wowlet.exe'
|
||||||
|
if ($sig.Status -ne 'Valid' -or -not $sig.TimeStamperCertificate -or $sig.SignerCertificate.Subject -notmatch 'Such Software LLC') {
|
||||||
|
throw "Final executable signature is not valid, timestamped and from the expected publisher"
|
||||||
|
}
|
||||||
|
$files = @('wowlet-x86_64-windows.zip', 'wowlet-x86_64-windows-unsigned.zip') | ForEach-Object {
|
||||||
|
@{name=$_;size=(Get-Item $_).Length;sha256=(Get-FileHash $_ -Algorithm SHA256).Hash.ToLowerInvariant()}
|
||||||
|
}
|
||||||
|
$receipt = @{
|
||||||
|
schema_version=1;app='wowlet';version=$build.version;platform='windows';
|
||||||
|
dispatch_commit=$env:GITHUB_SHA;wallet_library_commit=$build.wallet_library_commit;
|
||||||
|
environment_run_id=$env:GITHUB_RUN_ID;environment_run_attempt=$env:GITHUB_RUN_ATTEMPT;
|
||||||
|
artifacts=@($files);unsigned_executable=$build.artifacts[0];
|
||||||
|
signed_executable_sha256=(Get-FileHash 'stage/wowlet.exe' -Algorithm SHA256).Hash.ToLowerInvariant();
|
||||||
|
signing=@{status=$sig.Status.ToString();timestamped=$true;subject=$sig.SignerCertificate.Subject;
|
||||||
|
certificate_thumbprint=$sig.SignerCertificate.Thumbprint;certificate_not_after=$sig.SignerCertificate.NotAfter.ToUniversalTime().ToString('o')}
|
||||||
|
}
|
||||||
|
$receipt | ConvertTo-Json -Depth 6 | Set-Content -Encoding UTF8 'wowlet-windows-receipt.json'
|
||||||
|
|
||||||
|
- name: upload Windows zips
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: wowlet-x86_64-windows
|
||||||
|
path: |
|
||||||
|
wowlet-x86_64-windows.zip
|
||||||
|
wowlet-x86_64-windows-unsigned.zip
|
||||||
|
wowlet-windows-receipt.json
|
||||||
|
if-no-files-found: error
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
name: Wowlet verify
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Check support guides and release source admission
|
||||||
|
run: make check
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
name: ci/gh-actions/build
|
name: ci/gh-actions/build
|
||||||
|
|
||||||
on: [push, pull_request, workflow_dispatch]
|
# Legacy builds are manual only. Release artifacts are built in Builds/wowlet.
|
||||||
|
on: [workflow_dispatch]
|
||||||
|
|
||||||
# NOTE (wownero-rebase branch): the Arch + macOS jobs from upstream Feather's build.yml are
|
# NOTE (wownero-rebase branch): the Arch + macOS jobs from upstream Feather's build.yml are
|
||||||
# temporarily disabled to get a fast, cheap iteration loop while bringing up the build against the
|
# temporarily disabled to get a fast, cheap iteration loop while bringing up the build against the
|
||||||
@@ -21,7 +22,7 @@ jobs:
|
|||||||
qt6-wayland-dev qt6-declarative-dev libzip-dev libsodium-dev libgcrypt20-dev libx11-xcb-dev
|
qt6-wayland-dev qt6-declarative-dev libzip-dev libsodium-dev libgcrypt20-dev libx11-xcb-dev
|
||||||
qml6-module-qtquick qml6-module-qtquick-layouts qml6-module-qtquick-window qml6-module-qtqml-workerscript qml6-module-qtqml-models
|
qml6-module-qtquick qml6-module-qtquick-layouts qml6-module-qtquick-window qml6-module-qtqml-workerscript qml6-module-qtqml-models
|
||||||
protobuf-compiler libprotobuf-dev libhidapi-dev libzxing-dev libusb-dev
|
protobuf-compiler libprotobuf-dev libhidapi-dev libzxing-dev libusb-dev
|
||||||
libusb-1.0-0-dev patchelf
|
libusb-1.0-0-dev libcom-err2 patchelf
|
||||||
- name: configure git
|
- name: configure git
|
||||||
run: git config --global --add safe.directory '*'
|
run: git config --global --add safe.directory '*'
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -54,7 +55,12 @@ jobs:
|
|||||||
cd build
|
cd build
|
||||||
# ARCH=x86-64: portable generic baseline. NEVER 'native' for a release — the CI runner's
|
# ARCH=x86-64: portable generic baseline. NEVER 'native' for a release — the CI runner's
|
||||||
# CPU (often newer than users') would bake in unsupported instructions → SIGILL on launch.
|
# CPU (often newer than users') would bake in unsupported instructions → SIGILL on launch.
|
||||||
cmake -DWITH_SCANNER=OFF -DARCH=x86-64 -DWOWNEROD_DIR=/tmp/wownerod-embed -DTOR_DIR=/tmp/tor-embed -DTOR_VERSION=15.0 ..
|
# wowlet: TOR_VERSION must EXACTLY match `tor --version` of the embedded binary, else
|
||||||
|
# TorManager::unpackBins() sees embedded > the unpacked copy and rewrites Tor every launch.
|
||||||
|
# The tor-expert-bundle's RELEASE number (15.0) is NOT its tor version (0.4.8.x) — derive it.
|
||||||
|
TORVER=$(/tmp/tor-embed/tor --version | grep -oE '[0-9]+(\.[0-9]+){3}' | head -1)
|
||||||
|
echo "embedded tor version: $TORVER"
|
||||||
|
cmake -DWITH_SCANNER=OFF -DARCH=x86-64 -DWOWNEROD_DIR=/tmp/wownerod-embed -DTOR_DIR=/tmp/tor-embed -DTOR_VERSION="$TORVER" ..
|
||||||
cmake --build . -j $(nproc)
|
cmake --build . -j $(nproc)
|
||||||
- name: package AppImage
|
- name: package AppImage
|
||||||
run: |
|
run: |
|
||||||
@@ -71,10 +77,20 @@ jobs:
|
|||||||
export EXTRA_QT_PLUGINS="svg;multimedia;tls"
|
export EXTRA_QT_PLUGINS="svg;multimedia;tls"
|
||||||
export QML_SOURCES_PATHS="$PWD/src/assets/qml"
|
export QML_SOURCES_PATHS="$PWD/src/assets/qml"
|
||||||
export PATH="$PWD:$PATH"
|
export PATH="$PWD:$PATH"
|
||||||
|
# linuxdeploy's AppImage excludelist assumes libcom_err.so.2 is present on the host.
|
||||||
|
# Minimal Debian and openSUSE systems may omit it, but it is a transitive runtime dependency.
|
||||||
|
LIBCOM_ERR="$(ldconfig -p | awk '$1 == "libcom_err.so.2" { print $NF; exit }')"
|
||||||
|
if [[ -z "$LIBCOM_ERR" || ! -f "$LIBCOM_ERR" ]]; then
|
||||||
|
echo "ERROR: libcom_err.so.2 is unavailable in the build image; cannot make a portable AppImage." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
# Populate AppDir (libs + Qt plugins + QML modules) — no --output yet, so we can patch QML first.
|
# Populate AppDir (libs + Qt plugins + QML modules) — no --output yet, so we can patch QML first.
|
||||||
./linuxdeploy --appdir AppDir --plugin qt \
|
./linuxdeploy --appdir AppDir --plugin qt \
|
||||||
|
--library "$LIBCOM_ERR" \
|
||||||
-d AppDir/usr/share/applications/wowlet.desktop \
|
-d AppDir/usr/share/applications/wowlet.desktop \
|
||||||
-i AppDir/usr/share/icons/hicolor/256x256/apps/wowlet.png
|
-i AppDir/usr/share/icons/hicolor/256x256/apps/wowlet.png
|
||||||
|
# Guard this packaging regression: the AppImage must carry the excluded system library.
|
||||||
|
bash contrib/AppImage/check-libcom-err.sh AppDir
|
||||||
# ---------------------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------------------
|
||||||
# Make the QML bundle COMPLETE and deterministic (no whack-a-mole).
|
# Make the QML bundle COMPLETE and deterministic (no whack-a-mole).
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
name: source checks
|
||||||
|
|
||||||
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docs:
|
||||||
|
name: Wowlet documentation gate
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Check generated documentation and support contacts
|
||||||
|
run: make check
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
name: ci/gh-actions/guix
|
name: ci/gh-actions/guix
|
||||||
|
|
||||||
on: [push, pull_request]
|
# Legacy builds are manual only. Release artifacts are built in Builds/wowlet.
|
||||||
|
on: [workflow_dispatch]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
cache-sources:
|
cache-sources:
|
||||||
|
|||||||
@@ -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,6 +1,6 @@
|
|||||||
[submodule "monero"]
|
[submodule "monero"]
|
||||||
path = monero
|
path = monero
|
||||||
url = https://codeberg.org/wownero/wownero.git
|
url = https://github.com/wownero/wownero.git
|
||||||
branch = gui
|
branch = gui
|
||||||
[submodule "src/third-party/polyseed"]
|
[submodule "src/third-party/polyseed"]
|
||||||
path = src/third-party/polyseed
|
path = src/third-party/polyseed
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
cmake_minimum_required(VERSION 3.18)
|
cmake_minimum_required(VERSION 3.18)
|
||||||
|
|
||||||
project(feather
|
project(feather
|
||||||
VERSION "5.0.0"
|
VERSION "5.1.5"
|
||||||
DESCRIPTION "A free Monero desktop wallet"
|
DESCRIPTION "A free Monero desktop wallet"
|
||||||
LANGUAGES CXX C ASM
|
LANGUAGES CXX C ASM
|
||||||
)
|
)
|
||||||
@@ -148,15 +148,19 @@ if(TOR_DIR)
|
|||||||
|
|
||||||
message(STATUS "Embedded Tor version: ${TOR_VERSION}")
|
message(STATUS "Embedded Tor version: ${TOR_VERSION}")
|
||||||
|
|
||||||
|
# wowlet: the target dir is not tracked in git — create it (BSD cp's usage
|
||||||
|
# error exits 64, which the ret check below does not catch)
|
||||||
|
execute_process(COMMAND bash -c "mkdir -p ${CMAKE_CURRENT_SOURCE_DIR}/src/assets/tor")
|
||||||
|
|
||||||
# Always copy Tor when doing a reproducible build to prevent old versions from getting included
|
# Always copy Tor when doing a reproducible build to prevent old versions from getting included
|
||||||
if (REPRODUCIBLE)
|
if (REPRODUCIBLE)
|
||||||
set(TOR_COPY_CMD "cp -a ${TOR_DIR}/* ${CMAKE_CURRENT_SOURCE_DIR}/src/assets/tor")
|
set(TOR_COPY_CMD "cp -a ${TOR_DIR}/* ${CMAKE_CURRENT_SOURCE_DIR}/src/assets/tor")
|
||||||
else()
|
else()
|
||||||
set(TOR_COPY_CMD "cp -au ${TOR_DIR}/* ${CMAKE_CURRENT_SOURCE_DIR}/src/assets/tor")
|
set(TOR_COPY_CMD "cp -a ${TOR_DIR}/* ${CMAKE_CURRENT_SOURCE_DIR}/src/assets/tor") # wowlet: BSD cp has no -u (macOS: illegal option, exit 64, silent no-op)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
execute_process(COMMAND bash -c "${TOR_COPY_CMD}" RESULT_VARIABLE ret)
|
execute_process(COMMAND bash -c "${TOR_COPY_CMD}" RESULT_VARIABLE ret)
|
||||||
if(ret EQUAL "1")
|
if(NOT ret EQUAL "0")
|
||||||
message(FATAL_ERROR "Tor copy failure: ${TOR_COPY_CMD}")
|
message(FATAL_ERROR "Tor copy failure: ${TOR_COPY_CMD}")
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
@@ -172,11 +176,11 @@ if(WOWNEROD_DIR)
|
|||||||
if (REPRODUCIBLE)
|
if (REPRODUCIBLE)
|
||||||
set(WOWNEROD_COPY_CMD "cp -a ${WOWNEROD_DIR}/* ${CMAKE_CURRENT_SOURCE_DIR}/src/assets/wownerod")
|
set(WOWNEROD_COPY_CMD "cp -a ${WOWNEROD_DIR}/* ${CMAKE_CURRENT_SOURCE_DIR}/src/assets/wownerod")
|
||||||
else()
|
else()
|
||||||
set(WOWNEROD_COPY_CMD "cp -au ${WOWNEROD_DIR}/* ${CMAKE_CURRENT_SOURCE_DIR}/src/assets/wownerod")
|
set(WOWNEROD_COPY_CMD "cp -a ${WOWNEROD_DIR}/* ${CMAKE_CURRENT_SOURCE_DIR}/src/assets/wownerod") # wowlet: BSD cp has no -u
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
execute_process(COMMAND bash -c "${WOWNEROD_COPY_CMD}" RESULT_VARIABLE ret)
|
execute_process(COMMAND bash -c "${WOWNEROD_COPY_CMD}" RESULT_VARIABLE ret)
|
||||||
if(ret EQUAL "1")
|
if(NOT ret EQUAL "0")
|
||||||
message(FATAL_ERROR "wownerod copy failure: ${WOWNEROD_COPY_CMD}")
|
message(FATAL_ERROR "wownerod copy failure: ${WOWNEROD_COPY_CMD}")
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
|||||||
@@ -13,4 +13,8 @@ clean:
|
|||||||
DEFAULT_GOAL := default
|
DEFAULT_GOAL := default
|
||||||
default: build
|
default: build
|
||||||
|
|
||||||
.PHONY: default build attest verify clean
|
check:
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s contrib/docs/tests -v
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s contrib/release/tests -v
|
||||||
|
|
||||||
|
.PHONY: default build attest verify clean check
|
||||||
|
|||||||
@@ -1,60 +1,44 @@
|
|||||||
# Feather Wallet
|
# Wowlet
|
||||||
|
|
||||||
Feather is a free Monero desktop wallet for Linux, Tails, macOS and Windows. It is written in C++ with the Qt framework.
|
Wowlet is a Wownero desktop wallet for Linux, macOS and Windows, built with C++ and
|
||||||
|
Qt. It is based on [Feather Wallet](https://github.com/feather-wallet/feather).
|
||||||
- **easy-to-use**, **small** and **fast** - Feather runs well on any modern hardware, including virtual machines and live operating systems.
|
|
||||||
- **beginner friendly**, but also caters to advanced Monero users by providing a [feature set](https://docs.featherwallet.org/guides/features) that is on par with the official CLI.
|
|
||||||
- ships with **sane defaults** that suit most users, but can also be configured for high or uncommon threat models.
|
|
||||||
- serves as a testing grounds for **experimental features** that may later be adopted in the reference wallets.
|
|
||||||
|
|
||||||
## Download
|
## Download
|
||||||
|
|
||||||
You can download Feather from **[featherwallet.org](https://featherwallet.org/download/)** or **[GitHub](https://github.com/feather-wallet/feather/releases)**.
|
Download Wowlet and its signed release hashes from
|
||||||
|
[Wowlet releases](https://github.com/wownero/wowlet/releases).
|
||||||
|
|
||||||
If you need help installing Feather, check the [installation documentation](https://docs.featherwallet.org/).
|
## Support
|
||||||
|
|
||||||
We recommend that you verify downloads with GPG. Releases are signed with our [release signing key](https://docs.featherwallet.org/guides/release-signing-key). The fingerprint is:
|
- Matrix: [#wownero-gen:wowne.ro](https://matrix.to/#/#wownero-gen:wowne.ro)
|
||||||
|
- IRC: `#wownero` on [OFTC](https://www.oftc.net/), or use
|
||||||
|
[webchat](https://webchat.oftc.net/?randomnick=1&channels=wownero)
|
||||||
|
- Email: [wownero@wownero.org](mailto:wownero@wownero.org)
|
||||||
|
- Bug reports: [Wowlet issues](https://github.com/wownero/wowlet/issues)
|
||||||
|
|
||||||
```
|
If nobody replies immediately, stay in the room and give volunteers time to respond.
|
||||||
8185 E158 A333 30C7 FD61 BC0D 1F76 E155 CEFB A71C
|
Include your Wowlet version, operating system, reproduction steps and any error
|
||||||
```
|
message. Never share your wallet seed, private keys, password or wallet files.
|
||||||
|
|
||||||
Windows releases are code-signed for free by [SignPath.io](https://about.signpath.io/), certificate by [SignPath Foundation](https://signpath.org/).
|
Report vulnerabilities privately using [SECURITY.md](SECURITY.md).
|
||||||
|
|
||||||
## Resources
|
|
||||||
|
|
||||||
* [Official Site](https://featherwallet.org)
|
|
||||||
* [Documentation](https://docs.featherwallet.org)
|
|
||||||
* [Git Repository](https://github.com/feather-wallet/feather)
|
|
||||||
* [Matrix](https://matrix.to/#/#feather:monero.social)
|
|
||||||
* IRC: `#feather` on [OFTC](https://www.oftc.net/)
|
|
||||||
* Mail: dev@featherwallet.org
|
|
||||||
|
|
||||||
If you need help with your wallet, please contact us via Matrix or IRC.
|
|
||||||
If you don't have an IRC client, you can join the room via [webchat](https://webchat.oftc.net/?randomnick=1&channels=feather).
|
|
||||||
If you don’t receive a response immediately please idle in the room.
|
|
||||||
|
|
||||||
## Release Builds
|
|
||||||
|
|
||||||
To learn how to run a bootstrappable release build, see: [contrib/guix/README.md](https://github.com/feather-wallet/feather/blob/master/contrib/guix/README.md)
|
|
||||||
|
|
||||||
For release attestations, see the [feather-sigs](http://github.com/feather-wallet/feather-sigs) repo.
|
|
||||||
|
|
||||||
For release policy, see: [RELEASE.md](https://github.com/feather-wallet/feather/blob/master/RELEASE.md)
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
If you are looking to set up a development environment for Feather, see [HACKING.md](https://github.com/feather-wallet/feather/blob/master/HACKING.md).
|
[GitHub](https://github.com/wownero/wowlet) is the canonical source repository.
|
||||||
|
Release builds run in our private Builds system. Signed release artifacts and
|
||||||
|
hashes are published on GitHub Releases. GitHub runs lightweight source checks;
|
||||||
|
its legacy build workflows require an explicit manual dispatch.
|
||||||
|
|
||||||
It is highly recommended that you join our Matrix or IRC channel if you are hacking on Feather.
|
The application includes an offline documentation browser under Help. Its guides
|
||||||
Idling in this channel is the best way to stay updated on best practices and new developments.
|
are generated by `contrib/docs/generate.py` from the Feather documentation submodule.
|
||||||
|
Wowlet-specific guides in `contrib/docs/overrides` take precedence over upstream
|
||||||
|
guides with the same filename. Edit those sources, then regenerate; files under
|
||||||
|
`src/assets/docs` are generated and must not be edited directly.
|
||||||
|
|
||||||
For information on how Feather is maintained, see: [MAINTENANCE.md](https://github.com/feather-wallet/feather/blob/master/MAINTENANCE.md)
|
Run the documentation regression checks with `make check`. The inherited build
|
||||||
|
instructions are in [HACKING.md](HACKING.md).
|
||||||
To report a security vulnerability, see: [SECURITY.md](https://github.com/feather-wallet/feather/blob/master/SECURITY.md)
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Feather is free and open-source software, [licensed under BSD-3](https://raw.githubusercontent.com/feather-wallet/feather/master/LICENSE).
|
Wowlet is free and open-source software under [BSD-3-Clause](LICENSE).
|
||||||
|
Upstream copyright notices are retained in the source.
|
||||||
Copyright (c) 2020-2025, The Monero Project
|
|
||||||
|
|||||||
@@ -1,46 +1,41 @@
|
|||||||
# Release process
|
# Wowlet release process
|
||||||
|
|
||||||
- Before a minor release:
|
GitHub `wownero/wowlet` owns source and public releases. Private `Builds/wowlet`
|
||||||
- Update `src/assets/restore_heights_monero_{mainnet,stagenet}.txt`
|
is the build ingress on our hardware. Fleet owns dispatch and publication plans.
|
||||||
- To obtain values, run `contrib/generate-restore-heights/heights.py`
|
A source merge, a build, a code signature and a published release are separate outcomes.
|
||||||
- Update default node lists in `src/assets/nodes.json`
|
|
||||||
- Bump `openssl`, `qt`, `tor_*` packages in `contrib/depends/packages`
|
1. Prepare and review a GitHub pull request with the version, wallet-library pin,
|
||||||
- Update or patch any statically linked dependencies that have known vulnerabilities
|
support documentation and release inputs. Publish a new library commit to its
|
||||||
- Run `feather-utils/depends/vulns.py` to check
|
canonical repository before pinning it. Keep source and dependency revisions exact.
|
||||||
- Rebase on top of latest Monero version
|
2. `make check` runs the cheap source checks. Run the private Linux, Windows and
|
||||||
- Update `src/assets/ack.txt`
|
macOS workflows through reviewed Fleet admission with `expected_sha` equal to
|
||||||
- Update documentation (`feather-wallet/feather-docs`) and `external/feather-docs` submodule
|
the protected Builds commit. Preserve canonical source and Builds commit identities
|
||||||
- Update the version number in `CMakeLists.txt`
|
separately. Do not run release builds on GitHub.
|
||||||
- A hardfork-ready release must bump major version
|
3. The build helpers verify embedded Wownero archives against `contrib/release/wownerod.json`.
|
||||||
- Create an annotated tag (`git tag x.x.x -a`)
|
That file records the reviewed checksum provenance. Do not describe an unsigned
|
||||||
- Tag must match version in `CMakeLists.txt`
|
checksum list as a PGP verification.
|
||||||
- Only commits that update the version number in `CMakeLists.txt` may be tagged
|
4. Collect artifacts by their run and artifact identities. Require a Linux AppImage,
|
||||||
- Push the master branch and tags
|
macOS archive, signed Windows archive and unsigned Windows archive. Check archive
|
||||||
- `git push --tags origin master`
|
paths, bundled runtimes and packaging requirements. Never select an artifact by size.
|
||||||
- Run `guix` builds in a clean repo:
|
5. Require Developer ID signing, accepted notarization and stapling for macOS.
|
||||||
- ```bash
|
Require valid, timestamped Such Software LLC Authenticode signing for Windows.
|
||||||
git clone https://github.com/feather-wallet/feather.git
|
The EV token stays on its signing host; CI never receives its password or key.
|
||||||
cd feather
|
6. Record any manual wallet checks actually performed. Manual testing and independent
|
||||||
git checkout <TAG>
|
reproducibility are separate evidence; successful builds do not prove either.
|
||||||
git submodule update --init --recursive
|
The unsigned Windows archive is provided for reproduction, without claiming an
|
||||||
./contrib/guix/guix-build
|
independent reproduction occurred.
|
||||||
```
|
7. Create `hashes.txt` over the four final archives. Sign it on the owner's signing
|
||||||
- Alternatively, run `feather-utils/guix/run-build.sh`
|
machine with the existing release key `9F9930300530DF724761B7055A881347310EB79F`.
|
||||||
- Attest the builds (`contrib/guix/guix-attest`) and PR to `feather/feather-sigs`.
|
Verify the detached signature and all hashes before publication. Never transfer
|
||||||
- Wait until at least one other person has independently verified reproducibility
|
the private key or put a passphrase in a command, environment or log.
|
||||||
- If builds are not reproducible: fix any reproducibility defects and bump patch version. Do not sign or release non-reproducible builds.
|
8. Create an annotated `vX.Y.Z` tag from the exact reviewed canonical source, matching
|
||||||
- Sign release artifacts and hashlists.
|
`CMakeLists.txt`. Use Fleet's reviewed publication plan to upload the four archives,
|
||||||
- Transfer files in `guix-build-x.x.x/output` to release signing machine
|
`hashes.txt` and `hashes.txt.asc` to GitHub Releases. Refuse replacement of existing
|
||||||
- Run `feather-utils/release/make-release.sh`
|
tags or assets; recover partial publication from retained intent.
|
||||||
- Update the site (`feather-wallet/feather-site`)
|
9. Download the public assets again and verify their hashes and PGP signature. Record
|
||||||
- Run the `feather-utils/site/bump-version.py` script to create a template commit.
|
the tag, source commit, Builds commit, run identities, signing evidence and public
|
||||||
- Edit the changelog in `content/changelog`
|
URL in the private journal. Release notes should say when security fixes are included.
|
||||||
- If a release includes security fixes add "(includes security fixes)" to the title.
|
|
||||||
- Update the version number, file sizes and paths in `data/release.json`
|
Additional architectures and Guix builds remain separate lanes. Do not claim a
|
||||||
- Upload releases, signatures and signed hashlists.
|
platform is released merely because its workflow exists. Announcements are an
|
||||||
- Follow the directory structure defined in `MainWindow::onShowUpdateCheck`.
|
owner action, separate from publishing the release files.
|
||||||
- Make `depends` source files [available](https://featherwallet.org/files/sources/):
|
|
||||||
- `make -C contrib/depends download`
|
|
||||||
- Announce release on irc/Matrix
|
|
||||||
- Update websocket servers to notify clients of new release
|
|
||||||
- Wait up to 7 days to allow for bug reports before major rollout
|
|
||||||
|
|||||||
@@ -2,43 +2,11 @@
|
|||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
Please do not open an issue to report security issues.
|
Report Wowlet security vulnerabilities privately to
|
||||||
|
[wownero@wownero.org](mailto:wownero@wownero.org).
|
||||||
|
Please do not disclose them in public issues or community chat before the
|
||||||
|
maintainers have had time to investigate.
|
||||||
|
|
||||||
To report a vulnerability email to dev@featherwallet.org
|
Include the affected Wowlet version, operating system, expected impact and steps
|
||||||
|
to reproduce the issue. Use a disposable wallet for any reproduction and never
|
||||||
The following keys may be used to communicate sensitive information to developers:
|
send a real wallet seed, private keys, password or wallet files.
|
||||||
|
|
||||||
| Name | Fingerprint |
|
|
||||||
|---------|---------------------------------------------------|
|
|
||||||
| tobtoht | E87B D921 CDD8 85C9 D78A 38C5 E45B 10DD 027D 2472 |
|
|
||||||
|
|
||||||
Public keys can be found in [`utils/pubkeys`](utils/pubkeys).
|
|
||||||
|
|
||||||
## Bug Bounty Program
|
|
||||||
|
|
||||||
A bounty may be rewarded to a vulnerability report **if and only if** the issue can result in **a loss of funds**.
|
|
||||||
|
|
||||||
You must describe a **plausible scenario** in which a loss of funds can occur (or has occurred) that **isn't solely attributable to user error**.
|
|
||||||
|
|
||||||
Only **the code** of the **latest tagged release** of **[this repository](https://github.com/feather-wallet/feather/)** is in scope.
|
|
||||||
|
|
||||||
**The bounty can only be rewarded in XMR**. The bounty amount for your report is determined by the maintainers and ranges from USD 150 to USD 3000 (in terms of XMR) and depends on the severity of the issue and other factors.
|
|
||||||
|
|
||||||
Clarifications on scope:
|
|
||||||
|
|
||||||
- The issue **must be present in a [signed](https://docs.featherwallet.org/guides/release-signing-key) [release build](https://github.com/feather-wallet/feather/blob/master/contrib/guix/README.md)**. Custom builds, including distribution packages, are out of scope.
|
|
||||||
- The developers **must be able to reproduce and fix the issue**. If the issue cannot be fixed **in our code** for any reason, it is out of scope.
|
|
||||||
- The live [websites](https://github.com/feather-wallet/feather-site/blob/master/mirrors.txt) and their repositories are out of scope.
|
|
||||||
- Loss of funds due to malware on the user's machine is out of scope.
|
|
||||||
- Memory imaging, including cold boot attacks, is out of scope.
|
|
||||||
- Social engineering against users is out of scope. This includes messages received from the [websocket server](https://docs.featherwallet.org/guides/websocket).
|
|
||||||
- Any form of coercion, physical or psychological, is out of scope.
|
|
||||||
- Vulnerabilities that are attributable to hardware are out of scope.
|
|
||||||
- If the issue was fixed in the `master` branch before we receive your report, it is invalid and not eligible for a bounty from this program.
|
|
||||||
- If the vulnerability involves binary exploitation, we may ask you to provide a proof of concept of secret key exfiltration.
|
|
||||||
- Vulnerabilities that are present in the monero submodule but were not introduced in patches made by the Feather developers must
|
|
||||||
be reported [upstream](https://github.com/monero-project/meta/blob/master/VULNERABILITY_RESPONSE_PROCESS.md) and are not eligible for a bounty from this program.
|
|
||||||
- Vulnerabilities that are present in any of our third-party dependencies must be reported upstream and are not eligible for a bounty from this program.
|
|
||||||
- Vulnerabilities that are present in [supported hardware wallets](https://docs.featherwallet.org/guides/hardware-wallet-support) must be reported upstream and are not eligible for a bounty from this program.
|
|
||||||
- A bounty will not be awarded if the reported vulnerability was already known. We may make an exception if you demonstrate that the severity of the issue was underestimated and no immediate fix was planned.
|
|
||||||
- If, during your research, you disrupt Feather's release infrastructure or services, or attempt to coerce its developers, you will not be awarded a bounty.
|
|
||||||
|
|||||||
@@ -5,4 +5,4 @@ echo $HERE
|
|||||||
|
|
||||||
# Tails 5.8 sets QT_QPA_PLATFORM to wayland which breaks our app
|
# Tails 5.8 sets QT_QPA_PLATFORM to wayland which breaks our app
|
||||||
export QT_QPA_PLATFORM=xcb
|
export QT_QPA_PLATFORM=xcb
|
||||||
${APPDIR}/usr/bin/feather $*
|
${APPDIR}/usr/bin/wowlet $*
|
||||||
|
|||||||
@@ -5,29 +5,29 @@ unset SOURCE_DATE_EPOCH
|
|||||||
|
|
||||||
# Manually create the AppImage (reproducibly) since linuxdeployqt is not able to create cross-compiled AppImages
|
# Manually create the AppImage (reproducibly) since linuxdeployqt is not able to create cross-compiled AppImages
|
||||||
|
|
||||||
APPDIR="$PWD/feather.AppDir"
|
APPDIR="$PWD/wowlet.AppDir"
|
||||||
|
|
||||||
mkdir -p "$APPDIR"
|
mkdir -p "$APPDIR"
|
||||||
mkdir -p "$APPDIR/usr/share/applications/"
|
mkdir -p "$APPDIR/usr/share/applications/"
|
||||||
mkdir -p "$APPDIR/usr/bin"
|
mkdir -p "$APPDIR/usr/bin"
|
||||||
|
|
||||||
cp "src/assets/feather.desktop" "$APPDIR/usr/share/applications/feather.desktop"
|
cp "src/assets/wowlet.desktop" "$APPDIR/usr/share/applications/wowlet.desktop"
|
||||||
cp "src/assets/feather.desktop" "$APPDIR/feather.desktop"
|
cp "src/assets/wowlet.desktop" "$APPDIR/wowlet.desktop"
|
||||||
cp "src/assets/images/appicons/64x64.png" "$APPDIR/feather.png"
|
cp "src/assets/images/appicons/64x64.png" "$APPDIR/wowlet.png"
|
||||||
cp "build/bin/feather" "$APPDIR/usr/bin/feather"
|
cp "build/bin/wowlet" "$APPDIR/usr/bin/wowlet"
|
||||||
chmod +x "$APPDIR/usr/bin/feather"
|
chmod +x "$APPDIR/usr/bin/wowlet"
|
||||||
|
|
||||||
cp "contrib/AppImage/AppRun" "$APPDIR/"
|
cp "contrib/AppImage/AppRun" "$APPDIR/"
|
||||||
chmod +x "$APPDIR/AppRun"
|
chmod +x "$APPDIR/AppRun"
|
||||||
|
|
||||||
find feather.AppDir/ -exec touch -h -a -m -t 202101010100.00 {} \;
|
find wowlet.AppDir/ -exec touch -h -a -m -t 202101010100.00 {} \;
|
||||||
|
|
||||||
mksquashfs feather.AppDir feather.squashfs -comp zstd -info -root-owned -no-xattrs -noappend -fstime 0
|
mksquashfs wowlet.AppDir wowlet.squashfs -comp zstd -info -root-owned -no-xattrs -noappend -fstime 0
|
||||||
# mksquashfs writes a timestamp to the header
|
# mksquashfs writes a timestamp to the header
|
||||||
printf '\x00\x00\x00\x00' | dd conv=notrunc of=feather.squashfs bs=1 seek=$((0x8))
|
printf '\x00\x00\x00\x00' | dd conv=notrunc of=wowlet.squashfs bs=1 seek=$((0x8))
|
||||||
|
|
||||||
rm -f feather.AppImage
|
rm -f wowlet.AppImage
|
||||||
|
|
||||||
cat "${GITHUB_WORKSPACE:-/feather}/contrib/depends/${HOST}/runtime" >> feather.AppImage
|
cat "${GITHUB_WORKSPACE:-/feather}/contrib/depends/${HOST}/runtime" >> wowlet.AppImage
|
||||||
cat feather.squashfs >> feather.AppImage
|
cat wowlet.squashfs >> wowlet.AppImage
|
||||||
chmod a+x feather.AppImage
|
chmod a+x wowlet.AppImage
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
appdir="${1:?usage: check-libcom-err.sh APPDIR}"
|
||||||
|
library="$(find "$appdir/usr/lib" -name libcom_err.so.2 -print -quit 2>/dev/null || true)"
|
||||||
|
if [[ -z "$library" || ! -e "$library" ]]; then
|
||||||
|
echo "ERROR: AppDir is missing the bundled libcom_err.so.2 runtime library." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -198,6 +198,14 @@ ifneq ($(host),$(build))
|
|||||||
$(1)_cmake += -DCMAKE_SYSTEM_NAME=$($(host_os)_cmake_system)
|
$(1)_cmake += -DCMAKE_SYSTEM_NAME=$($(host_os)_cmake_system)
|
||||||
$(1)_cmake += -DCMAKE_C_COMPILER_TARGET=$(host)
|
$(1)_cmake += -DCMAKE_C_COMPILER_TARGET=$(host)
|
||||||
$(1)_cmake += -DCMAKE_CXX_COMPILER_TARGET=$(host)
|
$(1)_cmake += -DCMAKE_CXX_COMPILER_TARGET=$(host)
|
||||||
|
# wowlet: confine find_package to the depends sysroot so cross cmake builds don't grab host
|
||||||
|
# /usr -dev packages (libzip's find_package(ZLIB) was picking up host zlib1g-dev). Mirrors
|
||||||
|
# toolchain.cmake.in; no-op on clean CI, needed on dev boxes with -dev libs. Upstreamable.
|
||||||
|
$(1)_cmake += -DCMAKE_FIND_ROOT_PATH=$(host_prefix)
|
||||||
|
$(1)_cmake += -DCMAKE_FIND_ROOT_PATH_MODE_PROGRAM=NEVER
|
||||||
|
$(1)_cmake += -DCMAKE_FIND_ROOT_PATH_MODE_LIBRARY=ONLY
|
||||||
|
$(1)_cmake += -DCMAKE_FIND_ROOT_PATH_MODE_INCLUDE=ONLY
|
||||||
|
$(1)_cmake += -DCMAKE_FIND_ROOT_PATH_MODE_PACKAGE=ONLY
|
||||||
endif
|
endif
|
||||||
endif
|
endif
|
||||||
endef
|
endef
|
||||||
|
|||||||
@@ -43,6 +43,15 @@ SET(absl_ROOT @prefix@)
|
|||||||
SET(NATIVE_BIN_PATH @prefix@/native/bin CACHE FILEPATH "path to native binaries" FORCE)
|
SET(NATIVE_BIN_PATH @prefix@/native/bin CACHE FILEPATH "path to native binaries" FORCE)
|
||||||
SET(LRELEASE_PATH @prefix@/native/bin CACHE FILEPATH "path to lrelease" FORCE)
|
SET(LRELEASE_PATH @prefix@/native/bin CACHE FILEPATH "path to lrelease" FORCE)
|
||||||
|
|
||||||
|
# A cross Qt that carries qtdeclarative makes find_package(Qt6) demand a HOST Qt,
|
||||||
|
# because QML needs host-side tools (qmlcachegen, qmlimportscanner,
|
||||||
|
# qmltyperegistrar). depends already builds one: native_qt installs to
|
||||||
|
# native/qt-host and qt.mk hands that path to the cross Qt's own configure. Our
|
||||||
|
# build never heard about it, so Qt6Config refused with "please set the
|
||||||
|
# QT_HOST_PATH cache variable" and the Windows cross build has failed since
|
||||||
|
# qtdeclarative landed. Same path, stated once more for the consuming project.
|
||||||
|
SET(QT_HOST_PATH @prefix@/native/qt-host CACHE PATH "host Qt for cross QML tooling")
|
||||||
|
|
||||||
if(NOT CMAKE_SYSTEM_NAME STREQUAL "Android")
|
if(NOT CMAKE_SYSTEM_NAME STREQUAL "Android")
|
||||||
SET(LIBUNWIND_INCLUDE_DIR @prefix@/include)
|
SET(LIBUNWIND_INCLUDE_DIR @prefix@/include)
|
||||||
SET(LIBUNWIND_LIBRARIES @prefix@/lib/libunwind.a)
|
SET(LIBUNWIND_LIBRARIES @prefix@/lib/libunwind.a)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import os
|
|||||||
import glob
|
import glob
|
||||||
|
|
||||||
DOCS_DIR = "external/feather-docs/content/_guides"
|
DOCS_DIR = "external/feather-docs/content/_guides"
|
||||||
|
OVERRIDES_DIR = "contrib/docs/overrides"
|
||||||
OUT_DIR = "src/assets/docs"
|
OUT_DIR = "src/assets/docs"
|
||||||
|
|
||||||
CATEGORY_MAP = {
|
CATEGORY_MAP = {
|
||||||
@@ -21,9 +22,12 @@ outfiles = glob.glob(f"{OUT_DIR}/*.md")
|
|||||||
for file in outfiles:
|
for file in outfiles:
|
||||||
os.remove(file)
|
os.remove(file)
|
||||||
|
|
||||||
files = glob.glob(f"{DOCS_DIR}/*.md")
|
# Wowlet owns its support contacts. Local sources take precedence so regenerating
|
||||||
|
# the bundled docs cannot restore Feather's contact details.
|
||||||
|
files = {os.path.basename(path): path for path in glob.glob(f"{DOCS_DIR}/*.md")}
|
||||||
|
files.update({os.path.basename(path): path for path in glob.glob(f"{OVERRIDES_DIR}/*.md")})
|
||||||
|
|
||||||
for file in files:
|
for file in sorted(files.values()):
|
||||||
with open(file) as f:
|
with open(file) as f:
|
||||||
doc = f.read()
|
doc = f.read()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
title: "How to report an issue"
|
||||||
|
nav_title: "Report an issue"
|
||||||
|
category: "help"
|
||||||
|
---
|
||||||
|
|
||||||
|
For help with Wowlet, contact the Wownero community:
|
||||||
|
|
||||||
|
- Matrix: [#wownero-gen:wowne.ro](https://matrix.to/#/#wownero-gen:wowne.ro)
|
||||||
|
- IRC: `#wownero` on [OFTC](https://www.oftc.net/)
|
||||||
|
- Email: [wownero@wownero.org](mailto:wownero@wownero.org)
|
||||||
|
- Bug reports: [Wowlet issues](https://github.com/wownero/wowlet/issues)
|
||||||
|
|
||||||
|
For live support, use Matrix or IRC. If you do not have an IRC client, join
|
||||||
|
[#wownero in your browser](https://webchat.oftc.net/?randomnick=1&channels=wownero).
|
||||||
|
If nobody replies immediately, stay in the room and give volunteers time to respond.
|
||||||
|
|
||||||
|
Please include your Wowlet version, operating system, the steps that caused the
|
||||||
|
problem, and any error message. Review **Help → Debug Info** before sharing it and
|
||||||
|
remove any personal information. Never share your wallet seed, private keys or
|
||||||
|
password, and do not upload your wallet files.
|
||||||
|
|
||||||
|
Report security vulnerabilities privately to
|
||||||
|
[wownero@wownero.org](mailto:wownero@wownero.org), rather than in a public issue or
|
||||||
|
chat. See the [Wowlet security policy](https://github.com/wownero/wowlet/blob/master/SECURITY.md).
|
||||||
|
|
||||||
|
Downloads and signed release hashes are available from
|
||||||
|
[Wowlet releases](https://github.com/wownero/wowlet/releases), also linked from
|
||||||
|
[wownero.org](https://wownero.org/).
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateDocsTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.directory = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.directory.cleanup)
|
||||||
|
self.root = Path(self.directory.name)
|
||||||
|
self.upstream = self.root / "external/feather-docs/content/_guides"
|
||||||
|
self.upstream.mkdir(parents=True)
|
||||||
|
self.output = self.root / "src/assets/docs"
|
||||||
|
self.output.mkdir(parents=True)
|
||||||
|
shutil.copytree(ROOT / "contrib/docs/overrides", self.root / "contrib/docs/overrides")
|
||||||
|
|
||||||
|
def write_upstream(self, name, body):
|
||||||
|
(self.upstream / name).write_text(
|
||||||
|
'---\ntitle: "Upstream guide"\nnav_title: "Upstream"\ncategory: "help"\n---\n'
|
||||||
|
+ body
|
||||||
|
)
|
||||||
|
|
||||||
|
def generate(self):
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, "-B", str(ROOT / "contrib/docs/generate.py")],
|
||||||
|
cwd=self.root, capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||||
|
|
||||||
|
def assert_wownero_contacts(self, document):
|
||||||
|
urls = [urlsplit(url) for url in re.findall(r"\]\(([^)]+)\)", document)]
|
||||||
|
self.assertTrue(any(
|
||||||
|
url.hostname == "matrix.to" and url.fragment == "/#wownero-gen:wowne.ro"
|
||||||
|
for url in urls
|
||||||
|
), "Matrix must lead to Wownero's published room")
|
||||||
|
self.assertTrue(any(
|
||||||
|
url.hostname == "webchat.oftc.net"
|
||||||
|
and "wownero" in parse_qs(url.query).get("channels", [])
|
||||||
|
for url in urls
|
||||||
|
), "Webchat must join Wownero's IRC channel")
|
||||||
|
self.assertTrue(any(
|
||||||
|
url.hostname == "github.com" and url.path == "/wownero/wowlet/issues"
|
||||||
|
for url in urls
|
||||||
|
), "Bug reports must go to the Wowlet tracker")
|
||||||
|
self.assertTrue(any(
|
||||||
|
url.scheme == "mailto" and url.path == "wownero@wownero.org"
|
||||||
|
for url in urls
|
||||||
|
), "Email must reach the published Wownero contact")
|
||||||
|
for url in urls:
|
||||||
|
self.assertNotIn("feather", url.geturl().lower(), "Support must not route to Feather")
|
||||||
|
|
||||||
|
def test_support_override_survives_regeneration(self):
|
||||||
|
self.write_upstream("report_an_issue.md", "Upstream support contacts")
|
||||||
|
self.write_upstream("accounts.md", "An upstream guide without an override")
|
||||||
|
(self.output / "stale.md").write_text("Obsolete generated guide")
|
||||||
|
for body in ("Upstream support contacts", "Changed upstream support contacts"):
|
||||||
|
self.write_upstream("report_an_issue.md", body)
|
||||||
|
self.generate()
|
||||||
|
support = (self.output / "report_an_issue.md").read_text()
|
||||||
|
self.assert_wownero_contacts(support)
|
||||||
|
self.assertNotIn(body, support)
|
||||||
|
self.assertRegex(support, r"(?m)^\[nav_title\]: # \(.+\)$")
|
||||||
|
self.assertRegex(support, r"(?m)^\[category\]: # \(.+\)$")
|
||||||
|
self.assertIn("An upstream guide without an override", (self.output / "accounts.md").read_text())
|
||||||
|
self.assertFalse((self.output / "stale.md").exists())
|
||||||
|
|
||||||
|
def test_readme_and_security_contacts_agree_with_bundled_help(self):
|
||||||
|
readme = (ROOT / "README.md").read_text()
|
||||||
|
support = readme.split("## Support\n", 1)[1].split("\n## ", 1)[0]
|
||||||
|
self.assert_wownero_contacts(support)
|
||||||
|
policy = (ROOT / "SECURITY.md").read_text()
|
||||||
|
self.assertIn("mailto:wownero@wownero.org", policy)
|
||||||
|
self.assertNotIn("featherwallet.org", policy)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -34,13 +34,13 @@
|
|||||||
<string>????</string>
|
<string>????</string>
|
||||||
|
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>feather</string>
|
<string>wowlet</string>
|
||||||
|
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>Feather</string>
|
<string>Wowlet</string>
|
||||||
|
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
<string>org.monero-project.feather</string>
|
<string>org.wownero.wowlet</string>
|
||||||
|
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>Camera is used to scan QR codes on demand.</string>
|
<string>Camera is used to scan QR codes on demand.</string>
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""Refuse a build whose resolved source differs from its reviewed request."""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
|
def check(expected, reported, checked_out):
|
||||||
|
if not re.fullmatch(r"[0-9a-f]{40}", expected or ""):
|
||||||
|
raise ValueError("expected_sha must identify the exact reviewed commit")
|
||||||
|
if reported != expected:
|
||||||
|
raise ValueError("provider run head differs from expected_sha")
|
||||||
|
if checked_out != expected:
|
||||||
|
raise ValueError("checked-out source differs from expected_sha")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
head = subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip()
|
||||||
|
try:
|
||||||
|
check(os.environ.get("EXPECTED_SHA"), os.environ.get("GITHUB_SHA"), head)
|
||||||
|
except ValueError as error:
|
||||||
|
raise SystemExit(str(error)) from None
|
||||||
|
print("Exact requested build source verified")
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""Fetch the pinned daemon archive and copy only its verified daemon binary."""
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
import shutil
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
import urllib.request
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
MAX_ARCHIVE = 300 * 1024 * 1024
|
||||||
|
MAX_BINARY = 128 * 1024 * 1024
|
||||||
|
TARGETS = {"x86_64-linux-gnu", "x86_64-w64-mingw32", "aarch64-apple-darwin11", "x86_64-apple-darwin11"}
|
||||||
|
|
||||||
|
|
||||||
|
def copy_daemon(archive, destination, windows):
|
||||||
|
name = "wownerod.exe" if windows else "wownerod"
|
||||||
|
if windows:
|
||||||
|
container = zipfile.ZipFile(archive)
|
||||||
|
members = [m for m in container.infolist() if PurePosixPath(m.filename).name == name]
|
||||||
|
valid = lambda m: not m.is_dir() and (m.external_attr >> 16 & 0o170000) in (0, 0o100000) and 0 < m.file_size <= MAX_BINARY
|
||||||
|
reader = container.open
|
||||||
|
else:
|
||||||
|
container = tarfile.open(archive, "r:bz2")
|
||||||
|
members = [m for m in container.getmembers() if PurePosixPath(m.name).name == name]
|
||||||
|
valid = lambda m: m.isfile() and 0 < m.size <= MAX_BINARY
|
||||||
|
reader = container.extractfile
|
||||||
|
with container:
|
||||||
|
if len(members) != 1 or not valid(members[0]):
|
||||||
|
raise ValueError("Pinned archive must contain exactly one regular daemon binary")
|
||||||
|
# Do not extract archive paths, symlinks or unrelated files.
|
||||||
|
with reader(members[0]) as source, (destination / name).open("xb") as output:
|
||||||
|
shutil.copyfileobj(source, output)
|
||||||
|
(destination / name).chmod(0o755)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch(target, destination):
|
||||||
|
manifest = json.loads(Path(__file__).with_name("wownerod.json").read_text())
|
||||||
|
suffix = ".zip" if target.endswith("mingw32") else ".tar.bz2"
|
||||||
|
filename = f"wownero-{target}-{manifest['version']}{suffix}"
|
||||||
|
expected = manifest["archives"][filename]
|
||||||
|
destination.mkdir(parents=True, exist_ok=True)
|
||||||
|
with tempfile.TemporaryDirectory(prefix="wowlet-daemon-") as staging:
|
||||||
|
archive = Path(staging) / filename
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
size = 0
|
||||||
|
url = f"https://github.com/wownero/wownero/releases/download/{manifest['version']}/{filename}"
|
||||||
|
with urllib.request.urlopen(url, timeout=120) as response, archive.open("xb") as output:
|
||||||
|
if not response.geturl().startswith("https://"):
|
||||||
|
raise ValueError("Daemon download redirected outside HTTPS")
|
||||||
|
while chunk := response.read(1024 * 1024):
|
||||||
|
size += len(chunk)
|
||||||
|
if size > MAX_ARCHIVE:
|
||||||
|
raise ValueError("Daemon archive exceeds size bound")
|
||||||
|
digest.update(chunk)
|
||||||
|
output.write(chunk)
|
||||||
|
if digest.hexdigest() != expected:
|
||||||
|
raise ValueError("Daemon archive SHA-256 differs from the reviewed release pin")
|
||||||
|
copy_daemon(archive, destination, target.endswith("mingw32"))
|
||||||
|
print(f"Verified {filename}: {expected}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--target", required=True, choices=sorted(TARGETS))
|
||||||
|
parser.add_argument("--destination", required=True, type=Path)
|
||||||
|
args = parser.parse_args()
|
||||||
|
fetch(args.target, args.destination)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"""Sign one private candidate without placing passwords in process arguments.
|
||||||
|
|
||||||
|
Apple SecurityTool reads one quoted operation from stdin in interactive mode:
|
||||||
|
https://github.com/apple-oss-distributions/SecurityTool/blob/main/security.c
|
||||||
|
Output stays captured because native diagnostics may repeat supplied input.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
from contextlib import contextmanager
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import secrets
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
FIELDS = (
|
||||||
|
"APPLE_CERTIFICATE", "APPLE_CERTIFICATE_PASSWORD", "APPLE_SIGNING_IDENTITY",
|
||||||
|
"APPLE_API_ISSUER", "APPLE_API_KEY", "APPLE_API_KEY_P8_BASE64",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def argument(value):
|
||||||
|
if any(ord(char) < 32 or ord(char) == 127 for char in value):
|
||||||
|
raise ValueError("Keychain input contains an unsupported control character")
|
||||||
|
return '"' + value.replace('\\', '\\\\').replace('"', '\\"') + '"'
|
||||||
|
|
||||||
|
|
||||||
|
def child_environment():
|
||||||
|
return {key: os.environ[key] for key in ("HOME", "PATH", "TMPDIR", "LANG", "DEVELOPER_DIR") if key in os.environ}
|
||||||
|
|
||||||
|
|
||||||
|
def security(operation, *values):
|
||||||
|
if operation not in {
|
||||||
|
"create-keychain", "set-keychain-settings", "unlock-keychain", "import",
|
||||||
|
"set-key-partition-list", "delete-keychain", "list-keychains",
|
||||||
|
}:
|
||||||
|
raise ValueError("Keychain operation is outside candidate signing")
|
||||||
|
command = " ".join(argument(value) for value in (operation, *values)) + "\n"
|
||||||
|
if len(command.encode()) > 1024:
|
||||||
|
raise ValueError("Keychain input exceeds the bounded operation size")
|
||||||
|
result = subprocess.run(
|
||||||
|
["/usr/bin/security", "-i", "-q"], input=command,
|
||||||
|
text=True, capture_output=True, env=child_environment(), timeout=90,
|
||||||
|
)
|
||||||
|
if result.returncode:
|
||||||
|
# Never forward diagnostics or operation data. Report recognized causes.
|
||||||
|
causes = {
|
||||||
|
"MAC verification failed": "PKCS12 encoding or password verification failed",
|
||||||
|
"User interaction is not allowed": "keychain requires interactive access",
|
||||||
|
"already exists": "ephemeral keychain or imported identity already exists",
|
||||||
|
"could not be found": "requested keychain item was not found",
|
||||||
|
}
|
||||||
|
cause = next((cause for marker, cause in causes.items() if marker in result.stderr),
|
||||||
|
"native keychain operation returned an error")
|
||||||
|
raise RuntimeError(f"{operation}: {cause} (exit {result.returncode}); raw diagnostics withheld")
|
||||||
|
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def run(arguments, *, timeout=120):
|
||||||
|
result = subprocess.run(arguments, text=True, capture_output=True,
|
||||||
|
env=child_environment(), timeout=timeout)
|
||||||
|
if result.returncode:
|
||||||
|
causes = {
|
||||||
|
"no identity found": "expected signing identity was not found",
|
||||||
|
"User interaction is not allowed": "signing key requires interactive access",
|
||||||
|
"errSecInternalComponent": "keychain denied access to the signing identity",
|
||||||
|
"not signed at all": "candidate contains an unsigned component",
|
||||||
|
"resource fork, Finder information": "candidate contains metadata forbidden by code signing",
|
||||||
|
"unable to build chain": "signing certificate chain could not be built",
|
||||||
|
"unsealed contents present": "embedded bundle has unsealed root contents",
|
||||||
|
"bundle format unrecognized": "component has an unsupported bundle format",
|
||||||
|
"timestamp service": "Apple timestamp service failed",
|
||||||
|
"Timestamping service": "Apple timestamp service failed",
|
||||||
|
"certificate has expired": "signing certificate has expired",
|
||||||
|
"CSSMERR_TP_CERT_EXPIRED": "signing certificate has expired",
|
||||||
|
}
|
||||||
|
cause = next((cause for marker, cause in causes.items() if marker in result.stderr),
|
||||||
|
"native signing operation returned an error")
|
||||||
|
raise RuntimeError(f"{Path(arguments[0]).name}: {cause} (exit {result.returncode}); raw diagnostics withheld")
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def keychain_search(keychain):
|
||||||
|
previous = [value for value in shlex.split(security("list-keychains", "-d", "user"))
|
||||||
|
if Path(value) != keychain]
|
||||||
|
if not previous or any(not Path(value).is_absolute() for value in previous):
|
||||||
|
raise RuntimeError("Existing user keychain search list is absent or malformed")
|
||||||
|
try:
|
||||||
|
security("list-keychains", "-d", "user", "-s", str(keychain), *previous)
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
security("list-keychains", "-d", "user", "-s", *previous)
|
||||||
|
|
||||||
|
|
||||||
|
def signable_files(app):
|
||||||
|
for path in sorted(app.rglob("*")):
|
||||||
|
if path.is_symlink() or not path.is_file() or any(part.endswith(".framework") for part in path.parts):
|
||||||
|
continue
|
||||||
|
if "Mach-O" in run(["/usr/bin/file", "-b", str(path)]):
|
||||||
|
yield path
|
||||||
|
|
||||||
|
|
||||||
|
def private_file(path, content):
|
||||||
|
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||||
|
with os.fdopen(descriptor, "wb") as stream:
|
||||||
|
stream.write(content)
|
||||||
|
|
||||||
|
|
||||||
|
def signing_inputs():
|
||||||
|
credentials = {name: os.environ.get(name, "") for name in FIELDS}
|
||||||
|
missing = [name for name, value in credentials.items() if not value]
|
||||||
|
if missing:
|
||||||
|
raise ValueError("Signing input is missing: " + ", ".join(missing))
|
||||||
|
if "Such Software" not in credentials["APPLE_SIGNING_IDENTITY"]:
|
||||||
|
raise ValueError("Developer ID identity does not name the expected publisher")
|
||||||
|
return credentials
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
credentials = signing_inputs()
|
||||||
|
app = Path("build/dist/Wowlet.app").resolve()
|
||||||
|
if not app.is_dir():
|
||||||
|
raise ValueError("Packaged Wowlet.app is missing")
|
||||||
|
old_umask = os.umask(0o077)
|
||||||
|
try:
|
||||||
|
with tempfile.TemporaryDirectory(prefix=".wowlet-signing-", dir=app.parent) as temporary:
|
||||||
|
workspace = Path(temporary)
|
||||||
|
keychain = workspace / "candidate.keychain-db"
|
||||||
|
certificate = workspace / "identity.p12"
|
||||||
|
notary_key = workspace / "notary.p8"
|
||||||
|
private_file(certificate, base64.b64decode(credentials["APPLE_CERTIFICATE"]))
|
||||||
|
private_file(notary_key, base64.b64decode(credentials["APPLE_API_KEY_P8_BASE64"]))
|
||||||
|
password = secrets.token_hex(32)
|
||||||
|
created = False
|
||||||
|
try:
|
||||||
|
security("create-keychain", "-p", password, str(keychain))
|
||||||
|
created = True
|
||||||
|
security("set-keychain-settings", "-lut", "3600", str(keychain))
|
||||||
|
security("unlock-keychain", "-p", password, str(keychain))
|
||||||
|
security("import", str(certificate), "-k", str(keychain), "-P",
|
||||||
|
credentials["APPLE_CERTIFICATE_PASSWORD"], "-T", "/usr/bin/codesign")
|
||||||
|
security("set-key-partition-list", "-S", "apple-tool:,apple:", "-s",
|
||||||
|
"-k", password, str(keychain))
|
||||||
|
|
||||||
|
with keychain_search(keychain):
|
||||||
|
def sign(path):
|
||||||
|
print("Signing component: " + (str(path.relative_to(app)) if path != app else "Wowlet.app"), flush=True)
|
||||||
|
run(["/usr/bin/codesign", "--force", "--options", "runtime", "--timestamp",
|
||||||
|
"--keychain", str(keychain), "--sign",
|
||||||
|
credentials["APPLE_SIGNING_IDENTITY"], str(path)])
|
||||||
|
|
||||||
|
for path in signable_files(app):
|
||||||
|
sign(path)
|
||||||
|
frameworks = app / "Contents/Frameworks"
|
||||||
|
for path in sorted(frameworks.glob("*.framework")):
|
||||||
|
sign(path)
|
||||||
|
sign(app)
|
||||||
|
run(["/usr/bin/codesign", "--verify", "--deep", "--strict", str(app)])
|
||||||
|
archive = workspace / "candidate.zip"
|
||||||
|
run(["/usr/bin/ditto", "-c", "-k", "--keepParent", str(app), str(archive)])
|
||||||
|
raw = run(["/usr/bin/xcrun", "notarytool", "submit", str(archive),
|
||||||
|
"--key", str(notary_key), "--key-id", credentials["APPLE_API_KEY"],
|
||||||
|
"--issuer", credentials["APPLE_API_ISSUER"], "--wait", "--timeout", "30m",
|
||||||
|
"--output-format", "json"], timeout=1900)
|
||||||
|
response = json.loads(raw)
|
||||||
|
if response.get("status") != "Accepted":
|
||||||
|
raise RuntimeError("Apple notarization did not return Accepted")
|
||||||
|
try:
|
||||||
|
uuid.UUID(response.get("id", ""))
|
||||||
|
except (ValueError, TypeError, AttributeError):
|
||||||
|
raise RuntimeError("Apple notarization submission identity is missing or malformed") from None
|
||||||
|
run(["/usr/bin/xcrun", "stapler", "staple", str(app)])
|
||||||
|
run(["/usr/bin/xcrun", "stapler", "validate", str(app)])
|
||||||
|
private_file(app.parent / "macos-signing.json", json.dumps({
|
||||||
|
"developer_id_verified": True, "notarization_status": "Accepted",
|
||||||
|
"notary_submission_id": response.get("id"), "staple_validated": True,
|
||||||
|
}, sort_keys=True).encode())
|
||||||
|
print("Developer ID signature verified; notarization accepted; staple validated")
|
||||||
|
finally:
|
||||||
|
if created:
|
||||||
|
security("delete-keychain", str(keychain))
|
||||||
|
finally:
|
||||||
|
os.umask(old_umask)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
if sys.argv[1:] == ["--check-inputs"]:
|
||||||
|
signing_inputs()
|
||||||
|
print("Required signing inputs are present")
|
||||||
|
elif sys.argv[1:]:
|
||||||
|
raise ValueError("Use no arguments to sign or --check-inputs for the cheap input guard")
|
||||||
|
else:
|
||||||
|
main()
|
||||||
|
except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired) as error:
|
||||||
|
# These exceptions contain logical fields, native result classes or
|
||||||
|
# public file paths. Captured child output is never included.
|
||||||
|
raise SystemExit(str(error)) from None
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
CHECKER = ROOT / "contrib/AppImage/check-libcom-err.sh"
|
||||||
|
|
||||||
|
|
||||||
|
class AppImageDependencyBundleTest(unittest.TestCase):
|
||||||
|
def test_accepts_appdir_with_libcom_err(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
appdir = Path(temporary) / "AppDir"
|
||||||
|
library = appdir / "usr/lib/libcom_err.so.2"
|
||||||
|
library.parent.mkdir(parents=True)
|
||||||
|
library.write_bytes(b"fixture")
|
||||||
|
|
||||||
|
result = subprocess.run(["bash", str(CHECKER), str(appdir)], check=False)
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 0)
|
||||||
|
|
||||||
|
def test_refuses_appdir_without_libcom_err(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
appdir = Path(temporary) / "AppDir"
|
||||||
|
(appdir / "usr/lib").mkdir(parents=True)
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
["bash", str(CHECKER), str(appdir)],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertTrue(result.stderr.strip())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location("dispatch", Path(__file__).parents[1] / "check_dispatch.py")
|
||||||
|
dispatch = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(dispatch)
|
||||||
|
|
||||||
|
|
||||||
|
class DispatchTests(unittest.TestCase):
|
||||||
|
def test_exact_source_is_required_before_building(self):
|
||||||
|
sha = "a" * 40
|
||||||
|
dispatch.check(sha, sha, sha)
|
||||||
|
for expected, reported, checked_out in (
|
||||||
|
(None, sha, sha), ("master", sha, sha),
|
||||||
|
(sha, "b" * 40, sha), (sha, sha, "b" * 40),
|
||||||
|
):
|
||||||
|
with self.subTest(expected=expected, reported=reported, checked_out=checked_out):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
dispatch.check(expected, reported, checked_out)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import importlib.util
|
||||||
|
import io
|
||||||
|
from pathlib import Path
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
SPEC = importlib.util.spec_from_file_location("fetch_wownerod", Path(__file__).parents[1] / "fetch_wownerod.py")
|
||||||
|
fetcher = importlib.util.module_from_spec(SPEC)
|
||||||
|
SPEC.loader.exec_module(fetcher)
|
||||||
|
|
||||||
|
|
||||||
|
class DaemonTests(unittest.TestCase):
|
||||||
|
def test_checksum_failure_never_extracts_a_daemon(self):
|
||||||
|
response = io.BytesIO(b"untrusted archive bytes")
|
||||||
|
response.geturl = lambda: "https://release-assets.githubusercontent.com/archive"
|
||||||
|
with tempfile.TemporaryDirectory() as tmp, patch.object(fetcher.urllib.request, "urlopen", return_value=response), patch.object(fetcher, "copy_daemon") as extract:
|
||||||
|
with self.assertRaisesRegex(ValueError, "SHA-256"):
|
||||||
|
fetcher.fetch("x86_64-linux-gnu", Path(tmp) / "daemon")
|
||||||
|
extract.assert_not_called()
|
||||||
|
self.assertFalse((Path(tmp) / "daemon/wownerod").exists())
|
||||||
|
|
||||||
|
def test_archive_paths_cannot_write_outside_destination(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
archive = root / "daemon.tar.bz2"
|
||||||
|
with tarfile.open(archive, "w:bz2") as output:
|
||||||
|
info = tarfile.TarInfo("../../wownerod")
|
||||||
|
info.size = 3
|
||||||
|
output.addfile(info, io.BytesIO(b"exe"))
|
||||||
|
destination = root / "stage"
|
||||||
|
destination.mkdir()
|
||||||
|
fetcher.copy_daemon(archive, destination, False)
|
||||||
|
self.assertEqual((destination / "wownerod").read_bytes(), b"exe")
|
||||||
|
self.assertFalse((root / "wownerod").exists())
|
||||||
|
|
||||||
|
def test_symlink_daemon_is_refused(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
archive = root / "daemon.tar.bz2"
|
||||||
|
with tarfile.open(archive, "w:bz2") as output:
|
||||||
|
info = tarfile.TarInfo("wownerod")
|
||||||
|
info.type = tarfile.SYMTYPE
|
||||||
|
info.linkname = "/etc/passwd"
|
||||||
|
output.addfile(info)
|
||||||
|
with self.assertRaisesRegex(ValueError, "regular daemon"):
|
||||||
|
fetcher.copy_daemon(archive, root, False)
|
||||||
|
self.assertFalse((root / "wownerod").exists())
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parents[1]))
|
||||||
|
SPEC = importlib.util.spec_from_file_location("write_receipt", Path(__file__).parents[1] / "write_receipt.py")
|
||||||
|
receipt = importlib.util.module_from_spec(SPEC)
|
||||||
|
SPEC.loader.exec_module(receipt)
|
||||||
|
|
||||||
|
|
||||||
|
class ReceiptTests(unittest.TestCase):
|
||||||
|
def test_hash_changes_with_artifact_bytes_and_symlinks_refuse(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = Path(tmp) / "candidate.zip"
|
||||||
|
path.write_bytes(b"candidate")
|
||||||
|
first = receipt.file_record(path)
|
||||||
|
path.write_bytes(b"different candidate")
|
||||||
|
second = receipt.file_record(path)
|
||||||
|
self.assertNotEqual(first["sha256"], second["sha256"])
|
||||||
|
self.assertEqual(second["size"], path.stat().st_size)
|
||||||
|
link = Path(tmp) / "link.zip"
|
||||||
|
link.symlink_to(path)
|
||||||
|
with self.assertRaisesRegex(ValueError, "regular file"):
|
||||||
|
receipt.file_record(link)
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
import tempfile
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location("sign_macos", Path(__file__).parents[1] / "sign_macos.py")
|
||||||
|
signing = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(signing)
|
||||||
|
|
||||||
|
|
||||||
|
class MacSigningTests(unittest.TestCase):
|
||||||
|
def test_password_uses_private_input_and_diagnostics_never_echo_it(self):
|
||||||
|
synthetic = 'synthetic "quoted" value \\ with spaces'
|
||||||
|
captured = []
|
||||||
|
|
||||||
|
def execute(arguments, **options):
|
||||||
|
captured.append((arguments, options))
|
||||||
|
return SimpleNamespace(returncode=1, stdout=synthetic,
|
||||||
|
stderr="MAC verification failed " + synthetic)
|
||||||
|
|
||||||
|
with patch.object(signing.subprocess, "run", execute):
|
||||||
|
with self.assertRaises(RuntimeError) as caught:
|
||||||
|
signing.security("import", "candidate.p12", "-P", synthetic)
|
||||||
|
self.assertNotIn(synthetic, str(caught.exception))
|
||||||
|
arguments, options = captured[0]
|
||||||
|
self.assertNotIn(synthetic, " ".join(arguments))
|
||||||
|
self.assertIn("quoted", options["input"])
|
||||||
|
self.assertTrue(options["capture_output"])
|
||||||
|
self.assertNotIn("APPLE_CERTIFICATE_PASSWORD", options["env"])
|
||||||
|
|
||||||
|
def test_missing_inputs_refuse_without_native_operations(self):
|
||||||
|
with patch.dict(signing.os.environ, {}, clear=True), patch.object(signing.subprocess, "run") as execute:
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
signing.signing_inputs()
|
||||||
|
execute.assert_not_called()
|
||||||
|
|
||||||
|
def test_control_characters_and_unrelated_keychain_operations_refuse(self):
|
||||||
|
with patch.object(signing.subprocess, "run") as execute:
|
||||||
|
for value in ("line\nbreak", "nul\x00byte", "carriage\rreturn"):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
signing.security("unlock-keychain", "-p", value, "candidate.keychain")
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
signing.security("dump-keychain", "login.keychain")
|
||||||
|
execute.assert_not_called()
|
||||||
|
|
||||||
|
def test_user_keychain_search_is_restored_after_signing_failure(self):
|
||||||
|
previous = ['/Users/runner/Library/Keychains/login.keychain-db', '/Library/Keychains/System.keychain']
|
||||||
|
calls = []
|
||||||
|
def security(operation, *values):
|
||||||
|
calls.append((operation, values))
|
||||||
|
return ' '.join('"' + value + '"' for value in previous)
|
||||||
|
with patch.object(signing, "security", security):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "synthetic signing failure"):
|
||||||
|
with signing.keychain_search(Path('/private/candidate.keychain-db')):
|
||||||
|
self.assertEqual(calls[-1][1][-len(previous):], tuple(previous))
|
||||||
|
self.assertIn('/private/candidate.keychain-db', calls[-1][1])
|
||||||
|
raise RuntimeError('synthetic signing failure')
|
||||||
|
self.assertEqual(calls[-1], ('list-keychains', ('-d', 'user', '-s', *previous)))
|
||||||
|
|
||||||
|
def test_failed_search_list_write_still_restores_original_list(self):
|
||||||
|
calls = []
|
||||||
|
previous = '/Users/runner/Library/Keychains/login.keychain-db'
|
||||||
|
def security(operation, *values):
|
||||||
|
calls.append((operation, values))
|
||||||
|
if '/private/candidate.keychain-db' in values:
|
||||||
|
raise RuntimeError('synthetic search write failure')
|
||||||
|
return '"' + previous + '"'
|
||||||
|
with patch.object(signing, "security", security):
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
with signing.keychain_search(Path('/private/candidate.keychain-db')):
|
||||||
|
self.fail('failed search-list setup entered signing')
|
||||||
|
self.assertEqual(calls[-1][1], ('-d', 'user', '-s', previous))
|
||||||
|
|
||||||
|
def test_signing_walk_skips_symlink_aliases_and_framework_internals(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
app = Path(temporary)
|
||||||
|
executable = app / 'actual.dylib'
|
||||||
|
executable.write_bytes(b'mach-o fixture')
|
||||||
|
(app / 'alias.dylib').symlink_to(executable.name)
|
||||||
|
framework = app / 'QtCore.framework'
|
||||||
|
framework.mkdir()
|
||||||
|
(framework / 'QtCore').write_bytes(b'framework fixture')
|
||||||
|
with patch.object(signing, 'run', return_value='Mach-O arm64'):
|
||||||
|
self.assertEqual(list(signing.signable_files(app)), [executable])
|
||||||
|
|
||||||
|
def test_native_failure_cause_does_not_echo_untrusted_diagnostics(self):
|
||||||
|
sentinel = 'must-not-echo-native-input'
|
||||||
|
result = SimpleNamespace(returncode=1, stdout='', stderr='unable to build chain ' + sentinel)
|
||||||
|
with patch.object(signing.subprocess, 'run', return_value=result):
|
||||||
|
with self.assertRaises(RuntimeError) as caught:
|
||||||
|
signing.run(['/usr/bin/codesign', 'public-component'])
|
||||||
|
self.assertIn('certificate chain', str(caught.exception))
|
||||||
|
self.assertNotIn(sentinel, str(caught.exception))
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"version": "v0.11.5.0",
|
||||||
|
"source_commit": "b22382c55fa2a97cd70aca450026b8e2bb8711a6",
|
||||||
|
"checksum_evidence": "https://github.com/wownero/wownero/actions/runs/37402323531",
|
||||||
|
"checksum_signature": "The release tag build replaced the archives after SHA256SUMS.txt.asc was signed. These hashes match the successful tag build and current release metadata; that signature does not cover these archives.",
|
||||||
|
"archives": {
|
||||||
|
"wownero-x86_64-w64-mingw32-v0.11.5.0.zip": "46dffbd313de1df4667516acf23737f986eb2e131c06a8dde1d11dcff2eb06e2",
|
||||||
|
"wownero-aarch64-apple-darwin11-v0.11.5.0.tar.bz2": "bad8cc7f1c78d026e63dbe3ffc0f2175d50b986bbde3fc18826a04ff693866d4",
|
||||||
|
"wownero-x86_64-apple-darwin11-v0.11.5.0.tar.bz2": "ae562c90a10856fcdd183dc9530dff73e46ed6eece9fb8dcfaa77f1e07174787",
|
||||||
|
"wownero-x86_64-linux-gnu-v0.11.5.0.tar.bz2": "2d0cc62c24255d45c51936616559651378011501c55a8caadb9a125bb875849b"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Bind candidate archive hashes to the checked source and provider run."""
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from check_dispatch import check
|
||||||
|
|
||||||
|
|
||||||
|
def file_record(path):
|
||||||
|
if not path.is_file() or path.is_symlink() or path.stat().st_size == 0:
|
||||||
|
raise ValueError("Candidate artifact must be a nonempty regular file")
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
while chunk := stream.read(1024 * 1024):
|
||||||
|
digest.update(chunk)
|
||||||
|
return {"name": path.name, "size": path.stat().st_size, "sha256": digest.hexdigest()}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--platform", required=True, choices=("linux", "macos", "windows-build"))
|
||||||
|
parser.add_argument("--file", type=Path, required=True, action="append")
|
||||||
|
parser.add_argument("--output", type=Path, required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
head = subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip()
|
||||||
|
check(os.environ.get("EXPECTED_SHA"), os.environ.get("GITHUB_SHA"), head)
|
||||||
|
run_id = os.environ.get("GITHUB_RUN_ID", "")
|
||||||
|
if not re.fullmatch(r"[1-9][0-9]*", run_id):
|
||||||
|
raise ValueError("Provider run identity is absent")
|
||||||
|
version = re.findall(r'^\s*VERSION "([0-9]+\.[0-9]+\.[0-9]+)"\s*$', Path("CMakeLists.txt").read_text(), re.M)
|
||||||
|
if len(version) != 1:
|
||||||
|
raise ValueError("Committed Wowlet version is ambiguous")
|
||||||
|
module = subprocess.check_output(["git", "ls-tree", "HEAD", "monero"], text=True).split()
|
||||||
|
if len(module) != 4 or module[:2] != ["160000", "commit"] or module[3] != "monero":
|
||||||
|
raise ValueError("Committed wallet-library pin is absent")
|
||||||
|
files = [file_record(path) for path in args.file]
|
||||||
|
if len({row["name"] for row in files}) != len(files):
|
||||||
|
raise ValueError("Candidate artifact filenames repeat")
|
||||||
|
receipt = {"schema_version": 1, "app": "wowlet", "version": version[0], "platform": args.platform,
|
||||||
|
"dispatch_commit": head, "wallet_library_commit": module[2],
|
||||||
|
"environment_run_id": run_id, "environment_run_attempt": os.environ.get("GITHUB_RUN_ATTEMPT"),
|
||||||
|
"artifacts": files}
|
||||||
|
if args.platform == "macos":
|
||||||
|
signing = json.loads(Path("build/dist/macos-signing.json").read_text())
|
||||||
|
if signing.get("developer_id_verified") is not True or signing.get("notarization_status") != "Accepted" or signing.get("staple_validated") is not True:
|
||||||
|
raise ValueError("macOS signing evidence is incomplete")
|
||||||
|
receipt["signing"] = signing
|
||||||
|
with args.output.open("x") as stream:
|
||||||
|
json.dump(receipt, stream, indent=2)
|
||||||
|
stream.write("\n")
|
||||||
|
print("Candidate source and artifact hashes recorded")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -34,7 +34,7 @@ if (CHECK_UPDATES)
|
|||||||
add_subdirectory(openpgp)
|
add_subdirectory(openpgp)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
qt_add_resources(RESOURCES assets.qrc assets_tor.qrc assets_wownerod.qrc assets_docs.qrc mining.qrc)
|
qt_add_resources(RESOURCES assets.qrc assets_tor.qrc assets_wownerod.qrc assets_docs.qrc mining.qrc silly.qrc scene.qrc)
|
||||||
|
|
||||||
# Compile source files (.h/.cpp)
|
# Compile source files (.h/.cpp)
|
||||||
file(GLOB SOURCE_FILES
|
file(GLOB SOURCE_FILES
|
||||||
@@ -148,7 +148,10 @@ add_executable(feather ${EXECUTABLE_FLAG} main.cpp
|
|||||||
${ASSETS_TOR}
|
${ASSETS_TOR}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# The CMake target keeps its upstream name so the target_* calls below stay put;
|
||||||
|
# only what we ship is renamed.
|
||||||
set_target_properties(feather PROPERTIES
|
set_target_properties(feather PROPERTIES
|
||||||
|
OUTPUT_NAME wowlet
|
||||||
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin"
|
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin"
|
||||||
LINK_FLAGS_RELEASE -s
|
LINK_FLAGS_RELEASE -s
|
||||||
)
|
)
|
||||||
@@ -370,14 +373,14 @@ else()
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (UNIX AND NOT APPLE)
|
if (UNIX AND NOT APPLE)
|
||||||
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/feather.desktop DESTINATION ${CMAKE_INSTALL_PREFIX}/share/applications)
|
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/wowlet.desktop DESTINATION ${CMAKE_INSTALL_PREFIX}/share/applications)
|
||||||
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/images/appicons/32x32.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/32x32/apps RENAME "feather.png")
|
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/images/appicons/32x32.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/32x32/apps RENAME "wowlet.png")
|
||||||
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/images/appicons/48x48.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/48x48/apps RENAME "feather.png")
|
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/images/appicons/48x48.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/48x48/apps RENAME "wowlet.png")
|
||||||
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/images/appicons/64x64.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/64x64/apps RENAME "feather.png")
|
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/images/appicons/64x64.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/64x64/apps RENAME "wowlet.png")
|
||||||
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/images/appicons/96x96.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/96x96/apps RENAME "feather.png")
|
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/images/appicons/96x96.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/96x96/apps RENAME "wowlet.png")
|
||||||
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/images/appicons/128x128.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/128x128/apps RENAME "feather.png")
|
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/images/appicons/128x128.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/128x128/apps RENAME "wowlet.png")
|
||||||
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/images/appicons/256x256.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/256x256/apps RENAME "feather.png")
|
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/images/appicons/256x256.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/256x256/apps RENAME "wowlet.png")
|
||||||
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/images/appicons/512x512.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/512x512/apps RENAME "feather.png")
|
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/assets/images/appicons/512x512.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/512x512/apps RENAME "wowlet.png")
|
||||||
endif()
|
endif()
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
@@ -385,13 +388,14 @@ if(APPLE)
|
|||||||
configure_file(${CMAKE_SOURCE_DIR}/contrib/macdeploy/Info.plist.in ${CMAKE_SOURCE_DIR}/contrib/macdeploy/Info.plist @ONLY)
|
configure_file(${CMAKE_SOURCE_DIR}/contrib/macdeploy/Info.plist.in ${CMAKE_SOURCE_DIR}/contrib/macdeploy/Info.plist @ONLY)
|
||||||
|
|
||||||
set_target_properties(feather PROPERTIES
|
set_target_properties(feather PROPERTIES
|
||||||
|
OUTPUT_NAME wowlet
|
||||||
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin"
|
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin"
|
||||||
MACOSX_BUNDLE TRUE
|
MACOSX_BUNDLE TRUE
|
||||||
MACOSX_BUNDLE_INFO_PLIST "${CMAKE_SOURCE_DIR}/contrib/macdeploy/Info.plist"
|
MACOSX_BUNDLE_INFO_PLIST "${CMAKE_SOURCE_DIR}/contrib/macdeploy/Info.plist"
|
||||||
LINK_FLAGS_RELEASE -s
|
LINK_FLAGS_RELEASE -s
|
||||||
)
|
)
|
||||||
|
|
||||||
file(COPY "${CMAKE_SOURCE_DIR}/src/assets/images/appicons/appicon.icns" DESTINATION "${CMAKE_SOURCE_DIR}/installed/feather.app/Contents/Resources/" )
|
file(COPY "${CMAKE_SOURCE_DIR}/src/assets/images/appicons/appicon.icns" DESTINATION "${CMAKE_SOURCE_DIR}/installed/wowlet.app/Contents/Resources/" )
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
qt_finalize_executable(feather)
|
qt_finalize_executable(feather)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
#include "utils/AppData.h"
|
#include "utils/AppData.h"
|
||||||
#include "utils/AsyncTask.h"
|
#include "utils/AsyncTask.h"
|
||||||
#include "utils/ColorScheme.h"
|
#include "utils/ColorScheme.h"
|
||||||
|
#include "utils/DaemonManager.h" // wowlet: for daemonStateChanged reconnect (node-toggle race)
|
||||||
#include "utils/Icons.h"
|
#include "utils/Icons.h"
|
||||||
#include "utils/TorManager.h"
|
#include "utils/TorManager.h"
|
||||||
#include "utils/WebsocketNotifier.h"
|
#include "utils/WebsocketNotifier.h"
|
||||||
@@ -251,7 +252,7 @@ void MainWindow::initWidgets() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// [Mining] — wowlet: the pixel-art mining tab (mining.qml + embedded-node solo miner)
|
// [Mining] — wowlet: the pixel-art mining tab (mining.qml + embedded-node solo miner)
|
||||||
m_miningWidget = new MiningWidget(m_wallet, this);
|
m_miningWidget = new MiningWidget(m_wallet, m_nodes, this);
|
||||||
ui->tabWidget->addTab(m_miningWidget, "Mining");
|
ui->tabWidget->addTab(m_miningWidget, "Mining");
|
||||||
|
|
||||||
// [Notes]
|
// [Notes]
|
||||||
@@ -512,6 +513,13 @@ void MainWindow::initWalletContext() {
|
|||||||
this->onConnectionStatusChanged(status);
|
this->onConnectionStatusChanged(status);
|
||||||
m_nodes->autoConnect();
|
m_nodes->autoConnect();
|
||||||
});
|
});
|
||||||
|
// wowlet: the embedded node's RPC port comes up asynchronously after daemonManager()->start(), so a
|
||||||
|
// freshly-toggled local node would otherwise sit Disconnected until the next status tick. Reconnect the
|
||||||
|
// instant its RPC is up. (autoConnect's runLocalNode branch routes to 127.0.0.1; start() is idempotent.)
|
||||||
|
connect(daemonManager(), &DaemonManager::daemonStateChanged, this, [this](bool up){
|
||||||
|
if (up && conf()->get(Config::runLocalNode).toBool())
|
||||||
|
m_nodes->autoConnect(true);
|
||||||
|
});
|
||||||
connect(m_wallet, &Wallet::currentSubaddressAccountChanged, this, &MainWindow::updateTitle);
|
connect(m_wallet, &Wallet::currentSubaddressAccountChanged, this, &MainWindow::updateTitle);
|
||||||
connect(m_wallet, &Wallet::walletPassphraseNeeded, this, &MainWindow::onWalletPassphraseNeeded);
|
connect(m_wallet, &Wallet::walletPassphraseNeeded, this, &MainWindow::onWalletPassphraseNeeded);
|
||||||
|
|
||||||
@@ -534,6 +542,24 @@ void MainWindow::initWalletContext() {
|
|||||||
connect(m_wallet, &Wallet::deviceError, this, &MainWindow::onDeviceError);
|
connect(m_wallet, &Wallet::deviceError, this, &MainWindow::onDeviceError);
|
||||||
|
|
||||||
connect(m_wallet, &Wallet::multiBroadcast, this, &MainWindow::onMultiBroadcast);
|
connect(m_wallet, &Wallet::multiBroadcast, this, &MainWindow::onMultiBroadcast);
|
||||||
|
|
||||||
|
// wowlet: count multi-broadcast relay results so a total failure (Tor not
|
||||||
|
// bootstrapped, every node unreachable) is surfaced instead of silent.
|
||||||
|
connect(m_rpc, &DaemonRpc::ApiResponse, this, [this](const DaemonRpc::DaemonResponse &resp){
|
||||||
|
if (resp.endpoint == DaemonRpc::Endpoint::SEND_RAW_TRANSACTION && resp.ok) {
|
||||||
|
m_broadcastBatchSuccesses++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// wowlet: when the embedded daemon reports synced after a (re)start,
|
||||||
|
// re-announce any still-pending outgoing txs; a restarted pool has
|
||||||
|
// forgotten them.
|
||||||
|
connect(daemonManager(), &DaemonManager::synced, this, &MainWindow::relayPendingTransactions);
|
||||||
|
|
||||||
|
// wowlet: periodic local check for outgoing txs stuck unconfirmed.
|
||||||
|
m_stuckTxTimer = new QTimer(this);
|
||||||
|
connect(m_stuckTxTimer, &QTimer::timeout, this, &MainWindow::checkStuckTransactions);
|
||||||
|
m_stuckTxTimer->start(2 * 60 * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::menuToggleTabVisible(const QString &key){
|
void MainWindow::menuToggleTabVisible(const QString &key){
|
||||||
@@ -703,7 +729,7 @@ void MainWindow::onWebsocketStatusChanged(bool enabled) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::onProxySettingsChangedConnect() {
|
void MainWindow::onProxySettingsChangedConnect() {
|
||||||
m_nodes->connectToNode();
|
m_nodes->reconnect(); // wowlet: clear blocklist + force-reconnect after a live Tor/proxy switch (test 2)
|
||||||
this->onProxySettingsChanged();
|
this->onProxySettingsChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -752,6 +778,7 @@ void MainWindow::onSubtractFeeFromAmountEnabled(bool enabled) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::onMultiBroadcast(const QMap<QString, QString> &txHexMap) {
|
void MainWindow::onMultiBroadcast(const QMap<QString, QString> &txHexMap) {
|
||||||
|
m_broadcastBatchSuccesses = 0;
|
||||||
QMapIterator<QString, QString> i(txHexMap);
|
QMapIterator<QString, QString> i(txHexMap);
|
||||||
while (i.hasNext()) {
|
while (i.hasNext()) {
|
||||||
i.next();
|
i.next();
|
||||||
@@ -762,6 +789,99 @@ void MainWindow::onMultiBroadcast(const QMap<QString, QString> &txHexMap) {
|
|||||||
m_rpc->sendRawTransaction(i.value());
|
m_rpc->sendRawTransaction(i.value());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wowlet: if not a single node accepted the relay, say so. The commit to
|
||||||
|
// the local daemon may still confirm on its own, but when Tor is down and
|
||||||
|
// the daemon runs with an exclusive --tx-proxy, nothing will ever leave
|
||||||
|
// this machine; the user should know within a minute, not after two days.
|
||||||
|
const QStringList txids = txHexMap.keys();
|
||||||
|
QTimer::singleShot(45 * 1000, this, [this, txids]{
|
||||||
|
if (m_broadcastBatchSuccesses > 0 || txids.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
QMessageBox box(this);
|
||||||
|
box.setWindowTitle("Transaction relay may have failed");
|
||||||
|
box.setText("No node accepted the transaction broadcast. If Tor is "
|
||||||
|
"still starting (or unavailable), the transaction may not "
|
||||||
|
"reach the network at all.");
|
||||||
|
box.setInformativeText("Rebroadcasting later is always safe: a "
|
||||||
|
"transaction can never be spent twice.");
|
||||||
|
QPushButton *retry = box.addButton("Rebroadcast now", QMessageBox::YesRole);
|
||||||
|
box.addButton("Later", QMessageBox::RejectRole);
|
||||||
|
box.exec();
|
||||||
|
if (box.clickedButton() == retry) {
|
||||||
|
this->onResendTransaction(txids[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::checkStuckTransactions() {
|
||||||
|
// wowlet: a pending outgoing tx unconfirmed for this long has, in
|
||||||
|
// practice, not reached the network (Tor down at send time, embedded
|
||||||
|
// node restarted mid-relay). Local history scan only; no network calls,
|
||||||
|
// and no txid is ever looked up against any external service. The remedy
|
||||||
|
// reuses the existing rebroadcast path, which reconnects to a fresh node
|
||||||
|
// first.
|
||||||
|
constexpr qint64 kStuckSecs = 25 * 60;
|
||||||
|
if (m_wallet == nullptr || m_wallet->history() == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
TransactionHistory *history = m_wallet->history();
|
||||||
|
for (quint64 i = 0; i < history->count(); i++) {
|
||||||
|
const TransactionRow &row = history->transaction(static_cast<int>(i));
|
||||||
|
if (!row.pending || row.direction == TransactionRow::Direction_In) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (row.timestamp.secsTo(QDateTime::currentDateTime()) < kStuckSecs) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (m_stuckTxWarned.contains(row.hash)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
m_stuckTxWarned.insert(row.hash);
|
||||||
|
QMessageBox box(this);
|
||||||
|
box.setWindowTitle("Transaction appears stuck");
|
||||||
|
box.setText(QString("An outgoing transaction has been unconfirmed for "
|
||||||
|
"more than %1 minutes:\n\n%2")
|
||||||
|
.arg(kStuckSecs / 60).arg(row.hash));
|
||||||
|
box.setInformativeText("It may never have reached the network. "
|
||||||
|
"Rebroadcasting is safe: a transaction can "
|
||||||
|
"never be spent twice, and your change stays "
|
||||||
|
"locked until this either confirms or fails.");
|
||||||
|
QPushButton *retry = box.addButton("Rebroadcast now", QMessageBox::YesRole);
|
||||||
|
box.addButton("Later", QMessageBox::RejectRole);
|
||||||
|
box.exec();
|
||||||
|
if (box.clickedButton() == retry) {
|
||||||
|
this->onResendTransaction(row.hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::relayPendingTransactions() {
|
||||||
|
// wowlet: rebuild the multi-broadcast map from the wallet's own tx cache
|
||||||
|
// for every pending outgoing tx and re-announce it. Strictly local
|
||||||
|
// sources; the announcement itself goes through the normal (Tor-routed)
|
||||||
|
// broadcast path.
|
||||||
|
if (m_wallet == nullptr || m_wallet->history() == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
TransactionHistory *history = m_wallet->history();
|
||||||
|
QMap<QString, QString> txHexMap;
|
||||||
|
for (quint64 i = 0; i < history->count(); i++) {
|
||||||
|
const TransactionRow &row = history->transaction(static_cast<int>(i));
|
||||||
|
if (!row.pending || row.direction == TransactionRow::Direction_In) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
QString txHex = m_wallet->getCacheTransaction(row.hash);
|
||||||
|
if (!txHex.isEmpty()) {
|
||||||
|
txHexMap[row.hash] = txHex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (txHexMap.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
qDebug() << QString("Re-relaying %1 pending transaction(s) after daemon sync").arg(txHexMap.size());
|
||||||
|
this->onMultiBroadcast(txHexMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::onSyncStatus(quint64 height, quint64 target, bool daemonSync) {
|
void MainWindow::onSyncStatus(quint64 height, quint64 target, bool daemonSync) {
|
||||||
|
|||||||
@@ -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, SceneWidget::Sit);
|
||||||
|
scene->setMinimumHeight(110);
|
||||||
|
scene->setMaximumHeight(130);
|
||||||
|
ui->verticalLayout->addWidget(scene);
|
||||||
|
|
||||||
m_model = m_wallet->subaddressModel();
|
m_model = m_wallet->subaddressModel();
|
||||||
m_proxyModel = new SubaddressProxyModel(this, m_wallet->subaddress());
|
m_proxyModel = new SubaddressProxyModel(this, m_wallet->subaddress());
|
||||||
m_proxyModel->setSourceModel(m_model);
|
m_proxyModel->setSourceModel(m_model);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
#include "Icons.h"
|
#include "Icons.h"
|
||||||
#include "libwalletqt/Wallet.h"
|
#include "libwalletqt/Wallet.h"
|
||||||
#include "libwalletqt/WalletManager.h"
|
#include "libwalletqt/WalletManager.h"
|
||||||
|
#include "widgets/SceneWidget.h"
|
||||||
|
|
||||||
#if defined(WITH_SCANNER)
|
#if defined(WITH_SCANNER)
|
||||||
#include "wizard/offline_tx_signing/OfflineTxSigningWizard.h"
|
#include "wizard/offline_tx_signing/OfflineTxSigningWizard.h"
|
||||||
@@ -25,6 +26,21 @@ SendWidget::SendWidget(Wallet *wallet, QWidget *parent)
|
|||||||
{
|
{
|
||||||
ui->setupUi(this);
|
ui->setupUi(this);
|
||||||
|
|
||||||
|
// wowlet: the form only needs the top of the tab — give the rest of it to
|
||||||
|
// the ambient scene. A layout item under the form, never an overlay: the
|
||||||
|
// send form itself stays untouched.
|
||||||
|
//
|
||||||
|
// Centred in the space below the form rather than stacked straight under
|
||||||
|
// it: butted up against the Clear/Send row the scene reads as part of the
|
||||||
|
// form, and a band of night sky touching a button looks like a mistake.
|
||||||
|
auto *scene = new SceneWidget(this, SceneWidget::Night, SceneWidget::Money);
|
||||||
|
scene->setMinimumHeight(160);
|
||||||
|
scene->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
|
||||||
|
ui->verticalLayoutOuter->addSpacing(14); // never touches the Send row
|
||||||
|
ui->verticalLayoutOuter->addStretch(1);
|
||||||
|
ui->verticalLayoutOuter->addWidget(scene);
|
||||||
|
ui->verticalLayoutOuter->addStretch(1);
|
||||||
|
|
||||||
QString amount_rx = R"(^\d{0,8}[\.,]\d{0,12}|(all)$)";
|
QString amount_rx = R"(^\d{0,8}[\.,]\d{0,12}|(all)$)";
|
||||||
QRegularExpression rx;
|
QRegularExpression rx;
|
||||||
rx.setPattern(amount_rx);
|
rx.setPattern(amount_rx);
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -177,7 +183,7 @@ void Settings::setupNetworkTab() {
|
|||||||
daemonManager()->start();
|
daemonManager()->start();
|
||||||
else
|
else
|
||||||
daemonManager()->stop(false); // non-blocking: don't freeze Settings while wownerod flushes
|
daemonManager()->stop(false); // non-blocking: don't freeze Settings while wownerod flushes
|
||||||
m_nodes->autoConnect(true);
|
m_nodes->reconnect(); // wowlet: clear blocklist + force-reconnect (handles a previously-failed remote)
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
m_nodes = new Nodes(this, nullptr);
|
m_nodes = new Nodes(this, nullptr);
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -529,6 +512,10 @@ Rectangle {
|
|||||||
Image {
|
Image {
|
||||||
id: imgAxe
|
id: imgAxe
|
||||||
visible: mining.daemonMiningState === 0
|
visible: mining.daemonMiningState === 0
|
||||||
|
// Greyed out while the wallet is on someone else's node:
|
||||||
|
// solo mining is an RPC to the daemon we are connected
|
||||||
|
// to, and that daemon has to be ours.
|
||||||
|
opacity: mining.canMine ? 1.0 : 0.35
|
||||||
source: "qrc:/mining/axe.png"
|
source: "qrc:/mining/axe.png"
|
||||||
width: 73
|
width: 73
|
||||||
height: 75
|
height: 75
|
||||||
@@ -538,6 +525,7 @@ Rectangle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
AnimatedImage {
|
AnimatedImage {
|
||||||
|
playing: !mining.reducedMotion // wowlet: respect reduced-motion
|
||||||
visible: mining.daemonMiningState !== 0
|
visible: mining.daemonMiningState !== 0
|
||||||
source: "qrc:/mining/elmo.gif"
|
source: "qrc:/mining/elmo.gif"
|
||||||
width: 106
|
width: 106
|
||||||
@@ -567,7 +555,11 @@ Rectangle {
|
|||||||
cursorShape: Qt.PointingHandCursor
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
|
||||||
onClicked: {
|
onClicked: {
|
||||||
if(mining.daemonMiningState === 0) {
|
if (mining.daemonMiningState === 0) {
|
||||||
|
if (!mining.canMine) {
|
||||||
|
remoteNodeNotice.flash();
|
||||||
|
return;
|
||||||
|
}
|
||||||
root.startMining();
|
root.startMining();
|
||||||
root.calcAvailableHeightConsoleLines();
|
root.calcAvailableHeightConsoleLines();
|
||||||
}
|
}
|
||||||
@@ -575,14 +567,56 @@ Rectangle {
|
|||||||
root.stopMining();
|
root.stopMining();
|
||||||
}
|
}
|
||||||
onEntered: {
|
onEntered: {
|
||||||
imgAxe.height = 64
|
if (mining.canMine) {
|
||||||
imgAxe.width = 64
|
imgAxe.height = 64
|
||||||
|
imgAxe.width = 64
|
||||||
|
} else {
|
||||||
|
remoteNodeNotice.show();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
onExited: {
|
onExited: {
|
||||||
imgAxe.height = 75
|
imgAxe.height = 75
|
||||||
imgAxe.width = 73
|
imgAxe.width = 73
|
||||||
|
remoteNodeNotice.hideSoon();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Why the axe is greyed out. Shown on hover, and flashed if
|
||||||
|
// the axe is clicked anyway.
|
||||||
|
Rectangle {
|
||||||
|
id: remoteNodeNotice
|
||||||
|
z: parent.z + 5
|
||||||
|
visible: opacity > 0.01
|
||||||
|
opacity: 0.0
|
||||||
|
width: 250
|
||||||
|
height: noticeText.implicitHeight + 16
|
||||||
|
radius: 6
|
||||||
|
color: "#231a10"
|
||||||
|
border.color: "#e0b062"
|
||||||
|
border.width: 1
|
||||||
|
anchors.right: parent.left
|
||||||
|
anchors.rightMargin: 8
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: noticeText
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: parent.width - 16
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
color: "#f2dcb4"
|
||||||
|
font.pointSize: 9
|
||||||
|
text: "Mining needs your own node.\nYou are connected to a remote one. " +
|
||||||
|
"start the built-in node or point the wallet at a local daemon " +
|
||||||
|
"under Settings to mine."
|
||||||
|
}
|
||||||
|
|
||||||
|
Behavior on opacity { NumberAnimation { duration: 160 } }
|
||||||
|
Timer { id: noticeHide; interval: 2600; onTriggered: remoteNodeNotice.opacity = 0.0 }
|
||||||
|
function show() { noticeHide.stop(); opacity = 1.0 }
|
||||||
|
function hideSoon() { noticeHide.restart() }
|
||||||
|
function flash() { show(); noticeHide.restart() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,312 +168,400 @@
|
|||||||
1601257433:250500
|
1601257433:250500
|
||||||
1601710572:252000
|
1601710572:252000
|
||||||
1602154921:253500
|
1602154921:253500
|
||||||
1609825674:279148
|
1602596617:255000
|
||||||
1609816860:279048
|
1603053507:256500
|
||||||
1610116800:280048
|
1603501419:258000
|
||||||
1610420700:281048
|
1603951028:259500
|
||||||
1610715120:282048
|
1604397604:261000
|
||||||
1611017160:283048
|
1604844178:262500
|
||||||
1611314940:284048
|
1605289509:264000
|
||||||
1611616080:285048
|
1605742395:265500
|
||||||
1611911760:286048
|
1606192218:267000
|
||||||
1612211940:287048
|
1606638968:268500
|
||||||
1612515660:288048
|
1607083718:270000
|
||||||
1612810500:289048
|
1607539961:271500
|
||||||
1613115540:290048
|
1607991764:273000
|
||||||
1613414820:291048
|
1608439952:274500
|
||||||
1613709300:292048
|
1608890342:276000
|
||||||
1614008280:293048
|
1609334849:277500
|
||||||
1614307380:294048
|
1609777306:279000
|
||||||
1614603060:295048
|
1610229306:280500
|
||||||
1614902280:296048
|
1610678028:282000
|
||||||
1615204740:297048
|
1611129205:283500
|
||||||
1615505100:298048
|
1611578787:285000
|
||||||
1615807020:299048
|
1612024723:286500
|
||||||
1616108820:300048
|
1612479368:288000
|
||||||
1616402280:301048
|
1612925285:289500
|
||||||
1616706720:302048
|
1613378445:291000
|
||||||
1617003960:303048
|
1613818784:292500
|
||||||
1617611100:305048
|
1614269634:294000
|
||||||
1617911640:306048
|
1614718724:295500
|
||||||
1618191900:307048
|
1615167437:297000
|
||||||
1618522560:308048
|
1615619299:298500
|
||||||
1618816620:309048
|
1616071879:300000
|
||||||
1619098740:310048
|
1616517919:301500
|
||||||
1619390520:311048
|
1616969191:303000
|
||||||
1619695500:312048
|
1617419137:304500
|
||||||
1619985120:313048
|
1617878649:306000
|
||||||
1620290220:314048
|
1618306800:307500
|
||||||
1620586260:315048
|
1618784409:309000
|
||||||
1620885360:316048
|
1619210159:310500
|
||||||
1621183620:317048
|
1619658329:312000
|
||||||
1621482300:318048
|
1620103337:313500
|
||||||
1621782840:319048
|
1620558729:315000
|
||||||
1622079960:320048
|
1621002710:316500
|
||||||
1622380260:321048
|
1621453789:318000
|
||||||
1622678460:322048
|
1621895697:319500
|
||||||
1622974380:323048
|
1622347075:321000
|
||||||
1623269760:324048
|
1622790315:322500
|
||||||
1623567420:325048
|
1623238613:324000
|
||||||
1623869820:326048
|
1623686218:325500
|
||||||
1624167180:327048
|
1624135035:327000
|
||||||
1624473000:328048
|
1624588247:328500
|
||||||
1624767600:329048
|
1625034084:330000
|
||||||
1625065980:330048
|
1625387596:331500
|
||||||
1625366940:331048
|
1625683505:333000
|
||||||
1625439000:332048
|
1626206520:334500
|
||||||
1625706420:333048
|
1626634887:336000
|
||||||
1626101160:334048
|
1627067110:337500
|
||||||
1626365940:335048
|
1627510005:339000
|
||||||
1626660360:336048
|
1627915694:340500
|
||||||
1626966840:337048
|
1628363606:342000
|
||||||
1627231740:338048
|
1628827297:343500
|
||||||
1627541700:339048
|
1629250950:345000
|
||||||
1627810440:340048
|
1629688820:346500
|
||||||
1628078820:341048
|
1630145454:348000
|
||||||
1628406780:342048
|
1630591816:349500
|
||||||
1628709420:343048
|
1631025927:351000
|
||||||
1629011100:344048
|
1631515284:352500
|
||||||
1629283260:345048
|
1631986172:354000
|
||||||
1629582540:346048
|
1632422703:355500
|
||||||
1629874620:347048
|
1632890624:357000
|
||||||
1630168440:348048
|
1633326321:358500
|
||||||
1630465980:349048
|
1633775768:360000
|
||||||
1630756800:350048
|
1634233650:361500
|
||||||
1631060460:351048
|
1634693889:363000
|
||||||
1631393400:352048
|
1635139294:364500
|
||||||
1631699460:353048
|
1635617950:366000
|
||||||
1632012480:354048
|
1636079519:367500
|
||||||
1632289920:355048
|
1636497795:369000
|
||||||
1632602820:356048
|
1636997495:370500
|
||||||
1632923220:357048
|
1637418065:372000
|
||||||
1633203180:358048
|
1637897948:373500
|
||||||
1633491000:359048
|
1638376658:375000
|
||||||
1633811400:360048
|
1638790941:376500
|
||||||
1634113140:361048
|
1639235224:378000
|
||||||
1634430300:362048
|
1639757854:379500
|
||||||
1634722260:363048
|
1640218491:381000
|
||||||
1635035460:364048
|
1640646944:382500
|
||||||
1635368760:365048
|
1641106951:384000
|
||||||
1635639660:366048
|
1641544811:385500
|
||||||
1635957840:367048
|
1641989328:387000
|
||||||
1636245240:368048
|
1642443489:388500
|
||||||
1636535820:369048
|
1643009648:390000
|
||||||
1636863900:370048
|
1643455341:391500
|
||||||
1637160360:371048
|
1643876044:393000
|
||||||
1637457780:372048
|
1644333115:394500
|
||||||
1637793240:373048
|
1644747412:396000
|
||||||
1638062460:374048
|
1645196998:397500
|
||||||
1638407580:375048
|
1645649589:399000
|
||||||
1638675240:376048
|
1646107975:400500
|
||||||
1638981360:377048
|
1646543179:402000
|
||||||
1639273140:378048
|
1647017449:403500
|
||||||
1639612740:379048
|
1647475207:405000
|
||||||
1639928760:380048
|
1647881079:406500
|
||||||
1640242500:381048
|
1648354637:408000
|
||||||
1640530620:382048
|
1648816244:409500
|
||||||
1640838000:383048
|
1649261814:411000
|
||||||
1641136800:384048
|
1649734559:412500
|
||||||
1641437400:385048
|
1650212215:414000
|
||||||
1641716040:386048
|
1650656774:415500
|
||||||
1642030680:387048
|
1651081594:417000
|
||||||
1642330500:388048
|
1651543047:418500
|
||||||
1642714260:389048
|
1651999579:420000
|
||||||
1643040360:390048
|
1652464011:421500
|
||||||
1643344560:391048
|
1652926487:423000
|
||||||
1643612040:392048
|
1653376589:424500
|
||||||
1643913360:393048
|
1653829783:426000
|
||||||
1644223920:394048
|
1654287342:427500
|
||||||
1644491820:395048
|
1654747411:429000
|
||||||
1644794760:396048
|
1655193349:430500
|
||||||
1645110660:397048
|
1655710653:432000
|
||||||
1645379400:398048
|
1656134720:433500
|
||||||
1645693500:399048
|
1656592570:435000
|
||||||
1645981080:400048
|
1657054346:436500
|
||||||
1646301360:401048
|
1657494432:438000
|
||||||
1646576220:402048
|
1657939752:439500
|
||||||
1646919420:403048
|
1658390870:441000
|
||||||
1647208320:404048
|
1658822838:442500
|
||||||
1647506640:405048
|
1659286786:444000
|
||||||
1647768840:406048
|
1659718822:445500
|
||||||
1648082520:407048
|
1660176142:447000
|
||||||
1648391700:408048
|
1660639818:448500
|
||||||
1648686240:409048
|
1661083112:450000
|
||||||
1649002860:410048
|
1661517325:451500
|
||||||
1649282820:411048
|
1661970076:453000
|
||||||
1649599860:412048
|
1662446938:454500
|
||||||
1649959980:413048
|
1662873827:456000
|
||||||
1650248280:414048
|
1663336599:457500
|
||||||
1650528840:415048
|
1663789611:459000
|
||||||
1650809340:416048
|
1664221220:460500
|
||||||
1651118280:417048
|
1664685037:462000
|
||||||
1651432260:418048
|
1665126666:463500
|
||||||
1651740360:419048
|
1665557872:465000
|
||||||
1652031600:420048
|
1666034414:466500
|
||||||
1652351400:421048
|
1666495833:468000
|
||||||
1652639520:422048
|
1666929111:469500
|
||||||
1652954640:423048
|
1667394182:471000
|
||||||
1653252600:424048
|
1667835975:472500
|
||||||
1653549840:425048
|
1668288340:474000
|
||||||
1653862740:426048
|
1668766046:475500
|
||||||
1654161060:427048
|
1669214111:477000
|
||||||
1654461900:428048
|
1669665943:478500
|
||||||
1654782600:429048
|
1670117908:480000
|
||||||
1655076960:430048
|
1670586033:481500
|
||||||
1655441820:431048
|
1671029494:483000
|
||||||
1655740500:432048
|
1671465279:484500
|
||||||
1656030240:433048
|
1671904526:486000
|
||||||
1656324720:434048
|
1672376519:487500
|
||||||
1656624060:435048
|
1672822751:489000
|
||||||
1656925920:436048
|
1673269600:490500
|
||||||
1657236840:437048
|
1673719076:492000
|
||||||
1657526700:438048
|
1674182057:493500
|
||||||
1657816740:439048
|
1674626828:495000
|
||||||
1658135940:440048
|
1675085613:496500
|
||||||
1658434440:441048
|
1675521264:498000
|
||||||
1658715780:442048
|
1675977630:499500
|
||||||
1659010080:443048
|
1676426332:501000
|
||||||
1659318120:444048
|
1676890583:502500
|
||||||
1659611040:445048
|
1677344883:504000
|
||||||
1659914940:446048
|
1677795450:505500
|
||||||
1660204260:447048
|
1678241156:507000
|
||||||
1660505220:448048
|
1678685201:508500
|
||||||
1660831740:449048
|
1679136670:510000
|
||||||
1661114940:450048
|
1679592119:511500
|
||||||
1661397720:451048
|
1680041240:513000
|
||||||
1661708760:452048
|
1680537447:514500
|
||||||
1661997180:453048
|
1680981131:516000
|
||||||
1662335640:454048
|
1681434379:517500
|
||||||
1662630120:455048
|
1681894205:519000
|
||||||
1662908820:456048
|
1682345821:520500
|
||||||
1663213380:457048
|
1682805333:522000
|
||||||
1663501140:458048
|
1683258937:523500
|
||||||
1663832400:459048
|
1683714088:525000
|
||||||
1664109480:460048
|
1684168676:526500
|
||||||
1664412600:461048
|
1684616632:528000
|
||||||
1664724720:462048
|
1685076954:529500
|
||||||
1665004620:463048
|
1685528631:531000
|
||||||
1665305460:464048
|
1685980354:532500
|
||||||
1665600480:465048
|
1686442998:534000
|
||||||
1665894960:466048
|
1686893463:535500
|
||||||
1666213620:467048
|
1687341403:537000
|
||||||
1666527600:468048
|
1687800345:538500
|
||||||
1666816380:469048
|
1688260852:540000
|
||||||
1667123160:470048
|
1688713555:541500
|
||||||
1667427180:471048
|
1689164934:543000
|
||||||
1667720640:472048
|
1689616311:544500
|
||||||
1668021000:473048
|
1690075687:546000
|
||||||
1668334740:474048
|
1690533856:547500
|
||||||
1668649440:475048
|
1690984681:549000
|
||||||
1668946680:476048
|
1691444970:550500
|
||||||
1669247100:477048
|
1691897459:552000
|
||||||
1669560960:478048
|
1692352014:553500
|
||||||
1669852800:479048
|
1692807402:555000
|
||||||
1670164020:480048
|
1693259665:556500
|
||||||
1670467860:481048
|
1693718708:558000
|
||||||
1670767440:482048
|
1694169620:559500
|
||||||
1671063480:483048
|
1694621215:561000
|
||||||
1671363960:484048
|
1695073751:562500
|
||||||
1671639900:485048
|
1695544139:564000
|
||||||
1671950940:486048
|
1696010948:565500
|
||||||
1672248180:487048
|
1696480073:567000
|
||||||
1672566480:488048
|
1696934545:568500
|
||||||
1672859400:489048
|
1697383183:570000
|
||||||
1673179080:490048
|
1697850119:571500
|
||||||
1673451120:491048
|
1698298303:573000
|
||||||
1673759520:492048
|
1698763478:574500
|
||||||
1674053100:493048
|
1699224283:576000
|
||||||
1674374100:494048
|
1699683095:577500
|
||||||
1674658440:495048
|
1700138177:579000
|
||||||
1674978420:496048
|
1700593190:580500
|
||||||
1675249380:497048
|
1701074336:582000
|
||||||
1675559460:498048
|
1701528794:583500
|
||||||
1675847160:499048
|
1701994567:585000
|
||||||
1676178900:500048
|
1702441199:586500
|
||||||
1676463600:501048
|
1702920637:588000
|
||||||
1676782680:502048
|
1703388368:589500
|
||||||
1677074640:503048
|
1703830741:591000
|
||||||
1677385020:504048
|
1704284418:592500
|
||||||
1677686160:505048
|
1704734515:594000
|
||||||
1677996420:506048
|
1705184380:595500
|
||||||
1678277700:507048
|
1705643726:597000
|
||||||
1678564680:508048
|
1706087617:598500
|
||||||
1678852920:509048
|
1706539628:600000
|
||||||
1679178960:510048
|
1706999159:601500
|
||||||
1679473500:511048
|
1707446328:603000
|
||||||
1679780220:512048
|
1707920134:604500
|
||||||
1680075120:513048
|
1708380012:606000
|
||||||
1680415620:514048
|
1708845016:607500
|
||||||
1680715500:515048
|
1709293588:609000
|
||||||
1681014240:516048
|
1709748870:610500
|
||||||
1681314240:517048
|
1710202528:612000
|
||||||
1681628940:518048
|
1710647834:613500
|
||||||
1681919940:519048
|
1711098409:615000
|
||||||
1682226660:520048
|
1711550139:616500
|
||||||
1682528460:521048
|
1712000662:618000
|
||||||
1682838000:522048
|
1712465889:619500
|
||||||
1683149040:523048
|
1712914193:621000
|
||||||
1683448680:524048
|
1713366831:622500
|
||||||
1683750540:525048
|
1713821520:624000
|
||||||
1684050540:526048
|
1714275415:625500
|
||||||
1684346940:527048
|
1714718068:627000
|
||||||
1684659780:528048
|
1715165016:628500
|
||||||
1684960560:529048
|
1715625858:630000
|
||||||
1685261880:530048
|
1716087656:631500
|
||||||
1685565420:531048
|
1716542805:633000
|
||||||
1685865660:532048
|
1716996296:634500
|
||||||
1686178020:533048
|
1717442953:636000
|
||||||
1686480060:534048
|
1717899613:637500
|
||||||
1686776820:535048
|
1718343607:639000
|
||||||
1687078860:536048
|
1718798893:640500
|
||||||
1687379580:537048
|
1719259600:642000
|
||||||
1687685100:538048
|
1719715208:643500
|
||||||
1687993320:539048
|
1720171971:645000
|
||||||
1688293380:540048
|
1720619357:646500
|
||||||
1688598420:541048
|
1721076296:648000
|
||||||
1688898120:542048
|
1721532239:649500
|
||||||
1689208500:543048
|
1721987052:651000
|
||||||
1689503940:544048
|
1722437031:652500
|
||||||
1689802800:545048
|
1722888953:654000
|
||||||
1690109040:546048
|
1723343110:655500
|
||||||
1690414320:547048
|
1723795513:657000
|
||||||
1690721280:548048
|
1724247189:658500
|
||||||
1691025480:549048
|
1724705009:660000
|
||||||
1691325720:550048
|
1725165021:661500
|
||||||
1691633940:551048
|
1725617360:663000
|
||||||
1691931480:552048
|
1726071946:664500
|
||||||
1692233280:553048
|
1726522439:666000
|
||||||
1692532620:554048
|
1726979451:667500
|
||||||
1692843420:555048
|
1727430507:669000
|
||||||
1693147020:556048
|
1727884081:670500
|
||||||
1693453140:557048
|
1728334947:672000
|
||||||
1693749540:558048
|
1728789282:673500
|
||||||
1694054460:559048
|
1729231059:675000
|
||||||
1694348940:560048
|
1729687775:676500
|
||||||
1694661120:561048
|
1730150813:678000
|
||||||
1694961240:562048
|
1730607377:679500
|
||||||
1695270600:563048
|
1731052869:681000
|
||||||
1695572880:564048
|
1731502177:682500
|
||||||
1695891600:565048
|
1731953584:684000
|
||||||
1696201200:566048
|
1732397467:685500
|
||||||
1696506300:567048
|
1732857103:687000
|
||||||
1696833360:568048
|
1733305492:688500
|
||||||
1697119860:569048
|
1733757932:690000
|
||||||
1697424660:570048
|
1734213761:691500
|
||||||
1697728620:571048
|
1734665858:693000
|
||||||
1698030720:572048
|
1735109323:694500
|
||||||
1698328680:573048
|
1735571098:696000
|
||||||
1698640800:574048
|
1736017391:697500
|
||||||
1698955800:575048
|
1736470274:699000
|
||||||
1699261560:576048
|
1736922413:700500
|
||||||
1699561560:577048
|
1737375374:702000
|
||||||
1699873800:578048
|
1737822863:703500
|
||||||
1700185680:579048
|
1738278318:705000
|
||||||
1700481900:580048
|
1738734744:706500
|
||||||
1700801820:581048
|
1739188167:708000
|
||||||
1701103860:582048
|
1739646442:709500
|
||||||
1701414900:583048
|
1740096952:711000
|
||||||
1701721080:584048
|
1740548052:712500
|
||||||
1702027260:585048
|
1741000180:714000
|
||||||
1702332300:586048
|
1741453599:715500
|
||||||
1702641960:587048
|
1741905136:717000
|
||||||
|
1742364662:718500
|
||||||
|
1742812126:720000
|
||||||
|
1743264843:721500
|
||||||
|
1743714525:723000
|
||||||
|
1744175390:724500
|
||||||
|
1744626378:726000
|
||||||
|
1745080531:727500
|
||||||
|
1745536519:729000
|
||||||
|
1745985232:730500
|
||||||
|
1746429926:732000
|
||||||
|
1746893280:733500
|
||||||
|
1747344244:735000
|
||||||
|
1747800382:736500
|
||||||
|
1748256803:738000
|
||||||
|
1748720133:739500
|
||||||
|
1749171722:741000
|
||||||
|
1749626761:742500
|
||||||
|
1750075168:744000
|
||||||
|
1750529452:745500
|
||||||
|
1750980136:747000
|
||||||
|
1751430178:748500
|
||||||
|
1751884163:750000
|
||||||
|
1752336519:751500
|
||||||
|
1752789487:753000
|
||||||
|
1753244800:754500
|
||||||
|
1753695473:756000
|
||||||
|
1754167218:757500
|
||||||
|
1754620759:759000
|
||||||
|
1755063742:760500
|
||||||
|
1755512182:762000
|
||||||
|
1755966558:763500
|
||||||
|
1756408673:765000
|
||||||
|
1756867066:766500
|
||||||
|
1757321935:768000
|
||||||
|
1757781405:769500
|
||||||
|
1758227963:771000
|
||||||
|
1758690444:772500
|
||||||
|
1759142623:774000
|
||||||
|
1759597441:775500
|
||||||
|
1760050166:777000
|
||||||
|
1760517211:778500
|
||||||
|
1760966094:780000
|
||||||
|
1761425868:781500
|
||||||
|
1761871754:783000
|
||||||
|
1762332424:784500
|
||||||
|
1762788746:786000
|
||||||
|
1763241559:787500
|
||||||
|
1763691073:789000
|
||||||
|
1764148322:790500
|
||||||
|
1764600720:792000
|
||||||
|
1765052466:793500
|
||||||
|
1765508447:795000
|
||||||
|
1765967616:796500
|
||||||
|
1766426715:798000
|
||||||
|
1766881106:799500
|
||||||
|
1767332242:801000
|
||||||
|
1767779604:802500
|
||||||
|
1768229063:804000
|
||||||
|
1768689528:805500
|
||||||
|
1769142523:807000
|
||||||
|
1769587539:808500
|
||||||
|
1770045670:810000
|
||||||
|
1770505705:811500
|
||||||
|
1770956798:813000
|
||||||
|
1771407979:814500
|
||||||
|
1771860479:816000
|
||||||
|
1772315752:817500
|
||||||
|
1772768810:819000
|
||||||
|
1773214642:820500
|
||||||
|
1773666005:822000
|
||||||
|
1774124830:823500
|
||||||
|
1774579737:825000
|
||||||
|
1775029537:826500
|
||||||
|
1775486297:828000
|
||||||
|
1775943681:829500
|
||||||
|
1776404619:831000
|
||||||
|
1776859398:832500
|
||||||
|
1777309533:834000
|
||||||
|
1777768990:835500
|
||||||
|
1778220072:837000
|
||||||
|
1778676940:838500
|
||||||
|
1779131063:840000
|
||||||
|
1779582902:841500
|
||||||
|
1780038251:843000
|
||||||
|
1780489721:844500
|
||||||
|
1780940799:846000
|
||||||
|
1781397311:847500
|
||||||
|
1781853697:849000
|
||||||
|
|||||||
@@ -137,7 +137,7 @@
|
|||||||
<item row="3" column="1">
|
<item row="3" column="1">
|
||||||
<widget class="QLabel" name="label_11">
|
<widget class="QLabel" name="label_11">
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>codeberg.org/wownero/wowlet</string>
|
<string>github.com/wownero/wowlet</string>
|
||||||
</property>
|
</property>
|
||||||
<property name="textInteractionFlags">
|
<property name="textInteractionFlags">
|
||||||
<set>Qt::TextInteractionFlag::LinksAccessibleByMouse|Qt::TextInteractionFlag::TextSelectableByMouse</set>
|
<set>Qt::TextInteractionFlag::LinksAccessibleByMouse|Qt::TextInteractionFlag::TextSelectableByMouse</set>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// SPDX-License-Identifier: BSD-3-Clause
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
// SPDX-FileCopyrightText: The Monero Project
|
// SPDX-FileCopyrightText: The Monero Project
|
||||||
|
|
||||||
|
#include <QQuickWindow>
|
||||||
#include <QSslSocket>
|
#include <QSslSocket>
|
||||||
|
|
||||||
#include "Application.h"
|
#include "Application.h"
|
||||||
@@ -88,6 +89,14 @@ if (AttachConsole(ATTACH_PARENT_PROCESS)) {
|
|||||||
QApplication::setHighDpiScaleFactorRoundingPolicy(Qt::HighDpiScaleFactorRoundingPolicy::Round);
|
QApplication::setHighDpiScaleFactorRoundingPolicy(Qt::HighDpiScaleFactorRoundingPolicy::Round);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
// wowlet: force the QtQuick scene graph to the software renderer. The app's only QtQuick surface
|
||||||
|
// (the Mining tab's QQuickWidget) otherwise drives the default RHI/GL path, which faults (0xc0000005)
|
||||||
|
// on broken-GL environments — notably VirtualBox's VMSVGA — crashing the whole app and orphaning the
|
||||||
|
// embedded node. The mining widget is small + static, so software rendering costs nothing meaningful
|
||||||
|
// and guarantees no GL-init crash on any user's hardware. (Confirmed in a VMSVGA Win11 VM: the software
|
||||||
|
// backend launches cleanly where the default GL backend crashed.)
|
||||||
|
QQuickWindow::setGraphicsApi(QSGRendererInterface::Software);
|
||||||
|
|
||||||
Application app(argc, argv);
|
Application app(argc, argv);
|
||||||
|
|
||||||
QApplication::setApplicationName("wowlet");
|
QApplication::setApplicationName("wowlet");
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
// SPDX-FileCopyrightText: The Monero Project
|
||||||
|
|
||||||
|
#include "utils/ChildProcessGuard.h"
|
||||||
|
|
||||||
|
#include <QDebug>
|
||||||
|
|
||||||
|
#if defined(Q_OS_WIN)
|
||||||
|
#include <windows.h>
|
||||||
|
#elif defined(Q_OS_UNIX)
|
||||||
|
#include <csignal>
|
||||||
|
#if defined(Q_OS_LINUX)
|
||||||
|
#include <sys/prctl.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace ChildProcessGuard
|
||||||
|
{
|
||||||
|
|
||||||
|
#if defined(Q_OS_WIN)
|
||||||
|
|
||||||
|
// One process-wide job for all wowlet children. Created lazily on first use; the HANDLE is
|
||||||
|
// intentionally never closed — it must stay open for wowlet's entire lifetime so the job persists.
|
||||||
|
// On process death (clean OR crash) the kernel closes it, fires KILL_ON_JOB_CLOSE, and reaps the kids.
|
||||||
|
static HANDLE g_job = nullptr;
|
||||||
|
|
||||||
|
static HANDLE jobHandle()
|
||||||
|
{
|
||||||
|
if (g_job)
|
||||||
|
return g_job;
|
||||||
|
|
||||||
|
HANDLE job = CreateJobObjectW(nullptr, nullptr);
|
||||||
|
if (!job) {
|
||||||
|
qWarning() << "ChildProcessGuard: CreateJobObject failed:" << GetLastError();
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
JOBOBJECT_EXTENDED_LIMIT_INFORMATION info{};
|
||||||
|
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
||||||
|
if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, &info, sizeof(info))) {
|
||||||
|
qWarning() << "ChildProcessGuard: SetInformationJobObject failed:" << GetLastError();
|
||||||
|
CloseHandle(job);
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
g_job = job;
|
||||||
|
return g_job;
|
||||||
|
}
|
||||||
|
|
||||||
|
void installPreStart(QProcess *) { /* no-op on Windows: assignment happens post-start */ }
|
||||||
|
|
||||||
|
void adoptAfterStart(QProcess *process)
|
||||||
|
{
|
||||||
|
HANDLE job = jobHandle();
|
||||||
|
if (!job)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// QProcess::start() is asynchronous: right after it returns the child often has no PID yet. Wait
|
||||||
|
// (bounded) for it to actually start, else the assign is silently skipped and the child survives a
|
||||||
|
// parent crash (orphaned) — this is exactly what stranded wownerod in testing. Returns the instant
|
||||||
|
// the process is created, so the UI-thread cost is tiny.
|
||||||
|
if (process->state() == QProcess::Starting)
|
||||||
|
process->waitForStarted(5000);
|
||||||
|
|
||||||
|
const qint64 pid = process->processId(); // 0 if it still isn't running
|
||||||
|
if (pid == 0) {
|
||||||
|
qWarning() << "ChildProcessGuard: child has no PID; not assigned to job";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssignProcessToJobObject needs PROCESS_SET_QUOTA | PROCESS_TERMINATE. QProcess does not expose
|
||||||
|
// the child HANDLE, so re-open it by PID.
|
||||||
|
HANDLE proc = OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, FALSE,
|
||||||
|
static_cast<DWORD>(pid));
|
||||||
|
if (!proc) {
|
||||||
|
qWarning() << "ChildProcessGuard: OpenProcess failed for pid" << pid << ":" << GetLastError();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!AssignProcessToJobObject(job, proc))
|
||||||
|
qWarning() << "ChildProcessGuard: AssignProcessToJobObject failed:" << GetLastError();
|
||||||
|
else
|
||||||
|
qInfo() << "ChildProcessGuard: bound child pid" << pid << "to kill-on-close job";
|
||||||
|
|
||||||
|
CloseHandle(proc); // closing this per-child handle does NOT remove the process from the job
|
||||||
|
}
|
||||||
|
|
||||||
|
#elif defined(Q_OS_UNIX)
|
||||||
|
|
||||||
|
void installPreStart(QProcess *process)
|
||||||
|
{
|
||||||
|
#if defined(Q_OS_LINUX)
|
||||||
|
// Runs in the forked child between fork() and exec(). Keep it async-signal-safe.
|
||||||
|
process->setChildProcessModifier([] {
|
||||||
|
::prctl(PR_SET_PDEATHSIG, SIGKILL);
|
||||||
|
// Fork-race guard: if the parent already died before prctl ran, self-terminate.
|
||||||
|
if (::getppid() == 1)
|
||||||
|
::raise(SIGKILL);
|
||||||
|
});
|
||||||
|
#else
|
||||||
|
Q_UNUSED(process) // macOS/BSD: no PR_SET_PDEATHSIG; rely on aboutToQuit + destructor stop()
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void adoptAfterStart(QProcess *) { /* no-op on Unix: handled pre-start */ }
|
||||||
|
|
||||||
|
#else
|
||||||
|
|
||||||
|
void installPreStart(QProcess *) {}
|
||||||
|
void adoptAfterStart(QProcess *) {}
|
||||||
|
|
||||||
|
#endif
|
||||||
|
|
||||||
|
} // namespace ChildProcessGuard
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
// SPDX-FileCopyrightText: The Monero Project
|
||||||
|
|
||||||
|
#ifndef WOWLET_CHILDPROCESSGUARD_H
|
||||||
|
#define WOWLET_CHILDPROCESSGUARD_H
|
||||||
|
|
||||||
|
#include <QProcess>
|
||||||
|
|
||||||
|
// wowlet: OS-level "die with parent" for spawned child processes (embedded wownerod, bundled tor).
|
||||||
|
//
|
||||||
|
// QProcess does NOT kill its child when the parent exits or crashes; it only detaches. Feather's
|
||||||
|
// TorManager/DaemonManager relied solely on an explicit stop() in the normal-quit path, so an
|
||||||
|
// unhandled crash (e.g. a 0xc0000005 access violation) orphaned the children. This binds the
|
||||||
|
// child's lifetime to wowlet's at the kernel level, which survives a hard crash where no C++
|
||||||
|
// destructor and no Qt teardown runs.
|
||||||
|
//
|
||||||
|
// Windows: a single process-wide Job Object with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE. Assign each
|
||||||
|
// child after start(); when wowlet's process dies the job's last handle closes and the
|
||||||
|
// kernel terminates every process still in the job.
|
||||||
|
// Unix: prctl(PR_SET_PDEATHSIG, SIGKILL) installed in the child (via setChildProcessModifier),
|
||||||
|
// so the kernel SIGKILLs the child when the parent dies.
|
||||||
|
namespace ChildProcessGuard
|
||||||
|
{
|
||||||
|
// Unix: arrange for `process` to receive SIGKILL when wowlet dies. Call BEFORE process->start().
|
||||||
|
// No-op on Windows (the Job Object handles it post-start).
|
||||||
|
void installPreStart(QProcess *process);
|
||||||
|
|
||||||
|
// Windows: assign the (now-running) child to wowlet's kill-on-close job. Call right AFTER start()
|
||||||
|
// returns and the child has a PID. No-op on Unix.
|
||||||
|
void adoptAfterStart(QProcess *process);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif //WOWLET_CHILDPROCESSGUARD_H
|
||||||
@@ -4,12 +4,24 @@
|
|||||||
#include "utils/DaemonManager.h"
|
#include "utils/DaemonManager.h"
|
||||||
|
|
||||||
#include <QCoreApplication>
|
#include <QCoreApplication>
|
||||||
|
#include <QDateTime>
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
#include <QDirIterator>
|
#include <QDirIterator>
|
||||||
|
#include <QEventLoop>
|
||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QNetworkAccessManager>
|
||||||
|
#include <QNetworkReply>
|
||||||
|
#include <QNetworkRequest>
|
||||||
#include <QRegularExpression>
|
#include <QRegularExpression>
|
||||||
|
#include <QUrl>
|
||||||
|
|
||||||
|
#include "constants.h"
|
||||||
|
#include "utils/AppData.h"
|
||||||
|
#include "utils/ChildProcessGuard.h" // wowlet: OS-level die-with-parent for the embedded node
|
||||||
#include "utils/config.h"
|
#include "utils/config.h"
|
||||||
|
#include "utils/networktype.h"
|
||||||
#include "utils/TorManager.h"
|
#include "utils/TorManager.h"
|
||||||
#include "utils/Utils.h"
|
#include "utils/Utils.h"
|
||||||
|
|
||||||
@@ -25,6 +37,11 @@ DaemonManager::DaemonManager(QObject *parent)
|
|||||||
connect(m_process, &QProcess::readyReadStandardOutput, this, &DaemonManager::handleProcessOutput);
|
connect(m_process, &QProcess::readyReadStandardOutput, this, &DaemonManager::handleProcessOutput);
|
||||||
connect(m_process, &QProcess::errorOccurred, this, &DaemonManager::handleProcessError);
|
connect(m_process, &QProcess::errorOccurred, this, &DaemonManager::handleProcessError);
|
||||||
connect(m_process, &QProcess::stateChanged, this, &DaemonManager::onStateChanged);
|
connect(m_process, &QProcess::stateChanged, this, &DaemonManager::onStateChanged);
|
||||||
|
|
||||||
|
// wowlet: SIGKILL wownerod if wowlet dies (Unix); Windows binds it to a kill-on-close job post-start.
|
||||||
|
ChildProcessGuard::installPreStart(m_process);
|
||||||
|
// wowlet: graceful stop on any clean quit, not just the last-window-closed path (flushes LMDB).
|
||||||
|
connect(QCoreApplication::instance(), &QCoreApplication::aboutToQuit, this, [this]{ this->stop(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
QPointer<DaemonManager> DaemonManager::m_instance(nullptr);
|
QPointer<DaemonManager> DaemonManager::m_instance(nullptr);
|
||||||
@@ -46,18 +63,42 @@ void DaemonManager::start() {
|
|||||||
if (state == QProcess::ProcessState::Running || state == QProcess::ProcessState::Starting)
|
if (state == QProcess::ProcessState::Running || state == QProcess::ProcessState::Starting)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
// wowlet: a deliberate (re)start clears the stop latch, so a later UNEXPECTED exit can auto-restart
|
||||||
|
// again. Without this, m_stopping stays true after the first node toggle-off and the crash
|
||||||
|
// auto-restart is silently dead for the rest of the session.
|
||||||
|
m_stopping = false;
|
||||||
|
|
||||||
if (!this->unpackBins()) {
|
if (!this->unpackBins()) {
|
||||||
this->setErrorMessage("Unable to unpack the embedded wownerod binary.");
|
this->setErrorMessage("Unable to unpack the embedded wownerod binary.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wowlet: don't trust a bare open port. Before adopting whatever is listening on our RPC port,
|
||||||
|
// verify via get_info that it's a healthy wownero-mainnet wownerod (right nettype, sane height,
|
||||||
|
// making progress) — not a stale/stuck orphan from a prior crash or an unrelated squatter.
|
||||||
if (Utils::portOpen(this->rpcHost, this->rpcPort)) {
|
if (Utils::portOpen(this->rpcHost, this->rpcPort)) {
|
||||||
// Something is already listening on our RPC port; assume it is a usable local node.
|
if (this->verifyAdoptableNode()) {
|
||||||
m_alreadyRunning = true;
|
qDebug() << "Adopting existing wownerod on" << this->rpcAddress();
|
||||||
emit daemonStateChanged(true);
|
m_alreadyRunning = true;
|
||||||
return;
|
emit daemonStateChanged(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
qWarning() << "Refusing to adopt unverified listener on" << this->rpcAddress()
|
||||||
|
<< "- will spawn a managed wownerod instead";
|
||||||
|
this->setErrorMessage(
|
||||||
|
QString("Found an unrecognized process on %1; starting a fresh node.").arg(this->rpcAddress()));
|
||||||
|
// fall through to spawn; if the port/data-dir is genuinely held, the spawn fails to bind
|
||||||
|
// and surfaces a clear error via handleProcessError().
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wowlet: the retry cap guards against a rapid crash-loop, not legit restarts. If the node had been
|
||||||
|
// up a while before this (re)start, clear the budget — otherwise a few node toggles over a session
|
||||||
|
// trip "maximum retries exceeded" and it won't start again until wowlet is relaunched.
|
||||||
|
const qint64 nowMs = QDateTime::currentMSecsSinceEpoch();
|
||||||
|
if (m_lastSpawnMs != 0 && (nowMs - m_lastSpawnMs) > 60000)
|
||||||
|
m_restarts = 0;
|
||||||
|
m_lastSpawnMs = nowMs;
|
||||||
|
|
||||||
m_restarts += 1;
|
m_restarts += 1;
|
||||||
if (m_restarts > 4) {
|
if (m_restarts > 4) {
|
||||||
this->setErrorMessage("wownerod failed to start: maximum retries exceeded");
|
this->setErrorMessage("wownerod failed to start: maximum retries exceeded");
|
||||||
@@ -81,6 +122,14 @@ void DaemonManager::start() {
|
|||||||
arguments << "--no-igd"; // no UPnP port mapping
|
arguments << "--no-igd"; // no UPnP port mapping
|
||||||
arguments << "--out-peers" << "16";
|
arguments << "--out-peers" << "16";
|
||||||
arguments << "--log-file" << QDir(dataDir).filePath("wownerod.log");
|
arguments << "--log-file" << QDir(dataDir).filePath("wownerod.log");
|
||||||
|
// wowlet: disable LMDB batch write mode so every block is committed in its own transaction.
|
||||||
|
// Without this, wownero accumulates blocks in a single large write transaction that is ABORTED
|
||||||
|
// (not committed) when the daemon shuts down mid-sync — causing all progress since the last
|
||||||
|
// batch boundary to be silently lost. This is visible in wownerod.log: sessions consistently
|
||||||
|
// revert to a stale height despite a full graceful shutdown sequence. Using "safe" mode is
|
||||||
|
// slower for initial sync but is the only correct choice for an embedded node that starts and
|
||||||
|
// stops with the wallet; the wownero chain is small enough that the overhead is acceptable.
|
||||||
|
arguments << "--db-sync-mode" << "safe";
|
||||||
|
|
||||||
// Full archival by default (wownero's chain is small); prune is an explicit opt-in.
|
// Full archival by default (wownero's chain is small); prune is an explicit opt-in.
|
||||||
if (conf()->get(Config::pruneBlockchain).toBool())
|
if (conf()->get(Config::pruneBlockchain).toBool())
|
||||||
@@ -106,6 +155,7 @@ void DaemonManager::start() {
|
|||||||
qDebug() << QString("Starting wownerod: %1 %2").arg(this->daemonPath, arguments.join(" "));
|
qDebug() << QString("Starting wownerod: %1 %2").arg(this->daemonPath, arguments.join(" "));
|
||||||
|
|
||||||
m_process->start(this->daemonPath, arguments);
|
m_process->start(this->daemonPath, arguments);
|
||||||
|
ChildProcessGuard::adoptAfterStart(m_process); // wowlet: bind to kill-on-crash job (Windows)
|
||||||
m_started = true;
|
m_started = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,6 +288,95 @@ bool DaemonManager::shouldStartDaemon() {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wowlet: synchronous, bounded get_info probe used by start() to decide whether an already-open RPC
|
||||||
|
// port is a node we can safely adopt. Returns true ONLY for a healthy wownero-mainnet wownerod.
|
||||||
|
// Self-contained (own QNetworkAccessManager + local QEventLoop with a hard timeout) so it can run
|
||||||
|
// inside the synchronous start() at boot, before any wallet/DaemonRpc exists. Mirrors TorManager's
|
||||||
|
// blocking getVersion() probe idiom.
|
||||||
|
bool DaemonManager::verifyAdoptableNode() {
|
||||||
|
if (conf()->get(Config::offlineMode).toBool())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
QNetworkAccessManager nam;
|
||||||
|
QNetworkRequest req(QUrl(QString("http://%1/get_info").arg(this->rpcAddress())));
|
||||||
|
req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
|
||||||
|
|
||||||
|
QEventLoop loop;
|
||||||
|
QTimer timeout;
|
||||||
|
timeout.setSingleShot(true);
|
||||||
|
QObject::connect(&timeout, &QTimer::timeout, &loop, &QEventLoop::quit);
|
||||||
|
|
||||||
|
QNetworkReply *reply = nam.post(req, QByteArray("{}"));
|
||||||
|
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
|
||||||
|
|
||||||
|
timeout.start(1500); // hard cap on UI-thread block
|
||||||
|
loop.exec();
|
||||||
|
|
||||||
|
if (!reply->isFinished()) { // timed out
|
||||||
|
reply->abort();
|
||||||
|
reply->deleteLater();
|
||||||
|
qWarning() << "get_info verify: no response from" << this->rpcAddress() << "within 1.5s";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool netOk = reply->error() == QNetworkReply::NoError;
|
||||||
|
QByteArray data = reply->readAll();
|
||||||
|
reply->deleteLater();
|
||||||
|
|
||||||
|
if (!netOk || data.isEmpty() || !Utils::validateJSON(data)) {
|
||||||
|
qWarning() << "get_info verify: not a JSON-RPC daemon on" << this->rpcAddress();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject obj = QJsonDocument::fromJson(data).object();
|
||||||
|
|
||||||
|
if (obj.value("status").toString() != "OK") { // 1. real wownerod RPC
|
||||||
|
qWarning() << "get_info verify: bad status" << obj.value("status").toString();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString wantNet = constants::networkType == NetworkType::MAINNET ? "mainnet" // 2. nettype
|
||||||
|
: constants::networkType == NetworkType::TESTNET ? "testnet"
|
||||||
|
: "stagenet";
|
||||||
|
if (obj.value("nettype").toString() != wantNet) {
|
||||||
|
qWarning() << "get_info verify: wrong nettype" << obj.value("nettype").toString()
|
||||||
|
<< "(want" << wantNet << ")";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const quint64 height = obj.value("height").toVariant().toULongLong(); // 3. height sanity
|
||||||
|
if (height == 0) {
|
||||||
|
qWarning() << "get_info verify: node reports height 0";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
quint64 knownHeight = 0;
|
||||||
|
if (appData()->heights.contains(constants::networkType))
|
||||||
|
knownHeight = static_cast<quint64>(appData()->heights[constants::networkType]);
|
||||||
|
|
||||||
|
const quint64 peers = obj.value("outgoing_connections_count").toVariant().toULongLong()
|
||||||
|
+ obj.value("incoming_connections_count").toVariant().toULongLong();
|
||||||
|
const bool progressing = obj.value("busy_syncing").toBool() || obj.value("synchronized").toBool();
|
||||||
|
|
||||||
|
if (knownHeight > 0) {
|
||||||
|
const quint64 slack = 720; // ~1 day of wownero blocks; live-but-lagging is still adoptable
|
||||||
|
if (height + slack < knownHeight) {
|
||||||
|
if (peers == 0 && !progressing) { // 4. stuck-orphan signature: behind AND peerless AND idle
|
||||||
|
qWarning() << "get_info verify: stale/stuck node — height" << height
|
||||||
|
<< "<< known" << knownHeight << "with 0 peers, not syncing";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
qWarning() << "get_info verify: node behind (" << height << "/" << knownHeight
|
||||||
|
<< ") but live; adopting and letting it catch up";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (peers == 0) // soft: a freshly-spawned-but-orphaned node sits at 0 peers for minutes
|
||||||
|
qWarning() << "get_info verify: adopting node with 0 peers (likely still bootstrapping)";
|
||||||
|
|
||||||
|
qDebug() << "get_info verify: OK — nettype" << wantNet << "height" << height << "peers" << peers;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
void DaemonManager::setErrorMessage(const QString &msg) {
|
void DaemonManager::setErrorMessage(const QString &msg) {
|
||||||
this->errorMsg = msg;
|
this->errorMsg = msg;
|
||||||
if (!msg.isEmpty())
|
if (!msg.isEmpty())
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ private slots:
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
bool shouldStartDaemon();
|
bool shouldStartDaemon();
|
||||||
|
// wowlet: verify an already-open RPC port really is a healthy wownero-mainnet wownerod before
|
||||||
|
// adopting it, instead of trusting a bare TCP port-open probe. Prevents the wallet from silently
|
||||||
|
// syncing against a stale/stuck orphan (e.g. left by a prior crash) or a process squatting
|
||||||
|
// 127.0.0.1:34568. Synchronous, bounded (~1.5s), UI-thread-safe.
|
||||||
|
bool verifyAdoptableNode();
|
||||||
void setErrorMessage(const QString &msg);
|
void setErrorMessage(const QString &msg);
|
||||||
|
|
||||||
QProcess *m_process;
|
QProcess *m_process;
|
||||||
@@ -68,6 +73,7 @@ private:
|
|||||||
bool m_synced = false;
|
bool m_synced = false;
|
||||||
bool m_stopping = false;
|
bool m_stopping = false;
|
||||||
int m_restarts = 0;
|
int m_restarts = 0;
|
||||||
|
qint64 m_lastSpawnMs = 0; // wowlet: wall-clock of last spawn — ages out the crash-retry budget
|
||||||
|
|
||||||
static QPointer<DaemonManager> m_instance;
|
static QPointer<DaemonManager> m_instance;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
#include <QCoreApplication>
|
#include <QCoreApplication>
|
||||||
#include <QDirIterator>
|
#include <QDirIterator>
|
||||||
|
|
||||||
|
#include "utils/ChildProcessGuard.h" // wowlet: OS-level die-with-parent for bundled tor
|
||||||
#include "utils/config.h"
|
#include "utils/config.h"
|
||||||
#include "utils/Utils.h"
|
#include "utils/Utils.h"
|
||||||
#include "utils/os/tails.h"
|
#include "utils/os/tails.h"
|
||||||
@@ -37,15 +38,26 @@ TorManager::TorManager(QObject *parent)
|
|||||||
connect(m_process, &QProcess::readyReadStandardOutput, this, &TorManager::handleProcessOutput);
|
connect(m_process, &QProcess::readyReadStandardOutput, this, &TorManager::handleProcessOutput);
|
||||||
connect(m_process, &QProcess::errorOccurred, this, &TorManager::handleProcessError);
|
connect(m_process, &QProcess::errorOccurred, this, &TorManager::handleProcessError);
|
||||||
connect(m_process, &QProcess::stateChanged, this, &TorManager::stateChanged);
|
connect(m_process, &QProcess::stateChanged, this, &TorManager::stateChanged);
|
||||||
|
|
||||||
|
ChildProcessGuard::installPreStart(m_process); // wowlet: SIGKILL tor if wowlet dies (Unix)
|
||||||
|
connect(QCoreApplication::instance(), &QCoreApplication::aboutToQuit, this, [this]{ this->stop(); }); // wowlet
|
||||||
}
|
}
|
||||||
|
|
||||||
QPointer<TorManager> TorManager::m_instance(nullptr);
|
QPointer<TorManager> TorManager::m_instance(nullptr);
|
||||||
|
|
||||||
void TorManager::init() {
|
void TorManager::init() {
|
||||||
|
// wowlet: a live proxy/useLocalTor toggle re-runs init(). Reset failure/retry state so switching
|
||||||
|
// system->bundled Tor after a prior failure (Linux unpack, or a FailedToStart that latched
|
||||||
|
// m_stopRetries in handleProcessError) can actually start again instead of staying silently dead.
|
||||||
|
m_restarts = 0;
|
||||||
|
m_stopRetries = false;
|
||||||
|
m_unpacked = false; // re-evaluate the unpack on the next start()
|
||||||
|
|
||||||
m_localTor = !shouldStartTorDaemon();
|
m_localTor = !shouldStartTorDaemon();
|
||||||
|
|
||||||
auto state = m_process->state();
|
auto state = m_process->state();
|
||||||
if (m_localTor && (state == QProcess::ProcessState::Running || state == QProcess::ProcessState::Starting)) {
|
if (m_localTor && (state == QProcess::ProcessState::Running || state == QProcess::ProcessState::Starting)) {
|
||||||
|
m_stopRetries = true; // wowlet: intentional kill (switching to system Tor); don't auto-restart it
|
||||||
m_process->kill();
|
m_process->kill();
|
||||||
m_started = false;
|
m_started = false;
|
||||||
}
|
}
|
||||||
@@ -54,6 +66,8 @@ void TorManager::init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void TorManager::stop() {
|
void TorManager::stop() {
|
||||||
|
m_stopRetries = true; // wowlet: intentional stop — suppress the stateChanged() auto-restart
|
||||||
|
m_checkConnectionTimer->stop(); // wowlet: stop probing the connection after an intentional stop
|
||||||
m_process->kill();
|
m_process->kill();
|
||||||
m_started = false;
|
m_started = false;
|
||||||
}
|
}
|
||||||
@@ -82,6 +96,17 @@ void TorManager::start() {
|
|||||||
this->torPath = alternativeTorFile;
|
this->torPath = alternativeTorFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wowlet: never hand QProcess a non-existent path (the Linux "empty folder" symptom). If the bundled
|
||||||
|
// binary isn't on disk, degrade to local/external-Tor mode + surface an error instead of burning the
|
||||||
|
// 4-retry budget on FailedToStart. With clearnet sync, node sync is unaffected; only broadcast-over-Tor
|
||||||
|
// is lost, which DaemonManager tolerates via clearnet relay.
|
||||||
|
if (!QFileInfo(this->torPath).isFile()) {
|
||||||
|
this->setErrorMessage("Bundled Tor binary not found; using external/local Tor if available: " + this->torPath);
|
||||||
|
m_localTor = true;
|
||||||
|
this->checkConnection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
qDebug() << QString("Start process: %1").arg(this->torPath);
|
qDebug() << QString("Start process: %1").arg(this->torPath);
|
||||||
|
|
||||||
m_restarts += 1;
|
m_restarts += 1;
|
||||||
@@ -102,6 +127,7 @@ void TorManager::start() {
|
|||||||
qDebug() << QString("%1 %2").arg(this->torPath, arguments.join(" "));
|
qDebug() << QString("%1 %2").arg(this->torPath, arguments.join(" "));
|
||||||
|
|
||||||
m_process->start(this->torPath, arguments);
|
m_process->start(this->torPath, arguments);
|
||||||
|
ChildProcessGuard::adoptAfterStart(m_process); // wowlet: bind to kill-on-crash job (Windows)
|
||||||
m_started = true;
|
m_started = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,37 +227,54 @@ bool TorManager::unpackBins() {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
SemanticVersion embeddedVersion = SemanticVersion::fromString(QString(TOR_VERSION));
|
// wowlet: QFile::copy never creates parent dirs; without this the copy loop can silently no-op on
|
||||||
SemanticVersion filesystemVersion = this->getVersion(torPath);
|
// a fresh profile, leaving an empty tor/ dir and "tor failed to start" (Lucas/Fedora). main.cpp
|
||||||
qDebug() << QString("Tor versions: embedded %1, filesystem %2").arg(embeddedVersion.toString(), filesystemVersion.toString());
|
// also mkpaths this, but make unpackBins() self-sufficient. Mirrors DaemonManager::unpackBins().
|
||||||
if (SemanticVersion::isValid(filesystemVersion) && (embeddedVersion > filesystemVersion)) {
|
QDir().mkpath(this->torDir);
|
||||||
qInfo() << "Embedded version is newer, overwriting.";
|
|
||||||
QFile::setPermissions(torPath, QFile::ReadOther | QFile::WriteOther);
|
|
||||||
if (!QFile::remove(torPath)) {
|
|
||||||
qWarning() << "Unable to remove old Tor binary";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (embeddedVersion > filesystemVersion) {
|
// wowlet: unpack when the binary is MISSING, not only when a version compare says "newer". Overwrite
|
||||||
|
// only when the embedded build is a VALID newer version (getVersion() of a non-existent file returns
|
||||||
|
// an invalid SemanticVersion, so it must not gate the first unpack).
|
||||||
|
bool present = QFileInfo(this->torPath).isFile();
|
||||||
|
SemanticVersion embeddedVersion = SemanticVersion::fromString(QString(TOR_VERSION));
|
||||||
|
SemanticVersion filesystemVersion = present ? this->getVersion(this->torPath) : SemanticVersion();
|
||||||
|
qDebug() << QString("Tor versions: embedded %1, filesystem %2").arg(embeddedVersion.toString(), filesystemVersion.toString());
|
||||||
|
bool needsOverwrite = present && SemanticVersion::isValid(filesystemVersion)
|
||||||
|
&& (embeddedVersion > filesystemVersion);
|
||||||
|
|
||||||
|
if (!present || needsOverwrite) {
|
||||||
|
if (needsOverwrite) {
|
||||||
|
qInfo() << "Embedded Tor is newer, overwriting.";
|
||||||
|
QFile::setPermissions(this->torPath, QFile::ReadOther | QFile::WriteOther);
|
||||||
|
QFile::remove(this->torPath);
|
||||||
|
}
|
||||||
QDirIterator it(":/assets/tor", QDirIterator::Subdirectories);
|
QDirIterator it(":/assets/tor", QDirIterator::Subdirectories);
|
||||||
while (it.hasNext()) {
|
while (it.hasNext()) {
|
||||||
QString assetFile = it.next();
|
QString assetFile = it.next();
|
||||||
QFileInfo assetFileInfo = QFileInfo(assetFile);
|
QString filePath = QDir(this->torDir).filePath(QFileInfo(assetFile).fileName());
|
||||||
QFile f(assetFile);
|
QFile f(assetFile);
|
||||||
QString filePath = QDir(this->torDir).filePath(assetFileInfo.fileName());
|
|
||||||
f.copy(filePath);
|
f.copy(filePath);
|
||||||
f.close();
|
f.close();
|
||||||
}
|
}
|
||||||
qInfo() << "Wrote Tor binaries to: " << this->torDir;
|
qInfo() << "Wrote Tor binaries to:" << this->torDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
#if defined(Q_OS_UNIX)
|
#if defined(Q_OS_UNIX)
|
||||||
QFile tor(this->torPath);
|
// wowlet: only chmod if the binary actually landed.
|
||||||
tor.setPermissions(QFile::ExeUser | QFile::ExeGroup | QFile::ExeOther
|
if (QFileInfo(this->torPath).isFile()) {
|
||||||
| QFile::ReadOwner | QFile::ReadGroup | QFile::ReadOther);
|
QFile tor(this->torPath);
|
||||||
|
tor.setPermissions(QFile::ExeUser | QFile::ExeGroup | QFile::ExeOther
|
||||||
|
| QFile::ReadOwner | QFile::ReadGroup | QFile::ReadOther);
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
// wowlet: do NOT report success if nothing landed — return false so shouldStartTorDaemon() falls
|
||||||
|
// back to --use-local-tor cleanly (see :297-303) instead of start()ing a missing binary.
|
||||||
|
if (!QFileInfo(this->torPath).isFile()) {
|
||||||
|
qWarning() << "Tor was not unpacked (no embedded Tor, or copy failed):" << this->torPath;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
m_unpacked = true;
|
m_unpacked = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -310,7 +353,7 @@ bool TorManager::shouldStartTorDaemon() {
|
|||||||
SemanticVersion TorManager::getVersion(const QString &fileName) {
|
SemanticVersion TorManager::getVersion(const QString &fileName) {
|
||||||
QProcess process;
|
QProcess process;
|
||||||
process.setProcessChannelMode(QProcess::MergedChannels);
|
process.setProcessChannelMode(QProcess::MergedChannels);
|
||||||
process.start(this->torPath, QStringList() << "--version");
|
process.start(fileName, QStringList() << "--version"); // wowlet: use the passed path, not this->torPath
|
||||||
process.waitForFinished(-1);
|
process.waitForFinished(-1);
|
||||||
QString output = process.readAllStandardOutput();
|
QString output = process.readAllStandardOutput();
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ static const QHash<Config::ConfigKey, ConfigDirective> configStrings = {
|
|||||||
{Config::homeWidget,{QS("homeWidget"), "ccs"}},
|
{Config::homeWidget,{QS("homeWidget"), "ccs"}},
|
||||||
{Config::donateBeg,{QS("donateBeg"), 1}},
|
{Config::donateBeg,{QS("donateBeg"), 1}},
|
||||||
{Config::showHistorySyncNotice, {QS("showHistorySyncNotice"), true}},
|
{Config::showHistorySyncNotice, {QS("showHistorySyncNotice"), true}},
|
||||||
|
{Config::reducedMotion, {QS("reducedMotion"), false}},
|
||||||
|
|
||||||
{Config::geometry, {QS("geometry"), {}}},
|
{Config::geometry, {QS("geometry"), {}}},
|
||||||
{Config::windowState, {QS("windowState"), {}}},
|
{Config::windowState, {QS("windowState"), {}}},
|
||||||
@@ -115,7 +116,10 @@ static const QHash<Config::ConfigKey, ConfigDirective> configStrings = {
|
|||||||
|
|
||||||
// Tor
|
// Tor
|
||||||
{Config::proxy, {QS("proxy"), Config::Proxy::Tor}},
|
{Config::proxy, {QS("proxy"), Config::Proxy::Tor}},
|
||||||
{Config::torPrivacyLevel, {QS("torPrivacyLevel"), 1}},
|
// wowlet: allTorExceptNode(0) — NEVER sync blocks over Tor. Block sync is ALWAYS clearnet (speed,
|
||||||
|
// the standard monero model). Tor is ONLY for tx broadcast (via DaemonManager's --tx-proxy), which
|
||||||
|
// is independent of this. This is what makes remote nodes work with OR without Tor.
|
||||||
|
{Config::torPrivacyLevel, {QS("torPrivacyLevel"), 0}},
|
||||||
{Config::torOnlyAllowOnion, {QS("torOnlyAllowOnion"), false}},
|
{Config::torOnlyAllowOnion, {QS("torOnlyAllowOnion"), false}},
|
||||||
{Config::socks5Host, {QS("socks5Host"), "127.0.0.1"}},
|
{Config::socks5Host, {QS("socks5Host"), "127.0.0.1"}},
|
||||||
{Config::socks5Port, {QS("socks5Port"), "9050"}},
|
{Config::socks5Port, {QS("socks5Port"), "9050"}},
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ public:
|
|||||||
homeWidget,
|
homeWidget,
|
||||||
donateBeg,
|
donateBeg,
|
||||||
showHistorySyncNotice,
|
showHistorySyncNotice,
|
||||||
|
reducedMotion, // wowlet: disable playful animations (accessibility / preference)
|
||||||
|
|
||||||
geometry,
|
geometry,
|
||||||
windowState,
|
windowState,
|
||||||
|
|||||||
@@ -330,6 +330,15 @@ void Nodes::autoConnect(bool forceReconnect) {
|
|||||||
this->updateModels();
|
this->updateModels();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wowlet: clear the recent-failure blocklist then force a reconnect. autoConnect(true) alone is not
|
||||||
|
// enough — pickEligibleNode() still filters the failed node out of m_recentFailures, so a node that
|
||||||
|
// dropped during a transient Tor/proxy outage would stay blocklisted ("permanent Disconnected"). The
|
||||||
|
// node/Tor live-toggle handlers call this to recover.
|
||||||
|
void Nodes::reconnect() {
|
||||||
|
m_recentFailures.clear();
|
||||||
|
this->autoConnect(true);
|
||||||
|
}
|
||||||
|
|
||||||
FeatherNode Nodes::pickEligibleNode() {
|
FeatherNode Nodes::pickEligibleNode() {
|
||||||
// Pick a node at random to connect to
|
// Pick a node at random to connect to
|
||||||
auto rtn = FeatherNode();
|
auto rtn = FeatherNode();
|
||||||
|
|||||||
@@ -173,6 +173,9 @@ public slots:
|
|||||||
void onNodeSourceChanged(NodeSource nodeSource);
|
void onNodeSourceChanged(NodeSource nodeSource);
|
||||||
void setCustomNodes(const QList<FeatherNode>& nodes);
|
void setCustomNodes(const QList<FeatherNode>& nodes);
|
||||||
void autoConnect(bool forceReconnect = false);
|
void autoConnect(bool forceReconnect = false);
|
||||||
|
// wowlet: clear the recent-failure blocklist and force-reconnect. Used by the node/Tor live-toggle
|
||||||
|
// handlers so a node blocklisted during a transient outage (e.g. a Tor toggle) is actually retried.
|
||||||
|
void reconnect();
|
||||||
|
|
||||||
private slots:
|
private slots:
|
||||||
void onWalletRefreshed();
|
void onWalletRefreshed();
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
|
|
||||||
#include "MiningWidget.h"
|
#include "MiningWidget.h"
|
||||||
|
|
||||||
|
#include "utils/nodes.h"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
|
||||||
#include <QVBoxLayout>
|
#include <QVBoxLayout>
|
||||||
@@ -11,6 +13,8 @@
|
|||||||
#include <QThread>
|
#include <QThread>
|
||||||
#include <QDateTime>
|
#include <QDateTime>
|
||||||
|
|
||||||
|
#include "utils/config.h"
|
||||||
|
|
||||||
static QString formatUptime(qint64 secs) {
|
static QString formatUptime(qint64 secs) {
|
||||||
return QString("%1:%2:%3")
|
return QString("%1:%2:%3")
|
||||||
.arg(secs / 3600, 2, 10, QChar('0'))
|
.arg(secs / 3600, 2, 10, QChar('0'))
|
||||||
@@ -18,9 +22,10 @@ static QString formatUptime(qint64 secs) {
|
|||||||
.arg(secs % 60, 2, 10, QChar('0'));
|
.arg(secs % 60, 2, 10, QChar('0'));
|
||||||
}
|
}
|
||||||
|
|
||||||
MiningWidget::MiningWidget(Wallet *wallet, QWidget *parent)
|
MiningWidget::MiningWidget(Wallet *wallet, Nodes *nodes, QWidget *parent)
|
||||||
: QWidget(parent)
|
: QWidget(parent)
|
||||||
, m_wallet(wallet)
|
, m_wallet(wallet)
|
||||||
|
, m_nodes(nodes)
|
||||||
, m_quickWidget(new QQuickWidget(this))
|
, m_quickWidget(new QQuickWidget(this))
|
||||||
, m_backend(new MiningBackend(this))
|
, m_backend(new MiningBackend(this))
|
||||||
, m_timer(new QTimer(this))
|
, m_timer(new QTimer(this))
|
||||||
@@ -29,6 +34,7 @@ MiningWidget::MiningWidget(Wallet *wallet, QWidget *parent)
|
|||||||
layout->setContentsMargins(0, 0, 0, 0);
|
layout->setContentsMargins(0, 0, 0, 0);
|
||||||
layout->addWidget(m_quickWidget);
|
layout->addWidget(m_quickWidget);
|
||||||
|
|
||||||
|
m_backend->setReducedMotion(conf()->get(Config::reducedMotion).toBool());
|
||||||
m_quickWidget->rootContext()->setContextProperty("mining", m_backend);
|
m_quickWidget->rootContext()->setContextProperty("mining", m_backend);
|
||||||
m_quickWidget->setResizeMode(QQuickWidget::SizeRootObjectToView);
|
m_quickWidget->setResizeMode(QQuickWidget::SizeRootObjectToView);
|
||||||
m_quickWidget->setSource(QUrl("qrc:/qml/mining.qml"));
|
m_quickWidget->setSource(QUrl("qrc:/qml/mining.qml"));
|
||||||
@@ -43,6 +49,10 @@ MiningWidget::MiningWidget(Wallet *wallet, QWidget *parent)
|
|||||||
}
|
}
|
||||||
|
|
||||||
void MiningWidget::onStartMining() {
|
void MiningWidget::onStartMining() {
|
||||||
|
// The QML disables the control, but the signal is not a promise: refuse
|
||||||
|
// here too rather than send start_mining to a stranger's daemon.
|
||||||
|
if (!m_backend->canMine())
|
||||||
|
return;
|
||||||
int threads = std::max(1, QThread::idealThreadCount() / 2);
|
int threads = std::max(1, QThread::idealThreadCount() / 2);
|
||||||
if (m_wallet->startMining(threads))
|
if (m_wallet->startMining(threads))
|
||||||
m_backend->setState(3);
|
m_backend->setState(3);
|
||||||
@@ -54,6 +64,8 @@ void MiningWidget::onStopMining() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void MiningWidget::poll() {
|
void MiningWidget::poll() {
|
||||||
|
m_backend->setCanMine(m_nodes && m_nodes->connection().isLocal());
|
||||||
|
|
||||||
bool mining = m_wallet->isMining();
|
bool mining = m_wallet->isMining();
|
||||||
m_backend->setState(mining ? 3 : 0);
|
m_backend->setState(mining ? 3 : 0);
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
#define WOWLET_MININGWIDGET_H
|
#define WOWLET_MININGWIDGET_H
|
||||||
|
|
||||||
#include <QWidget>
|
#include <QWidget>
|
||||||
|
|
||||||
|
class Nodes;
|
||||||
#include <QTimer>
|
#include <QTimer>
|
||||||
#include <QQuickWidget>
|
#include <QQuickWidget>
|
||||||
|
|
||||||
@@ -15,13 +17,25 @@
|
|||||||
class MiningBackend : public QObject {
|
class MiningBackend : public QObject {
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
Q_PROPERTY(int daemonMiningState READ daemonMiningState NOTIFY daemonMiningStateChanged)
|
Q_PROPERTY(int daemonMiningState READ daemonMiningState NOTIFY daemonMiningStateChanged)
|
||||||
|
Q_PROPERTY(bool reducedMotion READ reducedMotion NOTIFY reducedMotionChanged)
|
||||||
|
Q_PROPERTY(bool canMine READ canMine NOTIFY canMineChanged)
|
||||||
public:
|
public:
|
||||||
explicit MiningBackend(QObject *parent = nullptr) : QObject(parent) {}
|
explicit MiningBackend(QObject *parent = nullptr) : QObject(parent) {}
|
||||||
int daemonMiningState() const { return m_state; }
|
int daemonMiningState() const { return m_state; }
|
||||||
void setState(int s) { if (m_state != s) { m_state = s; emit daemonMiningStateChanged(); } }
|
void setState(int s) { if (m_state != s) { m_state = s; emit daemonMiningStateChanged(); } }
|
||||||
|
bool reducedMotion() const { return m_reducedMotion; }
|
||||||
|
void setReducedMotion(bool r) { if (m_reducedMotion != r) { m_reducedMotion = r; emit reducedMotionChanged(); } }
|
||||||
|
// Solo mining is a start_mining RPC to whichever daemon the wallet is
|
||||||
|
// connected to. Against a remote node that is either refused outright or —
|
||||||
|
// worse, if the operator left the RPC open — it puts someone else's machine
|
||||||
|
// to work on our address. It is only ever ours to ask of our own node.
|
||||||
|
bool canMine() const { return m_canMine; }
|
||||||
|
void setCanMine(bool c) { if (m_canMine != c) { m_canMine = c; emit canMineChanged(); } }
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void daemonMiningStateChanged();
|
void daemonMiningStateChanged();
|
||||||
|
void reducedMotionChanged();
|
||||||
|
void canMineChanged();
|
||||||
void daemonOutput(const QString &line);
|
void daemonOutput(const QString &line);
|
||||||
void syncStatus(int from, int to, int pct);
|
void syncStatus(int from, int to, int pct);
|
||||||
void uptimeChanged(const QString &uptime);
|
void uptimeChanged(const QString &uptime);
|
||||||
@@ -29,6 +43,8 @@ signals:
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
int m_state = 0; // 0:idle 1:startup 2:syncing 3:mining
|
int m_state = 0; // 0:idle 1:startup 2:syncing 3:mining
|
||||||
|
bool m_reducedMotion = false;
|
||||||
|
bool m_canMine = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
// wowlet: the pixel-art mining tab. Hosts mining.qml and wires its pick-axe to the wallet's solo
|
// wowlet: the pixel-art mining tab. Hosts mining.qml and wires its pick-axe to the wallet's solo
|
||||||
@@ -36,7 +52,7 @@ private:
|
|||||||
class MiningWidget : public QWidget {
|
class MiningWidget : public QWidget {
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
public:
|
public:
|
||||||
explicit MiningWidget(Wallet *wallet, QWidget *parent = nullptr);
|
explicit MiningWidget(Wallet *wallet, Nodes *nodes, QWidget *parent = nullptr);
|
||||||
|
|
||||||
private slots:
|
private slots:
|
||||||
void onStartMining();
|
void onStartMining();
|
||||||
@@ -45,6 +61,7 @@ private slots:
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
Wallet *m_wallet;
|
Wallet *m_wallet;
|
||||||
|
Nodes *m_nodes;
|
||||||
QQuickWidget *m_quickWidget;
|
QQuickWidget *m_quickWidget;
|
||||||
MiningBackend *m_backend;
|
MiningBackend *m_backend;
|
||||||
QTimer *m_timer;
|
QTimer *m_timer;
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
// SPDX-FileCopyrightText: The Monero Project
|
||||||
|
|
||||||
|
#include "SceneWidget.h"
|
||||||
|
|
||||||
|
#include <QQmlContext>
|
||||||
|
#include <QQuickItem>
|
||||||
|
|
||||||
|
#include "utils/config.h"
|
||||||
|
|
||||||
|
SceneWidget::SceneWidget(QWidget *parent, Variant variant, Mode mode)
|
||||||
|
: QQuickWidget(parent)
|
||||||
|
, m_backend(new SceneBackend(this))
|
||||||
|
{
|
||||||
|
m_backend->setReducedMotion(conf()->get(Config::reducedMotion).toBool());
|
||||||
|
m_backend->setVariant(static_cast<int>(variant));
|
||||||
|
m_backend->setMode(mode == Sit ? QStringLiteral("sit")
|
||||||
|
: mode == Money ? QStringLiteral("money")
|
||||||
|
: QStringLiteral("run"));
|
||||||
|
this->rootContext()->setContextProperty("sceneCtx", m_backend);
|
||||||
|
this->setResizeMode(QQuickWidget::SizeRootObjectToView);
|
||||||
|
this->setSource(QUrl("qrc:/qml/scene.qml"));
|
||||||
|
this->callScene("suspend");
|
||||||
|
}
|
||||||
|
|
||||||
|
void SceneWidget::showEvent(QShowEvent *event) {
|
||||||
|
QQuickWidget::showEvent(event);
|
||||||
|
this->callScene("restart");
|
||||||
|
}
|
||||||
|
|
||||||
|
void SceneWidget::hideEvent(QHideEvent *event) {
|
||||||
|
this->callScene("suspend");
|
||||||
|
QQuickWidget::hideEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SceneWidget::resizeEvent(QResizeEvent *event) {
|
||||||
|
QQuickWidget::resizeEvent(event);
|
||||||
|
if (this->isVisible())
|
||||||
|
this->callScene("restart");
|
||||||
|
}
|
||||||
|
|
||||||
|
void SceneWidget::callScene(const char *method) {
|
||||||
|
if (auto *root = this->rootObject())
|
||||||
|
QMetaObject::invokeMethod(root, method);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SceneWidget::refreshReducedMotion() {
|
||||||
|
m_backend->setReducedMotion(conf()->get(Config::reducedMotion).toBool());
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
// SPDX-FileCopyrightText: The Monero Project
|
||||||
|
|
||||||
|
#ifndef WOWLET_SCENEWIDGET_H
|
||||||
|
#define WOWLET_SCENEWIDGET_H
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QString>
|
||||||
|
#include <QQuickWidget>
|
||||||
|
|
||||||
|
// Context object exposed to scene.qml as `sceneCtx`. Carries the reduced-motion
|
||||||
|
// setting so the scene freezes into its still (reduced-motion) form, and the
|
||||||
|
// sky variant so the tabs are not all showing the same field.
|
||||||
|
class SceneBackend : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
Q_PROPERTY(bool reducedMotion READ reducedMotion NOTIFY reducedMotionChanged)
|
||||||
|
Q_PROPERTY(int variant READ variant NOTIFY variantChanged)
|
||||||
|
Q_PROPERTY(QString mode READ mode NOTIFY modeChanged)
|
||||||
|
public:
|
||||||
|
explicit SceneBackend(QObject *parent = nullptr) : QObject(parent) {}
|
||||||
|
bool reducedMotion() const { return m_reducedMotion; }
|
||||||
|
void setReducedMotion(bool r) { if (m_reducedMotion != r) { m_reducedMotion = r; emit reducedMotionChanged(); } }
|
||||||
|
int variant() const { return m_variant; }
|
||||||
|
void setVariant(int v) { if (m_variant != v) { m_variant = v; emit variantChanged(); } }
|
||||||
|
QString mode() const { return m_mode; }
|
||||||
|
void setMode(const QString &m) { if (m_mode != m) { m_mode = m; emit modeChanged(); } }
|
||||||
|
signals:
|
||||||
|
void reducedMotionChanged();
|
||||||
|
void variantChanged();
|
||||||
|
void modeChanged();
|
||||||
|
private:
|
||||||
|
bool m_reducedMotion = false;
|
||||||
|
int m_variant = 0;
|
||||||
|
QString m_mode = QStringLiteral("run");
|
||||||
|
};
|
||||||
|
|
||||||
|
// wowlet: reusable ambient-scene surface. Hosts scene.qml and reads the
|
||||||
|
// reduced-motion preference from Config. Drop it behind a form, into a banner,
|
||||||
|
// or over the window as an idle screensaver.
|
||||||
|
class SceneWidget : public QQuickWidget {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
// Sky variants, one per host, so History/Send/Receive do not all show the
|
||||||
|
// same field. Must match the `skies` table in scene.qml.
|
||||||
|
enum Variant { Dusk = 0, Dawn = 1, Night = 2 };
|
||||||
|
|
||||||
|
// How much scene a surface wants. History has room for the whole run;
|
||||||
|
// Receive gets a doge sitting in the field; Send gets money blowing past and
|
||||||
|
// nothing else, so the form it sits under is not competing with a dog.
|
||||||
|
enum Mode { Run, Sit, Money };
|
||||||
|
|
||||||
|
explicit SceneWidget(QWidget *parent = nullptr, Variant variant = Dusk,
|
||||||
|
Mode mode = Run);
|
||||||
|
void refreshReducedMotion();
|
||||||
|
|
||||||
|
protected:
|
||||||
|
// A scene on a hidden tab is parked rather than left running: an unshown
|
||||||
|
// QQuickWidget is 0px wide, and a lap run against a 0px field collects every
|
||||||
|
// coin at once and leaves its litter (+1s, barks) to surface on the real lap
|
||||||
|
// when the tab is finally shown. Parking also keeps a background tab from
|
||||||
|
// burning CPU on animation nobody is looking at.
|
||||||
|
void showEvent(QShowEvent *event) override;
|
||||||
|
void hideEvent(QHideEvent *event) override;
|
||||||
|
// Belt and braces: a scene that is on screen and has just been given a real
|
||||||
|
// size is running, whether or not a show event was the thing that got it
|
||||||
|
// there. `restart` only sets a flag, so re-arming an already-running scene
|
||||||
|
// costs nothing and does not interrupt its lap.
|
||||||
|
void resizeEvent(QResizeEvent *event) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
void callScene(const char *method);
|
||||||
|
|
||||||
|
SceneBackend *m_backend;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif //WOWLET_SCENEWIDGET_H
|
||||||
@@ -9,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);
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ PageMenu::PageMenu(WizardFields *fields, WalletKeysFilesModel *wallets, QWidget
|
|||||||
, m_fields(fields)
|
, m_fields(fields)
|
||||||
{
|
{
|
||||||
ui->setupUi(this);
|
ui->setupUi(this);
|
||||||
|
|
||||||
|
// wowlet: hide the hardware-device option — wownero has no Ledger/Trezor support
|
||||||
|
ui->radioCreateFromDevice->setVisible(false);
|
||||||
|
|
||||||
this->setButtonText(QWizard::FinishButton, "Open recent wallet");
|
this->setButtonText(QWizard::FinishButton, "Open recent wallet");
|
||||||
|
|
||||||
ui->label_version->setText(QString("wowlet %1 — by wownero.org").arg(FEATHER_VERSION));
|
ui->label_version->setText(QString("wowlet %1 — by wownero.org").arg(FEATHER_VERSION));
|
||||||
@@ -23,6 +27,13 @@ PageMenu::PageMenu(WizardFields *fields, WalletKeysFilesModel *wallets, QWidget
|
|||||||
});
|
});
|
||||||
|
|
||||||
QString settingsSkin = conf()->get(Config::skin).toString();
|
QString settingsSkin = conf()->get(Config::skin).toString();
|
||||||
|
|
||||||
|
// wowlet: reduced-motion opt-out, surfaced on first run (unchecked = playful
|
||||||
|
// animations on, the default). Also lives in Settings → Appearance.
|
||||||
|
ui->checkBox_reducedMotion->setChecked(conf()->get(Config::reducedMotion).toBool());
|
||||||
|
connect(ui->checkBox_reducedMotion, &QCheckBox::toggled, [](bool toggled){
|
||||||
|
conf()->set(Config::reducedMotion, toggled);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void PageMenu::initializePage() {
|
void PageMenu::initializePage() {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||