diff --git a/.gitignore b/.gitignore index 2b1c6637..f94b57d4 100644 --- a/.gitignore +++ b/.gitignore @@ -189,5 +189,6 @@ robot_results/ output.xml log.html report.html -config/service_configs.yaml +config/kubeconfigs config/wiring.yaml +config/service_configs.yaml diff --git a/compose/backend.yml b/compose/backend.yml index a32468ed..985d0e5d 100644 --- a/compose/backend.yml +++ b/compose/backend.yml @@ -54,6 +54,7 @@ services: - ../config:/config # Mount config directory (read-write for feature flags) - ../compose:/compose # Mount compose files for service management - ../mycelia:/mycelia # Mount mycelia for building mycelia-backend service + - ../openmemory:/openmemory # Mount openmemory for building openmemory services - /app/__pycache__ - /app/.pytest_cache - /app/.venv # Mask host .venv - container uses its own venv from image diff --git a/compose/openmemory-compose.yaml b/compose/openmemory-compose.yaml index 70beb997..1535e728 100644 --- a/compose/openmemory-compose.yaml +++ b/compose/openmemory-compose.yaml @@ -1,6 +1,11 @@ # OpenMemory (mem0) service definition # Graph-based memory with MCP support # Environment variables are passed directly via docker compose subprocess env +# +# Image Strategy: +# - Submodule checked out: Builds from local source (./openmemory/openmemory) +# - Submodule missing: Pulls pre-built images from ghcr.io/ushadow-io/u-mem0-* +# - Uses pull_policy: build to prefer local builds when context exists # ============================================================================= # USHADOW METADATA (ignored by Docker, read by ushadow backend) @@ -23,8 +28,11 @@ x-ushadow: services: mem0: image: ghcr.io/ushadow-io/u-mem0-api:v1.0.4 + build: + context: ${PROJECT_ROOT:-..}/openmemory/openmemory/api + dockerfile: Dockerfile + pull_policy: build # Build from source if openmemory submodule exists, otherwise pull from GHCR container_name: ${COMPOSE_PROJECT_NAME:-ushadow}-mem0 - pull_policy: always # Requires qdrant from infra (started via infra_services in x-ushadow) ports: - "${OPENMEMORY_PORT:-8765}:8765" @@ -65,13 +73,21 @@ services: mem0-ui: image: ghcr.io/ushadow-io/u-mem0-ui:v1.0.4 + build: + context: ${PROJECT_ROOT:-..}/openmemory/openmemory/ui + dockerfile: Dockerfile + args: + # Use placeholder values so entrypoint can replace them at runtime + - NEXT_PUBLIC_USER_ID=NEXT_PUBLIC_USER_ID + - NEXT_PUBLIC_API_URL=NEXT_PUBLIC_API_URL + pull_policy: build # Build from source if openmemory submodule exists, otherwise pull from GHCR container_name: ${COMPOSE_PROJECT_NAME:-ushadow}-mem0-ui ports: - "3002:3000" environment: - - VITE_API_URL=http://localhost:${OPENMEMORY_PORT:-8765} - - API_URL=http://mem0:8765 - NEXT_PUBLIC_USER_ID=${ADMIN_EMAIL:-admin@example.com} + - NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://localhost:8765} + - API_URL=http://mem0:8765 networks: - ushadow-network depends_on: diff --git a/compose/parakeet-compose.yml b/compose/parakeet-compose.yml index add594b8..1598bbd0 100644 --- a/compose/parakeet-compose.yml +++ b/compose/parakeet-compose.yml @@ -1,8 +1,8 @@ services: parakeet-asr: build: - context: . - dockerfile: Dockerfile_Parakeet + context: ../parakeet + dockerfile: Dockerfile args: PYTORCH_CUDA_VERSION: ${PYTORCH_CUDA_VERSION:-cu126} image: parakeet-asr:latest @@ -22,7 +22,8 @@ services: capabilities: [gpu] environment: - HF_HOME=/models - - PARAKEET_MODEL=$PARAKEET_MODEL + - HF_TOKEN=${HF_TOKEN:-} + - PARAKEET_MODEL=${PARAKEET_MODEL:-arakeet-tdt-0.6b-v2} # Enhanced chunking configuration - CHUNKING_ENABLED=${CHUNKING_ENABLED:-true} - CHUNK_DURATION_SECONDS=${CHUNK_DURATION_SECONDS:-30.0} diff --git a/config/feature_flags.yaml b/config/feature_flags.yaml index 49984d4e..7267b1de 100644 --- a/config/feature_flags.yaml +++ b/config/feature_flags.yaml @@ -89,7 +89,7 @@ flags: # Service Configs - Show custom service instance configurations service_configs: - enabled: false + enabled: true description: "Show custom service config instances in the Services tab (multi-instance per template)" type: release @@ -103,7 +103,7 @@ flags: # Legacy Services Page - Old Docker service configuration page legacy_services_page: - enabled: false + enabled: true description: "Legacy Services page (replaced by Instances page) - Docker service configuration" type: release diff --git a/config/keycloak/realm-export.json b/config/keycloak/realm-export.json index 71fe59df..2463516a 100644 --- a/config/keycloak/realm-export.json +++ b/config/keycloak/realm-export.json @@ -156,7 +156,11 @@ ] } ], - "defaultDefaultClientScopes": ["openid", "profile", "email"], + "defaultDefaultClientScopes": [ + "openid", + "profile", + "email" + ], "clients": [ { "clientId": "ushadow-frontend", @@ -174,12 +178,14 @@ "redirectUris": [ "http://localhost:3000/oauth/callback", "http://localhost:*/oauth/callback", - "tauri://oauth-callback" + "tauri://oauth-callback", + "ushadow://oauth/callback" ], "webOrigins": [ "http://localhost:3000", "http://localhost:*", - "tauri://localhost" + "tauri://localhost", + "ushadow://localhost" ], "attributes": { "pkce.code.challenge.method": "S256", @@ -198,6 +204,46 @@ } } ] + }, + { + "clientId": "ushadow-mobile", + "name": "Ushadow Mobile", + "description": "Ushadow mobile application (React Native)", + "enabled": true, + "publicClient": true, + "protocol": "openid-connect", + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "authorizationServicesEnabled": false, + "fullScopeAllowed": true, + "redirectUris": [ + "ushadow://oauth/callback", + "exp://*/--/oauth/callback", + "exp://localhost:*/--/oauth/callback" + ], + "webOrigins": [ + "ushadow://localhost", + "exp://*" + ], + "attributes": { + "pkce.code.challenge.method": "S256", + "post.logout.redirect.uris": "ushadow://##exp://*" + }, + "protocolMappers": [ + { + "name": "sub", + "protocol": "openid-connect", + "protocolMapper": "oidc-sub-mapper", + "consentRequired": false, + "config": { + "access.token.claim": "true", + "id.token.claim": "true", + "userinfo.token.claim": "true" + } + } + ] } ], "users": [], diff --git a/scripts/build-push-images.sh b/scripts/build-push-images.sh index 0f2eff66..730a3e6b 100755 --- a/scripts/build-push-images.sh +++ b/scripts/build-push-images.sh @@ -123,11 +123,25 @@ case "$SERVICE" in build_and_push \ "mycelia" \ "mycelia/backend/Dockerfile" \ - "mycelia-backend" + "mycelia/backend" + + # Build frontend (context is mycelia root, use prod Dockerfile) + build_and_push \ + "mycelia" \ + "mycelia/frontend/Dockerfile.prod" \ + "mycelia/frontend" + + # Build python worker (context is mycelia root, Dockerfile is in python/) + build_and_push \ + "mycelia" \ + "mycelia/python/Dockerfile" \ + "mycelia/python-worker" info "=============================================" info "Mycelia images pushed successfully!" - info " ${REGISTRY}/mycelia-backend:${TAG}" + info " ${REGISTRY}/mycelia/backend:${TAG}" + info " ${REGISTRY}/mycelia/frontend:${TAG}" + info " ${REGISTRY}/mycelia/python-worker:${TAG}" info "=============================================" ;; @@ -180,7 +194,7 @@ case "$SERVICE" in echo "Available services:" echo " ushadow - Build ushadow backend + frontend" echo " chronicle - Build Chronicle backend + workers + webui" - echo " mycelia - Build Mycelia backend" + echo " mycelia - Build Mycelia backend + frontend + python-worker" echo " openmemory - Build OpenMemory server" echo "" echo "Examples:" diff --git a/ushadow/backend/src/config/keycloak_settings.py b/ushadow/backend/src/config/keycloak_settings.py index 33966f3f..8acf5caa 100644 --- a/ushadow/backend/src/config/keycloak_settings.py +++ b/ushadow/backend/src/config/keycloak_settings.py @@ -139,35 +139,55 @@ def get_keycloak_openid(client_id: Optional[str] = None) -> KeycloakOpenID: Args: client_id: Client ID to use (defaults to frontend_client_id from config) + If provided, creates a NEW instance (no caching for specific clients) Returns: KeycloakOpenID instance """ global _keycloak_openid - if _keycloak_openid is None: - settings = get_settings() - - # Internal URL for backend-to-Keycloak communication - # Resolved by OmegaConf: ${oc.env:KEYCLOAK_URL,http://keycloak:8080} - internal_url = settings.get_sync("keycloak.url", "http://keycloak:8080") + settings = get_settings() - # Use provided client_id or default to frontend - if client_id is None: - client_id = settings.get_sync("keycloak.frontend_client_id", "ushadow-frontend") + # Internal URL for backend-to-Keycloak communication + internal_url = settings.get_sync("keycloak.url", "http://keycloak:8080") + app_realm = settings.get_sync("keycloak.realm", "ushadow") - client_secret = settings.get_sync("keycloak.backend_client_secret") + # Determine if this is a public or confidential client + # Public clients (frontend, mobile) = no secret (browser/app can't protect secrets) + # Confidential clients (backend) = with secret (server-side can protect secrets) + def is_public_client(cid: str) -> bool: + frontend_client_id = settings.get_sync("keycloak.frontend_client_id", "ushadow-frontend") + mobile_client_id = settings.get_sync("keycloak.mobile_client_id", "ushadow-mobile") + return cid in [frontend_client_id, mobile_client_id] + + # If client_id provided, create NEW instance (don't use cache) + if client_id is not None: + if is_public_client(client_id): + client_secret = None # Public client - no secret + logger.info(f"[KC-SETTINGS] Creating public client instance (no secret): {client_id}") + else: + # Confidential client (backend) + client_secret = settings.get_sync("keycloak.backend_client_secret") + logger.info(f"[KC-SETTINGS] Creating confidential client instance (with secret): {client_id}") + + return KeycloakOpenID( + server_url=internal_url, + realm_name=app_realm, + client_id=client_id, + client_secret_key=client_secret, + ) - # OpenID operations use the application realm (ushadow), not master - app_realm = settings.get_sync("keycloak.realm", "ushadow") + # No client_id provided - use cached default frontend instance (public client) + if _keycloak_openid is None: + default_client_id = settings.get_sync("keycloak.frontend_client_id", "ushadow-frontend") - logger.info(f"[KC-SETTINGS] Initializing KeycloakOpenID for client: {client_id}") + logger.info(f"[KC-SETTINGS] Initializing default KeycloakOpenID (public): {default_client_id}") _keycloak_openid = KeycloakOpenID( server_url=internal_url, - realm_name=app_realm, # Use application realm for token operations - client_id=client_id, - client_secret_key=client_secret, + realm_name=app_realm, + client_id=default_client_id, + client_secret_key=None, # Frontend is public - no secret ) return _keycloak_openid diff --git a/ushadow/backend/src/routers/auth.py b/ushadow/backend/src/routers/auth.py index 26e96fc3..b129f88a 100644 --- a/ushadow/backend/src/routers/auth.py +++ b/ushadow/backend/src/routers/auth.py @@ -406,6 +406,7 @@ class TokenExchangeRequest(BaseModel): code: str = Field(..., description="Authorization code from Keycloak") code_verifier: str = Field(..., description="PKCE code verifier") redirect_uri: str = Field(..., description="Redirect URI used in authorization request") + client_id: Optional[str] = Field(None, description="OAuth client ID (defaults to frontend client)") class TokenExchangeResponse(BaseModel): @@ -437,6 +438,8 @@ async def exchange_code_for_tokens(request: TokenExchangeRequest): from src.services.keycloak_client import get_keycloak_client from keycloak.exceptions import KeycloakError + logger.info(f"[TOKEN-EXCHANGE] Request received with client_id={request.client_id}") + try: kc_client = get_keycloak_client() @@ -444,7 +447,8 @@ async def exchange_code_for_tokens(request: TokenExchangeRequest): tokens = kc_client.exchange_code_for_tokens( code=request.code, redirect_uri=request.redirect_uri, - code_verifier=request.code_verifier + code_verifier=request.code_verifier, + client_id=request.client_id ) logger.info("[TOKEN-EXCHANGE] โ Successfully exchanged code for tokens") @@ -484,6 +488,10 @@ async def refresh_access_token(request: TokenRefreshRequest): Standard OAuth 2.0 refresh token flow using python-keycloak. + IMPORTANT: Extracts the issuer from the refresh token to ensure + we use the same Keycloak URL that issued the token (handles multi-domain + scenarios where browser uses Tailscale hostname but backend sees IP). + Args: request: Contains refresh token @@ -494,14 +502,55 @@ async def refresh_access_token(request: TokenRefreshRequest): 401: If refresh token is invalid or expired 503: If Keycloak is unreachable """ - from src.services.keycloak_client import get_keycloak_client + from keycloak import KeycloakOpenID from keycloak.exceptions import KeycloakError + from src.config.keycloak_settings import get_keycloak_connection, get_keycloak_config + import jwt + from urllib.parse import urlparse try: - kc_client = get_keycloak_client() + # Decode refresh token WITHOUT validation to extract issuer + # This is safe - we're only reading metadata, not trusting the token yet + decoded = jwt.decode(request.refresh_token, options={"verify_signature": False}) + issuer = decoded.get("iss") + + if not issuer: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Refresh token missing issuer claim" + ) + + # Extract server_url from issuer (removes /realms/xxx suffix) + # e.g., "https://orange.spangled-kettle.ts.net/realms/ushadow" -> "https://orange.spangled-kettle.ts.net" + if "/realms/" in issuer: + server_url = issuer.split("/realms/")[0] + else: + server_url = issuer + + # CRITICAL: Translate public URLs to internal Docker network URLs + # When backend is inside Docker, external URLs (localhost/Tailscale) don't work + kc_config = get_keycloak_config() + issuer_parsed = urlparse(server_url) + public_parsed = urlparse(kc_config["public_url"]) + + if issuer_parsed.hostname == public_parsed.hostname and issuer_parsed.port == public_parsed.port: + # Issuer matches public URL -> use internal Docker network URL + server_url = kc_config["url"] + logger.info(f"[TOKEN-REFRESH] Translated public โ internal: {server_url}") + else: + logger.info(f"[TOKEN-REFRESH] Using issuer URL: {server_url}") + + # Create KeycloakOpenID client with the SAME URL that issued the token + # IMPORTANT: Use application realm (ushadow), NOT admin realm (master) + keycloak_openid = KeycloakOpenID( + server_url=server_url, + realm_name=kc_config["realm"], # Application realm where tokens are issued + client_id=kc_config["frontend_client_id"], # Public client (no secret needed) + client_secret_key=None, # Explicit: public client has no secret + ) - # Refresh token - tokens = kc_client.refresh_token(request.refresh_token) + # Refresh token using the correct issuer URL + tokens = keycloak_openid.refresh_token(request.refresh_token) logger.info("[TOKEN-REFRESH] โ Successfully refreshed access token") @@ -520,6 +569,8 @@ async def refresh_access_token(request: TokenRefreshRequest): status_code=status.HTTP_401_UNAUTHORIZED, detail=f"Token refresh failed: {str(e)}" ) + except HTTPException: + raise except Exception as e: logger.error(f"[TOKEN-REFRESH] Unexpected error: {e}", exc_info=True) raise HTTPException( diff --git a/ushadow/backend/src/routers/tailscale.py b/ushadow/backend/src/routers/tailscale.py index 87f2d361..a45e8a54 100644 --- a/ushadow/backend/src/routers/tailscale.py +++ b/ushadow/backend/src/routers/tailscale.py @@ -731,9 +731,7 @@ async def get_mobile_connection_qr( try: keycloak_admin = get_keycloak_admin() mobile_uris = [ - "ushadow://*", # Production mobile app - "exp://localhost:8081/--/oauth/callback", # Expo Go development - "exp://*", # Expo Go wildcard + "ushadow://oauth/callback", # Production mobile app ] await keycloak_admin.update_client_redirect_uris( client_id="ushadow-frontend", diff --git a/ushadow/backend/src/routers/unodes.py b/ushadow/backend/src/routers/unodes.py index 5acbc047..74d2a6fb 100644 --- a/ushadow/backend/src/routers/unodes.py +++ b/ushadow/backend/src/routers/unodes.py @@ -391,6 +391,7 @@ class LeaderInfoResponse(BaseModel): # API URLs for specific services ushadow_api_url: str # Main ushadow backend API chronicle_api_url: Optional[str] = None # Chronicle/OMI backend API (if running) + keycloak_url: Optional[str] = None # Keycloak authentication URL # Streaming URLs (only available when Chronicle service is running) ws_pcm_url: Optional[str] = None # WebSocket for PCM audio streaming @@ -517,6 +518,10 @@ async def get_leader_info(): ws_omi_url=service_ws_omi_url, )) + # Build Keycloak URL for mobile devices (exposed on port 8081) + # Use HOST's Tailscale IP (leader's IP, not container's IP) + keycloak_url = f"http://{leader.tailscale_ip}:8081" + return LeaderInfoResponse( hostname=leader.hostname, envname=leader.envname, @@ -527,6 +532,7 @@ async def get_leader_info(): api_port=api_port, ushadow_api_url=ushadow_api_url, chronicle_api_url=chronicle_api_url, + keycloak_url=keycloak_url, ws_pcm_url=ws_pcm_url, ws_omi_url=ws_omi_url, unodes=unodes, @@ -573,11 +579,18 @@ async def get_unode_info(hostname: str): keycloak_config = None if is_keycloak_enabled(): kc_config = get_keycloak_config() + + # Build Keycloak public URL for mobile clients using HOST's Tailscale IP + # Keycloak runs on the host, so use the unode's tailscale_ip (host IP) + # Mobile devices on Tailscale access Keycloak directly via IP:8081 + keycloak_public_url = f"http://{unode.tailscale_ip}:8081" + keycloak_config = { "enabled": True, - "public_url": kc_config.get("public_url"), + "public_url": keycloak_public_url, "realm": kc_config.get("realm"), "frontend_client_id": kc_config.get("frontend_client_id"), + "mobile_client_id": "ushadow-mobile", # Dedicated mobile client } return UNodeInfoResponse( diff --git a/ushadow/backend/src/services/deployment_platforms.py b/ushadow/backend/src/services/deployment_platforms.py index 98e2a017..e4646a06 100644 --- a/ushadow/backend/src/services/deployment_platforms.py +++ b/ushadow/backend/src/services/deployment_platforms.py @@ -247,6 +247,7 @@ async def _deploy_local( "ushadow.deployed_at": datetime.now(timezone.utc).isoformat(), "ushadow.backend_type": "docker", "com.docker.compose.project": project_name, + "com.docker.compose.service": resolved_service.service_id, # Required for docker_manager to find services } # Use ushadow-network to communicate with infrastructure (mongo, redis, qdrant) @@ -436,6 +437,7 @@ async def deploy( "ushadow.unode_hostname": hostname, "ushadow.deployed_at": datetime.now(timezone.utc).isoformat(), "ushadow.backend_type": "docker", + "com.docker.compose.service": resolved_service.service_id, # Required for docker_manager to find services } payload = { @@ -613,20 +615,27 @@ async def list_deployments( deployments = [] try: - if self._is_local_deployment(target.identifier): - # Query local Docker - docker_client = docker.from_env() - filters = {"label": [ - "ushadow.deployment_id", - f"ushadow.unode_hostname={target.identifier}" - ]} + # Query local Docker for containers (works for both local and "remote" unodes on same host) + docker_client = docker.from_env() + filters = {"label": [ + "ushadow.deployment_id", + f"ushadow.unode_hostname={target.identifier}" + ]} + + if service_id: + filters["label"].append(f"ushadow.service_id={service_id}") - if service_id: - filters["label"].append(f"ushadow.service_id={service_id}") + containers = docker_client.containers.list(all=True, filters=filters) - containers = docker_client.containers.list(all=True, filters=filters) + if containers and not self._is_local_deployment(target.identifier): + logger.info(f"[list_deployments] Found {len(containers)} local containers for remote unode {target.identifier}") + elif not containers and not self._is_local_deployment(target.identifier): + # No local containers found for remote unode + logger.warning(f"No local containers found for {target.identifier}. Remote deployment listing not yet implemented.") + return deployments - for container in containers: + # Process all found containers + for container in containers: labels = container.labels # Extract deployment info from labels @@ -720,11 +729,6 @@ async def list_deployments( deployments.append(deployment) - else: - # Query remote unode manager - # TODO: Implement remote query via unode manager API - logger.warning(f"Remote deployment listing not yet implemented for {target.identifier}") - except Exception as e: logger.error(f"Failed to list deployments: {e}") diff --git a/ushadow/backend/src/services/docker_manager.py b/ushadow/backend/src/services/docker_manager.py index 6e3297ac..b84d03eb 100644 --- a/ushadow/backend/src/services/docker_manager.py +++ b/ushadow/backend/src/services/docker_manager.py @@ -359,16 +359,26 @@ def MANAGEABLE_SERVICES(self) -> Dict[str, Any]: Combines hardcoded CORE_SERVICES with services discovered from compose/*-compose.yaml files via ComposeServiceRegistry. + + Cached after first access to avoid repeated compose registry lookups. """ + # Return cached value if available + if hasattr(self, '_manageable_services_cache'): + return self._manageable_services_cache + # Start with core services services = dict(self.CORE_SERVICES) # Load services from ComposeServiceRegistry (compose-first approach) try: compose_registry = get_compose_registry() - for service in compose_registry.get_services(): + all_compose_services = list(compose_registry.get_services()) + logger.info(f"[MANAGEABLE_SERVICES] Found {len(all_compose_services)} services from compose registry") + logger.info(f"[MANAGEABLE_SERVICES] Service names: {[s.service_name for s in all_compose_services]}") + for service in all_compose_services: # Skip if already in core services if service.service_name in services: + logger.debug(f"[MANAGEABLE_SERVICES] Skipping {service.service_name} (already in core)") continue # Use service_name as the key @@ -397,10 +407,18 @@ def MANAGEABLE_SERVICES(self) -> Dict[str, Any]: logger.warning(f"Failed to load services from compose registry: {e}") logger.debug(f"Loaded {len(services)} manageable services") + + # Cache the result to avoid repeated lookups + self._manageable_services_cache = services return services def reload_services(self) -> None: - """Force reload services from ComposeServiceRegistry.""" + """Force reload services from ComposeServiceRegistry and clear cache.""" + # Clear cache + if hasattr(self, '_manageable_services_cache'): + delattr(self, '_manageable_services_cache') + + # Reload compose registry registry = get_compose_registry() registry.reload() logger.info("ComposeServiceRegistry reloaded") @@ -1669,20 +1687,20 @@ def replace(match): import subprocess try: - # Run docker compose build using container paths - # Unset PROJECT_ROOT so compose file uses relative path (../mycelia) - # which resolves correctly from the mounted /mycelia directory + # Run docker compose build - use container path for compose file + # Docker compose reads from mounted /compose directory + # PROJECT_ROOT env var ensures build contexts resolve to host paths cmd = ["docker", "compose", "-f", compose_file, "build", service_name] - logger.info(f"[BUILD] Running: {' '.join(cmd)} from cwd=/ with PROJECT_ROOT unset") + logger.info(f"[BUILD] Running: {' '.join(cmd)} from cwd=/ with PROJECT_ROOT={project_root}") - # Create environment without PROJECT_ROOT + # Use environment with PROJECT_ROOT set to host path env = os.environ.copy() - env.pop('PROJECT_ROOT', None) + env['PROJECT_ROOT'] = project_root result = subprocess.run( cmd, cwd="/", # Use root of container filesystem - env=env, # Use environment without PROJECT_ROOT + env=env, # Use environment with PROJECT_ROOT set to host path capture_output=True, text=True, timeout=600 # 10 minute timeout for builds diff --git a/ushadow/backend/src/services/keycloak_client.py b/ushadow/backend/src/services/keycloak_client.py index 5b7d9cf3..dd1aef43 100644 --- a/ushadow/backend/src/services/keycloak_client.py +++ b/ushadow/backend/src/services/keycloak_client.py @@ -48,7 +48,8 @@ def exchange_code_for_tokens( self, code: str, redirect_uri: str, - code_verifier: Optional[str] = None + code_verifier: Optional[str] = None, + client_id: Optional[str] = None ) -> Dict[str, Any]: """ Exchange authorization code for access/refresh tokens. @@ -59,6 +60,7 @@ def exchange_code_for_tokens( code: Authorization code from Keycloak redirect_uri: Redirect URI used in authorization request code_verifier: PKCE code verifier (if PKCE was used) + client_id: OAuth client ID (must match the one used in authorization request) Returns: Token response with access_token, refresh_token, id_token, etc. @@ -67,7 +69,7 @@ def exchange_code_for_tokens( KeycloakError: If token exchange fails """ try: - logger.info("[KC-CLIENT] Exchanging authorization code for tokens") + logger.info(f"[KC-CLIENT] Exchanging authorization code for tokens (client_id={client_id})") # Build token request parameters token_params = { @@ -80,8 +82,15 @@ def exchange_code_for_tokens( token_params["code_verifier"] = code_verifier logger.debug("[KC-CLIENT] Using PKCE code_verifier") + # Use client-specific KeycloakOpenID instance if client_id provided + if client_id: + from src.config.keycloak_settings import get_keycloak_openid + keycloak_openid = get_keycloak_openid(client_id=client_id) + else: + keycloak_openid = self.keycloak_openid + # Exchange code for tokens - tokens = self.keycloak_openid.token( + tokens = keycloak_openid.token( grant_type="authorization_code", **token_params ) diff --git a/ushadow/backend/src/services/keycloak_startup.py b/ushadow/backend/src/services/keycloak_startup.py index a7aa5c4d..8f1ae156 100644 --- a/ushadow/backend/src/services/keycloak_startup.py +++ b/ushadow/backend/src/services/keycloak_startup.py @@ -77,21 +77,14 @@ def get_current_redirect_uris() -> List[str]: # Tailscale hostname (auto-detect using TailscaleManager) tailscale_hostname = get_tailscale_hostname() if tailscale_hostname: - # Support both http and https for Tailscale - ts_uri_http = f"http://{tailscale_hostname}/oauth/callback" + # Only HTTPS for Tailscale (HTTP doesn't work with Tailscale Serve) ts_uri_https = f"https://{tailscale_hostname}/oauth/callback" - redirect_uris.append(ts_uri_http) redirect_uris.append(ts_uri_https) - logger.info(f"[KC-STARTUP] ๐ก Adding Tailscale URIs: {tailscale_hostname}") + logger.info(f"[KC-STARTUP] ๐ก Adding Tailscale URI: {tailscale_hostname}") - # Mobile app redirect URIs (React Native) - mobile_uris = [ - "ushadow://*", # Production mobile app (covers oauth/callback) - "exp://localhost:8081/--/oauth/callback", # Expo Go development - "exp://*", # Expo Go wildcard - ] - redirect_uris.extend(mobile_uris) - logger.info(f"[KC-STARTUP] ๐ฑ Adding mobile app URIs") + # NOTE: Mobile app URIs are registered in a SEPARATE client (ushadow-mobile) + # See register_mobile_client() - mobile apps should use their own client to avoid + # redirect URI conflicts with web clients return redirect_uris @@ -128,12 +121,7 @@ def get_current_post_logout_uris() -> List[str]: post_logout_uris.append(f"https://{tailscale_hostname}") post_logout_uris.append(f"https://{tailscale_hostname}/") - # Mobile app post-logout redirect URIs (React Native) - mobile_logout_uris = [ - "ushadow://*", # Production mobile app (covers logout/callback) - "exp://*", # Expo Go wildcard - ] - post_logout_uris.extend(mobile_logout_uris) + # NOTE: Mobile logout URIs are registered in separate client (ushadow-mobile) return post_logout_uris @@ -169,6 +157,57 @@ def get_web_origins() -> List[str]: return [f"http://localhost:{frontend_port}"] +async def register_mobile_client(): + """ + Register redirect URIs for the mobile client. + + Mobile client is defined in realm export with base configuration. + This function adds any environment-specific redirect URIs at startup. + + Note: Mobile uses a SEPARATE client from web because: + 1. Different redirect URI schemes (ushadow:// vs http://localhost) + 2. PKCE is mandatory for mobile (public clients can't protect secrets) + 3. Avoids redirect URI conflicts (Keycloak may default to first URI) + """ + try: + admin_client = get_keycloak_admin() + + # Mobile-specific redirect URIs (already in realm export, but add any dynamic ones here) + mobile_redirect_uris = [ + "ushadow://oauth/callback", # Production mobile app + # Add any environment-specific mobile URIs here if needed + ] + + mobile_logout_uris = [ + "ushadow://logout/callback", + ] + + logger.info("[KC-STARTUP] ๐ฑ Registering mobile redirect URIs...") + + # Update mobile client redirect URIs (merge with existing from realm export) + success = await admin_client.update_client_redirect_uris( + client_id="ushadow-mobile", + redirect_uris=mobile_redirect_uris, + merge=True # Add to existing URIs from realm export + ) + + if success: + # Update post-logout URIs + await admin_client.update_post_logout_redirect_uris( + client_id="ushadow-mobile", + post_logout_redirect_uris=mobile_logout_uris, + merge=True + ) + logger.info("[KC-STARTUP] โ Mobile redirect URIs registered") + logger.info(f"[KC-STARTUP] Client ID: ushadow-mobile") + logger.info(f"[KC-STARTUP] Redirect URIs: {mobile_redirect_uris}") + else: + logger.warning("[KC-STARTUP] โ ๏ธ Failed to register mobile redirect URIs") + + except Exception as e: + logger.warning(f"[KC-STARTUP] โ ๏ธ Failed to register mobile client: {e}") + + async def register_current_environment(): """ Register the current environment's redirect URIs with Keycloak. @@ -206,13 +245,19 @@ async def register_current_environment(): logger.info("[KC-STARTUP] ๐ Registering redirect URIs with Keycloak...") logger.info(f"[KC-STARTUP] Environment: PORT_OFFSET={os.getenv('PORT_OFFSET', '10')}") + logger.info(f"[KC-STARTUP] Redirect URIs to register ({len(redirect_uris)}):") + for uri in redirect_uris: + logger.info(f"[KC-STARTUP] - {uri}") + logger.info(f"[KC-STARTUP] Post-logout URIs to register ({len(post_logout_uris)}):") + for uri in post_logout_uris: + logger.info(f"[KC-STARTUP] - {uri}") # Register redirect URIs and webOrigins (CORS) success = await admin_client.update_client_redirect_uris( client_id="ushadow-frontend", redirect_uris=redirect_uris, web_origins=web_origins, # Pass CORS origins from settings - merge=True # Merge with existing URIs + merge=True # Merge with existing URIs for multi-environment support ) if not success: @@ -249,6 +294,9 @@ async def register_current_environment(): logger.warning(f"[KC-STARTUP] โ ๏ธ Failed to update realm CSP: {csp_error}") logger.warning("[KC-STARTUP] You may need to manually configure CSP in Keycloak admin console") + # Register mobile client (separate from web client) + await register_mobile_client() + except Exception as e: logger.warning(f"[KC-STARTUP] โ ๏ธ Failed to auto-register Keycloak URIs: {e}") logger.warning("[KC-STARTUP] This is non-critical - you can manually configure URIs in Keycloak admin console") diff --git a/ushadow/backend/src/services/kubernetes_manager.py b/ushadow/backend/src/services/kubernetes_manager.py index e97dca94..7c56c7ee 100644 --- a/ushadow/backend/src/services/kubernetes_manager.py +++ b/ushadow/backend/src/services/kubernetes_manager.py @@ -1094,6 +1094,7 @@ async def scan_cluster_for_infra_services( "postgres": {"names": ["postgres", "postgresql"], "port": 5432}, "qdrant": {"names": ["qdrant"], "port": 6333}, "neo4j": {"names": ["neo4j"], "port": 7687}, + "keycloak": {"names": ["keycloak"], "port": 8080}, } # Common namespaces where infrastructure might be deployed diff --git a/ushadow/frontend/keycloak-theme/login/resources/css/login.css b/ushadow/frontend/keycloak-theme/login/resources/css/login.css index 87026a2a..8363ad3a 100644 --- a/ushadow/frontend/keycloak-theme/login/resources/css/login.css +++ b/ushadow/frontend/keycloak-theme/login/resources/css/login.css @@ -499,26 +499,202 @@ button[type="submit"]:active { } /* ============================================ - RESPONSIVE + RESPONSIVE - MOBILE & KEYBOARD AWARE ============================================ */ +/* Mobile portrait - general adjustments */ @media (max-width: 768px) { + /* Use dynamic viewport height for better keyboard handling */ + html, + body { + height: 100dvh !important; + min-height: 100dvh !important; + } + .login-pf-page { - padding-top: 2rem !important; + min-height: 100dvh !important; + padding: 1.5rem 1rem !important; + justify-content: flex-start !important; } + /* Smaller logo on mobile */ #kc-header-wrapper::before { - width: 100px !important; - height: 100px !important; + width: 80px !important; + height: 80px !important; + margin-bottom: 0.5rem !important; + } + + /* Smaller title */ + #kc-header, + #kc-header-wrapper h1 { + font-size: 1.75rem !important; } - #kc-header { - font-size: 1.875rem !important; + /* Smaller subtitle */ + #kc-header::after { + font-size: 0.8rem !important; } + /* Compact card with less padding */ .card-pf { - padding: 1.75rem !important; - margin: 1rem !important; + padding: 1.5rem !important; + margin: 0 !important; + max-width: 100% !important; + } + + /* Reduce spacing between form elements */ + .form-group, + .pf-c-form__group { + margin-bottom: 1rem !important; + } + + /* Slightly smaller inputs */ + input[type="text"], + input[type="email"], + input[type="password"], + input.pf-c-form-control { + padding: 0.65rem 0.875rem !important; + font-size: 16px !important; /* Prevents iOS zoom on focus */ + } + + /* Compact button */ + .btn-primary, + button[type="submit"], + input[type="submit"], + .pf-c-button.pf-m-primary { + padding: 0.65rem 1.25rem !important; + font-size: 0.9375rem !important; + } + + /* Smaller page title */ + #kc-page-title, + .instruction { + font-size: 0.875rem !important; + margin-bottom: 1.25rem !important; + } + + /* Compact form options */ + #kc-form-options { + margin: 0.75rem 0 1rem 0 !important; + } + + /* Reduce glow effects on mobile (performance) */ + body::before, + body::after { + display: none !important; + } +} + +/* Small mobile devices - ultra compact */ +@media (max-width: 480px) { + .login-pf-page { + padding: 1rem 0.75rem !important; + } + + /* Extra small logo */ + #kc-header-wrapper::before { + width: 64px !important; + height: 64px !important; + } + + #kc-header, + #kc-header-wrapper h1 { + font-size: 1.5rem !important; + } + + .card-pf { + padding: 1.25rem !important; + border-radius: 12px !important; + } +} + +/* Landscape mobile - keyboard takes up most of screen */ +@media (max-width: 768px) and (max-height: 500px) { + /* Critical: Minimal padding when in landscape with keyboard */ + .login-pf-page { + padding: 0.5rem !important; + min-height: auto !important; + } + + /* Hide logo in landscape to save space */ + #kc-header-wrapper::before { + display: none !important; + } + + /* Minimal header */ + #kc-header, + #kc-header-wrapper h1 { + font-size: 1.25rem !important; + margin-bottom: 0 !important; + } + + /* Hide subtitle in landscape */ + #kc-header::after { + display: none !important; + } + + /* Minimal card padding */ + .card-pf { + padding: 1rem !important; + } + + /* Compact form elements */ + .form-group, + .pf-c-form__group { + margin-bottom: 0.75rem !important; + } + + label, + .pf-c-form__label { + margin-bottom: 0.25rem !important; + } + + /* Minimal input padding */ + input[type="text"], + input[type="email"], + input[type="password"], + input.pf-c-form-control { + padding: 0.5rem 0.75rem !important; + } + + /* Compact button */ + .btn-primary, + button[type="submit"], + input[type="submit"], + .pf-c-button.pf-m-primary { + padding: 0.5rem 1rem !important; + } + + /* Hide registration link in landscape to save space */ + #kc-registration { + display: none !important; + } + + /* Minimal form options */ + #kc-form-options { + margin: 0.5rem 0 0.75rem 0 !important; + } +} + +/* Touch-specific improvements */ +@media (hover: none) and (pointer: coarse) { + /* Larger touch targets */ + input[type="checkbox"] { + width: 1.125rem !important; + height: 1.125rem !important; + } + + /* Easier to tap links */ + a { + padding: 0.25rem 0 !important; + display: inline-block !important; + } + + /* Better button touch target */ + .btn-primary, + button[type="submit"], + input[type="submit"] { + min-height: 44px !important; /* Apple's recommended touch target */ } } diff --git a/ushadow/frontend/src/auth/TokenManager.ts b/ushadow/frontend/src/auth/TokenManager.ts index 462cfa14..f12f5e87 100644 --- a/ushadow/frontend/src/auth/TokenManager.ts +++ b/ushadow/frontend/src/auth/TokenManager.ts @@ -65,14 +65,23 @@ export class TokenManager { static storeTokens(tokens: TokenResponse): void { const now = Math.floor(Date.now() / 1000) + // Store tokens (or remove if not provided) if (tokens.access_token) { localStorage.setItem(TOKEN_KEY, tokens.access_token) + } else { + localStorage.removeItem(TOKEN_KEY) } + if (tokens.refresh_token) { localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refresh_token) + } else { + localStorage.removeItem(REFRESH_TOKEN_KEY) } + if (tokens.id_token) { localStorage.setItem(ID_TOKEN_KEY, tokens.id_token) + } else { + localStorage.removeItem(ID_TOKEN_KEY) } // Store expiry times (OAuth2 standard: use expires_in from token response) @@ -80,6 +89,8 @@ export class TokenManager { const expiresAt = now + tokens.expires_in localStorage.setItem(EXPIRES_AT_KEY, expiresAt.toString()) console.log('[TokenManager] Access token expires in:', tokens.expires_in, 'seconds') + } else { + localStorage.removeItem(EXPIRES_AT_KEY) } // Store refresh token expiry if provided @@ -87,6 +98,8 @@ export class TokenManager { const refreshExpiresAt = now + tokens.refresh_expires_in localStorage.setItem(REFRESH_EXPIRES_AT_KEY, refreshExpiresAt.toString()) console.log('[TokenManager] Refresh token expires in:', tokens.refresh_expires_in, 'seconds') + } else { + localStorage.removeItem(REFRESH_EXPIRES_AT_KEY) } } @@ -185,6 +198,24 @@ export class TokenManager { localStorage.removeItem(REFRESH_EXPIRES_AT_KEY) } + /** + * Clean up stale token values (removes "null" or "undefined" strings) + * + * This handles cases where tokens were accidentally set to the string "null" + * instead of being removed. Should be called on app initialization. + */ + static cleanupStaleTokens(): void { + const keys = [TOKEN_KEY, REFRESH_TOKEN_KEY, ID_TOKEN_KEY, EXPIRES_AT_KEY, REFRESH_EXPIRES_AT_KEY] + + for (const key of keys) { + const value = sessionStorage.getItem(key) + if (value === 'null' || value === 'undefined' || value === '') { + sessionStorage.removeItem(key) + console.log(`[TokenManager] Cleaned up stale value for ${key}`) + } + } + } + /** * Get access token expiry info from storage (OAuth2 standard) */ @@ -260,6 +291,9 @@ export class TokenManager { console.warn('[TokenManager] โ ๏ธ Token EXPIRED!', { expiredAgo: `${Math.floor(Math.abs(expiresIn) / 60)}m ${Math.abs(expiresIn) % 60}s ago` }) + // CRITICAL: Clear expired token to prevent 401 errors + console.log('[TokenManager] Clearing expired token from storage') + this.clearTokens() } return isValid @@ -306,6 +340,9 @@ export class TokenManager { console.warn('[TokenManager] โ ๏ธ Token EXPIRED!', { expiredAgo: `${Math.floor(Math.abs(expiresIn) / 60)}m ${Math.abs(expiresIn) % 60}s ago` }) + // CRITICAL: Clear expired token to prevent 401 errors + console.log('[TokenManager] Clearing expired token from storage') + this.clearTokens() } return isValid @@ -358,7 +395,7 @@ export class TokenManager { client_id: clientId, redirect_uri: redirectUri, response_type: 'code', - scope: 'openid profile email', + scope: 'openid profile email offline_access', state: state, code_challenge: codeChallenge, code_challenge_method: 'S256', diff --git a/ushadow/frontend/src/components/services/ServiceCard.tsx b/ushadow/frontend/src/components/services/ServiceCard.tsx index 71a37b0b..6066a705 100644 --- a/ushadow/frontend/src/components/services/ServiceCard.tsx +++ b/ushadow/frontend/src/components/services/ServiceCard.tsx @@ -194,25 +194,36 @@ export function ServiceCard({ {/* Enable/Disable Toggle */} {!isEditing && ( - { - e.stopPropagation() - onToggleEnabled() - }} - disabled={isTogglingEnabled} - aria-label={service.enabled ? `Disable ${service.name}` : `Enable ${service.name}`} - className="flex items-center gap-1 text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300 transition-colors" - title={service.enabled ? 'Click to disable' : 'Click to enable'} - > - {isTogglingEnabled ? ( - - ) : service.enabled ? ( - - ) : ( - + + { + e.stopPropagation() + onToggleEnabled() + }} + disabled={isTogglingEnabled} + aria-label={service.enabled ? `Disable ${service.name}` : `Enable ${service.name}`} + className="flex items-center gap-1 text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300 transition-colors" + title={isTogglingEnabled ? 'Updating...' : service.enabled ? 'Click to disable' : 'Click to enable'} + > + {service.enabled ? ( + + ) : ( + + )} + + {isTogglingEnabled && ( + )} - + )} {/* Expand indicator */} diff --git a/ushadow/frontend/src/components/services/ServicesTab.tsx b/ushadow/frontend/src/components/services/ServicesTab.tsx index 0618d2c7..9ed76e07 100644 --- a/ushadow/frontend/src/components/services/ServicesTab.tsx +++ b/ushadow/frontend/src/components/services/ServicesTab.tsx @@ -16,6 +16,7 @@ interface ServicesTabProps { providerTemplates: Template[] serviceStatuses: Record deployments: any[] + togglingDeployments: Set splitServicesEnabled?: boolean // Feature flag for split services view onAddConfig: (serviceId: string) => void onWiringChange: (consumerId: string, capability: string, sourceConfigId: string) => Promise @@ -43,6 +44,7 @@ export default function ServicesTab({ providerTemplates, serviceStatuses, deployments, + togglingDeployments, splitServicesEnabled = false, onAddConfig, onWiringChange, @@ -110,6 +112,7 @@ export default function ServicesTab({ initialConfigs={templateConfigs} instanceCount={templateConfigs.length} deployments={serviceDeployments} + togglingDeployments={togglingDeployments} onStopDeployment={onStopDeployment} onRestartDeployment={onRestartDeployment} onRemoveDeployment={onRemoveDeployment} @@ -245,6 +248,7 @@ export default function ServicesTab({ initialConfigs={templateConfigs} instanceCount={templateConfigs.length} deployments={serviceDeployments} + togglingDeployments={togglingDeployments} onStopDeployment={onStopDeployment} onRestartDeployment={onRestartDeployment} onRemoveDeployment={onRemoveDeployment} diff --git a/ushadow/frontend/src/components/wiring/FlatServiceCard.tsx b/ushadow/frontend/src/components/wiring/FlatServiceCard.tsx index 9953231c..df7bb9e5 100644 --- a/ushadow/frontend/src/components/wiring/FlatServiceCard.tsx +++ b/ushadow/frontend/src/components/wiring/FlatServiceCard.tsx @@ -76,6 +76,8 @@ export interface FlatServiceCardProps { instanceCount?: number /** Active deployments for this service */ deployments?: any[] + /** Set of deployment IDs currently being toggled */ + togglingDeployments?: Set /** Called to stop a deployment */ onStopDeployment?: (deploymentId: string) => Promise /** Called to restart a deployment */ @@ -428,6 +430,7 @@ export function FlatServiceCard({ initialConfigs, instanceCount = 0, deployments = [], + togglingDeployments = new Set(), onStopDeployment, onRestartDeployment, onRemoveDeployment, @@ -842,20 +845,26 @@ export function FlatServiceCard({ onRestartDeployment(deployment.id) } }} - className={`relative flex-shrink-0 w-8 h-4 rounded-full transition-colors ${ - isRunning - ? 'bg-success-500' - : 'bg-neutral-300 dark:bg-neutral-600' + disabled={togglingDeployments.has(deployment.id)} + className={`relative flex-shrink-0 w-8 h-4 rounded-full transition-all ${ + togglingDeployments.has(deployment.id) + ? 'bg-neutral-400 dark:bg-neutral-500 opacity-60' + : isRunning + ? 'bg-success-500' + : 'bg-neutral-300 dark:bg-neutral-600' }`} - title={isRunning ? 'Stop' : 'Start'} + title={togglingDeployments.has(deployment.id) ? 'Updating...' : isRunning ? 'Stop' : 'Start'} data-testid={`toggle-deployment-${deployment.id}`} > + {togglingDeployments.has(deployment.id) && ( + + )} {shortContainerName} @@ -1166,19 +1175,26 @@ export function FlatServiceCard({ onRestartDeployment(dep.id) } }} - className={`relative flex-shrink-0 w-8 h-4 rounded-full transition-colors ${ - isDepRunning - ? 'bg-success-500' - : 'bg-neutral-300 dark:bg-neutral-600' + disabled={togglingDeployments.has(dep.id)} + className={`relative flex-shrink-0 w-8 h-4 rounded-full transition-all ${ + togglingDeployments.has(dep.id) + ? 'bg-neutral-400 dark:bg-neutral-500 opacity-60' + : isDepRunning + ? 'bg-success-500' + : 'bg-neutral-300 dark:bg-neutral-600' }`} - title={isDepRunning ? 'Stop' : 'Start'} + title={togglingDeployments.has(dep.id) ? 'Updating...' : isDepRunning ? 'Stop' : 'Start'} + data-testid={`toggle-worker-deployment-${dep.id}`} > + {togglingDeployments.has(dep.id) && ( + + )} {shortName} diff --git a/ushadow/frontend/src/contexts/KeycloakAuthContext.tsx b/ushadow/frontend/src/contexts/KeycloakAuthContext.tsx index 9e3699e1..ec48d57c 100644 --- a/ushadow/frontend/src/contexts/KeycloakAuthContext.tsx +++ b/ushadow/frontend/src/contexts/KeycloakAuthContext.tsx @@ -93,12 +93,13 @@ export function KeycloakAuthProvider({ children }: { children: ReactNode }) { const { expiresAt, expiresIn } = expiry - // If token is already expired or expires in less than 0 seconds, don't set up refresh + // If token is already expired or expires in less than 0 seconds, clear it if (expiresIn <= 0) { - console.warn('[KC-AUTH] Token already expired, skipping refresh setup') + console.warn('[KC-AUTH] Token already expired, clearing tokens...') setIsAuthenticated(false) setUserInfo(null) setUser(null) + TokenManager.clearTokens() // CRITICAL: Clear expired tokens! return } @@ -170,6 +171,9 @@ export function KeycloakAuthProvider({ children }: { children: ReactNode }) { } useEffect(() => { + // Clean up any stale "null" or "undefined" string values in sessionStorage + TokenManager.cleanupStaleTokens() + // Check auth state with launcher support (async) const checkAuth = async () => { console.log('[KC-AUTH] Checking authentication (launcher-aware)...') diff --git a/ushadow/frontend/src/contexts/ServicesContext.tsx b/ushadow/frontend/src/contexts/ServicesContext.tsx index 7fabc52b..449a38f6 100644 --- a/ushadow/frontend/src/contexts/ServicesContext.tsx +++ b/ushadow/frontend/src/contexts/ServicesContext.tsx @@ -323,18 +323,27 @@ export function ServicesProvider({ children }: { children: ReactNode }) { }, []) const toggleEnabled = useCallback(async (serviceId: string, currentEnabled: boolean) => { + const newEnabled = !currentEnabled + + // Optimistically update the UI immediately + setServiceServiceConfigs(prev => + prev.map(s => s.service_id === serviceId ? { ...s, enabled: newEnabled } : s) + ) + + // Set toggling state to show pending appearance setTogglingEnabled(serviceId) + try { - const newEnabled = !currentEnabled + // Make the API call await servicesApi.setEnabled(serviceId, newEnabled) - setServiceServiceConfigs(prev => - prev.map(s => s.service_id === serviceId ? { ...s, enabled: newEnabled } : s) - ) - const action = newEnabled ? 'enabled' : 'disabled' setMessage({ type: 'success', text: `Service ${action}` }) } catch (error: any) { + // Revert the optimistic update on error + setServiceServiceConfigs(prev => + prev.map(s => s.service_id === serviceId ? { ...s, enabled: currentEnabled } : s) + ) setMessage({ type: 'error', text: error.response?.data?.detail || 'Failed to toggle service' }) } finally { setTogglingEnabled(null) diff --git a/ushadow/frontend/src/pages/ServiceConfigsPage.tsx b/ushadow/frontend/src/pages/ServiceConfigsPage.tsx index eb08ccc0..e611cd35 100644 --- a/ushadow/frontend/src/pages/ServiceConfigsPage.tsx +++ b/ushadow/frontend/src/pages/ServiceConfigsPage.tsx @@ -145,6 +145,9 @@ export default function ServiceConfigsPage() { const [loadingProviderCard, setLoadingProviderCard] = useState(false) const [savingProviderCard, setSavingProviderCard] = useState(false) + // Track toggling deployments (for spinner state) + const [togglingDeployments, setTogglingDeployments] = useState>(new Set()) + // Unified deploy modal state const [deployModalState, setDeployModalState] = useState<{ isOpen: boolean @@ -1184,20 +1187,33 @@ export default function ServiceConfigsPage() { // Deployment action handlers const handleStopDeployment = async (deploymentId: string) => { + // Add to toggling set + setTogglingDeployments(prev => new Set(prev).add(deploymentId)) + try { await deploymentActions.stopDeployment(deploymentId) - refreshData() + await refreshData() setMessage({ type: 'success', text: 'Deployment stopped' }) } catch (error: any) { console.error('Failed to stop deployment:', error) setMessage({ type: 'error', text: 'Failed to stop deployment' }) + } finally { + // Remove from toggling set + setTogglingDeployments(prev => { + const next = new Set(prev) + next.delete(deploymentId) + return next + }) } } const handleRestartDeployment = async (deploymentId: string) => { + // Add to toggling set + setTogglingDeployments(prev => new Set(prev).add(deploymentId)) + try { await deploymentActions.restartDeployment(deploymentId) - refreshData() + await refreshData() setMessage({ type: 'success', text: 'Deployment restarted' }) } catch (error: any) { console.error('Failed to restart deployment:', error) @@ -1235,6 +1251,13 @@ export default function ServiceConfigsPage() { } setMessage({ type: 'error', text: getErrorMessage(error, 'Failed to restart deployment') }) + } finally { + // Remove from toggling set + setTogglingDeployments(prev => { + const next = new Set(prev) + next.delete(deploymentId) + return next + }) } } @@ -1527,6 +1550,7 @@ export default function ServiceConfigsPage() { providerTemplates={providerTemplates} serviceStatuses={serviceStatuses} deployments={filteredDeployments} + togglingDeployments={togglingDeployments} splitServicesEnabled={isEnabled('split_services')} onAddConfig={showServiceConfigs ? handleAddConfig : () => {}} onWiringChange={handleWiringChange} diff --git a/ushadow/frontend/src/services/api.ts b/ushadow/frontend/src/services/api.ts index 2d7ea5aa..3b26b67c 100644 --- a/ushadow/frontend/src/services/api.ts +++ b/ushadow/frontend/src/services/api.ts @@ -74,7 +74,7 @@ api.interceptors.request.use((config) => { // Priority: Keycloak > Native > Legacy (all in localStorage now) const token = kcToken || nativeToken || legacyToken - // Only add Authorization header if we have a valid token (not null, not empty) + // Only set header if we have a valid token (not null, undefined, or the string "null") if (token && token !== 'null' && token !== 'undefined') { config.headers.Authorization = `Bearer ${token}` } diff --git a/ushadow/launcher/clean-rebuild.sh b/ushadow/launcher/clean-rebuild.sh index d82f61d4..9d4f33a6 100755 --- a/ushadow/launcher/clean-rebuild.sh +++ b/ushadow/launcher/clean-rebuild.sh @@ -7,6 +7,13 @@ set -e # Exit on error cd "$(dirname "$0")" +# Ensure bundled resources exist +if [ ! -d "src-tauri/bundled" ]; then + echo "๐ฆ Bundled resources not found, running bundle-resources.sh..." + bash bundle-resources.sh + echo "" +fi + echo "๐งน Cleaning caches..." # Clear Rust build cache diff --git a/ushadow/launcher/package-lock.json b/ushadow/launcher/package-lock.json index 18033281..dc19667d 100644 --- a/ushadow/launcher/package-lock.json +++ b/ushadow/launcher/package-lock.json @@ -1,12 +1,12 @@ { "name": "ushadow-launcher", - "version": "0.3.0", + "version": "0.7.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ushadow-launcher", - "version": "0.3.0", + "version": "0.7.15", "dependencies": { "@tauri-apps/api": "^1.5.3", "lucide-react": "^0.446.0", @@ -72,6 +72,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -769,7 +770,6 @@ "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", @@ -785,7 +785,6 @@ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -797,7 +796,6 @@ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -811,7 +809,6 @@ "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@eslint/core": "^0.17.0" }, @@ -825,7 +822,6 @@ "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@types/json-schema": "^7.0.15" }, @@ -839,7 +835,6 @@ "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", @@ -864,7 +859,6 @@ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -876,7 +870,6 @@ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 4" } @@ -887,7 +880,6 @@ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -901,7 +893,6 @@ "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -915,7 +906,6 @@ "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } @@ -926,7 +916,6 @@ "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" @@ -941,7 +930,6 @@ "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=18.18.0" } @@ -952,7 +940,6 @@ "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" @@ -967,7 +954,6 @@ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=12.22" }, @@ -982,7 +968,6 @@ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=18.18" }, @@ -1710,8 +1695,7 @@ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/prop-types": { "version": "15.7.15", @@ -1726,6 +1710,7 @@ "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1776,6 +1761,7 @@ "integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/types": "8.52.0", @@ -2015,7 +2001,6 @@ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } @@ -2026,7 +2011,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -2044,7 +2028,6 @@ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -2088,8 +2071,7 @@ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true, - "license": "Python-2.0", - "peer": true + "license": "Python-2.0" }, "node_modules/autoprefixer": { "version": "10.4.23", @@ -2201,6 +2183,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2221,7 +2204,6 @@ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -2263,7 +2245,6 @@ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -2319,7 +2300,6 @@ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "color-name": "~1.1.4" }, @@ -2332,8 +2312,7 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/commander": { "version": "4.1.1", @@ -2350,8 +2329,7 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/convert-source-map": { "version": "2.0.0", @@ -2366,7 +2344,6 @@ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -2419,8 +2396,7 @@ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/didyoumean": { "version": "1.2.2", @@ -2498,7 +2474,6 @@ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -2573,7 +2548,6 @@ "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -2604,7 +2578,6 @@ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -2616,7 +2589,6 @@ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -2630,7 +2602,6 @@ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 4" } @@ -2641,7 +2612,6 @@ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -2655,7 +2625,6 @@ "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", @@ -2674,7 +2643,6 @@ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -2688,7 +2656,6 @@ "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "estraverse": "^5.1.0" }, @@ -2702,7 +2669,6 @@ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "estraverse": "^5.2.0" }, @@ -2716,7 +2682,6 @@ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=4.0" } @@ -2727,7 +2692,6 @@ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -2737,8 +2701,7 @@ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fast-glob": { "version": "3.3.3", @@ -2775,16 +2738,14 @@ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fastq": { "version": "1.20.1", @@ -2802,7 +2763,6 @@ "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "flat-cache": "^4.0.0" }, @@ -2829,7 +2789,6 @@ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -2847,7 +2806,6 @@ "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" @@ -2861,8 +2819,7 @@ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/fraction.js": { "version": "5.3.4", @@ -2932,7 +2889,6 @@ "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -2946,7 +2902,6 @@ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -2980,7 +2935,6 @@ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -2998,7 +2952,6 @@ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.8.19" } @@ -3070,8 +3023,7 @@ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/jiti": { "version": "1.21.7", @@ -3079,6 +3031,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -3095,7 +3048,6 @@ "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "argparse": "^2.0.1" }, @@ -3121,24 +3073,21 @@ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json5": { "version": "2.2.3", @@ -3159,7 +3108,6 @@ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "json-buffer": "3.0.1" } @@ -3170,7 +3118,6 @@ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" @@ -3205,7 +3152,6 @@ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "p-locate": "^5.0.0" }, @@ -3221,8 +3167,7 @@ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/loose-envify": { "version": "1.4.0", @@ -3383,7 +3328,6 @@ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", @@ -3402,7 +3346,6 @@ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "yocto-queue": "^0.1.0" }, @@ -3419,7 +3362,6 @@ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "p-limit": "^3.0.2" }, @@ -3436,7 +3378,6 @@ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "callsites": "^3.0.0" }, @@ -3450,7 +3391,6 @@ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -3461,7 +3401,6 @@ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -3533,6 +3472,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -3682,7 +3622,6 @@ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8.0" } @@ -3693,7 +3632,6 @@ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -3724,6 +3662,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -3804,7 +3743,6 @@ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=4" } @@ -3917,7 +3855,6 @@ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -3931,7 +3868,6 @@ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -3952,7 +3888,6 @@ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" }, @@ -3989,7 +3924,6 @@ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -4112,6 +4046,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4158,7 +4093,6 @@ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "prelude-ls": "^1.2.1" }, @@ -4172,6 +4106,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4217,7 +4152,6 @@ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "punycode": "^2.1.0" } @@ -4235,6 +4169,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -4295,7 +4230,6 @@ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "isexe": "^2.0.0" }, @@ -4312,7 +4246,6 @@ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -4330,7 +4263,6 @@ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, diff --git a/ushadow/launcher/scripts/build.sh b/ushadow/launcher/scripts/build.sh index 5acfebd5..eab5c981 100755 --- a/ushadow/launcher/scripts/build.sh +++ b/ushadow/launcher/scripts/build.sh @@ -15,6 +15,14 @@ YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' +# Ensure bundled resources exist +if [ ! -d "$LAUNCHER_DIR/src-tauri/bundled" ]; then + echo -e "${YELLOW}๐ฆ Bundled resources not found, running bundle-resources.sh...${NC}" + cd "$LAUNCHER_DIR" + bash bundle-resources.sh + echo "" +fi + show_usage() { echo "Usage: $0 [--debug]" echo "" diff --git a/ushadow/launcher/src-tauri/src/commands/docker.rs b/ushadow/launcher/src-tauri/src/commands/docker.rs index 3c0a2ba9..7050969e 100644 --- a/ushadow/launcher/src-tauri/src/commands/docker.rs +++ b/ushadow/launcher/src-tauri/src/commands/docker.rs @@ -171,7 +171,7 @@ pub async fn start_infrastructure(state: State<'_, AppState>) -> Result) -> Result) -> Result, env_name: String, env debug_log.push(format!("Warning: Failed to copy setup directory: {}", e)); // Continue anyway - might be a partial copy that still works } else { - debug_log.push(format!("โ Bundled setup copied successfully")); + debug_log.push(format!("[OK] Bundled setup copied successfully")); } } @@ -541,7 +541,7 @@ pub async fn start_environment(state: State<'_, AppState>, env_name: String, env } // On success, only show status log - status_log.push(format!("โ Environment '{}' initialized and started", env_name)); + status_log.push(format!("[OK] Environment '{}' initialized and started", env_name)); return Ok(status_log.join("\n")); } diff --git a/ushadow/launcher/src-tauri/src/commands/worktree.rs b/ushadow/launcher/src-tauri/src/commands/worktree.rs index b5f43b6e..b380c18e 100644 --- a/ushadow/launcher/src-tauri/src/commands/worktree.rs +++ b/ushadow/launcher/src-tauri/src/commands/worktree.rs @@ -627,10 +627,10 @@ set -g terminal-overrides 'xterm*:smcup@:rmcup@'\n\ eprintln!("[open_in_vscode] ERROR: Failed to create tmux window: {}", stderr); return Err(format!("Failed to create tmux window: {}", stderr)); } else { - eprintln!("[open_in_vscode] โ Created tmux window '{}'", window_name); + eprintln!("[open_in_vscode] [OK] Created tmux window '{}'", window_name); } } else { - eprintln!("[open_in_vscode] โ Tmux window '{}' already exists", window_name); + eprintln!("[open_in_vscode] [OK] Tmux window '{}' already exists", window_name); } // Create .vscode directory if it doesn't exist @@ -839,18 +839,18 @@ pub async fn delete_environment(main_repo: String, env_name: String) -> Result { - messages.push(format!("โ Stopped containers for '{}'", env_name)); + messages.push(format!("[OK] Stopped containers for '{}'", env_name)); } Ok(output) => { let stderr = String::from_utf8_lossy(&output.stderr); if !stderr.contains("No such file") && !stderr.to_lowercase().contains("not found") { eprintln!("[delete_environment] Warning: Failed to stop containers: {}", stderr); - messages.push(format!("โ Could not stop containers (may already be stopped)")); + messages.push(format!("[WARN] Could not stop containers (may already be stopped)")); } } Err(e) => { eprintln!("[delete_environment] Warning: Failed to run docker compose down: {}", e); - messages.push(format!("โ Could not stop containers (may already be stopped)")); + messages.push(format!("[WARN] Could not stop containers (may already be stopped)")); } } @@ -862,7 +862,7 @@ pub async fn delete_environment(main_repo: String, env_name: String) -> Result { - messages.push(format!("โ Closed tmux window '{}'", window_name)); + messages.push(format!("[OK] Closed tmux window '{}'", window_name)); } Ok(_) | Err(_) => { // Tmux window might not exist, that's fine @@ -878,7 +878,7 @@ pub async fn delete_environment(main_repo: String, env_name: String) -> Result { - messages.push(format!("โ Removed worktree '{}'", env_name)); + messages.push(format!("[OK] Removed worktree '{}'", env_name)); } Err(e) => { return Err(format!("Failed to remove worktree: {}", e)); @@ -893,7 +893,7 @@ pub async fn delete_environment(main_repo: String, env_name: String) -> Result { // Error checking worktree, log but don't fail eprintln!("[delete_environment] Warning: Could not check worktree existence: {}", e); - messages.push(format!("โ Could not check for worktree")); + messages.push(format!("[WARN] Could not check for worktree")); } } diff --git a/ushadow/mobile/Makefile b/ushadow/mobile/Makefile index b149aaf2..977c8403 100644 --- a/ushadow/mobile/Makefile +++ b/ushadow/mobile/Makefile @@ -1,20 +1,28 @@ -.PHONY: help build-ios build-ios-preview submit-testflight build-and-submit build-list install clean +.PHONY: help build-ios build-ios-preview submit-testflight build-and-submit build-list install clean build-ios-local build-local-and-submit # Default target help: @echo "ushadow Mobile - EAS Build Commands" @echo "" - @echo "Available targets:" + @echo "Cloud Builds (slower, uses EAS builders):" @echo " make build-ios - Build production iOS app for TestFlight/App Store" @echo " make build-ios-preview - Build preview iOS app for internal testing (requires UDID)" - @echo " make submit-testflight - Submit latest build to TestFlight" @echo " make build-and-submit - Build and auto-submit to TestFlight in one step" + @echo "" + @echo "Local Builds (faster, builds on your Mac):" + @echo " make build-ios-local - Build iOS locally (no EAS queue wait)" + @echo " make build-local-and-submit - Build locally and submit to TestFlight" + @echo "" + @echo "Submit & Manage:" + @echo " make submit-testflight - Submit latest build to TestFlight" @echo " make build-list - List all EAS builds" + @echo "" + @echo "Development:" @echo " make install - Install dependencies" @echo " make clean - Clean build artifacts" @echo "" @echo "Quick start:" - @echo " make build-and-submit # Build and submit to TestFlight" + @echo " make build-local-and-submit # Fastest - build locally + submit" # Build production iOS (for TestFlight/App Store) build-ios: @@ -36,6 +44,23 @@ build-and-submit: @echo "Building and submitting to TestFlight..." eas build --platform ios --profile production --auto-submit +# Build iOS locally (no EAS queue, runs on your Mac) +build-ios-local: + @echo "Building iOS production build locally..." + @echo "โ ๏ธ This requires Xcode and valid code signing credentials" + eas build --platform ios --profile production --local + +# Build locally and submit to TestFlight +build-local-and-submit: + @echo "Building iOS locally and submitting to TestFlight..." + @echo "โ ๏ธ This requires Xcode and valid code signing credentials" + @echo "" + @echo "Step 1: Building locally..." + eas build --platform ios --profile production --local + @echo "" + @echo "Step 2: Submitting to TestFlight..." + eas submit --platform ios --latest + # List all builds build-list: @echo "Listing all EAS builds..." diff --git a/ushadow/mobile/app.json b/ushadow/mobile/app.json index e3854f4d..ab0d9cb3 100644 --- a/ushadow/mobile/app.json +++ b/ushadow/mobile/app.json @@ -24,6 +24,7 @@ "NSSpeechRecognitionUsageDescription": "Ushadow uses speech recognition for voice input in chat.", "NSMicrophoneUsageDescription": "Ushadow needs microphone access for voice input.", "UIBackgroundModes": [ + "audio", "bluetooth-central", "fetch", "processing" @@ -35,7 +36,7 @@ "ITSAppUsesNonExemptEncryption": false }, "appleTeamId": "6SJ7NH4HSZ", - "buildNumber": "4" + "buildNumber": "8" }, "android": { "adaptiveIcon": { diff --git a/ushadow/mobile/app/_utils/authStorage.ts b/ushadow/mobile/app/_utils/authStorage.ts index 9694da45..bf305b9d 100644 --- a/ushadow/mobile/app/_utils/authStorage.ts +++ b/ushadow/mobile/app/_utils/authStorage.ts @@ -10,10 +10,114 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import AppConfig from '../config'; const AUTH_TOKEN_KEY = '@ushadow_auth_token'; +const REFRESH_TOKEN_KEY = '@ushadow_refresh_token'; const ID_TOKEN_KEY = '@ushadow_id_token'; const API_URL_KEY = '@ushadow_api_url'; const DEFAULT_SERVER_URL_KEY = '@ushadow_default_server_url'; +/** + * Validate a JWT token (expiration and required claims) + * @returns true if valid, false if expired/invalid (and clears token) + */ +async function validateToken(token: string): Promise { + try { + const parts = token.split('.'); + if (parts.length !== 3) { + console.log('[AuthStorage] Invalid token format'); + await clearAuthToken(); + return false; + } + + const payload = JSON.parse(atob(parts[1])); + + // Check expiration + if (payload.exp && Date.now() / 1000 > payload.exp) { + console.log('[AuthStorage] Token expired'); + await clearAuthToken(); + return false; + } + + // Check required claims for Keycloak tokens + if (payload.iss && payload.iss.includes('/realms/')) { + // It's a Keycloak token - validate required claims + if (!payload.sub || !payload.email) { + console.warn('[AuthStorage] Keycloak token missing required claims (sub/email)'); + await clearAuthToken(); + return false; + } + } + + return true; + } catch (error) { + console.error('[AuthStorage] Token validation error:', error); + await clearAuthToken(); + return false; + } +} + +/** + * Check if token should be refreshed (expires within 5 minutes) + */ +async function shouldRefreshToken(token: string): Promise { + try { + const parts = token.split('.'); + if (parts.length !== 3) return false; + + const payload = JSON.parse(atob(parts[1])); + if (!payload.exp) return false; + + const now = Date.now() / 1000; + const expiresIn = payload.exp - now; + const REFRESH_THRESHOLD = 5 * 60; // 5 minutes + + return expiresIn < REFRESH_THRESHOLD && expiresIn > 0; + } catch { + return false; + } +} + +/** + * Attempt to refresh the access token using the refresh token + * @returns New access token if successful, null if failed + */ +async function attemptTokenRefresh(): Promise { + try { + const refreshToken = await AsyncStorage.getItem(REFRESH_TOKEN_KEY); + const apiUrl = await getApiUrl(); + + if (!refreshToken || !apiUrl) { + console.log('[AuthStorage] Missing refresh token or API URL for refresh'); + return null; + } + + // Import the Keycloak refresh function + const { refreshKeycloakToken } = await import('../services/keycloakAuth'); + + const tokens = await refreshKeycloakToken(apiUrl, refreshToken); + + if (!tokens || !tokens.access_token) { + console.error('[AuthStorage] Token refresh failed'); + // Don't clear tokens yet - let validation handle it + return null; + } + + // Save new tokens + await AsyncStorage.setItem(AUTH_TOKEN_KEY, tokens.access_token); + if (tokens.refresh_token) { + await AsyncStorage.setItem(REFRESH_TOKEN_KEY, tokens.refresh_token); + } + if (tokens.id_token) { + await AsyncStorage.setItem(ID_TOKEN_KEY, tokens.id_token); + } + + console.log('[AuthStorage] โ Token refreshed successfully'); + return tokens.access_token; + } catch (error) { + console.error('[AuthStorage] Token refresh error:', error); + return null; + } +} + /** * Store the auth token */ @@ -28,11 +132,57 @@ export async function saveAuthToken(token: string): Promise { } /** - * Get the stored auth token + * Store the refresh token + */ +export async function saveRefreshToken(refreshToken: string): Promise { + try { + await AsyncStorage.setItem(REFRESH_TOKEN_KEY, refreshToken); + console.log('[AuthStorage] Refresh token saved'); + } catch (error) { + console.error('[AuthStorage] Failed to save refresh token:', error); + throw error; + } +} + +/** + * Get the stored refresh token + */ +export async function getRefreshToken(): Promise { + try { + const token = await AsyncStorage.getItem(REFRESH_TOKEN_KEY); + return token; + } catch (error) { + console.error('[AuthStorage] Failed to get refresh token:', error); + return null; + } +} + +/** + * Get the stored auth token (with automatic refresh if expiring soon) */ export async function getAuthToken(): Promise { try { const token = await AsyncStorage.getItem(AUTH_TOKEN_KEY); + if (!token) return null; + + // Check if token needs refresh (expires within 5 minutes) + const needsRefresh = await shouldRefreshToken(token); + if (needsRefresh) { + console.log('[AuthStorage] Token expiring soon, attempting refresh...'); + const refreshed = await attemptTokenRefresh(); + if (refreshed) { + return refreshed; // Return fresh token + } + // Refresh failed, continue with validation + } + + // Validate token and clear if expired/invalid + const isValid = await validateToken(token); + if (!isValid) { + console.log('[AuthStorage] Token validation failed, cleared from storage'); + return null; + } + return token; } catch (error) { console.error('[AuthStorage] Failed to get token:', error); @@ -46,8 +196,9 @@ export async function getAuthToken(): Promise { export async function clearAuthToken(): Promise { try { await AsyncStorage.removeItem(AUTH_TOKEN_KEY); + await AsyncStorage.removeItem(REFRESH_TOKEN_KEY); await AsyncStorage.removeItem(ID_TOKEN_KEY); - console.log('[AuthStorage] Token cleared'); + console.log('[AuthStorage] Tokens cleared (access, refresh, id)'); } catch (error) { console.error('[AuthStorage] Failed to clear token:', error); throw error; @@ -115,19 +266,26 @@ export async function isAuthenticated(): Promise { // Basic JWT expiration check (decode without verification) try { const parts = token.split('.'); - if (parts.length !== 3) return false; + if (parts.length !== 3) { + console.log('[AuthStorage] Invalid token format, clearing...'); + await clearAuthToken(); + return false; + } const payload = JSON.parse(atob(parts[1])); const exp = payload.exp; if (exp && Date.now() / 1000 > exp) { - console.log('[AuthStorage] Token expired'); + console.log('[AuthStorage] Token expired, clearing...'); + // CRITICAL: Clear expired token to force re-authentication + await clearAuthToken(); return false; } return true; - } catch { - console.log('[AuthStorage] Invalid token format'); + } catch (error) { + console.log('[AuthStorage] Invalid token format, clearing...', error); + await clearAuthToken(); return false; } } @@ -144,9 +302,17 @@ export async function getAuthInfo(): Promise<{ email: string; userId: string } | if (parts.length !== 3) return null; const payload = JSON.parse(atob(parts[1])); + + // Validate that required claims exist + if (!payload.sub || !payload.email) { + console.warn('[AuthStorage] Token missing required claims (sub or email), clearing...'); + await clearAuthToken(); + return null; + } + return { - email: payload.email || 'Unknown', - userId: payload.sub || 'Unknown', + email: payload.email, + userId: payload.sub, }; } catch { return null; @@ -225,3 +391,14 @@ export async function getEffectiveServerUrl(): Promise { // Otherwise return the default return getDefaultServerUrl(); } + +/** + * Handle 401 Unauthorized responses by clearing auth tokens. + * This forces the user to re-authenticate with Keycloak. + * + * Usage: if (response.status === 401) handleUnauthorized(); + */ +export async function handleUnauthorized(): Promise { + console.log('[AuthStorage] Received 401 Unauthorized, clearing auth tokens...'); + await clearAuthToken(); +} diff --git a/ushadow/mobile/app/components/LoginScreenWithKeycloak.tsx b/ushadow/mobile/app/components/LoginScreenWithKeycloak.tsx index a5f88cf7..a9a74fcb 100644 --- a/ushadow/mobile/app/components/LoginScreenWithKeycloak.tsx +++ b/ushadow/mobile/app/components/LoginScreenWithKeycloak.tsx @@ -19,7 +19,7 @@ import { ScrollView, } from 'react-native'; import { colors, theme, spacing, borderRadius, fontSize } from '../theme'; -import { saveAuthToken, saveIdToken, saveApiUrl, getDefaultServerUrl, setDefaultServerUrl } from '../_utils/authStorage'; +import { saveAuthToken, saveRefreshToken, saveIdToken, saveApiUrl, getDefaultServerUrl, setDefaultServerUrl } from '../_utils/authStorage'; import { isKeycloakAvailable, authenticateWithKeycloak, @@ -133,6 +133,12 @@ export const LoginScreen: React.FC = ({ // Save tokens and API URL await saveAuthToken(tokens.access_token); + if (tokens.refresh_token) { + await saveRefreshToken(tokens.refresh_token); + console.log('[Login] Refresh token saved for automatic token refresh'); + } else { + console.warn('[Login] No refresh token received - token refresh will not work'); + } if (tokens.id_token) { await saveIdToken(tokens.id_token); console.log('[Login] ID token saved for logout'); diff --git a/ushadow/mobile/app/hooks/index.ts b/ushadow/mobile/app/hooks/index.ts index 29d2212f..2733b124 100644 --- a/ushadow/mobile/app/hooks/index.ts +++ b/ushadow/mobile/app/hooks/index.ts @@ -30,6 +30,8 @@ export { useSessionTracking } from './useSessionTracking'; export { useAppLifecycle } from './useAppLifecycle'; export type { UseAppLifecycle } from './useAppLifecycle'; +export { useLockScreenControls } from './useLockScreenControls'; + export { useConnectionHealth } from './useConnectionHealth'; export type { UseConnectionHealth } from './useConnectionHealth'; diff --git a/ushadow/mobile/app/hooks/useAudioManager.ts b/ushadow/mobile/app/hooks/useAudioManager.ts index c0d582b2..4af0cf25 100644 --- a/ushadow/mobile/app/hooks/useAudioManager.ts +++ b/ushadow/mobile/app/hooks/useAudioManager.ts @@ -1,6 +1,8 @@ import { useState, useCallback, useRef, useEffect } from 'react'; import { Alert } from 'react-native'; import { OmiConnection } from 'friend-lite-react-native'; +import { Audio } from 'expo-av'; +import { useLockScreenControls } from './useLockScreenControls'; // Type definitions for audio streaming services interface AudioStreamer { @@ -89,6 +91,9 @@ export const useAudioManager = ({ const [currentSessionId, setCurrentSessionId] = useState(null); const [currentConversationId, setCurrentConversationId] = useState(null); + // Lock screen controls for showing streaming status + const lockScreenControls = useLockScreenControls(); + // Track previous WebSocket state to detect transitions const previousWsReadyStateRef = useRef(undefined); const sessionIdRef = useRef(null); @@ -211,6 +216,22 @@ export const useAudioManager = ({ sessionIdRef.current = sessionId; setCurrentSessionId(sessionId); + // Configure iOS audio session for background streaming + // This keeps the audio pipeline active when screen locks + try { + await Audio.setAudioModeAsync({ + allowsRecordingIOS: false, // OMI does the recording + playsInSilentModeIOS: true, + staysActiveInBackground: true, // โญ Critical for background audio + interruptionModeIOS: 2, // DoNotMix + shouldDuckAndroid: false, + }); + console.log('[useAudioManager] โ Audio session configured for background OMI streaming'); + } catch (audioModeError) { + console.warn('[useAudioManager] โ ๏ธ Failed to set audio mode:', audioModeError); + // Continue anyway - streaming might still work + } + const finalWebSocketUrl = buildWebSocketUrl(webSocketUrl); // Start custom WebSocket streaming first (OMI uses Opus codec) @@ -222,6 +243,13 @@ export const useAudioManager = ({ // Then start OMI audio listener with offline-aware handler await startAudioListener(handleAudioData); + // Show lock screen controls with device info + await lockScreenControls.showStreamingControls({ + title: 'OMI Audio Streaming', + artist: connectedDeviceId ? `Device: ${connectedDeviceId}` : 'OMI Device', + album: 'Ushadow', + }); + console.log('[useAudioManager] OMI audio streaming started successfully', { sessionId }); } catch (error) { console.error('[useAudioManager] Error starting OMI audio streaming:', error); @@ -240,6 +268,7 @@ export const useAudioManager = ({ buildWebSocketUrl, handleAudioData, generateSessionId, + lockScreenControls, ]); /** @@ -257,13 +286,28 @@ export const useAudioManager = ({ await stopAudioListener(); audioStreamer.stopStreaming(); + // Hide lock screen controls + await lockScreenControls.hideControls(); + + // Deactivate audio session to allow iOS to suspend app if needed + // (unless other audio is active) + try { + await Audio.setAudioModeAsync({ + allowsRecordingIOS: false, + staysActiveInBackground: false, + }); + console.log('[useAudioManager] Audio session deactivated'); + } catch (audioModeError) { + console.warn('[useAudioManager] Failed to deactivate audio session:', audioModeError); + } + // Clear session tracking sessionIdRef.current = null; conversationIdRef.current = null; setCurrentSessionId(null); setCurrentConversationId(null); previousWsReadyStateRef.current = undefined; - }, [stopAudioListener, audioStreamer, offlineMode]); + }, [stopAudioListener, audioStreamer, offlineMode, lockScreenControls]); /** * Start phone microphone audio streaming @@ -292,6 +336,13 @@ export const useAudioManager = ({ } }); + // Show lock screen controls + await lockScreenControls.showStreamingControls({ + title: 'Phone Microphone Streaming', + artist: userId || 'Phone', + album: 'Ushadow', + }); + setIsPhoneAudioMode(true); console.log('[useAudioManager] Phone audio streaming started successfully'); } catch (error) { @@ -307,6 +358,8 @@ export const useAudioManager = ({ audioStreamer, phoneAudioRecorder, buildWebSocketUrl, + lockScreenControls, + userId, ]); /** @@ -316,8 +369,12 @@ export const useAudioManager = ({ console.log('[useAudioManager] Stopping phone audio streaming'); await phoneAudioRecorder.stopRecording(); audioStreamer.stopStreaming(); + + // Hide lock screen controls + await lockScreenControls.hideControls(); + setIsPhoneAudioMode(false); - }, [phoneAudioRecorder, audioStreamer]); + }, [phoneAudioRecorder, audioStreamer, lockScreenControls]); /** * Toggle phone audio on/off diff --git a/ushadow/mobile/app/hooks/useLockScreenControls.ts b/ushadow/mobile/app/hooks/useLockScreenControls.ts new file mode 100644 index 00000000..4d53083e --- /dev/null +++ b/ushadow/mobile/app/hooks/useLockScreenControls.ts @@ -0,0 +1,224 @@ +/** + * useLockScreenControls Hook + * + * Manages lock screen media controls for audio streaming. + * Shows streaming status, device name, and stop button on iOS lock screen. + * + * This significantly improves iOS background priority and user experience: + * - iOS treats app like a music player (higher priority) + * - Users can see streaming status without unlocking + * - Can stop streaming from lock screen + * + * Usage: + * ```typescript + * const lockScreen = useLockScreenControls(); + * + * // When starting streaming + * await lockScreen.showStreamingControls({ + * title: 'OMI Device Streaming', + * artist: 'Device: OMI-ABC123', + * }); + * + * // When stopping + * await lockScreen.hideControls(); + * ``` + */ + +import { useCallback, useEffect, useRef } from 'react'; +import { Platform, AppState, AppStateStatus } from 'react-native'; +import { Audio } from 'expo-av'; + +interface LockScreenMetadata { + title: string; + artist?: string; + album?: string; +} + +interface UseLockScreenControls { + showStreamingControls: (metadata: LockScreenMetadata) => Promise; + hideControls: () => Promise; + updateMetadata: (metadata: Partial) => Promise; + isActive: boolean; +} + +/** + * Hook to manage lock screen media controls for streaming audio. + * + * Platform behavior: + * - iOS: Shows now-playing info on lock screen with active audio session + * - Android: Could be extended with MediaSession API (future enhancement) + */ +export const useLockScreenControls = (): UseLockScreenControls => { + const isActiveRef = useRef(false); + const currentMetadataRef = useRef(null); + const audioObjectRef = useRef(null); + + /** + * Create a silent audio player to maintain lock screen presence. + * + * iOS requires an active audio playback to show now-playing info. + * We play a silent loop in the background to maintain this presence. + */ + const createSilentAudioPlayer = useCallback(async (): Promise => { + if (Platform.OS !== 'ios') { + console.log('[LockScreenControls] Silent audio only needed on iOS'); + return; + } + + try { + // Clean up existing audio object + if (audioObjectRef.current) { + await audioObjectRef.current.unloadAsync(); + audioObjectRef.current = null; + } + + // Configure audio session for background playback + await Audio.setAudioModeAsync({ + playsInSilentModeIOS: true, + staysActiveInBackground: true, + shouldDuckAndroid: false, + playThroughEarpieceAndroid: false, + }); + + // Create a silent audio buffer (1 second of silence) + // We'll use a data URI with a minimal WAV file + const silentWavDataUri = + 'data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQAAAAA='; + + const { sound } = await Audio.Sound.createAsync( + { uri: silentWavDataUri }, + { + isLooping: true, + volume: 0.0, // Completely silent + shouldPlay: true, + }, + null, // No status updates needed + false // Don't download + ); + + audioObjectRef.current = sound; + await sound.playAsync(); + + console.log('[LockScreenControls] โ Silent audio player started (iOS lock screen presence)'); + } catch (error) { + console.error('[LockScreenControls] โ Failed to create silent audio player:', error); + // Continue anyway - the main audio streaming should still work + } + }, []); + + /** + * Stop the silent audio player. + */ + const stopSilentAudioPlayer = useCallback(async (): Promise => { + if (audioObjectRef.current) { + try { + await audioObjectRef.current.stopAsync(); + await audioObjectRef.current.unloadAsync(); + audioObjectRef.current = null; + console.log('[LockScreenControls] Silent audio player stopped'); + } catch (error) { + console.error('[LockScreenControls] Error stopping silent audio:', error); + } + } + }, []); + + /** + * Show streaming controls on lock screen. + */ + const showStreamingControls = useCallback(async (metadata: LockScreenMetadata): Promise => { + if (isActiveRef.current) { + console.log('[LockScreenControls] Controls already active, updating metadata'); + await updateMetadata(metadata); + return; + } + + console.log('[LockScreenControls] Showing lock screen controls:', metadata); + currentMetadataRef.current = metadata; + isActiveRef.current = true; + + // Start silent audio player to maintain lock screen presence + await createSilentAudioPlayer(); + + // Note: On iOS, the now-playing info is automatically shown when + // an app has an active audio session and is playing audio. + // The combination of: + // 1. audio background mode (in app.json) + // 2. active audio session (via Audio.setAudioModeAsync) + // 3. playing audio (our silent loop + actual streaming) + // ... triggers the lock screen controls automatically. + // + // For more advanced controls (play/pause buttons, artwork, etc.), + // we would need react-native-music-control or similar native module. + + }, [createSilentAudioPlayer]); + + /** + * Hide lock screen controls. + */ + const hideControls = useCallback(async (): Promise => { + if (!isActiveRef.current) { + console.log('[LockScreenControls] Controls not active'); + return; + } + + console.log('[LockScreenControls] Hiding lock screen controls'); + isActiveRef.current = false; + currentMetadataRef.current = null; + + // Stop silent audio player + await stopSilentAudioPlayer(); + + }, [stopSilentAudioPlayer]); + + /** + * Update lock screen metadata without restarting controls. + */ + const updateMetadata = useCallback(async (metadata: Partial): Promise => { + if (!isActiveRef.current) { + console.warn('[LockScreenControls] Cannot update metadata - controls not active'); + return; + } + + currentMetadataRef.current = { + ...currentMetadataRef.current, + ...metadata, + } as LockScreenMetadata; + + console.log('[LockScreenControls] Metadata updated:', currentMetadataRef.current); + + // With react-native-music-control, we would call: + // MusicControl.updatePlayback({ ... }) + // For now, metadata updates are passive + + }, []); + + /** + * Handle app state changes - clean up if app is terminated. + */ + useEffect(() => { + const handleAppStateChange = (nextAppState: AppStateStatus) => { + if (nextAppState === 'background' && isActiveRef.current) { + console.log('[LockScreenControls] App backgrounded, lock screen controls should be visible'); + } + }; + + const subscription = AppState.addEventListener('change', handleAppStateChange); + + return () => { + subscription.remove(); + // Cleanup on unmount + if (isActiveRef.current) { + stopSilentAudioPlayer(); + } + }; + }, [stopSilentAudioPlayer]); + + return { + showStreamingControls, + hideControls, + updateMetadata, + isActive: isActiveRef.current, + }; +}; + +export default useLockScreenControls; diff --git a/ushadow/mobile/app/hooks/usePhoneAudioRecorder.ts b/ushadow/mobile/app/hooks/usePhoneAudioRecorder.ts index 82f97d0c..b52bfb70 100644 --- a/ushadow/mobile/app/hooks/usePhoneAudioRecorder.ts +++ b/ushadow/mobile/app/hooks/usePhoneAudioRecorder.ts @@ -201,6 +201,23 @@ export const usePhoneAudioRecorder = (): UsePhoneAudioRecorder => { console.log('[PhoneAudioRecorder] Starting audio recording...'); + // Configure iOS audio session for background recording + // This keeps the audio pipeline active when screen locks + try { + await Audio.setAudioModeAsync({ + allowsRecordingIOS: true, + playsInSilentModeIOS: true, + staysActiveInBackground: true, // โญ Critical for background audio + interruptionModeIOS: 2, // DoNotMix + shouldDuckAndroid: false, + playThroughEarpieceAndroid: false, + }); + console.log('[PhoneAudioRecorder] โ Audio session configured for background recording'); + } catch (audioModeError) { + console.warn('[PhoneAudioRecorder] โ ๏ธ Failed to set audio mode:', audioModeError); + // Continue anyway - audio recording might still work + } + // Clear partial chunk buffer from previous session partialChunkBuffer.current = new Uint8Array(0); @@ -255,6 +272,18 @@ export const usePhoneAudioRecorder = (): UsePhoneAudioRecorder => { // Stop recording await AudioRecord.stop(); console.log('[PhoneAudioRecorder] Recording stopped'); + + // Deactivate audio session to allow iOS to suspend app if needed + // (unless other audio is active) + try { + await Audio.setAudioModeAsync({ + allowsRecordingIOS: false, + staysActiveInBackground: false, + }); + console.log('[PhoneAudioRecorder] Audio session deactivated'); + } catch (audioModeError) { + console.warn('[PhoneAudioRecorder] Failed to deactivate audio session:', audioModeError); + } } catch (err) { console.error('[PhoneAudioRecorder] Stop recording error:', err); setStateSafe(setError, 'Failed to stop recording'); diff --git a/ushadow/mobile/app/hooks/useTailscaleDiscovery.ts b/ushadow/mobile/app/hooks/useTailscaleDiscovery.ts index 97c03954..5497ee6d 100644 --- a/ushadow/mobile/app/hooks/useTailscaleDiscovery.ts +++ b/ushadow/mobile/app/hooks/useTailscaleDiscovery.ts @@ -28,7 +28,7 @@ const LEADER_PORT = 8000; // Timeout for probes (ms) const PROBE_TIMEOUT = 3000; // Timeout for fetching leader info (ms) -const LEADER_INFO_TIMEOUT = 5000; +const LEADER_INFO_TIMEOUT = 15000; // Increased from 5s to 15s for Tailscale latency /** * QR code data structure from web dashboard (v2 - minimal) @@ -92,10 +92,13 @@ const probeLeader = async ( ip: string, port: number = LEADER_PORT ): Promise<{ reachable: boolean; hostname?: string }> => { - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), PROBE_TIMEOUT); + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + console.log(`[Discovery] Probe timeout for ${ip}:${port}, aborting...`); + controller.abort(); + }, PROBE_TIMEOUT); + try { // Use HTTPS for Tailscale hostnames (.ts.net), HTTP for IP addresses const protocol = ip.includes('.ts.net') ? 'https' : 'http'; const portSuffix = protocol === 'https' ? '' : `:${port}`; @@ -107,8 +110,6 @@ const probeLeader = async ( signal: controller.signal, }); - clearTimeout(timeoutId); - if (response.ok) { try { const data = await response.json(); @@ -119,8 +120,15 @@ const probeLeader = async ( } return { reachable: false }; } catch (e) { - console.log(`[Discovery] Probe failed for ${ip}:${port}:`, e); + if (e instanceof Error && e.name === 'AbortError') { + console.log(`[Discovery] Probe aborted (timeout after ${PROBE_TIMEOUT}ms) for ${ip}:${port}`); + } else { + console.log(`[Discovery] Probe failed for ${ip}:${port}:`, e); + } return { reachable: false }; + } finally { + // Ensure timeout is always cleared + clearTimeout(timeoutId); } }; @@ -144,10 +152,13 @@ const fetchLeaderInfoFromApi = async ( url = `${protocol}://${urlOrIp}${portSuffix}/api/unodes/leader/info`; } - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), LEADER_INFO_TIMEOUT); + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + console.log('[Discovery] Request timeout, aborting...'); + controller.abort(); + }, LEADER_INFO_TIMEOUT); + try { console.log('[Discovery] Fetching leader info from:', url); const response = await fetch(url, { method: 'GET', @@ -164,8 +175,15 @@ const fetchLeaderInfoFromApi = async ( console.log(`[Discovery] Failed to fetch leader info: ${response.status}`); return null; } catch (e) { - console.log(`[Discovery] Error fetching leader info from ${url}:`, e); + if (e instanceof Error && e.name === 'AbortError') { + console.log(`[Discovery] Request aborted (timeout after ${LEADER_INFO_TIMEOUT}ms)`); + } else { + console.log(`[Discovery] Error fetching leader info from ${url}:`, e); + } return null; + } finally { + // Ensure timeout is always cleared + clearTimeout(timeoutId); } }; diff --git a/ushadow/mobile/app/services/keycloakAuth.ts b/ushadow/mobile/app/services/keycloakAuth.ts index 16c6effb..e5fa25bc 100644 --- a/ushadow/mobile/app/services/keycloakAuth.ts +++ b/ushadow/mobile/app/services/keycloakAuth.ts @@ -25,9 +25,11 @@ WebBrowser.maybeCompleteAuthSession(); export interface KeycloakConfig { enabled: boolean; - public_url: string; // e.g., "http://localhost:8080" + public_url: string; // HTTPS URL for web browsers (e.g., "https://orange.spangled-kettle.ts.net/keycloak") + mobile_url?: string; // Optional direct IP for mobile (e.g., "http://100.105.225.45:8081") realm: string; // e.g., "ushadow" - frontend_client_id: string; // e.g., "ushadow-frontend" + frontend_client_id: string; // Web client (e.g., "ushadow-frontend") + mobile_client_id: string; // Mobile client (e.g., "ushadow-mobile") backend_client_id: string; } @@ -65,12 +67,21 @@ export async function getKeycloakConfigFromUnode( console.log('[Keycloak] Fetching config from general endpoint'); } - const response = await fetch(configUrl, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }); + console.log('[Keycloak] Requesting config from:', configUrl); + + const response = await Promise.race([ + fetch(configUrl, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Request timeout after 10s')), 10000) + ) + ]); + + console.log('[Keycloak] Response status:', response.status); if (!response.ok) { console.warn('[Keycloak] Config endpoint failed:', response.status); @@ -78,6 +89,7 @@ export async function getKeycloakConfigFromUnode( } const data = await response.json(); + console.log('[Keycloak] Response data:', JSON.stringify(data).substring(0, 200)); // Extract Keycloak config from unode info response let config: KeycloakConfig; @@ -88,6 +100,7 @@ export async function getKeycloakConfigFromUnode( public_url: data.keycloak_config.public_url, realm: data.keycloak_config.realm, frontend_client_id: data.keycloak_config.frontend_client_id, + mobile_client_id: data.keycloak_config.mobile_client_id || 'ushadow-mobile', backend_client_id: data.keycloak_config.backend_client_id || '', }; } else { @@ -95,9 +108,15 @@ export async function getKeycloakConfigFromUnode( config = data; } + // Mobile apps should prefer mobile_url (direct IP) over public_url (HTTPS) + // This avoids iOS cookie/redirect issues with ASWebAuthenticationSession + const keycloakUrl = config.mobile_url || config.public_url; + console.log('[Keycloak] Config received:', { enabled: config.enabled, public_url: config.public_url, + mobile_url: config.mobile_url, + using: keycloakUrl, realm: config.realm, source: hostname ? `unode:${hostname}` : 'general', }); @@ -183,7 +202,9 @@ export async function authenticateWithKeycloak( return null; } - const { public_url, realm, frontend_client_id } = config; + const { realm, mobile_client_id } = config; + // Prefer mobile_url (direct IP) for mobile apps to avoid cookie issues + const keycloak_url = config.mobile_url || config.public_url; // 2. Generate PKCE challenge const { codeVerifier, codeChallenge } = await generatePKCE(); @@ -191,8 +212,8 @@ export async function authenticateWithKeycloak( // 3. Set up OAuth2 endpoints const discovery = { - authorizationEndpoint: `${public_url}/realms/${realm}/protocol/openid-connect/auth`, - tokenEndpoint: `${public_url}/realms/${realm}/protocol/openid-connect/token`, + authorizationEndpoint: `${keycloak_url}/realms/${realm}/protocol/openid-connect/auth`, + tokenEndpoint: `${keycloak_url}/realms/${realm}/protocol/openid-connect/token`, }; // 4. Create redirect URI (expo AuthSession handles this) @@ -203,12 +224,16 @@ export async function authenticateWithKeycloak( useProxy: false, // Use native deep link, not Expo proxy }); + console.log('[Keycloak] ========== OAuth Flow Debug =========='); + console.log('[Keycloak] Platform:', Platform.OS); console.log('[Keycloak] Redirect URI:', redirectUri); + console.log('[Keycloak] Using DEDICATED MOBILE CLIENT:', mobile_client_id); + console.log('[Keycloak] โ ๏ธ NOT using web client (frontend_client_id)'); // 5. Build authorization request const authRequestParams: AuthSession.AuthRequestConfig = { - clientId: frontend_client_id, - scopes: ['openid', 'profile', 'email'], + clientId: mobile_client_id, // Use dedicated mobile client (NOT frontend_client_id) + scopes: ['openid', 'profile', 'email', 'offline_access'], redirectUri, usePKCE: false, // We'll handle PKCE manually for more control extraParams: { @@ -221,15 +246,48 @@ export async function authenticateWithKeycloak( // 6. Open browser for user authentication console.log('[Keycloak] Opening browser for authentication...'); + console.log('[Keycloak] Discovery endpoints:', discovery); + console.log('[Keycloak] Auth request config:', { + clientId: mobile_client_id, + redirectUri, + scopes: authRequestParams.scopes, + extraParams: authRequestParams.extraParams, + }); + + // Build the authorization URL explicitly to debug + try { + const authUrl = await authRequest.makeAuthUrlAsync(discovery); + console.log('[Keycloak] Built authorization URL:', authUrl); + + // Parse URL to check redirect_uri parameter + if (authUrl) { + const url = new URL(authUrl); + console.log('[Keycloak] Authorization endpoint:', url.origin + url.pathname); + console.log('[Keycloak] Query parameters:'); + url.searchParams.forEach((value, key) => { + if (key === 'redirect_uri') { + console.log(` ๐ฏ ${key}: ${value}`); + } else if (key === 'code_challenge') { + console.log(` ๐ ${key}: ${value.substring(0, 20)}...`); + } else { + console.log(` โข ${key}: ${value}`); + } + }); + } + } catch (error) { + console.error('[Keycloak] Failed to build auth URL:', error); + } // Configure browser options for better redirect handling const browserOptions: AuthSession.AuthSessionOptions = { - preferEphemeralSession: true, // Use fresh session each time (prevents black screen on re-login) + preferEphemeralSession: false, // MUST be false for Keycloak cookies to persist showInRecents: false, // Don't show in recent apps }; const authResult = await authRequest.promptAsync(discovery, browserOptions); + console.log('[Keycloak] Actual auth URL used:', authRequest.url); + console.log('[Keycloak] Auth result type:', authResult.type); if (authResult.type !== 'success') { @@ -265,6 +323,7 @@ export async function authenticateWithKeycloak( code, code_verifier: codeVerifier, redirect_uri: redirectUri, + client_id: mobile_client_id, }), }); @@ -345,7 +404,9 @@ export async function logoutFromKeycloak( return; } - const { public_url, realm, frontend_client_id } = config; + const { realm, mobile_client_id } = config; + // Prefer mobile_url (direct IP) for mobile apps + const keycloak_url = config.mobile_url || config.public_url; // Create redirect URI for post-logout const redirectUri = AuthSession.makeRedirectUri({ @@ -357,7 +418,7 @@ export async function logoutFromKeycloak( // Build Keycloak logout URL with required parameters // Note: client_id is required when using post_logout_redirect_uri const params = new URLSearchParams({ - client_id: frontend_client_id, + client_id: mobile_client_id, // Use dedicated mobile client post_logout_redirect_uri: redirectUri, }); @@ -368,7 +429,7 @@ export async function logoutFromKeycloak( console.warn('[Keycloak] No id_token provided - logout may fail with parameter error'); } - const logoutUrl = `${public_url}/realms/${realm}/protocol/openid-connect/logout?${params.toString()}`; + const logoutUrl = `${keycloak_url}/realms/${realm}/protocol/openid-connect/logout?${params.toString()}`; console.log('[Keycloak] Logging out from Keycloak session...'); console.log('[Keycloak] Logout URL params:', { hasIdTokenHint: !!idToken }); diff --git a/ushadow/mobile/app/types/network.ts b/ushadow/mobile/app/types/network.ts index 5cc16dfe..65e0bd7d 100644 --- a/ushadow/mobile/app/types/network.ts +++ b/ushadow/mobile/app/types/network.ts @@ -49,6 +49,7 @@ export interface LeaderInfo { // API URLs for specific services ushadow_api_url: string; chronicle_api_url?: string; + keycloak_url?: string; // Keycloak authentication URL (e.g., "http://hostname:8081") // WebSocket streaming URLs ws_pcm_url: string; ws_omi_url: string; diff --git a/ushadow/mobile/app/unode-details.tsx b/ushadow/mobile/app/unode-details.tsx index 20ee1915..db15ee84 100644 --- a/ushadow/mobile/app/unode-details.tsx +++ b/ushadow/mobile/app/unode-details.tsx @@ -339,6 +339,67 @@ export default function UNodeDetailsPage() { setShowScanner(true); }; + // Reconnect to an existing unode (fetch fresh details with Keycloak auth) + const handleReconnect = async (unodeId: string) => { + try { + const node = unodes.find(n => n.id === unodeId); + if (!node) { + Alert.alert('Error', 'UNode not found'); + return; + } + + console.log('[UNodeDetails] Reconnecting to unode:', node.hostname); + + // Fetch fresh unode details from API using Keycloak auth + const token = await getAuthToken(); + if (!token) { + Alert.alert('Not Authenticated', 'Please login with Keycloak first'); + router.replace('/'); + return; + } + + const baseApiUrl = node.apiUrl.replace(/\/api\/.*$/, ''); + const infoUrl = `${baseApiUrl}/api/unodes/${node.hostname}/info`; + + console.log('[UNodeDetails] Fetching fresh details from:', infoUrl); + + const response = await fetch(infoUrl, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch unode info: ${response.status}`); + } + + const info = await response.json(); + console.log('[UNodeDetails] Fresh unode info:', info); + + // Update saved unode with fresh connection details + await saveUnode({ + id: node.id, + name: node.name, + hostname: info.hostname || node.hostname, + apiUrl: baseApiUrl, + chronicleApiUrl: info.chronicle_api_url, + streamUrl: info.ws_pcm_url, + tailscaleIp: info.tailscale_ip || node.tailscaleIp, + }); + + console.log('[UNodeDetails] โ Reconnected successfully'); + await getUnodes(); + + Alert.alert('Success', `Reconnected to ${node.name}`); + } catch (error) { + console.error('[UNodeDetails] Reconnect failed:', error); + Alert.alert( + 'Reconnect Failed', + error instanceof Error ? error.message : 'Could not reconnect to unode' + ); + } + }; + // Handle QR scan result for rescan const handleQRScan = async (data: UshadowConnectionData) => { console.log('[UNodeDetails] QR scan successful, data:', data); @@ -537,14 +598,25 @@ export default function UNodeDetailsPage() { Remove - handleRescanNode(selectedNode)} - testID="rescan-qr-button" - > - - Rescan - + {authToken ? ( + handleReconnect(selectedNode.id)} + testID="reconnect-button" + > + + Reconnect + + ) : ( + handleRescanNode(selectedNode)} + testID="rescan-qr-button" + > + + Rescan QR + + )} {/* Expanded Content - Advanced details */} @@ -1240,6 +1312,20 @@ const styles = StyleSheet.create({ color: '#fff', fontWeight: '600', }, + reconnectButton: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.success[500], + paddingVertical: spacing.sm, + paddingHorizontal: spacing.lg, + borderRadius: borderRadius.md, + gap: spacing.xs, + }, + reconnectButtonText: { + fontSize: fontSize.base, + color: '#fff', + fontWeight: '600', + }, // Other nodes list otherNodesList: { gap: spacing.sm,