fix(sponsor): anchor 14-day toast cooldown on show, not just on CTA click (#2477)

The sponsor toast re-fired on every fresh browser session. The backend
owns the 14-day cooldown but only persists the anchor (last_shown_at) and
the seen-milestone record inside POST /sponsor-prompt/dismiss, and the hook
only called dismiss from the "View supporters" CTA onClick. A user who saw
the toast but never clicked the CTA persisted no state; the per-tab
sessionStorage guard hid the re-fire within one session, but every new
session re-checked against empty state and re-showed the same milestone.

Record the toast as shown the moment it renders (POST /dismiss right after
showPersistentToast) so display is what arms the cooldown. CTA click stays
optional and just navigates. Frontend-only; backend cooldown logic unchanged.
This commit is contained in:
maziggy
2026-07-07 08:30:58 +02:00
parent 90a7d68d4a
commit 4af9da26f9
5 changed files with 125 additions and 6 deletions
+1
View File
@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
## [0.2.5b2] - Unreleased
### Fixed
- **Sponsor toast ignored its 14-day cooldown and re-fired on every fresh session (#2477, reporter @pchulpjoost)** — The in-app supporter toast ("You've completed X prints with Bambuddy…") reappeared on essentially every visit instead of respecting the documented 14-day cross-family cooldown. **Root cause:** the backend owns the cooldown, but it only persists the cooldown anchor (`last_shown_at`) and the seen-milestone record inside `POST /sponsor-prompt/dismiss` — and the frontend hook only called `dismiss` from the "View supporters" CTA's `onClick`. A user who *saw* the toast but never clicked the CTA persisted no state at all. The per-tab `sessionStorage` guard hid the re-fire within a single browser session, but every fresh session (new tab, reopened browser, or another device — the reporter confirmed "Other OS/Browser has the same problem") re-ran the check against still-empty backend state and showed the same milestone again. To a user opening Bambuddy to check on a finished print, that looked like "every print." **Fix:** the hook now records the toast as shown the moment it renders — it POSTs `/dismiss` (whose documented job is exactly "anchor the 14-day cooldown and record the milestone as shown") immediately after `showPersistentToast`, instead of waiting for a CTA click. Being *displayed* is what arms the cooldown; clicking the CTA stays optional and just navigates to the sponsors page. `frontend/src/hooks/useSponsorPrompt.ts` only; backend cooldown logic unchanged.
- **Windows installer: fresh install fails to start — "connection refused", nothing listening on port 8000 (#2474, reporter @fangme)** — On a clean Windows 10 machine, installing the `.exe` left the Bambuddy service showing "running" in services.msc but the dashboard refused every connection (localhost, IP, and hostname), and `netstat -ano` showed nothing on :8000. **Root cause — bottom of a 100k-line `service-stderr.log` traceback:** `init_db()` → SQLAlchemy async engine → `greenlet_spawn` → `ValueError: the greenlet library is required to use this function. DLL load failed while importing _greenlet: The specified module could not be found.` The 100k repeated `merged_lifespan` frames above it are just FastAPI's nested-lifespan stack unwinding — noise. The real failure: greenlet's `_greenlet.pyd` couldn't load, SQLAlchemy's async engine couldn't start, the FastAPI lifespan raised, and uvicorn **never bound the port** — so the supervised process stays up (NSSM sees it running) while the app itself has crashed on startup. **Why greenlet specifically:** the Win32 error 126 ("The specified module could not be found") on a `.pyd` that pip installed successfully means the module is present but a *dependency DLL* is missing. greenlet's extension is **C++** and needs `vcruntime140_1.dll` (table-based exception handling); the python.org **embeddable** distribution the installer bundles ships `vcruntime140.dll` but **not** `vcruntime140_1.dll`. `python313.dll` is pure C and only needs the former, which is exactly why python.exe starts and runs all the way to `init_db` before greenlet is the first thing to need the missing C++ runtime. On machines that already have the VC++ 2015-2022 redistributable installed (the maintainer's box, CI runners) `vcruntime140_1.dll` is in System32 and everything works — masking the bug until a truly fresh Win10 box hit it. **Fix:** the installer build now ships the C++ runtime app-locally — `vcruntime140_1.dll` and `msvcp140.dll` are staged next to `python.exe` (where `vcruntime140.dll` already lives), sourced from a vendored copy if present or the build runner's System32 otherwise, failing the build loudly if neither has them. `installers/windows/build.py` only; the Inno Setup `[Files]` step already copies `staging\python\*` recursively so the new DLLs are packaged automatically. **Workaround for the current build:** install the "Microsoft Visual C++ 2015-2022 Redistributable (x64)" from Microsoft, then restart the Bambuddy service — that puts `vcruntime140_1.dll` in System32 where the embedded Python finds it.
- **Virtual Printer "bind interface" dropdown is empty on macOS** — Adding a Virtual Printer on macOS showed no interfaces to bind to. `get_network_interfaces()` only routed Windows to the cross-platform psutil path; macOS fell into the Linux branch, which uses the Linux-only `SIOCGIFADDR`/`SIOCGIFNETMASK` ioctls (`0x8915`/`0x891B`). macOS/BSD have `fcntl` but different ioctl numbers and sockaddr layout, so every per-interface ioctl raised `OSError` and the function silently returned an empty list (and `get_all_interface_ips()`, which has no `ip` binary to fall back to on macOS, inherited the empty result). Interface enumeration now routes **all** non-Linux platforms (macOS, BSD, Windows) through psutil, which returns each interface's name + IPv4 + netmask and filters loopback/link-local/down adapters while keeping real LAN and VPN (utun/Tailscale) interfaces bindable. Linux keeps its existing ioctl path unchanged.
- **Native install script fails on macOS with Homebrew/venv permission errors** — On macOS the installer mixed root-only steps (defaulting to `/opt/bambuddy`, which nudged users into `sudo ./install.sh`) with steps that must **not** run as root: `brew install` hard-refuses to run as root (aborting the script mid-way), and any venv / `node_modules` created by root can't be managed by the launchd agent (which runs as the user), producing permission errors on `pip`/`npm`. The macOS path is now fully rootless: the script refuses to run under `sudo` on macOS with an actionable message, defaults the install directory to `~/bambuddy` (user-owned, no `/opt` write), and the download/venv/frontend/env/directory steps no longer shell out to `sudo` on macOS. A custom `--path` under a root-owned parent still works — the script elevates only to create+chown that one directory to the user, then continues rootless. Linux behaviour (service user + systemd) is unchanged.
@@ -0,0 +1,114 @@
/**
* Tests for the in-app sponsor-toast hook (#2477 regression guard).
*
* The bug: the 14-day cooldown is backend-owned, but the anchor is only
* persisted by POST /sponsor-prompt/dismiss — and the hook used to call
* dismiss ONLY from the "View supporters" CTA's onClick. A user who saw the
* toast but never clicked it persisted no state, so the toast re-fired on
* every fresh browser session. The fix records-on-show: the hook POSTs
* /dismiss the moment it renders the toast. These tests pin that contract so
* it can't silently regress back to click-only anchoring.
*/
import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
import { renderHook, waitFor, cleanup } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
import { useSponsorPrompt } from '../../hooks/useSponsorPrompt';
// The hook only needs `loading` from auth and `showPersistentToast` from the
// toast context — mock both so the test doesn't drag in the real providers
// (auth bootstrap, toast portal). The real sponsorPromptApi still runs and
// hits MSW, which is exactly what we want to assert on.
vi.mock('../../contexts/AuthContext', () => ({
useAuth: () => ({ loading: false }),
}));
const showPersistentToast = vi.fn();
vi.mock('../../contexts/ToastContext', () => ({
useToast: () => ({ showPersistentToast }),
}));
beforeEach(() => {
showPersistentToast.mockClear();
sessionStorage.clear();
});
afterEach(() => {
cleanup();
});
describe('useSponsorPrompt', () => {
it('records the toast as shown (POSTs /dismiss) as soon as it renders, without a CTA click', async () => {
const dismissed: string[] = [];
server.use(
http.get('/api/v1/sponsor-prompt/check', () =>
HttpResponse.json({
show: true,
milestone: 'prints-500',
family: 'prints',
threshold: 500,
payload: { count: 512 },
}),
),
http.post('/api/v1/sponsor-prompt/dismiss', async ({ request }) => {
const body = (await request.json()) as { milestone: string };
dismissed.push(body.milestone);
return new HttpResponse(null, { status: 204 });
}),
);
renderHook(() => useSponsorPrompt('EUR'));
// The toast is shown...
await waitFor(() => expect(showPersistentToast).toHaveBeenCalledTimes(1));
// ...and the cooldown is anchored on show, not on any CTA interaction.
await waitFor(() => expect(dismissed).toEqual(['prints-500']));
// The CTA is present for navigation but carries no onClick side effect —
// anchoring no longer depends on the user clicking through.
const options = showPersistentToast.mock.calls[0][3];
expect(options.action.href).toContain('from=app-toast-prints-500');
expect(options.action.onClick).toBeUndefined();
});
it('does not show a toast or anchor the cooldown when the check returns show:false', async () => {
let dismissCalls = 0;
server.use(
http.get('/api/v1/sponsor-prompt/check', () => HttpResponse.json({ show: false })),
http.post('/api/v1/sponsor-prompt/dismiss', () => {
dismissCalls += 1;
return new HttpResponse(null, { status: 204 });
}),
);
renderHook(() => useSponsorPrompt('EUR'));
// Give the async effect a chance to run before asserting the negatives.
await waitFor(() => expect(sessionStorage.getItem('sponsorPromptShown')).toBe('1'));
expect(showPersistentToast).not.toHaveBeenCalled();
expect(dismissCalls).toBe(0);
});
it('does not re-check within the same browser session (sessionStorage guard)', async () => {
let checkCalls = 0;
server.use(
http.get('/api/v1/sponsor-prompt/check', () => {
checkCalls += 1;
return HttpResponse.json({ show: false });
}),
http.post('/api/v1/sponsor-prompt/dismiss', () => new HttpResponse(null, { status: 204 })),
);
const first = renderHook(() => useSponsorPrompt('EUR'));
await waitFor(() => expect(checkCalls).toBe(1));
first.unmount();
// A second mount in the same session (e.g. a route change that remounts
// Layout) must not re-run the check — that per-tab guard is what keeps the
// toast from flashing repeatedly while a session is open.
renderHook(() => useSponsorPrompt('EUR'));
await new Promise((r) => setTimeout(r, 20));
expect(checkCalls).toBe(1);
});
});
+8 -4
View File
@@ -5,7 +5,11 @@
* sponsors page with a Matomo-trackable `?from=app-toast-{milestone}` param.
*
* The 14-day cooldown + already-seen-milestone deduplication is owned by the
* backend service — the hook just trusts the check endpoint's verdict.
* backend service. The hook trusts the check endpoint's verdict, and the moment
* it actually renders the toast it POSTs /dismiss to anchor the cooldown — being
* *shown* is what arms the 14-day gate, not the user clicking the CTA. (Clicking
* is optional; without this record-on-show, an ignored toast would never persist
* any state and would re-fire on every fresh browser session.)
*/
import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
@@ -77,11 +81,11 @@ export function useSponsorPrompt(currencyCode = 'EUR') {
action: {
label: t('sponsors.viewSupporters', 'View supporters'),
href: `https://bambuddy.cool/sponsors.html?from=app-toast-${result.milestone}`,
onClick: () => {
void sponsorPromptApi.dismiss(result.milestone!);
},
},
});
// Anchor the 14-day cooldown as soon as the toast is on screen, so an
// ignored toast doesn't re-fire on the next browser session.
void sponsorPromptApi.dismiss(result.milestone);
} catch {
// Network / 401 — silently skip; next session retries.
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -26,7 +26,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-DqJZ0C8s.js"></script>
<script type="module" crossorigin src="/assets/index-Bp9OAjMR.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BxVhuRti.css">
</head>
<body>