diff --git a/.github/workflows/macOSBuild.yml b/.github/workflows/macOSBuild.yml index 9c03d618..da9b9138 100644 --- a/.github/workflows/macOSBuild.yml +++ b/.github/workflows/macOSBuild.yml @@ -1,72 +1,374 @@ name: macOS deployment -#on: [push, pull_request] - on: workflow_dispatch: push: - branches: - - master + branches: + - master + tags: + - 'v*' jobs: macos-build: - name: MacOS Build - strategy: - matrix: - os: [macos-12, macos-13] - - runs-on: ${{ matrix.os }} - - steps: - - name: Install Dependencies - run: | - unset HOMEBREW_NO_INSTALL_FROM_API - brew update - brew upgrade || true - brew install qt6 - brew install qt6-webengine - brew link qt6 --force - brew link qt6-webengine --force - brew install hamlib - brew link hamlib --force - brew install qtkeychain - brew install dbus-glib - brew install brotli - brew install icu4c - brew install pkg-config - - name: Checkout Code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Get version from tag - run : | - TAGVERSION=$(git describe --tags) - echo "TAGVERSION=${TAGVERSION:1}" >> $GITHUB_ENV - - - name: Configure and compile - run: | - mkdir build - cd build - qmake -config release .. - make -j4 - - name: Build dmg - run: | - cd build - macdeployqt qlog.app -executable=./qlog.app/Contents/MacOS/qlog - cp `brew --prefix`/lib/libhamlib.dylib qlog.app/Contents/Frameworks/libhamlib.dylib - cp `brew --prefix`/lib/libqt6keychain.dylib qlog.app/Contents/Frameworks/libqt6keychain.dylib - cp `brew --prefix`/lib/libdbus-1.dylib qlog.app/Contents/Frameworks/libdbus-1.dylib - cp `brew --prefix brotli`/lib/libbrotlicommon.1.dylib qlog.app/Contents/Frameworks/libbrotlicommon.1.dylib - cp `brew --prefix`/opt/icu4c/lib/libicui18n.74.dylib qlog.app/Contents/Frameworks/libicui18n.74.dylib - install_name_tool -change `brew --prefix`/lib/libhamlib.dylib @executable_path/../Frameworks/libhamlib.dylib qlog.app/Contents/MacOS/qlog - install_name_tool -change `brew --prefix`/lib/libqt6keychain.dylib @executable_path/../Frameworks/libqt6keychain.dylib qlog.app/Contents/MacOS/qlog - install_name_tool -change @loader_path/libbrotlicommon.1.dylib @executable_path/../Frameworks/libbrotlicommon.1.dylib qlog.app/Contents/MacOS/qlog - install_name_tool -change /usr/local/opt/icu4c/lib/libicui18n.74.dylib @executable_path/../Frameworks/libicui18n.74.dylib qlog.app/Contents/MacOS/qlog - otool -L qlog.app/Contents/MacOS/qlog - macdeployqt qlog.app -dmg - - name: Copy artifact - uses: actions/upload-artifact@v4 - with: - name: QLog-${{ env.TAGVERSION }}-${{ matrix.os }} - path: /Users/runner/work/QLog/QLog/build/qlog.dmg + name: macOS Build + runs-on: macos-latest + + env: + APP_NAME: QLog + BUNDLE_BIN: qlog + QT_PREFIX: /opt/homebrew + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: recursive + + - name: Install Dependencies + run: | + set -euo pipefail + unset HOMEBREW_NO_INSTALL_FROM_API + brew update + brew upgrade || true + brew install \ + qt6 \ + hamlib \ + qtkeychain \ + dbus-glib \ + brotli \ + icu4c \ + pkg-config \ + autoconf \ + automake \ + libtool \ + libusb + # qt6 is keg-only; many Qt-aware tools rely on the linkage being visible + brew link qt6 --force || true + brew link hamlib --force || true + + - name: Get version from tag + run: | + set -euo pipefail + # If we're on a tag like v0.50.0, use that; otherwise fall back to describe. + if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then + TAGVERSION="${GITHUB_REF#refs/tags/v}" + else + RAW="$(git describe --tags --always 2>/dev/null || echo dev)" + TAGVERSION="${RAW#v}" + fi + echo "TAGVERSION=${TAGVERSION}" >> "$GITHUB_ENV" + echo "Version: ${TAGVERSION}" + + - name: Ensure QtSvg module is enabled + run: | + set -euo pipefail + PRO="qlog.pro" + if ! grep -q ' svg' "$PRO"; then + echo "Adding svg to QT modules" + awk ' + BEGIN { added=0 } + /^[[:space:]]*QT[[:space:]]/ && !added { + print $0 " svg"; added=1; next + } + { print } + ' "$PRO" > "$PRO.tmp" && mv "$PRO.tmp" "$PRO" + else + echo "QtSvg already present" + fi + + - name: Configure and compile + run: | + set -euo pipefail + mkdir -p build + cd build + qmake CONFIG+=release .. + make -j"$(sysctl -n hw.ncpu)" + + - name: Run macdeployqt + run: | + set -euo pipefail + cd build + APP="$APP_NAME.app" + # macdeployqt builds Contents/Frameworks and rewrites Qt links. + macdeployqt "$APP" \ + -always-overwrite \ + -verbose=2 \ + -executable="$APP/Contents/MacOS/$BUNDLE_BIN" \ + -libpath="$QT_PREFIX/opt/qtbase/lib" \ + -libpath="$QT_PREFIX/opt/qtdeclarative/lib" \ + -libpath="$QT_PREFIX/opt/qtwebengine/lib" \ + -libpath="$QT_PREFIX/opt/qtsvg/lib" + + - name: Bundle rigctld and fix linkage + run: | + set -euo pipefail + cd build + APP="$APP_NAME.app" + MACOS_DIR="$APP/Contents/MacOS" + FRAMEWORKS_DIR="$APP/Contents/Frameworks" + mkdir -p "$MACOS_DIR" "$FRAMEWORKS_DIR" + + RIGCTLD_SRC="$(command -v rigctld)" + [ -n "$RIGCTLD_SRC" ] || { echo "ERROR: rigctld not found"; exit 1; } + echo "Using rigctld from: $RIGCTLD_SRC" + cp -f "$RIGCTLD_SRC" "$MACOS_DIR/rigctld" + chmod 0755 "$MACOS_DIR/rigctld" + + install_name_tool -add_rpath "@executable_path/../Frameworks" \ + "$MACOS_DIR/rigctld" 2>/dev/null || true + + # Recursively bundle any /opt/homebrew or /usr/local dependency, fix + # install IDs to @rpath, and rewrite the parent's link. + bundle_deps() { + local target="$1" + local dep base + otool -L "$target" | awk 'NR>1 {print $1}' | while read -r dep; do + case "$dep" in + /opt/homebrew/*|/usr/local/*) + base="$(basename "$dep")" + if [ ! -f "$FRAMEWORKS_DIR/$base" ]; then + echo " -> bundling $base (from $dep)" + cp -f "$dep" "$FRAMEWORKS_DIR/$base" + chmod u+w "$FRAMEWORKS_DIR/$base" + install_name_tool -id "@rpath/$base" "$FRAMEWORKS_DIR/$base" + bundle_deps "$FRAMEWORKS_DIR/$base" + fi + install_name_tool -change "$dep" "@rpath/$base" "$target" + ;; + esac + done + } + + bundle_deps "$MACOS_DIR/rigctld" + bundle_deps "$MACOS_DIR/$BUNDLE_BIN" + + echo "rigctld linkage:" + otool -L "$MACOS_DIR/rigctld" + echo "$BUNDLE_BIN linkage:" + otool -L "$MACOS_DIR/$BUNDLE_BIN" + + - name: Fix QtWebEngineProcess rpaths + run: | + set -euo pipefail + cd build + APP="$APP_NAME.app" + QWEP="$APP/Contents/Frameworks/QtWebEngineCore.framework/Versions/A/Helpers/QtWebEngineProcess.app/Contents/MacOS/QtWebEngineProcess" + [ -f "$QWEP" ] || { echo "QtWebEngineProcess not found at $QWEP"; exit 1; } + + install_name_tool -add_rpath \ + "@executable_path/../../../../Frameworks" "$QWEP" 2>/dev/null || true + + # Rewrite any remaining hardcoded Qt framework references to @rpath. + otool -L "$QWEP" | awk 'NR>1 {print $1}' \ + | grep '^/opt/homebrew/.*\.framework/' \ + | while read -r dep; do + fw="$(basename "$dep")" + fwdir="$(basename "$(dirname "$(dirname "$(dirname "$dep")")")")" + install_name_tool -change "$dep" \ + "@rpath/$fwdir/Versions/A/$fw" "$QWEP" || true + done + + echo "QtWebEngineProcess after fixup:" + otool -L "$QWEP" + + - name: Scan for hardcoded Homebrew paths + run: | + set -euo pipefail + cd build + APP="$APP_NAME.app" + BAD=0 + while IFS= read -r -d '' BIN; do + if otool -L "$BIN" 2>/dev/null \ + | awk 'NR>1 {print $1}' \ + | grep -E '^(/opt/homebrew/|/usr/local/Cellar/|/usr/local/opt/)' >/dev/null; then + echo "Hardcoded Homebrew path in: $BIN" + otool -L "$BIN" | awk 'NR>1 {print " " $1}' \ + | grep -E '^[[:space:]]+(/opt/homebrew/|/usr/local/Cellar/|/usr/local/opt/)' + BAD=1 + fi + done < <(find "$APP" -type f -perm +111 -print0) + if [ "$BAD" -eq 1 ]; then + echo "ERROR: hardcoded Homebrew paths remain. Bundle would fail on user machines." + exit 1 + fi + + - name: Codesign app bundle + env: + MACOS_CERTIFICATE: ${{ secrets.PROD_MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PWD: ${{ secrets.PROD_MACOS_CERTIFICATE_PWD }} + MACOS_CERTIFICATE_NAME: ${{ secrets.PROD_MACOS_CERTIFICATE_NAME }} + MACOS_CI_KEYCHAIN_PWD: ${{ secrets.PROD_MACOS_CI_KEYCHAIN_PWD }} + run: | + set -euo pipefail + APP="$GITHUB_WORKSPACE/build/$APP_NAME.app" + ENT="$GITHUB_WORKSPACE/entitlements.xml" + KEYCHAIN="$RUNNER_TEMP/build.keychain-db" + + test -f "$ENT" || { echo "Missing entitlements.xml at $ENT"; exit 1; } + + security create-keychain -p "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" + security set-keychain-settings -lut 21600 "$KEYCHAIN" + security unlock-keychain -p "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" + security list-keychains -d user -s "$KEYCHAIN" + security default-keychain -d user -s "$KEYCHAIN" + + echo "$MACOS_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/cert.p12" + security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" \ + -P "$MACOS_CERTIFICATE_PWD" \ + -T /usr/bin/codesign -T /usr/bin/security + security set-key-partition-list -S apple-tool:,apple: \ + -s -k "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" + + security find-identity -v -p codesigning "$KEYCHAIN" || true + + # 1) Sign every Mach-O inside the bundle (no entitlements), skipping + # QtWebEngineProcess which we sign with entitlements below. + while IFS= read -r -d '' F; do + if file "$F" | grep -q "Mach-O"; then + if [[ "$F" == *"/QtWebEngineProcess.app/Contents/MacOS/QtWebEngineProcess" ]]; then + continue + fi + /usr/bin/codesign --force --timestamp --options runtime \ + -s "$MACOS_CERTIFICATE_NAME" "$F" + fi + done < <(find "$APP" -type f -print0) + + # 2) Sign QtWebEngineProcess binaries WITH entitlements + while IFS= read -r -d '' BIN; do + echo "Signing WebEngine helper: $BIN" + /usr/bin/codesign --force --timestamp --options runtime \ + --entitlements "$ENT" \ + -s "$MACOS_CERTIFICATE_NAME" "$BIN" + done < <(find "$APP" -path "*QtWebEngineProcess.app/Contents/MacOS/QtWebEngineProcess" -type f -print0) + + # 3) Sign QtWebEngineProcess.app bundles WITH entitlements + while IFS= read -r -d '' HAPP; do + echo "Signing WebEngine helper app: $HAPP" + /usr/bin/codesign --force --timestamp --options runtime \ + --entitlements "$ENT" \ + -s "$MACOS_CERTIFICATE_NAME" "$HAPP" + done < <(find "$APP" -path "*QtWebEngineProcess.app" -type d -print0) + + # 4) Re-seal every framework so the framework's seal includes any + # nested helper apps we just re-signed. Without this, signing the + # outer app fails with "nested code is modified or invalid". + while IFS= read -r -d '' FW; do + echo "Re-signing framework: $FW" + /usr/bin/codesign --force --timestamp --options runtime \ + -s "$MACOS_CERTIFICATE_NAME" "$FW" + done < <(find "$APP/Contents/Frameworks" -maxdepth 1 -type d -name "*.framework" -print0) + + # 5) Sign the outer app bundle WITH entitlements + /usr/bin/codesign --force --timestamp --options runtime \ + --entitlements "$ENT" \ + -s "$MACOS_CERTIFICATE_NAME" "$APP" + + /usr/bin/codesign --verify --deep --strict --verbose=2 "$APP" + spctl -a -t exec -vv "$APP" || true + + - name: Notarize app bundle + env: + PROD_MACOS_NOTARIZATION_APPLE_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_APPLE_ID }} + PROD_MACOS_NOTARIZATION_TEAM_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_TEAM_ID }} + PROD_MACOS_NOTARIZATION_PWD: ${{ secrets.PROD_MACOS_NOTARIZATION_PWD }} + run: | + set -euo pipefail + APP="$GITHUB_WORKSPACE/build/$APP_NAME.app" + + xcrun notarytool store-credentials "notarytool-profile" \ + --apple-id "$PROD_MACOS_NOTARIZATION_APPLE_ID" \ + --team-id "$PROD_MACOS_NOTARIZATION_TEAM_ID" \ + --password "$PROD_MACOS_NOTARIZATION_PWD" + + ditto -c -k --sequesterRsrc --keepParent "$APP" "notarization.zip" + + xcrun notarytool submit "notarization.zip" \ + --keychain-profile "notarytool-profile" \ + --wait --output-format json > notarization_log.json + cat notarization_log.json + + NOTARY_ID=$(python3 -c 'import json; print(json.load(open("notarization_log.json"))["id"])') + NOTARY_STATUS=$(python3 -c 'import json; print(json.load(open("notarization_log.json"))["status"])') + + xcrun notarytool log "$NOTARY_ID" --keychain-profile "notarytool-profile" || true + + if [ "$NOTARY_STATUS" != "Accepted" ]; then + echo "Notarization failed: $NOTARY_STATUS" + exit 1 + fi + + xcrun stapler staple "$APP" + xcrun stapler validate "$APP" + + - name: Build DMG + run: | + set -euo pipefail + VERSION="${TAGVERSION%%-*}" + DMG_NAME="$APP_NAME.v${VERSION}.dmg" + echo "DMG_NAME=$DMG_NAME" >> "$GITHUB_ENV" + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + + STAGING="$GITHUB_WORKSPACE/build/dmg-staging" + rm -rf "$STAGING" + mkdir -p "$STAGING" + cp -R "$GITHUB_WORKSPACE/build/$APP_NAME.app" "$STAGING/" + ln -s /Applications "$STAGING/Applications" + + hdiutil create \ + -volname "$APP_NAME Installer" \ + -srcfolder "$STAGING" \ + -ov -format UDZO \ + "$GITHUB_WORKSPACE/build/$DMG_NAME" + ls -lh "$GITHUB_WORKSPACE/build/$DMG_NAME" + + - name: Codesign DMG + env: + MACOS_CERTIFICATE_NAME: ${{ secrets.PROD_MACOS_CERTIFICATE_NAME }} + MACOS_CI_KEYCHAIN_PWD: ${{ secrets.PROD_MACOS_CI_KEYCHAIN_PWD }} + run: | + set -euo pipefail + DMG="$GITHUB_WORKSPACE/build/$DMG_NAME" + KEYCHAIN="$RUNNER_TEMP/build.keychain-db" + security unlock-keychain -p "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" + /usr/bin/codesign --force --timestamp \ + -s "$MACOS_CERTIFICATE_NAME" "$DMG" + /usr/bin/codesign --verify --verbose=2 "$DMG" + + - name: Notarize DMG + env: + PROD_MACOS_NOTARIZATION_APPLE_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_APPLE_ID }} + PROD_MACOS_NOTARIZATION_TEAM_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_TEAM_ID }} + PROD_MACOS_NOTARIZATION_PWD: ${{ secrets.PROD_MACOS_NOTARIZATION_PWD }} + run: | + set -euo pipefail + DMG="$GITHUB_WORKSPACE/build/$DMG_NAME" + + xcrun notarytool store-credentials "notarytool-profile" \ + --apple-id "$PROD_MACOS_NOTARIZATION_APPLE_ID" \ + --team-id "$PROD_MACOS_NOTARIZATION_TEAM_ID" \ + --password "$PROD_MACOS_NOTARIZATION_PWD" + + xcrun notarytool submit "$DMG" \ + --keychain-profile "notarytool-profile" \ + --wait --output-format json > notarization_dmg.json + cat notarization_dmg.json + + NOTARY_ID=$(python3 -c 'import json; print(json.load(open("notarization_dmg.json"))["id"])') + NOTARY_STATUS=$(python3 -c 'import json; print(json.load(open("notarization_dmg.json"))["status"])') + xcrun notarytool log "$NOTARY_ID" --keychain-profile "notarytool-profile" || true + + if [ "$NOTARY_STATUS" != "Accepted" ]; then + echo "DMG notarization failed: $NOTARY_STATUS" + exit 1 + fi + + xcrun stapler staple "$DMG" + xcrun stapler validate "$DMG" + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: QLog-${{ env.TAGVERSION }}-macos + path: ${{ github.workspace }}/build/${{ env.DMG_NAME }} + if-no-files-found: error diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml new file mode 100644 index 00000000..f13cc4e7 --- /dev/null +++ b/.github/workflows/windows.yml @@ -0,0 +1,343 @@ +# ============================================================ +# QLog Windows Build — GitHub Actions +# +# Orignal from HB9VQQ +# +# Builds QLog for Windows (MSVC 2022 x64) and creates: +# - A portable ZIP (QLog-Portable-Windows) +# - A Qt IFW installer (QLog-Installer-Windows) +# +# Triggers: +# - push to master → build + artifacts (for testing) +# - push a tag v* → build + artifacts + GitHub Release +# - manual via Actions tab +# ============================================================ +name: Windows Build + +on: + push: + branches: [master] + tags: ['v*'] + paths-ignore: + - '*.md' + - 'doc/**' + - 'LICENSE' + - '.gitignore' + workflow_dispatch: # manual trigger button in Actions tab + +env: + QT_VERSION: '6.10.2' + HAMLIB_VERSION: '4.7.1' + +jobs: + build: + runs-on: windows-2022 + timeout-minutes: 60 + + steps: + # —— 1. Checkout source ——————————————————————————————————— + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # —— 2. MSVC 2022 x64 environment ———————————————————————— + - name: Setup MSVC + uses: TheMrMilchmann/setup-msvc-dev@v4 + with: + arch: x64 + + # —— 3. Install Qt 6 with required modules ——————————————— + - name: Install Qt + uses: jurplel/install-qt-action@v4 + with: + version: ${{ env.QT_VERSION }} + host: windows + target: desktop + arch: win64_msvc2022_64 + modules: >- + qtwebengine qtcharts qtserialport + qtwebsockets qtwebchannel qtpositioning + tools: tools_ifw + source: true + src-archives: qtbase + cache: true + + # —— 4. Download Hamlib w64 binary ———————————————————————— + - name: Download Hamlib + shell: pwsh + run: | + $zip = "hamlib-w64-${{ env.HAMLIB_VERSION }}.zip" + $url = "https://github.com/Hamlib/Hamlib/releases/download/${{ env.HAMLIB_VERSION }}/$zip" + Invoke-WebRequest $url -OutFile $zip + Expand-Archive $zip -DestinationPath C:\ + Rename-Item "C:\hamlib-w64-${{ env.HAMLIB_VERSION }}" C:\hamlib + + $msvc = "C:\hamlib\lib\msvc" + if (!(Test-Path $msvc)) { New-Item -ItemType Directory $msvc | Out-Null } + if (!(Test-Path "$msvc\libhamlib-4.lib")) { + $def = Get-ChildItem C:\hamlib -Recurse -Filter "libhamlib-4.def" | Select -First 1 + if ($def) { + $dest = "$msvc\libhamlib-4.def" + if ($def.FullName -ne $dest) { Copy-Item $def.FullName $dest } + Push-Location $msvc + lib /machine:X64 /def:libhamlib-4.def /out:libhamlib-4.lib + Pop-Location + } + } + + # —— 5. pthreads + zlib via fresh vcpkg clone ————————————— + - name: Install vcpkg dependencies + shell: cmd + run: | + git clone --depth 1 https://github.com/microsoft/vcpkg.git C:\vcpkg + cd /d C:\vcpkg + call bootstrap-vcpkg.bat + vcpkg install pthreads:x64-windows zlib:x64-windows openssl:x64-windows + + # —— 6. Build QtKeychain from source —————————————————————— + - name: Build QtKeychain + shell: cmd + run: | + git clone --depth 1 https://github.com/frankosterfeld/qtkeychain.git C:\qtkeychain-src + cd /d C:\qtkeychain-src + cmake -B build -G "NMake Makefiles" ^ + -DCMAKE_BUILD_TYPE=Release ^ + -DBUILD_WITH_QT6=ON ^ + -DCMAKE_PREFIX_PATH="%QT_ROOT_DIR%" ^ + -DCMAKE_INSTALL_PREFIX=C:\qtkeychain + cmake --build build --config Release + cmake --install build + + # —— 7. Locate OpenSSL ———————————————————————————————————— + - name: Locate OpenSSL + shell: pwsh + run: | + $candidates = @( + "C:\Program Files\OpenSSL-Win64", + "C:\Program Files\OpenSSL", + "C:\OpenSSL-Win64" + ) + $ssl = $candidates | Where-Object { Test-Path $_ } | Select -First 1 + if (!$ssl) { + choco install openssl -y --no-progress + $ssl = $candidates | Where-Object { Test-Path $_ } | Select -First 1 + } + if ($ssl) { + echo "OPENSSL_DIR=$ssl" >> $env:GITHUB_ENV + } else { + echo "OPENSSL_DIR=" >> $env:GITHUB_ENV + } + + # —— 7c. Install OmniRig v1 + v2 ————————————————————————— + - name: Install OmniRig + shell: pwsh + run: | + # --- OmniRig v1: InnoSetup installer (works silently) --- + Write-Host "=== OmniRig v1 ===" + Invoke-WebRequest "http://www.dxatlas.com/OmniRig/Files/OmniRig.zip" -OutFile OmniRig.zip + Expand-Archive OmniRig.zip -DestinationPath C:\omnirig-v1-tmp + $setup = Get-ChildItem C:\omnirig-v1-tmp -Recurse -Filter "OmniRigSetup.exe" | Select -First 1 + if ($setup) { + Start-Process -FilePath $setup.FullName -ArgumentList "/VERYSILENT","/SUPPRESSMSGBOXES","/NORESTART" -Wait + Start-Sleep -Seconds 2 + } + $v1path = "C:\Program Files (x86)\Afreet\OmniRig\OmniRig.exe" + if (Test-Path $v1path) { + Write-Host "v1 OK: $v1path" + } else { + Write-Error "v1 FAILED" + } + + # --- OmniRig v2: try installer with timeout, fall back to v1 .tlb --- + Write-Host "=== OmniRig v2 ===" + $v2installed = $false + Invoke-WebRequest "https://www.hb9ryz.ch/downloads/install_omnirigv21.zip" -OutFile omnirigv2.zip + Expand-Archive omnirigv2.zip -DestinationPath C:\omnirig-v2-tmp + + $installer = Get-ChildItem C:\omnirig-v2-tmp -Recurse -Filter "*.exe" | Select -First 1 + if ($installer) { + Write-Host "Trying /S (NSIS) with 30s timeout..." + $proc = Start-Process -FilePath $installer.FullName -ArgumentList "/S" -PassThru + $finished = $proc.WaitForExit(30000) + if (!$finished) { + Write-Host "Installer timed out — killing" + $proc.Kill() + } + $v2path = "C:\Program Files (x86)\Omni-Rig V2\omnirig2.exe" + if (Test-Path $v2path) { + Write-Host "v2 installed via /S" + $v2installed = $true + } + } + + if (!$v2installed) { + # Fallback: use v1 .tlb and patch source to remove v2-only features + Write-Host "v2 installer failed — using v1 .tlb fallback with Rig3/Rig4 patch" + Invoke-WebRequest "https://raw.githubusercontent.com/VE3NEA/OmniRig/master/OmniRig.tlb" -OutFile "C:\omnirig-v1.tlb" + + $v2file = "rig\drivers\Omnirigv2RigDrv.cpp" + $content = Get-Content $v2file -Raw + + # Patch #import to use v1 .tlb + $content = $content.Replace( + 'C:\\Program Files (x86)\\Omni-Rig V2\\omnirig2.exe', + 'C:\\omnirig-v1.tlb') + + # Remove get_Rig3/get_Rig4 calls (v1 only has Rig1+Rig2) + # Change case 3/4 to fall through to default (E_INVALIDARG) + $content = $content.Replace( + 'case 3: hr = omniInterface->get_Rig3(&rig); break;', + 'case 3: /* Rig3 not available in v1 fallback */') + $content = $content.Replace( + 'case 4: hr = omniInterface->get_Rig4(&rig); break;', + 'case 4: /* Rig4 not available in v1 fallback */') + + Set-Content $v2file $content -NoNewline + + Write-Host "Patched v2 source:" + Select-String '#import' $v2file | ForEach-Object { $_.Line.Trim() } + Select-String 'case 3:|case 4:' $v2file | ForEach-Object { $_.Line.Trim() } + } + + # —— 8. Build QLog ———————————————————————————————————————— + - name: Build QLog + shell: cmd + run: | + set "QTKC_INC=C:\qtkeychain\include" + set "VCPKG_INC=C:\vcpkg\installed\x64-windows\include" + set "VCPKG_LIB=C:\vcpkg\installed\x64-windows\lib" + + mkdir build + cd build + qmake ..\QLog.pro -spec win32-msvc ^ + "CONFIG+=release" ^ + "HAMLIBINCLUDEPATH=C:\hamlib\include" ^ + "HAMLIBLIBPATH=C:\hamlib\lib\msvc" ^ + "HAMLIBVERSION_MAJOR=4" ^ + "HAMLIBVERSION_MINOR=7" ^ + "HAMLIBVERSION_PATCH=1" ^ + "QTKEYCHAININCLUDEPATH=%QTKC_INC%" ^ + "QTKEYCHAINLIBPATH=C:\qtkeychain\lib" ^ + "PTHREADINCLUDEPATH=%VCPKG_INC%" ^ + "PTHREADLIBPATH=%VCPKG_LIB%" ^ + "ZLIBINCLUDEPATH=%VCPKG_INC%" ^ + "ZLIBLIBPATH=%VCPKG_LIB%" ^ + "OPENSSLINCLUDEPATH=%VCPKG_INC%" ^ + "OPENSSLLIBPATH=%VCPKG_LIB%" + nmake + + # —— 9. Package with windeployqt —————————————————————————— + - name: Deploy + shell: pwsh + run: | + $deploy = "C:\qlog-deploy" + New-Item -ItemType Directory $deploy -Force | Out-Null + + $exe = Get-ChildItem build -Recurse -Filter "qlog.exe" | Select -First 1 + if (!$exe) { Write-Error "qlog.exe not found!"; exit 1 } + Copy-Item $exe.FullName $deploy\ + + # Copy qt6keychain.dll to Qt bin dir so windeployqt can resolve it + $qtBin = Join-Path $env:QT_ROOT_DIR "bin" + Copy-Item C:\qtkeychain\bin\qt6keychain.dll "$qtBin\" -Force -ErrorAction SilentlyContinue + Copy-Item C:\qtkeychain\lib\qt6keychain.dll "$qtBin\" -Force -ErrorAction SilentlyContinue + + Copy-Item C:\hamlib\bin\*.dll $deploy\ + Copy-Item C:\qtkeychain\bin\*.dll $deploy\ -ErrorAction SilentlyContinue + Copy-Item C:\qtkeychain\lib\*.dll $deploy\ -ErrorAction SilentlyContinue + + $vcpkgBin = "C:\vcpkg\installed\x64-windows\bin" + if (Test-Path $vcpkgBin) { + Copy-Item "$vcpkgBin\*.dll" $deploy\ -ErrorAction SilentlyContinue + } + + if ($env:OPENSSL_DIR -and (Test-Path $env:OPENSSL_DIR)) { + $sslBin = Join-Path $env:OPENSSL_DIR "bin" + if (Test-Path $sslBin) { + Copy-Item "$sslBin\libssl*.dll" $deploy\ -ErrorAction SilentlyContinue + Copy-Item "$sslBin\libcrypto*.dll" $deploy\ -ErrorAction SilentlyContinue + } + } + + Push-Location $deploy + windeployqt --release --no-translations qlog.exe + Pop-Location + + Write-Host "Deploy contents:" + Get-ChildItem $deploy | Format-Table Name, Length + + # —— 10. Create Qt IFW installer —————————————————————————— + - name: Create Installer + shell: pwsh + run: | + $bc = $null + if ($env:IQTA_TOOLS) { + $bc = Get-ChildItem $env:IQTA_TOOLS -Recurse -Filter "binarycreator.exe" -ErrorAction SilentlyContinue | Select -First 1 + } + if (!$bc) { + $bc = Get-ChildItem "$env:RUNNER_TOOL_CACHE" -Recurse -Filter "binarycreator.exe" -ErrorAction SilentlyContinue | Select -First 1 + } + if (!$bc) { + Write-Warning "binarycreator not found — skipping installer" + exit 0 + } + + Copy-Item installer -Destination installer-build -Recurse + $pkgData = "installer-build\packages\de.dl2ic.qlog\data" + New-Item -ItemType Directory $pkgData -Force | Out-Null + Copy-Item C:\qlog-deploy\* $pkgData\ -Recurse + + & $bc.FullName -f ` + -c installer-build\config\config.xml ` + -p installer-build\packages ` + qlog-installer.exe + + if (Test-Path qlog-installer.exe) { + Write-Host "Installer created: qlog-installer.exe" + } + + # —— 11. Create portable ZIP —————————————————————————————— + - name: Create Portable ZIP + if: startsWith(github.ref, 'refs/tags/v') + shell: pwsh + run: | + $tag = "${{ github.ref_name }}" + Compress-Archive -Path C:\qlog-deploy\* -DestinationPath "QLog-Portable-Windows-${tag}.zip" + Write-Host "Portable ZIP: QLog-Portable-Windows-${tag}.zip" + + # —— 12. Upload artifacts (always — for testing) —————————— + - name: Upload Installer + if: ${{ hashFiles('qlog-installer.exe') != '' }} + uses: actions/upload-artifact@v4 + with: + name: QLog-Installer-Windows + path: qlog-installer.exe + + - name: Upload Portable + uses: actions/upload-artifact@v4 + with: + name: QLog-Portable-Windows + path: C:\qlog-deploy\ + + # —— 13. Create GitHub Release (only on tag push) ————————— + - name: Create GitHub Release + if: startsWith(github.ref, 'refs/tags/v') + uses: softprops/action-gh-release@v2 + with: + name: "QLog ${{ github.ref_name }}" + body: | + ## QLog ${{ github.ref_name }} + + **Downloads:** + - **Installer** (recommended) — run `qlog-installer.exe` + - **Portable ZIP** — extract anywhere and run `qlog.exe` + + Built with Qt ${{ env.QT_VERSION }}, Hamlib ${{ env.HAMLIB_VERSION }}, MSVC 2022 x64. + draft: false + prerelease: false + files: | + qlog-installer.exe + QLog-Portable-Windows-${{ github.ref_name }}.zip + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/logformat/AdiFormat.cpp b/logformat/AdiFormat.cpp index 8d0ca30d..a2128636 100644 --- a/logformat/AdiFormat.cpp +++ b/logformat/AdiFormat.cpp @@ -492,6 +492,14 @@ void AdiFormat::contactFields2SQLRecord(QMap &contact, QSqlRe time_on = time_off; } + /* Records that have a date but no time (e.g. LoTW DXCC credit exports) would + * produce an invalid QDateTime, making them impossible to match later. + * Default to midnight UTC so the record remains usable. */ + if ( !time_on.isValid() && date_on.isValid() ) + { + time_on = QTime(0, 0, 0); + } + QDateTime start_time(date_on, time_on, QTimeZone::utc()); QDateTime end_time(date_off, time_off, QTimeZone::utc()); diff --git a/logformat/LogFormat.cpp b/logformat/LogFormat.cpp index 3e846827..5f1b4a35 100644 --- a/logformat/LogFormat.cpp +++ b/logformat/LogFormat.cpp @@ -784,6 +784,136 @@ unsigned long LogFormat::runImport(QTextStream& importLogStream, #undef RECORDIDX +void LogFormat::runQSOCreditImport(QSLFrom /*fromService*/) +{ + FCT_IDENTIFICATION; + + auto reportFormatter = [&](const QDateTime &qsoTime, + const QString &callsign, + const QString &mode, + const QStringList addInfo = QStringList()) + { + return QString("%0; %1; %2%3 %4").arg(qsoTime.isValid() ? qsoTime.toString(locale.formatDateShortWithYYYY()) : "-", + callsign, + mode, + (addInfo.size() > 0 ) ? ";" : "", + addInfo.join(", ")); + }; + + QSLMergeStat stats = {QStringList(), QStringList(), QStringList(), QStringList(), 0}; + this->importStart(); + + QSqlTableModel model; + model.setTable("contacts"); + QSqlRecord QSLRecord = model.record(0); + + while ( true ) + { + QSLRecord.clearValues(); + + if ( !this->importNext(QSLRecord) ) break; + + stats.qsosDownloaded++; + + if ( stats.qsosDownloaded % 100 == 0 ) + { + emit importPosition(stream.pos()); + } + + const QVariant &call = QSLRecord.value("callsign"); + const QVariant &band = QSLRecord.value("band"); + const QVariant &start_time = QSLRecord.value("start_time"); + const QVariant &satName = QSLRecord.value("sat_name"); + + /* require at minimum: callsign, band, and a valid date */ + if ( !start_time.toDateTime().isValid() + || call.toString().isEmpty() + || band.toString().isEmpty() ) + { + qWarning() << "DXCC credit import: missing start_time, callsign, or band"; + qCDebug(runtime) << QSLRecord; + stats.errorQSLs.append(reportFormatter(start_time.toDateTime(), call.toString(), "")); + continue; + } + + /* Match on callsign + band + date + must already have a QSL confirmed. + * Mode is intentionally omitted because DXCC credit records may use generic + * mode group names that do not precisely match the logged mode. + * A ±1 day tolerance is applied because the credit file may record the QSO + * date in the operator's local time zone while the DB stores UTC, causing a + * one-day discrepancy for QSOs made near midnight. */ + const QString matchFilter = QString( + "callsign=upper('%1') AND upper(band)=upper('%2') AND " + "COALESCE(sat_name, '') = upper('%3') AND " + "ABS(JULIANDAY(date(start_time)) - JULIANDAY(date('%4'))) <= 1 AND " + "(qsl_rcvd = 'Y' OR lotw_qsl_rcvd = 'Y')" + ).arg(call.toString(), + band.toString(), + satName.toString(), + start_time.toDateTime().toTimeZone(QTimeZone::utc()).toString("yyyy-MM-dd hh:mm:ss")); + + model.setFilter(matchFilter); + model.select(); + + if ( model.rowCount() < 1 ) + { + stats.unmatchedQSLs.append(reportFormatter(start_time.toDateTime(), call.toString(), "")); + continue; + } + + qCDebug(runtime) << "DXCC credit: found" << model.rowCount() << "match(es) for" + << call.toString() << band.toString() << start_time.toString(); + + /* Update every matching contact — multiple QSOs on the same date/band are possible. */ + for ( int row = 0; row < model.rowCount(); ++row ) + { + QSqlRecord originalRecord = model.record(row); + + QStringList updatedFields; + bool callUpdate = false; + + auto conditionUpdate = [&](const QString &contactKey, + const QString &qslKey) + { + if ( !QSLRecord.value(qslKey).toString().isEmpty() + && originalRecord.value(contactKey).toString().isEmpty() ) + { + qCDebug(runtime) << "Updating:" << contactKey + << "to" << QSLRecord.value(qslKey).toString(); + updatedFields.append(contactKey + "(" + QSLRecord.value(qslKey).toString() + ")"); + originalRecord.setValue(contactKey, QSLRecord.value(qslKey)); + return true; + } + return false; + }; + + callUpdate |= conditionUpdate("credit_granted", "credit_granted"); + callUpdate |= conditionUpdate("credit_submitted", "credit_submitted"); + + if ( callUpdate ) + { + if ( !model.setRecord(row, originalRecord) ) + { + qWarning() << "Cannot update a Contact record - " << model.lastError(); + qCDebug(runtime) << originalRecord; + } + stats.updatedQSOs.append(reportFormatter(start_time.toDateTime(), call.toString(), "", updatedFields)); + } + } + + if ( !model.submitAll() ) + { + qWarning() << "Cannot commit changes to Contact Table - " << model.lastError(); + } + } + + emit importPosition(stream.pos()); + + this->importEnd(); + + emit QSLMergeFinished(stats); +} + void LogFormat::runQSLImport(QSLFrom fromService) { FCT_IDENTIFICATION; diff --git a/logformat/LogFormat.h b/logformat/LogFormat.h index bc3f19ce..8065fffe 100644 --- a/logformat/LogFormat.h +++ b/logformat/LogFormat.h @@ -34,6 +34,7 @@ class LogFormat : public QObject { enum QSLFrom { LOTW, EQSL, + LOTW_DXCC, UNKNOW }; @@ -57,6 +58,7 @@ class LogFormat : public QObject { unsigned long *warnings, unsigned long *errors); void runQSLImport(QSLFrom fromService); + void runQSOCreditImport(QSLFrom fromService); long runExport(); long runExport(const QList&); void setDefaults(QMap& defaults); diff --git a/service/lotw/Lotw.cpp b/service/lotw/Lotw.cpp index c3a29a27..8e121a3d 100644 --- a/service/lotw/Lotw.cpp +++ b/service/lotw/Lotw.cpp @@ -434,21 +434,29 @@ void LotwQSLDownloader::receiveQSL(const QDate &start_date, bool qso_since, cons qCDebug(function_parameters) << start_date << " " << qso_since; QList> params; - params.append(qMakePair(QString("qso_query"), QString("1"))); - params.append(qMakePair(QString("qso_qsldetail"), QString("yes"))); - params.append(qMakePair(QString("qso_owncall"), station_callsign)); - const QString &start = start_date.toString("yyyy-MM-dd"); - - if (qso_since) + if ( LotwDXCCCredits ) { - params.append(qMakePair(QString("qso_qsl"), QString("no"))); - params.append(qMakePair(QString("qso_qsorxsince"), start)); + params.append(qMakePair(QString("ac_acct"), QString("1"))); } else { - params.append(qMakePair(QString("qso_qsl"), QString("yes"))); - params.append(qMakePair(QString("qso_qslsince"), start)); + params.append(qMakePair(QString("qso_query"), QString("1"))); + params.append(qMakePair(QString("qso_qsldetail"), QString("yes"))); + params.append(qMakePair(QString("qso_owncall"), station_callsign)); + + const QString &start = start_date.toString("yyyy-MM-dd"); + + if (qso_since) + { + params.append(qMakePair(QString("qso_qsl"), QString("no"))); + params.append(qMakePair(QString("qso_qsorxsince"), start)); + } + else + { + params.append(qMakePair(QString("qso_qsl"), QString("yes"))); + params.append(qMakePair(QString("qso_qslsince"), start)); + } } get(params); @@ -540,7 +548,10 @@ void LotwQSLDownloader::processReply(QNetworkReply *reply) emit receiveQSLComplete(stats); }); - adi.runQSLImport(adi.LOTW); + if ( LotwDXCCCredits ) + adi.runQSOCreditImport(adi.LOTW_DXCC); + else + adi.runQSLImport(adi.LOTW); tempFile.close(); @@ -559,7 +570,7 @@ void LotwQSLDownloader::get(QList> params) query.addQueryItem("login", username.toUtf8().toPercentEncoding()); query.addQueryItem("password", password.toUtf8().toPercentEncoding()); - QUrl url(ADIF_API); + QUrl url(LotwDXCCCredits ? DXCC_CREDIT_API : ADIF_API); url.setQuery(query); qCDebug(runtime) << Data::safeQueryString(query); diff --git a/service/lotw/Lotw.h b/service/lotw/Lotw.h index 801e6f7f..1928360c 100644 --- a/service/lotw/Lotw.h +++ b/service/lotw/Lotw.h @@ -95,6 +95,8 @@ class LotwQSLDownloader : public GenericQSLDownloader, private LotwBase explicit LotwQSLDownloader(QObject *parent = nullptr); virtual ~LotwQSLDownloader(); + bool LotwDXCCCredits = false; + virtual void receiveQSL(const QDate &, bool, const QString &) override; public slots: @@ -103,6 +105,7 @@ public slots: private: QNetworkReply *currentReply; const QString ADIF_API = "https://lotw.arrl.org/lotwuser/lotwreport.adi"; + const QString DXCC_CREDIT_API = "https://lotw.arrl.org/lotwuser/logbook/qslcards.php"; virtual void processReply(QNetworkReply* reply) override; void get(QList> params); diff --git a/ui/DownloadQSLDialog.cpp b/ui/DownloadQSLDialog.cpp index d3873739..1aadff0e 100644 --- a/ui/DownloadQSLDialog.cpp +++ b/ui/DownloadQSLDialog.cpp @@ -25,6 +25,7 @@ DownloadQSLDialog::DownloadQSLDialog(QWidget *parent) "FROM contacts ORDER BY station_callsign", "", ui->lotwMyCallsignCombo)); ui->lotwDateEdit->setDisplayFormat(locale.formatDateShortWithYYYY()); ui->eqslDateEdit->setDisplayFormat(locale.formatDateShortWithYYYY()); + ui->buttonBox->button(QDialogButtonBox::Ok)->setText(tr("&Download")); const StationProfile &profile = StationProfilesManager::instance()->getCurProfile1(); @@ -44,6 +45,9 @@ DownloadQSLDialog::DownloadQSLDialog(QWidget *parent) ui->lotwGroupBox->setChecked(false); ui->lotwGroupBox->setEnabled(false); ui->lotwGroupBox->setToolTip(tr("LoTW is not configured properly.

