Skip to content

Commit 214087d

Browse files
sodiumsunclaude
andcommitted
fix: four sign-in/trial bugs from fresh-install testing
1. New email skipped onboarding: sign-out cleared token/plan/email but not onboarded/onboarded_plan, so a new email inherited the prior account's onboarded=True. Sign-out now clears the per-account onboarding + trial state. 2. Auto-start trial silently missed: the sign-in enroll is a one-shot with no log/retry; on a brand-new account it races (account not queryable yet) and left the user on the generic trial. Added a self-healing net in the daemon's /v1/me poll (fires for a signed-in Power build not-yet-Power, trial unused; server-idempotent) + logging on the sign-in path so it's never silent again. 3. 'Start trial' looked dead on failure: the handler only notified on 'already used', not on a network error. Now notifies on failure too. 4. 'Manage on heard.dev' opened heard.dev/account — not a route (blank page). Points at /dashboard, which exists. Full suite (841) + full ruff green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019GTBZjBGurqsHUHsjaU1gA
1 parent edd7f4f commit 214087d

4 files changed

Lines changed: 166 additions & 3 deletions

File tree

heard/daemon.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1215,6 +1215,62 @@ def _stop_audio_monitor(self) -> None:
12151215
pass
12161216
self._audio_monitor = None
12171217

