mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-07 23:01:06 +02:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user