Please, use Settings dialog to configure it.

")); + ui->lotwDXCCCheckBox->setChecked(false); + ui->lotwDXCCCheckBox->setEnabled(false); + ui->lotwDXCCCheckBox->setToolTip(tr("LoTW is not configured properly.

Please, use Settings dialog to configure it.

")); } if ( EQSLBase::getUsername().isEmpty() ) @@ -97,6 +101,9 @@ void DownloadQSLDialog::loadDialogState() ui->eqslDateTypeCombo->setCurrentIndex((LogParam::getDownloadQSLServiceLastQSOQSL("eqsl")) ? 0 : 1); ui->eqslQTHProfileEdit->setText(LogParam::getDownloadQSLeQSLLastProfile()); + + /* LoTW DXCC Credits checkbox always starts unchecked — this is a one-off task. */ + ui->lotwDXCCCheckBox->setChecked(false); } void DownloadQSLDialog::saveDialogState() @@ -205,6 +212,15 @@ void DownloadQSLDialog::downloadQSLs() lotw->receiveQSL(ui->lotwDateEdit->date(), !qslSinceActive, ui->lotwMyCallsignCombo->currentText()); }); + if ( ui->lotwDXCCCheckBox->isChecked() ) + downloadQueue.enqueue([=]() + { + LotwQSLDownloader* lotw = new LotwQSLDownloader(this); + lotw->LotwDXCCCredits = true; + prepareDownload(lotw, tr("LoTW DXCC Credits"), false, "lotwdxcc"); + lotw->receiveQSL(QDate(), false, QString()); + }); + if ( downloadQueue.isEmpty() ) { QMessageBox::information(this, tr("QLog Information"), tr("No service selected")); diff --git a/ui/DownloadQSLDialog.ui b/ui/DownloadQSLDialog.ui index 74ba4f78..afc24723 100644 --- a/ui/DownloadQSLDialog.ui +++ b/ui/DownloadQSLDialog.ui @@ -108,7 +108,14 @@ - + + + + LoTW DXCC Credits + + + + Qt::Horizontal @@ -129,6 +136,7 @@ lotwDateTypeCombo lotwDateEdit lotwMyCallsignCombo + lotwDXCCCheckBox