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:
maziggy 2026-06-04 15:50:44 +02:00
parent 764eb58540
commit 18d534c945
45 changed files with 4891 additions and 630 deletions

File diff suppressed because one or more lines are too long

View file

@ -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

View 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,
)

View file

@ -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,
)

View file

@ -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

View file

@ -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)

View file

@ -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,

View file

@ -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)

View file

@ -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",

View 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

View file

@ -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):

View file

@ -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"

View 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]

View file

@ -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,

View 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")

View file

@ -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 -------------------------------------------------

View file

@ -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."""

View file

@ -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

View file

@ -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)',

View 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();
});
});
});

View file

@ -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,

View file

@ -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: () =>

View file

@ -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 || '';

View 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>
);
}

View 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>
);
}

View file

@ -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';

View file

@ -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]);

View file

@ -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)',

View file

@ -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)',

View file

@ -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)',

View file

@ -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)',

View file

@ -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)',

View file

@ -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: '自動(プロセスプリセットを使用)',

View file

@ -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: '모든 플레이트를 단일 다중 플레이트 출력으로 슬라이싱합니다 (단일 아카이브). 필라멘트 선택은 프로젝트가 정의하는 모든 슬롯을 포함합니다.',

View file

@ -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)',

View file

@ -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)',

View file

@ -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: '自动(使用工艺预设)',

View file

@ -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: '自動(使用製程預設)',

View file

@ -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 />}

View file

@ -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

File diff suppressed because one or more lines are too long

View file

@ -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>

View file

@ -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