1218+
def _maybe_autostart_power_trial(self, me: dict) -> None:
1219+
"""Self-healing Power-trial enrollment, run on every /v1/me poll.
1220+
1221+
The sign-in-time enroll (url_scheme._maybe_start_power_trial) is a
1222+
one-shot that can miss — a brand-new account isn't queryable the instant
1223+
it's created, or a transient network blip drops the call — and it leaves
1224+
the user stuck on the generic trial. This backstops it: if this is the
1225+
Power build, they're signed in, they aren't already Power, and they
1226+
haven't used their one trial, enroll them. Server-idempotent (no-ops if
1227+
already power / trial used), so calling it every poll is safe."""
1228+
try:
1229+
if not (self.cfg.get("voice_service_cmd") or "").strip():
1230+
return # not the Power build
1231+
token = (self.cfg.get("heard_token") or "").strip()
1232+
if not token:
1233+
return
1234+
if (me.get("plan") or "").strip().lower() == "power":
1235+
return # already Power (paid or trialing)
1236+
if me.get("power_trial_used"):
1237+
return # used their one trial — don't retry forever
1238+
import json as _json
1239+
import ssl as _ssl
1240+
import urllib.request as _urlreq
1241+
1242+
try:
1243+
import certifi # type: ignore
1244+
1245+
ctx = _ssl.create_default_context(cafile=certifi.where())
1246+
except ImportError:
1247+
ctx = _ssl.create_default_context()
1248+
base_url = (
1249+
self.cfg.get("heard_api_base") or "https://api.heard.dev"
1250+
).rstrip("/")
1251+
req = _urlreq.Request(
1252+
f"{base_url}/v1/power/trial/start",
1253+
method="POST",
1254+
headers={
1255+
"Authorization": f"Bearer {token}",
1256+
"User-Agent": "Heard-daemon/1.0",
1257+
},
1258+
)
1259+
with _urlreq.urlopen(req, timeout=8.0, context=ctx) as resp:
1260+
data = _json.loads(resp.read().decode("utf-8") or "{}")
1261+
if data.get("plan") == "power":
1262+
config.set_value("heard_plan", "power")
1263+
exp = int(data.get("trial_expires_at") or 0)
1264+
if exp:
1265+
config.set_value("heard_trial_expires_at", exp)
1266+
config.set_value("power_trial_used", True)
1267+
_log("power_trial_autostarted", via="daemon_poll")
1268+
self._reload_config()
1269+
else:
1270+
_log("power_trial_autostart_noop", reason=str(data.get("reason") or "not_power"))
1271+
except Exception as e:
1272+
_log("power_trial_autostart_error", err=str(e))
1273+
12181274
def _sync_plan_from_me(self, me: dict) -> None:
12191275
"""Persist plan + trial-expiry from a /v1/me snapshot when they've
12201276
drifted from local config, then reload so the change takes effect.
@@ -3816,6 +3872,7 @@ def _refresh_account_usage(self) -> None:
38163872
self._account_usage = data
38173873
self._account_usage_at = _time.time()
38183874
self._sync_plan_from_me(data)
3875+
self._maybe_autostart_power_trial(data)
38193876
self._maybe_announce_friend_joined(data)
38203877
except (_urlerr.HTTPError, _urlerr.URLError, TimeoutError, OSError, ValueError):
38213878
# Stay quiet; menu bar shows the previous value (or nothing).

heard/home_window.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,13 @@ def _act_signout(self, body):
625625
config.set_value(key, "")
626626
config.set_value("heard_trial_expires_at", 0)
627627
config.set_value("byok_enabled", False)
628+
# Onboarding + trial state are PER-ACCOUNT. Clearing them on sign-
629+
# out means the next email always re-onboards (new users were
630+
# skipping the wizard because the previous account's onboarded=True
631+
# persisted) and can start its own trial.
632+
config.set_value("onboarded", False)
633+
config.set_value("onboarded_plan", "")
634+
config.set_value("power_trial_used", False)
628635
_reload_daemon()
629636
self._push_state()
630637
except Exception as e:
@@ -661,10 +668,11 @@ def _act_upgrade_power(self, body):
661668

662669
def _act_manage_account(self, body):
663670
# Open the browser to manage plan / payment / email on heard.dev.
671+
# /account is not a route (blank page) — the dashboard is /dashboard.
664672
try:
665673
import webbrowser
666674

667-
webbrowser.open("https://heard.dev/account")
675+
webbrowser.open("https://heard.dev/dashboard")
668676
except Exception as e:
669677
_log_bridge_error("manage_account", e)
670678

@@ -830,7 +838,20 @@ def _work():
830838
)
831839
AppHelper.callAfter(self._push_state)
832840
except Exception as e:
841+
# Don't fail silently — a dead-looking button is worse than an
842+
# error. Tell the user so it's clear the click registered.
833843
_log_bridge_error("start_power_trial", e)
844+
try:
845+
from heard import notify
846+
847+
AppHelper.callAfter(
848+
notify.notify,
849+
"Heard",
850+
"Couldn't start your Power trial - check your connection and try again.",
851+
"power_trial",
852+
)
853+
except Exception:
854+
pass
834855

835856
threading.Thread(target=_work, daemon=True).start()
836857

heard/url_scheme.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,14 @@ def _maybe_start_power_trial(token: str) -> None:
102102
exp = int(data.get("trial_expires_at") or 0)
103103
if exp:
104104
config.set_value("heard_trial_expires_at", exp)
105-
except Exception:
106-
pass
105+
print(f"[power_trial] sign-in autostart OK (exp={exp})")
106+
else:
107+
# Not power — usually a brand-new-account race (account not queryable
108+
# yet). The daemon's /v1/me poll retries this within ~5 min, so it
109+
# self-heals. Logged so it's never a silent mystery again.
110+
print(f"[power_trial] sign-in autostart no-op: {data.get('reason') or data}")
111+
except Exception as e:
112+
print(f"[power_trial] sign-in autostart failed: {e}")
107113

108114

109115
def _refresh_byok_enabled(token: str) -> None:

tests/test_daemon_power_trial.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"""The daemon's self-healing Power-trial enrollment net.
2+
3+
Backstops the sign-in-time enroll, which can miss on a brand-new account (not
4+
queryable the instant it's created) or a transient blip. Runs on every /v1/me
5+
poll, so it must be correctly gated: fire only for a signed-in Power build that
6+
isn't already Power and hasn't used its one trial.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import json
12+
from unittest.mock import MagicMock, patch
13+
14+
from heard.daemon import Daemon
15+
16+
17+
def _stub(cfg):
18+
"""A minimal object exposing just what the unbound method touches."""
19+
s = Daemon.__new__(Daemon)
20+
s.cfg = cfg
21+
s._reload_config = lambda: None
22+
return s
23+
24+
25+
POWER_CFG = {"voice_service_cmd": "{python} -m heard_power serve", "heard_token": "tok"}
26+
27+
28+
def _run(cfg, me):
29+
"""Call the net with urlopen mocked; return the mock so we can assert calls."""
30+
resp = MagicMock()
31+
resp.read.return_value = json.dumps(
32+
{"plan": "power", "trial_expires_at": 111}
33+
).encode()
34+
resp.__enter__.return_value = resp
35+
with patch("urllib.request.urlopen", return_value=resp) as urlopen, patch(
36+
"heard.daemon.config.set_value"
37+
) as setv:
38+
Daemon._maybe_autostart_power_trial(_stub(dict(cfg)), me)
39+
return urlopen, setv
40+
41+
42+
def test_fires_for_signed_in_power_build_not_yet_power():
43+
urlopen, setv = _run(POWER_CFG, {"plan": "free", "power_trial_used": False})
44+
urlopen.assert_called_once()
45+
# persisted the trial locally
46+
keys = {c.args[0] for c in setv.call_args_list}
47+
assert "heard_plan" in keys and "power_trial_used" in keys
48+
49+
50+
def test_skips_on_oss_build_no_voice_service():
51+
urlopen, _ = _run({"voice_service_cmd": "", "heard_token": "tok"}, {"plan": "free"})
52+
urlopen.assert_not_called()
53+
54+
55+
def test_skips_when_not_signed_in():
56+
urlopen, _ = _run(
57+
{"voice_service_cmd": "cmd", "heard_token": ""}, {"plan": "free"}
58+
)
59+
urlopen.assert_not_called()
60+
61+
62+
def test_skips_when_already_power():
63+
urlopen, _ = _run(POWER_CFG, {"plan": "power"})
64+
urlopen.assert_not_called()
65+
66+
67+
def test_skips_when_trial_already_used():
68+
urlopen, _ = _run(POWER_CFG, {"plan": "free", "power_trial_used": True})
69+
urlopen.assert_not_called()
70+
71+
72+
def test_network_error_is_swallowed():
73+
with patch("urllib.request.urlopen", side_effect=OSError("down")), patch(
74+
"heard.daemon.config.set_value"
75+
):
76+
# must not raise
77+
Daemon._maybe_autostart_power_trial(
78+
_stub(dict(POWER_CFG)), {"plan": "free", "power_trial_used": False}
79+
)

0 commit comments

Comments
 (0)