This commit is contained in:
Bastian Wagner
2026-08-15 20:31:57 +02:00
parent 85b0d861b4
commit 7c9e19ba0b
8 changed files with 146 additions and 5 deletions

View File

@@ -20,6 +20,10 @@ class MyWhooshAuthError(MyWhooshError):
pass
class MyWhooshDeviceConflictError(MyWhooshAuthError):
pass
class MyWhooshTransientError(MyWhooshError):
pass
@@ -52,7 +56,7 @@ class MyWhooshClient:
"Platform": "Android",
"Action": 1001,
"CorrelationId": str(uuid.uuid4()),
"DeviceId": str(uuid.uuid4()),
"DeviceId": self.token_store.get_or_create_device_id(),
"Authorization": "",
}
try:
@@ -70,7 +74,10 @@ class MyWhooshClient:
if not isinstance(body, dict):
raise MyWhooshIntegrationError("MyWhoosh login response is not a JSON object")
if body.get("Success") is not True or not body.get("AccessToken"):
raise MyWhooshAuthError(str(body.get("Message") or "MyWhoosh login failed"))
message = str(body.get("Message") or "MyWhoosh login failed")
if "another device" in message.lower():
raise MyWhooshDeviceConflictError(message)
raise MyWhooshAuthError(message)
self.token = MyWhooshToken(
access_token=str(body["AccessToken"]),
refresh_token=str(body["RefreshToken"]) if body.get("RefreshToken") else None,

View File

@@ -1,5 +1,6 @@
import json
import os
import uuid
from pathlib import Path
from app.mywhoosh.models import MyWhooshToken
@@ -8,6 +9,7 @@ from app.mywhoosh.models import MyWhooshToken
class MyWhooshTokenStore:
def __init__(self, path: Path) -> None:
self.path = path
self.device_id_path = path.with_name("device_id")
def load(self) -> MyWhooshToken | None:
try:
@@ -40,3 +42,16 @@ class MyWhooshTokenStore:
def clear(self) -> None:
self.path.unlink(missing_ok=True)
def get_or_create_device_id(self) -> str:
try:
existing = self.device_id_path.read_text("utf-8").strip()
except (FileNotFoundError, OSError):
existing = ""
if existing:
return existing
device_id = str(uuid.uuid4())
self.device_id_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
self.device_id_path.write_text(device_id, "utf-8")
os.chmod(self.device_id_path, 0o600)
return device_id

View File

@@ -14,7 +14,12 @@ from app.garmin.uploader import (
GarminTransientError,
GarminUploadBlocked,
)
from app.mywhoosh.client import MyWhooshAuthError, MyWhooshIntegrationError, MyWhooshTransientError
from app.mywhoosh.client import (
MyWhooshAuthError,
MyWhooshDeviceConflictError,
MyWhooshIntegrationError,
MyWhooshTransientError,
)
from app.mywhoosh.tokenstore import MyWhooshTokenStore
from app.security.credentials import CredentialCipher
from app.sync.states import SyncOutcome
@@ -30,7 +35,9 @@ class SyncAlreadyRunning(RuntimeError):
# succeeding (reaching the end of the activity loop with stop_user_run still
# False already proves MyWhoosh is working again -- every MyWhoosh-related
# exception branch that could fire also sets stop_user_run=True).
_MYWHOOSH_ACTION_REASONS = frozenset({"mywhoosh_auth_required", "mywhoosh_integration_changed"})
_MYWHOOSH_ACTION_REASONS = frozenset(
{"mywhoosh_auth_required", "mywhoosh_integration_changed", "mywhoosh_device_conflict"}
)
# action_reason values that can only be resolved by actual, this-run evidence
# of a successful Garmin import -- the mere absence of a Garmin exception does
@@ -136,6 +143,14 @@ class SyncManager:
remote_activities = []
stop_user_run = True
summary_error = str(exc)
except MyWhooshDeviceConflictError as exc:
user.health_state = HealthState.ACTION_REQUIRED
user.mywhoosh_state = "device_conflict"
user.action_reason = "mywhoosh_device_conflict"
session.commit()
remote_activities = []
stop_user_run = True
summary_error = str(exc)
except MyWhooshAuthError as exc:
user.health_state = HealthState.ACTION_REQUIRED
user.mywhoosh_state = "auth_required"
@@ -245,6 +260,12 @@ class SyncManager:
user.mywhoosh_state = "error"
activity_repo.mark_failed(activity.id, str(exc), retryable=True)
failed_count += 1
except MyWhooshDeviceConflictError as exc:
user.health_state = HealthState.ACTION_REQUIRED
user.mywhoosh_state = "device_conflict"
user.action_reason = "mywhoosh_device_conflict"
stop_user_run = True
summary_error = str(exc)
except MyWhooshAuthError as exc:
user.health_state = HealthState.ACTION_REQUIRED
user.mywhoosh_state = "auth_required"

View File

@@ -26,6 +26,8 @@
</div>
{% if row.action_reason == "garmin_mfa_required" %}
<span class="action-required">Garmin MFA required &mdash; <a href="/users/{{ row.id }}">resolve</a></span>
{% elif row.action_reason == "mywhoosh_device_conflict" %}
<span class="action-required">MyWhoosh account logged in on another device &mdash; log out there, then <a href="/users/{{ row.id }}">retry</a></span>
{% endif %}
</div>
<div class="user-actions">

View File

@@ -39,6 +39,11 @@
<div class="card">
{% include "fragments/mfa_form.html" %}
</div>
{% elif user.action_reason == "mywhoosh_device_conflict" %}
<h2>MyWhoosh device conflict</h2>
<div class="card">
<p>MyWhoosh reports this account is already logged in on another device. Log out of MyWhoosh there (app or website), then retry the sync.</p>
</div>
{% endif %}
<h2>Recent sync runs</h2>