diff --git a/.github/workflows/electron.yml b/.github/workflows/electron.yml index af8d84d4..0337b1fe 100644 --- a/.github/workflows/electron.yml +++ b/.github/workflows/electron.yml @@ -158,10 +158,50 @@ jobs: CSC_NAME: "Distheirs LLC (788KRST4S8)" run: yarn ${{ matrix.dist_script }} + # Signed through Azure Artifact Signing (build.win.azureSignOptions). The + # certificate lives in Azure, never on disk; these three variables are what + # electron-builder's EnvironmentCredential authenticates with. - name: Build (Windows) if: matrix.platform == 'win' + env: + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} run: yarn ${{ matrix.dist_script }} + # The Store package. Locally this is `yarn dist:win-msix-`, mirroring + # dist:mac-mas; here electron-builder is called directly so it reuses what the + # step above produced instead of wiping dist/ and rebuilding Rust for the + # third time. + # + # The Store package must ship unsigned: Partner Center re-signs it, and our + # certificate subject (CN=Juan Carlos Carmona Calvo) does not match the + # package publisher (CN=96EC5B6E-…) anyway, which would make it invalid. + # + # Three separate switches are needed for that, none of them obvious: + # - "!.appx" in build.win.signExts stops the package itself being signed. + # signAndEditExecutable does NOT cover it — AppxTarget calls signIf on + # the artifact directly, and signIf never consults that flag. + # - signAndEditExecutable=false skips the binaries inside the package. + # Microsoft's signature covers them, so signing here would only burn + # signing calls — and would make this step need the Azure credentials. + # - azureSignOptions is cleared. Left in place, electron-builder builds the + # Azure signing manager just to ask it for a publisher name, which both + # demands the credentials this step does not carry and writes the + # certificate's subject into Identity/@Publisher, ignoring + # build.appx.publisher (windowsSignAzureManager.computePublisherName + # discards its argument) — a bare personal name, not a valid DN, which + # makeappx rejects. Cleared, the publisher comes from build.appx.publisher. + - name: Build MSIX (Microsoft Store) + if: matrix.platform == 'win' + shell: bash + run: > + npx electron-builder -w appx --${{ matrix.arch }} + "-c.extraMetadata.main=build/electron.js" + "-c.win.signAndEditExecutable=false" + "-c.win.azureSignOptions=" + --publish never + - name: Upload artifacts (Linux) uses: actions/upload-artifact@v4 if: matrix.platform == 'linux' @@ -194,6 +234,18 @@ jobs: dist/*.msi dist/*.zip + # Produced by dist:win-* alongside the msi/zip, but kept in its own artifact: + # this one goes to Partner Center, which signs it on upload. That signature is + # what gets it past Smart App Control — the msi/zip above stay unsigned and + # blocked. It is excluded from the public release in the release job, since + # unsigned it cannot be installed by anyone who downloads it. + - name: Upload MSIX (Microsoft Store) + uses: actions/upload-artifact@v4 + if: matrix.platform == 'win' + with: + name: zingo-pc-win-msix-${{ matrix.arch }} + path: dist/*.appx + flatpak: needs: build runs-on: ubuntu-latest @@ -246,6 +298,12 @@ jobs: path: artifacts merge-multiple: false + # Store-bound packages, not user downloads. The .appx goes to Partner Center + # and the .pkg to App Store Connect; both are signed by the store on upload, + # so as published on a release page they are files nobody can install. + - name: Keep the store packages out of the public release + run: rm -rf artifacts/zingo-pc-win-msix-* artifacts/zingo-pc-mas-* + - name: Generate release notes id: notes uses: actions/github-script@v7 diff --git a/.gitignore b/.gitignore index 0768a31a..dcaa584d 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,9 @@ native.node /resources/nym-proxy /resources/nym-proxy.exe +# staged VC++ runtime (copied from the MSVC redist by scripts/stage-vcruntime.js) +/resources/vcruntime + # testing /coverage @@ -35,3 +38,6 @@ npm-debug.log* yarn-debug.log* yarn-error.log* *.provisionprofile + +# throwaway self-signed cert for sideloading local MSIX builds (docs/windows-msix.md) +zingo-dev.pfx diff --git a/README.md b/README.md index c2368d72..0d67f183 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ Pre-built binaries for each release are available on the [Releases page](https:/ | Linux | `.deb`, `.AppImage` | | Linux (Flatpak) | `.flatpak` | +> **Windows users:** if Windows blocks the app on launch, see [Windows blocks Zingo PC from opening](#troubleshooting) in Troubleshooting. Our Windows builds are code signed, but a recently issued certificate has to accumulate reputation before Windows stops flagging it. + --- ## Compiling from source @@ -240,3 +242,20 @@ Antivirus products that inspect HTTPS traffic cannot decrypt those connections, The fix is to allowlist those two hosts in your antivirus. Do not turn off Mixnet Mode just to silence it — that is what hides your IP from the indexer when you send. Unrelated to this warning: always download releases from the [official Releases page](https://github.com/zingolabs/zingo-pc/releases) and verify the checksum. That, not an antivirus popup, is how you confirm your build is genuine. + +--- + +**Q: Windows blocks Zingo PC from opening ("Smart App Control" or "Windows protected your PC")** + +A: Expected on recent releases. Windows weighs **reputation**, not just whether a file is signed, and a signing certificate starts with no history — so early releases can be flagged exactly like unsigned ones. It clears as installs accumulate. (The publisher on the signature is an individual's name rather than an organisation; that is how the certificate was issued, not a sign the build is unofficial.) + +Verify the download yourself rather than trusting Windows' verdict either way. Right-click the file → **Properties** → **Digital Signatures** for a valid, timestamped signature, then check the hash against the [Releases page](https://github.com/zingolabs/zingo-pc/releases): + +```powershell +Get-FileHash "Zingo PC .msi" -Algorithm SHA256 +``` + +Then: + +- **SmartScreen** (*"Windows protected your PC"*): **More info** → **Run anyway**. +- **Smart App Control** (clean installs of Windows 11 22H2+): no per-app exception exists. It can only be disabled entirely, and **cannot be re-enabled without reinstalling Windows** — we do not recommend it. Use the Microsoft Store build instead once it is published; Store packages are trusted by SAC from the first install. diff --git a/bin/printversion.ps1 b/bin/printversion.ps1 index 81d7688d..822bb767 100644 --- a/bin/printversion.ps1 +++ b/bin/printversion.ps1 @@ -1 +1 @@ -echo "VERSION=2.0.24-170" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append +echo "VERSION=2.0.25-177" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append diff --git a/bin/printversion.sh b/bin/printversion.sh index d2cd4d1a..ca76c899 100755 --- a/bin/printversion.sh +++ b/bin/printversion.sh @@ -1,3 +1,3 @@ #!/bin/bash -VERSION="2.0.24-170" +VERSION="2.0.25-177" echo "VERSION=$VERSION" >> $GITHUB_ENV diff --git a/docs/windows-msix.md b/docs/windows-msix.md new file mode 100644 index 00000000..89087ad1 --- /dev/null +++ b/docs/windows-msix.md @@ -0,0 +1,164 @@ +# Windows MSIX / AppX packaging + +Zingo PC's Windows artifacts (`zip`, `msi`) are unsigned, so Windows Smart App Control +blocks them. Publishing an **MSIX package through the Microsoft Store** sidesteps that: +the Store signs the package with Microsoft's own certificate, and Store-installed apps +are trusted by Smart App Control. No code signing certificate has to be bought or +validated. + +This is a separate channel, not a replacement — users who download the `zip`/`msi` from +GitHub releases are still affected until those artifacts get signed. + +## Build + +``` +yarn dist:win-msix-x64 # or dist:win-msix-arm64 +``` + +Its own script, the same way `dist:mac-mas` sits beside `dist:mac-x64`: the store package is +built separately from the ones that go on the release page. `appx` is deliberately **not** in +`build.win.target`, so `dist:win-x64` keeps producing just `zip` and `msi`. + +Output is `dist/Zingo PC .appx` (`-arm64.appx` on the other arch), carrying the real +Partner Center identity from `build.appx` and ready to upload as-is. + +Two things this separation buys locally: + +- The `msi` target needs an **elevated** shell. WiX cannot run ICE validation under a + restricted system policy, and electron-builder passes `-wx`, so that warning becomes + `LGHT1105` and kills the build. The MSIX script never touches WiX. +- No Rust rebuild is wasted on targets you are not shipping to the Store. + +Requires the Windows 10/11 SDK (`makeappx.exe`, `signtool.exe`); electron-builder downloads +its own copy on first run. That extraction needs symlink privileges — enable Developer Mode +(Settings → System → For developers), or the build dies unpacking `winCodeSign` with +"Cannot create symbolic link". + +## Product identity + +`build.appx` holds the values Partner Center assigned when the app name was reserved. They +are not secrets — every published package carries them in its manifest — but they must match +Partner Center **byte for byte** or the upload is rejected: + +| Field | Partner Center → app → Product identity | +| --- | --- | +| `identityName` | *Package/Identity/Name* | +| `publisher` | *Package/Identity/Publisher* (the full `CN=...`) | +| `publisherDisplayName` | *Package/Properties/PublisherDisplayName* | + +`applicationId` is ours, not Partner Center's, and is unrelated to the Store. + +The package version comes from `package.json`; the Store requires the fourth component to be +`0` (electron-builder does this) and the version to increase on every submission. + +## Testing locally + +The `.appx` that `dist:win-*` produces **cannot be sideloaded** — it is unsigned, and its +publisher is the Store's, not a certificate you hold. The two are mutually exclusive by +design: what you can install locally is not what you can upload. + +To install one on this machine, rebuild with a development identity and sign it yourself: + +``` +npx electron-builder -w appx --x64 -c.extraMetadata.main=build/electron.js --publish never ^ + -c.appx.identityName=ZingoPC -c.appx.publisher="CN=Zingo PC Dev" ^ + -c.appx.publisherDisplayName="Zingo PC Dev" +``` + +```powershell +# 1. Create the cert (once). Subject must equal the publisher above, exactly. +$cert = New-SelfSignedCertificate -Type Custom -Subject "CN=Zingo PC Dev" ` + -KeyUsage DigitalSignature -FriendlyName "Zingo PC Dev" ` + -CertStoreLocation "Cert:\CurrentUser\My" ` + -TextExtension @("2.5.29.37={text}1.3.6.1.5.5.7.3.3", "2.5.29.19={text}") + +# 2. Export it and trust it ($pwd is a PowerShell automatic variable — do not reuse the name) +$certPwd = ConvertTo-SecureString -String "devpass" -Force -AsPlainText +Export-PfxCertificate -Cert "Cert:\CurrentUser\My\$($cert.Thumbprint)" ` + -FilePath zingo-dev.pfx -Password $certPwd +Import-PfxCertificate -FilePath zingo-dev.pfx -Password $certPwd ` + -CertStoreLocation "Cert:\LocalMachine\TrustedPeople" + +# 3. Sign and install +$signtool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin\*\x64\signtool.exe" | + Sort-Object FullName | Select-Object -Last 1 +& $signtool.FullName sign /fd SHA256 /f zingo-dev.pfx /p devpass "dist\Zingo PC .appx" +Add-AppxPackage "dist\Zingo PC .appx" +``` + +`zingo-dev.pfx` is a throwaway credential (gitignored) — do not reuse it for anything else. + +Both installs show up as "Zingo PC" in the Start menu and are indistinguishable there. Launch +the packaged one explicitly: + +```powershell +Start-Process "shell:AppsFolder\$((Get-AppxPackage *ZingoPC*).PackageFamilyName)!ZingoPC" +``` + +## What to verify in the packaged app + +MSIX runs full-trust but virtualizes filesystem and registry writes, so these are the parts +most likely to behave differently from the `msi` build: + +- **`zcash:` URI handling.** `app.setAsDefaultProtocolClient` in `public/electron.js` cannot + register the scheme from inside an MSIX container — its registry writes are virtualized. + The scheme is instead declared in the package manifest, generated from `build.win.protocols`. + Test both paths: app closed (cold start) and app already running (`second-instance`). +- **`keytar`.** `getRequireAuth`/`setRequireAuth` swallow any keytar failure and fall back to + `settings.json`, so a broken keytar looks like a working app. Prove it by toggling the + device-auth setting and checking that a `Zingo PC` entry appears in Credential Manager. +- **`nym-proxy.exe`.** Spawned as a child process from `extraResources`; confirm it launches + and that its listening socket works. +- **`electron-settings` / `electron-json-storage`.** Writes to `%APPDATA%` are redirected to + the package's private store. Existing users migrating from the `msi` build will not see + their previous settings. + +## Store assets + +The tile assets live in `public/appx/` (`build.directories.buildResources` is `public`) and +are generated from `resources/icon.png`: + +``` +powershell -ExecutionPolicy Bypass -File scripts/generate-appx-assets.ps1 +``` + +Re-run it whenever the icon changes. Without these files electron-builder silently falls +back to its own placeholder images — a local build still succeeds, but Store certification +fails. + +## CI + +`.github/workflows/electron.yml` builds the MSIX inside the existing Windows job rather than +calling `dist:win-msix-*`, which would `rimraf dist` and rebuild Rust from scratch a third +time. It reuses what `dist:win-` just produced, so it costs seconds. + +That is the one place the MAS symmetry stops. MAS earns its own matrix entry because it is a +genuinely different build — universal lipo, different entitlements, different signing. The +Store MSIX is the same build output packed into another container, so a second +electron-builder call in the same job is enough. + +It is uploaded as its own artifact (`zingo-pc-win-msix-x64` / `-arm64`) and **excluded from +the GitHub release**, along with the MAS `.pkg`. Both are store-bound: signed by Apple and +Microsoft on upload, so on a release page they would be things nobody can install. + +Download both `.appx` files and upload them to the same submission — Partner Center serves +each machine the matching one. An x64-only listing would still run on Windows on ARM through +emulation, just slower and with worse battery life. + +## Publishing + +The first submission has to be done by hand — reserving the name, the listing, screenshots, +age rating and privacy policy have no unattended path: + +1. Register at `partner.microsoft.com` (individual account, one-off fee) and pass identity + verification. +2. Reserve the app name. That is what mints the product identity values above. +3. Push a `zingo-pc-*` tag and download the two `zingo-pc-win-msix-*` artifacts from the run. +4. Upload both `.appx` files to the submission, unsigned — Partner Center re-signs them. +5. Under Packages, tick **Windows 10/11 Desktop** under device family availability, or the + product ships available to nobody. +6. Complete the listing (category: Personal finance) and submit for certification. + +Later submissions can be automated with the Microsoft Store submission API, which needs an +Entra tenant with an app registration linked under Partner Center → Account settings → User +management. Worth doing only once the manual flow has gone through at least once. diff --git a/docs/windows-signing.md b/docs/windows-signing.md new file mode 100644 index 00000000..4cf136b2 --- /dev/null +++ b/docs/windows-signing.md @@ -0,0 +1,150 @@ +# Windows packaging and code signing + +## The Visual C++ runtime + +`native.node` and `nym-proxy.exe` are built with MSVC and import `VCRUNTIME140.dll`. +Electron does not, so without that DLL the window opens perfectly and then every native +call fails: `require()` of the module returns *"the specified module could not be found"*, +`getNative()` yields null, and the app stops on the loading screen. + +It is invisible during development. Visual Studio installs the runtime, so developer +machines and CI runners always have it; clean consumer machines often do not. This cost two +Microsoft Store certification rounds — the report was an app that launched and did nothing, +reproducible on their hardware and on nobody's desk. + +`scripts/stage-vcruntime.js` copies the DLLs out of the MSVC redistributable into +`resources/vcruntime/` (gitignored, architecture-specific, restaged per build), and +`build.win.extraFiles` places them next to `Zingo PC.exe`. App-local deployment is the model +Microsoft's own documentation recommends for this case, and redistribution is permitted. + +`vcruntime140_1.dll` exists only on x64 — it carries C++ exception handling — and is absent +from the arm64 redist, so it is copied when present rather than required. + +The `api-ms-win-crt-*` imports need nothing: those are the Universal CRT, shipped with +Windows 10 and later. + +# Code signing + +The `zip` and `msi` artifacts are signed with **Azure Artifact Signing** (formerly Trusted +Signing). The certificate never exists as a file: it lives in Azure, and each signature is a +call to the service. Nothing sensitive is stored in the repo or on a build machine. + +This is what stops Smart App Control blocking the direct downloads. The Microsoft Store +route ([windows-msix.md](windows-msix.md)) solves the same problem for Store installs, by a +different mechanism — the Store signs the package itself. + +## Configuration + +`build.win.azureSignOptions` in `package.json`: + +| Field | Value | Where it comes from | +| --- | --- | --- | +| `publisherName` | `Juan Carlos Carmona Calvo` | The **CN** of the certificate subject. Must match exactly | +| `endpoint` | `https://eus.codesigning.azure.net` | Region of the signing account (East US → `eus`) | +| `codeSigningAccountName` | `zingo-pc-signing` | The Azure resource | +| `certificateProfileName` | `Zingo-PC` | Certificate profile inside that resource | + +The full certificate subject is +`CN=Juan Carlos Carmona Calvo, O=Juan Carlos Carmona Calvo, L=Boulder, S=co, C=US`, from an +**individual** identity validation. The publisher shown to users is therefore a person, not +an organisation. + +`build.win.signExts` adds `.node` and `.dll` to what gets signed. Without it electron-builder +signs only `.exe` files, leaving the native addon, keytar's binding and Electron's own DLLs +unsigned — modules the process loads at runtime, which is exactly what Smart App Control +inspects. `nym-proxy.exe` needs no special handling: `extraResources` are passed through the +signing transformer because it ends in `.exe`. + +## Credentials + +Three **repository secrets** — real credentials, unlike the Store identity values: + +``` +AZURE_TENANT_ID +AZURE_CLIENT_ID +AZURE_CLIENT_SECRET +``` + +They belong to the `Zingo PC` app registration, which holds the +**Artifact Signing Certificate Profile Signer** role on the signing account. Being subscription +Owner is not enough — that role is data-plane and must be assigned explicitly. + +⚠️ The client secret **expires**. When it does, Windows builds start failing at the signing +step with an authentication error that does not obviously point at an expired credential. +Note the expiry date somewhere visible. + +## Building locally + +`dist:win-x64` and `dist:win-arm64` now sign, so they need the same three variables: + +```powershell +$env:AZURE_TENANT_ID = "..." +$env:AZURE_CLIENT_ID = "..." +$env:AZURE_CLIENT_SECRET = "..." +yarn dist:win-x64 +``` + +Without them the build fails when it reaches signing. There is no unsigned fallback — that is +deliberate: a silently unsigned artifact is the failure this whole setup exists to prevent. + +The `msi` target additionally needs an **elevated** shell (WiX cannot run ICE validation under +a restricted system policy, and `-wx` turns that warning into `LGHT1105`). + +Two prerequisites that GitHub's runners carry by default and a developer machine usually does +not. Both surface as confusing errors rather than a missing-dependency message: + +- **pwsh (PowerShell 7)** — electron-builder shells out to it for Azure signing, and + `scripts/sign-nym-proxy.ps1` does too. Windows PowerShell 5.1 is not enough: its + PowerShellGet cannot load `Install-Module` to pull the `TrustedSigning` module. + `winget install --id Microsoft.PowerShell -e` +- **The .NET SDK** — `Invoke-TrustedSigning` installs the `sign` dotnet tool on first use, and + the runtime alone will not do it (*"No .NET SDKs were found"*). + `winget install --id Microsoft.DotNet.SDK.8 -e` + +That script signs `resources/nym-proxy.exe` before packaging, because electron-builder does not +sign `extraResources`: its pass covers the app directory, `resources/app.asar.unpacked` and +`swiftshader`, and the proxy sits in `resources/`. It is spawned as a child process, so Smart +App Control inspects it independently of the main executable. + +## Why the MSIX build does not sign + +Partner Center re-signs the `.appx` on upload and requires it unsigned. Beyond that, a package +is only valid if the signing certificate subject equals its `Identity/Publisher` — and those +differ here on purpose: the package publisher is the Partner Center seller GUID +(`CN=96EC5B6E-…`), while this certificate is issued to a person. Signing the `.appx` would +produce a package Windows rejects and Partner Center refuses. + +It takes **three** switches, none of them obvious: + +- **`"!.appx"` in `build.win.signExts`** stops the package itself from being signed. + `signAndEditExecutable` does not cover this: `AppxTarget` calls `packager.signIf()` on the + finished artifact, and `signIf` only consults `signExts`. +- **`-c.win.signAndEditExecutable=false`**, passed by `dist:win-msix-*` and the CI step, skips + the binaries inside the package. Microsoft's signature covers them, so signing here would + only spend signing calls — and would force this step to carry the Azure credentials. +- **`-c.win.azureSignOptions=`**, same two callers, clears the Azure config for that run. Left + in place it costs twice: electron-builder constructs the Azure signing manager merely to ask + it for a publisher name, and that manager validates the credentials on construction — which + this step deliberately does not carry. It then writes the *certificate's* subject into + `Identity/@Publisher`, ignoring `build.appx.publisher` + (`windowsSignAzureManager.computePublisherName` discards its argument) on the assumption that + whoever signs a package also publishes it. Ours differ on purpose, and the result is a bare + personal name — not a valid DN — which `makeappx` rejects with a pattern-constraint error. + Cleared, the publisher comes from `build.appx.publisher` as intended. + +With all three in place the MSIX build needs no credentials at all — verified: the run logs +*"AppX is not signed"* and *"file signing skipped via signExts configuration"*, and the +resulting package carries `Publisher='CN=96EC5B6E-…'` with no `AppxSignature.p7x`. + +## Reputation + +A signature is necessary but not instantly sufficient. Smart App Control wants a valid +signature **and** a favourable reputation prediction, and a freshly issued certificate has no +history. Expect some friction on the first releases while the Intelligent Security Graph +accumulates installs. + +Two things help, neither optional if the first releases matter: + +- Keep signing with the **same** certificate. Rotating it resets the reputation. +- Submit each release to `microsoft.com/wdsi/filesubmission` as a software developer. That + feeds the graph directly instead of waiting for organic installs. diff --git a/flatpak/co.zingo.pc.metainfo.xml b/flatpak/co.zingo.pc.metainfo.xml index 49b1f0cb..c8693766 100644 --- a/flatpak/co.zingo.pc.metainfo.xml +++ b/flatpak/co.zingo.pc.metainfo.xml @@ -55,6 +55,7 @@ + diff --git a/package.json b/package.json index 92d53021..68a4e927 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zingo-pc", "productName": "Zingo PC", - "version": "2.0.24", + "version": "2.0.25", "private": true, "description": "Zingo PC", "license": "MIT", @@ -65,8 +65,10 @@ "dist:mac-arm64": "rimraf dist && yarn build-mac-arm64 && node scripts/stage-nym-proxy.js --target aarch64-apple-darwin && electron-builder -m --arm64 -c.extraMetadata.main=build/electron.js --publish never", "dist:mac-mas": "rimraf dist && yarn cargo:clean && yarn neon-mac-x64 && cp src/native.node src/native-x64.node && yarn neon-mac-arm64 && lipo -create -output src/native.node src/native-x64.node src/native.node && rm src/native-x64.node && node scripts/stage-nym-proxy.js && cross-env node scripts/build.js && electron-builder --mac mas --universal -c.extraMetadata.main=build/electron.js --publish never", "dist:mac-x64": "rimraf dist && yarn build-mac-x64 && node scripts/stage-nym-proxy.js --target x86_64-apple-darwin && electron-builder -m --x64 -c.extraMetadata.main=build/electron.js --publish never", - "dist:win-arm64": "rimraf dist && yarn build-win-arm64 && node scripts/stage-nym-proxy.js --target aarch64-pc-windows-msvc && electron-builder -w --arm64 -c.extraMetadata.main=build/electron.js --publish never", - "dist:win-x64": "rimraf dist && yarn build-win-x64 && node scripts/stage-nym-proxy.js --target x86_64-pc-windows-msvc && electron-builder -w --x64 -c.extraMetadata.main=build/electron.js --publish never", + "dist:win-arm64": "rimraf dist && yarn build-win-arm64 && node scripts/stage-nym-proxy.js --target aarch64-pc-windows-msvc && node scripts/stage-vcruntime.js --arch arm64 && node scripts/check-win-arch.js --arch arm64 && pwsh -ExecutionPolicy Bypass -File scripts/sign-nym-proxy.ps1 && electron-builder -w --arm64 -c.extraMetadata.main=build/electron.js --publish never", + "dist:win-x64": "rimraf dist && yarn build-win-x64 && node scripts/stage-nym-proxy.js --target x86_64-pc-windows-msvc && node scripts/stage-vcruntime.js --arch x64 && node scripts/check-win-arch.js --arch x64 && pwsh -ExecutionPolicy Bypass -File scripts/sign-nym-proxy.ps1 && electron-builder -w --x64 -c.extraMetadata.main=build/electron.js --publish never", + "dist:win-msix-arm64": "rimraf dist && yarn build-win-arm64 && node scripts/stage-nym-proxy.js --target aarch64-pc-windows-msvc && node scripts/stage-vcruntime.js --arch arm64 && node scripts/check-win-arch.js --arch arm64 && electron-builder -w appx --arm64 -c.extraMetadata.main=build/electron.js -c.win.signAndEditExecutable=false -c.win.azureSignOptions= --publish never", + "dist:win-msix-x64": "rimraf dist && yarn build-win-x64 && node scripts/stage-nym-proxy.js --target x86_64-pc-windows-msvc && node scripts/stage-vcruntime.js --arch x64 && node scripts/check-win-arch.js --arch x64 && electron-builder -w appx --x64 -c.extraMetadata.main=build/electron.js -c.win.signAndEditExecutable=false -c.win.azureSignOptions= --publish never", "react-start": "cross-env node scripts/start.js", "neon": "rimraf src/native.node && cargo-cp-artifact -a cdylib zingolib-native src/native.node -- cargo build --release --manifest-path native/Cargo.toml --message-format=json-render-diagnostics", "neon-mac-x64": "rimraf src/native.node && cargo-cp-artifact -a cdylib zingolib-native src/native.node -- cargo build --release --target x86_64-apple-darwin --manifest-path native/Cargo.toml --message-format=json-render-diagnostics", @@ -224,7 +226,7 @@ "hardenedRuntime": true, "gatekeeperAssess": false, "entitlements": "./configs/entitlements.mac.inherit.plist", - "bundleVersion": "170", + "bundleVersion": "177", "extendInfo": { "ITSAppUsesNonExemptEncryption": false }, @@ -252,7 +254,7 @@ "entitlements": "./configs/entitlements.mas.plist", "entitlementsInherit": "./configs/entitlements.mas.inherit.plist", "provisioningProfile": "./configs/Zingo_PC_Profile.provisionprofile", - "bundleVersion": "170", + "bundleVersion": "177", "extendInfo": { "ITSAppUsesNonExemptEncryption": false }, @@ -275,11 +277,48 @@ "zip", "msi" ], + "azureSignOptions": { + "publisherName": "Juan Carlos Carmona Calvo", + "endpoint": "https://eus.codesigning.azure.net", + "codeSigningAccountName": "zingo-pc-signing", + "certificateProfileName": "Zingo-PC" + }, + "signExts": [ + ".node", + ".dll", + "!.appx" + ], + "protocols": [ + { + "name": "Zcash", + "schemes": [ + "zcash" + ] + } + ], "extraResources": [ { "from": "resources/nym-proxy.exe", "to": "nym-proxy.exe" } + ], + "extraFiles": [ + { + "from": "resources/vcruntime", + "to": "." + } + ] + }, + "appx": { + "applicationId": "ZingoPC", + "identityName": "JuanCarlosCarmonaCalvo.ZingoPC", + "publisher": "CN=96EC5B6E-FB9F-4DEA-9C02-05C6CAF8DDF8", + "publisherDisplayName": "Juan Carlos Carmona Calvo", + "displayName": "Zingo PC", + "backgroundColor": "#06172d", + "showNameOnTiles": true, + "languages": [ + "en-US" ] }, "deb": { diff --git a/public/appx/BadgeLogo.png b/public/appx/BadgeLogo.png new file mode 100644 index 00000000..64e8f277 Binary files /dev/null and b/public/appx/BadgeLogo.png differ diff --git a/public/appx/LargeTile.png b/public/appx/LargeTile.png new file mode 100644 index 00000000..0608185e Binary files /dev/null and b/public/appx/LargeTile.png differ diff --git a/public/appx/SmallTile.png b/public/appx/SmallTile.png new file mode 100644 index 00000000..b129c3ab Binary files /dev/null and b/public/appx/SmallTile.png differ diff --git a/public/appx/SplashScreen.png b/public/appx/SplashScreen.png new file mode 100644 index 00000000..bbcacc7f Binary files /dev/null and b/public/appx/SplashScreen.png differ diff --git a/public/appx/Square150x150Logo.png b/public/appx/Square150x150Logo.png new file mode 100644 index 00000000..73121b19 Binary files /dev/null and b/public/appx/Square150x150Logo.png differ diff --git a/public/appx/Square310x310Logo.png b/public/appx/Square310x310Logo.png new file mode 100644 index 00000000..0608185e Binary files /dev/null and b/public/appx/Square310x310Logo.png differ diff --git a/public/appx/Square44x44Logo.png b/public/appx/Square44x44Logo.png new file mode 100644 index 00000000..5ec97f5b Binary files /dev/null and b/public/appx/Square44x44Logo.png differ diff --git a/public/appx/Square71x71Logo.png b/public/appx/Square71x71Logo.png new file mode 100644 index 00000000..b129c3ab Binary files /dev/null and b/public/appx/Square71x71Logo.png differ diff --git a/public/appx/StoreLogo.png b/public/appx/StoreLogo.png new file mode 100644 index 00000000..51d1f1e0 Binary files /dev/null and b/public/appx/StoreLogo.png differ diff --git a/public/appx/Wide310x150Logo.png b/public/appx/Wide310x150Logo.png new file mode 100644 index 00000000..3c0eeead Binary files /dev/null and b/public/appx/Wide310x150Logo.png differ diff --git a/public/electron.js b/public/electron.js index 31965852..0a47c96e 100644 --- a/public/electron.js +++ b/public/electron.js @@ -508,15 +508,23 @@ if (process.platform === "darwin") { // Register all IPC handlers once — calling ipcMain.handle twice for the same channel throws +// Race a platform probe against a timeout so a hung native call (e.g. Windows +// Hello on a system where the consent dialog never surfaces) doesn't block the +// renderer forever and leave it on a screen with no way out. +const withAuthTimeout = (probe, fallback = "not_supported", ms = 3000) => + Promise.race([ + Promise.resolve().then(() => probe()), + new Promise((resolve) => setTimeout(() => resolve(fallback), ms)), + ]).catch(() => fallback); + +// Availability probes are non-interactive, so seconds are plenty. Verification +// waits on a person presenting a face, finger or PIN, so it gets a minute — long +// enough not to cut a real user off, short enough to end rather than hang. +const AUTH_PROBE_TIMEOUT_MS = 3000; +const AUTH_VERIFY_TIMEOUT_MS = 60000; + ipcMain.handle("auth:check", async () => { - // Race any platform probe against a 3s timeout so a hung native call - // (e.g. Windows Hello on a system without it configured) doesn't block - // the renderer's lock-check forever and leave a white screen. - const withTimeout = (probe, fallback = "not_supported", ms = 3000) => - Promise.race([ - Promise.resolve().then(() => probe()), - new Promise((resolve) => setTimeout(() => resolve(fallback), ms)), - ]).catch(() => fallback); + const withTimeout = withAuthTimeout; if (process.platform === "win32") { return withTimeout(() => getNative().checkWindowsHello()); @@ -548,13 +556,25 @@ ipcMain.handle("auth:verify", async (_e, reason) => { // feature: `requireDeviceAuth` defaults to true, but the renderer also gates // the LOCK screen on auth:check === "available", so disabling here keeps the // two callers consistent. + // Both calls are timed out for the same reason auth:check is: a native probe + // or prompt that never returns used to strand the caller. The lock screen sat + // on "Authenticating..." with the window already blurred, and no way forward. if (process.platform === "win32") { const win = BrowserWindow.getAllWindows()[0] ?? null; try { const native = getNative(); - if (native.checkWindowsHello() !== "available") return { success: true }; + const availability = await withAuthTimeout( + () => native.checkWindowsHello(), + "not_supported", + AUTH_PROBE_TIMEOUT_MS, + ); + if (availability !== "available") return { success: true }; if (win) win.blur(); - const result = await native.verifyWindowsUser(String(reason)); + const result = await withAuthTimeout( + () => native.verifyWindowsUser(String(reason)), + { success: false }, + AUTH_VERIFY_TIMEOUT_MS, + ); if (win) win.focus(); return result; } catch { @@ -564,8 +584,13 @@ ipcMain.handle("auth:verify", async (_e, reason) => { } else if (process.platform === "darwin") { try { const native = getNative(); - if (native.checkMacAuth() !== "available") return { success: true }; - return await native.verifyMacUser(String(reason)); + const availability = await withAuthTimeout(() => native.checkMacAuth(), "not_supported", AUTH_PROBE_TIMEOUT_MS); + if (availability !== "available") return { success: true }; + return await withAuthTimeout( + () => native.verifyMacUser(String(reason)), + { success: false }, + AUTH_VERIFY_TIMEOUT_MS, + ); } catch { return { success: false }; } @@ -791,15 +816,37 @@ const _nativePath = __dirname.includes(".asar") : path.join(__dirname, "../src/native.node"); let _mainNative = null; +// Why the load error is kept instead of discarded: when native.node fails to +// load, every caller below fails separately — a wrong-architecture module gave +// four different "cannot read properties of null" further up, none of them +// naming the real cause, and the app looked frozen rather than broken. Windows +// says exactly what is wrong ("%1 is not a valid Win32 application" for an +// arch mismatch); this keeps that sentence and puts it in front of the user. +let _mainNativeError = null; function getNative() { - if (!_mainNative) { + if (!_mainNative && !_mainNativeError) { try { _mainNative = require(_nativePath); - } catch (_) {} + } catch (e) { + _mainNativeError = e; + console.error(`FATAL: native module failed to load from ${_nativePath}: ${e && e.message}`); + } } return _mainNative; } +// Throws the load failure rather than letting callers trip over a null. +function requireNative(method) { + const native = getNative(); + if (native && typeof native[method] === "function") { + return native; + } + if (_mainNativeError) { + throw new Error(`native module failed to load (${_nativePath}): ${_mainNativeError.message}`); + } + throw new Error(`native.${method} not available`); +} + // Activates a security-scoped bookmark from the main process, which has // com.apple.security.files.bookmarks.app-scope explicitly. Apple docs say // app-scoped bookmark access applies to all processes in the app sandbox. @@ -875,13 +922,7 @@ const _NATIVE_NO_PARAM_METHODS = [ ]; for (const method of _NATIVE_NO_PARAM_METHODS) { - ipcMain.handle(`native:${method}`, () => { - const native = getNative(); - if (!native || typeof native[method] !== "function") { - throw new Error(`native.${method} not available`); - } - return native[method](); - }); + ipcMain.handle(`native:${method}`, () => requireNative(method)[method]()); } // Sync no-param methods (also routed to main — become async over IPC) @@ -891,41 +932,53 @@ for (const method of [ "get_zennies_for_zingo_donation_address", "set_crypto_default_provider_to_ring", ]) { - ipcMain.handle(`native:${method}`, () => { - const native = getNative(); - if (!native || typeof native[method] !== "function") { - throw new Error(`native.${method} not available`); - } - return native[method](); - }); + ipcMain.handle(`native:${method}`, () => requireNative(method)[method]()); } // Methods with parameters ipcMain.handle("native:wallet_exists", (_e, server_uri, chain_hint, perf, min_conf, wallet_name) => { assertWalletName(wallet_name); - return getNative().wallet_exists(server_uri, chain_hint, perf, min_conf, wallet_name); + return requireNative("wallet_exists").wallet_exists(server_uri, chain_hint, perf, min_conf, wallet_name); }); ipcMain.handle("native:init_new", (_e, server_uri, chain_hint, perf, min_conf, wallet_name) => { assertWalletName(wallet_name); - return getNative().init_new(server_uri, chain_hint, perf, min_conf, wallet_name); + return requireNative("init_new").init_new(server_uri, chain_hint, perf, min_conf, wallet_name); }); ipcMain.handle("native:init_from_seed", (_e, seed, birthday, server_uri, chain_hint, perf, min_conf, wallet_name) => { assertWalletName(wallet_name); - return getNative().init_from_seed(seed, birthday, server_uri, chain_hint, perf, min_conf, wallet_name); + return requireNative("init_from_seed").init_from_seed( + seed, + birthday, + server_uri, + chain_hint, + perf, + min_conf, + wallet_name, + ); }); ipcMain.handle("native:init_from_ufvk", (_e, ufvk, birthday, server_uri, chain_hint, perf, min_conf, wallet_name) => { assertWalletName(wallet_name); - return getNative().init_from_ufvk(ufvk, birthday, server_uri, chain_hint, perf, min_conf, wallet_name); + return requireNative("init_from_ufvk").init_from_ufvk( + ufvk, + birthday, + server_uri, + chain_hint, + perf, + min_conf, + wallet_name, + ); }); ipcMain.handle("native:init_from_b64", (_e, server_uri, chain_hint, perf, min_conf, wallet_name) => { assertWalletName(wallet_name); - return getNative().init_from_b64(server_uri, chain_hint, perf, min_conf, wallet_name); + return requireNative("init_from_b64").init_from_b64(server_uri, chain_hint, perf, min_conf, wallet_name); }); -ipcMain.handle("native:get_latest_block_server", (_e, server_uri) => getNative().get_latest_block_server(server_uri)); -ipcMain.handle("native:parse_address", (_e, address) => getNative().parse_address(address)); -ipcMain.handle("native:parse_ufvk", (_e, ufvk) => getNative().parse_ufvk(ufvk)); -ipcMain.handle("native:get_messages", (_e, address) => getNative().get_messages(address)); -ipcMain.handle("native:zec_price_over_mixnet", () => getNative().zec_price_over_mixnet()); +ipcMain.handle("native:get_latest_block_server", (_e, server_uri) => + requireNative("get_latest_block_server").get_latest_block_server(server_uri), +); +ipcMain.handle("native:parse_address", (_e, address) => requireNative("parse_address").parse_address(address)); +ipcMain.handle("native:parse_ufvk", (_e, ufvk) => requireNative("parse_ufvk").parse_ufvk(ufvk)); +ipcMain.handle("native:get_messages", (_e, address) => requireNative("get_messages").get_messages(address)); +ipcMain.handle("native:zec_price_over_mixnet", () => requireNative("zec_price_over_mixnet").zec_price_over_mixnet()); // --- Mixnet transport: main-owned, session-level (ADR 0024) ---------------- // Main spawns and holds the nym-proxy for the whole app session. Switching // wallets re-attaches the new LightClient to the same tunnel instead of @@ -983,7 +1036,7 @@ function setMixnetPhase(phase) { async function attachCurrentWallet() { if (!mixnet.socks5Addr) return; try { - await getNative().attach_mixnet(mixnet.socks5Addr); + await requireNative("attach_mixnet").attach_mixnet(mixnet.socks5Addr); setMixnetPhase("ready"); } catch (e) { console.error("[mixnet] attach failed:", e && e.message ? e.message : e); @@ -1058,7 +1111,7 @@ ipcMain.handle("mixnet:disable", async () => { mixnet.intent = "off"; killProxy(); try { - await getNative().stop_mixnet(); + await requireNative("stop_mixnet").stop_mixnet(); } catch (e) { console.error("[mixnet] stop failed:", e && e.message ? e.message : e); } @@ -1070,7 +1123,7 @@ ipcMain.handle("mixnet:disable", async () => { ipcMain.handle("mixnet:attach-current", async () => { if (mixnet.intent === "off") { try { - await getNative().stop_mixnet(); + await requireNative("stop_mixnet").stop_mixnet(); } catch {} setMixnetPhase("switched_off"); } else if (mixnet.socks5Addr) { @@ -1082,27 +1135,31 @@ ipcMain.handle("mixnet:attach-current", async () => { }); app.on("before-quit", () => killProxy()); -ipcMain.handle("native:remove_transaction", (_e, txid) => getNative().remove_transaction(txid)); +ipcMain.handle("native:remove_transaction", (_e, txid) => requireNative("remove_transaction").remove_transaction(txid)); ipcMain.handle("native:get_spendable_balance_with_address", (_e, address, zennies) => - getNative().get_spendable_balance_with_address(address, zennies), + requireNative("get_spendable_balance_with_address").get_spendable_balance_with_address(address, zennies), ); ipcMain.handle("native:create_new_unified_address", (_e, receivers) => - getNative().create_new_unified_address(receivers), + requireNative("create_new_unified_address").create_new_unified_address(receivers), ); ipcMain.handle("native:set_config_wallet_to_prod", (_e, perf, min_conf) => - getNative().set_config_wallet_to_prod(perf, min_conf), + requireNative("set_config_wallet_to_prod").set_config_wallet_to_prod(perf, min_conf), ); -ipcMain.handle("native:send", (_e, send_json) => getNative().send(send_json)); +ipcMain.handle("native:send", (_e, send_json) => requireNative("send").send(send_json)); ipcMain.handle("native:delete_wallet", (_e, server_uri, chain_hint, perf, min_conf, wallet_name) => { assertWalletName(wallet_name); - return getNative().delete_wallet(server_uri, chain_hint, perf, min_conf, wallet_name); + return requireNative("delete_wallet").delete_wallet(server_uri, chain_hint, perf, min_conf, wallet_name); }); -ipcMain.handle("native:change_server", (_e, server_uri) => getNative().change_server(server_uri)); +ipcMain.handle("native:change_server", (_e, server_uri) => requireNative("change_server").change_server(server_uri)); ipcMain.handle("native:start_ironwood_migration", (_e, consented_plan_hash, per_bucket) => - getNative().start_ironwood_migration(consented_plan_hash, per_bucket), + requireNative("start_ironwood_migration").start_ironwood_migration(consented_plan_hash, per_bucket), +); +ipcMain.handle("native:reschedule_parts", (_e, per_bucket) => + requireNative("reschedule_parts").reschedule_parts(per_bucket), +); +ipcMain.handle("native:execute_due_parts", (_e, spacing_ms) => + requireNative("execute_due_parts").execute_due_parts(spacing_ms), ); -ipcMain.handle("native:reschedule_parts", (_e, per_bucket) => getNative().reschedule_parts(per_bucket)); -ipcMain.handle("native:execute_due_parts", (_e, spacing_ms) => getNative().execute_due_parts(spacing_ms)); ipcMain.handle("wallet-dir:request", async () => { const wdLog = (msg) => { diff --git a/scripts/build.js b/scripts/build.js index 1e90b483..79091760 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -93,12 +93,18 @@ function copyPublicFolder() { function copyNativeNode() { const src = path.join(__dirname, "../src/native.node"); const dest = path.join(paths.appBuild, "native.node"); - if (fs.existsSync(src)) { - fs.copySync(src, dest); - console.log(chalk.cyan("Copied native.node to build/")); - } else { - console.warn(chalk.yellow("Warning: src/native.node not found, skipping copy.")); + // Missing means the neon step did not produce a module, and a build without it + // is not a build: it packages, installs and launches, then fails on the first + // wallet call with an error that points nowhere near this. This used to be a + // yellow warning in the middle of a few hundred lines of webpack output. + if (!fs.existsSync(src)) { + throw new Error( + `src/native.node not found. The native module was not built — run "yarn neon-win-" ` + + `(or the matching neon script for this platform) before building.`, + ); } + fs.copySync(src, dest); + console.log(chalk.cyan("Copied native.node to build/")); } function gzipSize(filePath) { diff --git a/scripts/check-win-arch.js b/scripts/check-win-arch.js new file mode 100644 index 00000000..bc72dd3b --- /dev/null +++ b/scripts/check-win-arch.js @@ -0,0 +1,85 @@ +// Verifies the architecture-specific files match the target before packaging. +// +// node scripts/check-win-arch.js --arch x64 (or arm64) +// +// build/native.node, src/native.node and resources/nym-proxy.exe are shared +// paths rewritten by `yarn build-win-` and stage-nym-proxy. Nothing about +// them records which architecture they hold, so any electron-builder run that +// does not regenerate them packages whatever the previous build left behind. +// +// That is not hypothetical: an x64 package once shipped with an arm64 +// native.node after an arm64 build in the same tree. It installed, launched, and +// then failed on every native call, because require() of a wrong-architecture +// module throws and the failure surfaced far from its cause. CI never sees this +// (each job gets a clean runner); local builds — the ones uploaded to the Store — +// are wide open to it. + +const fs = require("fs"); +const path = require("path"); + +// PE header: offset at 0x3c, then "PE\0\0", then the 2-byte Machine field. +const MACHINE = { 0x8664: "x64", 0xaa64: "arm64", 0x14c: "ia32" }; + +function peArch(file) { + const fd = fs.openSync(file, "r"); + try { + const off = Buffer.alloc(4); + fs.readSync(fd, off, 0, 4, 0x3c); + const machine = Buffer.alloc(2); + fs.readSync(fd, machine, 0, 2, off.readUInt32LE(0) + 4); + const value = machine.readUInt16LE(0); + return MACHINE[value] || `unknown (0x${value.toString(16)})`; + } finally { + fs.closeSync(fd); + } +} + +const argIndex = process.argv.indexOf("--arch"); +const expected = argIndex !== -1 ? process.argv[argIndex + 1] : null; +if (!expected || !Object.values(MACHINE).includes(expected)) { + console.error("check-win-arch: pass --arch x64 or --arch arm64"); + process.exit(1); +} + +const root = path.resolve(__dirname, ".."); +const required = [ + path.join(root, "src", "native.node"), + path.join(root, "build", "native.node"), + path.join(root, "resources", "nym-proxy.exe"), +]; +// Staged by stage-vcruntime.js; also architecture-specific, and shipping the +// wrong one would fail the same way it would with no runtime at all. +const vcDir = path.join(root, "resources", "vcruntime"); +const optional = fs.existsSync(vcDir) + ? fs + .readdirSync(vcDir) + .filter((f) => f.endsWith(".dll")) + .map((f) => path.join(vcDir, f)) + : []; + +const problems = []; +for (const file of [...required, ...optional]) { + const name = path.relative(root, file); + if (!fs.existsSync(file)) { + problems.push(`${name}: missing`); + continue; + } + const actual = peArch(file); + console.log(` ${actual.padEnd(8)} ${name}`); + if (actual !== expected) { + problems.push(`${name}: ${actual}, expected ${expected}`); + } +} + +if (problems.length > 0) { + console.error(`\ncheck-win-arch: architecture mismatch for a ${expected} build:\n`); + for (const p of problems) console.error(` - ${p}`); + console.error( + `\nA package built now would install and then fail on every native call.\n` + + `Run "yarn dist:win-${expected}" (or dist:win-msix-${expected}) from the top, so the\n` + + `native module and nym-proxy are rebuilt for ${expected} rather than reused.\n`, + ); + process.exit(1); +} + +console.log(`check-win-arch: all ${expected}.`); diff --git a/scripts/generate-appx-assets.ps1 b/scripts/generate-appx-assets.ps1 new file mode 100644 index 00000000..93f262aa --- /dev/null +++ b/scripts/generate-appx-assets.ps1 @@ -0,0 +1,71 @@ +# Generates the AppX/MSIX tile assets required for Microsoft Store certification +# from resources/icon.png, into public/appx/ (build.directories.buildResources). +# +# powershell -ExecutionPolicy Bypass -File scripts/generate-appx-assets.ps1 +# +# Without these, electron-builder falls back to its own placeholder images, which +# pass a local build but fail Store certification. Re-run whenever the icon changes. +# +# Square assets are a straight resize. Non-square ones (wide tile, splash screen) +# centre the icon on build.appx.backgroundColor so they don't come out stretched. + +$ErrorActionPreference = "Stop" +Add-Type -AssemblyName System.Drawing + +$root = Split-Path -Parent $PSScriptRoot +$source = Join-Path $root "resources\icon.png" +$outDir = Join-Path $root "public\appx" + +if (-not (Test-Path $source)) { throw "Source icon not found: $source" } +if (-not (Test-Path $outDir)) { New-Item -ItemType Directory -Path $outDir | Out-Null } + +# Must match build.appx.backgroundColor in package.json +$bg = [System.Drawing.ColorTranslator]::FromHtml("#06172d") + +$assets = @( + @{ Name = "StoreLogo.png"; W = 50; H = 50 }, + @{ Name = "Square44x44Logo.png"; W = 44; H = 44 }, + @{ Name = "Square71x71Logo.png"; W = 71; H = 71 }, + @{ Name = "SmallTile.png"; W = 71; H = 71 }, + @{ Name = "Square150x150Logo.png"; W = 150; H = 150 }, + @{ Name = "Square310x310Logo.png"; W = 310; H = 310 }, + @{ Name = "LargeTile.png"; W = 310; H = 310 }, + @{ Name = "Wide310x150Logo.png"; W = 310; H = 150 }, + @{ Name = "SplashScreen.png"; W = 620; H = 300 }, + @{ Name = "BadgeLogo.png"; W = 24; H = 24 } +) + +$src = [System.Drawing.Image]::FromFile($source) +try { + foreach ($a in $assets) { + $bmp = New-Object System.Drawing.Bitmap($a.W, $a.H) + $g = [System.Drawing.Graphics]::FromImage($bmp) + try { + $g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic + $g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality + $g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality + + if ($a.W -eq $a.H) { + # Square: transparent background, icon fills the tile. + $g.Clear([System.Drawing.Color]::Transparent) + $g.DrawImage($src, 0, 0, $a.W, $a.H) + } else { + # Non-square: solid background, icon centred at 80% of the short side. + $g.Clear($bg) + $side = [int]([Math]::Min($a.W, $a.H) * 0.8) + $x = [int](($a.W - $side) / 2) + $y = [int](($a.H - $side) / 2) + $g.DrawImage($src, $x, $y, $side, $side) + } + + $bmp.Save((Join-Path $outDir $a.Name), [System.Drawing.Imaging.ImageFormat]::Png) + Write-Host (" {0,-24} {1}x{2}" -f $a.Name, $a.W, $a.H) + } finally { + $g.Dispose(); $bmp.Dispose() + } + } +} finally { + $src.Dispose() +} + +Write-Host "`nWrote $($assets.Count) assets to public\appx" -ForegroundColor Green diff --git a/scripts/sign-nym-proxy.ps1 b/scripts/sign-nym-proxy.ps1 new file mode 100644 index 00000000..d77791e3 --- /dev/null +++ b/scripts/sign-nym-proxy.ps1 @@ -0,0 +1,60 @@ +# Signs resources/nym-proxy.exe with Azure Artifact Signing, before electron-builder +# packages it. +# +# electron-builder does not sign extraResources: its signing pass covers the app +# directory, resources/app.asar.unpacked and swiftshader, and nym-proxy.exe sits in +# resources/ instead. It is launched as a child process, so Smart App Control +# inspects it on its own — an unsigned copy would be blocked even with everything +# else signed. Signing the staged binary means electron-builder copies one that is +# already signed. +# +# Values mirror build.win.azureSignOptions in package.json. Credentials come from +# the same AZURE_* environment variables electron-builder uses. +# +# pwsh -ExecutionPolicy Bypass -File scripts/sign-nym-proxy.ps1 +# +# Must run under pwsh (PowerShell 7), not Windows PowerShell 5.1, whose PowerShellGet +# cannot load Install-Module in a CI runner. No extra dependency: electron-builder +# shells out to pwsh for Azure signing too, so it is already required. + +$ErrorActionPreference = "Stop" + +if ($PSVersionTable.PSVersion.Major -lt 6) { + throw "Run this under pwsh (PowerShell 7+), not Windows PowerShell $($PSVersionTable.PSVersion)." +} + +$binary = Join-Path (Split-Path -Parent $PSScriptRoot) "resources\nym-proxy.exe" +if (-not (Test-Path $binary)) { throw "Not staged yet: $binary" } + +foreach ($v in "AZURE_TENANT_ID", "AZURE_CLIENT_ID", "AZURE_CLIENT_SECRET") { + if (-not (Get-Item "env:$v" -ErrorAction SilentlyContinue)) { + throw "$v is not set. Windows builds sign, so the Azure credentials are required." + } +} + +if (-not (Get-Module -ListAvailable -Name TrustedSigning)) { + Write-Host "Installing TrustedSigning module..." + Install-Module -Name TrustedSigning -Repository PSGallery -Scope CurrentUser -Force -AllowClobber +} +Import-Module TrustedSigning + +Write-Host "Signing $binary..." +Invoke-TrustedSigning ` + -Endpoint "https://eus.codesigning.azure.net" ` + -CodeSigningAccountName "zingo-pc-signing" ` + -CertificateProfileName "Zingo-PC" ` + -Files $binary ` + -FileDigest SHA256 ` + -TimestampRfc3161 "http://timestamp.acs.microsoft.com" ` + -TimestampDigest SHA256 + +# Fail loudly rather than shipping an unsigned child process. +$signtool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin\*\x64\signtool.exe" -ErrorAction SilentlyContinue | + Sort-Object FullName | Select-Object -Last 1 +if ($signtool) { + & $signtool.FullName verify /pa /q $binary + if ($LASTEXITCODE -ne 0) { throw "nym-proxy.exe is not signed after Invoke-TrustedSigning" } + Write-Host "Verified." -ForegroundColor Green +} else { + Write-Warning "signtool.exe not found; signature not verified." +} diff --git a/scripts/stage-vcruntime.js b/scripts/stage-vcruntime.js new file mode 100644 index 00000000..69965d7c --- /dev/null +++ b/scripts/stage-vcruntime.js @@ -0,0 +1,96 @@ +// Stages the Visual C++ runtime DLLs into resources/vcruntime/ so electron-builder +// can place them next to Zingo PC.exe via extraFiles. +// +// node scripts/stage-vcruntime.js --arch x64 (or arm64) +// +// Why this exists: native.node and nym-proxy.exe are built with MSVC and import +// VCRUNTIME140.dll. Electron itself does not, so on a machine without the Visual +// C++ Redistributable the window opens normally and then every native call fails +// — require() of the module returns "the specified module could not be found". +// Development machines and CI runners have the runtime because Visual Studio +// installs it, so the failure only ever showed up on clean consumer machines. It +// cost two Microsoft Store certification rounds to find. +// +// The api-ms-win-crt-* imports need nothing: those are the Universal CRT, part of +// Windows 10 and later. +// +// Microsoft permits app-local deployment of these DLLs; this is the deployment +// model their own documentation recommends for exactly this case. + +const fs = require("fs"); +const path = require("path"); + +// vcruntime140_1.dll only exists on x64 (C++ exception handling) and is absent +// from the arm64 redist; copied when present rather than required. +const WANTED = ["vcruntime140.dll", "vcruntime140_1.dll"]; + +const argIndex = process.argv.indexOf("--arch"); +const arch = argIndex !== -1 ? process.argv[argIndex + 1] : null; +if (!arch || !["x64", "arm64"].includes(arch)) { + console.error("stage-vcruntime: pass --arch x64 or --arch arm64"); + process.exit(1); +} + +// The redist ships with the VS Build Tools / VS installation, under a version +// directory that changes with every toolset update, hence the walk. +function findRedistDirs() { + const roots = [process.env["ProgramFiles(x86)"], process.env.ProgramFiles] + .filter(Boolean) + .map((p) => path.join(p, "Microsoft Visual Studio")); + const found = []; + for (const root of roots) { + if (!fs.existsSync(root)) continue; + for (const year of fs.readdirSync(root)) { + for (const edition of safeReaddir(path.join(root, year))) { + const base = path.join(root, year, edition, "VC", "Redist", "MSVC"); + for (const version of safeReaddir(base)) { + const dir = path.join(base, version, arch); + for (const crt of safeReaddir(dir)) { + if (/^Microsoft\.VC\d+\.CRT$/i.test(crt)) found.push(path.join(dir, crt)); + } + } + } + } + } + // Newest toolset last in readdir order is not guaranteed; sort so it is. + return found.sort(); +} + +function safeReaddir(dir) { + try { + return fs.readdirSync(dir); + } catch { + return []; + } +} + +const candidates = findRedistDirs(); +if (candidates.length === 0) { + console.error( + `stage-vcruntime: no Visual C++ ${arch} redistributable found.\n` + + `Install the "MSVC v143 - VS 2022 C++ ${arch === "arm64" ? "ARM64/ARM64EC" : "x64/x86"} build tools"\n` + + `component from the Visual Studio Installer.`, + ); + process.exit(1); +} +const source = candidates[candidates.length - 1]; + +const outDir = path.resolve(__dirname, "../resources/vcruntime"); +fs.rmSync(outDir, { recursive: true, force: true }); +fs.mkdirSync(outDir, { recursive: true }); + +let copied = 0; +for (const name of WANTED) { + const from = path.join(source, name); + if (!fs.existsSync(from)) continue; + fs.copyFileSync(from, path.join(outDir, name)); + console.log(`stage-vcruntime: ${name} (${arch})`); + copied++; +} + +if (copied === 0) { + console.error(`stage-vcruntime: found ${source} but none of ${WANTED.join(", ")} were in it.`); + process.exit(1); +} + +console.log(`stage-vcruntime: staged ${copied} file(s) from ${source}`); diff --git a/src/components/loadingScreen/LoadingScreen.tsx b/src/components/loadingScreen/LoadingScreen.tsx index 42cdd5ca..93398434 100644 --- a/src/components/loadingScreen/LoadingScreen.tsx +++ b/src/components/loadingScreen/LoadingScreen.tsx @@ -77,7 +77,29 @@ class LoadingScreen extends Component { console.error(`Critical Error crypto provider default ${error}`); } - await this.doFirstTimeSetup(); + // A throw in here used to end the launch silently: the promise rejected, + // componentDidMount stopped, and the app sat on this screen for good — no + // message, and menu clicks doing nothing because the renderer never got as + // far as registering their listeners. Whatever failed (the native module, + // the wallet directory, settings) is worth showing: a wrong answer the user + // can report beats a window that does nothing. + try { + await this.doFirstTimeSetup(); + } catch (error) { + console.error(`Critical Error first time setup ${error}`); + closeErrorModal(); + openErrorModal( + "Zingo PC could not start", +
+
Something failed while preparing the wallet, and the app cannot continue.
+
{String(error)}
+
+ Please report this at github.com/zingolabs/zingo-pc/issues, including the message above. +
+
, + ); + return; + } // only if the active wallet exists if (this.state.walletExists) { diff --git a/src/version.ts b/src/version.ts index c780bae4..cadbb73c 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,3 +1,3 @@ -const APP_VERSION = "2.0.24 (170)"; +const APP_VERSION = "2.0.25 (177)"; export default APP_VERSION;