mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-08-11 00:30:12 -04:00
feat(orca-cloud): integrate Orca Cloud profile sync across UI, slicer and SpoolBuddy
Reads, lists, and slices with profiles from a user's Orca Cloud account (OrcaSlicer 2.4.0-alpha's Supabase-backed sync) alongside the existing Bambu Cloud integration. Four sign-in providers (Google / Apple / GitHub / email+password); password defaults. Paste-flow PKCE because Orca's Supabase project only allowlists localhost redirect_to — open feature request at OrcaSlicer/OrcaSlicer#14028. Surfaces: - Profiles tab: new "Orca Cloud" tab next to "Bambu Cloud" with the same rich layout (search + 5 filter dropdowns + 3-column grouped grid + read-only detail modal) - SliceModal: 4-tier preset picker (orca_cloud > local > bambu cloud > standard); separate status banner per cloud; metadata-aware pre-pick scores Orca filaments above local (Orca's sync_pull returns full content inline so filament_type / filament_colour come for free, no per-setting fetch rate-limit dance) - ConfigureAmsSlotModal: orca_cloud as a new preset source (prefixed orca_<UUID> to match local_/builtin_); generic Bambu filament-ID derivation from parsed material (printer firmware can't grok Orca UUIDs); slot mapping persists preset_source='orca_cloud' - SpoolForm / SpoolBuddyWriteTagPage: Orca filaments merge into the cloud preset list via Promise.allSettled (OrcaProfileMeta is structurally identical to SlicerSetting) Backend: - services/orca_cloud.py: OrcaCloudService with PKCE / token exchange / single-use refresh rotation / get_user_info / list_profiles via the bare /sync/pull bootstrap path - routes/orca_cloud.py: 7 endpoints (auth/start, auth/finish, auth/password, status, logout, profiles, profiles/{id}); router-level _cloud_api_key_gate + per-route cloud_caller() so API-keyed callers (SpoolBuddy kiosk) properly resolve their owner User; just-in-time refresh with atomic persist-before-API-call - routes/slicer_presets.py: _fetch_orca_cloud_presets mirrors the Bambu Cloud fetcher (status vocabulary, 5min cache, permission shortcut); _dedupe_by_name extended to 4 tiers; UnifiedPresetsResponse gains orca_cloud + orca_cloud_status - services/preset_resolver.py: PresetRef.source extended with "orca_cloud"; _resolve_orca_cloud walks list + filters - 8 columns on users table for tokens (5 persistent) + transient PKCE handshake state with 10-min TTL (3); dialect-branched DATETIME / TIMESTAMP; auth-disabled mode falls back to Settings table - orca_cloud:auth permission folded into can_access_cloud API-key scope (same trust dimension)
This commit is contained in:
parent
764eb58540
commit
18d534c945
45 changed files with 4891 additions and 630 deletions
File diff suppressed because one or more lines are too long
|
|
@ -260,6 +260,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
|
|||
- MQTT publishing for Home Assistant, Node-RED, etc.
|
||||
- **Prometheus metrics** - Export printer telemetry for Grafana dashboards
|
||||
- Bambu Cloud profile management
|
||||
- **Orca Cloud profile sync** — read your OrcaSlicer 2.4.0+ cloud-synced profiles directly in Bambuddy, usable for slicing alongside Bambu Cloud / local / standard presets. Four sign-in providers (Google / Apple / GitHub / email+password)
|
||||
- **Local Profiles** - Import OrcaSlicer presets (`.orca_filament`, `.bbscfg`, `.bbsflmt`, `.zip`, `.json`) without Bambu Cloud
|
||||
- K-profiles (pressure advance)
|
||||
- **GitHub backup** - Schedule automatic backups of cloud profiles, k profiles and settings to GitHub
|
||||
|
|
@ -267,7 +268,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
|
|||
- External sidebar links
|
||||
- Webhooks & API keys
|
||||
- Per-user ownership — each key acts on behalf of its creator
|
||||
- Optional **cloud-access scope** — opt in to let an API key read its owner's Bambu Cloud presets / filament catalogue / device list (off by default)
|
||||
- Optional **cloud-access scope** — opt in to let an API key read its owner's Bambu Cloud + Orca Cloud presets / filament catalogue / device list (off by default)
|
||||
- Interactive API browser with live testing
|
||||
|
||||
### 🖨️ Virtual Printer & Remote Printing
|
||||
|
|
|
|||
666
backend/app/api/routes/orca_cloud.py
Normal file
666
backend/app/api/routes/orca_cloud.py
Normal file
|
|
@ -0,0 +1,666 @@
|
|||
"""
|
||||
Orca Cloud API Routes
|
||||
|
||||
PKCE-based connect/disconnect + profile sync endpoints for the
|
||||
Orca Cloud (Supabase) profile-sync surface.
|
||||
|
||||
Auth shape (see :mod:`backend.app.services.orca_cloud` for the deep dive):
|
||||
|
||||
POST /orca-cloud/auth/start
|
||||
Generate PKCE + state, persist them (TTL 10 min), return the auth URL.
|
||||
POST /orca-cloud/auth/finish
|
||||
Parse the pasted callback URL, validate state for CSRF, exchange the
|
||||
code for tokens, persist them atomically.
|
||||
GET /orca-cloud/status
|
||||
Connected/disconnected + email + user_id.
|
||||
POST /orca-cloud/logout
|
||||
Clear stored tokens (no Supabase-side revocation — token still
|
||||
survives until its 1h expiry, but Bambuddy has no way to use it).
|
||||
GET /orca-cloud/profiles
|
||||
Paginated list of the user's Orca Cloud profiles. JIT-refreshes the
|
||||
access token if it's within the 5-min leeway of expiry.
|
||||
GET /orca-cloud/profiles/{id}
|
||||
Single profile's full content.
|
||||
|
||||
Storage shape mirrors the Bambu Cloud surface: per-user columns on
|
||||
``users`` when auth is enabled, fallback to global ``settings`` keys when
|
||||
auth is disabled. The transient PKCE state (verifier, state, pending_at)
|
||||
is stored alongside the tokens — same dual-mode pattern.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.api.routes.cloud import _cloud_api_key_gate, cloud_caller
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.models.user import User
|
||||
from backend.app.schemas.orca_cloud import (
|
||||
OrcaAuthFinishRequest,
|
||||
OrcaAuthPasswordRequest,
|
||||
OrcaAuthStartRequest,
|
||||
OrcaAuthStartResponse,
|
||||
OrcaAuthStatusResponse,
|
||||
OrcaProfileDetail,
|
||||
OrcaProfileListResponse,
|
||||
OrcaProfileMeta,
|
||||
)
|
||||
from backend.app.services.orca_cloud import (
|
||||
PENDING_PKCE_TTL,
|
||||
OrcaCloudAuthError,
|
||||
OrcaCloudError,
|
||||
OrcaCloudService,
|
||||
build_authorize_url,
|
||||
generate_pkce,
|
||||
parse_callback_url,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Router-level dependency: enforce the same API-key cloud-access fence as the
|
||||
# Bambu Cloud router (rejects ownerless legacy keys, requires the
|
||||
# ``can_access_cloud`` scope, stashes the owner on ``request.state`` so
|
||||
# per-route deps can resolve it as the effective ``current_user``).
|
||||
# Without this gate the kiosk's API-keyed requests sail past with
|
||||
# ``current_user=None`` → ``_build_authenticated_service`` falls back to
|
||||
# the global Settings table → no Orca token → 401, no presets surfaced.
|
||||
# Bambu Cloud works in the same kiosk because its router has this gate.
|
||||
router = APIRouter(prefix="/orca-cloud", tags=["orca-cloud"], dependencies=[Depends(_cloud_api_key_gate)])
|
||||
|
||||
# Orca ``content.type`` values map onto Bambu Cloud's preset type vocabulary.
|
||||
# Empirically (confirmed against a live account on 2026-06-04): Orca uses
|
||||
# ``"printer"`` / ``"print"`` / ``"filament"`` — NOT the BambuStudio
|
||||
# ``"machine"`` / ``"process"`` / ``"filament"`` triplet that lives elsewhere
|
||||
# in the OrcaSlicer source. The aliases keep us forward-compatible if Orca
|
||||
# ever flips back to the older naming.
|
||||
_ORCA_TYPE_TO_BAMBU = {
|
||||
"filament": "filament",
|
||||
"printer": "printer",
|
||||
"machine": "printer", # alias for the BambuStudio-style naming
|
||||
"print": "process",
|
||||
"process": "process", # alias for the BambuStudio-style naming
|
||||
}
|
||||
|
||||
|
||||
def _orca_to_setting(orca_profile: dict) -> OrcaProfileMeta | None:
|
||||
"""Normalize one Orca ``ProfileUpsert`` (``{id, name, content, ...}``)
|
||||
into a ``SlicerSetting``-shaped row. Returns ``None`` if the content
|
||||
isn't a dict or the type isn't one we render."""
|
||||
content = orca_profile.get("content") or {}
|
||||
if not isinstance(content, dict):
|
||||
return None
|
||||
bambu_type = _ORCA_TYPE_TO_BAMBU.get(str(content.get("type", "")))
|
||||
if bambu_type is None:
|
||||
return None
|
||||
pid = orca_profile.get("id")
|
||||
if pid is None:
|
||||
return None
|
||||
updated = orca_profile.get("updated_time")
|
||||
return OrcaProfileMeta(
|
||||
setting_id=str(pid),
|
||||
name=str(orca_profile.get("name") or pid),
|
||||
type=bambu_type,
|
||||
version=_str_or_none(content.get("version")),
|
||||
# ``from`` distinguishes ``system`` (bundled) from ``User`` (custom),
|
||||
# same field the Bambu source-of-truth uses for that distinction.
|
||||
user_id=_str_or_none(content.get("user_id") or content.get("from")),
|
||||
updated_time=str(updated) if updated is not None else None,
|
||||
# Every profile that lives in the user's Orca Cloud account is by
|
||||
# definition user-authored; bundled defaults aren't synced.
|
||||
is_custom=True,
|
||||
)
|
||||
|
||||
|
||||
def _str_or_none(value: object) -> str | None:
|
||||
"""Cast non-empty scalars to ``str``; pass ``None`` and empty values
|
||||
through unchanged. Used to keep the response shape consistent when
|
||||
Orca's source data has heterogenous typing for the same field."""
|
||||
if value is None:
|
||||
return None
|
||||
s = str(value)
|
||||
return s if s else None
|
||||
|
||||
|
||||
# Settings table keys for the auth-disabled fallback. Mirrors the Bambu Cloud
|
||||
# pattern (``bambu_cloud_token`` etc.) so administrators inspecting the
|
||||
# settings table see a consistent prefix.
|
||||
_SETTINGS_KEYS = {
|
||||
"token": "orca_cloud_token",
|
||||
"refresh_token": "orca_cloud_refresh_token",
|
||||
"expires_at": "orca_cloud_expires_at", # ISO 8601 UTC string
|
||||
"email": "orca_cloud_email",
|
||||
"user_id": "orca_cloud_user_id",
|
||||
"pending_verifier": "orca_cloud_pending_verifier",
|
||||
"pending_state": "orca_cloud_pending_state",
|
||||
"pending_at": "orca_cloud_pending_at", # ISO 8601 UTC string
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Storage helpers — bridge User-row vs Settings-table fallback transparently
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _iso(dt: datetime | None) -> str | None:
|
||||
"""Serialize a datetime to ISO 8601 UTC. ``None`` passes through."""
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _as_utc(dt: datetime | None) -> datetime | None:
|
||||
"""Attach ``tzinfo=UTC`` to a naive datetime that we know was stored as
|
||||
UTC. ``None`` passes through. Already-aware datetimes are converted to
|
||||
UTC to normalize."""
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _parse_iso(value: str | None) -> datetime | None:
|
||||
"""Parse an ISO 8601 string back to a UTC datetime. ``None`` passes through."""
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
dt = datetime.fromisoformat(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt
|
||||
|
||||
|
||||
class _OrcaCredentials:
|
||||
"""Lightweight bag for stored Orca Cloud credentials. We use a class
|
||||
rather than a dataclass so the helpers can mutate it as needed during
|
||||
JIT-refresh without rebuilding the whole object."""
|
||||
|
||||
__slots__ = (
|
||||
"token",
|
||||
"refresh_token",
|
||||
"expires_at",
|
||||
"email",
|
||||
"user_id",
|
||||
"pending_verifier",
|
||||
"pending_state",
|
||||
"pending_at",
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.token: str | None = None
|
||||
self.refresh_token: str | None = None
|
||||
self.expires_at: datetime | None = None
|
||||
self.email: str | None = None
|
||||
self.user_id: str | None = None
|
||||
self.pending_verifier: str | None = None
|
||||
self.pending_state: str | None = None
|
||||
self.pending_at: datetime | None = None
|
||||
|
||||
|
||||
async def _load_credentials(db: AsyncSession, user: User | None) -> _OrcaCredentials:
|
||||
"""Load stored Orca Cloud credentials for the caller (user-row when auth
|
||||
is enabled, Settings fallback when auth is disabled).
|
||||
|
||||
Datetimes coming back from the User row are NAIVE on the Postgres side
|
||||
(asyncpg strips tzinfo for ``TIMESTAMP WITHOUT TIME ZONE`` columns) but
|
||||
represent UTC moments because that's what we stored. We attach
|
||||
``tzinfo=UTC`` here so downstream comparisons against
|
||||
``datetime.now(timezone.utc)`` don't get shifted by the host's local
|
||||
offset — ``naive_dt.astimezone(UTC)`` would assume local time, which on
|
||||
a UTC+2 host turns a 1-minute-old pending state into a 2h1m one and
|
||||
fires the 10-minute TTL guard immediately."""
|
||||
creds = _OrcaCredentials()
|
||||
if user is not None:
|
||||
creds.token = user.orca_cloud_token
|
||||
creds.refresh_token = user.orca_cloud_refresh_token
|
||||
creds.expires_at = _as_utc(user.orca_cloud_expires_at)
|
||||
creds.email = user.orca_cloud_email
|
||||
creds.user_id = user.orca_cloud_user_id
|
||||
creds.pending_verifier = user.orca_cloud_pending_verifier
|
||||
creds.pending_state = user.orca_cloud_pending_state
|
||||
creds.pending_at = _as_utc(user.orca_cloud_pending_at)
|
||||
return creds
|
||||
|
||||
result = await db.execute(select(Settings).where(Settings.key.in_(list(_SETTINGS_KEYS.values()))))
|
||||
raw = {s.key: s.value for s in result.scalars().all()}
|
||||
creds.token = raw.get(_SETTINGS_KEYS["token"])
|
||||
creds.refresh_token = raw.get(_SETTINGS_KEYS["refresh_token"])
|
||||
creds.expires_at = _parse_iso(raw.get(_SETTINGS_KEYS["expires_at"]))
|
||||
creds.email = raw.get(_SETTINGS_KEYS["email"])
|
||||
creds.user_id = raw.get(_SETTINGS_KEYS["user_id"])
|
||||
creds.pending_verifier = raw.get(_SETTINGS_KEYS["pending_verifier"])
|
||||
creds.pending_state = raw.get(_SETTINGS_KEYS["pending_state"])
|
||||
creds.pending_at = _parse_iso(raw.get(_SETTINGS_KEYS["pending_at"]))
|
||||
return creds
|
||||
|
||||
|
||||
async def _persist_pending_pkce(
|
||||
db: AsyncSession,
|
||||
user: User | None,
|
||||
verifier: str,
|
||||
state: str,
|
||||
when: datetime,
|
||||
) -> None:
|
||||
"""Store the transient PKCE state used by ``/auth/start`` -> ``/auth/finish``."""
|
||||
if user is not None:
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id == user.id)
|
||||
.values(
|
||||
orca_cloud_pending_verifier=verifier,
|
||||
orca_cloud_pending_state=state,
|
||||
orca_cloud_pending_at=when,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
return
|
||||
await _upsert_settings(
|
||||
db,
|
||||
{
|
||||
_SETTINGS_KEYS["pending_verifier"]: verifier,
|
||||
_SETTINGS_KEYS["pending_state"]: state,
|
||||
_SETTINGS_KEYS["pending_at"]: _iso(when),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _persist_tokens(
|
||||
db: AsyncSession,
|
||||
user: User | None,
|
||||
access_token: str,
|
||||
refresh_token: str | None,
|
||||
expires_at: datetime | None,
|
||||
email: str | None,
|
||||
user_id: str | None,
|
||||
) -> None:
|
||||
"""Atomically write the new access/refresh pair to whichever backing store
|
||||
the deployment uses. Also clears the pending PKCE state on the same write,
|
||||
since by this point the handshake is complete."""
|
||||
if user is not None:
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id == user.id)
|
||||
.values(
|
||||
orca_cloud_token=access_token,
|
||||
orca_cloud_refresh_token=refresh_token,
|
||||
orca_cloud_expires_at=expires_at,
|
||||
orca_cloud_email=email,
|
||||
orca_cloud_user_id=user_id,
|
||||
orca_cloud_pending_verifier=None,
|
||||
orca_cloud_pending_state=None,
|
||||
orca_cloud_pending_at=None,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
return
|
||||
await _upsert_settings(
|
||||
db,
|
||||
{
|
||||
_SETTINGS_KEYS["token"]: access_token,
|
||||
_SETTINGS_KEYS["refresh_token"]: refresh_token,
|
||||
_SETTINGS_KEYS["expires_at"]: _iso(expires_at),
|
||||
_SETTINGS_KEYS["email"]: email,
|
||||
_SETTINGS_KEYS["user_id"]: user_id,
|
||||
_SETTINGS_KEYS["pending_verifier"]: None,
|
||||
_SETTINGS_KEYS["pending_state"]: None,
|
||||
_SETTINGS_KEYS["pending_at"]: None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _persist_rotated_tokens(
|
||||
db: AsyncSession,
|
||||
user: User | None,
|
||||
access_token: str,
|
||||
refresh_token: str | None,
|
||||
expires_at: datetime | None,
|
||||
) -> None:
|
||||
"""Persist tokens after a refresh — does NOT touch email/user_id and does
|
||||
NOT touch the pending PKCE state (refresh happens long after the handshake)."""
|
||||
if user is not None:
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id == user.id)
|
||||
.values(
|
||||
orca_cloud_token=access_token,
|
||||
orca_cloud_refresh_token=refresh_token,
|
||||
orca_cloud_expires_at=expires_at,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
return
|
||||
await _upsert_settings(
|
||||
db,
|
||||
{
|
||||
_SETTINGS_KEYS["token"]: access_token,
|
||||
_SETTINGS_KEYS["refresh_token"]: refresh_token,
|
||||
_SETTINGS_KEYS["expires_at"]: _iso(expires_at),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _clear_credentials(db: AsyncSession, user: User | None) -> None:
|
||||
"""Wipe everything Orca-related (tokens, identity, pending state)."""
|
||||
if user is not None:
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id == user.id)
|
||||
.values(
|
||||
orca_cloud_token=None,
|
||||
orca_cloud_refresh_token=None,
|
||||
orca_cloud_expires_at=None,
|
||||
orca_cloud_email=None,
|
||||
orca_cloud_user_id=None,
|
||||
orca_cloud_pending_verifier=None,
|
||||
orca_cloud_pending_state=None,
|
||||
orca_cloud_pending_at=None,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
return
|
||||
result = await db.execute(select(Settings).where(Settings.key.in_(list(_SETTINGS_KEYS.values()))))
|
||||
for setting in result.scalars().all():
|
||||
await db.delete(setting)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _upsert_settings(db: AsyncSession, values: dict[str, str | None]) -> None:
|
||||
"""Idempotent upsert into the Settings table. ``None`` values delete the row."""
|
||||
keys = [k for k, _ in values.items()]
|
||||
result = await db.execute(select(Settings).where(Settings.key.in_(keys)))
|
||||
existing = {s.key: s for s in result.scalars().all()}
|
||||
for key, value in values.items():
|
||||
row = existing.get(key)
|
||||
if value is None:
|
||||
if row is not None:
|
||||
await db.delete(row)
|
||||
continue
|
||||
if row is not None:
|
||||
row.value = value
|
||||
else:
|
||||
db.add(Settings(key=key, value=value))
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Authenticated service builder with JIT refresh
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _build_authenticated_service(
|
||||
db: AsyncSession,
|
||||
user: User | None,
|
||||
) -> OrcaCloudService:
|
||||
"""Construct an :class:`OrcaCloudService` pre-populated with stored
|
||||
credentials. If the access token is within the refresh-leeway of expiry,
|
||||
proactively refresh and persist the new pair BEFORE returning, so the
|
||||
next API call doesn't time out mid-flight on an expired token."""
|
||||
creds = await _load_credentials(db, user)
|
||||
if not creds.token:
|
||||
raise HTTPException(status_code=401, detail="Orca Cloud is not connected — sign in first.")
|
||||
|
||||
svc = OrcaCloudService()
|
||||
svc.set_tokens(creds.token, creds.refresh_token, creds.expires_at)
|
||||
if not svc.is_authenticated:
|
||||
if not svc.refresh_token:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Orca Cloud session expired and no refresh token is stored — sign in again.",
|
||||
)
|
||||
try:
|
||||
await svc.refresh()
|
||||
except OrcaCloudAuthError as e:
|
||||
# Refresh token was revoked or rotated out from under us. Clear
|
||||
# the stale credentials so the UI flips to disconnected.
|
||||
await _clear_credentials(db, user)
|
||||
raise HTTPException(status_code=401, detail=f"Orca Cloud session refresh failed: {e}") from e
|
||||
except OrcaCloudError as e:
|
||||
raise HTTPException(status_code=502, detail=f"Orca Cloud unreachable: {e}") from e
|
||||
# Persist new pair BEFORE returning. A crash between here and the
|
||||
# downstream API call would still leave the user with valid stored
|
||||
# tokens for the next request.
|
||||
await _persist_rotated_tokens(db, user, svc.access_token, svc.refresh_token, svc.token_expiry)
|
||||
return svc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/auth/start", response_model=OrcaAuthStartResponse)
|
||||
async def auth_start(
|
||||
payload: OrcaAuthStartRequest = OrcaAuthStartRequest(),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
|
||||
):
|
||||
"""Generate PKCE state and return the Supabase authorize URL for the
|
||||
requested OAuth provider (google / apple / github). The frontend opens
|
||||
the URL in a new tab; after sign-in the user pastes the callback URL
|
||||
back into ``/auth/finish``.
|
||||
|
||||
``state`` is generated but NOT sent to Supabase (it would clash with
|
||||
GoTrue's internal redirect_to-tracking state). We still persist it so
|
||||
a future flow change can re-introduce state-based CSRF if needed; CSRF
|
||||
protection today comes from the PKCE verifier itself, which is
|
||||
single-use, server-side, and bound to the caller's user row."""
|
||||
verifier, challenge, state = generate_pkce()
|
||||
await _persist_pending_pkce(db, current_user, verifier, state, datetime.now(timezone.utc))
|
||||
return OrcaAuthStartResponse(auth_url=build_authorize_url(challenge, provider=payload.provider))
|
||||
|
||||
|
||||
@router.post("/auth/password", response_model=OrcaAuthStatusResponse)
|
||||
async def auth_password(
|
||||
payload: OrcaAuthPasswordRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
|
||||
):
|
||||
"""Direct email+password sign-in. No browser redirect, no paste flow —
|
||||
Bambuddy POSTs the credentials to Supabase and stores the returned
|
||||
tokens. Whether this succeeds depends on Orca's Supabase project
|
||||
accepting the password grant; if it rejects (the SDK refuses passwords
|
||||
by design, the backend may follow suit), the caller falls back to an
|
||||
OAuth provider via ``/auth/start``."""
|
||||
svc = OrcaCloudService()
|
||||
try:
|
||||
await svc.password_login(payload.email, payload.password)
|
||||
except OrcaCloudAuthError as e:
|
||||
raise HTTPException(status_code=400, detail=f"Orca Cloud rejected the sign-in: {e}") from e
|
||||
except OrcaCloudError as e:
|
||||
raise HTTPException(status_code=502, detail=f"Orca Cloud unreachable: {e}") from e
|
||||
|
||||
email: str | None = None
|
||||
user_id: str | None = None
|
||||
try:
|
||||
user_info = await svc.get_user_info()
|
||||
if isinstance(user_info, dict):
|
||||
email = user_info.get("email")
|
||||
user_id = user_info.get("id")
|
||||
except OrcaCloudError as e:
|
||||
logger.warning("Orca Cloud user-info fetch failed after successful password auth: %s", e)
|
||||
|
||||
await _persist_tokens(db, current_user, svc.access_token, svc.refresh_token, svc.token_expiry, email, user_id)
|
||||
return OrcaAuthStatusResponse(connected=True, email=email, user_id=user_id)
|
||||
|
||||
|
||||
@router.post("/auth/finish", response_model=OrcaAuthStatusResponse)
|
||||
async def auth_finish(
|
||||
payload: OrcaAuthFinishRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
|
||||
):
|
||||
"""Complete the PKCE handshake — parse the pasted callback URL, validate
|
||||
state (CSRF), exchange the code for tokens, persist."""
|
||||
creds = await _load_credentials(db, current_user)
|
||||
if not creds.pending_verifier or not creds.pending_state or not creds.pending_at:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No pending Orca Cloud sign-in. Click Connect first to start the flow.",
|
||||
)
|
||||
|
||||
# creds.pending_at is already tz-aware UTC after _load_credentials' _as_utc
|
||||
# normalization. Subtracting two aware UTC datetimes gives a real wall-clock
|
||||
# delta with no local-offset shift.
|
||||
age = datetime.now(timezone.utc) - creds.pending_at
|
||||
if age > PENDING_PKCE_TTL:
|
||||
# Don't leave the stale state in the DB — clear it so the user has to
|
||||
# restart fresh, which forces a new verifier/state pair.
|
||||
await _persist_pending_pkce(db, current_user, "", "", datetime.fromtimestamp(0, tz=timezone.utc))
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"The Orca Cloud sign-in flow expired after {PENDING_PKCE_TTL.total_seconds() / 60:.0f} minutes. "
|
||||
"Click Connect again to start over."
|
||||
),
|
||||
)
|
||||
|
||||
code, _callback_state = parse_callback_url(payload.callback_url)
|
||||
if not code:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No `code` parameter in the pasted callback URL. Copy the full URL from your browser's address bar.",
|
||||
)
|
||||
# We do NOT validate ``state`` here: Supabase doesn't echo back a state we
|
||||
# don't send (see :func:`build_authorize_url` for why we can't send one).
|
||||
# CSRF is protected by PKCE: the verifier is server-side and single-use,
|
||||
# so an attacker can't complete the exchange with a code they obtained
|
||||
# separately. ``pending_state`` is still stored for forward compatibility
|
||||
# if Supabase ever supports a client-passed state alongside redirect_to.
|
||||
|
||||
svc = OrcaCloudService()
|
||||
try:
|
||||
await svc.exchange_code(code, creds.pending_verifier)
|
||||
except OrcaCloudAuthError as e:
|
||||
raise HTTPException(status_code=400, detail=f"Orca Cloud rejected the sign-in: {e}") from e
|
||||
except OrcaCloudError as e:
|
||||
raise HTTPException(status_code=502, detail=f"Orca Cloud unreachable: {e}") from e
|
||||
|
||||
# Fetch user info so we can show the connected email in the UI.
|
||||
email: str | None = None
|
||||
user_id: str | None = None
|
||||
try:
|
||||
user_info = await svc.get_user_info()
|
||||
if isinstance(user_info, dict):
|
||||
email = user_info.get("email")
|
||||
user_id = user_info.get("id")
|
||||
except OrcaCloudError as e:
|
||||
# Don't fail the whole connect flow just because the user-info side
|
||||
# call hiccuped — we have valid tokens, that's the load-bearing part.
|
||||
logger.warning("Orca Cloud user-info fetch failed after successful auth: %s", e)
|
||||
|
||||
await _persist_tokens(db, current_user, svc.access_token, svc.refresh_token, svc.token_expiry, email, user_id)
|
||||
return OrcaAuthStatusResponse(connected=True, email=email, user_id=user_id)
|
||||
|
||||
|
||||
@router.get("/status", response_model=OrcaAuthStatusResponse)
|
||||
async def get_status(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
|
||||
):
|
||||
"""Return whether the caller has an Orca Cloud session stored, plus
|
||||
identifier details for display. Does NOT make a live API call."""
|
||||
creds = await _load_credentials(db, current_user)
|
||||
return OrcaAuthStatusResponse(
|
||||
connected=bool(creds.token),
|
||||
email=creds.email,
|
||||
user_id=creds.user_id,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
|
||||
):
|
||||
"""Clear stored Orca Cloud credentials. Does not call Supabase's
|
||||
``/logout`` endpoint (the token would still survive its 1h expiry there
|
||||
either way, and Bambuddy will no longer have it to use)."""
|
||||
await _clear_credentials(db, current_user)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.get("/profiles", response_model=OrcaProfileListResponse)
|
||||
async def list_profiles(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
|
||||
):
|
||||
"""Return profile metadata grouped by type (``filament`` / ``printer``
|
||||
/ ``process``), matching the ``SlicerSettingsResponse`` shape the
|
||||
Bambu Cloud tab consumes. This lets the frontend render Orca profiles
|
||||
with the same visual components — same cards, same filter bar, same
|
||||
grouping — without separate UI code paths."""
|
||||
svc = await _build_authenticated_service(db, current_user)
|
||||
try:
|
||||
raw_profiles = await svc.list_profiles()
|
||||
except OrcaCloudAuthError as e:
|
||||
raise HTTPException(status_code=401, detail=str(e)) from e
|
||||
except OrcaCloudError as e:
|
||||
raise HTTPException(status_code=502, detail=str(e)) from e
|
||||
grouped: dict[str, list[OrcaProfileMeta]] = {"filament": [], "printer": [], "process": []}
|
||||
# Log any unknown content.type values we silently drop, so a future
|
||||
# change in Orca's type vocabulary surfaces in the logs rather than
|
||||
# quietly losing profiles.
|
||||
unknown_types: dict[str, int] = {}
|
||||
for entry in raw_profiles:
|
||||
setting = _orca_to_setting(entry)
|
||||
if setting is None:
|
||||
content = entry.get("content") if isinstance(entry, dict) else None
|
||||
raw_type = (content.get("type") if isinstance(content, dict) else None) or "<missing>"
|
||||
unknown_types[str(raw_type)] = unknown_types.get(str(raw_type), 0) + 1
|
||||
continue
|
||||
grouped[setting.type].append(setting)
|
||||
if unknown_types:
|
||||
logger.warning(
|
||||
"Orca Cloud profile list dropped %d profiles with unmapped content.type values: %s",
|
||||
sum(unknown_types.values()),
|
||||
unknown_types,
|
||||
)
|
||||
return OrcaProfileListResponse(**grouped)
|
||||
|
||||
|
||||
@router.get("/profiles/{profile_id}", response_model=OrcaProfileDetail)
|
||||
async def get_profile(
|
||||
profile_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
|
||||
):
|
||||
"""Fetch a single profile's full content, shaped like
|
||||
``SlicerSettingDetail`` so the Bambu Cloud detail modal can render it
|
||||
unchanged. The inner ``setting`` field is the raw slicer-format JSON
|
||||
Orca stores — same shape Bambu Cloud uses since OrcaSlicer is a
|
||||
BambuStudio fork."""
|
||||
svc = await _build_authenticated_service(db, current_user)
|
||||
try:
|
||||
profile = await svc.get_profile(profile_id)
|
||||
except OrcaCloudAuthError as e:
|
||||
raise HTTPException(status_code=401, detail=str(e)) from e
|
||||
except OrcaCloudError as e:
|
||||
if "not found" in str(e).lower():
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
raise HTTPException(status_code=502, detail=str(e)) from e
|
||||
content = profile.get("content") if isinstance(profile, dict) else None
|
||||
if not isinstance(content, dict):
|
||||
content = {}
|
||||
orca_type = str(content.get("type", ""))
|
||||
bambu_type = _ORCA_TYPE_TO_BAMBU.get(orca_type, orca_type)
|
||||
update_time = profile.get("updated_time") if isinstance(profile, dict) else None
|
||||
return OrcaProfileDetail(
|
||||
setting_id=str(profile_id),
|
||||
name=str(profile.get("name") if isinstance(profile, dict) else "") or str(profile_id),
|
||||
type=bambu_type,
|
||||
version=_str_or_none(content.get("version")),
|
||||
base_id=_str_or_none(content.get("inherits") or content.get("base_id")),
|
||||
update_time=str(update_time) if update_time is not None else None,
|
||||
setting=content,
|
||||
)
|
||||
|
|
@ -21,6 +21,11 @@ from sqlalchemy import select
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.api.routes.cloud import get_stored_token, resolve_api_key_cloud_owner
|
||||
from backend.app.api.routes.orca_cloud import (
|
||||
_ORCA_TYPE_TO_BAMBU,
|
||||
_build_authenticated_service as _build_orca_service,
|
||||
_load_credentials as _load_orca_credentials,
|
||||
)
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.config import settings as app_settings
|
||||
from backend.app.core.database import get_db
|
||||
|
|
@ -37,6 +42,10 @@ from backend.app.services.bambu_cloud import (
|
|||
BambuCloudError,
|
||||
BambuCloudService,
|
||||
)
|
||||
from backend.app.services.orca_cloud import (
|
||||
OrcaCloudAuthError,
|
||||
OrcaCloudError,
|
||||
)
|
||||
from backend.app.services.slicer_api import (
|
||||
BundleNotFoundError,
|
||||
BundleSummary,
|
||||
|
|
@ -69,6 +78,9 @@ _bundled_cache: tuple[float, dict[str, list[UnifiedPreset]]] | None = None
|
|||
_CLOUD_TTL_S = 300.0
|
||||
_cloud_cache: dict[tuple[int, str], tuple[float, dict[str, list[UnifiedPreset]]]] = {}
|
||||
|
||||
# Same shape for Orca Cloud — keyed on (user_id, access_token-fingerprint).
|
||||
_orca_cloud_cache: dict[tuple[int, str], tuple[float, dict[str, list[UnifiedPreset]]]] = {}
|
||||
|
||||
|
||||
def _token_fingerprint(token: str) -> str:
|
||||
"""Short stable hash of the cloud token for use as a cache-key component.
|
||||
|
|
@ -172,6 +184,101 @@ async def _fetch_cloud_presets(
|
|||
await cloud.close()
|
||||
|
||||
|
||||
async def _fetch_orca_cloud_presets(
|
||||
db: AsyncSession, user: User | None, *, refresh: bool = False
|
||||
) -> tuple[dict[str, list[UnifiedPreset]], str]:
|
||||
"""Mirror of :func:`_fetch_cloud_presets` but for Orca Cloud. Same status
|
||||
vocabulary (``ok`` / ``not_authenticated`` / ``expired`` / ``unreachable``),
|
||||
same caching shape, same defence-in-depth permission gate
|
||||
(``orca_cloud:auth`` rather than ``cloud:auth``).
|
||||
|
||||
Filament metadata (``filament_type`` / ``filament_colour``) is extracted
|
||||
from the profile's inline ``content`` dict — unlike Bambu Cloud where
|
||||
we'd have to fetch each setting separately and hit a rate limit, Orca's
|
||||
``/sync/pull`` already returns full content per profile, so the metadata
|
||||
enrichment is free here.
|
||||
"""
|
||||
if user is not None and not user.has_permission(Permission.ORCA_CLOUD_AUTH.value):
|
||||
return _empty_slots(), "not_authenticated"
|
||||
|
||||
creds = await _load_orca_credentials(db, user)
|
||||
if not creds.token:
|
||||
return _empty_slots(), "not_authenticated"
|
||||
|
||||
user_key = user.id if user is not None else 0
|
||||
cache_key = (user_key, _token_fingerprint(creds.token))
|
||||
now = time.monotonic()
|
||||
if not refresh:
|
||||
cached = _orca_cloud_cache.get(cache_key)
|
||||
if cached and now - cached[0] < _CLOUD_TTL_S:
|
||||
return cached[1], "ok"
|
||||
|
||||
try:
|
||||
svc = await _build_orca_service(db, user)
|
||||
except HTTPException as e:
|
||||
# ``_build_orca_service`` raises 401 when the token is missing,
|
||||
# the refresh-token rotation failed, or the JIT refresh hit Orca's
|
||||
# backend and got rejected; 502 when Orca is unreachable. Translate
|
||||
# to the status vocabulary the SliceModal expects.
|
||||
if e.status_code == 401:
|
||||
return _empty_slots(), "expired"
|
||||
return _empty_slots(), "unreachable"
|
||||
|
||||
try:
|
||||
try:
|
||||
raw_profiles = await svc.list_profiles()
|
||||
except OrcaCloudAuthError:
|
||||
return _empty_slots(), "expired"
|
||||
except OrcaCloudError as e:
|
||||
logger.warning("Orca Cloud preset fetch failed for user %s: %s", user_key, e)
|
||||
return _empty_slots(), "unreachable"
|
||||
except Exception as e: # noqa: BLE001 — defensive: never crash the modal
|
||||
logger.warning("Orca Cloud preset fetch unexpected error for user %s: %s", user_key, e)
|
||||
return _empty_slots(), "unreachable"
|
||||
|
||||
slots = _empty_slots()
|
||||
for entry in raw_profiles:
|
||||
content = entry.get("content") if isinstance(entry, dict) else None
|
||||
if not isinstance(content, dict):
|
||||
continue
|
||||
slot = _ORCA_TYPE_TO_BAMBU.get(str(content.get("type", "")))
|
||||
if slot is None:
|
||||
continue
|
||||
preset_id = entry.get("id")
|
||||
name = entry.get("name") or preset_id
|
||||
if not preset_id or not name:
|
||||
continue
|
||||
filament_type: str | None = None
|
||||
filament_colour: str | None = None
|
||||
if slot == "filament":
|
||||
# Bambu/Orca filament profiles store these as single-element
|
||||
# arrays (the historical multi-extruder shape). Extract the
|
||||
# first non-empty element for both.
|
||||
ft = content.get("filament_type")
|
||||
if isinstance(ft, list) and ft and isinstance(ft[0], str):
|
||||
filament_type = ft[0]
|
||||
elif isinstance(ft, str):
|
||||
filament_type = ft
|
||||
fc = content.get("default_filament_colour")
|
||||
if isinstance(fc, list) and fc and isinstance(fc[0], str):
|
||||
filament_colour = fc[0]
|
||||
elif isinstance(fc, str):
|
||||
filament_colour = fc
|
||||
slots[slot].append(
|
||||
UnifiedPreset(
|
||||
id=str(preset_id),
|
||||
name=str(name),
|
||||
source="orca_cloud",
|
||||
filament_type=filament_type,
|
||||
filament_colour=filament_colour,
|
||||
)
|
||||
)
|
||||
_orca_cloud_cache[cache_key] = (now, slots)
|
||||
return slots, "ok"
|
||||
finally:
|
||||
await svc.close()
|
||||
|
||||
|
||||
async def _fetch_local_presets(db: AsyncSession) -> dict[str, list[UnifiedPreset]]:
|
||||
"""Local imports — no caching needed, single indexed DB read."""
|
||||
result = await db.execute(select(LocalPreset).order_by(LocalPreset.name))
|
||||
|
|
@ -314,6 +421,7 @@ async def _resolve_slicer_api_url(db: AsyncSession) -> str | None:
|
|||
|
||||
|
||||
def _dedupe_by_name(
|
||||
orca_cloud: dict[str, list[UnifiedPreset]],
|
||||
cloud: dict[str, list[UnifiedPreset]],
|
||||
local: dict[str, list[UnifiedPreset]],
|
||||
standard: dict[str, list[UnifiedPreset]],
|
||||
|
|
@ -321,34 +429,35 @@ def _dedupe_by_name(
|
|||
dict[str, list[UnifiedPreset]],
|
||||
dict[str, list[UnifiedPreset]],
|
||||
dict[str, list[UnifiedPreset]],
|
||||
dict[str, list[UnifiedPreset]],
|
||||
]:
|
||||
"""Filter so each preset name appears in exactly one tier (cloud > local > standard).
|
||||
"""Filter so each preset name appears in exactly one tier.
|
||||
|
||||
Order within each tier is preserved as-is — only "lower-priority duplicates"
|
||||
are dropped. A preset shared across tiers (e.g. "Bambu PLA Basic" in cloud
|
||||
public AND standard bundled) only renders once, in the cloud tier.
|
||||
Precedence: ``orca_cloud > cloud > local > standard``. Orca Cloud is
|
||||
highest because a user who set up Orca sync is explicitly curating
|
||||
those profiles for use here; Bambu Cloud follows for the same reason
|
||||
one tier down. Order within each tier is preserved.
|
||||
|
||||
Filament metadata is **merged across tiers** during dedup: when a cloud
|
||||
entry wins over a same-named local entry, the cloud entry inherits the
|
||||
local entry's ``filament_type`` and ``filament_colour`` (cloud entries
|
||||
carry no metadata themselves because we deliberately don't fetch each
|
||||
setting's content — see _fetch_cloud_presets). Without this merge, the
|
||||
SliceModal's metadata-aware pre-pick would silently lose match data for
|
||||
every preset the user has both cloud-synced and locally imported, and
|
||||
fall back to plain priority selection.
|
||||
Filament metadata merges across tiers: a Bambu Cloud entry without its
|
||||
own ``filament_type`` / ``filament_colour`` (Bambu Cloud doesn't surface
|
||||
these in the list response for rate-limiting reasons — see
|
||||
:func:`_fetch_cloud_presets`) inherits values from the same-named local
|
||||
or standard entry. Orca Cloud already carries metadata inline, so no
|
||||
backfill is needed for it.
|
||||
"""
|
||||
# Build a lookup: filament name → metadata from the highest-quality tier
|
||||
# that has it. Local + standard both expose parsed metadata; cloud
|
||||
# doesn't. Take whichever non-empty entry shows up first.
|
||||
# Build a name → metadata lookup from the tiers that carry it (orca_cloud,
|
||||
# local, standard). Bambu cloud is intentionally skipped — it doesn't
|
||||
# populate filament_type/colour in the list response. Take whichever
|
||||
# non-empty entry shows up first.
|
||||
metadata_by_name: dict[str, tuple[str | None, str | None]] = {}
|
||||
for tier in (local, standard):
|
||||
for tier in (orca_cloud, local, standard):
|
||||
for p in tier["filament"]:
|
||||
if p.name in metadata_by_name:
|
||||
continue
|
||||
if p.filament_type or p.filament_colour:
|
||||
metadata_by_name[p.name] = (p.filament_type, p.filament_colour)
|
||||
|
||||
# Backfill cloud entries that don't have their own metadata.
|
||||
# Backfill Bambu Cloud entries that don't have their own metadata.
|
||||
for p in cloud["filament"]:
|
||||
if (p.filament_type is None or p.filament_colour is None) and p.name in metadata_by_name:
|
||||
t, c = metadata_by_name[p.name]
|
||||
|
|
@ -357,10 +466,16 @@ def _dedupe_by_name(
|
|||
if p.filament_colour is None and c is not None:
|
||||
p.filament_colour = c
|
||||
|
||||
deduped_cloud = _empty_slots()
|
||||
deduped_local = _empty_slots()
|
||||
deduped_standard = _empty_slots()
|
||||
for slot in ("printer", "process", "filament"):
|
||||
seen = {p.name for p in cloud[slot]}
|
||||
seen = {p.name for p in orca_cloud[slot]}
|
||||
for p in cloud[slot]:
|
||||
if p.name in seen:
|
||||
continue
|
||||
deduped_cloud[slot].append(p)
|
||||
seen.add(p.name)
|
||||
for p in local[slot]:
|
||||
if p.name in seen:
|
||||
continue
|
||||
|
|
@ -371,7 +486,7 @@ def _dedupe_by_name(
|
|||
continue
|
||||
deduped_standard[slot].append(p)
|
||||
seen.add(p.name)
|
||||
return cloud, deduped_local, deduped_standard
|
||||
return orca_cloud, deduped_cloud, deduped_local, deduped_standard
|
||||
|
||||
|
||||
@router.get("/printer-models")
|
||||
|
|
@ -421,17 +536,20 @@ async def list_unified_presets(
|
|||
too — matching the slice route (#1182 follow-up).
|
||||
"""
|
||||
cloud_token_user = current_user or api_key_cloud_owner
|
||||
orca_cloud, orca_cloud_status = await _fetch_orca_cloud_presets(db, cloud_token_user, refresh=refresh)
|
||||
cloud, cloud_status = await _fetch_cloud_presets(db, cloud_token_user, refresh=refresh)
|
||||
local = await _fetch_local_presets(db)
|
||||
standard = await _fetch_bundled_presets(db, refresh=refresh)
|
||||
|
||||
cloud, local, standard = _dedupe_by_name(cloud, local, standard)
|
||||
orca_cloud, cloud, local, standard = _dedupe_by_name(orca_cloud, cloud, local, standard)
|
||||
|
||||
return UnifiedPresetsResponse(
|
||||
orca_cloud=UnifiedPresetsBySlot(**orca_cloud),
|
||||
cloud=UnifiedPresetsBySlot(**cloud),
|
||||
local=UnifiedPresetsBySlot(**local),
|
||||
standard=UnifiedPresetsBySlot(**standard),
|
||||
cloud_status=cloud_status,
|
||||
orca_cloud_status=orca_cloud_status,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -124,6 +124,12 @@ _APIKEY_SCOPE_BY_PERMISSION: dict[Permission, str] = {
|
|||
# level ``cloud_caller(Permission.CLOUD_AUTH)`` dep also fails closed
|
||||
# when the flag is off (defence-in-depth).
|
||||
Permission.CLOUD_AUTH: "can_access_cloud",
|
||||
# ORCA_CLOUD_AUTH folds into the same ``can_access_cloud`` scope: same
|
||||
# trust dimension (third-party cloud access for profile sync), so an
|
||||
# operator who already accepted "this key can talk to clouds for the
|
||||
# owner" doesn't need a second toggle for Orca. Splitting later requires
|
||||
# a new column + migration — easy to add if the trust dimensions diverge.
|
||||
Permission.ORCA_CLOUD_AUTH: "can_access_cloud",
|
||||
}
|
||||
|
||||
# Retained for documentation, drift-detection, and the prior "administrative
|
||||
|
|
|
|||
|
|
@ -2755,6 +2755,27 @@ async def run_migrations(conn):
|
|||
"ALTER TABLE smart_plugs ADD COLUMN IF NOT EXISTS off_delay_after_drying_minutes INTEGER DEFAULT 10",
|
||||
)
|
||||
|
||||
# Migration: Add per-user Orca Cloud credential columns. Mirrors the Bambu
|
||||
# Cloud columns but adds refresh_token + expires_at (Supabase PKCE issues
|
||||
# short-lived access tokens with rotating refresh tokens), plus three
|
||||
# transient PKCE state columns held during the auth handshake. DATETIME
|
||||
# is SQLite-only — Postgres uses TIMESTAMP, so the datetime columns are
|
||||
# dialect-branched per project convention.
|
||||
await _safe_execute(conn, "ALTER TABLE users ADD COLUMN orca_cloud_token VARCHAR(2000)")
|
||||
await _safe_execute(conn, "ALTER TABLE users ADD COLUMN orca_cloud_refresh_token VARCHAR(128)")
|
||||
if is_sqlite():
|
||||
await _safe_execute(conn, "ALTER TABLE users ADD COLUMN orca_cloud_expires_at DATETIME")
|
||||
else:
|
||||
await _safe_execute(conn, "ALTER TABLE users ADD COLUMN IF NOT EXISTS orca_cloud_expires_at TIMESTAMP")
|
||||
await _safe_execute(conn, "ALTER TABLE users ADD COLUMN orca_cloud_email VARCHAR(255)")
|
||||
await _safe_execute(conn, "ALTER TABLE users ADD COLUMN orca_cloud_user_id VARCHAR(64)")
|
||||
await _safe_execute(conn, "ALTER TABLE users ADD COLUMN orca_cloud_pending_verifier VARCHAR(64)")
|
||||
await _safe_execute(conn, "ALTER TABLE users ADD COLUMN orca_cloud_pending_state VARCHAR(32)")
|
||||
if is_sqlite():
|
||||
await _safe_execute(conn, "ALTER TABLE users ADD COLUMN orca_cloud_pending_at DATETIME")
|
||||
else:
|
||||
await _safe_execute(conn, "ALTER TABLE users ADD COLUMN IF NOT EXISTS orca_cloud_pending_at TIMESTAMP")
|
||||
|
||||
# Data migration: drop the embedded 3MF Title (`print_name`) from library
|
||||
# file metadata so the FileManager displays the filename, not the title (#1489).
|
||||
await _migrate_drop_library_print_name(conn)
|
||||
|
|
|
|||
|
|
@ -144,6 +144,7 @@ class Permission(StrEnum):
|
|||
|
||||
# Cloud Auth (admin-level)
|
||||
CLOUD_AUTH = "cloud:auth"
|
||||
ORCA_CLOUD_AUTH = "orca_cloud:auth"
|
||||
|
||||
# MakerWorld Integration
|
||||
MAKERWORLD_VIEW = "makerworld:view" # Resolve MakerWorld URLs and view model metadata
|
||||
|
|
@ -297,6 +298,7 @@ PERMISSION_CATEGORIES = {
|
|||
],
|
||||
"Cloud": [
|
||||
Permission.CLOUD_AUTH,
|
||||
Permission.ORCA_CLOUD_AUTH,
|
||||
],
|
||||
"MakerWorld": [
|
||||
Permission.MAKERWORLD_VIEW,
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ from backend.app.api.routes import (
|
|||
notification_templates,
|
||||
notifications,
|
||||
obico,
|
||||
orca_cloud,
|
||||
pending_uploads,
|
||||
print_log,
|
||||
print_queue,
|
||||
|
|
@ -5777,6 +5778,7 @@ app.include_router(inventory.router, prefix=app_settings.api_prefix)
|
|||
app.include_router(labels.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(settings_routes.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(cloud.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(orca_cloud.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(local_presets.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(smart_plugs.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(print_log.router, prefix=app_settings.api_prefix)
|
||||
|
|
|
|||
|
|
@ -45,6 +45,20 @@ class User(Base):
|
|||
# "global" or "china"; NULL treated as "global" for legacy rows.
|
||||
cloud_region: Mapped[str | None] = mapped_column(String(10), nullable=True, default=None)
|
||||
|
||||
# Per-user Orca Cloud credentials. Unlike Bambu Cloud, Orca uses Supabase PKCE
|
||||
# with short-lived access tokens (1h) and rotating single-use refresh tokens,
|
||||
# so we store the refresh token + expiry alongside the access token.
|
||||
orca_cloud_token: Mapped[str | None] = mapped_column(String(2000), nullable=True, default=None)
|
||||
orca_cloud_refresh_token: Mapped[str | None] = mapped_column(String(128), nullable=True, default=None)
|
||||
orca_cloud_expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None)
|
||||
orca_cloud_email: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
|
||||
orca_cloud_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, default=None)
|
||||
# Transient PKCE state held between /orca-cloud/auth/start and /orca-cloud/auth/finish.
|
||||
# Cleared on successful finish; expires after 10 minutes if the user abandons the flow.
|
||||
orca_cloud_pending_verifier: Mapped[str | None] = mapped_column(String(64), nullable=True, default=None)
|
||||
orca_cloud_pending_state: Mapped[str | None] = mapped_column(String(32), nullable=True, default=None)
|
||||
orca_cloud_pending_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None)
|
||||
|
||||
# Relationship to groups through association table
|
||||
groups: Mapped[list[Group]] = relationship(
|
||||
"Group",
|
||||
|
|
|
|||
94
backend/app/schemas/orca_cloud.py
Normal file
94
backend/app/schemas/orca_cloud.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"""Schemas for Orca Cloud auth + profile sync endpoints."""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# The three OAuth providers Orca's sign-in surface offers. Supabase
|
||||
# accepts the bare lowercase provider name in the authorize query string.
|
||||
OrcaOAuthProvider = Literal["google", "apple", "github"]
|
||||
|
||||
|
||||
class OrcaAuthStartRequest(BaseModel):
|
||||
"""Body for ``POST /orca-cloud/auth/start``. Provider defaults to
|
||||
``google`` so existing clients that send an empty body keep working."""
|
||||
|
||||
provider: OrcaOAuthProvider = Field(default="google", description="OAuth provider to use for sign-in")
|
||||
|
||||
|
||||
class OrcaAuthStartResponse(BaseModel):
|
||||
"""Returned by ``POST /orca-cloud/auth/start``. The frontend opens
|
||||
``auth_url`` in a new tab. After the user signs in to Orca, they copy the
|
||||
redirected URL from their address bar and POST it to
|
||||
``/orca-cloud/auth/finish`` to complete the handshake."""
|
||||
|
||||
auth_url: str = Field(..., description="URL to open for Orca Cloud sign-in")
|
||||
|
||||
|
||||
class OrcaAuthFinishRequest(BaseModel):
|
||||
"""Submitted by the frontend after the user pastes the callback URL from
|
||||
their browser. The URL contains a Supabase ``code`` (and our ``state``)
|
||||
that we exchange for tokens."""
|
||||
|
||||
callback_url: str = Field(..., description="The full URL the browser was redirected to after sign-in")
|
||||
|
||||
|
||||
class OrcaAuthPasswordRequest(BaseModel):
|
||||
"""Body for ``POST /orca-cloud/auth/password``. Whether this succeeds
|
||||
depends on Orca's Supabase project — their desktop client refuses
|
||||
password payloads, but the web sign-in offers email+password as one
|
||||
option. We forward the credentials and surface the server's response.
|
||||
``email`` is plain ``str`` rather than Pydantic's ``EmailStr`` to avoid
|
||||
pulling in the optional ``email-validator`` dependency — Supabase will
|
||||
reject malformed addresses with a clear error itself, and the existing
|
||||
Bambu Cloud login schema uses the same approach."""
|
||||
|
||||
email: str = Field(..., min_length=1)
|
||||
password: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class OrcaAuthStatusResponse(BaseModel):
|
||||
"""Connection status for the Orca Cloud tab."""
|
||||
|
||||
connected: bool
|
||||
email: str | None = None
|
||||
user_id: str | None = None
|
||||
|
||||
|
||||
class OrcaProfileMeta(BaseModel):
|
||||
"""A single profile, shaped to match the Bambu Cloud ``SlicerSetting``
|
||||
schema so the frontend can render Orca profiles with the existing
|
||||
Bambu Cloud visual components (cards, filter bar, grouping). Per-source
|
||||
differences (Orca's IDs are UUIDs not Bambu's ``PFU...`` prefix; Orca
|
||||
types are ``machine`` / ``process`` / ``filament`` whereas Bambu uses
|
||||
``printer`` / ``process`` / ``filament``) are normalized at the route
|
||||
layer before the response leaves the backend."""
|
||||
|
||||
setting_id: str
|
||||
name: str
|
||||
type: str
|
||||
version: str | None = None
|
||||
user_id: str | None = None
|
||||
updated_time: str | None = None
|
||||
is_custom: bool = True
|
||||
|
||||
|
||||
class OrcaProfileListResponse(BaseModel):
|
||||
"""Groups Orca profiles by type, matching ``SlicerSettingsResponse``."""
|
||||
|
||||
filament: list[OrcaProfileMeta] = []
|
||||
printer: list[OrcaProfileMeta] = []
|
||||
process: list[OrcaProfileMeta] = []
|
||||
|
||||
|
||||
class OrcaProfileDetail(BaseModel):
|
||||
"""Single profile's full content, shaped to match ``SlicerSettingDetail``
|
||||
so the frontend's detail modal can render it without translation."""
|
||||
|
||||
setting_id: str
|
||||
name: str
|
||||
type: str
|
||||
version: str | None = None
|
||||
base_id: str | None = None
|
||||
update_time: str | None = None
|
||||
setting: dict
|
||||
|
|
@ -8,13 +8,20 @@ from pydantic import BaseModel, Field, model_validator
|
|||
class PresetRef(BaseModel):
|
||||
"""A source-aware reference to a printer / process / filament preset.
|
||||
|
||||
The SliceModal pulls dropdown options from three tiers (cloud / local /
|
||||
standard). At submit time the client sends one of these per slot so the
|
||||
backend knows where to fetch the preset content from at slice time.
|
||||
The SliceModal pulls dropdown options from four tiers (orca_cloud /
|
||||
cloud / local / standard). At submit time the client sends one of these
|
||||
per slot so the backend knows where to fetch the preset content from at
|
||||
slice time. ``cloud`` is Bambu Cloud (kept as the bare name for backward
|
||||
compatibility with existing requests); ``orca_cloud`` is Orca Cloud.
|
||||
"""
|
||||
|
||||
source: Literal["cloud", "local", "standard"]
|
||||
id: str = Field(..., description=("Cloud setting_id, local DB row id (stringified), or standard preset name."))
|
||||
source: Literal["orca_cloud", "cloud", "local", "standard"]
|
||||
id: str = Field(
|
||||
...,
|
||||
description=(
|
||||
"Orca Cloud profile id, Bambu Cloud setting_id, local DB row id (stringified), or standard preset name."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class SliceBundleSpec(BaseModel):
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ class UnifiedPreset(BaseModel):
|
|||
|
||||
id: str
|
||||
name: str
|
||||
source: Literal["cloud", "local", "standard"]
|
||||
source: Literal["orca_cloud", "cloud", "local", "standard"]
|
||||
filament_type: str | None = None
|
||||
filament_colour: str | None = None
|
||||
compatible_printers: list[str] | None = None
|
||||
|
|
@ -61,17 +61,21 @@ class UnifiedPresetsBySlot(BaseModel):
|
|||
class UnifiedPresetsResponse(BaseModel):
|
||||
"""Each tier carries only the names that didn't appear in a higher tier.
|
||||
|
||||
Cloud is the highest priority (user's personal customisations win), then
|
||||
the local imports the user explicitly curated, then the slicer's stock
|
||||
fallback. A name that appears in cloud is filtered out of local and
|
||||
standard; a name that appears in local is filtered out of standard.
|
||||
Priority order: ``orca_cloud > cloud > local > standard``. Orca Cloud is
|
||||
highest because it's the most-recently-explicitly-curated source for
|
||||
users who set up Orca sync (they did it on purpose; their Orca picks
|
||||
should outrank everything else). Bambu Cloud follows as the next-most-
|
||||
curated tier. Local imports beat the slicer's stock fallback.
|
||||
|
||||
``cloud_status`` lets the frontend show a banner explaining why the cloud
|
||||
tier is empty when the user expected to see it (signed out / token
|
||||
expired / network down).
|
||||
``cloud_status`` / ``orca_cloud_status`` let the frontend show a banner
|
||||
explaining why a cloud tier is empty when the user expected to see it
|
||||
(signed out / token expired / network down). Each tier has its own
|
||||
status because they can fail independently.
|
||||
"""
|
||||
|
||||
orca_cloud: UnifiedPresetsBySlot = UnifiedPresetsBySlot()
|
||||
cloud: UnifiedPresetsBySlot = UnifiedPresetsBySlot()
|
||||
local: UnifiedPresetsBySlot = UnifiedPresetsBySlot()
|
||||
standard: UnifiedPresetsBySlot = UnifiedPresetsBySlot()
|
||||
cloud_status: CloudStatus = "ok"
|
||||
orca_cloud_status: CloudStatus = "ok"
|
||||
|
|
|
|||
504
backend/app/services/orca_cloud.py
Normal file
504
backend/app/services/orca_cloud.py
Normal file
|
|
@ -0,0 +1,504 @@
|
|||
"""
|
||||
Orca Cloud API Service
|
||||
|
||||
Handles authentication and profile sync with the Orca Cloud (Supabase-backed).
|
||||
|
||||
Auth shape: PKCE flow against ``auth.orcaslicer.com`` with the in-source public
|
||||
publishable key. Bambuddy generates the verifier/challenge/state, redirects the
|
||||
user's browser to Supabase's ``/auth/v1/authorize`` endpoint with
|
||||
``redirect_to=http://localhost:41172/callback``, and the user pastes the
|
||||
callback URL back into Bambuddy (the loopback URL is the only ``redirect_to``
|
||||
Orca's Supabase project actually honors as of v2.4.0-alpha — see
|
||||
OrcaSlicer/OrcaSlicer#14028 for the open feature request asking SoftFever to
|
||||
broaden this).
|
||||
|
||||
Token shape: short-lived access JWT (1h) + rotating single-use refresh token.
|
||||
Every refresh issues a new pair and invalidates the old one — the route layer
|
||||
is responsible for atomically swapping the stored pair on each refresh, or a
|
||||
mid-refresh crash strands the user.
|
||||
|
||||
Cloudflare protects ``api.orcaslicer.com`` with a User-Agent gate; sending an
|
||||
honest ``Bambuddy/<version>`` UA clears it. No TLS-fingerprint matching needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Auth + API endpoints — extracted verbatim from OrcaCloudServiceAgent.cpp
|
||||
# v2.4.0-alpha. The "publishable" key is documented in-source as a public
|
||||
# client identifier (Supabase anon-key pattern); embedding it in our client
|
||||
# is by-design and not a secret leak.
|
||||
ORCA_AUTH_BASE = "https://auth.orcaslicer.com"
|
||||
ORCA_API_BASE = "https://api.orcaslicer.com"
|
||||
ORCA_ANON_KEY = "sb_publishable_lvVe_whOi80SU9BPSxM1kA_tbt9AbR_"
|
||||
|
||||
# Loopback redirect from OrcaCloudServiceAgent.cpp. Supabase's redirect_to
|
||||
# allowlist on Orca's project only honors localhost URIs — anything else
|
||||
# silently falls through to the project Site URL after the OAuth dance.
|
||||
ORCA_REDIRECT_URI = "http://localhost:41172/callback"
|
||||
|
||||
# Honest client identity. Same posture as Bambu Cloud: identifies Bambuddy
|
||||
# without impersonating Orca's desktop client (which would be CWE-style
|
||||
# falsified-identity and was the exact thing called out in Bambu Lab's May 2026
|
||||
# blog post about cloud-access etiquette).
|
||||
_USER_AGENT = "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)"
|
||||
|
||||
# Refresh access tokens when they have less than this much life left, on the
|
||||
# theory that a slow downstream API call shouldn't expire the token mid-flight.
|
||||
_REFRESH_LEEWAY = timedelta(minutes=5)
|
||||
|
||||
# PKCE handshake state TTL. If the user clicks "Connect" then walks away,
|
||||
# the stored verifier+state is invalid after this window — they have to
|
||||
# restart. 10 minutes is the OAuth norm for desktop-app PKCE flows.
|
||||
PENDING_PKCE_TTL = timedelta(minutes=10)
|
||||
|
||||
|
||||
class OrcaCloudError(Exception):
|
||||
"""Base exception for Orca Cloud errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class OrcaCloudAuthError(OrcaCloudError):
|
||||
"""Authentication / token-related errors. Caller should typically prompt
|
||||
the user to reconnect — neither a fresh access token nor a refresh will
|
||||
recover without re-authentication."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
_shared_http_client: httpx.AsyncClient | None = None
|
||||
|
||||
|
||||
def set_shared_http_client(client: httpx.AsyncClient | None) -> None:
|
||||
"""Register an app-scoped ``httpx.AsyncClient`` so per-request
|
||||
``OrcaCloudService`` instances can reuse its connection pool. Mirrors the
|
||||
pattern used by :mod:`backend.app.services.bambu_cloud`."""
|
||||
global _shared_http_client
|
||||
_shared_http_client = client
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PKCE helpers (free functions — no service-instance state needed)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _b64url(data: bytes) -> str:
|
||||
"""RFC 7636-style base64url encoding, no padding."""
|
||||
return base64.urlsafe_b64encode(data).decode().rstrip("=")
|
||||
|
||||
|
||||
def generate_pkce() -> tuple[str, str, str]:
|
||||
"""Generate a fresh ``(verifier, challenge, state)`` triple for one PKCE
|
||||
handshake. The verifier is the secret kept by Bambuddy until the code
|
||||
exchange; the challenge is sent to Supabase as ``code_challenge``; the
|
||||
state is the CSRF nonce we'll verify against the callback.
|
||||
|
||||
Verifier = 32 random bytes (43 base64url chars), within RFC 7636's
|
||||
43-128 char range. Challenge = ``base64url(sha256(verifier))``.
|
||||
"""
|
||||
verifier = _b64url(secrets.token_bytes(32))
|
||||
challenge = _b64url(hashlib.sha256(verifier.encode()).digest())
|
||||
state = _b64url(secrets.token_bytes(16))
|
||||
return verifier, challenge, state
|
||||
|
||||
|
||||
def build_authorize_url(challenge: str, provider: str = "google") -> str:
|
||||
"""Construct the URL the user's browser should visit to start the OAuth
|
||||
handshake.
|
||||
|
||||
Notably **does not** pass a ``state`` query parameter. Supabase's GoTrue
|
||||
uses its own internal state encoding to remember which ``redirect_to``
|
||||
belongs to which OAuth session; a client-passed ``state`` overwrites
|
||||
that, GoTrue can no longer decode the redirect_to from Google's
|
||||
callback, and silently falls back to the project Site URL — which is
|
||||
exactly the bug that broke the live test against our deployed integration.
|
||||
|
||||
CSRF is still protected by the PKCE flow itself: the server-side
|
||||
``code_verifier`` is single-use and bound to the user's session, so an
|
||||
attacker with a code-only URL can't complete the exchange.
|
||||
"""
|
||||
from urllib.parse import urlencode
|
||||
|
||||
qs = urlencode(
|
||||
{
|
||||
"provider": provider,
|
||||
"redirect_to": ORCA_REDIRECT_URI,
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
)
|
||||
return f"{ORCA_AUTH_BASE}/auth/v1/authorize?{qs}"
|
||||
|
||||
|
||||
def parse_callback_url(callback_url: str) -> tuple[str | None, str | None]:
|
||||
"""Extract ``(code, state)`` from a pasted callback URL. Both query string
|
||||
and fragment are checked — some Supabase configurations put PKCE codes in
|
||||
the fragment rather than the query string. Returns ``(None, None)`` if
|
||||
nothing parses out; the route layer surfaces the user-facing error."""
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
parsed = urlparse(callback_url.strip())
|
||||
qsd = parse_qs(parsed.query)
|
||||
code = qsd.get("code", [""])[0] or None
|
||||
state = qsd.get("state", [""])[0] or None
|
||||
if not code:
|
||||
frag = parse_qs(parsed.fragment)
|
||||
code = frag.get("code", [""])[0] or None
|
||||
state = state or (frag.get("state", [""])[0] or None)
|
||||
return code, state
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Service class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class OrcaCloudService:
|
||||
"""Stateful per-request client for the Orca Cloud API.
|
||||
|
||||
Instantiated by the route layer, populated with a stored token via
|
||||
:meth:`set_tokens`, then used to call the sync endpoints. Token rotation
|
||||
on refresh is the route layer's responsibility (see
|
||||
:meth:`refresh` — returns the new pair, doesn't persist).
|
||||
"""
|
||||
|
||||
def __init__(self, client: httpx.AsyncClient | None = None):
|
||||
self.access_token: str | None = None
|
||||
self.refresh_token: str | None = None
|
||||
self.token_expiry: datetime | None = None
|
||||
# Mirror the bambu_cloud pattern for client ownership: prefer injected
|
||||
# client (tests), fall back to app-scoped shared client (production),
|
||||
# else create our own so ad-hoc scripts still work.
|
||||
if client is not None:
|
||||
self._client = client
|
||||
self._owns_client = False
|
||||
elif _shared_http_client is not None:
|
||||
self._client = _shared_http_client
|
||||
self._owns_client = False
|
||||
else:
|
||||
self._client = httpx.AsyncClient(timeout=30.0)
|
||||
self._owns_client = True
|
||||
|
||||
@property
|
||||
def is_authenticated(self) -> bool:
|
||||
"""True iff we have an access token that won't expire within
|
||||
:data:`_REFRESH_LEEWAY`. The leeway prevents a slow API call from
|
||||
timing out mid-flight on a token that was nominally still valid."""
|
||||
if not self.access_token:
|
||||
return False
|
||||
if self.token_expiry is None:
|
||||
# No expiry recorded — pessimistically treat as expired so the
|
||||
# caller refreshes before use.
|
||||
return False
|
||||
return datetime.now(timezone.utc) + _REFRESH_LEEWAY < self.token_expiry
|
||||
|
||||
def set_tokens(
|
||||
self,
|
||||
access_token: str | None,
|
||||
refresh_token: str | None,
|
||||
expires_at: datetime | None,
|
||||
) -> None:
|
||||
"""Hydrate the service from stored credentials."""
|
||||
self.access_token = access_token
|
||||
self.refresh_token = refresh_token
|
||||
# Normalize to timezone-aware UTC so subsequent comparisons against
|
||||
# ``datetime.now(timezone.utc)`` are well-defined. asyncpg returns
|
||||
# naive datetimes from a ``TIMESTAMP WITHOUT TIME ZONE`` column —
|
||||
# we treat naive values as UTC since that's how we stored them.
|
||||
if expires_at is not None and expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
self.token_expiry = expires_at
|
||||
|
||||
def clear_tokens(self) -> None:
|
||||
"""Forget all credentials. Used on logout and after auth failures."""
|
||||
self.access_token = None
|
||||
self.refresh_token = None
|
||||
self.token_expiry = None
|
||||
|
||||
def _auth_headers(self) -> dict[str, str]:
|
||||
"""Headers for calls to ``auth.orcaslicer.com``. Always includes the
|
||||
apikey; the ``Authorization`` header is added only if we already have
|
||||
an access token (used by ``/logout``, not by token exchange)."""
|
||||
headers = {
|
||||
"User-Agent": _USER_AGENT,
|
||||
"apikey": ORCA_ANON_KEY,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
if self.access_token:
|
||||
headers["Authorization"] = f"Bearer {self.access_token}"
|
||||
return headers
|
||||
|
||||
def _api_headers(self) -> dict[str, str]:
|
||||
"""Headers for calls to ``api.orcaslicer.com``. Requires a bearer
|
||||
token — callers should ensure the service is authenticated first."""
|
||||
if not self.access_token:
|
||||
raise OrcaCloudAuthError("Orca Cloud API requires an access token")
|
||||
return {
|
||||
"User-Agent": _USER_AGENT,
|
||||
"apikey": ORCA_ANON_KEY,
|
||||
"Authorization": f"Bearer {self.access_token}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Token lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def password_login(self, email: str, password: str) -> dict[str, Any]:
|
||||
"""Direct email+password login via ``/auth/v1/token?grant_type=password``.
|
||||
|
||||
Whether this works depends on the Supabase project's auth config —
|
||||
Orca's web sign-in offers email/password as one option, but their
|
||||
desktop client refuses ``{username, password}`` payloads with
|
||||
``"Username/password login is disabled. Use the Orca cloud PKCE
|
||||
flow."`` (the SDK enforces PKCE regardless of what the backend
|
||||
allows). The actual server behaviour is what matters for Bambuddy
|
||||
— we POST the credentials and surface whatever response we get;
|
||||
an ``OrcaCloudAuthError`` with the verbatim Supabase error message
|
||||
is the right signal for callers to fall back to an OAuth provider.
|
||||
"""
|
||||
url = f"{ORCA_AUTH_BASE}/auth/v1/token?grant_type=password"
|
||||
payload = {"email": email, "password": password}
|
||||
try:
|
||||
resp = await self._client.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers={
|
||||
"User-Agent": _USER_AGENT,
|
||||
"apikey": ORCA_ANON_KEY,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
raise OrcaCloudError(f"Network error during Orca Cloud password login: {e}") from e
|
||||
|
||||
if resp.status_code >= 400:
|
||||
detail = _describe_token_error(resp)
|
||||
if resp.status_code in (400, 401, 403, 422):
|
||||
raise OrcaCloudAuthError(f"Orca Cloud password login rejected: {detail}")
|
||||
raise OrcaCloudError(f"Orca Cloud password login failed ({resp.status_code}): {detail}")
|
||||
|
||||
data = resp.json()
|
||||
self._apply_token_response(data)
|
||||
return data
|
||||
|
||||
async def exchange_code(self, auth_code: str, code_verifier: str) -> dict[str, Any]:
|
||||
"""Exchange a PKCE auth code for tokens. Mutates ``self`` so the
|
||||
service is ready for API calls. Returns the raw Supabase token
|
||||
response so the route layer can persist the new credentials."""
|
||||
url = f"{ORCA_AUTH_BASE}/auth/v1/token?grant_type=pkce"
|
||||
payload = {"auth_code": auth_code, "code_verifier": code_verifier}
|
||||
try:
|
||||
resp = await self._client.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers={
|
||||
"User-Agent": _USER_AGENT,
|
||||
"apikey": ORCA_ANON_KEY,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
raise OrcaCloudError(f"Network error during Orca Cloud token exchange: {e}") from e
|
||||
|
||||
if resp.status_code >= 400:
|
||||
# Supabase returns ``{"error":"...", "error_description":"..."}``
|
||||
# on most failures and ``{"msg":"..."}`` on a few. Surface
|
||||
# whatever we can find.
|
||||
detail = _describe_token_error(resp)
|
||||
if resp.status_code in (400, 401, 403):
|
||||
raise OrcaCloudAuthError(f"Orca Cloud token exchange rejected: {detail}")
|
||||
raise OrcaCloudError(f"Orca Cloud token exchange failed ({resp.status_code}): {detail}")
|
||||
|
||||
data = resp.json()
|
||||
self._apply_token_response(data)
|
||||
return data
|
||||
|
||||
async def refresh(self) -> dict[str, Any]:
|
||||
"""Use the stored refresh token to obtain a fresh access/refresh pair.
|
||||
|
||||
Supabase issues single-use refresh tokens — the old refresh token is
|
||||
invalidated the moment this call succeeds. The caller MUST persist the
|
||||
new pair atomically with consuming the old one; otherwise a crash
|
||||
between this return and the DB write strands the user. Returns the
|
||||
raw token-response dict so the caller has the full new pair.
|
||||
"""
|
||||
if not self.refresh_token:
|
||||
raise OrcaCloudAuthError("Cannot refresh: no refresh token stored")
|
||||
|
||||
url = f"{ORCA_AUTH_BASE}/auth/v1/token?grant_type=refresh_token"
|
||||
payload = {"refresh_token": self.refresh_token}
|
||||
try:
|
||||
resp = await self._client.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers={
|
||||
"User-Agent": _USER_AGENT,
|
||||
"apikey": ORCA_ANON_KEY,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
raise OrcaCloudError(f"Network error during Orca Cloud refresh: {e}") from e
|
||||
|
||||
if resp.status_code >= 400:
|
||||
detail = _describe_token_error(resp)
|
||||
# 400/401 typically means "refresh token rotated or revoked" —
|
||||
# the user has to reconnect. Don't try to recover here.
|
||||
if resp.status_code in (400, 401, 403):
|
||||
self.clear_tokens()
|
||||
raise OrcaCloudAuthError(f"Orca Cloud refresh rejected: {detail}")
|
||||
raise OrcaCloudError(f"Orca Cloud refresh failed ({resp.status_code}): {detail}")
|
||||
|
||||
data = resp.json()
|
||||
self._apply_token_response(data)
|
||||
return data
|
||||
|
||||
def _apply_token_response(self, data: dict[str, Any]) -> None:
|
||||
"""Update ``self.access_token`` / ``self.refresh_token`` /
|
||||
``self.token_expiry`` from a Supabase token-response payload. Caller
|
||||
is still responsible for persisting the values to the DB."""
|
||||
access = data.get("access_token")
|
||||
refresh = data.get("refresh_token")
|
||||
expires_in = data.get("expires_in")
|
||||
if not access:
|
||||
raise OrcaCloudAuthError("Orca Cloud token response missing access_token")
|
||||
self.access_token = access
|
||||
# Supabase always rotates refresh tokens on /token calls; if the
|
||||
# response omits one we keep the previous value to avoid stranding
|
||||
# the session, but that shouldn't happen in practice.
|
||||
if refresh:
|
||||
self.refresh_token = refresh
|
||||
if isinstance(expires_in, (int, float)) and expires_in > 0:
|
||||
self.token_expiry = datetime.now(timezone.utc) + timedelta(seconds=int(expires_in))
|
||||
else:
|
||||
self.token_expiry = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Sync API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def get_user_info(self) -> dict[str, Any]:
|
||||
"""Return Supabase's user record for the current token (id, email,
|
||||
metadata, ...). Used after token exchange to record the user's email
|
||||
for display in Bambuddy's UI."""
|
||||
url = f"{ORCA_AUTH_BASE}/auth/v1/user"
|
||||
try:
|
||||
resp = await self._client.get(url, headers=self._auth_headers())
|
||||
except httpx.HTTPError as e:
|
||||
raise OrcaCloudError(f"Network error fetching Orca Cloud user info: {e}") from e
|
||||
if resp.status_code == 401:
|
||||
raise OrcaCloudAuthError("Orca Cloud user fetch unauthorized — token expired or revoked")
|
||||
if resp.status_code >= 400:
|
||||
raise OrcaCloudError(f"Orca Cloud user fetch failed ({resp.status_code}): {resp.text[:200]}")
|
||||
return resp.json()
|
||||
|
||||
async def list_profiles(self) -> list[dict[str, Any]]:
|
||||
"""Return the user's Orca Cloud profiles as a flat list of
|
||||
``ProfileUpsert`` entries (``{id, name, content, updated_time,
|
||||
created_time}``) — forwarded verbatim; callers pick the fields they
|
||||
need.
|
||||
|
||||
Uses ``GET /api/v1/sync/pull`` with NO ``?cursor=`` parameter, which
|
||||
is the same "first-sync bootstrap" path OrcaSlicer's own client
|
||||
uses (``OrcaCloudServiceAgent.cpp::sync_pull``):
|
||||
|
||||
std::string path = ORCA_SYNC_PULL_PATH;
|
||||
if (sync_state.last_sync_timestamp != 0) {
|
||||
path += "?cursor=" + std::to_string(sync_state.last_sync_timestamp);
|
||||
}
|
||||
...
|
||||
// Handle 410 Gone — cursor too old, need full resync
|
||||
if (http_code == 410) {
|
||||
clear_sync_state();
|
||||
path = ORCA_SYNC_PULL_PATH; // retry without cursor
|
||||
...
|
||||
}
|
||||
|
||||
Sending ``cursor=0`` explicitly trips ``410 cursor_too_old`` — the
|
||||
server-side sync log doesn't reach back to the Unix epoch. Omitting
|
||||
the parameter entirely is the documented "give me the full snapshot"
|
||||
semantic. The previously-attempted ``/api/v1/sync/profiles`` is
|
||||
declared as a constant in Orca's source but isn't deployed on the
|
||||
production cloud (returns 404).
|
||||
|
||||
The pull response is a ``SyncPullResponse`` (``{next_cursor, upserts,
|
||||
deletes}``); we extract ``upserts`` and ignore ``deletes`` (no prior
|
||||
state on the client side to invalidate).
|
||||
"""
|
||||
url = f"{ORCA_API_BASE}/api/v1/sync/pull"
|
||||
try:
|
||||
resp = await self._client.get(url, headers=self._api_headers())
|
||||
except httpx.HTTPError as e:
|
||||
raise OrcaCloudError(f"Network error listing Orca Cloud profiles: {e}") from e
|
||||
if resp.status_code == 401:
|
||||
raise OrcaCloudAuthError("Orca Cloud profile list unauthorized — token expired or revoked")
|
||||
if resp.status_code >= 400:
|
||||
raise OrcaCloudError(f"Orca Cloud profile list failed ({resp.status_code}): {resp.text[:200]}")
|
||||
data = resp.json()
|
||||
if isinstance(data, dict):
|
||||
upserts = data.get("upserts")
|
||||
if isinstance(upserts, list):
|
||||
return upserts
|
||||
# Tolerate the shape we'd see if Orca ever rolls out a flat-list
|
||||
# endpoint at this path — forward whatever array is on the dict.
|
||||
for key in ("profiles", "data"):
|
||||
value = data.get(key)
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
logger.warning("Orca Cloud /sync/pull returned unexpected shape: %r", type(data).__name__)
|
||||
return []
|
||||
|
||||
async def get_profile(self, profile_id: str) -> dict[str, Any]:
|
||||
"""Fetch a single profile's full content. Orca's sync API doesn't
|
||||
expose a per-profile GET, so we list and filter. For small profile
|
||||
counts (the realistic case) this is fine; if it becomes a hot path
|
||||
we'll add client-side caching at the route layer rather than hammer
|
||||
the list endpoint.
|
||||
"""
|
||||
profiles = await self.list_profiles()
|
||||
for profile in profiles:
|
||||
if str(profile.get("id")) == str(profile_id):
|
||||
return profile
|
||||
raise OrcaCloudError(f"Orca Cloud profile {profile_id!r} not found (scanned {len(profiles)} profiles)")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Release the underlying httpx client iff we own it. No-op if we're
|
||||
using an injected or app-shared client (those are managed elsewhere)."""
|
||||
if self._owns_client:
|
||||
await self._client.aclose()
|
||||
|
||||
|
||||
def _describe_token_error(resp: httpx.Response) -> str:
|
||||
"""Best-effort extraction of a user-facing message from a Supabase token
|
||||
endpoint error response. Tries JSON fields in order; falls back to the
|
||||
raw body (truncated) if nothing parses."""
|
||||
try:
|
||||
data = resp.json()
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return (resp.text or "<empty body>")[:200]
|
||||
if not isinstance(data, dict):
|
||||
return str(data)[:200]
|
||||
for key in ("error_description", "msg", "error", "message"):
|
||||
val = data.get(key)
|
||||
if isinstance(val, str) and val:
|
||||
return val
|
||||
return str(data)[:200]
|
||||
|
|
@ -30,6 +30,7 @@ from fastapi import HTTPException
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.api.routes.cloud import get_stored_token
|
||||
from backend.app.api.routes.orca_cloud import _build_authenticated_service as _build_orca_service
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.local_preset import LocalPreset
|
||||
from backend.app.models.user import User
|
||||
|
|
@ -39,6 +40,7 @@ from backend.app.services.bambu_cloud import (
|
|||
BambuCloudError,
|
||||
BambuCloudService,
|
||||
)
|
||||
from backend.app.services.orca_cloud import OrcaCloudAuthError, OrcaCloudError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -85,6 +87,8 @@ async def resolve_preset_ref(
|
|||
return await _resolve_local(db, ref, slot)
|
||||
if ref.source == "cloud":
|
||||
return await _resolve_cloud(db, user, ref, slot)
|
||||
if ref.source == "orca_cloud":
|
||||
return await _resolve_orca_cloud(db, user, ref, slot)
|
||||
if ref.source == "standard":
|
||||
return _resolve_standard(ref, slot)
|
||||
raise HTTPException(
|
||||
|
|
@ -163,6 +167,64 @@ async def _resolve_cloud(db: AsyncSession, user: User | None, ref: PresetRef, sl
|
|||
return json.dumps(payload)
|
||||
|
||||
|
||||
async def _resolve_orca_cloud(db: AsyncSession, user: User | None, ref: PresetRef, slot: str) -> str:
|
||||
"""Fetch a single profile from Orca Cloud and return its content JSON.
|
||||
|
||||
The route-layer service builder handles JIT token refresh and stale-credential
|
||||
cleanup, so any exception here means a genuine fetch / network / not-found
|
||||
problem — never a "stale token" situation the caller could retry through.
|
||||
Permission gate matches the rest of the Orca Cloud surface so a user with
|
||||
``LIBRARY_UPLOAD`` but no ``ORCA_CLOUD_AUTH`` cannot slice using cloud
|
||||
profiles even if their stored token survived a permission revocation.
|
||||
"""
|
||||
if user is not None and not user.has_permission(Permission.ORCA_CLOUD_AUTH.value):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Orca Cloud presets require the orca_cloud:auth permission ({slot})",
|
||||
)
|
||||
|
||||
try:
|
||||
svc = await _build_orca_service(db, user)
|
||||
except HTTPException:
|
||||
# Builder already produces the right user-facing error (401 not
|
||||
# connected, 401 session refresh failed, 502 unreachable).
|
||||
raise
|
||||
|
||||
try:
|
||||
profile = await svc.get_profile(ref.id)
|
||||
except OrcaCloudAuthError as e:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail=f"Orca Cloud session expired while fetching {slot} preset. Sign in again and retry.",
|
||||
) from e
|
||||
except OrcaCloudError as e:
|
||||
if "not found" in str(e).lower():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Orca Cloud {slot} preset {ref.id!r} not found.",
|
||||
) from e
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Orca Cloud unreachable while fetching {slot} preset: {e}",
|
||||
) from e
|
||||
finally:
|
||||
await svc.close()
|
||||
|
||||
# ``profile`` is the ProfileUpsert shape — the inner ``content`` is the
|
||||
# actual slicer-format JSON. Fall back to forwarding the wrapper if the
|
||||
# shape doesn't match what we expect (defensive, in case Orca evolves
|
||||
# the wire format).
|
||||
content = profile.get("content") if isinstance(profile, dict) else None
|
||||
if not isinstance(content, dict):
|
||||
logger.info(
|
||||
"Orca Cloud preset %r for %s returned unexpected shape, forwarding raw payload",
|
||||
ref.id,
|
||||
slot,
|
||||
)
|
||||
content = profile
|
||||
return json.dumps(content)
|
||||
|
||||
|
||||
def _resolve_standard(ref: PresetRef, slot: str) -> str:
|
||||
"""Build a minimal `{name, inherits, from, type}` stub. The sidecar's
|
||||
resolver walks `BUNDLED_PROFILES_PATH/<category>/<name>.json` and merges,
|
||||
|
|
|
|||
433
backend/tests/unit/services/test_orca_cloud.py
Normal file
433
backend/tests/unit/services/test_orca_cloud.py
Normal file
|
|
@ -0,0 +1,433 @@
|
|||
"""Tests for the Orca Cloud service — PKCE generation, authorize URL shape,
|
||||
token exchange / refresh round-trip, single-use refresh token rotation,
|
||||
and Cloudflare-cleaning User-Agent header."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from backend.app.services import orca_cloud
|
||||
from backend.app.services.orca_cloud import (
|
||||
ORCA_ANON_KEY,
|
||||
ORCA_AUTH_BASE,
|
||||
ORCA_REDIRECT_URI,
|
||||
OrcaCloudAuthError,
|
||||
OrcaCloudError,
|
||||
OrcaCloudService,
|
||||
build_authorize_url,
|
||||
generate_pkce,
|
||||
parse_callback_url,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PKCE primitives
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPkce:
|
||||
def test_challenge_is_sha256_of_verifier(self):
|
||||
"""The challenge must be base64url(sha256(verifier)) — this is the
|
||||
RFC 7636 invariant Supabase will check on the exchange step. A bug
|
||||
here means the exchange always fails with code_verifier mismatch."""
|
||||
verifier, challenge, _state = generate_pkce()
|
||||
expected = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=")
|
||||
assert challenge == expected
|
||||
|
||||
def test_verifier_length_in_rfc_range(self):
|
||||
verifier, _challenge, _state = generate_pkce()
|
||||
# 32 random bytes -> 43 chars after base64url-no-pad; RFC 7636
|
||||
# requires 43-128.
|
||||
assert 43 <= len(verifier) <= 128
|
||||
|
||||
def test_state_is_unique_per_call(self):
|
||||
"""Two consecutive calls must not share state — otherwise a stolen
|
||||
state from one flow could be replayed against another in-flight one."""
|
||||
_, _, s1 = generate_pkce()
|
||||
_, _, s2 = generate_pkce()
|
||||
assert s1 != s2
|
||||
|
||||
def test_characters_are_url_safe(self):
|
||||
"""Both verifier and challenge must be URL-safe base64 (no padding,
|
||||
no + or /) so they can be sent as query-string values without
|
||||
re-encoding."""
|
||||
verifier, challenge, state = generate_pkce()
|
||||
for value in (verifier, challenge, state):
|
||||
assert all(c.isalnum() or c in ("-", "_") for c in value), value
|
||||
|
||||
|
||||
class TestAuthorizeUrl:
|
||||
def test_url_targets_authorize_endpoint(self):
|
||||
url = build_authorize_url("CHALLENGE")
|
||||
assert url.startswith(f"{ORCA_AUTH_BASE}/auth/v1/authorize?")
|
||||
|
||||
def test_url_contains_required_pkce_params(self):
|
||||
"""The four PKCE params Supabase needs at authorize time. Missing any
|
||||
of these = Supabase 400s the request before redirecting to Google."""
|
||||
url = build_authorize_url("CHALLENGE")
|
||||
params = parse_qs(urlparse(url).query)
|
||||
assert params["provider"] == ["google"]
|
||||
assert params["redirect_to"] == [ORCA_REDIRECT_URI]
|
||||
assert params["code_challenge"] == ["CHALLENGE"]
|
||||
assert params["code_challenge_method"] == ["S256"]
|
||||
|
||||
def test_url_does_not_pass_state(self):
|
||||
"""Regression guard against re-introducing the bug we hit in the
|
||||
first deployed integration: passing ``state`` to GoTrue's authorize
|
||||
endpoint silently overrides its internal redirect_to tracking, so
|
||||
the user lands at the project Site URL instead of our localhost
|
||||
callback. CSRF is protected by PKCE alone — verifier is server-side
|
||||
and single-use."""
|
||||
url = build_authorize_url("CHALLENGE")
|
||||
params = parse_qs(urlparse(url).query)
|
||||
assert "state" not in params
|
||||
|
||||
|
||||
class TestParseCallback:
|
||||
def test_extracts_code_and_state_from_query(self):
|
||||
code, state = parse_callback_url("http://localhost:41172/callback?code=ABC&state=XYZ")
|
||||
assert code == "ABC"
|
||||
assert state == "XYZ"
|
||||
|
||||
def test_falls_back_to_fragment(self):
|
||||
"""Some Supabase configurations put PKCE codes in the URL fragment
|
||||
rather than the query (depends on response_mode setting). Both must
|
||||
be handled or some users get a confusing 'no code in URL' error."""
|
||||
code, state = parse_callback_url("http://localhost:41172/callback#code=ABC&state=XYZ")
|
||||
assert code == "ABC"
|
||||
assert state == "XYZ"
|
||||
|
||||
def test_returns_none_when_no_code(self):
|
||||
code, state = parse_callback_url("http://localhost:41172/callback?error=denied")
|
||||
assert code is None
|
||||
assert state is None
|
||||
|
||||
def test_handles_whitespace_padding(self):
|
||||
"""Users paste from address bars and sometimes accidentally include
|
||||
a leading/trailing space — the parser must be forgiving."""
|
||||
code, _state = parse_callback_url(" http://localhost:41172/callback?code=ABC&state=XYZ ")
|
||||
assert code == "ABC"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token exchange + refresh
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _mock_response(
|
||||
*,
|
||||
status_code: int = 200,
|
||||
json_data: dict | None = None,
|
||||
text_body: str = "",
|
||||
) -> MagicMock:
|
||||
"""Build an httpx-like response mock with the only attributes the
|
||||
service touches: ``status_code``, ``.json()``, ``.text``."""
|
||||
resp = MagicMock(spec=["status_code", "json", "text"])
|
||||
resp.status_code = status_code
|
||||
if json_data is not None:
|
||||
resp.json.return_value = json_data
|
||||
resp.text = json.dumps(json_data)
|
||||
else:
|
||||
resp.json.side_effect = ValueError("not json")
|
||||
resp.text = text_body
|
||||
return resp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def svc() -> OrcaCloudService:
|
||||
return OrcaCloudService(client=MagicMock(spec=httpx.AsyncClient))
|
||||
|
||||
|
||||
class TestExchangeCode:
|
||||
@pytest.mark.asyncio
|
||||
async def test_success_populates_tokens_and_expiry(self, svc):
|
||||
token_resp = _mock_response(
|
||||
json_data={
|
||||
"access_token": "ACCESS-1",
|
||||
"refresh_token": "REFRESH-1",
|
||||
"expires_in": 3600,
|
||||
"token_type": "bearer",
|
||||
}
|
||||
)
|
||||
svc._client.post = AsyncMock(return_value=token_resp)
|
||||
|
||||
await svc.exchange_code("CODE", "VERIFIER")
|
||||
|
||||
assert svc.access_token == "ACCESS-1"
|
||||
assert svc.refresh_token == "REFRESH-1"
|
||||
assert svc.token_expiry is not None
|
||||
# Expiry should be approximately now + 3600s (within a 60s window).
|
||||
delta = svc.token_expiry - datetime.now(timezone.utc)
|
||||
assert timedelta(seconds=3540) <= delta <= timedelta(seconds=3660)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sends_apikey_and_user_agent_headers(self, svc):
|
||||
"""Two load-bearing headers: the publishable apikey (Supabase
|
||||
requires it) and a non-default User-Agent (Cloudflare 1010s
|
||||
``Python-urllib/X.Y`` so an honest ``Bambuddy/<v>`` UA is needed)."""
|
||||
token_resp = _mock_response(json_data={"access_token": "A", "refresh_token": "R", "expires_in": 3600})
|
||||
svc._client.post = AsyncMock(return_value=token_resp)
|
||||
|
||||
await svc.exchange_code("CODE", "VERIFIER")
|
||||
|
||||
_args, kwargs = svc._client.post.call_args
|
||||
headers = kwargs["headers"]
|
||||
assert headers["apikey"] == ORCA_ANON_KEY
|
||||
assert headers["User-Agent"].startswith("Bambuddy/")
|
||||
assert headers["Content-Type"] == "application/json"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_400_raises_auth_error_not_generic(self, svc):
|
||||
"""400 from Supabase usually means a bad verifier or stale code —
|
||||
the user has to restart sign-in. Raising auth-specific exception
|
||||
lets the route map to a sensible 400 with a 'click Connect again'
|
||||
message rather than a generic 502."""
|
||||
err_resp = _mock_response(
|
||||
status_code=400,
|
||||
json_data={"error": "invalid_grant", "error_description": "code expired"},
|
||||
)
|
||||
svc._client.post = AsyncMock(return_value=err_resp)
|
||||
|
||||
with pytest.raises(OrcaCloudAuthError) as exc:
|
||||
await svc.exchange_code("CODE", "VERIFIER")
|
||||
assert "code expired" in str(exc.value)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_network_error_wraps_as_orca_error(self, svc):
|
||||
svc._client.post = AsyncMock(side_effect=httpx.ConnectError("boom"))
|
||||
with pytest.raises(OrcaCloudError):
|
||||
await svc.exchange_code("CODE", "VERIFIER")
|
||||
|
||||
|
||||
class TestPasswordLogin:
|
||||
@pytest.mark.asyncio
|
||||
async def test_success_populates_tokens(self, svc):
|
||||
resp = _mock_response(
|
||||
json_data={
|
||||
"access_token": "PWD-A",
|
||||
"refresh_token": "PWD-R",
|
||||
"expires_in": 3600,
|
||||
}
|
||||
)
|
||||
svc._client.post = AsyncMock(return_value=resp)
|
||||
|
||||
await svc.password_login("user@example.com", "secret")
|
||||
|
||||
assert svc.access_token == "PWD-A"
|
||||
assert svc.refresh_token == "PWD-R"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disabled_provider_raises_auth_error_not_generic(self, svc):
|
||||
"""Whether Orca's Supabase project accepts password grant is config-
|
||||
dependent. When it doesn't (their desktop SDK refuses passwords by
|
||||
design, the backend may follow suit), the failure mode is a 400 /
|
||||
422 with an error like ``email_provider_disabled``. The caller maps
|
||||
``OrcaCloudAuthError`` to a 400 with a "use OAuth instead" hint —
|
||||
a 502 would imply Orca is down, which would be wrong UX."""
|
||||
err = _mock_response(
|
||||
status_code=422,
|
||||
json_data={"error": "email_provider_disabled", "error_description": "Email logins are disabled"},
|
||||
)
|
||||
svc._client.post = AsyncMock(return_value=err)
|
||||
with pytest.raises(OrcaCloudAuthError, match="Email logins are disabled"):
|
||||
await svc.password_login("user@example.com", "secret")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_credentials_raises_auth_error(self, svc):
|
||||
err = _mock_response(
|
||||
status_code=400,
|
||||
json_data={"error": "invalid_grant", "error_description": "Invalid login credentials"},
|
||||
)
|
||||
svc._client.post = AsyncMock(return_value=err)
|
||||
with pytest.raises(OrcaCloudAuthError, match="Invalid login credentials"):
|
||||
await svc.password_login("user@example.com", "wrong")
|
||||
|
||||
|
||||
class TestRefresh:
|
||||
@pytest.mark.asyncio
|
||||
async def test_rotates_refresh_token(self, svc):
|
||||
"""Supabase refresh tokens are single-use — every successful refresh
|
||||
returns a NEW refresh token and invalidates the old. If the service
|
||||
kept the old one, the next refresh would 400 and the user would be
|
||||
force-logged-out."""
|
||||
svc.refresh_token = "REFRESH-1"
|
||||
resp = _mock_response(
|
||||
json_data={
|
||||
"access_token": "ACCESS-2",
|
||||
"refresh_token": "REFRESH-2",
|
||||
"expires_in": 3600,
|
||||
}
|
||||
)
|
||||
svc._client.post = AsyncMock(return_value=resp)
|
||||
|
||||
await svc.refresh()
|
||||
|
||||
assert svc.access_token == "ACCESS-2"
|
||||
assert svc.refresh_token == "REFRESH-2"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_refresh_token_raises_auth_error(self, svc):
|
||||
svc.refresh_token = None
|
||||
with pytest.raises(OrcaCloudAuthError):
|
||||
await svc.refresh()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejected_refresh_clears_tokens(self, svc):
|
||||
"""If Supabase rejects the refresh token (revoked / rotated out from
|
||||
under us / hit by a token-replay defense), the service must clear
|
||||
the now-useless stored credentials so the UI can flip to the
|
||||
disconnected state rather than retrying forever."""
|
||||
svc.access_token = "OLD-ACCESS"
|
||||
svc.refresh_token = "OLD-REFRESH"
|
||||
svc.token_expiry = datetime.now(timezone.utc)
|
||||
err = _mock_response(
|
||||
status_code=401,
|
||||
json_data={"error": "invalid_grant", "error_description": "refresh token rotated"},
|
||||
)
|
||||
svc._client.post = AsyncMock(return_value=err)
|
||||
|
||||
with pytest.raises(OrcaCloudAuthError):
|
||||
await svc.refresh()
|
||||
|
||||
assert svc.access_token is None
|
||||
assert svc.refresh_token is None
|
||||
assert svc.token_expiry is None
|
||||
|
||||
|
||||
class TestIsAuthenticated:
|
||||
def test_no_token_means_not_authenticated(self, svc):
|
||||
assert svc.is_authenticated is False
|
||||
|
||||
def test_no_expiry_means_not_authenticated(self, svc):
|
||||
"""Pessimistic default: if we don't know when the token expires,
|
||||
treat it as expired so the next API call triggers a refresh
|
||||
rather than fails halfway through."""
|
||||
svc.access_token = "ACCESS"
|
||||
svc.token_expiry = None
|
||||
assert svc.is_authenticated is False
|
||||
|
||||
def test_within_refresh_leeway_is_not_authenticated(self, svc):
|
||||
"""The 5-minute leeway prevents a long-running API call from timing
|
||||
out mid-flight on a token that was technically still valid when the
|
||||
call started."""
|
||||
svc.access_token = "ACCESS"
|
||||
svc.token_expiry = datetime.now(timezone.utc) + timedelta(minutes=2)
|
||||
assert svc.is_authenticated is False
|
||||
|
||||
def test_with_comfortable_expiry_is_authenticated(self, svc):
|
||||
svc.access_token = "ACCESS"
|
||||
svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
assert svc.is_authenticated is True
|
||||
|
||||
|
||||
class TestApiHeaders:
|
||||
def test_api_headers_include_apikey_and_bearer(self, svc):
|
||||
svc.access_token = "ACCESS-123"
|
||||
headers = svc._api_headers()
|
||||
assert headers["apikey"] == ORCA_ANON_KEY
|
||||
assert headers["Authorization"] == "Bearer ACCESS-123"
|
||||
assert headers["User-Agent"].startswith("Bambuddy/")
|
||||
|
||||
def test_api_headers_without_token_raises(self, svc):
|
||||
svc.access_token = None
|
||||
with pytest.raises(OrcaCloudAuthError):
|
||||
svc._api_headers()
|
||||
|
||||
|
||||
class TestListProfiles:
|
||||
@pytest.mark.asyncio
|
||||
async def test_pull_response_upserts_extracted(self, svc):
|
||||
"""The bare-cursor /sync/pull returns a ``SyncPullResponse`` shape;
|
||||
we extract the ``upserts`` list and ignore ``next_cursor`` / ``deletes``
|
||||
(no prior client state to invalidate)."""
|
||||
svc.access_token = "ACCESS"
|
||||
svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
svc._client.get = AsyncMock(
|
||||
return_value=_mock_response(
|
||||
json_data={
|
||||
"next_cursor": 12345,
|
||||
"upserts": [
|
||||
{"id": "a", "name": "A", "content": {"x": 1}},
|
||||
{"id": "b", "name": "B", "content": {"x": 2}},
|
||||
],
|
||||
"deletes": ["zzz"],
|
||||
},
|
||||
)
|
||||
)
|
||||
result = await svc.list_profiles()
|
||||
assert [p["id"] for p in result] == ["a", "b"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pull_hits_path_without_cursor(self, svc):
|
||||
"""Regression guard: ``cursor=0`` trips ``410 cursor_too_old`` on
|
||||
the production endpoint. The first-sync bootstrap must hit
|
||||
``/api/v1/sync/pull`` with no ``?cursor=`` parameter — same behaviour
|
||||
as OrcaSlicer's own client."""
|
||||
svc.access_token = "ACCESS"
|
||||
svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
svc._client.get = AsyncMock(
|
||||
return_value=_mock_response(json_data={"upserts": [], "deletes": []}),
|
||||
)
|
||||
await svc.list_profiles()
|
||||
called_url = svc._client.get.call_args.args[0]
|
||||
assert called_url.endswith("/api/v1/sync/pull")
|
||||
assert "cursor" not in called_url
|
||||
# And no ``params`` kwarg either, which would be a second way to
|
||||
# smuggle the cursor in.
|
||||
assert "params" not in svc._client.get.call_args.kwargs
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bare_list_response_tolerated(self, svc):
|
||||
"""If the server ever rolls out a flat-list response shape, we
|
||||
forward it verbatim rather than logging-and-empty."""
|
||||
svc.access_token = "ACCESS"
|
||||
svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
svc._client.get = AsyncMock(
|
||||
return_value=_mock_response(json_data=[{"id": "a", "name": "A"}]),
|
||||
)
|
||||
assert [p["id"] for p in await svc.list_profiles()] == ["a"]
|
||||
|
||||
|
||||
class TestGetProfile:
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_matching_profile_with_content(self, svc):
|
||||
"""``get_profile`` lists then filters since Orca has no dedicated
|
||||
per-profile GET — verify the matched entry returns with full
|
||||
content, not stripped to metadata."""
|
||||
svc.access_token = "ACCESS"
|
||||
svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
svc._client.get = AsyncMock(
|
||||
return_value=_mock_response(
|
||||
json_data={
|
||||
"upserts": [
|
||||
{"id": "a", "name": "A", "content": {"foo": 1}},
|
||||
{"id": "target", "name": "Target", "content": {"hit": True}},
|
||||
],
|
||||
"deletes": [],
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
profile = await svc.get_profile("target")
|
||||
|
||||
assert profile["id"] == "target"
|
||||
assert profile["content"] == {"hit": True}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_not_found_raises(self, svc):
|
||||
svc.access_token = "ACCESS"
|
||||
svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
svc._client.get = AsyncMock(
|
||||
return_value=_mock_response(
|
||||
json_data={"upserts": [{"id": "a", "name": "A"}], "deletes": []},
|
||||
),
|
||||
)
|
||||
with pytest.raises(OrcaCloudError, match="not found"):
|
||||
await svc.get_profile("missing")
|
||||
|
|
@ -208,6 +208,83 @@ async def test_cloud_auth_error_returns_401():
|
|||
assert exc.value.status_code == 401
|
||||
|
||||
|
||||
# --- orca_cloud tier -------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_orca_cloud_blocks_user_without_orca_cloud_auth():
|
||||
"""Defence-in-depth, same shape as the Bambu Cloud permission check:
|
||||
a user holding LIBRARY_UPLOAD but not ORCA_CLOUD_AUTH can't slice with
|
||||
Orca Cloud presets even if their User row still carries a token."""
|
||||
db = MagicMock()
|
||||
user = MagicMock()
|
||||
user.has_permission = MagicMock(return_value=False)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await preset_resolver._resolve_orca_cloud(db, user, PresetRef(source="orca_cloud", id="abc"), slot="printer")
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_orca_cloud_unwraps_content():
|
||||
"""Orca's profile shape is ``{id, name, content, updated_time, created_time}``
|
||||
— the inner ``content`` is the actual slicer-format JSON. We forward
|
||||
that, not the wrapper."""
|
||||
db = MagicMock()
|
||||
user = MagicMock()
|
||||
user.has_permission = MagicMock(return_value=True)
|
||||
svc_mock = MagicMock()
|
||||
svc_mock.get_profile = AsyncMock(
|
||||
return_value={
|
||||
"id": "abc",
|
||||
"name": "X1C Custom",
|
||||
"content": {"name": "X1C Custom", "nozzle_diameter": [0.4]},
|
||||
}
|
||||
)
|
||||
svc_mock.close = AsyncMock()
|
||||
with patch.object(preset_resolver, "_build_orca_service", AsyncMock(return_value=svc_mock)):
|
||||
out = await preset_resolver._resolve_orca_cloud(
|
||||
db, user, PresetRef(source="orca_cloud", id="abc"), slot="printer"
|
||||
)
|
||||
payload = json.loads(out)
|
||||
assert payload == {"name": "X1C Custom", "nozzle_diameter": [0.4]}
|
||||
svc_mock.close.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_orca_cloud_auth_error_returns_401():
|
||||
db = MagicMock()
|
||||
user = MagicMock()
|
||||
user.has_permission = MagicMock(return_value=True)
|
||||
svc_mock = MagicMock()
|
||||
svc_mock.get_profile = AsyncMock(side_effect=preset_resolver.OrcaCloudAuthError("expired"))
|
||||
svc_mock.close = AsyncMock()
|
||||
with (
|
||||
patch.object(preset_resolver, "_build_orca_service", AsyncMock(return_value=svc_mock)),
|
||||
pytest.raises(HTTPException) as exc,
|
||||
):
|
||||
await preset_resolver._resolve_orca_cloud(db, user, PresetRef(source="orca_cloud", id="abc"), slot="printer")
|
||||
assert exc.value.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_orca_cloud_not_found_returns_400():
|
||||
"""``get_profile`` raises generic ``OrcaCloudError`` for "not found" —
|
||||
the resolver maps that to a 400 (not 502) so the UI can show "profile
|
||||
no longer exists" rather than "service down"."""
|
||||
db = MagicMock()
|
||||
user = MagicMock()
|
||||
user.has_permission = MagicMock(return_value=True)
|
||||
svc_mock = MagicMock()
|
||||
svc_mock.get_profile = AsyncMock(side_effect=preset_resolver.OrcaCloudError("profile 'abc' not found (scanned 0)"))
|
||||
svc_mock.close = AsyncMock()
|
||||
with (
|
||||
patch.object(preset_resolver, "_build_orca_service", AsyncMock(return_value=svc_mock)),
|
||||
pytest.raises(HTTPException) as exc,
|
||||
):
|
||||
await preset_resolver._resolve_orca_cloud(db, user, PresetRef(source="orca_cloud", id="abc"), slot="printer")
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
# --- top-level dispatcher -------------------------------------------------
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ class TestDedupeByName:
|
|||
local = _slot([("lid1", "Bambu PLA Basic", "local")])
|
||||
standard = _slot([("Bambu PLA Basic", "Bambu PLA Basic", "standard")])
|
||||
|
||||
c, l_, s = sp._dedupe_by_name(cloud, local, standard)
|
||||
_oc, c, l_, s = sp._dedupe_by_name(_slot([]), cloud, local, standard)
|
||||
|
||||
assert [p.source for p in c["printer"]] == ["cloud"]
|
||||
assert l_["printer"] == []
|
||||
|
|
@ -52,7 +52,7 @@ class TestDedupeByName:
|
|||
)
|
||||
standard = _slot([])
|
||||
|
||||
_c, l_, _s = sp._dedupe_by_name(cloud, local, standard)
|
||||
_oc, _c, l_, _s = sp._dedupe_by_name(_slot([]), cloud, local, standard)
|
||||
assert [p.name for p in l_["printer"]] == ["My Workhorse PLA"]
|
||||
|
||||
def test_standard_filtered_against_both_higher_tiers(self):
|
||||
|
|
@ -66,7 +66,7 @@ class TestDedupeByName:
|
|||
]
|
||||
)
|
||||
|
||||
_c, _l, s = sp._dedupe_by_name(cloud, local, standard)
|
||||
_oc, _c, _l, s = sp._dedupe_by_name(_slot([]), cloud, local, standard)
|
||||
assert [p.name for p in s["printer"]] == ["C"]
|
||||
|
||||
def test_preserves_order_within_tier(self):
|
||||
|
|
@ -79,7 +79,7 @@ class TestDedupeByName:
|
|||
("c3", "M-Third", "cloud"),
|
||||
]
|
||||
)
|
||||
c, _l, _s = sp._dedupe_by_name(cloud, _slot([]), _slot([]))
|
||||
_oc, c, _l, _s = sp._dedupe_by_name(_slot([]), cloud, _slot([]), _slot([]))
|
||||
assert [p.name for p in c["printer"]] == ["Z-First", "A-Second", "M-Third"]
|
||||
|
||||
def test_dedupe_is_per_slot(self):
|
||||
|
|
@ -95,7 +95,7 @@ class TestDedupeByName:
|
|||
"process": [],
|
||||
"filament": [],
|
||||
}
|
||||
_c, l_, _s = sp._dedupe_by_name(cloud, local, _slot([]))
|
||||
_oc, _c, l_, _s = sp._dedupe_by_name(_slot([]), cloud, local, _slot([]))
|
||||
# The filament-tier collision must NOT remove the printer-tier "Custom".
|
||||
assert [p.name for p in l_["printer"]] == ["Custom"]
|
||||
|
||||
|
|
@ -111,6 +111,133 @@ def _user_with_cloud_auth(user_id: int = 1) -> MagicMock:
|
|||
return user
|
||||
|
||||
|
||||
class TestFetchOrcaCloudPresets:
|
||||
"""``_fetch_orca_cloud_presets`` mirrors the Bambu Cloud fetcher's status
|
||||
vocabulary (``ok`` / ``not_authenticated`` / ``expired`` / ``unreachable``)
|
||||
and the same permission-shortcut + caching behaviour. Tests pin the
|
||||
contract so a future bug in either fetcher doesn't silently desync them."""
|
||||
|
||||
def _orca_creds(self, token: str | None = "tok") -> MagicMock:
|
||||
creds = MagicMock()
|
||||
creds.token = token
|
||||
return creds
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_token_returns_not_authenticated(self):
|
||||
sp._orca_cloud_cache.clear()
|
||||
with patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds(None))):
|
||||
user = MagicMock(id=1)
|
||||
user.has_permission = MagicMock(return_value=True)
|
||||
slots, status = await sp._fetch_orca_cloud_presets(MagicMock(), user)
|
||||
assert status == "not_authenticated"
|
||||
assert slots == {"printer": [], "process": [], "filament": []}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_without_orca_cloud_auth_returns_not_authenticated(self):
|
||||
"""Defence-in-depth — a user lacking ORCA_CLOUD_AUTH must not see Orca
|
||||
presets even if their User row carries a stale token. Credentials
|
||||
lookup must short-circuit ahead of the token read."""
|
||||
sp._orca_cloud_cache.clear()
|
||||
user = MagicMock(id=1)
|
||||
user.has_permission = MagicMock(return_value=False)
|
||||
with patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))) as load:
|
||||
slots, status = await sp._fetch_orca_cloud_presets(MagicMock(), user)
|
||||
assert status == "not_authenticated"
|
||||
assert slots["printer"] == []
|
||||
load.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_error_returns_expired(self):
|
||||
sp._orca_cloud_cache.clear()
|
||||
svc_mock = MagicMock()
|
||||
svc_mock.list_profiles = AsyncMock(side_effect=sp.OrcaCloudAuthError("expired"))
|
||||
svc_mock.close = AsyncMock()
|
||||
user = MagicMock(id=1)
|
||||
user.has_permission = MagicMock(return_value=True)
|
||||
with (
|
||||
patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))),
|
||||
patch.object(sp, "_build_orca_service", AsyncMock(return_value=svc_mock)),
|
||||
):
|
||||
_slots, status = await sp._fetch_orca_cloud_presets(MagicMock(), user)
|
||||
assert status == "expired"
|
||||
svc_mock.close.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_orca_error_returns_unreachable(self):
|
||||
sp._orca_cloud_cache.clear()
|
||||
svc_mock = MagicMock()
|
||||
svc_mock.list_profiles = AsyncMock(side_effect=sp.OrcaCloudError("net down"))
|
||||
svc_mock.close = AsyncMock()
|
||||
user = MagicMock(id=1)
|
||||
user.has_permission = MagicMock(return_value=True)
|
||||
with (
|
||||
patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))),
|
||||
patch.object(sp, "_build_orca_service", AsyncMock(return_value=svc_mock)),
|
||||
):
|
||||
_slots, status = await sp._fetch_orca_cloud_presets(MagicMock(), user)
|
||||
assert status == "unreachable"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_happy_path_shapes_grouped_by_type(self):
|
||||
"""Orca content.type values map onto Bambu Cloud's preset type vocab
|
||||
(``printer`` / ``print`` → ``process`` / ``filament``). Verify the
|
||||
full mapping by feeding one of each shape."""
|
||||
sp._orca_cloud_cache.clear()
|
||||
svc_mock = MagicMock()
|
||||
svc_mock.list_profiles = AsyncMock(
|
||||
return_value=[
|
||||
{"id": "m1", "name": "Orca X1C", "content": {"type": "printer"}},
|
||||
{"id": "p1", "name": "Orca 0.20mm", "content": {"type": "print"}},
|
||||
{
|
||||
"id": "f1",
|
||||
"name": "Orca PLA",
|
||||
"content": {
|
||||
"type": "filament",
|
||||
"filament_type": ["PLA"],
|
||||
"default_filament_colour": ["#000000"],
|
||||
},
|
||||
},
|
||||
]
|
||||
)
|
||||
svc_mock.close = AsyncMock()
|
||||
user = MagicMock(id=1)
|
||||
user.has_permission = MagicMock(return_value=True)
|
||||
with (
|
||||
patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))),
|
||||
patch.object(sp, "_build_orca_service", AsyncMock(return_value=svc_mock)),
|
||||
):
|
||||
slots, status = await sp._fetch_orca_cloud_presets(MagicMock(), user)
|
||||
assert status == "ok"
|
||||
assert [p.name for p in slots["printer"]] == ["Orca X1C"]
|
||||
assert [p.name for p in slots["process"]] == ["Orca 0.20mm"]
|
||||
filament = slots["filament"]
|
||||
assert [p.name for p in filament] == ["Orca PLA"]
|
||||
# Inline metadata extracted from the content blob (Orca's sync_pull
|
||||
# returns full content, so unlike Bambu Cloud we don't need a second
|
||||
# per-preset fetch to enrich filament_type / filament_colour).
|
||||
assert filament[0].filament_type == "PLA"
|
||||
assert filament[0].filament_colour == "#000000"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_hit_skips_orca_call(self):
|
||||
"""A second call within TTL must reuse the cached slots and NOT
|
||||
hit the Orca service again — same TTL as Bambu Cloud (5 min)."""
|
||||
sp._orca_cloud_cache.clear()
|
||||
svc_mock = MagicMock()
|
||||
svc_mock.list_profiles = AsyncMock(return_value=[])
|
||||
svc_mock.close = AsyncMock()
|
||||
user = MagicMock(id=1)
|
||||
user.has_permission = MagicMock(return_value=True)
|
||||
with (
|
||||
patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))),
|
||||
patch.object(sp, "_build_orca_service", AsyncMock(return_value=svc_mock)) as build,
|
||||
):
|
||||
await sp._fetch_orca_cloud_presets(MagicMock(), user)
|
||||
await sp._fetch_orca_cloud_presets(MagicMock(), user)
|
||||
# Build is the cache miss signal — second call reused the cache.
|
||||
build.assert_awaited_once()
|
||||
|
||||
|
||||
class TestFetchCloudPresets:
|
||||
"""`_fetch_cloud_presets` translates token state and cloud errors into
|
||||
the four ``cloud_status`` values the SliceModal banner consumes."""
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Bambuddy Service Worker
|
||||
const CACHE_NAME = 'bambuddy-v28';
|
||||
const STATIC_CACHE = 'bambuddy-static-v27';
|
||||
const CACHE_NAME = 'bambuddy-v29';
|
||||
const STATIC_CACHE = 'bambuddy-static-v28';
|
||||
|
||||
// Static assets to cache on install
|
||||
const STATIC_ASSETS = [
|
||||
|
|
@ -31,23 +31,46 @@ self.addEventListener('install', (event) => {
|
|||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// Activate event - clean up old caches
|
||||
// Activate event - clean up old caches, then force-reload any controlled
|
||||
// windows so they pick up the new bundle. Important for the SpoolBuddy kiosk
|
||||
// (Pi + Chromium-in-kiosk-mode, no devtools, no manual reload control):
|
||||
// without this hop, restarting Chromium installs the new SW but the existing
|
||||
// document keeps running the previously-cached bundle until a navigation
|
||||
// happens — which on a locked kiosk never occurs.
|
||||
self.addEventListener('activate', (event) => {
|
||||
console.log('[SW] Activating service worker...');
|
||||
event.waitUntil(
|
||||
caches.keys().then((cacheNames) => {
|
||||
return Promise.all(
|
||||
(async () => {
|
||||
const cacheNames = await caches.keys();
|
||||
await Promise.all(
|
||||
cacheNames
|
||||
.filter((name) => name !== CACHE_NAME && name !== STATIC_CACHE)
|
||||
.map((name) => {
|
||||
console.log('[SW] Deleting old cache:', name);
|
||||
return caches.delete(name);
|
||||
})
|
||||
}),
|
||||
);
|
||||
})
|
||||
// Take control immediately.
|
||||
await self.clients.claim();
|
||||
// Force a fresh navigation in any window that this SW now controls.
|
||||
// ``client.navigate(client.url)`` re-requests the page through the
|
||||
// network-first fetch handler, picking up the new index.html + the
|
||||
// new content-hashed JS bundle. Guarded so the very first install on
|
||||
// a never-controlled client doesn't trigger an unwanted reload.
|
||||
const clients = await self.clients.matchAll({ type: 'window' });
|
||||
for (const client of clients) {
|
||||
try {
|
||||
if (client.url && typeof client.navigate === 'function') {
|
||||
await client.navigate(client.url);
|
||||
}
|
||||
} catch (e) {
|
||||
// Some browsers reject navigate on cross-origin or detached
|
||||
// clients — swallow so one bad client doesn't break the rest.
|
||||
console.warn('[SW] Forced reload skipped for client:', client.url, e);
|
||||
}
|
||||
}
|
||||
})(),
|
||||
);
|
||||
// Take control immediately
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
// Fetch event - network-first for API, cache-first for static
|
||||
|
|
|
|||
|
|
@ -142,6 +142,7 @@ function isAlwaysAllowedIdentical(value) {
|
|||
const DE_COGNATES = [
|
||||
'Name', 'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Modus',
|
||||
'Stop', 'Reset', 'Test', 'Code', 'Token', 'Server', 'Port', 'Bug', 'Job',
|
||||
'Bambu Cloud', 'Orca Cloud', // brand names — same in every locale
|
||||
'Pause', 'Power', 'System', 'Problem', 'Designer', 'Extruder', 'Firmware',
|
||||
'Material', 'Original', 'Position', 'Webhook', 'Workflow', 'Slicer',
|
||||
'Region', 'Normal', 'Orange', 'Branch', 'Budget', 'Commit', 'Global',
|
||||
|
|
@ -169,6 +170,7 @@ const DE_COGNATES = [
|
|||
|
||||
// French cognates — many UI labels overlap with English exactly.
|
||||
const FR_COGNATES = [
|
||||
'Bambu Cloud', 'Orca Cloud', // brand names — same in every locale
|
||||
'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Filament',
|
||||
'Filaments', 'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code',
|
||||
'Token', 'Server', 'Port', 'Plate', 'Layer', 'Active', 'Total', 'Avatar',
|
||||
|
|
@ -205,6 +207,8 @@ const FR_COGNATES = [
|
|||
|
||||
// Italian cognates.
|
||||
const IT_COGNATES = [
|
||||
'Bambu Cloud', 'Orca Cloud', // brand names — same in every locale
|
||||
'Email', // common loanword in Italian, used verbatim in UI labels
|
||||
'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Filament',
|
||||
'Filaments', 'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code',
|
||||
'Token', 'Server', 'Port', 'Plate', 'Layer', 'Modal', 'Pin', 'Pro', 'Mini',
|
||||
|
|
@ -232,6 +236,7 @@ const IT_COGNATES = [
|
|||
// everything needs translation. Only true loanwords / proper nouns stay.
|
||||
const JA_COGNATES = [
|
||||
'OK', 'Bambu', 'Code',
|
||||
'Bambu Cloud', 'Orca Cloud', // brand names — same in every locale
|
||||
'EU (DD/MM/YYYY)', 'US (MM/DD/YYYY)', 'ON, true, 1',
|
||||
'({{count}}/8)', 'Custom Headers (JSON)',
|
||||
'Box label (62 × 29 mm)',
|
||||
|
|
@ -242,6 +247,7 @@ const JA_COGNATES = [
|
|||
|
||||
// Portuguese (BR) cognates.
|
||||
const PT_BR_COGNATES = [
|
||||
'Bambu Cloud', 'Orca Cloud', // brand names — same in every locale
|
||||
'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Filament',
|
||||
'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code', 'Token', 'Server',
|
||||
'Port', 'Plate', 'Layer', 'Modal', 'Pin', 'Pro', 'Mini', 'Studio', 'Cache',
|
||||
|
|
@ -269,6 +275,7 @@ const PT_BR_COGNATES = [
|
|||
// Chinese (Simplified): very few cognates beyond brand names.
|
||||
const ZH_CN_COGNATES = [
|
||||
'OK', 'Bambu',
|
||||
'Bambu Cloud', 'Orca Cloud', // brand names — same in every locale
|
||||
'({{count}}/8)', 'Custom Headers (JSON)',
|
||||
'Box label (62 × 29 mm)',
|
||||
'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
|
||||
|
|
@ -278,6 +285,7 @@ const ZH_CN_COGNATES = [
|
|||
|
||||
const ZH_TW_COGNATES = [
|
||||
'OK', 'Bambu',
|
||||
'Bambu Cloud', 'Orca Cloud', // brand names — same in every locale
|
||||
'({{count}}/8)', 'Custom Headers (JSON)',
|
||||
'Box label (62 × 29 mm)',
|
||||
'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
|
||||
|
|
@ -289,6 +297,7 @@ const ZH_TW_COGNATES = [
|
|||
// Allow loanwords/acronyms, format strings, and proper nouns that stay verbatim.
|
||||
const KO_COGNATES = [
|
||||
'OK', 'Bambu', 'N/A',
|
||||
'Bambu Cloud', 'Orca Cloud', // brand names — same in every locale
|
||||
'({{count}}/8)', '(25%, 50%, 75%)',
|
||||
'Custom Headers (JSON)',
|
||||
'Box label (62 × 29 mm)',
|
||||
|
|
@ -305,6 +314,7 @@ const KO_COGNATES = [
|
|||
|
||||
// Spanish cognates — words/phrases that are genuinely identical in Spanish.
|
||||
const ES_COGNATES = [
|
||||
'Bambu Cloud', 'Orca Cloud', // brand names — same in every locale
|
||||
'Error', 'Firmware', 'General', 'Control', 'Total', 'total', 'Material',
|
||||
'Material:', 'Color', 'Hex', 'Local', 'Global', 'China', 'Editable',
|
||||
'Normal', 'Metal', 'Multicolor', 'Proxy', 'Host', 'Factor', 'Original',
|
||||
|
|
@ -323,6 +333,7 @@ const ES_COGNATES = [
|
|||
// from English (loanwords + acronyms + format strings). Curated, not a shortcut.
|
||||
const TR_COGNATES = [
|
||||
'Filament', 'Firmware', 'Disk', 'Hex', 'Test', 'Port', 'Model', 'Metal',
|
||||
'Bambu Cloud', 'Orca Cloud', // brand names — same in every locale
|
||||
'Min', 'Normal', 'Platform', 'Net', 'Trend', 'Commit', 'Global', 'Proxy',
|
||||
'N/A', 'email',
|
||||
'STARTTLS (Port 587)', 'SSL/TLS (Port 465)',
|
||||
|
|
|
|||
211
frontend/src/__tests__/components/OrcaCloudView.test.tsx
Normal file
211
frontend/src/__tests__/components/OrcaCloudView.test.tsx
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
/**
|
||||
* Tests for OrcaCloudView component — covers the four UI phases of the
|
||||
* paste-based PKCE handshake: disconnected, awaiting-paste, connected,
|
||||
* and disconnect.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
|
||||
import { server } from '../mocks/server';
|
||||
import { render } from '../utils';
|
||||
import { OrcaCloudView } from '../../components/OrcaCloudView';
|
||||
|
||||
// JSDOM doesn't implement window.open; the connect flow opens the auth URL
|
||||
// in a new tab so we stub it to capture the call.
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('open', vi.fn());
|
||||
});
|
||||
|
||||
const noProfilesResponse = { profiles: [] };
|
||||
|
||||
describe('OrcaCloudView', () => {
|
||||
it('shows all four sign-in options when not connected', async () => {
|
||||
server.use(
|
||||
http.get('/api/v1/orca-cloud/status', () =>
|
||||
HttpResponse.json({ connected: false, email: null, user_id: null }),
|
||||
),
|
||||
);
|
||||
render(<OrcaCloudView />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Connect to Orca Cloud/i)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByRole('button', { name: /Sign in with Google/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Sign in with Apple/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Sign in with GitHub/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Sign in with email and password/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes the selected OAuth provider to auth/start', async () => {
|
||||
let receivedProvider: string | undefined;
|
||||
server.use(
|
||||
http.get('/api/v1/orca-cloud/status', () =>
|
||||
HttpResponse.json({ connected: false, email: null, user_id: null }),
|
||||
),
|
||||
http.post('/api/v1/orca-cloud/auth/start', async ({ request }) => {
|
||||
const body = (await request.json()) as { provider?: string };
|
||||
receivedProvider = body.provider;
|
||||
return HttpResponse.json({ auth_url: 'https://auth.orcaslicer.com/auth/v1/authorize?test=1' });
|
||||
}),
|
||||
);
|
||||
render(<OrcaCloudView />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Sign in with Apple/i })).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /Sign in with Apple/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(receivedProvider).toBe('apple');
|
||||
});
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'https://auth.orcaslicer.com/auth/v1/authorize?test=1',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
);
|
||||
});
|
||||
|
||||
it('connects via email and password without the paste flow', async () => {
|
||||
let connected = false;
|
||||
let receivedCreds: { email?: string; password?: string } = {};
|
||||
server.use(
|
||||
http.get('/api/v1/orca-cloud/status', () =>
|
||||
HttpResponse.json(
|
||||
connected
|
||||
? { connected: true, email: 'martin@example.com', user_id: 'u1' }
|
||||
: { connected: false, email: null, user_id: null },
|
||||
),
|
||||
),
|
||||
http.post('/api/v1/orca-cloud/auth/password', async ({ request }) => {
|
||||
receivedCreds = (await request.json()) as { email?: string; password?: string };
|
||||
connected = true;
|
||||
return HttpResponse.json({ connected: true, email: 'martin@example.com', user_id: 'u1' });
|
||||
}),
|
||||
http.get('/api/v1/orca-cloud/profiles', () => HttpResponse.json(noProfilesResponse)),
|
||||
);
|
||||
render(<OrcaCloudView />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Sign in with email and password/i })).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /Sign in with email and password/i }));
|
||||
|
||||
// The password form replaces the provider picker.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/^Email$/i)).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/^Email$/i), { target: { value: 'martin@example.com' } });
|
||||
fireEvent.change(screen.getByLabelText(/^Password$/i), { target: { value: 'hunter2' } });
|
||||
// Click the submit button inside the form (not the picker's email button).
|
||||
const submitButtons = screen.getAllByRole('button', { name: /^Sign in$/i });
|
||||
fireEvent.click(submitButtons[submitButtons.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('martin@example.com')).toBeInTheDocument();
|
||||
});
|
||||
expect(receivedCreds).toEqual({ email: 'martin@example.com', password: 'hunter2' });
|
||||
});
|
||||
|
||||
it('rejects a URL without a code parameter with a client-side error', async () => {
|
||||
server.use(
|
||||
http.get('/api/v1/orca-cloud/status', () =>
|
||||
HttpResponse.json({ connected: false, email: null, user_id: null }),
|
||||
),
|
||||
http.post('/api/v1/orca-cloud/auth/start', () =>
|
||||
HttpResponse.json({ auth_url: 'https://auth.orcaslicer.com/auth/v1/authorize?test=1' }),
|
||||
),
|
||||
);
|
||||
render(<OrcaCloudView />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Sign in with Google/i })).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /Sign in with Google/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText(/http:\/\/localhost:41172/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/http:\/\/localhost:41172/i);
|
||||
// Paste something with no ?code= — the client-side guard should fire
|
||||
// before we hit the server.
|
||||
fireEvent.change(textarea, { target: { value: 'http://localhost:41172/callback?error=denied' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Finish connecting/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/does not look like an Orca Cloud callback/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the connected state with the email after a successful paste', async () => {
|
||||
// Start disconnected; after a successful finish the status query refetches
|
||||
// and returns connected. MSW lets us swap handlers mid-test.
|
||||
let connected = false;
|
||||
server.use(
|
||||
http.get('/api/v1/orca-cloud/status', () => {
|
||||
return HttpResponse.json(
|
||||
connected
|
||||
? { connected: true, email: 'martin@example.com', user_id: 'u1' }
|
||||
: { connected: false, email: null, user_id: null },
|
||||
);
|
||||
}),
|
||||
http.post('/api/v1/orca-cloud/auth/start', () =>
|
||||
HttpResponse.json({ auth_url: 'https://auth.orcaslicer.com/auth/v1/authorize?test=1' }),
|
||||
),
|
||||
http.post('/api/v1/orca-cloud/auth/finish', () => {
|
||||
connected = true;
|
||||
return HttpResponse.json({ connected: true, email: 'martin@example.com', user_id: 'u1' });
|
||||
}),
|
||||
http.get('/api/v1/orca-cloud/profiles', () => HttpResponse.json(noProfilesResponse)),
|
||||
);
|
||||
render(<OrcaCloudView />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Sign in with Google/i })).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /Sign in with Google/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText(/http:\/\/localhost:41172/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/http:\/\/localhost:41172/i), {
|
||||
target: { value: 'http://localhost:41172/callback?code=ABC&state=XYZ' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /Finish connecting/i }));
|
||||
|
||||
// After the finish call resolves, the status query is invalidated and
|
||||
// refetches connected=true → the connection banner appears with the email.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('martin@example.com')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByRole('button', { name: /Disconnect/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears the connection on Disconnect', async () => {
|
||||
let connected = true;
|
||||
server.use(
|
||||
http.get('/api/v1/orca-cloud/status', () =>
|
||||
HttpResponse.json(
|
||||
connected
|
||||
? { connected: true, email: 'martin@example.com', user_id: 'u1' }
|
||||
: { connected: false, email: null, user_id: null },
|
||||
),
|
||||
),
|
||||
http.get('/api/v1/orca-cloud/profiles', () => HttpResponse.json(noProfilesResponse)),
|
||||
http.post('/api/v1/orca-cloud/logout', () => {
|
||||
connected = false;
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
render(<OrcaCloudView />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('martin@example.com')).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /Disconnect/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Connect to Orca Cloud/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -46,10 +46,12 @@ const mockApi = api as unknown as {
|
|||
|
||||
function makeUnified(overrides: Partial<UnifiedPresetsResponse> = {}): UnifiedPresetsResponse {
|
||||
return {
|
||||
orca_cloud: { printer: [], process: [], filament: [] },
|
||||
cloud: { printer: [], process: [], filament: [] },
|
||||
local: { printer: [], process: [], filament: [] },
|
||||
standard: { printer: [], process: [], filament: [] },
|
||||
cloud_status: 'ok',
|
||||
orca_cloud_status: 'ok',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
@ -166,7 +168,7 @@ describe('SliceModal', () => {
|
|||
const groups = printerSelect.querySelectorAll('optgroup');
|
||||
expect(Array.from(groups).map((g) => g.label)).toEqual([
|
||||
'Imported',
|
||||
'Cloud',
|
||||
'Bambu Cloud',
|
||||
'Standard',
|
||||
]);
|
||||
|
||||
|
|
@ -703,6 +705,7 @@ describe('SliceModal', () => {
|
|||
// should match each plate slot to the same-colour preset so the user
|
||||
// doesn't have to manually align them.
|
||||
return {
|
||||
orca_cloud: { printer: [], process: [], filament: [] },
|
||||
cloud: {
|
||||
printer: [{ id: 'P1', name: 'X1C', source: 'cloud' }],
|
||||
process: [{ id: 'PR1', name: '0.20mm', source: 'cloud' }],
|
||||
|
|
@ -714,6 +717,7 @@ describe('SliceModal', () => {
|
|||
local: { printer: [], process: [], filament: [] },
|
||||
standard: { printer: [], process: [], filament: [] },
|
||||
cloud_status: 'ok',
|
||||
orca_cloud_status: 'ok',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -921,6 +925,8 @@ describe('SliceModal', () => {
|
|||
local: { printer: [], process: [], filament: [] },
|
||||
standard: { printer: [], process: [], filament: [] },
|
||||
cloud_status: 'ok',
|
||||
orca_cloud: { printer: [], process: [], filament: [] },
|
||||
orca_cloud_status: 'ok',
|
||||
});
|
||||
|
||||
renderWithTracker({
|
||||
|
|
@ -985,6 +991,8 @@ describe('SliceModal', () => {
|
|||
local: { printer: [], process: [], filament: [] },
|
||||
standard: { printer: [], process: [], filament: [] },
|
||||
cloud_status: 'ok',
|
||||
orca_cloud: { printer: [], process: [], filament: [] },
|
||||
orca_cloud_status: 'ok',
|
||||
});
|
||||
mockApi.sliceLibraryFile.mockResolvedValue({
|
||||
job_id: 50,
|
||||
|
|
|
|||
|
|
@ -1185,6 +1185,51 @@ export interface CloudLoginResponse {
|
|||
tfa_key?: string | null;
|
||||
}
|
||||
|
||||
// Orca Cloud types — paste-flow PKCE handshake against auth.orcaslicer.com.
|
||||
// See backend/app/services/orca_cloud.py for the deep dive on why this
|
||||
// flow is paste-based rather than callback-based.
|
||||
export type OrcaOAuthProvider = 'google' | 'apple' | 'github';
|
||||
|
||||
export interface OrcaAuthStartResponse {
|
||||
auth_url: string;
|
||||
}
|
||||
|
||||
export interface OrcaAuthStatusResponse {
|
||||
connected: boolean;
|
||||
email: string | null;
|
||||
user_id: string | null;
|
||||
}
|
||||
|
||||
// Orca profiles are shaped to match Bambu Cloud's SlicerSetting on the wire
|
||||
// so the frontend can use the same visual components for both surfaces (cards,
|
||||
// grouped sections, filter bar). Backend handles the source-specific
|
||||
// transformation in routes/orca_cloud.py::_orca_to_setting.
|
||||
export interface OrcaProfileMeta {
|
||||
setting_id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
version: string | null;
|
||||
user_id: string | null;
|
||||
updated_time: string | null;
|
||||
is_custom: boolean;
|
||||
}
|
||||
|
||||
export interface OrcaProfileListResponse {
|
||||
filament: OrcaProfileMeta[];
|
||||
printer: OrcaProfileMeta[];
|
||||
process: OrcaProfileMeta[];
|
||||
}
|
||||
|
||||
export interface OrcaProfileDetail {
|
||||
setting_id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
version: string | null;
|
||||
base_id: string | null;
|
||||
update_time: string | null;
|
||||
setting: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// MakerWorld integration. Full metadata/instance shapes come back as
|
||||
// Record<string, unknown> — MakerWorld's API adds fields over time, so we
|
||||
// pass them through verbatim rather than maintaining a brittle mirror.
|
||||
|
|
@ -1305,7 +1350,7 @@ export interface BuiltinFilament {
|
|||
// - Source-aware refs (`*_preset: PresetRef`) — new SliceModal that picks
|
||||
// across cloud / local / standard tiers. Source-aware refs win when both
|
||||
// are present in the same payload.
|
||||
export type PresetSource = 'cloud' | 'local' | 'standard';
|
||||
export type PresetSource = 'orca_cloud' | 'cloud' | 'local' | 'standard';
|
||||
export interface PresetRef {
|
||||
source: PresetSource;
|
||||
id: string;
|
||||
|
|
@ -1384,10 +1429,14 @@ export interface UnifiedPresetsBySlot {
|
|||
filament: UnifiedPreset[];
|
||||
}
|
||||
export interface UnifiedPresetsResponse {
|
||||
// Priority order: orca_cloud > cloud > local > standard. Dedup is applied
|
||||
// backend-side so each name appears in only one tier.
|
||||
orca_cloud: UnifiedPresetsBySlot;
|
||||
cloud: UnifiedPresetsBySlot;
|
||||
local: UnifiedPresetsBySlot;
|
||||
standard: UnifiedPresetsBySlot;
|
||||
cloud_status: SlicerCloudStatus;
|
||||
orca_cloud_status: SlicerCloudStatus;
|
||||
}
|
||||
|
||||
export interface SliceResponse {
|
||||
|
|
@ -2814,7 +2863,7 @@ export type Permission =
|
|||
| 'system:read'
|
||||
| 'settings:read' | 'settings:update' | 'settings:backup' | 'settings:restore'
|
||||
| 'github:backup' | 'github:restore'
|
||||
| 'cloud:auth'
|
||||
| 'cloud:auth' | 'orca_cloud:auth'
|
||||
| 'makerworld:view' | 'makerworld:import'
|
||||
| 'api_keys:read' | 'api_keys:create' | 'api_keys:update' | 'api_keys:delete'
|
||||
| 'users:read' | 'users:create' | 'users:update' | 'users:delete'
|
||||
|
|
@ -4341,6 +4390,34 @@ export const api = {
|
|||
}),
|
||||
cloudLogout: () =>
|
||||
request<{ success: boolean }>('/cloud/logout', { method: 'POST' }),
|
||||
|
||||
// Orca Cloud — paste-based PKCE flow for OAuth (Google/Apple/GitHub),
|
||||
// direct credentials for email+password. start() returns an auth URL the
|
||||
// user opens in their browser; after sign-in they paste the callback URL
|
||||
// back via finish(). password() skips the dance entirely.
|
||||
orcaCloudStartAuth: (provider: OrcaOAuthProvider = 'google') =>
|
||||
request<OrcaAuthStartResponse>('/orca-cloud/auth/start', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ provider }),
|
||||
}),
|
||||
orcaCloudFinishAuth: (callback_url: string) =>
|
||||
request<OrcaAuthStatusResponse>('/orca-cloud/auth/finish', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ callback_url }),
|
||||
}),
|
||||
orcaCloudPasswordLogin: (email: string, password: string) =>
|
||||
request<OrcaAuthStatusResponse>('/orca-cloud/auth/password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
}),
|
||||
orcaCloudStatus: () =>
|
||||
request<OrcaAuthStatusResponse>('/orca-cloud/status'),
|
||||
orcaCloudLogout: () =>
|
||||
request<{ success: boolean }>('/orca-cloud/logout', { method: 'POST' }),
|
||||
orcaCloudListProfiles: () =>
|
||||
request<OrcaProfileListResponse>('/orca-cloud/profiles'),
|
||||
orcaCloudGetProfile: (id: string) =>
|
||||
request<OrcaProfileDetail>(`/orca-cloud/profiles/${id}`),
|
||||
getCloudSettings: (version = '02.04.00.70') =>
|
||||
request<SlicerSettingsResponse>(`/cloud/settings?version=${version}`),
|
||||
getBuiltinFilaments: () =>
|
||||
|
|
|
|||
|
|
@ -266,6 +266,17 @@ export function ConfigureAmsSlotModal({
|
|||
retry: false,
|
||||
});
|
||||
|
||||
// Orca Cloud filament profiles, same shape as Bambu Cloud's. Each query
|
||||
// is independent — the picker degrades gracefully if Orca Cloud isn't
|
||||
// connected (no entries surface, no error banner because we don't want
|
||||
// to nag users who deliberately only use Bambu Cloud).
|
||||
const { data: orcaCloudList } = useQuery({
|
||||
queryKey: ['orcaCloudProfilesForAmsSlot'],
|
||||
queryFn: () => api.orcaCloudListProfiles(),
|
||||
enabled: isOpen,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// Fetch local presets
|
||||
const { data: localPresets, isLoading: localLoading } = useQuery({
|
||||
queryKey: ['localPresets'],
|
||||
|
|
@ -301,9 +312,23 @@ export function ConfigureAmsSlotModal({
|
|||
mutationFn: async () => {
|
||||
if (!selectedPresetId) throw new Error('No filament preset selected');
|
||||
|
||||
// Determine preset source
|
||||
// Determine preset source. Orca detection is done via setting_id
|
||||
// lookup as well as the ``orca_`` prefix because the saved-preset
|
||||
// pre-population on modal open (the useEffect at the top of this
|
||||
// component) writes ``slotInfo.savedPresetId`` verbatim — which for a
|
||||
// historical Orca save would be the raw UUID, no prefix. Falling
|
||||
// through to the cloud lookup in that case would have thrown
|
||||
// "Selected preset not found".
|
||||
const isLocal = selectedPresetId.startsWith('local_');
|
||||
const isBuiltin = selectedPresetId.startsWith('builtin_');
|
||||
const orcaSettingId = selectedPresetId.startsWith('orca_')
|
||||
? selectedPresetId.replace('orca_', '')
|
||||
: selectedPresetId;
|
||||
const orcaPresetCandidate = (!isLocal && !isBuiltin)
|
||||
? orcaCloudList?.filament.find(p => p.setting_id === orcaSettingId)
|
||||
: undefined;
|
||||
const isOrca = !!orcaPresetCandidate;
|
||||
const orcaPreset = orcaPresetCandidate ?? null;
|
||||
const localId = isLocal ? parseInt(selectedPresetId.replace('local_', ''), 10) : null;
|
||||
const builtinFilamentId = isBuiltin ? selectedPresetId.replace('builtin_', '') : null;
|
||||
const localPreset = isLocal
|
||||
|
|
@ -313,17 +338,24 @@ export function ConfigureAmsSlotModal({
|
|||
? builtinFilaments?.find(b => b.filament_id === builtinFilamentId)
|
||||
: null;
|
||||
|
||||
// Get the selected cloud preset details (null for local/builtin presets)
|
||||
const selectedPreset = (!isLocal && !isBuiltin)
|
||||
// Get the selected cloud preset details (null for local/builtin/orca presets)
|
||||
const selectedPreset = (!isLocal && !isBuiltin && !isOrca)
|
||||
? cloudSettings?.filament.find(p => p.setting_id === selectedPresetId)
|
||||
: null;
|
||||
|
||||
if (!isLocal && !isBuiltin && !selectedPreset) throw new Error('Selected preset not found');
|
||||
if (!isLocal && !isBuiltin && !isOrca && !selectedPreset) throw new Error('Selected preset not found');
|
||||
if (isLocal && !localPreset) throw new Error('Selected local preset not found');
|
||||
if (isBuiltin && !builtinPreset) throw new Error('Selected builtin preset not found');
|
||||
if (isOrca && !orcaPreset) throw new Error('Selected Orca Cloud preset not found');
|
||||
|
||||
// Parse the preset name for filament info
|
||||
const presetName = isLocal ? localPreset!.name : isBuiltin ? builtinPreset!.name : selectedPreset!.name;
|
||||
const presetName = isLocal
|
||||
? localPreset!.name
|
||||
: isBuiltin
|
||||
? builtinPreset!.name
|
||||
: isOrca
|
||||
? orcaPreset!.name
|
||||
: selectedPreset!.name;
|
||||
const parsed = parsePresetName(presetName);
|
||||
|
||||
// Get cali_idx from selected K profile's slot_id (-1 = use default 0.020)
|
||||
|
|
@ -342,21 +374,27 @@ export function ConfigureAmsSlotModal({
|
|||
// Prefer this over stored filament_type which may have been parsed with old logic.
|
||||
const parsedMat = parsed.material.toUpperCase();
|
||||
|
||||
// Generic Bambu filament-ID map used to derive a ``tray_info_idx`` for
|
||||
// presets that don't carry a Bambu setting_id of their own (local
|
||||
// imports and Orca Cloud sync both fall in this bucket). The printer's
|
||||
// firmware needs SOMETHING in tray_info_idx to recognize the filament
|
||||
// type for HMS / drying / colour-matching; the closest generic Bambu
|
||||
// filament for the parsed material is the right choice.
|
||||
const GENERIC_IDS: Record<string, string> = {
|
||||
'PLA': 'GFL99', 'PLA-CF': 'GFL98', 'PLA SILK': 'GFL96', 'PLA HIGH SPEED': 'GFL95',
|
||||
'PETG': 'GFG99', 'PETG HF': 'GFG96', 'PETG-CF': 'GFG98', 'PCTG': 'GFG97',
|
||||
'ABS': 'GFB99', 'ASA': 'GFB98',
|
||||
'PC': 'GFC99',
|
||||
'PA': 'GFN99', 'PA-CF': 'GFN98', 'NYLON': 'GFN99',
|
||||
'TPU': 'GFU99',
|
||||
'PVA': 'GFS99', 'HIPS': 'GFS98',
|
||||
'PE': 'GFP99', 'PP': 'GFP97',
|
||||
};
|
||||
|
||||
if (isLocal) {
|
||||
// Local presets have no Bambu Cloud setting_id, but need a valid
|
||||
// tray_info_idx for the printer to recognize the filament type.
|
||||
// Map the material type to the closest generic Bambu filament ID.
|
||||
const material = (MATERIAL_TYPES.includes(parsedMat) ? parsedMat : localPreset?.filament_type || parsed.material || '').toUpperCase();
|
||||
const GENERIC_IDS: Record<string, string> = {
|
||||
'PLA': 'GFL99', 'PLA-CF': 'GFL98', 'PLA SILK': 'GFL96', 'PLA HIGH SPEED': 'GFL95',
|
||||
'PETG': 'GFG99', 'PETG HF': 'GFG96', 'PETG-CF': 'GFG98', 'PCTG': 'GFG97',
|
||||
'ABS': 'GFB99', 'ASA': 'GFB98',
|
||||
'PC': 'GFC99',
|
||||
'PA': 'GFN99', 'PA-CF': 'GFN98', 'NYLON': 'GFN99',
|
||||
'TPU': 'GFU99',
|
||||
'PVA': 'GFS99', 'HIPS': 'GFS98',
|
||||
'PE': 'GFP99', 'PP': 'GFP97',
|
||||
};
|
||||
// Try exact match first, then base material (strip suffixes like "-CF", "+", " HF")
|
||||
trayInfoIdx = GENERIC_IDS[material]
|
||||
|| GENERIC_IDS[material.replace(/[-\s]?CF$/, '')]
|
||||
|
|
@ -364,6 +402,18 @@ export function ConfigureAmsSlotModal({
|
|||
|| GENERIC_IDS[material.split(/[-\s]/)[0]]
|
||||
|| '';
|
||||
settingId = '';
|
||||
} else if (isOrca) {
|
||||
// Orca Cloud presets have a UUID setting_id that Bambu printers can't
|
||||
// resolve; treat them like local imports — derive a generic tray_info
|
||||
// _idx from the parsed material, leave settingId empty so the slicer
|
||||
// doesn't get a foreign cloud ID it can't look up.
|
||||
const material = (MATERIAL_TYPES.includes(parsedMat) ? parsedMat : parsed.material || '').toUpperCase();
|
||||
trayInfoIdx = GENERIC_IDS[material]
|
||||
|| GENERIC_IDS[material.replace(/[-\s]?CF$/, '')]
|
||||
|| GENERIC_IDS[material.replace(/\+$/, '')]
|
||||
|| GENERIC_IDS[material.split(/[-\s]/)[0]]
|
||||
|| '';
|
||||
settingId = '';
|
||||
} else if (isBuiltin) {
|
||||
// Built-in presets use the filament_id directly as tray_info_idx
|
||||
trayInfoIdx = builtinFilamentId!;
|
||||
|
|
@ -392,7 +442,7 @@ export function ConfigureAmsSlotModal({
|
|||
let tempMin = isLocal && localPreset?.nozzle_temp_min ? localPreset.nozzle_temp_min : 190;
|
||||
let tempMax = isLocal && localPreset?.nozzle_temp_max ? localPreset.nozzle_temp_max : 230;
|
||||
|
||||
if (!isLocal || isBuiltin || (!localPreset?.nozzle_temp_min && !localPreset?.nozzle_temp_max)) {
|
||||
if (!isLocal || isBuiltin || isOrca || (!localPreset?.nozzle_temp_min && !localPreset?.nozzle_temp_max)) {
|
||||
// Fall back to material-based defaults (prefer parsed material for "Support for" handling)
|
||||
const material = (isLocal
|
||||
? (MATERIAL_TYPES.includes(parsedMat) ? parsedMat : localPreset?.filament_type || parsed.material || '')
|
||||
|
|
@ -431,7 +481,9 @@ export function ConfigureAmsSlotModal({
|
|||
// patterns correctly) over stored filament_type which may have been parsed with old logic.
|
||||
const trayType = isLocal
|
||||
? (MATERIAL_TYPES.includes(parsedMat) ? parsedMat : localPreset?.filament_type || parsed.material || 'PLA')
|
||||
: (parsed.material || 'PLA');
|
||||
: isOrca
|
||||
? (MATERIAL_TYPES.includes(parsedMat) ? parsedMat : parsed.material || 'PLA')
|
||||
: (parsed.material || 'PLA');
|
||||
|
||||
// Configure the slot via MQTT
|
||||
const result = await api.configureAmsSlot(printerId, slotInfo.amsId, slotInfo.trayId, {
|
||||
|
|
@ -454,8 +506,20 @@ export function ConfigureAmsSlotModal({
|
|||
// Save the preset mapping so we can display the correct name in the UI
|
||||
// This is needed because user presets use filament_id (e.g., P285e239) as tray_info_idx,
|
||||
// which can't be resolved to a name via the filamentInfo API
|
||||
const mappingPresetId = isLocal ? `local_${localId}` : isBuiltin ? `builtin_${builtinFilamentId}` : selectedPresetId;
|
||||
const mappingSource = isLocal ? 'local' : isBuiltin ? 'builtin' : 'cloud';
|
||||
const mappingPresetId = isLocal
|
||||
? `local_${localId}`
|
||||
: isBuiltin
|
||||
? `builtin_${builtinFilamentId}`
|
||||
: isOrca
|
||||
? selectedPresetId
|
||||
: selectedPresetId;
|
||||
const mappingSource = isLocal
|
||||
? 'local'
|
||||
: isBuiltin
|
||||
? 'builtin'
|
||||
: isOrca
|
||||
? 'orca_cloud'
|
||||
: 'cloud';
|
||||
try {
|
||||
await api.saveSlotPreset(printerId, slotInfo.amsId, slotInfo.trayId, mappingPresetId, traySubBrands, mappingSource);
|
||||
} catch (e) {
|
||||
|
|
@ -491,24 +555,45 @@ export function ConfigureAmsSlotModal({
|
|||
},
|
||||
});
|
||||
|
||||
// Unified preset item for the list (cloud + local + builtin fallback)
|
||||
type PresetItem = { id: string; name: string; source: 'cloud' | 'local' | 'builtin'; isUser: boolean };
|
||||
// Unified preset item for the list (orca_cloud + cloud + local + builtin fallback)
|
||||
type PresetItem = { id: string; name: string; source: 'orca_cloud' | 'cloud' | 'local' | 'builtin'; isUser: boolean };
|
||||
|
||||
// Filter filament presets based on search (merged cloud + local + builtin)
|
||||
// Filter filament presets based on search (merged orca_cloud + cloud + local + builtin)
|
||||
const filteredPresets = useMemo(() => {
|
||||
const query = searchQuery.toLowerCase();
|
||||
const items: PresetItem[] = [];
|
||||
|
||||
// Collect IDs already covered by cloud and local to avoid duplicates in fallback
|
||||
// Collect IDs already covered by higher-priority tiers to avoid duplicates
|
||||
const coveredIds = new Set<string>();
|
||||
|
||||
// Currently-configured preset should always be shown (bypass model filter)
|
||||
const savedId = slotInfo.savedPresetId;
|
||||
const trayIdx = slotInfo.trayInfoIdx;
|
||||
|
||||
// 0. Orca Cloud filament presets — surfaced first because the user
|
||||
// explicitly opted into Orca sync; their picks should outrank Bambu Cloud
|
||||
// presets of the same name. IDs are prefixed ``orca_`` so the configure
|
||||
// flow can detect "this is an Orca preset" via a cheap string check
|
||||
// (mirrors the ``local_`` / ``builtin_`` prefix convention already in use).
|
||||
if (orcaCloudList?.filament) {
|
||||
for (const op of orcaCloudList.filament) {
|
||||
const orcaId = `orca_${op.setting_id}`;
|
||||
coveredIds.add(op.setting_id);
|
||||
coveredIds.add(orcaId);
|
||||
if (query && !op.name.toLowerCase().includes(query)) continue;
|
||||
if (printerModel) {
|
||||
const presetModel = extractPresetModel(op.name);
|
||||
if (presetModel && presetModel.toUpperCase() !== printerModel.toUpperCase()) continue;
|
||||
}
|
||||
// All Orca Cloud profiles are user-authored, so isUser is always true.
|
||||
items.push({ id: orcaId, name: op.name, source: 'orca_cloud', isUser: true });
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Cloud presets
|
||||
if (cloudSettings?.filament) {
|
||||
for (const cp of cloudSettings.filament) {
|
||||
if (coveredIds.has(cp.setting_id)) continue;
|
||||
coveredIds.add(cp.setting_id);
|
||||
// Keep preset if it matches the slot's saved mapping or current tray_info_idx
|
||||
const isSavedPreset = savedId === cp.setting_id;
|
||||
|
|
@ -550,21 +635,24 @@ export function ConfigureAmsSlotModal({
|
|||
}
|
||||
}
|
||||
|
||||
// Sort: cloud user presets first, then cloud built-in, then local, then builtin fallback
|
||||
// Sort: orca_cloud first (user-curated), then cloud user presets, then
|
||||
// cloud built-in, then local, then builtin fallback
|
||||
return items.sort((a, b) => {
|
||||
const sourceOrder = { cloud: 0, local: 1, builtin: 2 };
|
||||
const sourceOrder = { orca_cloud: 0, cloud: 1, local: 2, builtin: 3 };
|
||||
if (a.source !== b.source) return sourceOrder[a.source] - sourceOrder[b.source];
|
||||
if (a.isUser && !b.isUser) return -1;
|
||||
if (!a.isUser && b.isUser) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}, [cloudSettings?.filament, localPresets?.filament, builtinFilaments, searchQuery, printerModel, slotInfo.savedPresetId, slotInfo.trayInfoIdx]);
|
||||
}, [orcaCloudList?.filament, cloudSettings?.filament, localPresets?.filament, builtinFilaments, searchQuery, printerModel, slotInfo.savedPresetId, slotInfo.trayInfoIdx]);
|
||||
|
||||
// Get full preset name for K profile filtering (brand + material, without printer suffix)
|
||||
const selectedPresetInfo = useMemo(() => {
|
||||
if (!selectedPresetId) return null;
|
||||
|
||||
// Resolve the name from cloud, local, or builtin presets
|
||||
// Resolve the name from orca, cloud, local, or builtin presets. The
|
||||
// Orca branch tolerates both ``orca_<UUID>`` and a bare UUID — see the
|
||||
// configure-mutation comment for why the raw UUID also reaches us.
|
||||
let presetName: string | null = null;
|
||||
if (selectedPresetId.startsWith('local_')) {
|
||||
const localId = parseInt(selectedPresetId.replace('local_', ''), 10);
|
||||
|
|
@ -574,11 +662,17 @@ export function ConfigureAmsSlotModal({
|
|||
const filamentId = selectedPresetId.replace('builtin_', '');
|
||||
const bf = builtinFilaments?.find(b => b.filament_id === filamentId);
|
||||
presetName = bf?.name || null;
|
||||
} else if (cloudSettings?.filament) {
|
||||
const cp = cloudSettings.filament.find(p => p.setting_id === selectedPresetId);
|
||||
presetName = cp?.name || null;
|
||||
} else {
|
||||
// No cloud settings available
|
||||
const orcaCandidateId = selectedPresetId.startsWith('orca_')
|
||||
? selectedPresetId.replace('orca_', '')
|
||||
: selectedPresetId;
|
||||
const op = orcaCloudList?.filament.find(p => p.setting_id === orcaCandidateId);
|
||||
if (op) {
|
||||
presetName = op.name;
|
||||
} else if (cloudSettings?.filament) {
|
||||
const cp = cloudSettings.filament.find(p => p.setting_id === selectedPresetId);
|
||||
presetName = cp?.name || null;
|
||||
}
|
||||
}
|
||||
if (!presetName) {
|
||||
return null;
|
||||
|
|
@ -597,7 +691,7 @@ export function ConfigureAmsSlotModal({
|
|||
material: parsed.material,
|
||||
brand: parsed.brand,
|
||||
};
|
||||
}, [selectedPresetId, cloudSettings?.filament, localPresets?.filament, builtinFilaments]);
|
||||
}, [selectedPresetId, cloudSettings?.filament, localPresets?.filament, builtinFilaments, orcaCloudList?.filament]);
|
||||
|
||||
// For backwards compatibility with the label
|
||||
const selectedMaterial = selectedPresetInfo?.fullName || '';
|
||||
|
|
|
|||
399
frontend/src/components/OrcaCloudProfilesView.tsx
Normal file
399
frontend/src/components/OrcaCloudProfilesView.tsx
Normal file
|
|
@ -0,0 +1,399 @@
|
|||
import { useState, useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { Search, Filter, RefreshCw, Droplet, Settings2, Printer as PrinterIcon, Layers, X, Loader2, Clock } from 'lucide-react';
|
||||
|
||||
import { api } from '../api/client';
|
||||
import type { OrcaProfileListResponse, OrcaProfileMeta, Printer } from '../api/client';
|
||||
import { Button } from './Button';
|
||||
import { FilterDropdown } from '../pages/ProfilesPage';
|
||||
import { formatRelativeTime } from '../utils/date';
|
||||
|
||||
/**
|
||||
* Read-only profile browser for the Orca Cloud tab.
|
||||
*
|
||||
* Visual parity with Bambu Cloud's CloudProfilesView: same filter bar layout,
|
||||
* same 3-column grouped list (Filament / Process / Printer), same card
|
||||
* styling. Differences vs the Bambu version are removals — no
|
||||
* Create / Edit / Duplicate / Delete / Compare / Templates buttons because
|
||||
* Orca's `/sync/push` and `/sync/delete` endpoints aren't wired in this
|
||||
* shipping cut (planned for a follow-up). The "Owner" filter is also
|
||||
* dropped: every profile that lives in a user's Orca Cloud account is
|
||||
* user-authored, the system/builtin distinction doesn't apply.
|
||||
*/
|
||||
|
||||
type ProfileType = 'all' | 'filament' | 'printer' | 'process';
|
||||
|
||||
interface PresetMeta {
|
||||
printer: string | null;
|
||||
nozzle: string | null;
|
||||
layerHeight: string | null;
|
||||
filamentType: string | null;
|
||||
}
|
||||
|
||||
// Mirror of ProfilesPage.tsx::extractMetadata. Inlined rather than exported
|
||||
// because the patterns are conservative (Bambu printer-name regex etc.) and
|
||||
// Orca profile naming follows the same conventions (Orca is a BambuStudio
|
||||
// fork; profile names like "Bambu PLA Basic @BBL X1C" carry over verbatim).
|
||||
function extractMetadata(name: string): PresetMeta {
|
||||
const printerMatch = name.match(/@?\s*(?:BBL\s+)?(?:Bambu\s+Lab\s+)?([XPAH][1-9][A-Z]?(?:\s*(?:Carbon|mini))?|H2D)/i);
|
||||
const nozzleMatch = name.match(/(\d+\.?\d*)\s*(?:mm\s*)?nozzle|nozzle\s*(\d+\.?\d*)/i);
|
||||
const layerMatch = name.match(/(\d+\.?\d*)mm\s*(?:Standard|Fine|Extra Fine|Draft|Quality)?/i);
|
||||
const filamentMatch = name.match(/\b(PLA|PETG|ABS|ASA|TPU|PC|PA|PVA|HIPS|PP|PET(?:-?CF)?|PA(?:-?CF)?|PLA(?:-?CF)?)\b/i);
|
||||
return {
|
||||
printer: printerMatch ? printerMatch[1].trim() : null,
|
||||
nozzle: nozzleMatch ? (nozzleMatch[1] || nozzleMatch[2]) + 'mm' : null,
|
||||
layerHeight: layerMatch ? layerMatch[1] + 'mm' : null,
|
||||
filamentType: filamentMatch ? filamentMatch[1].toUpperCase() : null,
|
||||
};
|
||||
}
|
||||
|
||||
interface OrcaCloudProfilesViewProps {
|
||||
settings: OrcaProfileListResponse;
|
||||
lastSyncTime?: Date;
|
||||
onRefresh: () => void;
|
||||
isRefreshing: boolean;
|
||||
printers: Printer[];
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
export function OrcaCloudProfilesView({
|
||||
settings,
|
||||
lastSyncTime,
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
printers,
|
||||
t,
|
||||
}: OrcaCloudProfilesViewProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filterType, setFilterType] = useState<ProfileType>('all');
|
||||
const [filterPrinter, setFilterPrinter] = useState('all');
|
||||
const [filterNozzle, setFilterNozzle] = useState('all');
|
||||
const [filterFilament, setFilterFilament] = useState('all');
|
||||
const [filterLayerHeight, setFilterLayerHeight] = useState('all');
|
||||
const [selectedSetting, setSelectedSetting] = useState<OrcaProfileMeta | null>(null);
|
||||
|
||||
const allPresetsWithMeta = useMemo(() => {
|
||||
const combined = [
|
||||
...settings.filament.map(s => ({ ...s, type: 'filament' as const })),
|
||||
...settings.printer.map(s => ({ ...s, type: 'printer' as const })),
|
||||
...settings.process.map(s => ({ ...s, type: 'process' as const })),
|
||||
];
|
||||
return combined.map(s => ({ ...s, meta: extractMetadata(s.name) }));
|
||||
}, [settings]);
|
||||
|
||||
const filterOptions = useMemo(() => {
|
||||
const nozzles = new Set<string>();
|
||||
const filaments = new Set<string>();
|
||||
const layerHeights = new Set<string>();
|
||||
allPresetsWithMeta.forEach(p => {
|
||||
if (p.meta.nozzle) nozzles.add(p.meta.nozzle);
|
||||
if (p.meta.filamentType) filaments.add(p.meta.filamentType);
|
||||
if (p.meta.layerHeight) layerHeights.add(p.meta.layerHeight);
|
||||
});
|
||||
return {
|
||||
printers: printers.map(p => ({ id: p.id.toString(), name: p.name })),
|
||||
nozzles: Array.from(nozzles).sort((a, b) => parseFloat(a) - parseFloat(b)),
|
||||
filaments: Array.from(filaments).sort(),
|
||||
layerHeights: Array.from(layerHeights).sort((a, b) => parseFloat(a) - parseFloat(b)),
|
||||
};
|
||||
}, [allPresetsWithMeta, printers]);
|
||||
|
||||
const selectedPrinterModel = useMemo(() => {
|
||||
if (filterPrinter === 'all') return null;
|
||||
const printer = printers.find(p => p.id.toString() === filterPrinter);
|
||||
return printer?.model || null;
|
||||
}, [filterPrinter, printers]);
|
||||
|
||||
const filteredPresets = useMemo(() => {
|
||||
return allPresetsWithMeta
|
||||
.filter(s => filterType === 'all' || s.type === filterType)
|
||||
.filter(s => {
|
||||
if (filterPrinter === 'all' || !selectedPrinterModel) return true;
|
||||
const presetPrinter = s.meta.printer?.toLowerCase() || '';
|
||||
const configuredModel = selectedPrinterModel.toLowerCase();
|
||||
return presetPrinter.includes(configuredModel) || configuredModel.includes(presetPrinter);
|
||||
})
|
||||
.filter(s => filterNozzle === 'all' || s.meta.nozzle === filterNozzle)
|
||||
.filter(s => filterFilament === 'all' || s.meta.filamentType === filterFilament)
|
||||
.filter(s => filterLayerHeight === 'all' || s.meta.layerHeight === filterLayerHeight)
|
||||
.filter(s => searchQuery === '' || s.name.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [allPresetsWithMeta, filterType, filterPrinter, selectedPrinterModel, filterNozzle, filterFilament, filterLayerHeight, searchQuery]);
|
||||
|
||||
const clearFilters = () => {
|
||||
setFilterType('all');
|
||||
setFilterPrinter('all');
|
||||
setFilterNozzle('all');
|
||||
setFilterFilament('all');
|
||||
setFilterLayerHeight('all');
|
||||
setSearchQuery('');
|
||||
};
|
||||
|
||||
const hasActiveFilters =
|
||||
filterType !== 'all' ||
|
||||
filterPrinter !== 'all' ||
|
||||
filterNozzle !== 'all' ||
|
||||
filterFilament !== 'all' ||
|
||||
filterLayerHeight !== 'all' ||
|
||||
searchQuery !== '';
|
||||
|
||||
const totalCount = settings.filament.length + settings.printer.length + settings.process.length;
|
||||
|
||||
const presetsByType = (type: ProfileType) => filteredPresets.filter(p => p.type === type);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Search and Filters — mirrors the layout of profiles.cloudView in the
|
||||
Bambu Cloud tab so the two tabs feel like the same surface. */}
|
||||
<div className="space-y-4 mb-6">
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('profiles.cloudView.searchPlaceholder')}
|
||||
className="w-full pl-10 pr-4 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray-dark focus:border-bambu-green focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" onClick={onRefresh} disabled={isRefreshing}>
|
||||
<RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
{t('profiles.cloudView.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Filter className="w-4 h-4 text-bambu-gray" />
|
||||
<FilterDropdown
|
||||
label={t('profiles.cloudView.filters.type')}
|
||||
value={filterType}
|
||||
options={[
|
||||
{ value: 'all', label: t('profiles.cloudView.filters.all'), count: totalCount },
|
||||
{ value: 'filament', label: t('profiles.cloudView.filters.filament'), count: settings.filament.length },
|
||||
{ value: 'printer', label: t('profiles.cloudView.filters.printer'), count: settings.printer.length },
|
||||
{ value: 'process', label: t('profiles.cloudView.filters.process'), count: settings.process.length },
|
||||
]}
|
||||
onChange={(v) => setFilterType(v as ProfileType)}
|
||||
/>
|
||||
{filterOptions.printers.length > 0 && (
|
||||
<FilterDropdown
|
||||
label={t('profiles.cloudView.filters.printer')}
|
||||
value={filterPrinter}
|
||||
options={[
|
||||
{ value: 'all', label: t('profiles.cloudView.filters.all') },
|
||||
...filterOptions.printers.map(p => ({ value: p.id, label: p.name })),
|
||||
]}
|
||||
onChange={setFilterPrinter}
|
||||
/>
|
||||
)}
|
||||
{filterOptions.nozzles.length > 0 && (
|
||||
<FilterDropdown
|
||||
label={t('profiles.cloudView.filters.nozzle')}
|
||||
value={filterNozzle}
|
||||
options={[
|
||||
{ value: 'all', label: t('profiles.cloudView.filters.all') },
|
||||
...filterOptions.nozzles.map(n => ({ value: n, label: n })),
|
||||
]}
|
||||
onChange={setFilterNozzle}
|
||||
/>
|
||||
)}
|
||||
{filterOptions.filaments.length > 0 && (filterType === 'all' || filterType === 'filament') && (
|
||||
<FilterDropdown
|
||||
label={t('profiles.cloudView.filters.filament')}
|
||||
value={filterFilament}
|
||||
options={[
|
||||
{ value: 'all', label: t('profiles.cloudView.filters.all') },
|
||||
...filterOptions.filaments.map(f => ({ value: f, label: f })),
|
||||
]}
|
||||
onChange={setFilterFilament}
|
||||
/>
|
||||
)}
|
||||
{filterOptions.layerHeights.length > 0 && (filterType === 'all' || filterType === 'process') && (
|
||||
<FilterDropdown
|
||||
label={t('profiles.cloudView.filters.layer')}
|
||||
value={filterLayerHeight}
|
||||
options={[
|
||||
{ value: 'all', label: t('profiles.cloudView.filters.all') },
|
||||
...filterOptions.layerHeights.map(l => ({ value: l, label: l })),
|
||||
]}
|
||||
onChange={setFilterLayerHeight}
|
||||
/>
|
||||
)}
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="px-3 py-2 text-sm text-bambu-gray hover:text-white transition-colors"
|
||||
>
|
||||
{t('profiles.cloudView.clearFilters')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4 mb-4 text-sm text-bambu-gray">
|
||||
{lastSyncTime && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{t('profiles.cloudView.lastSynced')} {formatRelativeTime(lastSyncTime.toISOString(), 'system', t)}
|
||||
</div>
|
||||
)}
|
||||
<span>{t('profiles.cloudView.showingCount', { showing: filteredPresets.length, total: totalCount })}</span>
|
||||
</div>
|
||||
|
||||
{filteredPresets.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<Layers className="w-12 h-12 text-bambu-gray-dark mx-auto mb-4" />
|
||||
<p className="text-bambu-gray">{t('profiles.cloudView.noPresetsFound')}</p>
|
||||
{hasActiveFilters && (
|
||||
<button onClick={clearFilters} className="mt-2 text-sm text-bambu-green hover:text-bambu-green-light">
|
||||
{t('profiles.cloudView.clearFilters')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<PresetColumn
|
||||
icon={<Droplet className="w-4 h-4 text-amber-400" />}
|
||||
title={t('profiles.cloudView.columns.filament')}
|
||||
presets={presetsByType('filament')}
|
||||
emptyText={t('profiles.cloudView.noFilamentPresets')}
|
||||
onSelect={setSelectedSetting}
|
||||
/>
|
||||
<PresetColumn
|
||||
icon={<Settings2 className="w-4 h-4 text-blue-400" />}
|
||||
title={t('profiles.cloudView.columns.process')}
|
||||
presets={presetsByType('process')}
|
||||
emptyText={t('profiles.cloudView.noProcessPresets')}
|
||||
onSelect={setSelectedSetting}
|
||||
/>
|
||||
<PresetColumn
|
||||
icon={<PrinterIcon className="w-4 h-4 text-purple-400" />}
|
||||
title={t('profiles.cloudView.columns.printer')}
|
||||
presets={presetsByType('printer')}
|
||||
emptyText={t('profiles.cloudView.noPrinterPresets')}
|
||||
onSelect={setSelectedSetting}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedSetting && (
|
||||
<OrcaPresetDetailModal
|
||||
setting={selectedSetting}
|
||||
onClose={() => setSelectedSetting(null)}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface PresetColumnProps {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
presets: (OrcaProfileMeta & { meta: PresetMeta })[];
|
||||
emptyText: string;
|
||||
onSelect: (preset: OrcaProfileMeta) => void;
|
||||
}
|
||||
|
||||
function PresetColumn({ icon, title, presets, emptyText, onSelect }: PresetColumnProps) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3 px-1">
|
||||
{icon}
|
||||
<h3 className="text-sm font-medium text-bambu-gray">{title}</h3>
|
||||
<span className="text-xs text-bambu-gray-dark">({presets.length})</span>
|
||||
</div>
|
||||
<div className="space-y-1 max-h-[calc(100vh-320px)] overflow-y-auto pr-1">
|
||||
{presets.length === 0 ? (
|
||||
<p className="text-xs text-bambu-gray-dark px-3 py-2">{emptyText}</p>
|
||||
) : (
|
||||
presets.map((preset) => (
|
||||
<PresetCard key={preset.setting_id} preset={preset} onClick={() => onSelect(preset)} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PresetCard({
|
||||
preset,
|
||||
onClick,
|
||||
}: {
|
||||
preset: OrcaProfileMeta & { meta: PresetMeta };
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="w-full text-left px-3 py-2 rounded bg-bambu-dark hover:bg-bambu-dark-tertiary transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white text-sm truncate flex-1" title={preset.name}>
|
||||
{preset.name}
|
||||
</span>
|
||||
{preset.meta.filamentType && preset.type === 'filament' && (
|
||||
<span className="text-xs text-bambu-gray whitespace-nowrap">{preset.meta.filamentType}</span>
|
||||
)}
|
||||
{preset.meta.layerHeight && preset.type === 'process' && (
|
||||
<span className="text-xs text-bambu-gray whitespace-nowrap">{preset.meta.layerHeight}</span>
|
||||
)}
|
||||
{preset.meta.printer && (
|
||||
<span className="text-xs text-bambu-gray whitespace-nowrap">{preset.meta.printer}</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface OrcaPresetDetailModalProps {
|
||||
setting: OrcaProfileMeta;
|
||||
onClose: () => void;
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
function OrcaPresetDetailModal({ setting, onClose, t }: OrcaPresetDetailModalProps) {
|
||||
const { data: detail, isLoading, error } = useQuery({
|
||||
queryKey: ['orcaCloudProfileDetail', setting.setting_id],
|
||||
queryFn: () => api.orcaCloudGetProfile(setting.setting_id),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" onClick={onClose}>
|
||||
<div
|
||||
className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-2xl max-w-4xl w-full max-h-[90vh] flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-white">{setting.name}</h2>
|
||||
<p className="text-xs text-bambu-gray mt-0.5">{setting.type}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-bambu-gray hover:text-white p-1">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<p className="text-center text-bambu-gray py-16">{(error as Error).message}</p>
|
||||
) : detail ? (
|
||||
<pre className="text-xs font-mono text-bambu-gray bg-bambu-dark p-3 rounded overflow-x-auto whitespace-pre">
|
||||
{JSON.stringify(detail.setting, null, 2)}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="text-center text-bambu-gray py-16">{t('profiles.cloudView.noPresetsFound')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
515
frontend/src/components/OrcaCloudView.tsx
Normal file
515
frontend/src/components/OrcaCloudView.tsx
Normal file
|
|
@ -0,0 +1,515 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Cloud, ExternalLink, LogOut, Loader2, AlertCircle, AlertTriangle, Check, Mail, ArrowLeft } from 'lucide-react';
|
||||
|
||||
import { api } from '../api/client';
|
||||
import type { OrcaOAuthProvider } from '../api/client';
|
||||
import { Card, CardContent } from './Card';
|
||||
import { Button } from './Button';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { OrcaCloudProfilesView } from './OrcaCloudProfilesView';
|
||||
|
||||
/**
|
||||
* Orca Cloud profile sync tab.
|
||||
*
|
||||
* Auth uses a paste-based PKCE handshake: backend generates the verifier and
|
||||
* authorize URL, the user opens it in a new tab and signs in, the browser
|
||||
* redirects to ``http://localhost:41172/callback`` (which fails to load since
|
||||
* Bambuddy isn't on the user's localhost), and the user copies the URL from
|
||||
* their address bar back into the paste textarea below. The backend extracts
|
||||
* the code, validates state for CSRF, and exchanges for tokens.
|
||||
*
|
||||
* See OrcaSlicer/OrcaSlicer#14028 for the open feature request asking
|
||||
* SoftFever to broaden the Supabase redirect_to allowlist so we could ship
|
||||
* a clean OAuth callback instead.
|
||||
*/
|
||||
export function OrcaCloudView() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
const { hasPermission } = useAuth();
|
||||
const canManage = hasPermission('orca_cloud:auth');
|
||||
|
||||
// Paste-flow local state: once the user clicks an OAuth provider, we hold
|
||||
// the returned auth_url so the same URL stays clickable while they go
|
||||
// fetch the callback URL from their browser. ``mode`` drives which
|
||||
// sub-form is showing: picker → OAuth paste-flow → email/password form.
|
||||
const [mode, setMode] = useState<'picker' | 'paste' | 'password'>('picker');
|
||||
const [authUrl, setAuthUrl] = useState<string | null>(null);
|
||||
const [pastedUrl, setPastedUrl] = useState('');
|
||||
const [pasteError, setPasteError] = useState<string | null>(null);
|
||||
const [passwordEmail, setPasswordEmail] = useState('');
|
||||
const [passwordValue, setPasswordValue] = useState('');
|
||||
const [passwordError, setPasswordError] = useState<string | null>(null);
|
||||
|
||||
const { data: status, isLoading: statusLoading } = useQuery({
|
||||
queryKey: ['orcaCloudStatus'],
|
||||
queryFn: api.orcaCloudStatus,
|
||||
});
|
||||
|
||||
const connected = !!status?.connected;
|
||||
|
||||
const {
|
||||
data: profilesData,
|
||||
isLoading: profilesLoading,
|
||||
refetch: refetchProfiles,
|
||||
isRefetching: profilesRefetching,
|
||||
error: profilesError,
|
||||
dataUpdatedAt: profilesUpdatedAt,
|
||||
} = useQuery({
|
||||
queryKey: ['orcaCloudProfiles'],
|
||||
queryFn: api.orcaCloudListProfiles,
|
||||
enabled: connected,
|
||||
retry: false,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
});
|
||||
|
||||
// Configured Bambuddy printers — fed into the profile-view's printer
|
||||
// filter dropdown so the user can narrow profiles to a specific printer
|
||||
// model. Same usage as the Bambu Cloud tab.
|
||||
const { data: printers = [] } = useQuery({
|
||||
queryKey: ['printers'],
|
||||
queryFn: api.getPrinters,
|
||||
enabled: connected,
|
||||
});
|
||||
|
||||
const [lastSyncTime, setLastSyncTime] = useState<Date | undefined>();
|
||||
useEffect(() => {
|
||||
if (profilesUpdatedAt) setLastSyncTime(new Date(profilesUpdatedAt));
|
||||
}, [profilesUpdatedAt]);
|
||||
|
||||
const startAuthMutation = useMutation({
|
||||
mutationFn: (provider: OrcaOAuthProvider) => api.orcaCloudStartAuth(provider),
|
||||
onSuccess: (data) => {
|
||||
setAuthUrl(data.auth_url);
|
||||
setPastedUrl('');
|
||||
setPasteError(null);
|
||||
setMode('paste');
|
||||
// Open in a new tab so the user can keep Bambuddy open in their
|
||||
// current tab while they sign in.
|
||||
window.open(data.auth_url, '_blank', 'noopener,noreferrer');
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
showToast(err.message || t('profiles.orcaCloud.errors.startFailed'), 'error');
|
||||
},
|
||||
});
|
||||
|
||||
const finishAuthMutation = useMutation({
|
||||
mutationFn: (url: string) => api.orcaCloudFinishAuth(url),
|
||||
onSuccess: (data) => {
|
||||
setAuthUrl(null);
|
||||
setPastedUrl('');
|
||||
setPasteError(null);
|
||||
setMode('picker');
|
||||
queryClient.invalidateQueries({ queryKey: ['orcaCloudStatus'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['orcaCloudProfiles'] });
|
||||
showToast(t('profiles.orcaCloud.toast.connected', { email: data.email || '' }));
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
// Surface the backend's error message in the paste-error slot so the
|
||||
// user can fix the input (rather than a transient toast they might miss).
|
||||
setPasteError(err.message || t('profiles.orcaCloud.errors.finishFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
const passwordLoginMutation = useMutation({
|
||||
mutationFn: ({ email, password }: { email: string; password: string }) =>
|
||||
api.orcaCloudPasswordLogin(email, password),
|
||||
onSuccess: (data) => {
|
||||
setPasswordEmail('');
|
||||
setPasswordValue('');
|
||||
setPasswordError(null);
|
||||
setMode('picker');
|
||||
queryClient.invalidateQueries({ queryKey: ['orcaCloudStatus'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['orcaCloudProfiles'] });
|
||||
showToast(t('profiles.orcaCloud.toast.connected', { email: data.email || '' }));
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setPasswordError(err.message || t('profiles.orcaCloud.errors.passwordFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
const logoutMutation = useMutation({
|
||||
mutationFn: api.orcaCloudLogout,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['orcaCloudStatus'] });
|
||||
queryClient.removeQueries({ queryKey: ['orcaCloudProfiles'] });
|
||||
showToast(t('profiles.orcaCloud.toast.disconnected'));
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmitPaste = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setPasteError(null);
|
||||
const trimmed = pastedUrl.trim();
|
||||
if (!trimmed) {
|
||||
setPasteError(t('profiles.orcaCloud.errors.emptyPaste'));
|
||||
return;
|
||||
}
|
||||
if (!trimmed.includes('code=')) {
|
||||
setPasteError(t('profiles.orcaCloud.errors.noCode'));
|
||||
return;
|
||||
}
|
||||
finishAuthMutation.mutate(trimmed);
|
||||
};
|
||||
|
||||
const handleSubmitPassword = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setPasswordError(null);
|
||||
const email = passwordEmail.trim();
|
||||
if (!email || !passwordValue) {
|
||||
setPasswordError(t('profiles.orcaCloud.errors.passwordEmpty'));
|
||||
return;
|
||||
}
|
||||
passwordLoginMutation.mutate({ email, password: passwordValue });
|
||||
};
|
||||
|
||||
const resetToPicker = () => {
|
||||
setMode('picker');
|
||||
setAuthUrl(null);
|
||||
setPastedUrl('');
|
||||
setPasteError(null);
|
||||
setPasswordEmail('');
|
||||
setPasswordValue('');
|
||||
setPasswordError(null);
|
||||
};
|
||||
|
||||
if (statusLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{connected && (
|
||||
<div className="flex items-center justify-between p-3 mb-6 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 rounded-full bg-bambu-green animate-pulse" />
|
||||
<span className="text-sm text-bambu-gray">
|
||||
{t('profiles.orcaCloud.connectedAs')}{' '}
|
||||
<span className="text-white">{status?.email}</span>
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => logoutMutation.mutate()}
|
||||
disabled={logoutMutation.isPending || !canManage}
|
||||
title={!canManage ? t('profiles.orcaCloud.noLogoutPermission') : undefined}
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
{t('profiles.orcaCloud.logout')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!connected ? (
|
||||
<ConnectFlow
|
||||
mode={mode}
|
||||
authUrl={authUrl}
|
||||
pastedUrl={pastedUrl}
|
||||
setPastedUrl={setPastedUrl}
|
||||
pasteError={pasteError}
|
||||
passwordEmail={passwordEmail}
|
||||
setPasswordEmail={setPasswordEmail}
|
||||
passwordValue={passwordValue}
|
||||
setPasswordValue={setPasswordValue}
|
||||
passwordError={passwordError}
|
||||
onPickProvider={(provider) => startAuthMutation.mutate(provider)}
|
||||
onPickPassword={() => {
|
||||
setMode('password');
|
||||
setPasswordError(null);
|
||||
}}
|
||||
onSubmitPaste={handleSubmitPaste}
|
||||
onSubmitPassword={handleSubmitPassword}
|
||||
onBack={resetToPicker}
|
||||
isStarting={startAuthMutation.isPending}
|
||||
isFinishing={finishAuthMutation.isPending}
|
||||
isPasswordLoading={passwordLoginMutation.isPending}
|
||||
canManage={canManage}
|
||||
t={t}
|
||||
/>
|
||||
) : profilesLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
|
||||
</div>
|
||||
) : profilesError ? (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-bambu-gray mb-4">{(profilesError as Error).message}</p>
|
||||
<Button onClick={() => refetchProfiles()}>{t('profiles.orcaCloud.retry')}</Button>
|
||||
</div>
|
||||
) : profilesData ? (
|
||||
<OrcaCloudProfilesView
|
||||
settings={profilesData}
|
||||
lastSyncTime={lastSyncTime}
|
||||
onRefresh={() => refetchProfiles()}
|
||||
isRefreshing={profilesRefetching}
|
||||
printers={printers}
|
||||
t={t}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConnectFlowProps {
|
||||
mode: 'picker' | 'paste' | 'password';
|
||||
authUrl: string | null;
|
||||
pastedUrl: string;
|
||||
setPastedUrl: (v: string) => void;
|
||||
pasteError: string | null;
|
||||
passwordEmail: string;
|
||||
setPasswordEmail: (v: string) => void;
|
||||
passwordValue: string;
|
||||
setPasswordValue: (v: string) => void;
|
||||
passwordError: string | null;
|
||||
onPickProvider: (provider: OrcaOAuthProvider) => void;
|
||||
onPickPassword: () => void;
|
||||
onSubmitPaste: (e: React.FormEvent) => void;
|
||||
onSubmitPassword: (e: React.FormEvent) => void;
|
||||
onBack: () => void;
|
||||
isStarting: boolean;
|
||||
isFinishing: boolean;
|
||||
isPasswordLoading: boolean;
|
||||
canManage: boolean;
|
||||
t: (key: string, opts?: Record<string, string>) => string;
|
||||
}
|
||||
|
||||
function ConnectFlow(props: ConnectFlowProps) {
|
||||
if (props.mode === 'paste' && props.authUrl) {
|
||||
return <PasteCard {...props} authUrl={props.authUrl} />;
|
||||
}
|
||||
if (props.mode === 'password') {
|
||||
return <PasswordCard {...props} />;
|
||||
}
|
||||
return <PickerCard {...props} />;
|
||||
}
|
||||
|
||||
function PickerCard({
|
||||
onPickProvider,
|
||||
onPickPassword,
|
||||
isStarting,
|
||||
canManage,
|
||||
t,
|
||||
}: ConnectFlowProps) {
|
||||
// Orca's web sign-in offers four options: Google, Apple, GitHub (all
|
||||
// OAuth, paste-flow) and email+password (direct). We mirror that surface
|
||||
// so users with a non-Google account aren't blocked.
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center">
|
||||
<Cloud className="w-12 h-12 text-bambu-green mx-auto mb-4" />
|
||||
<h2 className="text-xl font-bold text-white mb-2">
|
||||
{t('profiles.orcaCloud.connect.title')}
|
||||
</h2>
|
||||
<p className="text-bambu-gray mb-6 max-w-xl mx-auto">
|
||||
{t('profiles.orcaCloud.connect.description')}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 max-w-sm mx-auto">
|
||||
<Button
|
||||
onClick={onPickPassword}
|
||||
disabled={isStarting || !canManage}
|
||||
title={!canManage ? t('profiles.orcaCloud.noConnectPermission') : undefined}
|
||||
>
|
||||
<Mail className="w-4 h-4" />
|
||||
{t('profiles.orcaCloud.providers.email')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => onPickProvider('google')}
|
||||
disabled={isStarting || !canManage}
|
||||
title={!canManage ? t('profiles.orcaCloud.noConnectPermission') : undefined}
|
||||
>
|
||||
{isStarting ? <Loader2 className="w-4 h-4 animate-spin" /> : <ExternalLink className="w-4 h-4" />}
|
||||
{t('profiles.orcaCloud.providers.google')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => onPickProvider('github')}
|
||||
disabled={isStarting || !canManage}
|
||||
title={!canManage ? t('profiles.orcaCloud.noConnectPermission') : undefined}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
{t('profiles.orcaCloud.providers.github')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => onPickProvider('apple')}
|
||||
disabled={isStarting || !canManage}
|
||||
title={!canManage ? t('profiles.orcaCloud.noConnectPermission') : undefined}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
{t('profiles.orcaCloud.providers.apple')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PasteCard({
|
||||
authUrl,
|
||||
pastedUrl,
|
||||
setPastedUrl,
|
||||
pasteError,
|
||||
onSubmitPaste,
|
||||
onBack,
|
||||
isFinishing,
|
||||
t,
|
||||
}: ConnectFlowProps & { authUrl: string }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="text-bambu-gray hover:text-white text-sm flex items-center gap-1 mb-4"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t('profiles.orcaCloud.back')}
|
||||
</button>
|
||||
<h2 className="text-xl font-bold text-white mb-4">
|
||||
{t('profiles.orcaCloud.paste.title')}
|
||||
</h2>
|
||||
|
||||
{/* Numbered-step list with prominent visual treatment. Step 2 carries
|
||||
the critical "the page failing is expected" message inside an
|
||||
amber callout so users don't read the connection-refused page
|
||||
as a Bambuddy error. */}
|
||||
<ol className="space-y-3 mb-6">
|
||||
<li className="flex gap-3">
|
||||
<span className="flex-shrink-0 w-7 h-7 rounded-full bg-bambu-dark-tertiary text-white text-sm font-bold flex items-center justify-center">1</span>
|
||||
<p className="text-base text-white pt-0.5">{t('profiles.orcaCloud.paste.step1')}</p>
|
||||
</li>
|
||||
<li className="flex gap-3">
|
||||
<span className="flex-shrink-0 w-7 h-7 rounded-full bg-amber-500/20 text-amber-400 text-sm font-bold flex items-center justify-center">2</span>
|
||||
<div className="flex-1 p-3 bg-amber-500/10 border border-amber-500/40 rounded">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-base text-white font-medium">{t('profiles.orcaCloud.paste.step2')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex gap-3">
|
||||
<span className="flex-shrink-0 w-7 h-7 rounded-full bg-bambu-dark-tertiary text-white text-sm font-bold flex items-center justify-center">3</span>
|
||||
<p className="text-base text-white pt-0.5">{t('profiles.orcaCloud.paste.step3')}</p>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div className="mb-4 p-3 bg-bambu-dark rounded border border-bambu-dark-tertiary">
|
||||
<p className="text-xs text-bambu-gray mb-1">{t('profiles.orcaCloud.paste.signInUrl')}</p>
|
||||
<a
|
||||
href={authUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-bambu-green text-sm break-all hover:underline"
|
||||
>
|
||||
{authUrl}
|
||||
</a>
|
||||
</div>
|
||||
<form onSubmit={onSubmitPaste}>
|
||||
<label htmlFor="orca-callback-url" className="block text-sm text-bambu-gray mb-2">
|
||||
{t('profiles.orcaCloud.paste.label')}
|
||||
</label>
|
||||
<textarea
|
||||
id="orca-callback-url"
|
||||
value={pastedUrl}
|
||||
onChange={(e) => setPastedUrl(e.target.value)}
|
||||
placeholder={t('profiles.orcaCloud.paste.placeholder')}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm font-mono resize-none focus:outline-none focus:border-bambu-green"
|
||||
rows={3}
|
||||
disabled={isFinishing}
|
||||
/>
|
||||
{pasteError && (
|
||||
<p className="mt-2 text-sm text-red-400 flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{pasteError}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<Button type="submit" disabled={isFinishing || !pastedUrl.trim()}>
|
||||
{isFinishing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
|
||||
{t('profiles.orcaCloud.paste.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordCard({
|
||||
passwordEmail,
|
||||
setPasswordEmail,
|
||||
passwordValue,
|
||||
setPasswordValue,
|
||||
passwordError,
|
||||
onSubmitPassword,
|
||||
onBack,
|
||||
isPasswordLoading,
|
||||
t,
|
||||
}: ConnectFlowProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6 max-w-md mx-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="text-bambu-gray hover:text-white text-sm flex items-center gap-1 mb-4"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t('profiles.orcaCloud.back')}
|
||||
</button>
|
||||
<h2 className="text-xl font-bold text-white mb-4">
|
||||
{t('profiles.orcaCloud.password.title')}
|
||||
</h2>
|
||||
<form onSubmit={onSubmitPassword} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="orca-password-email" className="block text-sm text-bambu-gray mb-1">
|
||||
{t('profiles.orcaCloud.password.email')}
|
||||
</label>
|
||||
<input
|
||||
id="orca-password-email"
|
||||
type="email"
|
||||
value={passwordEmail}
|
||||
onChange={(e) => setPasswordEmail(e.target.value)}
|
||||
placeholder={t('profiles.orcaCloud.password.emailPlaceholder')}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm focus:outline-none focus:border-bambu-green"
|
||||
disabled={isPasswordLoading}
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="orca-password-value" className="block text-sm text-bambu-gray mb-1">
|
||||
{t('profiles.orcaCloud.password.password')}
|
||||
</label>
|
||||
<input
|
||||
id="orca-password-value"
|
||||
type="password"
|
||||
value={passwordValue}
|
||||
onChange={(e) => setPasswordValue(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm focus:outline-none focus:border-bambu-green"
|
||||
disabled={isPasswordLoading}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
{passwordError && (
|
||||
<p className="text-sm text-red-400 flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{passwordError}
|
||||
</p>
|
||||
)}
|
||||
<Button type="submit" disabled={isPasswordLoading || !passwordEmail.trim() || !passwordValue}>
|
||||
{isPasswordLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
|
||||
{t('profiles.orcaCloud.password.submit')}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -38,13 +38,15 @@ interface SliceModalProps {
|
|||
|
||||
type Slot = 'printer' | 'process' | 'filament';
|
||||
|
||||
// SliceModal-specific tier priority: local (imported) → cloud → standard.
|
||||
// Imported profiles are surfaced first because they're the user's curated
|
||||
// picks (often colour/type-tagged), cloud is second since names alone can't
|
||||
// drive metadata-aware match, standard is the bundled fallback. This is
|
||||
// SliceModal-specific tier priority: orca_cloud → local → cloud → standard.
|
||||
// Imported (local) profiles are surfaced before Bambu Cloud because they're
|
||||
// metadata-tagged (Bambu Cloud isn't, by design — see
|
||||
// `_fetch_cloud_presets`'s rate-limit note). Orca Cloud comes first because
|
||||
// its sync_pull response inlines metadata too AND represents the user's
|
||||
// most-recently-curated source. Standard is the bundled fallback. This is
|
||||
// distinct from the listing endpoint's dedup order and only affects what
|
||||
// the SliceModal renders / pre-picks.
|
||||
const SLICE_MODAL_TIER_ORDER = ['local', 'cloud', 'standard'] as const;
|
||||
const SLICE_MODAL_TIER_ORDER = ['orca_cloud', 'local', 'cloud', 'standard'] as const;
|
||||
|
||||
function pickDefault(by: UnifiedPresetsResponse, slot: Slot): PresetRef | null {
|
||||
for (const tier of SLICE_MODAL_TIER_ORDER) {
|
||||
|
|
@ -115,6 +117,7 @@ function pickProcessDefault(
|
|||
}
|
||||
|
||||
const TIER_BONUS: Record<PresetSource, number> = {
|
||||
orca_cloud: 1.75,
|
||||
local: 1.5,
|
||||
cloud: 1.0,
|
||||
standard: 0.5,
|
||||
|
|
@ -176,7 +179,7 @@ function fromRefValue(raw: string): PresetRef | null {
|
|||
if (idx < 0) return null;
|
||||
const source = raw.slice(0, idx) as PresetSource;
|
||||
const id = raw.slice(idx + 1);
|
||||
if (source !== 'cloud' && source !== 'local' && source !== 'standard') return null;
|
||||
if (source !== 'orca_cloud' && source !== 'cloud' && source !== 'local' && source !== 'standard') return null;
|
||||
return { source, id };
|
||||
}
|
||||
|
||||
|
|
@ -747,8 +750,9 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
|
|||
{presetsQuery.data && (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
<CloudStatusBanner status={presetsQuery.data.cloud_status} />
|
||||
<div className="flex-1 space-y-2">
|
||||
<CloudStatusBanner status={presetsQuery.data.cloud_status} cloudName="bambu" />
|
||||
<CloudStatusBanner status={presetsQuery.data.orca_cloud_status} cloudName="orca" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -987,34 +991,67 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
|
|||
);
|
||||
}
|
||||
|
||||
function CloudStatusBanner({ status }: { status: SlicerCloudStatus }) {
|
||||
function CloudStatusBanner({
|
||||
status,
|
||||
cloudName = 'bambu',
|
||||
}: {
|
||||
status: SlicerCloudStatus;
|
||||
cloudName?: 'bambu' | 'orca';
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (status === 'ok') return null;
|
||||
|
||||
// Map each non-ok status to the appropriate icon + tone. None of these are
|
||||
// hard errors — the user can still slice using local + standard presets,
|
||||
// so we use info / warn styling rather than error red.
|
||||
const config: Record<Exclude<SlicerCloudStatus, 'ok'>, { tone: string; icon: typeof Cloud; key: string; fallback: string }> = {
|
||||
// Same status vocabulary for both Bambu and Orca Cloud — only the
|
||||
// user-facing text varies. The fallbacks below name each cloud explicitly
|
||||
// so the banner makes sense without translation when i18n hasn't been
|
||||
// updated for a new locale.
|
||||
const messages =
|
||||
cloudName === 'orca'
|
||||
? {
|
||||
not_authenticated: {
|
||||
key: 'slice.orcaCloud.notAuthenticated',
|
||||
fallback: 'Sign in to Orca Cloud (Profiles → Orca Cloud) to see your Orca presets.',
|
||||
},
|
||||
expired: {
|
||||
key: 'slice.orcaCloud.expired',
|
||||
fallback: 'Orca Cloud session expired — sign in again to refresh your Orca presets.',
|
||||
},
|
||||
unreachable: {
|
||||
key: 'slice.orcaCloud.unreachable',
|
||||
fallback: 'Orca Cloud is unreachable right now. Other presets still work.',
|
||||
},
|
||||
}
|
||||
: {
|
||||
not_authenticated: {
|
||||
key: 'slice.cloud.notAuthenticated',
|
||||
fallback: 'Sign in to Bambu Cloud (Settings → Profiles → Cloud) to see your cloud presets.',
|
||||
},
|
||||
expired: {
|
||||
key: 'slice.cloud.expired',
|
||||
fallback: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.',
|
||||
},
|
||||
unreachable: {
|
||||
key: 'slice.cloud.unreachable',
|
||||
fallback: 'Bambu Cloud is unreachable right now. Local and standard presets still work.',
|
||||
},
|
||||
};
|
||||
|
||||
const tones: Record<Exclude<SlicerCloudStatus, 'ok'>, { tone: string; icon: typeof Cloud }> = {
|
||||
not_authenticated: {
|
||||
tone: 'border-bambu-dark-tertiary/40 bg-bambu-dark text-bambu-gray',
|
||||
icon: Cloud,
|
||||
key: 'slice.cloud.notAuthenticated',
|
||||
fallback: 'Sign in to Bambu Cloud (Settings → Profiles → Cloud) to see your cloud presets.',
|
||||
},
|
||||
expired: {
|
||||
tone: 'border-amber-700/40 bg-amber-900/20 text-amber-200',
|
||||
icon: CloudOff,
|
||||
key: 'slice.cloud.expired',
|
||||
fallback: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.',
|
||||
},
|
||||
unreachable: {
|
||||
tone: 'border-bambu-dark-tertiary/40 bg-bambu-dark text-bambu-gray',
|
||||
icon: CloudOff,
|
||||
key: 'slice.cloud.unreachable',
|
||||
fallback: 'Bambu Cloud is unreachable right now. Local and standard presets still work.',
|
||||
},
|
||||
};
|
||||
const { tone, icon: Icon, key, fallback } = config[status];
|
||||
const { tone, icon: Icon } = tones[status];
|
||||
const { key, fallback } = messages[status];
|
||||
return (
|
||||
<div className={`flex items-start gap-2 text-xs rounded-md border p-2 ${tone}`} role="status">
|
||||
<Icon className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||
|
|
@ -1112,8 +1149,9 @@ function PresetDropdown({
|
|||
// empty sections collapse out.
|
||||
const { sections, otherEntries } = useMemo(() => {
|
||||
const tiers: { key: keyof UnifiedPresetsResponse; label: string; fallback: string }[] = [
|
||||
{ key: 'orca_cloud', label: 'slice.tier.orcaCloud', fallback: 'Orca Cloud' },
|
||||
{ key: 'local', label: 'slice.tier.local', fallback: 'Imported' },
|
||||
{ key: 'cloud', label: 'slice.tier.cloud', fallback: 'Cloud' },
|
||||
{ key: 'cloud', label: 'slice.tier.cloud', fallback: 'Bambu Cloud' },
|
||||
{ key: 'standard', label: 'slice.tier.standard', fallback: 'Standard' },
|
||||
];
|
||||
const filterByPrinter = slot !== 'printer';
|
||||
|
|
|
|||
|
|
@ -122,23 +122,51 @@ export function SpoolFormModal({
|
|||
setRecentColors(loadRecentColors());
|
||||
}, []);
|
||||
|
||||
// Fetch cloud presets and catalog when modal opens
|
||||
// Fetch cloud presets and catalog when modal opens. Fetches Bambu Cloud
|
||||
// and Orca Cloud in parallel; merges Orca filaments into ``cloudPresets``
|
||||
// since ``OrcaProfileMeta`` is structurally identical to ``SlicerSetting``
|
||||
// (same fields, same semantics). ``cloudAuthenticated`` flips on if either
|
||||
// cloud is connected — the UI only uses it to gate "no cloud" hints.
|
||||
useEffect(() => {
|
||||
// ``cancelled`` gates every state setter so a fetch that resolves AFTER
|
||||
// the modal closes / unmounts can't fire setState on a torn-down
|
||||
// component. Without this guard the parallel Promise.allSettled chain
|
||||
// can still hit ``setLoadingCloudPresets(false)`` in its ``finally``
|
||||
// after vitest has dismantled the JSDOM window — surfaced as an
|
||||
// "Unhandled Rejection: window is not defined" in CI runs.
|
||||
let cancelled = false;
|
||||
if (isOpen) {
|
||||
const fetchData = async () => {
|
||||
setLoadingCloudPresets(true);
|
||||
try {
|
||||
const status = await api.getCloudStatus();
|
||||
setCloudAuthenticated(status.is_authenticated);
|
||||
if (status.is_authenticated) {
|
||||
const presets = await api.getFilamentPresets();
|
||||
setCloudPresets(presets);
|
||||
}
|
||||
const [bambuResult, orcaResult] = await Promise.allSettled([
|
||||
(async () => {
|
||||
const status = await api.getCloudStatus();
|
||||
if (!status.is_authenticated) return { connected: false, presets: [] as SlicerSetting[] };
|
||||
const presets = await api.getFilamentPresets();
|
||||
return { connected: true, presets };
|
||||
})(),
|
||||
(async () => {
|
||||
const status = await api.orcaCloudStatus();
|
||||
if (!status.connected) return { connected: false, presets: [] as SlicerSetting[] };
|
||||
const list = await api.orcaCloudListProfiles();
|
||||
// OrcaProfileMeta is structurally identical to SlicerSetting.
|
||||
return { connected: true, presets: list.filament as unknown as SlicerSetting[] };
|
||||
})(),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
const bambuConnected = bambuResult.status === 'fulfilled' && bambuResult.value.connected;
|
||||
const orcaConnected = orcaResult.status === 'fulfilled' && orcaResult.value.connected;
|
||||
const bambuPresets = bambuResult.status === 'fulfilled' ? bambuResult.value.presets : [];
|
||||
const orcaPresets = orcaResult.status === 'fulfilled' ? orcaResult.value.presets : [];
|
||||
setCloudAuthenticated(bambuConnected || orcaConnected);
|
||||
setCloudPresets([...bambuPresets, ...orcaPresets]);
|
||||
} catch (e) {
|
||||
if (cancelled) return;
|
||||
console.error('Failed to fetch cloud presets:', e);
|
||||
setCloudAuthenticated(false);
|
||||
} finally {
|
||||
setLoadingCloudPresets(false);
|
||||
if (!cancelled) setLoadingCloudPresets(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
|
|
@ -195,6 +223,9 @@ export function SpoolFormModal({
|
|||
// "test environment was torn down" errors in vitest. spoolmanMode only
|
||||
// gates a single fetch (getSpoolCatalog) which is cheap enough to skip
|
||||
// when the modal opens in Spoolman mode.
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen, printersWithCalibrations.length]);
|
||||
|
||||
|
|
|
|||
|
|
@ -2848,10 +2848,63 @@ export default {
|
|||
title: 'Profile',
|
||||
subtitle: 'Verwalten Sie Ihre Slicer-Voreinstellungen und Druckvorschub-Kalibrierungen',
|
||||
tabs: {
|
||||
cloud: 'Cloud-Profile',
|
||||
bambuCloud: 'Bambu Cloud',
|
||||
orcaCloud: 'Orca Cloud',
|
||||
local: 'Lokale Profile',
|
||||
kprofiles: 'K-Profile',
|
||||
},
|
||||
orcaCloud: {
|
||||
connectedAs: 'Verbunden als',
|
||||
logout: 'Trennen',
|
||||
noLogoutPermission: 'Sie haben keine Berechtigung zum Trennen',
|
||||
noConnectPermission: 'Sie haben keine Berechtigung, sich mit Orca Cloud zu verbinden',
|
||||
retry: 'Erneut versuchen',
|
||||
back: 'Andere Anmeldemethode verwenden',
|
||||
connect: {
|
||||
title: 'Mit Orca Cloud verbinden',
|
||||
description: 'Melden Sie sich bei Ihrem Orca Cloud-Konto an, um Ihre Slicer-Profile in Bambuddy zu synchronisieren.',
|
||||
},
|
||||
providers: {
|
||||
google: 'Mit Google anmelden',
|
||||
apple: 'Mit Apple anmelden',
|
||||
github: 'Mit GitHub anmelden',
|
||||
email: 'Mit E-Mail und Passwort anmelden',
|
||||
},
|
||||
password: {
|
||||
title: 'Mit E-Mail und Passwort anmelden',
|
||||
email: 'E-Mail',
|
||||
emailPlaceholder: 'sie@beispiel.de',
|
||||
password: 'Passwort',
|
||||
submit: 'Anmelden',
|
||||
},
|
||||
paste: {
|
||||
title: 'Anmeldung abschließen',
|
||||
step1: 'Ein neuer Tab wurde mit der Orca Cloud-Anmeldeseite geöffnet. Melden Sie sich mit Ihrem Orca-Konto an.',
|
||||
step2: 'Ihr Browser wird zu einer "localhost"-URL umgeleitet, die nicht geladen werden kann. Das ist normal — die URL ist es, was wir brauchen.',
|
||||
step3: 'Kopieren Sie die gesamte URL aus der Adressleiste Ihres Browsers und fügen Sie sie unten ein.',
|
||||
signInUrl: 'Falls sich der Anmelde-Tab nicht geöffnet hat, klicken Sie auf diese URL:',
|
||||
label: 'Callback-URL hier einfügen',
|
||||
placeholder: 'http://localhost:41172/callback?code=...&state=...',
|
||||
submit: 'Verbindung abschließen',
|
||||
},
|
||||
profiles: {
|
||||
title: 'Ihre Orca Cloud-Profile ({{count}})',
|
||||
refresh: 'Aktualisieren',
|
||||
empty: 'Noch keine Profile in Ihrem Orca Cloud-Konto gefunden.',
|
||||
},
|
||||
toast: {
|
||||
connected: 'Mit Orca Cloud verbunden als {{email}}',
|
||||
disconnected: 'Verbindung zu Orca Cloud getrennt',
|
||||
},
|
||||
errors: {
|
||||
startFailed: 'Anmeldevorgang für Orca Cloud konnte nicht gestartet werden.',
|
||||
finishFailed: 'Orca Cloud-Anmeldung konnte nicht abgeschlossen werden.',
|
||||
passwordFailed: 'Anmeldung mit dieser E-Mail und diesem Passwort fehlgeschlagen.',
|
||||
passwordEmpty: 'Bitte geben Sie sowohl E-Mail als auch Passwort ein.',
|
||||
emptyPaste: 'Bitte fügen Sie die Callback-URL aus Ihrem Browser ein.',
|
||||
noCode: 'Diese URL sieht nicht wie ein Orca Cloud-Callback aus (kein code-Parameter). Kopieren Sie die vollständige URL aus der Adressleiste.',
|
||||
},
|
||||
},
|
||||
localProfiles: {
|
||||
title: 'Lokale Profile',
|
||||
subtitle: 'Slicer-Voreinstellungen aus OrcaSlicer importieren und verwalten',
|
||||
|
|
@ -3500,14 +3553,20 @@ export default {
|
|||
failedToast: 'Slicen von {{name}} fehlgeschlagen: {{detail}}',
|
||||
tier: {
|
||||
local: 'Importiert',
|
||||
cloud: 'Cloud',
|
||||
cloud: 'Bambu Cloud',
|
||||
orcaCloud: 'Orca Cloud',
|
||||
standard: 'Standard',
|
||||
},
|
||||
cloud: {
|
||||
notAuthenticated: 'In Bambu Cloud anmelden (Einstellungen → Profile → Cloud), um deine Cloud-Profile zu sehen.',
|
||||
notAuthenticated: 'In Bambu Cloud anmelden (Einstellungen → Profile → Bambu Cloud), um deine Cloud-Profile zu sehen.',
|
||||
expired: 'Bambu-Cloud-Sitzung abgelaufen — erneut anmelden, um die Cloud-Profile zu aktualisieren.',
|
||||
unreachable: 'Bambu Cloud ist gerade nicht erreichbar. Lokale und Standard-Profile funktionieren weiterhin.',
|
||||
},
|
||||
orcaCloud: {
|
||||
notAuthenticated: 'Bei Orca Cloud anmelden (Profile → Orca Cloud), um Ihre Orca-Profile zu sehen.',
|
||||
expired: 'Orca Cloud-Sitzung abgelaufen — erneut anmelden, um die Orca-Profile zu aktualisieren.',
|
||||
unreachable: 'Orca Cloud ist derzeit nicht erreichbar. Andere Profile funktionieren weiterhin.',
|
||||
},
|
||||
bedType: {
|
||||
label: 'Druckbett',
|
||||
auto: 'Auto (aus Prozess-Profil)',
|
||||
|
|
|
|||
|
|
@ -2851,10 +2851,63 @@ export default {
|
|||
title: 'Profiles',
|
||||
subtitle: 'Manage your slicer presets and pressure advance calibrations',
|
||||
tabs: {
|
||||
cloud: 'Cloud Profiles',
|
||||
bambuCloud: 'Bambu Cloud',
|
||||
orcaCloud: 'Orca Cloud',
|
||||
local: 'Local Profiles',
|
||||
kprofiles: 'K-Profiles',
|
||||
},
|
||||
orcaCloud: {
|
||||
connectedAs: 'Connected as',
|
||||
logout: 'Disconnect',
|
||||
noLogoutPermission: 'You do not have permission to disconnect',
|
||||
noConnectPermission: 'You do not have permission to connect to Orca Cloud',
|
||||
retry: 'Retry',
|
||||
back: 'Use a different sign-in method',
|
||||
connect: {
|
||||
title: 'Connect to Orca Cloud',
|
||||
description: 'Sign in to your Orca Cloud account to sync your slicer profiles into Bambuddy.',
|
||||
},
|
||||
providers: {
|
||||
google: 'Sign in with Google',
|
||||
apple: 'Sign in with Apple',
|
||||
github: 'Sign in with GitHub',
|
||||
email: 'Sign in with email and password',
|
||||
},
|
||||
password: {
|
||||
title: 'Sign in with email and password',
|
||||
email: 'Email',
|
||||
emailPlaceholder: 'you@example.com',
|
||||
password: 'Password',
|
||||
submit: 'Sign in',
|
||||
},
|
||||
paste: {
|
||||
title: 'Finish signing in',
|
||||
step1: 'A new tab opened with the Orca Cloud sign-in page. Sign in with your Orca account.',
|
||||
step2: 'Your browser will be redirected to a "localhost" URL that fails to load. That is expected — the URL is what we need.',
|
||||
step3: 'Copy the entire URL from your browser\'s address bar and paste it below.',
|
||||
signInUrl: 'If the sign-in tab did not open, click this URL:',
|
||||
label: 'Paste the callback URL here',
|
||||
placeholder: 'http://localhost:41172/callback?code=...&state=...',
|
||||
submit: 'Finish connecting',
|
||||
},
|
||||
profiles: {
|
||||
title: 'Your Orca Cloud profiles ({{count}})',
|
||||
refresh: 'Refresh',
|
||||
empty: 'No profiles found in your Orca Cloud account yet.',
|
||||
},
|
||||
toast: {
|
||||
connected: 'Connected to Orca Cloud as {{email}}',
|
||||
disconnected: 'Disconnected from Orca Cloud',
|
||||
},
|
||||
errors: {
|
||||
startFailed: 'Could not start the Orca Cloud sign-in flow.',
|
||||
finishFailed: 'Could not finish the Orca Cloud sign-in.',
|
||||
passwordFailed: 'Could not sign in with that email and password.',
|
||||
passwordEmpty: 'Please enter both your email and password.',
|
||||
emptyPaste: 'Please paste the callback URL from your browser.',
|
||||
noCode: 'That URL does not look like an Orca Cloud callback (no code parameter). Copy the full URL from your address bar.',
|
||||
},
|
||||
},
|
||||
localProfiles: {
|
||||
title: 'Local Profiles',
|
||||
subtitle: 'Import and manage slicer presets from OrcaSlicer',
|
||||
|
|
@ -3503,14 +3556,20 @@ export default {
|
|||
failedToast: 'Slicing {{name}} failed: {{detail}}',
|
||||
tier: {
|
||||
local: 'Imported',
|
||||
cloud: 'Cloud',
|
||||
cloud: 'Bambu Cloud',
|
||||
orcaCloud: 'Orca Cloud',
|
||||
standard: 'Standard',
|
||||
},
|
||||
cloud: {
|
||||
notAuthenticated: 'Sign in to Bambu Cloud (Settings → Profiles → Cloud) to see your cloud presets.',
|
||||
notAuthenticated: 'Sign in to Bambu Cloud (Settings → Profiles → Bambu Cloud) to see your cloud presets.',
|
||||
expired: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.',
|
||||
unreachable: 'Bambu Cloud is unreachable right now. Local and standard presets still work.',
|
||||
},
|
||||
orcaCloud: {
|
||||
notAuthenticated: 'Sign in to Orca Cloud (Profiles → Orca Cloud) to see your Orca presets.',
|
||||
expired: 'Orca Cloud session expired — sign in again to refresh your Orca presets.',
|
||||
unreachable: 'Orca Cloud is unreachable right now. Other presets still work.',
|
||||
},
|
||||
bedType: {
|
||||
label: 'Build plate',
|
||||
auto: 'Auto (use process preset)',
|
||||
|
|
|
|||
|
|
@ -2851,10 +2851,63 @@ export default {
|
|||
title: 'Perfiles',
|
||||
subtitle: 'Gestione sus preajustes del laminador y las calibraciones de avance de presión',
|
||||
tabs: {
|
||||
cloud: 'Perfiles en la nube',
|
||||
bambuCloud: 'Bambu Cloud',
|
||||
orcaCloud: 'Orca Cloud',
|
||||
local: 'Perfiles locales',
|
||||
kprofiles: 'Perfiles K',
|
||||
},
|
||||
orcaCloud: {
|
||||
connectedAs: 'Conectado como',
|
||||
logout: 'Desconectar',
|
||||
noLogoutPermission: 'No tienes permiso para desconectar',
|
||||
noConnectPermission: 'No tienes permiso para conectar a Orca Cloud',
|
||||
retry: 'Reintentar',
|
||||
back: 'Usar otro método de inicio de sesión',
|
||||
connect: {
|
||||
title: 'Conectar a Orca Cloud',
|
||||
description: 'Inicia sesión en tu cuenta Orca Cloud para sincronizar tus perfiles de slicer en Bambuddy.',
|
||||
},
|
||||
providers: {
|
||||
google: 'Iniciar sesión con Google',
|
||||
apple: 'Iniciar sesión con Apple',
|
||||
github: 'Iniciar sesión con GitHub',
|
||||
email: 'Iniciar sesión con correo y contraseña',
|
||||
},
|
||||
password: {
|
||||
title: 'Iniciar sesión con correo y contraseña',
|
||||
email: 'Correo electrónico',
|
||||
emailPlaceholder: 'tu@ejemplo.com',
|
||||
password: 'Contraseña',
|
||||
submit: 'Iniciar sesión',
|
||||
},
|
||||
paste: {
|
||||
title: 'Finalizar inicio de sesión',
|
||||
step1: 'Se abrió una nueva pestaña con la página de inicio de sesión de Orca Cloud. Inicia sesión con tu cuenta de Orca.',
|
||||
step2: 'Tu navegador será redirigido a una URL "localhost" que no se cargará. Es lo esperado — esa URL es lo que necesitamos.',
|
||||
step3: 'Copia la URL completa desde la barra de direcciones del navegador y pégala abajo.',
|
||||
signInUrl: 'Si la pestaña de inicio de sesión no se abrió, haz clic en esta URL:',
|
||||
label: 'Pega aquí la URL de callback',
|
||||
placeholder: 'http://localhost:41172/callback?code=...&state=...',
|
||||
submit: 'Finalizar conexión',
|
||||
},
|
||||
profiles: {
|
||||
title: 'Tus perfiles de Orca Cloud ({{count}})',
|
||||
refresh: 'Actualizar',
|
||||
empty: 'Aún no hay perfiles en tu cuenta de Orca Cloud.',
|
||||
},
|
||||
toast: {
|
||||
connected: 'Conectado a Orca Cloud como {{email}}',
|
||||
disconnected: 'Desconectado de Orca Cloud',
|
||||
},
|
||||
errors: {
|
||||
startFailed: 'No se pudo iniciar el inicio de sesión de Orca Cloud.',
|
||||
finishFailed: 'No se pudo finalizar el inicio de sesión de Orca Cloud.',
|
||||
passwordFailed: 'No se pudo iniciar sesión con ese correo y contraseña.',
|
||||
passwordEmpty: 'Por favor introduce tanto tu correo como tu contraseña.',
|
||||
emptyPaste: 'Pega la URL de callback desde tu navegador.',
|
||||
noCode: 'Esa URL no parece un callback de Orca Cloud (sin parámetro code). Copia la URL completa desde la barra de direcciones.',
|
||||
},
|
||||
},
|
||||
localProfiles: {
|
||||
title: 'Perfiles locales',
|
||||
subtitle: 'Importe y gestione preajustes del laminador desde OrcaSlicer',
|
||||
|
|
@ -3503,14 +3556,20 @@ export default {
|
|||
failedToast: 'Error al laminar {{name}}: {{detail}}',
|
||||
tier: {
|
||||
local: 'Importado',
|
||||
cloud: 'Nube',
|
||||
cloud: 'Bambu Cloud',
|
||||
orcaCloud: 'Orca Cloud',
|
||||
standard: 'Estándar',
|
||||
},
|
||||
cloud: {
|
||||
notAuthenticated: 'Inicie sesión en Bambu Cloud (Ajustes → Perfiles → Nube) para ver sus preajustes de la nube.',
|
||||
notAuthenticated: 'Inicie sesión en Bambu Cloud (Ajustes → Perfiles → Bambu Cloud) para ver sus preajustes de la nube.',
|
||||
expired: 'La sesión de Bambu Cloud ha caducado — inicie sesión de nuevo para actualizar sus preajustes de la nube.',
|
||||
unreachable: 'Bambu Cloud no es accesible en este momento. Los preajustes locales y estándar siguen funcionando.',
|
||||
},
|
||||
orcaCloud: {
|
||||
notAuthenticated: 'Inicia sesión en Orca Cloud (Perfiles → Orca Cloud) para ver tus preajustes Orca.',
|
||||
expired: 'Sesión de Orca Cloud caducada — inicia sesión de nuevo para actualizar tus preajustes Orca.',
|
||||
unreachable: 'Orca Cloud no está disponible ahora. Otros preajustes siguen funcionando.',
|
||||
},
|
||||
bedType: {
|
||||
label: 'Cama de impresión',
|
||||
auto: 'Automático (usar el preajuste de proceso)',
|
||||
|
|
|
|||
|
|
@ -2837,10 +2837,63 @@ export default {
|
|||
title: 'Profils',
|
||||
subtitle: 'Gérez vos presets slicer et calibrations Pressure Advance',
|
||||
tabs: {
|
||||
cloud: 'Profils Cloud',
|
||||
bambuCloud: 'Bambu Cloud',
|
||||
orcaCloud: 'Orca Cloud',
|
||||
local: 'Profils Locaux',
|
||||
kprofiles: 'Profils K',
|
||||
},
|
||||
orcaCloud: {
|
||||
connectedAs: 'Connecté en tant que',
|
||||
logout: 'Déconnecter',
|
||||
noLogoutPermission: 'Vous n\'avez pas la permission de vous déconnecter',
|
||||
noConnectPermission: 'Vous n\'avez pas la permission de vous connecter à Orca Cloud',
|
||||
retry: 'Réessayer',
|
||||
back: 'Utiliser une autre méthode de connexion',
|
||||
connect: {
|
||||
title: 'Se connecter à Orca Cloud',
|
||||
description: 'Connectez-vous à votre compte Orca Cloud pour synchroniser vos profils de slicer dans Bambuddy.',
|
||||
},
|
||||
providers: {
|
||||
google: 'Se connecter avec Google',
|
||||
apple: 'Se connecter avec Apple',
|
||||
github: 'Se connecter avec GitHub',
|
||||
email: 'Se connecter avec e-mail et mot de passe',
|
||||
},
|
||||
password: {
|
||||
title: 'Se connecter avec e-mail et mot de passe',
|
||||
email: 'E-mail',
|
||||
emailPlaceholder: 'vous@exemple.fr',
|
||||
password: 'Mot de passe',
|
||||
submit: 'Se connecter',
|
||||
},
|
||||
paste: {
|
||||
title: 'Terminer la connexion',
|
||||
step1: 'Un nouvel onglet s\'est ouvert avec la page de connexion Orca Cloud. Connectez-vous avec votre compte Orca.',
|
||||
step2: 'Votre navigateur sera redirigé vers une URL "localhost" qui ne se chargera pas. C\'est normal — c\'est cette URL qu\'il nous faut.',
|
||||
step3: 'Copiez l\'URL complète depuis la barre d\'adresse de votre navigateur et collez-la ci-dessous.',
|
||||
signInUrl: 'Si l\'onglet de connexion ne s\'est pas ouvert, cliquez sur cette URL :',
|
||||
label: 'Collez l\'URL de rappel ici',
|
||||
placeholder: 'http://localhost:41172/callback?code=...&state=...',
|
||||
submit: 'Terminer la connexion',
|
||||
},
|
||||
profiles: {
|
||||
title: 'Vos profils Orca Cloud ({{count}})',
|
||||
refresh: 'Actualiser',
|
||||
empty: 'Aucun profil trouvé dans votre compte Orca Cloud pour le moment.',
|
||||
},
|
||||
toast: {
|
||||
connected: 'Connecté à Orca Cloud en tant que {{email}}',
|
||||
disconnected: 'Déconnecté d\'Orca Cloud',
|
||||
},
|
||||
errors: {
|
||||
startFailed: 'Impossible de démarrer la connexion à Orca Cloud.',
|
||||
finishFailed: 'Impossible de terminer la connexion à Orca Cloud.',
|
||||
passwordFailed: 'Impossible de se connecter avec cet e-mail et ce mot de passe.',
|
||||
passwordEmpty: 'Veuillez saisir à la fois votre e-mail et votre mot de passe.',
|
||||
emptyPaste: 'Veuillez coller l\'URL de rappel depuis votre navigateur.',
|
||||
noCode: 'Cette URL ne ressemble pas à un rappel Orca Cloud (aucun paramètre code). Copiez l\'URL complète depuis la barre d\'adresse.',
|
||||
},
|
||||
},
|
||||
localProfiles: {
|
||||
title: 'Profils Locaux',
|
||||
subtitle: 'Gérez vos presets OrcaSlicer',
|
||||
|
|
@ -3489,14 +3542,20 @@ export default {
|
|||
failedToast: 'Échec du découpage de {{name}} : {{detail}}',
|
||||
tier: {
|
||||
local: 'Importé',
|
||||
cloud: 'Cloud',
|
||||
cloud: 'Bambu Cloud',
|
||||
orcaCloud: 'Orca Cloud',
|
||||
standard: 'Standard',
|
||||
},
|
||||
cloud: {
|
||||
notAuthenticated: 'Connectez-vous à Bambu Cloud (Paramètres → Profils → Cloud) pour voir vos préréglages cloud.',
|
||||
notAuthenticated: 'Connectez-vous à Bambu Cloud (Paramètres → Profils → Bambu Cloud) pour voir vos préréglages cloud.',
|
||||
expired: 'Session Bambu Cloud expirée – reconnectez-vous pour actualiser vos préréglages cloud.',
|
||||
unreachable: 'Bambu Cloud est inaccessible. Les préréglages locaux et standards fonctionnent encore.',
|
||||
},
|
||||
orcaCloud: {
|
||||
notAuthenticated: 'Connectez-vous à Orca Cloud (Profils → Orca Cloud) pour voir vos préréglages Orca.',
|
||||
expired: 'Session Orca Cloud expirée — reconnectez-vous pour actualiser vos préréglages Orca.',
|
||||
unreachable: 'Orca Cloud est indisponible pour le moment. Les autres préréglages fonctionnent toujours.',
|
||||
},
|
||||
bedType: {
|
||||
label: 'Plateau d\'impression',
|
||||
auto: 'Auto (utiliser le préréglage de processus)',
|
||||
|
|
|
|||
|
|
@ -2836,10 +2836,63 @@ export default {
|
|||
title: 'Profili',
|
||||
subtitle: 'Gestisci preset slicer e calibrazioni pressure advance',
|
||||
tabs: {
|
||||
cloud: 'Profili cloud',
|
||||
bambuCloud: 'Bambu Cloud',
|
||||
orcaCloud: 'Orca Cloud',
|
||||
local: 'Profili locali',
|
||||
kprofiles: 'Profili K',
|
||||
},
|
||||
orcaCloud: {
|
||||
connectedAs: 'Connesso come',
|
||||
logout: 'Disconnetti',
|
||||
noLogoutPermission: 'Non hai il permesso di disconnetterti',
|
||||
noConnectPermission: 'Non hai il permesso di connetterti a Orca Cloud',
|
||||
retry: 'Riprova',
|
||||
back: 'Usa un altro metodo di accesso',
|
||||
connect: {
|
||||
title: 'Connetti a Orca Cloud',
|
||||
description: 'Accedi al tuo account Orca Cloud per sincronizzare i profili dello slicer in Bambuddy.',
|
||||
},
|
||||
providers: {
|
||||
google: 'Accedi con Google',
|
||||
apple: 'Accedi con Apple',
|
||||
github: 'Accedi con GitHub',
|
||||
email: 'Accedi con email e password',
|
||||
},
|
||||
password: {
|
||||
title: 'Accedi con email e password',
|
||||
email: 'Email',
|
||||
emailPlaceholder: 'tu@esempio.it',
|
||||
password: 'Password',
|
||||
submit: 'Accedi',
|
||||
},
|
||||
paste: {
|
||||
title: 'Completa l\'accesso',
|
||||
step1: 'Si è aperta una nuova scheda con la pagina di accesso di Orca Cloud. Accedi con il tuo account Orca.',
|
||||
step2: 'Il browser verrà reindirizzato a un URL "localhost" che non riuscirà a caricarsi. È normale — è proprio quell\'URL che ci serve.',
|
||||
step3: 'Copia l\'intero URL dalla barra degli indirizzi del browser e incollalo qui sotto.',
|
||||
signInUrl: 'Se la scheda di accesso non si è aperta, clicca su questo URL:',
|
||||
label: 'Incolla qui l\'URL di callback',
|
||||
placeholder: 'http://localhost:41172/callback?code=...&state=...',
|
||||
submit: 'Completa la connessione',
|
||||
},
|
||||
profiles: {
|
||||
title: 'I tuoi profili Orca Cloud ({{count}})',
|
||||
refresh: 'Aggiorna',
|
||||
empty: 'Nessun profilo trovato nel tuo account Orca Cloud.',
|
||||
},
|
||||
toast: {
|
||||
connected: 'Connesso a Orca Cloud come {{email}}',
|
||||
disconnected: 'Disconnesso da Orca Cloud',
|
||||
},
|
||||
errors: {
|
||||
startFailed: 'Impossibile avviare l\'accesso a Orca Cloud.',
|
||||
finishFailed: 'Impossibile completare l\'accesso a Orca Cloud.',
|
||||
passwordFailed: 'Impossibile accedere con quell\'email e password.',
|
||||
passwordEmpty: 'Inserisci sia l\'email che la password.',
|
||||
emptyPaste: 'Incolla l\'URL di callback dal tuo browser.',
|
||||
noCode: 'Questo URL non sembra un callback di Orca Cloud (manca il parametro code). Copia l\'URL completo dalla barra degli indirizzi.',
|
||||
},
|
||||
},
|
||||
localProfiles: {
|
||||
title: 'Profili locali',
|
||||
subtitle: 'Importa e gestisci preset slicer da OrcaSlicer',
|
||||
|
|
@ -3488,14 +3541,20 @@ export default {
|
|||
failedToast: 'Slicing di {{name}} fallito: {{detail}}',
|
||||
tier: {
|
||||
local: 'Importato',
|
||||
cloud: 'Cloud',
|
||||
cloud: 'Bambu Cloud',
|
||||
orcaCloud: 'Orca Cloud',
|
||||
standard: 'Standard',
|
||||
},
|
||||
cloud: {
|
||||
notAuthenticated: 'Accedi a Bambu Cloud (Impostazioni → Profili → Cloud) per vedere i preset cloud.',
|
||||
notAuthenticated: 'Accedi a Bambu Cloud (Impostazioni → Profili → Bambu Cloud) per vedere i preset cloud.',
|
||||
expired: 'Sessione Bambu Cloud scaduta – accedi di nuovo per aggiornare i preset cloud.',
|
||||
unreachable: 'Bambu Cloud non è raggiungibile. I preset locali e standard funzionano ancora.',
|
||||
},
|
||||
orcaCloud: {
|
||||
notAuthenticated: 'Accedi a Orca Cloud (Profili → Orca Cloud) per vedere i tuoi preset Orca.',
|
||||
expired: 'Sessione Orca Cloud scaduta — accedi di nuovo per aggiornare i tuoi preset Orca.',
|
||||
unreachable: 'Orca Cloud non è raggiungibile in questo momento. Gli altri preset continuano a funzionare.',
|
||||
},
|
||||
bedType: {
|
||||
label: 'Piano di stampa',
|
||||
auto: 'Auto (usa preset di processo)',
|
||||
|
|
|
|||
|
|
@ -2848,10 +2848,63 @@ export default {
|
|||
title: 'フィラメントプロファイル',
|
||||
subtitle: 'スライサープリセットと圧力キャリブレーションの管理',
|
||||
tabs: {
|
||||
cloud: 'クラウドプロファイル',
|
||||
bambuCloud: 'Bambu Cloud',
|
||||
orcaCloud: 'Orca Cloud',
|
||||
local: 'ローカルプロファイル',
|
||||
kprofiles: 'Kプロファイル',
|
||||
},
|
||||
orcaCloud: {
|
||||
connectedAs: '接続中',
|
||||
logout: '切断',
|
||||
noLogoutPermission: '切断する権限がありません',
|
||||
noConnectPermission: 'Orca Cloudに接続する権限がありません',
|
||||
retry: '再試行',
|
||||
back: '別のサインイン方法を使用',
|
||||
connect: {
|
||||
title: 'Orca Cloudに接続',
|
||||
description: 'Orca Cloudアカウントにサインインして、スライサープロファイルをBambuddyに同期します。',
|
||||
},
|
||||
providers: {
|
||||
google: 'Googleでサインイン',
|
||||
apple: 'Appleでサインイン',
|
||||
github: 'GitHubでサインイン',
|
||||
email: 'メールとパスワードでサインイン',
|
||||
},
|
||||
password: {
|
||||
title: 'メールとパスワードでサインイン',
|
||||
email: 'メールアドレス',
|
||||
emailPlaceholder: 'you@example.com',
|
||||
password: 'パスワード',
|
||||
submit: 'サインイン',
|
||||
},
|
||||
paste: {
|
||||
title: 'サインインを完了',
|
||||
step1: 'Orca Cloudのサインインページが新しいタブで開きました。Orcaアカウントでサインインしてください。',
|
||||
step2: 'ブラウザは「localhost」のURLにリダイレクトされ、読み込みに失敗します。それは想定通りです — そのURLが必要です。',
|
||||
step3: 'ブラウザのアドレスバーからURL全体をコピーして、下に貼り付けてください。',
|
||||
signInUrl: 'サインインタブが開かなかった場合は、このURLをクリックしてください:',
|
||||
label: 'コールバックURLをここに貼り付け',
|
||||
placeholder: 'http://localhost:41172/callback?code=...&state=...',
|
||||
submit: '接続を完了',
|
||||
},
|
||||
profiles: {
|
||||
title: 'Orca Cloudプロファイル ({{count}})',
|
||||
refresh: '更新',
|
||||
empty: 'Orca Cloudアカウントにまだプロファイルがありません。',
|
||||
},
|
||||
toast: {
|
||||
connected: '{{email}}としてOrca Cloudに接続しました',
|
||||
disconnected: 'Orca Cloudから切断しました',
|
||||
},
|
||||
errors: {
|
||||
startFailed: 'Orca Cloudのサインインを開始できませんでした。',
|
||||
finishFailed: 'Orca Cloudのサインインを完了できませんでした。',
|
||||
passwordFailed: 'そのメールとパスワードでサインインできませんでした。',
|
||||
passwordEmpty: 'メールアドレスとパスワードの両方を入力してください。',
|
||||
emptyPaste: 'ブラウザからコールバックURLを貼り付けてください。',
|
||||
noCode: 'このURLはOrca Cloudのコールバックではないようです (codeパラメータがありません)。アドレスバーから完全なURLをコピーしてください。',
|
||||
},
|
||||
},
|
||||
localProfiles: {
|
||||
title: 'ローカルプロファイル',
|
||||
subtitle: 'OrcaSlicerからスライサープリセットをインポート・管理',
|
||||
|
|
@ -3500,14 +3553,20 @@ export default {
|
|||
failedToast: '{{name}}のスライスに失敗: {{detail}}',
|
||||
tier: {
|
||||
local: 'インポート済み',
|
||||
cloud: 'クラウド',
|
||||
cloud: 'Bambu Cloud',
|
||||
orcaCloud: 'Orca Cloud',
|
||||
standard: '標準',
|
||||
},
|
||||
cloud: {
|
||||
notAuthenticated: 'Bambu Cloudにサインイン(設定 → プロファイル → クラウド)してクラウドプリセットを表示。',
|
||||
notAuthenticated: 'Bambu Cloudにサインイン(設定 → プロファイル → Bambu Cloud)してクラウドプリセットを表示。',
|
||||
expired: 'Bambu Cloudセッションの有効期限切れ – クラウドプリセットを更新するには再ログインしてください。',
|
||||
unreachable: 'Bambu Cloudに接続できません。ローカルと標準のプリセットは引き続き使用できます。',
|
||||
},
|
||||
orcaCloud: {
|
||||
notAuthenticated: 'Orcaプリセットを表示するには、Orca Cloudにサインインしてください(プロファイル → Orca Cloud)。',
|
||||
expired: 'Orca Cloudセッションが期限切れです — Orcaプリセットを更新するには再度サインインしてください。',
|
||||
unreachable: 'Orca Cloudは現在到達できません。他のプリセットは引き続き動作します。',
|
||||
},
|
||||
bedType: {
|
||||
label: 'ビルドプレート',
|
||||
auto: '自動(プロセスプリセットを使用)',
|
||||
|
|
|
|||
|
|
@ -2673,10 +2673,63 @@ export default {
|
|||
title: '프로필',
|
||||
subtitle: '슬라이서 프리셋 및 압력 전진 보정 관리',
|
||||
tabs: {
|
||||
cloud: '클라우드 프로필',
|
||||
bambuCloud: 'Bambu Cloud',
|
||||
orcaCloud: 'Orca Cloud',
|
||||
local: '로컬 프로필',
|
||||
kprofiles: 'K-프로필'
|
||||
},
|
||||
orcaCloud: {
|
||||
connectedAs: '연결됨',
|
||||
logout: '연결 해제',
|
||||
noLogoutPermission: '연결을 해제할 권한이 없습니다',
|
||||
noConnectPermission: 'Orca Cloud에 연결할 권한이 없습니다',
|
||||
retry: '다시 시도',
|
||||
back: '다른 로그인 방법 사용',
|
||||
connect: {
|
||||
title: 'Orca Cloud에 연결',
|
||||
description: 'Orca Cloud 계정에 로그인하여 슬라이서 프로필을 Bambuddy에 동기화하세요.',
|
||||
},
|
||||
providers: {
|
||||
google: 'Google로 로그인',
|
||||
apple: 'Apple로 로그인',
|
||||
github: 'GitHub로 로그인',
|
||||
email: '이메일과 비밀번호로 로그인',
|
||||
},
|
||||
password: {
|
||||
title: '이메일과 비밀번호로 로그인',
|
||||
email: '이메일',
|
||||
emailPlaceholder: 'you@example.com',
|
||||
password: '비밀번호',
|
||||
submit: '로그인',
|
||||
},
|
||||
paste: {
|
||||
title: '로그인 완료',
|
||||
step1: '새 탭에서 Orca Cloud 로그인 페이지가 열렸습니다. Orca 계정으로 로그인하세요.',
|
||||
step2: '브라우저가 로드되지 않는 "localhost" URL로 리디렉션됩니다. 이것은 정상입니다 — 우리에게 필요한 것은 그 URL입니다.',
|
||||
step3: '브라우저의 주소 표시줄에서 전체 URL을 복사하여 아래에 붙여넣으세요.',
|
||||
signInUrl: '로그인 탭이 열리지 않은 경우 이 URL을 클릭하세요:',
|
||||
label: '여기에 콜백 URL 붙여넣기',
|
||||
placeholder: 'http://localhost:41172/callback?code=...&state=...',
|
||||
submit: '연결 완료',
|
||||
},
|
||||
profiles: {
|
||||
title: 'Orca Cloud 프로필 ({{count}})',
|
||||
refresh: '새로고침',
|
||||
empty: 'Orca Cloud 계정에 아직 프로필이 없습니다.',
|
||||
},
|
||||
toast: {
|
||||
connected: '{{email}}로 Orca Cloud에 연결됨',
|
||||
disconnected: 'Orca Cloud 연결 해제됨',
|
||||
},
|
||||
errors: {
|
||||
startFailed: 'Orca Cloud 로그인 흐름을 시작할 수 없습니다.',
|
||||
finishFailed: 'Orca Cloud 로그인을 완료할 수 없습니다.',
|
||||
passwordFailed: '해당 이메일과 비밀번호로 로그인할 수 없습니다.',
|
||||
passwordEmpty: '이메일과 비밀번호를 모두 입력하세요.',
|
||||
emptyPaste: '브라우저에서 콜백 URL을 붙여넣으세요.',
|
||||
noCode: '해당 URL은 Orca Cloud 콜백이 아닌 것 같습니다 (code 매개변수 없음). 주소 표시줄에서 전체 URL을 복사하세요.',
|
||||
},
|
||||
},
|
||||
localProfiles: {
|
||||
title: '로컬 프로필',
|
||||
subtitle: 'OrcaSlicer에서 슬라이서 프리셋 가져오기 및 관리',
|
||||
|
|
@ -3287,13 +3340,19 @@ export default {
|
|||
failedToast: '{{name}} 슬라이싱 실패: {{detail}}',
|
||||
tier: {
|
||||
local: '가져온 것',
|
||||
cloud: '클라우드',
|
||||
cloud: 'Bambu Cloud',
|
||||
orcaCloud: 'Orca Cloud',
|
||||
standard: '표준'
|
||||
},
|
||||
cloud: {
|
||||
notAuthenticated: '클라우드 프리셋을 보려면 Bambu 클라우드에 로그인하세요 (설정 → 프로필 → 클라우드).',
|
||||
expired: 'Bambu 클라우드 세션이 만료되었습니다 — 다시 로그인하여 클라우드 프리셋을 새로고침하세요.',
|
||||
unreachable: '현재 Bambu 클라우드에 연결할 수 없습니다. 로컬 및 표준 프리셋은 계속 작동합니다.'
|
||||
notAuthenticated: '클라우드 프리셋을 보려면 Bambu Cloud에 로그인하세요 (설정 → 프로필 → Bambu Cloud).',
|
||||
expired: 'Bambu Cloud 세션이 만료되었습니다 — 다시 로그인하여 클라우드 프리셋을 새로고침하세요.',
|
||||
unreachable: '현재 Bambu Cloud에 연결할 수 없습니다. 로컬 및 표준 프리셋은 계속 작동합니다.'
|
||||
},
|
||||
orcaCloud: {
|
||||
notAuthenticated: 'Orca 프리셋을 보려면 Orca Cloud (프로필 → Orca Cloud)에 로그인하세요.',
|
||||
expired: 'Orca Cloud 세션이 만료되었습니다 — Orca 프리셋을 새로고침하려면 다시 로그인하세요.',
|
||||
unreachable: 'Orca Cloud에 현재 연결할 수 없습니다. 다른 프리셋은 계속 작동합니다.'
|
||||
},
|
||||
actionAll: '{{count}}개 플레이트 모두 슬라이싱',
|
||||
actionAllTitle: '모든 플레이트를 단일 다중 플레이트 출력으로 슬라이싱합니다 (단일 아카이브). 필라멘트 선택은 프로젝트가 정의하는 모든 슬롯을 포함합니다.',
|
||||
|
|
|
|||
|
|
@ -2836,10 +2836,63 @@ export default {
|
|||
title: 'Perfis',
|
||||
subtitle: 'Gerencie seus presets de fatiador e calibrações de avanço de pressão',
|
||||
tabs: {
|
||||
cloud: 'Perfis na Nuvem',
|
||||
bambuCloud: 'Bambu Cloud',
|
||||
orcaCloud: 'Orca Cloud',
|
||||
local: 'Perfis Locais',
|
||||
kprofiles: 'K-Perfis',
|
||||
},
|
||||
orcaCloud: {
|
||||
connectedAs: 'Conectado como',
|
||||
logout: 'Desconectar',
|
||||
noLogoutPermission: 'Você não tem permissão para desconectar',
|
||||
noConnectPermission: 'Você não tem permissão para conectar ao Orca Cloud',
|
||||
retry: 'Tentar novamente',
|
||||
back: 'Usar outro método de login',
|
||||
connect: {
|
||||
title: 'Conectar ao Orca Cloud',
|
||||
description: 'Entre na sua conta Orca Cloud para sincronizar seus perfis de slicer no Bambuddy.',
|
||||
},
|
||||
providers: {
|
||||
google: 'Entrar com Google',
|
||||
apple: 'Entrar com Apple',
|
||||
github: 'Entrar com GitHub',
|
||||
email: 'Entrar com e-mail e senha',
|
||||
},
|
||||
password: {
|
||||
title: 'Entrar com e-mail e senha',
|
||||
email: 'E-mail',
|
||||
emailPlaceholder: 'voce@exemplo.com.br',
|
||||
password: 'Senha',
|
||||
submit: 'Entrar',
|
||||
},
|
||||
paste: {
|
||||
title: 'Concluir login',
|
||||
step1: 'Uma nova aba abriu com a página de login do Orca Cloud. Entre com sua conta Orca.',
|
||||
step2: 'Seu navegador será redirecionado para uma URL "localhost" que não carregará. Isso é esperado — é dessa URL que precisamos.',
|
||||
step3: 'Copie a URL completa da barra de endereços do navegador e cole abaixo.',
|
||||
signInUrl: 'Se a aba de login não abriu, clique nesta URL:',
|
||||
label: 'Cole a URL de callback aqui',
|
||||
placeholder: 'http://localhost:41172/callback?code=...&state=...',
|
||||
submit: 'Concluir conexão',
|
||||
},
|
||||
profiles: {
|
||||
title: 'Seus perfis do Orca Cloud ({{count}})',
|
||||
refresh: 'Atualizar',
|
||||
empty: 'Nenhum perfil encontrado na sua conta Orca Cloud ainda.',
|
||||
},
|
||||
toast: {
|
||||
connected: 'Conectado ao Orca Cloud como {{email}}',
|
||||
disconnected: 'Desconectado do Orca Cloud',
|
||||
},
|
||||
errors: {
|
||||
startFailed: 'Não foi possível iniciar o login do Orca Cloud.',
|
||||
finishFailed: 'Não foi possível concluir o login do Orca Cloud.',
|
||||
passwordFailed: 'Não foi possível entrar com esse e-mail e senha.',
|
||||
passwordEmpty: 'Insira o e-mail e a senha.',
|
||||
emptyPaste: 'Cole a URL de callback do seu navegador.',
|
||||
noCode: 'Essa URL não parece ser um callback do Orca Cloud (sem parâmetro code). Copie a URL completa da barra de endereços.',
|
||||
},
|
||||
},
|
||||
localProfiles: {
|
||||
title: 'Perfis Locais',
|
||||
subtitle: 'Importe e gerencie presets de fatiador do OrcaSlicer',
|
||||
|
|
@ -3488,14 +3541,20 @@ export default {
|
|||
failedToast: 'Falha ao fatiar {{name}}: {{detail}}',
|
||||
tier: {
|
||||
local: 'Importado',
|
||||
cloud: 'Nuvem',
|
||||
cloud: 'Bambu Cloud',
|
||||
orcaCloud: 'Orca Cloud',
|
||||
standard: 'Padrão',
|
||||
},
|
||||
cloud: {
|
||||
notAuthenticated: 'Faça login no Bambu Cloud (Configurações → Perfis → Nuvem) para ver as predefinições.',
|
||||
notAuthenticated: 'Faça login no Bambu Cloud (Configurações → Perfis → Bambu Cloud) para ver as predefinições.',
|
||||
expired: 'Sessão do Bambu Cloud expirou – faça login novamente para atualizar as predefinições.',
|
||||
unreachable: 'Bambu Cloud está inacessível agora. Predefinições locais e padrão ainda funcionam.',
|
||||
},
|
||||
orcaCloud: {
|
||||
notAuthenticated: 'Entre no Orca Cloud (Perfis → Orca Cloud) para ver seus presets Orca.',
|
||||
expired: 'Sessão do Orca Cloud expirada — entre novamente para atualizar seus presets Orca.',
|
||||
unreachable: 'Orca Cloud está indisponível no momento. Outros presets continuam funcionando.',
|
||||
},
|
||||
bedType: {
|
||||
label: 'Mesa de impressão',
|
||||
auto: 'Auto (usar predefinição de processo)',
|
||||
|
|
|
|||
|
|
@ -2851,10 +2851,63 @@ export default {
|
|||
title: 'Profiller',
|
||||
subtitle: 'Dilimleyici ön ayarlarını ve basınç ilerleme kalibrasyonlarını yönetin',
|
||||
tabs: {
|
||||
cloud: 'Bulut Profilleri',
|
||||
bambuCloud: 'Bambu Cloud',
|
||||
orcaCloud: 'Orca Cloud',
|
||||
local: 'Yerel Profiller',
|
||||
kprofiles: 'K-Profilleri',
|
||||
},
|
||||
orcaCloud: {
|
||||
connectedAs: 'Bağlı kullanıcı',
|
||||
logout: 'Bağlantıyı kes',
|
||||
noLogoutPermission: 'Bağlantıyı kesme izniniz yok',
|
||||
noConnectPermission: 'Orca Cloud\'a bağlanma izniniz yok',
|
||||
retry: 'Yeniden dene',
|
||||
back: 'Farklı bir giriş yöntemi kullan',
|
||||
connect: {
|
||||
title: 'Orca Cloud\'a bağlan',
|
||||
description: 'Dilimleyici profillerinizi Bambuddy ile senkronize etmek için Orca Cloud hesabınıza giriş yapın.',
|
||||
},
|
||||
providers: {
|
||||
google: 'Google ile giriş yap',
|
||||
apple: 'Apple ile giriş yap',
|
||||
github: 'GitHub ile giriş yap',
|
||||
email: 'E-posta ve şifre ile giriş yap',
|
||||
},
|
||||
password: {
|
||||
title: 'E-posta ve şifre ile giriş yap',
|
||||
email: 'E-posta',
|
||||
emailPlaceholder: 'sen@ornek.com',
|
||||
password: 'Şifre',
|
||||
submit: 'Giriş yap',
|
||||
},
|
||||
paste: {
|
||||
title: 'Girişi tamamla',
|
||||
step1: 'Yeni bir sekme Orca Cloud giriş sayfasıyla açıldı. Orca hesabınızla giriş yapın.',
|
||||
step2: 'Tarayıcınız yüklenemeyen bir "localhost" URL\'sine yönlendirilecektir. Bu beklenen bir durumdur — bize gereken URL budur.',
|
||||
step3: 'Tarayıcınızın adres çubuğundaki URL\'nin tamamını kopyalayın ve aşağıya yapıştırın.',
|
||||
signInUrl: 'Giriş sekmesi açılmadıysa bu URL\'ye tıklayın:',
|
||||
label: 'Geri çağrı URL\'sini buraya yapıştırın',
|
||||
placeholder: 'http://localhost:41172/callback?code=...&state=...',
|
||||
submit: 'Bağlantıyı tamamla',
|
||||
},
|
||||
profiles: {
|
||||
title: 'Orca Cloud profilleriniz ({{count}})',
|
||||
refresh: 'Yenile',
|
||||
empty: 'Orca Cloud hesabınızda henüz profil bulunamadı.',
|
||||
},
|
||||
toast: {
|
||||
connected: '{{email}} olarak Orca Cloud\'a bağlanıldı',
|
||||
disconnected: 'Orca Cloud bağlantısı kesildi',
|
||||
},
|
||||
errors: {
|
||||
startFailed: 'Orca Cloud giriş akışı başlatılamadı.',
|
||||
finishFailed: 'Orca Cloud girişi tamamlanamadı.',
|
||||
passwordFailed: 'Bu e-posta ve şifreyle giriş yapılamadı.',
|
||||
passwordEmpty: 'Lütfen hem e-postanızı hem de şifrenizi girin.',
|
||||
emptyPaste: 'Lütfen tarayıcınızdan geri çağrı URL\'sini yapıştırın.',
|
||||
noCode: 'Bu URL bir Orca Cloud geri çağrısına benzemiyor (code parametresi yok). Tam URL\'yi adres çubuğundan kopyalayın.',
|
||||
},
|
||||
},
|
||||
localProfiles: {
|
||||
title: 'Yerel Profiller',
|
||||
subtitle: 'OrcaSlicer\'dan dilimleyici ön ayarlarını içe aktar ve yönet',
|
||||
|
|
@ -3489,14 +3542,20 @@ export default {
|
|||
failedToast: '{{name}} dilimleme başarısız: {{detail}}',
|
||||
tier: {
|
||||
local: 'İçe aktarılmış',
|
||||
cloud: 'Bulut',
|
||||
cloud: 'Bambu Cloud',
|
||||
orcaCloud: 'Orca Cloud',
|
||||
standard: 'Standart',
|
||||
},
|
||||
cloud: {
|
||||
notAuthenticated: 'Bulut ön ayarlarınızı görmek için Bambu Cloud\'a giriş yapın (Ayarlar → Profiller → Bulut).',
|
||||
notAuthenticated: 'Bulut ön ayarlarınızı görmek için Bambu Cloud\'a giriş yapın (Ayarlar → Profiller → Bambu Cloud).',
|
||||
expired: 'Bambu Cloud oturumu süresi doldu — bulut ön ayarlarınızı yenilemek için tekrar giriş yapın.',
|
||||
unreachable: 'Bambu Cloud şu anda erişilemez. Yerel ve standart ön ayarlar hâlâ çalışıyor.',
|
||||
},
|
||||
orcaCloud: {
|
||||
notAuthenticated: 'Orca ön ayarlarınızı görmek için Orca Cloud\'a giriş yapın (Profiller → Orca Cloud).',
|
||||
expired: 'Orca Cloud oturumu sona erdi — Orca ön ayarlarınızı yenilemek için tekrar giriş yapın.',
|
||||
unreachable: 'Orca Cloud şu anda erişilemez. Diğer ön ayarlar çalışmaya devam ediyor.',
|
||||
},
|
||||
bedType: {
|
||||
label: 'Baskı plakası',
|
||||
auto: 'Otomatik (işlem ön ayarı kullan)',
|
||||
|
|
|
|||
|
|
@ -2836,10 +2836,63 @@ export default {
|
|||
title: '配置文件',
|
||||
subtitle: '管理您的切片预设和压力推进校准',
|
||||
tabs: {
|
||||
cloud: '云端配置文件',
|
||||
bambuCloud: 'Bambu Cloud',
|
||||
orcaCloud: 'Orca Cloud',
|
||||
local: '本地配置文件',
|
||||
kprofiles: 'K 值配置',
|
||||
},
|
||||
orcaCloud: {
|
||||
connectedAs: '已连接',
|
||||
logout: '断开连接',
|
||||
noLogoutPermission: '您没有断开连接的权限',
|
||||
noConnectPermission: '您没有连接到 Orca Cloud 的权限',
|
||||
retry: '重试',
|
||||
back: '使用其他登录方式',
|
||||
connect: {
|
||||
title: '连接到 Orca Cloud',
|
||||
description: '登录您的 Orca Cloud 账户,将切片机配置同步到 Bambuddy。',
|
||||
},
|
||||
providers: {
|
||||
google: '使用 Google 登录',
|
||||
apple: '使用 Apple 登录',
|
||||
github: '使用 GitHub 登录',
|
||||
email: '使用邮箱和密码登录',
|
||||
},
|
||||
password: {
|
||||
title: '使用邮箱和密码登录',
|
||||
email: '邮箱',
|
||||
emailPlaceholder: 'you@example.com',
|
||||
password: '密码',
|
||||
submit: '登录',
|
||||
},
|
||||
paste: {
|
||||
title: '完成登录',
|
||||
step1: '已在新标签页中打开 Orca Cloud 登录页面。请使用您的 Orca 账户登录。',
|
||||
step2: '您的浏览器将被重定向到一个 "localhost" URL,该 URL 无法加载。这是正常的 — 我们需要的就是这个 URL。',
|
||||
step3: '从浏览器的地址栏复制整个 URL,粘贴到下方。',
|
||||
signInUrl: '如果登录标签页未打开,请点击此 URL:',
|
||||
label: '在此处粘贴回调 URL',
|
||||
placeholder: 'http://localhost:41172/callback?code=...&state=...',
|
||||
submit: '完成连接',
|
||||
},
|
||||
profiles: {
|
||||
title: '您的 Orca Cloud 配置文件 ({{count}})',
|
||||
refresh: '刷新',
|
||||
empty: '您的 Orca Cloud 账户中尚无配置文件。',
|
||||
},
|
||||
toast: {
|
||||
connected: '已以 {{email}} 身份连接到 Orca Cloud',
|
||||
disconnected: '已从 Orca Cloud 断开连接',
|
||||
},
|
||||
errors: {
|
||||
startFailed: '无法启动 Orca Cloud 登录流程。',
|
||||
finishFailed: '无法完成 Orca Cloud 登录。',
|
||||
passwordFailed: '无法使用该邮箱和密码登录。',
|
||||
passwordEmpty: '请输入邮箱和密码。',
|
||||
emptyPaste: '请从浏览器粘贴回调 URL。',
|
||||
noCode: '该 URL 不像 Orca Cloud 回调 (缺少 code 参数)。请从地址栏复制完整 URL。',
|
||||
},
|
||||
},
|
||||
localProfiles: {
|
||||
title: '本地配置文件',
|
||||
subtitle: '从 OrcaSlicer 导入和管理切片预设',
|
||||
|
|
@ -3488,14 +3541,20 @@ export default {
|
|||
failedToast: '切片 {{name}} 失败:{{detail}}',
|
||||
tier: {
|
||||
local: '已导入',
|
||||
cloud: '云端',
|
||||
cloud: 'Bambu Cloud',
|
||||
orcaCloud: 'Orca Cloud',
|
||||
standard: '标准',
|
||||
},
|
||||
cloud: {
|
||||
notAuthenticated: '登录 Bambu Cloud(设置 → 配置文件 → 云端)以查看云端预设。',
|
||||
notAuthenticated: '登录 Bambu Cloud(设置 → 配置文件 → Bambu Cloud)以查看云端预设。',
|
||||
expired: 'Bambu Cloud 会话已过期 — 请重新登录以刷新云端预设。',
|
||||
unreachable: '目前无法访问 Bambu Cloud。本地和标准预设仍可使用。',
|
||||
},
|
||||
orcaCloud: {
|
||||
notAuthenticated: '登录 Orca Cloud(配置文件 → Orca Cloud)以查看您的 Orca 预设。',
|
||||
expired: 'Orca Cloud 会话已过期 — 请重新登录以刷新您的 Orca 预设。',
|
||||
unreachable: 'Orca Cloud 当前无法访问。其他预设仍可正常使用。',
|
||||
},
|
||||
bedType: {
|
||||
label: '打印板',
|
||||
auto: '自动(使用工艺预设)',
|
||||
|
|
|
|||
|
|
@ -2836,10 +2836,63 @@ export default {
|
|||
title: '設定檔案',
|
||||
subtitle: '管理您的切片預設和壓力推進校準',
|
||||
tabs: {
|
||||
cloud: '雲端設定檔案',
|
||||
bambuCloud: 'Bambu Cloud',
|
||||
orcaCloud: 'Orca Cloud',
|
||||
local: '本機設定檔案',
|
||||
kprofiles: 'K 值設定',
|
||||
},
|
||||
orcaCloud: {
|
||||
connectedAs: '已連接',
|
||||
logout: '中斷連線',
|
||||
noLogoutPermission: '您沒有中斷連線的權限',
|
||||
noConnectPermission: '您沒有連接到 Orca Cloud 的權限',
|
||||
retry: '重試',
|
||||
back: '使用其他登入方式',
|
||||
connect: {
|
||||
title: '連接到 Orca Cloud',
|
||||
description: '登入您的 Orca Cloud 帳號,將切片機設定檔同步到 Bambuddy。',
|
||||
},
|
||||
providers: {
|
||||
google: '使用 Google 登入',
|
||||
apple: '使用 Apple 登入',
|
||||
github: '使用 GitHub 登入',
|
||||
email: '使用電子郵件和密碼登入',
|
||||
},
|
||||
password: {
|
||||
title: '使用電子郵件和密碼登入',
|
||||
email: '電子郵件',
|
||||
emailPlaceholder: 'you@example.com',
|
||||
password: '密碼',
|
||||
submit: '登入',
|
||||
},
|
||||
paste: {
|
||||
title: '完成登入',
|
||||
step1: '已在新分頁中開啟 Orca Cloud 登入頁面。請使用您的 Orca 帳號登入。',
|
||||
step2: '您的瀏覽器將被重新導向到一個 "localhost" URL,該 URL 無法載入。這是正常的 — 我們需要的就是這個 URL。',
|
||||
step3: '從瀏覽器的網址列複製整個 URL,貼到下方。',
|
||||
signInUrl: '若登入分頁未開啟,請點擊此 URL:',
|
||||
label: '在此貼上回呼 URL',
|
||||
placeholder: 'http://localhost:41172/callback?code=...&state=...',
|
||||
submit: '完成連接',
|
||||
},
|
||||
profiles: {
|
||||
title: '您的 Orca Cloud 設定檔 ({{count}})',
|
||||
refresh: '重新整理',
|
||||
empty: '您的 Orca Cloud 帳號中尚無設定檔。',
|
||||
},
|
||||
toast: {
|
||||
connected: '已以 {{email}} 身分連接到 Orca Cloud',
|
||||
disconnected: '已從 Orca Cloud 中斷連線',
|
||||
},
|
||||
errors: {
|
||||
startFailed: '無法啟動 Orca Cloud 登入流程。',
|
||||
finishFailed: '無法完成 Orca Cloud 登入。',
|
||||
passwordFailed: '無法使用該電子郵件和密碼登入。',
|
||||
passwordEmpty: '請輸入電子郵件和密碼。',
|
||||
emptyPaste: '請從瀏覽器貼上回呼 URL。',
|
||||
noCode: '該 URL 不像 Orca Cloud 回呼 (缺少 code 參數)。請從網址列複製完整 URL。',
|
||||
},
|
||||
},
|
||||
localProfiles: {
|
||||
title: '本機設定檔案',
|
||||
subtitle: '從 OrcaSlicer 匯入和管理切片預設',
|
||||
|
|
@ -3488,14 +3541,20 @@ export default {
|
|||
failedToast: '切片 {{name}} 失敗:{{detail}}',
|
||||
tier: {
|
||||
local: '已匯入',
|
||||
cloud: '雲端',
|
||||
cloud: 'Bambu Cloud',
|
||||
orcaCloud: 'Orca Cloud',
|
||||
standard: '標準',
|
||||
},
|
||||
cloud: {
|
||||
notAuthenticated: '登入 Bambu Cloud(設定 → 設定檔 → 雲端)以查看雲端預設。',
|
||||
notAuthenticated: '登入 Bambu Cloud(設定 → 設定檔 → Bambu Cloud)以查看雲端預設。',
|
||||
expired: 'Bambu Cloud 工作階段已過期 — 請重新登入以重新整理雲端預設。',
|
||||
unreachable: '目前無法存取 Bambu Cloud。本機和標準預設仍可使用。',
|
||||
},
|
||||
orcaCloud: {
|
||||
notAuthenticated: '登入 Orca Cloud(設定檔 → Orca Cloud)以查看您的 Orca 預設。',
|
||||
expired: 'Orca Cloud 工作階段已過期 — 請重新登入以重新整理您的 Orca 預設。',
|
||||
unreachable: 'Orca Cloud 目前無法存取。其他預設仍可正常使用。',
|
||||
},
|
||||
bedType: {
|
||||
label: '列印板',
|
||||
auto: '自動(使用製程預設)',
|
||||
|
|
|
|||
|
|
@ -51,9 +51,10 @@ import { useToast } from '../contexts/ToastContext';
|
|||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { KProfilesView } from '../components/KProfilesView';
|
||||
import { LocalProfilesView } from '../components/LocalProfilesView';
|
||||
import { OrcaCloudView } from '../components/OrcaCloudView';
|
||||
|
||||
type TFunction = (key: string, options?: Record<string, unknown>) => string;
|
||||
type ProfileTab = 'cloud' | 'local' | 'kprofiles';
|
||||
type ProfileTab = 'cloud' | 'orca_cloud' | 'local' | 'kprofiles';
|
||||
type LoginStep = 'email' | 'code' | 'token';
|
||||
type PresetType = 'all' | 'filament' | 'printer' | 'process';
|
||||
|
||||
|
|
@ -305,7 +306,7 @@ function LoginForm({ onSuccess, t }: { onSuccess: () => void; t: TFunction }) {
|
|||
// FILTER DROPDOWN
|
||||
// ============================================================================
|
||||
|
||||
function FilterDropdown({
|
||||
export function FilterDropdown({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
|
|
@ -2889,7 +2890,18 @@ export function ProfilesPage() {
|
|||
}`}
|
||||
>
|
||||
<Cloud className="w-4 h-4" />
|
||||
{t('profiles.tabs.cloud')}
|
||||
{t('profiles.tabs.bambuCloud')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('orca_cloud')}
|
||||
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
||||
activeTab === 'orca_cloud'
|
||||
? 'text-bambu-green border-bambu-green'
|
||||
: 'text-bambu-gray hover:text-white border-transparent'
|
||||
}`}
|
||||
>
|
||||
<Cloud className="w-4 h-4" />
|
||||
{t('profiles.tabs.orcaCloud')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('local')}
|
||||
|
|
@ -2972,6 +2984,9 @@ export function ProfilesPage() {
|
|||
</>
|
||||
)}
|
||||
|
||||
{/* Orca Cloud Profiles Tab */}
|
||||
{activeTab === 'orca_cloud' && <OrcaCloudView />}
|
||||
|
||||
{/* Local Profiles Tab */}
|
||||
{activeTab === 'local' && <LocalProfilesView />}
|
||||
|
||||
|
|
|
|||
|
|
@ -463,6 +463,10 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, spoolmanM
|
|||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// ``cancelled`` guards every state setter so an async fetch that
|
||||
// resolves after view-mode change / unmount can't setState on a
|
||||
// torn-down component. Same shape as SpoolFormModal's cleanup.
|
||||
let cancelled = false;
|
||||
const fetchData = async () => {
|
||||
// Only load full data when in full view mode
|
||||
if (viewMode !== 'full') {
|
||||
|
|
@ -471,22 +475,41 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, spoolmanM
|
|||
|
||||
setLoadingCloudPresets(true);
|
||||
try {
|
||||
const status = await api.getCloudStatus();
|
||||
setCloudAuthenticated(status.is_authenticated);
|
||||
if (status.is_authenticated) {
|
||||
const presets = await api.getFilamentPresets();
|
||||
setCloudPresets(presets);
|
||||
}
|
||||
// Fetch Bambu + Orca in parallel; merge their filament lists into
|
||||
// ``cloudPresets`` because ``OrcaProfileMeta`` is structurally
|
||||
// identical to ``SlicerSetting`` (same fields, same semantics).
|
||||
// Same shape as SpoolFormModal — see that for the rationale.
|
||||
const [bambuResult, orcaResult] = await Promise.allSettled([
|
||||
(async () => {
|
||||
const status = await api.getCloudStatus();
|
||||
if (!status.is_authenticated) return { connected: false, presets: [] as SlicerSetting[] };
|
||||
const presets = await api.getFilamentPresets();
|
||||
return { connected: true, presets };
|
||||
})(),
|
||||
(async () => {
|
||||
const status = await api.orcaCloudStatus();
|
||||
if (!status.connected) return { connected: false, presets: [] as SlicerSetting[] };
|
||||
const list = await api.orcaCloudListProfiles();
|
||||
return { connected: true, presets: list.filament as unknown as SlicerSetting[] };
|
||||
})(),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
const bambuConnected = bambuResult.status === 'fulfilled' && bambuResult.value.connected;
|
||||
const orcaConnected = orcaResult.status === 'fulfilled' && orcaResult.value.connected;
|
||||
const bambuPresets = bambuResult.status === 'fulfilled' ? bambuResult.value.presets : [];
|
||||
const orcaPresets = orcaResult.status === 'fulfilled' ? orcaResult.value.presets : [];
|
||||
setCloudAuthenticated(bambuConnected || orcaConnected);
|
||||
setCloudPresets([...bambuPresets, ...orcaPresets]);
|
||||
} catch {
|
||||
setCloudAuthenticated(false);
|
||||
if (!cancelled) setCloudAuthenticated(false);
|
||||
} finally {
|
||||
setLoadingCloudPresets(false);
|
||||
if (!cancelled) setLoadingCloudPresets(false);
|
||||
}
|
||||
|
||||
api.getSpoolCatalog().then(setSpoolCatalog).catch(() => undefined);
|
||||
api.getColorCatalog().then(setColorCatalog).catch(() => undefined);
|
||||
api.getLocalPresets().then(r => setLocalPresets(r.filament)).catch(() => undefined);
|
||||
api.getBuiltinFilaments().then(setBuiltinFilaments).catch(() => undefined);
|
||||
api.getSpoolCatalog().then((d) => { if (!cancelled) setSpoolCatalog(d); }).catch(() => undefined);
|
||||
api.getColorCatalog().then((d) => { if (!cancelled) setColorCatalog(d); }).catch(() => undefined);
|
||||
api.getLocalPresets().then(r => { if (!cancelled) setLocalPresets(r.filament); }).catch(() => undefined);
|
||||
api.getBuiltinFilaments().then((d) => { if (!cancelled) setBuiltinFilaments(d); }).catch(() => undefined);
|
||||
|
||||
try {
|
||||
const printers = await api.getPrinters();
|
||||
|
|
@ -516,13 +539,16 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, spoolmanM
|
|||
}
|
||||
results.push({ printer: { ...printer, connected }, calibrations });
|
||||
}
|
||||
setPrintersWithCalibrations(results);
|
||||
if (!cancelled) setPrintersWithCalibrations(results);
|
||||
} catch {
|
||||
// ignore calibration loading errors on kiosk form
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [viewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
static/assets/index-Df3XYvpK.css
Normal file
1
static/assets/index-Df3XYvpK.css
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -26,8 +26,8 @@
|
|||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-CYiRVzBv.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C3FyyVE7.css">
|
||||
<script type="module" crossorigin src="/assets/index-6Wj7SYfZ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Df3XYvpK.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
41
static/sw.js
41
static/sw.js
|
|
@ -1,6 +1,6 @@
|
|||
// Bambuddy Service Worker
|
||||
const CACHE_NAME = 'bambuddy-v28';
|
||||
const STATIC_CACHE = 'bambuddy-static-v27';
|
||||
const CACHE_NAME = 'bambuddy-v29';
|
||||
const STATIC_CACHE = 'bambuddy-static-v28';
|
||||
|
||||
// Static assets to cache on install
|
||||
const STATIC_ASSETS = [
|
||||
|
|
@ -31,23 +31,46 @@ self.addEventListener('install', (event) => {
|
|||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// Activate event - clean up old caches
|
||||
// Activate event - clean up old caches, then force-reload any controlled
|
||||
// windows so they pick up the new bundle. Important for the SpoolBuddy kiosk
|
||||
// (Pi + Chromium-in-kiosk-mode, no devtools, no manual reload control):
|
||||
// without this hop, restarting Chromium installs the new SW but the existing
|
||||
// document keeps running the previously-cached bundle until a navigation
|
||||
// happens — which on a locked kiosk never occurs.
|
||||
self.addEventListener('activate', (event) => {
|
||||
console.log('[SW] Activating service worker...');
|
||||
event.waitUntil(
|
||||
caches.keys().then((cacheNames) => {
|
||||
return Promise.all(
|
||||
(async () => {
|
||||
const cacheNames = await caches.keys();
|
||||
await Promise.all(
|
||||
cacheNames
|
||||
.filter((name) => name !== CACHE_NAME && name !== STATIC_CACHE)
|
||||
.map((name) => {
|
||||
console.log('[SW] Deleting old cache:', name);
|
||||
return caches.delete(name);
|
||||
})
|
||||
}),
|
||||
);
|
||||
})
|
||||
// Take control immediately.
|
||||
await self.clients.claim();
|
||||
// Force a fresh navigation in any window that this SW now controls.
|
||||
// ``client.navigate(client.url)`` re-requests the page through the
|
||||
// network-first fetch handler, picking up the new index.html + the
|
||||
// new content-hashed JS bundle. Guarded so the very first install on
|
||||
// a never-controlled client doesn't trigger an unwanted reload.
|
||||
const clients = await self.clients.matchAll({ type: 'window' });
|
||||
for (const client of clients) {
|
||||
try {
|
||||
if (client.url && typeof client.navigate === 'function') {
|
||||
await client.navigate(client.url);
|
||||
}
|
||||
} catch (e) {
|
||||
// Some browsers reject navigate on cross-origin or detached
|
||||
// clients — swallow so one bad client doesn't break the rest.
|
||||
console.warn('[SW] Forced reload skipped for client:', client.url, e);
|
||||
}
|
||||
}
|
||||
})(),
|
||||
);
|
||||
// Take control immediately
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
// Fetch event - network-first for API, cache-first for static
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue