diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index b5536eb34..b955e5014 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -22,6 +22,7 @@ on: permissions: contents: write + id-token: write jobs: build: @@ -58,6 +59,22 @@ jobs: fi echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" + - name: Detect Windows signing eligibility + id: sign + shell: bash + run: | + # Sign Windows builds only on production v* tags (not beta-v*, not workflow_dispatch). + # This matches the single OIDC federated credential configured in Entra ID. + if [ "${{ matrix.os }}" = "windows-latest" ] \ + && [ "${{ github.event_name }}" = "push" ] \ + && [[ "$GITHUB_REF" == refs/tags/v* ]]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + echo "Windows signing: ENABLED (v* tag on windows-latest)" + else + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "Windows signing: skipped" + fi + - name: Setup pnpm uses: pnpm/action-setup@v5 @@ -98,7 +115,31 @@ jobs: - name: Package & Publish shell: bash - run: pnpm exec electron-builder ${{ matrix.platform }} --config electron-builder.yml --publish ${{ inputs.publish || 'always' }} -c.extraMetadata.version=${{ steps.version.outputs.VERSION }} + run: | + CMD=(pnpm exec electron-builder ${{ matrix.platform }} \ + --config electron-builder.yml \ + --publish "${{ inputs.publish || 'always' }}" \ + -c.extraMetadata.version="${{ steps.version.outputs.VERSION }}") + + if [ "${{ steps.sign.outputs.enabled }}" = "true" ]; then + CMD+=(-c.win.azureSignOptions.publisherName="$WINDOWS_PUBLISHER_NAME") + CMD+=(-c.win.azureSignOptions.endpoint="$AZURE_CODESIGN_ENDPOINT") + CMD+=(-c.win.azureSignOptions.codeSigningAccountName="$AZURE_CODESIGN_ACCOUNT") + CMD+=(-c.win.azureSignOptions.certificateProfileName="$AZURE_CODESIGN_PROFILE") + fi + + "${CMD[@]}" working-directory: surfsense_desktop env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + WINDOWS_PUBLISHER_NAME: ${{ vars.WINDOWS_PUBLISHER_NAME }} + AZURE_CODESIGN_ENDPOINT: ${{ vars.AZURE_CODESIGN_ENDPOINT }} + AZURE_CODESIGN_ACCOUNT: ${{ vars.AZURE_CODESIGN_ACCOUNT }} + AZURE_CODESIGN_PROFILE: ${{ vars.AZURE_CODESIGN_PROFILE }} + # Service principal credentials for Azure.Identity EnvironmentCredential used by the + # TrustedSigning PowerShell module. Only populated when signing is enabled. + # electron-builder 26 does not yet support OIDC federated tokens for Azure signing, + # so we fall back to client-secret auth. Rotate AZURE_CLIENT_SECRET before expiry. + AZURE_TENANT_ID: ${{ steps.sign.outputs.enabled == 'true' && secrets.AZURE_TENANT_ID || '' }} + AZURE_CLIENT_ID: ${{ steps.sign.outputs.enabled == 'true' && secrets.AZURE_CLIENT_ID || '' }} + AZURE_CLIENT_SECRET: ${{ steps.sign.outputs.enabled == 'true' && secrets.AZURE_CLIENT_SECRET || '' }} diff --git a/VERSION b/VERSION index e3b86dd9c..44517d518 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.16 +0.0.19 diff --git a/docker/.env.example b/docker/.env.example index a975ed8e7..95de0cf85 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -71,6 +71,7 @@ EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2 # BACKEND_URL=https://api.yourdomain.com # NEXT_PUBLIC_FASTAPI_BACKEND_URL=https://api.yourdomain.com # NEXT_PUBLIC_ZERO_CACHE_URL=https://zero.yourdomain.com +# FASTAPI_BACKEND_INTERNAL_URL=http://backend:8000 # ------------------------------------------------------------------------------ # Zero-cache (real-time sync) diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index c7922e3ef..bbe758d4f 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -77,6 +77,8 @@ services: - shared_temp:/shared_tmp env_file: - ../surfsense_backend/.env + extra_hosts: + - "host.docker.internal:host-gateway" environment: - DATABASE_URL=${DATABASE_URL:-postgresql+asyncpg://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}} - CELERY_BROKER_URL=${REDIS_URL:-redis://redis:6379/0} @@ -118,6 +120,8 @@ services: - shared_temp:/shared_tmp env_file: - ../surfsense_backend/.env + extra_hosts: + - "host.docker.internal:host-gateway" environment: - DATABASE_URL=${DATABASE_URL:-postgresql+asyncpg://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}} - CELERY_BROKER_URL=${REDIS_URL:-redis://redis:6379/0} diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 549190947..10cace249 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -60,6 +60,8 @@ services: - shared_temp:/shared_tmp env_file: - .env + extra_hosts: + - "host.docker.internal:host-gateway" environment: DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://${DB_USER:-surfsense}:${DB_PASSWORD:-surfsense}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}} CELERY_BROKER_URL: ${REDIS_URL:-redis://redis:6379/0} @@ -100,6 +102,8 @@ services: - shared_temp:/shared_tmp env_file: - .env + extra_hosts: + - "host.docker.internal:host-gateway" environment: DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://${DB_USER:-surfsense}:${DB_PASSWORD:-surfsense}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}} CELERY_BROKER_URL: ${REDIS_URL:-redis://redis:6379/0} @@ -198,6 +202,7 @@ services: NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE: ${AUTH_TYPE:-LOCAL} NEXT_PUBLIC_ETL_SERVICE: ${ETL_SERVICE:-DOCLING} NEXT_PUBLIC_DEPLOYMENT_MODE: ${DEPLOYMENT_MODE:-self-hosted} + FASTAPI_BACKEND_INTERNAL_URL: ${FASTAPI_BACKEND_INTERNAL_URL:-http://backend:8000} labels: - "com.centurylinklabs.watchtower.enable=true" depends_on: diff --git a/surfsense_backend/app/agents/new_chat/chat_deepagent.py b/surfsense_backend/app/agents/new_chat/chat_deepagent.py index 17334d66a..4b204ffa9 100644 --- a/surfsense_backend/app/agents/new_chat/chat_deepagent.py +++ b/surfsense_backend/app/agents/new_chat/chat_deepagent.py @@ -24,7 +24,6 @@ from deepagents.backends import StateBackend from deepagents.graph import BASE_AGENT_PROMPT from deepagents.middleware.patch_tool_calls import PatchToolCallsMiddleware from deepagents.middleware.subagents import GENERAL_PURPOSE_SUBAGENT -from deepagents.middleware.summarization import create_summarization_middleware from langchain.agents import create_agent from langchain.agents.middleware import TodoListMiddleware from langchain_anthropic.middleware import AnthropicPromptCachingMiddleware @@ -41,6 +40,9 @@ from app.agents.new_chat.middleware import ( MemoryInjectionMiddleware, SurfSenseFilesystemMiddleware, ) +from app.agents.new_chat.middleware.safe_summarization import ( + create_safe_summarization_middleware, +) from app.agents.new_chat.system_prompt import ( build_configurable_system_prompt, build_surfsense_system_prompt, @@ -347,7 +349,7 @@ async def create_surfsense_deep_agent( created_by_id=user_id, thread_id=thread_id, ), - create_summarization_middleware(llm, StateBackend), + create_safe_summarization_middleware(llm, StateBackend), PatchToolCallsMiddleware(), AnthropicPromptCachingMiddleware(unsupported_model_behavior="ignore"), ] @@ -377,7 +379,7 @@ async def create_surfsense_deep_agent( thread_id=thread_id, ), SubAgentMiddleware(backend=StateBackend, subagents=[general_purpose_spec]), - create_summarization_middleware(llm, StateBackend), + create_safe_summarization_middleware(llm, StateBackend), PatchToolCallsMiddleware(), DedupHITLToolCallsMiddleware(agent_tools=tools), AnthropicPromptCachingMiddleware(unsupported_model_behavior="ignore"), diff --git a/surfsense_backend/app/agents/new_chat/middleware/safe_summarization.py b/surfsense_backend/app/agents/new_chat/middleware/safe_summarization.py new file mode 100644 index 000000000..4ddcf334f --- /dev/null +++ b/surfsense_backend/app/agents/new_chat/middleware/safe_summarization.py @@ -0,0 +1,123 @@ +"""Safe wrapper around deepagents' SummarizationMiddleware. + +Upstream issue +-------------- +`deepagents.middleware.summarization.SummarizationMiddleware._aoffload_to_backend` +(and its sync counterpart) call +``get_buffer_string(filtered_messages)`` before writing the evicted history +to the backend file. In recent ``langchain-core`` versions, ``get_buffer_string`` +accesses ``m.text`` which iterates ``self.content`` — this raises +``TypeError: 'NoneType' object is not iterable`` whenever an ``AIMessage`` +has ``content=None`` (common when a model returns *only* tool_calls, seen +frequently with Azure OpenAI ``gpt-5.x`` responses streamed through +LiteLLM). + +The exception aborts the whole agent turn, so the user just sees "Error during +chat" with no assistant response. + +Fix +--- +We subclass ``SummarizationMiddleware`` and override +``_filter_summary_messages`` — the only call site that feeds messages into +``get_buffer_string`` — to return *copies* of messages whose ``content`` is +``None`` with ``content=""``. The originals flowing through the rest of the +agent state are untouched. + +We also expose a drop-in ``create_safe_summarization_middleware`` factory +that mirrors ``deepagents.middleware.summarization.create_summarization_middleware`` +but instantiates our safe subclass. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from deepagents.middleware.summarization import ( + SummarizationMiddleware, + compute_summarization_defaults, +) + +if TYPE_CHECKING: + from deepagents.backends.protocol import BACKEND_TYPES + from langchain_core.language_models import BaseChatModel + from langchain_core.messages import AnyMessage + +logger = logging.getLogger(__name__) + + +def _sanitize_message_content(msg: AnyMessage) -> AnyMessage: + """Return ``msg`` with ``content`` coerced to a non-``None`` value. + + ``get_buffer_string`` reads ``m.text`` which iterates ``self.content``; + when a provider streams back an ``AIMessage`` with only tool_calls and + no text, ``content`` can be ``None`` and the iteration explodes. We + replace ``None`` with an empty string so downstream consumers that only + care about text see an empty body. + + The original message is left untouched — we return a copy via + pydantic's ``model_copy`` when available, otherwise we fall back to + re-setting the attribute on a shallow copy. + """ + + if getattr(msg, "content", "not-missing") is not None: + return msg + + try: + return msg.model_copy(update={"content": ""}) + except AttributeError: + import copy + + new_msg = copy.copy(msg) + try: + new_msg.content = "" + except Exception: # pragma: no cover - defensive + logger.debug( + "Could not sanitize content=None on message of type %s", + type(msg).__name__, + ) + return msg + return new_msg + + +class SafeSummarizationMiddleware(SummarizationMiddleware): + """`SummarizationMiddleware` that tolerates messages with ``content=None``. + + Only ``_filter_summary_messages`` is overridden — this is the single + helper invoked by both the sync and async offload paths immediately + before ``get_buffer_string``. Normalising here means we get coverage + for both without having to copy the (long, rapidly-changing) offload + implementations from upstream. + """ + + def _filter_summary_messages(self, messages: list[AnyMessage]) -> list[AnyMessage]: + filtered = super()._filter_summary_messages(messages) + return [_sanitize_message_content(m) for m in filtered] + + +def create_safe_summarization_middleware( + model: BaseChatModel, + backend: BACKEND_TYPES, +) -> SafeSummarizationMiddleware: + """Drop-in replacement for ``create_summarization_middleware``. + + Mirrors the defaults computed by ``deepagents`` but returns our + ``SafeSummarizationMiddleware`` subclass so the + ``content=None`` crash in ``get_buffer_string`` is avoided. + """ + + defaults = compute_summarization_defaults(model) + return SafeSummarizationMiddleware( + model=model, + backend=backend, + trigger=defaults["trigger"], + keep=defaults["keep"], + trim_tokens_to_summarize=None, + truncate_args_settings=defaults["truncate_args_settings"], + ) + + +__all__ = [ + "SafeSummarizationMiddleware", + "create_safe_summarization_middleware", +] diff --git a/surfsense_backend/app/app.py b/surfsense_backend/app/app.py index 95aa1bf5d..a1795853a 100644 --- a/surfsense_backend/app/app.py +++ b/surfsense_backend/app/app.py @@ -114,8 +114,19 @@ def _surfsense_error_handler(request: Request, exc: SurfSenseError) -> JSONRespo def _http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse: - """Wrap FastAPI/Starlette HTTPExceptions into the standard envelope.""" + """Wrap FastAPI/Starlette HTTPExceptions into the standard envelope. + + 5xx sanitization policy: + - 500 responses are sanitized (replaced with ``GENERIC_5XX_MESSAGE``) because + they usually wrap raw internal errors and may leak sensitive info. + - Other 5xx statuses (501, 502, 503, 504, ...) are raised explicitly by + route code to communicate a specific, user-safe operational state + (e.g. 503 "Page purchases are temporarily unavailable."). Those details + are preserved so the frontend can render them, but the error is still + logged server-side. + """ rid = _get_request_id(request) + should_sanitize = exc.status_code == 500 # Structured dict details (e.g. {"code": "CAPTCHA_REQUIRED", "message": "..."}) # are preserved so the frontend can parse them. @@ -130,6 +141,7 @@ def _http_exception_handler(request: Request, exc: HTTPException) -> JSONRespons exc.status_code, message, ) + if should_sanitize: message = GENERIC_5XX_MESSAGE err_code = "INTERNAL_ERROR" body = { @@ -158,6 +170,7 @@ def _http_exception_handler(request: Request, exc: HTTPException) -> JSONRespons exc.status_code, detail, ) + if should_sanitize: detail = GENERIC_5XX_MESSAGE code = _status_to_code(exc.status_code, detail) return _build_error_response(exc.status_code, detail, code=code, request_id=rid) diff --git a/surfsense_backend/app/services/llm_router_service.py b/surfsense_backend/app/services/llm_router_service.py index 35dfdd44e..c9eeff01b 100644 --- a/surfsense_backend/app/services/llm_router_service.py +++ b/surfsense_backend/app/services/llm_router_service.py @@ -133,6 +133,44 @@ PROVIDER_MAP = { } +# Default ``api_base`` per LiteLLM provider prefix. Used as a safety net when +# a global LLM config does *not* specify ``api_base``: without this, LiteLLM +# happily picks up provider-agnostic env vars (e.g. ``AZURE_API_BASE``, +# ``OPENAI_API_BASE``) and routes, say, an ``openrouter/anthropic/claude-3-haiku`` +# request to an Azure endpoint, which then 404s with ``Resource not found``. +# Only providers with a well-known, stable public base URL are listed here — +# self-hosted / BYO-endpoint providers (ollama, custom, bedrock, vertex_ai, +# huggingface, databricks, cloudflare, replicate) are intentionally omitted +# so their existing config-driven behaviour is preserved. +PROVIDER_DEFAULT_API_BASE = { + "openrouter": "https://openrouter.ai/api/v1", + "groq": "https://api.groq.com/openai/v1", + "mistral": "https://api.mistral.ai/v1", + "perplexity": "https://api.perplexity.ai", + "xai": "https://api.x.ai/v1", + "cerebras": "https://api.cerebras.ai/v1", + "deepinfra": "https://api.deepinfra.com/v1/openai", + "fireworks_ai": "https://api.fireworks.ai/inference/v1", + "together_ai": "https://api.together.xyz/v1", + "anyscale": "https://api.endpoints.anyscale.com/v1", + "cometapi": "https://api.cometapi.com/v1", + "sambanova": "https://api.sambanova.ai/v1", +} + + +# Canonical provider → base URL when a config uses a generic ``openai``-style +# prefix but the ``provider`` field tells us which API it really is +# (e.g. DeepSeek/Alibaba/Moonshot/Zhipu/MiniMax all use ``openai`` compat but +# each has its own base URL). +PROVIDER_KEY_DEFAULT_API_BASE = { + "DEEPSEEK": "https://api.deepseek.com/v1", + "ALIBABA_QWEN": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", + "MOONSHOT": "https://api.moonshot.ai/v1", + "ZHIPU": "https://open.bigmodel.cn/api/paas/v4", + "MINIMAX": "https://api.minimax.io/v1", +} + + class LLMRouterService: """ Singleton service for managing LiteLLM Router. @@ -224,6 +262,16 @@ class LLMRouterService: # hits ContextWindowExceededError. full_model_list, ctx_fallbacks = cls._build_context_fallback_groups(model_list) + # Build a general-purpose fallback list so NotFound/timeout/rate-limit + # style failures on one deployment don't bubble up as hard errors — + # the router retries with a sibling deployment in ``auto-large``. + # ``auto-large`` is the large-context subset of ``auto``; if it is + # empty we fall back to ``auto`` itself so the router at least picks a + # different deployment in the same group. + fallbacks: list[dict[str, list[str]]] | None = None + if ctx_fallbacks: + fallbacks = [{"auto": ["auto-large"]}] + try: router_kwargs: dict[str, Any] = { "model_list": full_model_list, @@ -237,15 +285,18 @@ class LLMRouterService: } if ctx_fallbacks: router_kwargs["context_window_fallbacks"] = ctx_fallbacks + if fallbacks: + router_kwargs["fallbacks"] = fallbacks instance._router = Router(**router_kwargs) instance._initialized = True logger.info( "LLM Router initialized with %d deployments, " - "strategy: %s, context_window_fallbacks: %s", + "strategy: %s, context_window_fallbacks: %s, fallbacks: %s", len(model_list), final_settings.get("routing_strategy"), ctx_fallbacks or "none", + fallbacks or "none", ) except Exception as e: logger.error(f"Failed to initialize LLM Router: {e}") @@ -348,10 +399,11 @@ class LLMRouterService: return None # Build model string + provider = config.get("provider", "").upper() if config.get("custom_provider"): - model_string = f"{config['custom_provider']}/{config['model_name']}" + provider_prefix = config["custom_provider"] + model_string = f"{provider_prefix}/{config['model_name']}" else: - provider = config.get("provider", "").upper() provider_prefix = PROVIDER_MAP.get(provider, provider.lower()) model_string = f"{provider_prefix}/{config['model_name']}" @@ -361,9 +413,19 @@ class LLMRouterService: "api_key": config.get("api_key"), } - # Add optional api_base - if config.get("api_base"): - litellm_params["api_base"] = config["api_base"] + # Resolve ``api_base``. Config value wins; otherwise apply a + # provider-aware default so the deployment does not silently + # inherit unrelated env vars (e.g. ``AZURE_API_BASE``) and route + # requests to the wrong endpoint. See ``PROVIDER_DEFAULT_API_BASE`` + # docstring for the motivating bug (OpenRouter models 404-ing + # against an Azure endpoint). + api_base = config.get("api_base") + if not api_base: + api_base = PROVIDER_KEY_DEFAULT_API_BASE.get(provider) + if not api_base: + api_base = PROVIDER_DEFAULT_API_BASE.get(provider_prefix) + if api_base: + litellm_params["api_base"] = api_base # Add any additional litellm parameters if config.get("litellm_params"): diff --git a/surfsense_backend/app/services/llm_service.py b/surfsense_backend/app/services/llm_service.py index 1ad170d72..79a72dd25 100644 --- a/surfsense_backend/app/services/llm_service.py +++ b/surfsense_backend/app/services/llm_service.py @@ -1,3 +1,4 @@ +import asyncio import logging import litellm @@ -32,6 +33,39 @@ litellm.callbacks = [token_tracker] logger = logging.getLogger(__name__) +# Providers that require an interactive OAuth / device-flow login before +# issuing any completion. LiteLLM implements these with blocking sync polling +# (requests + time.sleep), which would freeze the FastAPI event loop if +# invoked from validation. They are never usable from a headless backend, +# so we reject them at the edge. +_INTERACTIVE_AUTH_PROVIDERS: frozenset[str] = frozenset( + { + "github_copilot", + "github-copilot", + "githubcopilot", + "copilot", + } +) + +# Hard upper bound for a single validation call. Must exceed the ChatLiteLLM +# request timeout (30s) by a small margin so a well-behaved provider never +# trips the watchdog, while any pathological/blocking provider is killed. +_VALIDATION_TIMEOUT_SECONDS: float = 35.0 + + +def _is_interactive_auth_provider( + provider: str | None, custom_provider: str | None +) -> bool: + """Return True if the given provider triggers interactive OAuth in LiteLLM.""" + for raw in (custom_provider, provider): + if not raw: + continue + normalized = raw.strip().lower().replace(" ", "_") + if normalized in _INTERACTIVE_AUTH_PROVIDERS: + return True + return False + + class LLMRole: AGENT = "agent" # For agent/chat operations DOCUMENT_SUMMARY = "document_summary" # For document summarization @@ -93,6 +127,25 @@ async def validate_llm_config( - is_valid: True if config works, False otherwise - error_message: Empty string if valid, error description if invalid """ + # Reject providers that require interactive OAuth/device-flow auth. + # LiteLLM's github_copilot provider (and similar) uses a blocking sync + # Authenticator that polls GitHub for up to several minutes and prints a + # device code to stdout. Running it on the FastAPI event loop will freeze + # the entire backend, so we refuse them up front. + if _is_interactive_auth_provider(provider, custom_provider): + msg = ( + "Provider requires interactive OAuth/device-flow authentication " + "(e.g. github_copilot) and cannot be used in a hosted backend. " + "Please choose a provider that authenticates via API key." + ) + logger.warning( + "Rejected LLM config validation for interactive-auth provider " + "(provider=%r, custom_provider=%r)", + provider, + custom_provider, + ) + return False, msg + try: # Build the model string for litellm if custom_provider: @@ -153,9 +206,30 @@ async def validate_llm_config( llm = SanitizedChatLiteLLM(**litellm_kwargs) - # Make a simple test call + # Run the test call in a worker thread with a hard timeout. Some + # LiteLLM providers have synchronous blocking code paths (e.g. OAuth + # authenticators that call time.sleep and requests.post) that would + # otherwise freeze the asyncio event loop. Offloading to a thread and + # bounding the wait keeps the server responsive even if a provider + # misbehaves. test_message = HumanMessage(content="Hello") - response = await llm.ainvoke([test_message]) + try: + response = await asyncio.wait_for( + asyncio.to_thread(llm.invoke, [test_message]), + timeout=_VALIDATION_TIMEOUT_SECONDS, + ) + except TimeoutError: + logger.warning( + "LLM config validation timed out after %ss for model: %s", + _VALIDATION_TIMEOUT_SECONDS, + model_string, + ) + return ( + False, + f"Validation timed out after {int(_VALIDATION_TIMEOUT_SECONDS)}s. " + "The provider is unreachable or requires interactive " + "authentication that is not supported by the backend.", + ) # If we got here without exception, the config is valid if response and response.content: diff --git a/surfsense_backend/pyproject.toml b/surfsense_backend/pyproject.toml index 3a8c24a9a..131627386 100644 --- a/surfsense_backend/pyproject.toml +++ b/surfsense_backend/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "surf-new-backend" -version = "0.0.16" +version = "0.0.19" description = "SurfSense Backend" requires-python = ">=3.12" dependencies = [ @@ -74,7 +74,7 @@ dependencies = [ "deepagents>=0.4.12", "stripe>=15.0.0", "azure-ai-documentintelligence>=1.0.2", - "litellm>=1.83.0", + "litellm>=1.83.4", "langchain-litellm>=0.6.4", ] diff --git a/surfsense_backend/tests/unit/test_error_contract.py b/surfsense_backend/tests/unit/test_error_contract.py index 8a1605dd1..ec8021290 100644 --- a/surfsense_backend/tests/unit/test_error_contract.py +++ b/surfsense_backend/tests/unit/test_error_contract.py @@ -70,6 +70,20 @@ def _make_test_app(): async def raise_http_500(): raise HTTPException(status_code=500, detail="secret db password leaked") + @app.get("/http-503") + async def raise_http_503(): + raise HTTPException( + status_code=503, + detail="Page purchases are temporarily unavailable.", + ) + + @app.get("/http-502") + async def raise_http_502(): + raise HTTPException( + status_code=502, + detail="Unable to create Stripe checkout session.", + ) + @app.get("/surfsense-connector") async def raise_connector(): raise ConnectorError("GitHub API returned 401") @@ -184,6 +198,18 @@ class TestHTTPExceptionHandler: assert body["error"]["message"] == GENERIC_5XX_MESSAGE assert body["error"]["code"] == "INTERNAL_ERROR" + def test_503_preserves_detail(self, client): + # Intentional 503s (e.g. feature flag off) must surface the developer + # message so the frontend can render actionable copy. + body = _assert_envelope(client.get("/http-503"), 503) + assert body["error"]["message"] == "Page purchases are temporarily unavailable." + assert body["error"]["message"] != GENERIC_5XX_MESSAGE + + def test_502_preserves_detail(self, client): + body = _assert_envelope(client.get("/http-502"), 502) + assert body["error"]["message"] == "Unable to create Stripe checkout session." + assert body["error"]["message"] != GENERIC_5XX_MESSAGE + # --------------------------------------------------------------------------- # SurfSenseError hierarchy diff --git a/surfsense_backend/uv.lock b/surfsense_backend/uv.lock index 2c846c60e..209c42a9c 100644 --- a/surfsense_backend/uv.lock +++ b/surfsense_backend/uv.lock @@ -7947,7 +7947,7 @@ wheels = [ [[package]] name = "surf-new-backend" -version = "0.0.16" +version = "0.0.19" source = { editable = "." } dependencies = [ { name = "alembic" }, @@ -8070,7 +8070,7 @@ requires-dist = [ { name = "langgraph", specifier = ">=1.1.3" }, { name = "langgraph-checkpoint-postgres", specifier = ">=3.0.2" }, { name = "linkup-sdk", specifier = ">=0.2.4" }, - { name = "litellm", specifier = ">=1.83.0" }, + { name = "litellm", specifier = ">=1.83.4" }, { name = "llama-cloud-services", specifier = ">=0.6.25" }, { name = "markdown", specifier = ">=3.7" }, { name = "markdownify", specifier = ">=0.14.1" }, diff --git a/surfsense_browser_extension/package.json b/surfsense_browser_extension/package.json index 487025a8d..146dd177e 100644 --- a/surfsense_browser_extension/package.json +++ b/surfsense_browser_extension/package.json @@ -1,7 +1,7 @@ { "name": "surfsense_browser_extension", "displayName": "Surfsense Browser Extension", - "version": "0.0.16", + "version": "0.0.19", "description": "Extension to collect Browsing History for SurfSense.", "author": "https://github.com/MODSetter", "engines": { diff --git a/surfsense_desktop/package.json b/surfsense_desktop/package.json index f099d732b..638fd3ffc 100644 --- a/surfsense_desktop/package.json +++ b/surfsense_desktop/package.json @@ -1,6 +1,6 @@ { "name": "surfsense-desktop", - "version": "0.0.16", + "version": "0.0.19", "description": "SurfSense Desktop App", "main": "dist/main.js", "scripts": { diff --git a/surfsense_desktop/src/ipc/channels.ts b/surfsense_desktop/src/ipc/channels.ts index 61213eb46..6731ecbfa 100644 --- a/surfsense_desktop/src/ipc/channels.ts +++ b/surfsense_desktop/src/ipc/channels.ts @@ -43,4 +43,12 @@ export const IPC_CHANNELS = { // Active search space GET_ACTIVE_SEARCH_SPACE: 'search-space:get-active', SET_ACTIVE_SEARCH_SPACE: 'search-space:set-active', + // Launch on system startup + GET_AUTO_LAUNCH: 'auto-launch:get', + SET_AUTO_LAUNCH: 'auto-launch:set', + // Analytics (PostHog) bridge: renderer <-> main + ANALYTICS_IDENTIFY: 'analytics:identify', + ANALYTICS_RESET: 'analytics:reset', + ANALYTICS_CAPTURE: 'analytics:capture', + ANALYTICS_GET_CONTEXT: 'analytics:get-context', } as const; diff --git a/surfsense_desktop/src/ipc/handlers.ts b/surfsense_desktop/src/ipc/handlers.ts index afb2ba038..05c327436 100644 --- a/surfsense_desktop/src/ipc/handlers.ts +++ b/surfsense_desktop/src/ipc/handlers.ts @@ -24,10 +24,18 @@ import { type WatchedFolderConfig, } from '../modules/folder-watcher'; import { getShortcuts, setShortcuts, type ShortcutConfig } from '../modules/shortcuts'; +import { getAutoLaunchState, setAutoLaunch } from '../modules/auto-launch'; import { getActiveSearchSpaceId, setActiveSearchSpaceId } from '../modules/active-search-space'; import { reregisterQuickAsk } from '../modules/quick-ask'; import { reregisterAutocomplete } from '../modules/autocomplete'; import { reregisterGeneralAssist } from '../modules/tray'; +import { + getDistinctId, + getMachineId, + identifyUser as analyticsIdentify, + resetUser as analyticsReset, + trackEvent, +} from '../modules/analytics'; let authTokens: { bearer: string; refresh: string } | null = null; @@ -120,6 +128,21 @@ export function registerIpcHandlers(): void { ipcMain.handle(IPC_CHANNELS.GET_SHORTCUTS, () => getShortcuts()); + ipcMain.handle(IPC_CHANNELS.GET_AUTO_LAUNCH, () => getAutoLaunchState()); + + ipcMain.handle( + IPC_CHANNELS.SET_AUTO_LAUNCH, + async (_event, payload: { enabled: boolean; openAsHidden?: boolean }) => { + const next = await setAutoLaunch(payload.enabled, payload.openAsHidden); + trackEvent('desktop_auto_launch_toggled', { + enabled: next.enabled, + open_as_hidden: next.openAsHidden, + supported: next.supported, + }); + return next; + }, + ); + ipcMain.handle(IPC_CHANNELS.GET_ACTIVE_SEARCH_SPACE, () => getActiveSearchSpaceId()); ipcMain.handle(IPC_CHANNELS.SET_ACTIVE_SEARCH_SPACE, (_event, id: string) => @@ -131,6 +154,41 @@ export function registerIpcHandlers(): void { if (config.generalAssist) await reregisterGeneralAssist(); if (config.quickAsk) await reregisterQuickAsk(); if (config.autocomplete) await reregisterAutocomplete(); + trackEvent('desktop_shortcut_updated', { + keys: Object.keys(config), + }); return updated; }); + + // Analytics bridge — the renderer (web UI) hands the logged-in user down + // to the main process so desktop-only events are attributed to the same + // PostHog person, not just an anonymous machine ID. + ipcMain.handle( + IPC_CHANNELS.ANALYTICS_IDENTIFY, + (_event, payload: { userId: string; properties?: Record }) => { + if (!payload?.userId) return; + analyticsIdentify(String(payload.userId), payload.properties); + } + ); + + ipcMain.handle(IPC_CHANNELS.ANALYTICS_RESET, () => { + analyticsReset(); + }); + + ipcMain.handle( + IPC_CHANNELS.ANALYTICS_CAPTURE, + (_event, payload: { event: string; properties?: Record }) => { + if (!payload?.event) return; + trackEvent(payload.event, payload.properties); + } + ); + + ipcMain.handle(IPC_CHANNELS.ANALYTICS_GET_CONTEXT, () => { + return { + distinctId: getDistinctId(), + machineId: getMachineId(), + appVersion: app.getVersion(), + platform: process.platform, + }; + }); } diff --git a/surfsense_desktop/src/main.ts b/surfsense_desktop/src/main.ts index 231553f9a..399144bed 100644 --- a/surfsense_desktop/src/main.ts +++ b/surfsense_desktop/src/main.ts @@ -1,10 +1,9 @@ -import { app, BrowserWindow } from 'electron'; +import { app } from 'electron'; -let isQuitting = false; import { registerGlobalErrorHandlers, showErrorDialog } from './modules/errors'; import { startNextServer } from './modules/server'; -import { createMainWindow, getMainWindow } from './modules/window'; -import { setupDeepLinks, handlePendingDeepLink } from './modules/deep-links'; +import { createMainWindow, getMainWindow, markQuitting } from './modules/window'; +import { setupDeepLinks, handlePendingDeepLink, hasPendingDeepLink } from './modules/deep-links'; import { setupAutoUpdater } from './modules/auto-updater'; import { setupMenu } from './modules/menu'; import { registerQuickAsk, unregisterQuickAsk } from './modules/quick-ask'; @@ -13,6 +12,12 @@ import { registerFolderWatcher, unregisterFolderWatcher } from './modules/folder import { registerIpcHandlers } from './ipc/handlers'; import { createTray, destroyTray } from './modules/tray'; import { initAnalytics, shutdownAnalytics, trackEvent } from './modules/analytics'; +import { + applyAutoLaunchDefaults, + shouldStartHidden, + syncAutoLaunchOnStartup, + wasLaunchedAtLogin, +} from './modules/auto-launch'; registerGlobalErrorHandlers(); @@ -24,7 +29,12 @@ registerIpcHandlers(); app.whenReady().then(async () => { initAnalytics(); - trackEvent('desktop_app_launched'); + const launchedAtLogin = wasLaunchedAtLogin(); + const startedHidden = shouldStartHidden(); + trackEvent('desktop_app_launched', { + launched_at_login: launchedAtLogin, + started_hidden: startedHidden, + }); setupMenu(); try { await startNextServer(); @@ -35,16 +45,19 @@ app.whenReady().then(async () => { } await createTray(); + const defaultsApplied = await applyAutoLaunchDefaults(); + if (defaultsApplied) { + trackEvent('desktop_auto_launch_defaulted_on'); + } + await syncAutoLaunchOnStartup(); - const win = createMainWindow('/dashboard'); - - // Minimize to tray instead of closing the app - win.on('close', (e) => { - if (!isQuitting) { - e.preventDefault(); - win.hide(); - } - }); + // When started by the OS at login we stay quietly in the tray. The window + // is created lazily on first user interaction (tray click / activate). + // Exception: if a deep link is queued, the user explicitly asked to land + // in the app — don't swallow it. + if (!startedHidden || hasPendingDeepLink()) { + createMainWindow('/dashboard'); + } await registerQuickAsk(); await registerAutocomplete(); @@ -55,6 +68,7 @@ app.whenReady().then(async () => { app.on('activate', () => { const mw = getMainWindow(); + trackEvent('desktop_app_activated'); if (!mw || mw.isDestroyed()) { createMainWindow('/dashboard'); } else { @@ -70,7 +84,8 @@ app.on('window-all-closed', () => { }); app.on('before-quit', () => { - isQuitting = true; + markQuitting(); + trackEvent('desktop_app_quit'); }); let didCleanup = false; diff --git a/surfsense_desktop/src/modules/analytics.ts b/surfsense_desktop/src/modules/analytics.ts index 0bbcb3026..01dba60f0 100644 --- a/surfsense_desktop/src/modules/analytics.ts +++ b/surfsense_desktop/src/modules/analytics.ts @@ -3,14 +3,27 @@ import { machineIdSync } from 'node-machine-id'; import { app } from 'electron'; let client: PostHog | null = null; -let distinctId = ''; +let machineId = ''; +let currentDistinctId = ''; +let identifiedUserId: string | null = null; + +function baseProperties(): Record { + return { + platform: 'desktop', + app_version: app.getVersion(), + os: process.platform, + arch: process.arch, + machine_id: machineId, + }; +} export function initAnalytics(): void { const key = process.env.POSTHOG_KEY; if (!key) return; try { - distinctId = machineIdSync(true); + machineId = machineIdSync(true); + currentDistinctId = machineId; } catch { return; } @@ -22,17 +35,92 @@ export function initAnalytics(): void { }); } -export function trackEvent(event: string, properties?: Record): void { +export function getMachineId(): string { + return machineId; +} + +export function getDistinctId(): string { + return currentDistinctId; +} + +/** + * Identify the current logged-in user in PostHog so main-process desktop + * events (and linked anonymous machine events) are attributed to that person. + * + * Idempotent: calling identify repeatedly with the same userId is a no-op. + */ +export function identifyUser( + userId: string, + properties?: Record +): void { + if (!client || !userId) return; + if (identifiedUserId === userId) { + // Already identified — only refresh person properties + try { + client.identify({ + distinctId: userId, + properties: { + ...baseProperties(), + $set: { + ...(properties || {}), + platform: 'desktop', + last_seen_at: new Date().toISOString(), + }, + }, + }); + } catch { + // ignore + } + return; + } + + try { + // Link the anonymous machine distinct ID to the authenticated user + client.identify({ + distinctId: userId, + properties: { + ...baseProperties(), + $anon_distinct_id: machineId, + $set: { + ...(properties || {}), + platform: 'desktop', + last_seen_at: new Date().toISOString(), + }, + $set_once: { + first_seen_platform: 'desktop', + }, + }, + }); + + identifiedUserId = userId; + currentDistinctId = userId; + } catch { + // Analytics must never break the app + } +} + +/** + * Reset user identity on logout. Subsequent events are captured anonymously + * against the machine ID until the user logs in again. + */ +export function resetUser(): void { + if (!client) return; + identifiedUserId = null; + currentDistinctId = machineId; +} + +export function trackEvent( + event: string, + properties?: Record +): void { if (!client) return; try { client.capture({ - distinctId, + distinctId: currentDistinctId || machineId, event, properties: { - platform: 'desktop', - app_version: app.getVersion(), - os: process.platform, + ...baseProperties(), ...properties, }, }); diff --git a/surfsense_desktop/src/modules/auto-launch.ts b/surfsense_desktop/src/modules/auto-launch.ts new file mode 100644 index 000000000..9759c4ef9 --- /dev/null +++ b/surfsense_desktop/src/modules/auto-launch.ts @@ -0,0 +1,304 @@ +import { app } from 'electron'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +// --------------------------------------------------------------------------- +// Launch on system startup ("auto-launch" / "open at login"). +// +// macOS + Windows : uses Electron's built-in `app.setLoginItemSettings()`. +// Linux : writes a freedesktop autostart `.desktop` file into +// `~/.config/autostart/`. Electron's API is a no-op there. +// +// The OS is the source of truth for whether we're enabled (so a user who +// disables us via System Settings / GNOME Tweaks isn't silently overridden). +// We persist a small companion record in electron-store for things the OS +// can't tell us — currently just `openAsHidden`, since on Windows we encode +// it as a CLI arg and on Linux as part of the Exec line, but on a fresh +// startup we still want the renderer toggle to reflect the user's intent. +// --------------------------------------------------------------------------- + +const STORE_KEY = 'launchAtLogin'; +const HIDDEN_FLAG = '--hidden'; +const LINUX_DESKTOP_FILENAME = 'surfsense.desktop'; + +export interface AutoLaunchState { + enabled: boolean; + openAsHidden: boolean; + supported: boolean; +} + +interface PersistedState { + enabled: boolean; + openAsHidden: boolean; + // True once we've run the first-launch defaults (opt-in to auto-launch). + // We never re-apply defaults if this is set, so a user who has explicitly + // turned auto-launch off stays off forever. + defaultsApplied: boolean; +} + +const DEFAULTS: PersistedState = { + enabled: false, + openAsHidden: true, + defaultsApplied: false, +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- lazily imported ESM module; matches shortcuts.ts pattern +let store: any = null; + +async function getStore() { + if (!store) { + const { default: Store } = await import('electron-store'); + store = new Store({ + name: 'auto-launch', + defaults: { [STORE_KEY]: DEFAULTS }, + }); + } + return store; +} + +async function readPersisted(): Promise { + const s = await getStore(); + const stored = s.get(STORE_KEY) as Partial | undefined; + return { ...DEFAULTS, ...(stored ?? {}) }; +} + +async function writePersisted(next: PersistedState): Promise { + const s = await getStore(); + s.set(STORE_KEY, next); +} + +// --------------------------------------------------------------------------- +// Platform support +// --------------------------------------------------------------------------- + +// Auto-launch only makes sense for the packaged app — in dev `process.execPath` +// is the local Electron binary, so registering it would point the OS at a +// throwaway path the next time the dev server isn't running. +function isSupported(): boolean { + if (!app.isPackaged) return false; + return ['darwin', 'win32', 'linux'].includes(process.platform); +} + +// --------------------------------------------------------------------------- +// Linux: ~/.config/autostart/surfsense.desktop +// --------------------------------------------------------------------------- + +function linuxAutostartDir(): string { + const xdg = process.env.XDG_CONFIG_HOME; + const base = xdg && xdg.length > 0 ? xdg : path.join(os.homedir(), '.config'); + return path.join(base, 'autostart'); +} + +function linuxAutostartFile(): string { + return path.join(linuxAutostartDir(), LINUX_DESKTOP_FILENAME); +} + +// AppImages move around with the user — `process.execPath` points at a temp +// mount, so we have to use the original AppImage path exposed via env. +function linuxExecPath(): string { + return process.env.APPIMAGE && process.env.APPIMAGE.length > 0 + ? process.env.APPIMAGE + : process.execPath; +} + +function escapeDesktopExecArg(value: string): string { + // Freedesktop `.desktop` Exec values require quoted args when spaces are + // present. We keep this intentionally minimal and escape only characters + // that can break quoted parsing. + return `"${value.replace(/(["\\`$])/g, '\\$1')}"`; +} + +function writeLinuxDesktopFile(openAsHidden: boolean): void { + const exec = escapeDesktopExecArg(linuxExecPath()); + const args = openAsHidden ? ` ${HIDDEN_FLAG}` : ''; + const contents = [ + '[Desktop Entry]', + 'Type=Application', + 'Version=1.0', + 'Name=SurfSense', + 'Comment=AI-powered research assistant', + `Exec=${exec}${args}`, + 'Terminal=false', + 'Categories=Utility;Office;', + 'X-GNOME-Autostart-enabled=true', + `X-GNOME-Autostart-Delay=${openAsHidden ? '5' : '0'}`, + '', + ].join('\n'); + + fs.mkdirSync(linuxAutostartDir(), { recursive: true }); + fs.writeFileSync(linuxAutostartFile(), contents, { mode: 0o644 }); +} + +function removeLinuxDesktopFile(): void { + try { + fs.unlinkSync(linuxAutostartFile()); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err; + } +} + +function readLinuxDesktopFile(): boolean { + return fs.existsSync(linuxAutostartFile()); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export async function getAutoLaunchState(): Promise { + const supported = isSupported(); + const persisted = await readPersisted(); + + if (!supported) { + return { enabled: false, openAsHidden: persisted.openAsHidden, supported: false }; + } + + // Trust the OS state — the user may have disabled it from system settings. + return { enabled: readOsEnabled(), openAsHidden: persisted.openAsHidden, supported: true }; +} + +export async function setAutoLaunch( + enabled: boolean, + openAsHidden: boolean = DEFAULTS.openAsHidden, +): Promise { + const supported = isSupported(); + + if (!supported) { + return { enabled: false, openAsHidden, supported: false }; + } + + applySystemRegistration(enabled, openAsHidden); + // Preserve `defaultsApplied` (and any future fields) — and explicitly + // mark them as applied, since the user has now made an intentional choice. + await writePersisted({ enabled, openAsHidden, defaultsApplied: true }); + return { enabled, openAsHidden, supported: true }; +} + +function applySystemRegistration(enabled: boolean, openAsHidden: boolean): void { + if (process.platform === 'linux') { + if (enabled) writeLinuxDesktopFile(openAsHidden); + else removeLinuxDesktopFile(); + return; + } + + if (!enabled) { + app.setLoginItemSettings({ openAtLogin: false }); + return; + } + + if (process.platform === 'win32') { + // On Windows we can't tell the OS to "launch hidden" — instead we pass an + // arg the app introspects on boot to skip showing the main window. + app.setLoginItemSettings({ + openAtLogin: true, + args: openAsHidden ? [HIDDEN_FLAG] : [], + }); + return; + } + + // darwin + app.setLoginItemSettings({ + openAtLogin: true, + openAsHidden, + }); +} + +// First-launch opt-in: register SurfSense as a hidden login item so the tray, +// global shortcuts, and folder watchers are ready right after the user signs +// in. Runs at most once per installation — the `defaultsApplied` flag is +// flipped before we ever touch the OS so a failure to register doesn't cause +// us to retry on every boot, and a user who turns the toggle off afterwards +// is never silently re-enabled. +// +// Returns whether the defaults were actually applied this boot, so callers +// can fire an analytics event without coupling this module to PostHog. +export async function applyAutoLaunchDefaults(): Promise { + if (!isSupported()) return false; + const persisted = await readPersisted(); + if (persisted.defaultsApplied) return false; + + // Mark the defaults as applied *first*. If `applySystemRegistration` + // throws (e.g. read-only home dir on Linux), we'd rather silently leave + // the user un-registered than spam them with a failed registration on + // every single boot. + const next: PersistedState = { + enabled: true, + openAsHidden: true, + defaultsApplied: true, + }; + + try { + applySystemRegistration(true, true); + } catch (err) { + console.error('[auto-launch] First-run registration failed:', err); + next.enabled = false; + } + + await writePersisted(next); + return next.enabled; +} + +// Called once at startup. Goal: +// * If the OS-level entry is already enabled, re-assert it so a moved +// binary (Windows reinstall to a new dir, Linux AppImage moved by user) +// gets its registered path refreshed. +// * If the OS-level entry has been disabled — typically because the user +// turned it off in System Settings / GNOME Tweaks — *respect that* and +// reconcile our persisted state to match. We never silently re-enable +// a login item the user explicitly turned off. +export async function syncAutoLaunchOnStartup(): Promise { + if (!isSupported()) return; + + const persisted = await readPersisted(); + const osEnabled = readOsEnabled(); + + if (!osEnabled) { + // User (or some other tool) turned us off out-of-band. Don't re-enable; + // just bring our persisted state in sync so the settings UI reflects + // reality on the next render. + if (persisted.enabled) { + await writePersisted({ ...persisted, enabled: false }); + } + return; + } + + // OS says we're enabled — refresh the registration so the recorded path / + // args match this binary. Idempotent on macOS; corrects path drift on + // Windows and Linux. If our persisted state was somehow stale we also + // bring it back in line. + try { + applySystemRegistration(true, persisted.openAsHidden); + if (!persisted.enabled) { + await writePersisted({ ...persisted, enabled: true }); + } + } catch (err) { + console.error('[auto-launch] Failed to re-assert login item:', err); + } +} + +function readOsEnabled(): boolean { + if (process.platform === 'linux') return readLinuxDesktopFile(); + return app.getLoginItemSettings().openAtLogin; +} + +// True when the OS launched us as part of login (used for analytics). +export function wasLaunchedAtLogin(): boolean { + if (process.argv.includes(HIDDEN_FLAG)) return true; + if (process.platform === 'darwin') { + const settings = app.getLoginItemSettings(); + return settings.wasOpenedAtLogin || settings.wasOpenedAsHidden; + } + return false; +} + +// Used for boot UI behavior. On macOS we only start hidden when the OS +// explicitly launched the app as hidden, not merely "at login". +export function shouldStartHidden(): boolean { + if (process.argv.includes(HIDDEN_FLAG)) return true; + if (process.platform === 'darwin') { + const settings = app.getLoginItemSettings(); + return settings.wasOpenedAsHidden; + } + return false; +} diff --git a/surfsense_desktop/src/modules/auto-updater.ts b/surfsense_desktop/src/modules/auto-updater.ts index 47a85b730..e323abe53 100644 --- a/surfsense_desktop/src/modules/auto-updater.ts +++ b/surfsense_desktop/src/modules/auto-updater.ts @@ -1,4 +1,5 @@ import { app, dialog } from 'electron'; +import { trackEvent } from './analytics'; const SEMVER_RE = /^\d+\.\d+\.\d+/; @@ -17,10 +18,18 @@ export function setupAutoUpdater(): void { autoUpdater.on('update-available', (info: { version: string }) => { console.log(`Update available: ${info.version}`); + trackEvent('desktop_update_available', { + current_version: version, + new_version: info.version, + }); }); autoUpdater.on('update-downloaded', (info: { version: string }) => { console.log(`Update downloaded: ${info.version}`); + trackEvent('desktop_update_downloaded', { + current_version: version, + new_version: info.version, + }); dialog.showMessageBox({ type: 'info', buttons: ['Restart', 'Later'], @@ -29,13 +38,19 @@ export function setupAutoUpdater(): void { message: `Version ${info.version} has been downloaded. Restart to apply the update.`, }).then(({ response }: { response: number }) => { if (response === 0) { + trackEvent('desktop_update_install_accepted', { new_version: info.version }); autoUpdater.quitAndInstall(); + } else { + trackEvent('desktop_update_install_deferred', { new_version: info.version }); } }); }); autoUpdater.on('error', (err: Error) => { console.log('Auto-updater: update check skipped —', err.message?.split('\n')[0]); + trackEvent('desktop_update_error', { + message: err.message?.split('\n')[0], + }); }); autoUpdater.checkForUpdates().catch(() => {}); diff --git a/surfsense_desktop/src/modules/deep-links.ts b/surfsense_desktop/src/modules/deep-links.ts index 1a2b08395..11b7bfcff 100644 --- a/surfsense_desktop/src/modules/deep-links.ts +++ b/surfsense_desktop/src/modules/deep-links.ts @@ -2,6 +2,7 @@ import { app } from 'electron'; import path from 'path'; import { getMainWindow } from './window'; import { getServerPort } from './server'; +import { trackEvent } from './analytics'; const PROTOCOL = 'surfsense'; @@ -16,6 +17,10 @@ function handleDeepLink(url: string) { if (!win) return; const parsed = new URL(url); + trackEvent('desktop_deep_link_received', { + host: parsed.hostname, + path: parsed.pathname, + }); if (parsed.hostname === 'auth' && parsed.pathname === '/callback') { const params = parsed.searchParams.toString(); win.loadURL(`http://localhost:${getServerPort()}/auth/callback?${params}`); @@ -64,3 +69,10 @@ export function handlePendingDeepLink(): void { deepLinkUrl = null; } } + +// True when a deep link arrived before the main window existed. Callers can +// use this to force-create a window even on a "started hidden" boot, so we +// don't silently swallow a `surfsense://` URL the user actually clicked on. +export function hasPendingDeepLink(): boolean { + return deepLinkUrl !== null; +} diff --git a/surfsense_desktop/src/modules/folder-watcher.ts b/surfsense_desktop/src/modules/folder-watcher.ts index 96b490d7b..ee4214d8a 100644 --- a/surfsense_desktop/src/modules/folder-watcher.ts +++ b/surfsense_desktop/src/modules/folder-watcher.ts @@ -4,6 +4,7 @@ import { randomUUID } from 'crypto'; import * as path from 'path'; import * as fs from 'fs'; import { IPC_CHANNELS } from '../ipc/channels'; +import { trackEvent } from './analytics'; export interface WatchedFolderConfig { path: string; @@ -401,6 +402,15 @@ export async function addWatchedFolder( await startWatcher(config); } + trackEvent('desktop_folder_watch_added', { + search_space_id: config.searchSpaceId, + root_folder_id: config.rootFolderId, + active: config.active, + has_exclude_patterns: (config.excludePatterns?.length ?? 0) > 0, + has_extension_filter: !!config.fileExtensions && config.fileExtensions.length > 0, + is_update: existing >= 0, + }); + return folders; } @@ -409,6 +419,7 @@ export async function removeWatchedFolder( ): Promise { const s = await getStore(); const folders: WatchedFolderConfig[] = s.get(STORE_KEY, []); + const removed = folders.find((f: WatchedFolderConfig) => f.path === folderPath); const updated = folders.filter((f: WatchedFolderConfig) => f.path !== folderPath); s.set(STORE_KEY, updated); @@ -418,6 +429,13 @@ export async function removeWatchedFolder( const ms = await getMtimeStore(); ms.delete(folderPath); + if (removed) { + trackEvent('desktop_folder_watch_removed', { + search_space_id: removed.searchSpaceId, + root_folder_id: removed.rootFolderId, + }); + } + return updated; } diff --git a/surfsense_desktop/src/modules/tray.ts b/surfsense_desktop/src/modules/tray.ts index 1749145a1..88444cc54 100644 --- a/surfsense_desktop/src/modules/tray.ts +++ b/surfsense_desktop/src/modules/tray.ts @@ -2,6 +2,7 @@ import { app, globalShortcut, Menu, nativeImage, Tray } from 'electron'; import path from 'path'; import { getMainWindow, createMainWindow } from './window'; import { getShortcuts } from './shortcuts'; +import { trackEvent } from './analytics'; let tray: Tray | null = null; let currentShortcut: string | null = null; @@ -15,14 +16,16 @@ function getTrayIcon(): nativeImage { return img.resize({ width: 16, height: 16 }); } -function showMainWindow(): void { - let win = getMainWindow(); - if (!win || win.isDestroyed()) { - win = createMainWindow('/dashboard'); +function showMainWindow(source: 'tray_click' | 'tray_menu' | 'shortcut' = 'tray_click'): void { + const existing = getMainWindow(); + const reopened = !existing || existing.isDestroyed(); + if (reopened) { + createMainWindow('/dashboard'); } else { - win.show(); - win.focus(); + existing.show(); + existing.focus(); } + trackEvent('desktop_main_window_shown', { source, reopened }); } function registerShortcut(accelerator: string): void { @@ -32,7 +35,7 @@ function registerShortcut(accelerator: string): void { } if (!accelerator) return; try { - const ok = globalShortcut.register(accelerator, showMainWindow); + const ok = globalShortcut.register(accelerator, () => showMainWindow('shortcut')); if (ok) { currentShortcut = accelerator; } else { @@ -50,13 +53,19 @@ export async function createTray(): Promise { tray.setToolTip('SurfSense'); const contextMenu = Menu.buildFromTemplate([ - { label: 'Open SurfSense', click: showMainWindow }, + { label: 'Open SurfSense', click: () => showMainWindow('tray_menu') }, { type: 'separator' }, - { label: 'Quit', click: () => { app.exit(0); } }, + { + label: 'Quit', + click: () => { + trackEvent('desktop_tray_quit_clicked'); + app.exit(0); + }, + }, ]); tray.setContextMenu(contextMenu); - tray.on('double-click', showMainWindow); + tray.on('double-click', () => showMainWindow('tray_click')); const shortcuts = await getShortcuts(); registerShortcut(shortcuts.generalAssist); diff --git a/surfsense_desktop/src/modules/window.ts b/surfsense_desktop/src/modules/window.ts index 9cd216501..c925bf947 100644 --- a/surfsense_desktop/src/modules/window.ts +++ b/surfsense_desktop/src/modules/window.ts @@ -8,11 +8,18 @@ const isDev = !app.isPackaged; const HOSTED_FRONTEND_URL = process.env.HOSTED_FRONTEND_URL as string; let mainWindow: BrowserWindow | null = null; +let isQuitting = false; export function getMainWindow(): BrowserWindow | null { return mainWindow; } +// Called from main.ts on `before-quit` so the close-to-tray handler knows +// to actually let the window die instead of hiding it. +export function markQuitting(): void { + isQuitting = true; +} + export function createMainWindow(initialPath = '/dashboard'): BrowserWindow { mainWindow = new BrowserWindow({ width: 1280, @@ -70,6 +77,16 @@ export function createMainWindow(initialPath = '/dashboard'): BrowserWindow { mainWindow.webContents.openDevTools(); } + // Hide-to-tray on close (don't actually destroy the window unless the + // user really is quitting). Applies to every instance — including the one + // created lazily after a launch-at-login boot. + mainWindow.on('close', (e) => { + if (!isQuitting && mainWindow) { + e.preventDefault(); + mainWindow.hide(); + } + }); + mainWindow.on('closed', () => { mainWindow = null; }); diff --git a/surfsense_desktop/src/preload.ts b/surfsense_desktop/src/preload.ts index e3d12c5e6..3a69f3239 100644 --- a/surfsense_desktop/src/preload.ts +++ b/surfsense_desktop/src/preload.ts @@ -82,8 +82,23 @@ contextBridge.exposeInMainWorld('electronAPI', { setShortcuts: (config: Record) => ipcRenderer.invoke(IPC_CHANNELS.SET_SHORTCUTS, config), + // Launch on system startup + getAutoLaunch: () => ipcRenderer.invoke(IPC_CHANNELS.GET_AUTO_LAUNCH), + setAutoLaunch: (enabled: boolean, openAsHidden?: boolean) => + ipcRenderer.invoke(IPC_CHANNELS.SET_AUTO_LAUNCH, { enabled, openAsHidden }), + // Active search space getActiveSearchSpace: () => ipcRenderer.invoke(IPC_CHANNELS.GET_ACTIVE_SEARCH_SPACE), setActiveSearchSpace: (id: string) => ipcRenderer.invoke(IPC_CHANNELS.SET_ACTIVE_SEARCH_SPACE, id), + + // Analytics bridge — lets posthog-js running inside the Next.js renderer + // mirror identify/reset/capture into the Electron main-process PostHog + // client so desktop-only events are attributed to the logged-in user. + analyticsIdentify: (userId: string, properties?: Record) => + ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_IDENTIFY, { userId, properties }), + analyticsReset: () => ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_RESET), + analyticsCapture: (event: string, properties?: Record) => + ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_CAPTURE, { event, properties }), + getAnalyticsContext: () => ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_GET_CONTEXT), }); diff --git a/surfsense_web/app/(home)/free/[model_slug]/page.tsx b/surfsense_web/app/(home)/free/[model_slug]/page.tsx index 9b7a7e0b6..cc06376d1 100644 --- a/surfsense_web/app/(home)/free/[model_slug]/page.tsx +++ b/surfsense_web/app/(home)/free/[model_slug]/page.tsx @@ -41,7 +41,7 @@ async function getAllModels(): Promise { function buildSeoTitle(model: AnonModel): string { if (model.seo_title) return model.seo_title; - return `${model.name} Free Online Without Login | No Sign-Up AI Chat | SurfSense`; + return `Chat with ${model.name} Free, No Login | SurfSense`; } function buildSeoDescription(model: AnonModel): string { diff --git a/surfsense_web/app/(home)/free/page.tsx b/surfsense_web/app/(home)/free/page.tsx index 00ad5e0e5..8d9ed5cb1 100644 --- a/surfsense_web/app/(home)/free/page.tsx +++ b/surfsense_web/app/(home)/free/page.tsx @@ -18,7 +18,7 @@ import type { AnonModel } from "@/contracts/types/anonymous-chat.types"; import { BACKEND_URL } from "@/lib/env-config"; export const metadata: Metadata = { - title: "ChatGPT Free Online Without Login | Chat GPT No Login, Claude AI Free | SurfSense", + title: "Free AI Chat, No Login Required | SurfSense", description: "Use ChatGPT free online without login. Chat with GPT-4, Claude AI, Gemini and more for free. No sign-up required. Open source NotebookLM alternative with free AI chat and document Q&A.", keywords: [ @@ -67,7 +67,7 @@ export const metadata: Metadata = { canonical: "https://surfsense.com/free", }, openGraph: { - title: "ChatGPT Free Online Without Login | Claude AI Free No Login | SurfSense", + title: "Free AI Chat, No Login Required | SurfSense", description: "Use ChatGPT free online without login. Chat with GPT-4, Claude AI, Gemini and 100+ AI models. Open source NotebookLM alternative.", url: "https://surfsense.com/free", @@ -84,7 +84,7 @@ export const metadata: Metadata = { }, twitter: { card: "summary_large_image", - title: "ChatGPT Free Online Without Login | Claude AI Free No Login | SurfSense", + title: "Free AI Chat, No Login Required | SurfSense", description: "Use ChatGPT free online without login. Chat with GPT-4, Claude AI, Gemini and more. No sign-up needed.", images: ["/og-image.png"], diff --git a/surfsense_web/app/api/zero/query/route.ts b/surfsense_web/app/api/zero/query/route.ts index 3d8ff0d33..a91edcd6f 100644 --- a/surfsense_web/app/api/zero/query/route.ts +++ b/surfsense_web/app/api/zero/query/route.ts @@ -5,7 +5,10 @@ import type { Context } from "@/types/zero"; import { queries } from "@/zero/queries"; import { schema } from "@/zero/schema"; -const backendURL = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000"; +const backendURL = + process.env.FASTAPI_BACKEND_INTERNAL_URL || + process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || + "http://localhost:8000"; async function authenticateRequest( request: Request diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/DesktopContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/DesktopContent.tsx index c3f457f96..63ca9f5df 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/DesktopContent.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/DesktopContent.tsx @@ -1,6 +1,6 @@ "use client"; -import { BrainCog, Rocket, Zap } from "lucide-react"; +import { BrainCog, Power, Rocket, Zap } from "lucide-react"; import { useEffect, useState } from "react"; import { toast } from "sonner"; import { DEFAULT_SHORTCUTS, ShortcutRecorder } from "@/components/desktop/shortcut-recorder"; @@ -30,6 +30,10 @@ export function DesktopContent() { const [searchSpaces, setSearchSpaces] = useState([]); const [activeSpaceId, setActiveSpaceId] = useState(null); + const [autoLaunchEnabled, setAutoLaunchEnabled] = useState(false); + const [autoLaunchHidden, setAutoLaunchHidden] = useState(true); + const [autoLaunchSupported, setAutoLaunchSupported] = useState(false); + useEffect(() => { if (!api) { setLoading(false); @@ -38,19 +42,28 @@ export function DesktopContent() { } let mounted = true; + const hasAutoLaunchApi = + typeof api.getAutoLaunch === "function" && typeof api.setAutoLaunch === "function"; + setAutoLaunchSupported(hasAutoLaunchApi); Promise.all([ api.getAutocompleteEnabled(), api.getShortcuts?.() ?? Promise.resolve(null), api.getActiveSearchSpace?.() ?? Promise.resolve(null), searchSpacesApiService.getSearchSpaces(), + hasAutoLaunchApi ? api.getAutoLaunch() : Promise.resolve(null), ]) - .then(([autoEnabled, config, spaceId, spaces]) => { + .then(([autoEnabled, config, spaceId, spaces, autoLaunch]) => { if (!mounted) return; setEnabled(autoEnabled); if (config) setShortcuts(config); setActiveSpaceId(spaceId); if (spaces) setSearchSpaces(spaces); + if (autoLaunch) { + setAutoLaunchEnabled(autoLaunch.enabled); + setAutoLaunchHidden(autoLaunch.openAsHidden); + setAutoLaunchSupported(autoLaunch.supported); + } setLoading(false); setShortcutsLoaded(true); }) @@ -106,6 +119,40 @@ export function DesktopContent() { updateShortcut(key, DEFAULT_SHORTCUTS[key]); }; + const handleAutoLaunchToggle = async (checked: boolean) => { + if (!autoLaunchSupported || !api.setAutoLaunch) { + toast.error("Please update the desktop app to configure launch on startup"); + return; + } + setAutoLaunchEnabled(checked); + try { + const next = await api.setAutoLaunch(checked, autoLaunchHidden); + if (next) { + setAutoLaunchEnabled(next.enabled); + setAutoLaunchHidden(next.openAsHidden); + setAutoLaunchSupported(next.supported); + } + toast.success(checked ? "SurfSense will launch on startup" : "Launch on startup disabled"); + } catch { + setAutoLaunchEnabled(!checked); + toast.error("Failed to update launch on startup"); + } + }; + + const handleAutoLaunchHiddenToggle = async (checked: boolean) => { + if (!autoLaunchSupported || !api.setAutoLaunch) { + toast.error("Please update the desktop app to configure startup behavior"); + return; + } + setAutoLaunchHidden(checked); + try { + await api.setAutoLaunch(autoLaunchEnabled, checked); + } catch { + setAutoLaunchHidden(!checked); + toast.error("Failed to update startup behavior"); + } + }; + const handleSearchSpaceChange = (value: string) => { setActiveSpaceId(value); api.setActiveSearchSpace?.(value); @@ -145,6 +192,59 @@ export function DesktopContent() { + {/* Launch on Startup */} + + + + + Launch on Startup + + + Automatically start SurfSense when you sign in to your computer so global shortcuts and + folder sync are always available. + + + +
+
+ +

+ {autoLaunchSupported + ? "Adds SurfSense to your system's login items." + : "Only available in the packaged desktop app."} +

+
+ +
+
+
+ +

+ Skip the main window on boot — SurfSense lives in the system tray until you need it. +

+
+ +
+
+
+ {/* Keyboard Shortcuts */} diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent.tsx index 9bc77edff..2b7422f80 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent.tsx @@ -1,7 +1,8 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; -import { ReceiptText } from "lucide-react"; +import { useQueries } from "@tanstack/react-query"; +import { Coins, FileText, ReceiptText } from "lucide-react"; +import { useMemo } from "react"; import { Badge } from "@/components/ui/badge"; import { Spinner } from "@/components/ui/spinner"; import { @@ -12,10 +13,26 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import type { PagePurchase, PagePurchaseStatus } from "@/contracts/types/stripe.types"; +import type { + PagePurchase, + PagePurchaseStatus, + TokenPurchase, +} from "@/contracts/types/stripe.types"; import { stripeApiService } from "@/lib/apis/stripe-api.service"; import { cn } from "@/lib/utils"; +type PurchaseKind = "pages" | "tokens"; + +type UnifiedPurchase = { + id: string; + kind: PurchaseKind; + created_at: string; + status: PagePurchaseStatus; + granted: number; + amount_total: number | null; + currency: string | null; +}; + const STATUS_STYLES: Record = { completed: { label: "Completed", @@ -31,6 +48,22 @@ const STATUS_STYLES: Record; iconClass: string } +> = { + pages: { + label: "Pages", + icon: FileText, + iconClass: "text-sky-500", + }, + tokens: { + label: "Premium Tokens", + icon: Coins, + iconClass: "text-amber-500", + }, +}; + function formatDate(iso: string): string { return new Date(iso).toLocaleDateString(undefined, { year: "numeric", @@ -39,19 +72,63 @@ function formatDate(iso: string): string { }); } -function formatAmount(purchase: PagePurchase): string { - if (purchase.amount_total == null) return "—"; - const dollars = purchase.amount_total / 100; - const currency = (purchase.currency ?? "usd").toUpperCase(); - return `$${dollars.toFixed(2)} ${currency}`; +function formatAmount(amount: number | null, currency: string | null): string { + if (amount == null) return "—"; + const dollars = amount / 100; + const code = (currency ?? "usd").toUpperCase(); + return `$${dollars.toFixed(2)} ${code}`; +} + +function normalizePagePurchase(p: PagePurchase): UnifiedPurchase { + return { + id: p.id, + kind: "pages", + created_at: p.created_at, + status: p.status, + granted: p.pages_granted, + amount_total: p.amount_total, + currency: p.currency, + }; +} + +function normalizeTokenPurchase(p: TokenPurchase): UnifiedPurchase { + return { + id: p.id, + kind: "tokens", + created_at: p.created_at, + status: p.status, + granted: p.tokens_granted, + amount_total: p.amount_total, + currency: p.currency, + }; } export function PurchaseHistoryContent() { - const { data, isLoading } = useQuery({ - queryKey: ["stripe-purchases"], - queryFn: () => stripeApiService.getPurchases(), + const results = useQueries({ + queries: [ + { + queryKey: ["stripe-purchases"], + queryFn: () => stripeApiService.getPurchases(), + }, + { + queryKey: ["stripe-token-purchases"], + queryFn: () => stripeApiService.getTokenPurchases(), + }, + ], }); + const [pagesQuery, tokensQuery] = results; + const isLoading = pagesQuery.isLoading || tokensQuery.isLoading; + + const purchases = useMemo(() => { + const pagePurchases = pagesQuery.data?.purchases ?? []; + const tokenPurchases = tokensQuery.data?.purchases ?? []; + return [ + ...pagePurchases.map(normalizePagePurchase), + ...tokenPurchases.map(normalizeTokenPurchase), + ].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); + }, [pagesQuery.data, tokensQuery.data]); + if (isLoading) { return (
@@ -60,15 +137,13 @@ export function PurchaseHistoryContent() { ); } - const purchases = data?.purchases ?? []; - if (purchases.length === 0) { return (

No purchases yet

- Your page-pack purchases will appear here after checkout. + Your page and premium token purchases will appear here after checkout.

); @@ -81,25 +156,36 @@ export function PurchaseHistoryContent() { Date - Pages + Type + Granted Amount Status {purchases.map((p) => { - const style = STATUS_STYLES[p.status]; + const statusStyle = STATUS_STYLES[p.status]; + const kind = KIND_META[p.kind]; + const KindIcon = kind.icon; return ( - + {formatDate(p.created_at)} - - {p.pages_granted.toLocaleString()} + +
+ + {kind.label} +
- {formatAmount(p)} + {p.granted.toLocaleString()} + + + {formatAmount(p.amount_total, p.currency)} - {style.label} + + {statusStyle.label} +
); @@ -108,7 +194,8 @@ export function PurchaseHistoryContent() {

- Showing your {purchases.length} most recent purchase{purchases.length !== 1 ? "s" : ""}. + Showing your {purchases.length} most recent purchase + {purchases.length !== 1 ? "s" : ""}.

); diff --git a/surfsense_web/app/layout.tsx b/surfsense_web/app/layout.tsx index 4e6930094..144968a2b 100644 --- a/surfsense_web/app/layout.tsx +++ b/surfsense_web/app/layout.tsx @@ -45,7 +45,7 @@ export const metadata: Metadata = { alternates: { canonical: "https://surfsense.com", }, - title: "SurfSense - NotebookLM Alternative | Free ChatGPT & Claude AI", + title: "SurfSense – Open Source, Privacy-Focused NotebookLM Alternative for Teams", description: "Open source NotebookLM alternative for teams with no data limits. Use ChatGPT, Claude AI, and any AI model for free.", keywords: [ @@ -87,7 +87,7 @@ export const metadata: Metadata = { "SurfSense", ], openGraph: { - title: "SurfSense - NotebookLM Alternative | Free ChatGPT & Claude AI", + title: "SurfSense – Open Source, Privacy-Focused NotebookLM Alternative for Teams", description: "Open source NotebookLM alternative for teams with no data limits. Use ChatGPT, Claude, and any AI model for free.", url: "https://surfsense.com", @@ -105,7 +105,7 @@ export const metadata: Metadata = { }, twitter: { card: "summary_large_image", - title: "SurfSense - NotebookLM Alternative | Free ChatGPT & Claude AI", + title: "SurfSense – Open Source, Privacy-Focused NotebookLM Alternative for Teams", description: "Open source NotebookLM alternative for teams with no data limits. Use ChatGPT, Claude AI, and any AI model for free.", creator: "@SurfSenseAI", diff --git a/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts b/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts index 927c84cdf..1f324d53e 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts @@ -303,5 +303,79 @@ export const AUTO_INDEX_DEFAULTS: Record = { export const AUTO_INDEX_CONNECTOR_TYPES = new Set(Object.keys(AUTO_INDEX_DEFAULTS)); +// ============================================================================ +// CONNECTOR TELEMETRY REGISTRY +// ---------------------------------------------------------------------------- +// Single source of truth for "what does this connector_type look like in +// analytics?". Any connector added to the lists above is automatically +// picked up here, so adding a new integration does NOT require touching +// `lib/posthog/events.ts` or per-connector tracking code. +// ============================================================================ + +export type ConnectorTelemetryGroup = "oauth" | "composio" | "crawler" | "other" | "unknown"; + +export interface ConnectorTelemetryMeta { + connector_type: string; + connector_title: string; + connector_group: ConnectorTelemetryGroup; + is_oauth: boolean; +} + +const CONNECTOR_TELEMETRY_REGISTRY: ReadonlyMap = (() => { + const map = new Map(); + + for (const c of OAUTH_CONNECTORS) { + map.set(c.connectorType, { + connector_type: c.connectorType, + connector_title: c.title, + connector_group: "oauth", + is_oauth: true, + }); + } + for (const c of COMPOSIO_CONNECTORS) { + map.set(c.connectorType, { + connector_type: c.connectorType, + connector_title: c.title, + connector_group: "composio", + is_oauth: true, + }); + } + for (const c of CRAWLERS) { + map.set(c.connectorType, { + connector_type: c.connectorType, + connector_title: c.title, + connector_group: "crawler", + is_oauth: false, + }); + } + for (const c of OTHER_CONNECTORS) { + map.set(c.connectorType, { + connector_type: c.connectorType, + connector_title: c.title, + connector_group: "other", + is_oauth: false, + }); + } + + return map; +})(); + +/** + * Returns telemetry metadata for a connector_type, or a minimal "unknown" + * record so tracking never no-ops for connectors that exist in the backend + * but were forgotten in the UI registry. + */ +export function getConnectorTelemetryMeta(connectorType: string): ConnectorTelemetryMeta { + const hit = CONNECTOR_TELEMETRY_REGISTRY.get(connectorType); + if (hit) return hit; + + return { + connector_type: connectorType, + connector_title: connectorType, + connector_group: "unknown", + is_oauth: false, + }; +} + // Re-export IndexingConfigState from schemas for backward compatibility export type { IndexingConfigState } from "./connector-popup.schemas"; diff --git a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts index 4a07693ce..9f968e2a7 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts @@ -24,6 +24,8 @@ import { isSelfHosted } from "@/lib/env-config"; import { trackConnectorConnected, trackConnectorDeleted, + trackConnectorSetupFailure, + trackConnectorSetupStarted, trackIndexWithDateRangeOpened, trackIndexWithDateRangeStarted, trackPeriodicIndexingStarted, @@ -232,10 +234,20 @@ export const useConnectorDialog = () => { if (result.error) { const oauthConnector = result.connector - ? OAUTH_CONNECTORS.find((c) => c.id === result.connector) + ? OAUTH_CONNECTORS.find((c) => c.id === result.connector) || + COMPOSIO_CONNECTORS.find((c) => c.id === result.connector) : null; const name = oauthConnector?.title || "connector"; + if (oauthConnector) { + trackConnectorSetupFailure( + Number(searchSpaceId), + oauthConnector.connectorType, + result.error, + "oauth_callback" + ); + } + if (result.error === "duplicate_account") { toast.error(`This ${name} account is already connected`, { description: "Please use a different account or manage the existing connection.", @@ -351,6 +363,8 @@ export const useConnectorDialog = () => { // Set connecting state immediately to disable button and show spinner setConnectingId(connector.id); + trackConnectorSetupStarted(Number(searchSpaceId), connector.connectorType, "oauth_click"); + try { // Check if authEndpoint already has query parameters const separator = connector.authEndpoint.includes("?") ? "&" : "?"; @@ -372,6 +386,12 @@ export const useConnectorDialog = () => { window.location.href = validatedData.auth_url; } catch (error) { console.error(`Error connecting to ${connector.title}:`, error); + trackConnectorSetupFailure( + Number(searchSpaceId), + connector.connectorType, + error instanceof Error ? error.message : "oauth_initiation_failed", + "oauth_init" + ); if (error instanceof Error && error.message.includes("Invalid auth URL")) { toast.error(`Invalid response from ${connector.title} OAuth endpoint`); } else { @@ -395,6 +415,11 @@ export const useConnectorDialog = () => { if (!searchSpaceId) return; setConnectingId("webcrawler-connector"); + trackConnectorSetupStarted( + Number(searchSpaceId), + EnumConnectorName.WEBCRAWLER_CONNECTOR, + "webcrawler_quick_add" + ); try { await createConnector({ data: { @@ -444,6 +469,12 @@ export const useConnectorDialog = () => { } } catch (error) { console.error("Error creating webcrawler connector:", error); + trackConnectorSetupFailure( + Number(searchSpaceId), + EnumConnectorName.WEBCRAWLER_CONNECTOR, + error instanceof Error ? error.message : "webcrawler_create_failed", + "webcrawler_quick_add" + ); toast.error("Failed to create web crawler connector"); } finally { setConnectingId(null); @@ -455,6 +486,8 @@ export const useConnectorDialog = () => { (connectorType: string) => { if (!searchSpaceId) return; + trackConnectorSetupStarted(Number(searchSpaceId), connectorType, "non_oauth_click"); + // Handle Obsidian specifically on Desktop & Cloud if (connectorType === EnumConnectorName.OBSIDIAN_CONNECTOR && !selfHosted && isDesktop) { setIsOpen(false); @@ -683,6 +716,12 @@ export const useConnectorDialog = () => { } } catch (error) { console.error("Error creating connector:", error); + trackConnectorSetupFailure( + Number(searchSpaceId), + connectingConnectorType ?? formData.connector_type, + error instanceof Error ? error.message : "connector_create_failed", + "non_oauth_form" + ); toast.error(error instanceof Error ? error.message : "Failed to create connector"); } finally { isCreatingConnectorRef.current = false; diff --git a/surfsense_web/components/documents/CreateFolderDialog.tsx b/surfsense_web/components/documents/CreateFolderDialog.tsx index 55548146f..5ecfebbe7 100644 --- a/surfsense_web/components/documents/CreateFolderDialog.tsx +++ b/surfsense_web/components/documents/CreateFolderDialog.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -29,12 +29,16 @@ export function CreateFolderDialog({ const [name, setName] = useState(""); const inputRef = useRef(null); - useEffect(() => { - if (open) { - setName(""); - setTimeout(() => inputRef.current?.focus(), 0); - } - }, [open]); + const handleOpenChange = useCallback( + (next: boolean) => { + if (next) { + setName(""); + setTimeout(() => inputRef.current?.focus(), 0); + } + onOpenChange(next); + }, + [onOpenChange] + ); const handleSubmit = useCallback( (e?: React.FormEvent) => { @@ -50,7 +54,7 @@ export function CreateFolderDialog({ const isSubfolder = !!parentFolderName; return ( - +
diff --git a/surfsense_web/components/documents/FolderPickerDialog.tsx b/surfsense_web/components/documents/FolderPickerDialog.tsx index 59e02f726..cb97caa62 100644 --- a/surfsense_web/components/documents/FolderPickerDialog.tsx +++ b/surfsense_web/components/documents/FolderPickerDialog.tsx @@ -1,7 +1,7 @@ "use client"; import { ChevronDown, ChevronRight, Folder, FolderOpen, Home } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -36,12 +36,16 @@ export function FolderPickerDialog({ const [selectedId, setSelectedId] = useState(null); const [expandedIds, setExpandedIds] = useState>(new Set()); - useEffect(() => { - if (open) { - setSelectedId(null); - setExpandedIds(new Set()); - } - }, [open]); + const handleOpenChange = useCallback( + (next: boolean) => { + if (next) { + setSelectedId(null); + setExpandedIds(new Set()); + } + onOpenChange(next); + }, + [onOpenChange] + ); const foldersByParent = useMemo(() => { const map: Record = {}; @@ -123,7 +127,7 @@ export function FolderPickerDialog({ } return ( - +
diff --git a/surfsense_web/components/free-chat/anonymous-chat.tsx b/surfsense_web/components/free-chat/anonymous-chat.tsx index 1ac6baad4..b286c5316 100644 --- a/surfsense_web/components/free-chat/anonymous-chat.tsx +++ b/surfsense_web/components/free-chat/anonymous-chat.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { AnonModel, AnonQuotaResponse } from "@/contracts/types/anonymous-chat.types"; import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service"; import { readSSEStream } from "@/lib/chat/streaming-state"; +import { trackAnonymousChatMessageSent } from "@/lib/posthog/events"; import { cn } from "@/lib/utils"; import { QuotaBar } from "./quota-bar"; import { QuotaWarningBanner } from "./quota-warning-banner"; @@ -61,6 +62,12 @@ export function AnonymousChat({ model }: AnonymousChatProps) { textareaRef.current.style.height = "auto"; } + trackAnonymousChatMessageSent({ + modelSlug: model.seo_slug, + messageLength: trimmed.length, + surface: "free_model_page", + }); + const controller = new AbortController(); abortRef.current = controller; diff --git a/surfsense_web/components/free-chat/free-chat-page.tsx b/surfsense_web/components/free-chat/free-chat-page.tsx index b1d0f6850..deac1fd00 100644 --- a/surfsense_web/components/free-chat/free-chat-page.tsx +++ b/surfsense_web/components/free-chat/free-chat-page.tsx @@ -28,6 +28,7 @@ import { updateToolCall, } from "@/lib/chat/streaming-state"; import { BACKEND_URL } from "@/lib/env-config"; +import { trackAnonymousChatMessageSent } from "@/lib/posthog/events"; import { FreeModelSelector } from "./free-model-selector"; import { FreeThread } from "./free-thread"; @@ -206,6 +207,13 @@ export function FreeChatPage() { } if (!userQuery.trim()) return; + trackAnonymousChatMessageSent({ + modelSlug, + messageLength: userQuery.trim().length, + hasUploadedDoc: anonMode.isAnonymous && anonMode.uploadedDoc !== null ? true : false, + surface: "free_chat_page", + }); + const userMsgId = `msg-user-${Date.now()}`; setMessages((prev) => [ ...prev, diff --git a/surfsense_web/components/free-chat/free-model-selector.tsx b/surfsense_web/components/free-chat/free-model-selector.tsx index 40112f780..b25d06db8 100644 --- a/surfsense_web/components/free-chat/free-model-selector.tsx +++ b/surfsense_web/components/free-chat/free-model-selector.tsx @@ -27,13 +27,14 @@ export function FreeModelSelector({ className }: { className?: string }) { anonymousChatApiService.getModels().then(setModels).catch(console.error); }, []); - useEffect(() => { - if (open) { + const handleOpenChange = useCallback((next: boolean) => { + if (next) { setSearchQuery(""); setFocusedIndex(-1); requestAnimationFrame(() => searchInputRef.current?.focus()); } - }, [open]); + setOpen(next); + }, []); const currentModel = useMemo( () => models.find((m) => m.seo_slug === currentSlug) ?? null, @@ -94,7 +95,7 @@ export function FreeModelSelector({ className }: { className?: string }) { ); return ( - +
)}
diff --git a/surfsense_web/components/theme/theme-toggle.tsx b/surfsense_web/components/theme/theme-toggle.tsx index a59c5248b..0b480a1ca 100644 --- a/surfsense_web/components/theme/theme-toggle.tsx +++ b/surfsense_web/components/theme/theme-toggle.tsx @@ -586,7 +586,7 @@ export const useThemeToggle = ({ }, []); const toggleTheme = useCallback(() => { - setIsDark(!isDark); + setIsDark((prev) => !prev); const animation = createAnimation(variant, start, blur, gifUrl); @@ -604,7 +604,7 @@ export const useThemeToggle = ({ } document.startViewTransition(switchTheme); - }, [theme, setTheme, variant, start, blur, gifUrl, updateStyles, isDark]); + }, [theme, setTheme, variant, start, blur, gifUrl, updateStyles]); const setCrazyLightTheme = useCallback(() => { setIsDark(false); diff --git a/surfsense_web/content/docs/how-to/meta.json b/surfsense_web/content/docs/how-to/meta.json index 477fcafc4..329b7172e 100644 --- a/surfsense_web/content/docs/how-to/meta.json +++ b/surfsense_web/content/docs/how-to/meta.json @@ -1,6 +1,6 @@ { "title": "How to", - "pages": ["zero-sync", "realtime-collaboration", "web-search"], + "pages": ["zero-sync", "realtime-collaboration", "web-search", "ollama"], "icon": "Compass", "defaultOpen": false } diff --git a/surfsense_web/content/docs/how-to/ollama.mdx b/surfsense_web/content/docs/how-to/ollama.mdx new file mode 100644 index 000000000..48b231705 --- /dev/null +++ b/surfsense_web/content/docs/how-to/ollama.mdx @@ -0,0 +1,90 @@ +--- +title: Connect Ollama +description: Simple setup guide for using Ollama with SurfSense across local, Docker, remote, and cloud setups +--- + +# Connect Ollama + +Use this page to choose the correct **API Base URL** when adding an Ollama provider in SurfSense. + +## 1) Pick your API Base URL + +| Ollama location | SurfSense location | API Base URL | +|---|---|---| +| Same machine | No Docker | `http://localhost:11434` | +| Host machine (macOS/Windows) | Docker Desktop | `http://host.docker.internal:11434` | +| Host machine (Linux) | Docker Compose | `http://host.docker.internal:11434` | +| Same Docker Compose stack | Docker Compose | `http://ollama:11434` | +| Another machine in your network | Any | `http://:11434` | +| Public Ollama endpoint / proxy / cloud | Any | `http(s)://` | + +If SurfSense runs in Docker, do not use `localhost` unless Ollama is in the same container. + +## 2) Add Ollama in SurfSense + +Go to **Search Space Settings -> Agent Models -> Add Model** and set: + +- Provider: `OLLAMA` +- Model name: your model tag, for example `llama3.2` or `qwen3:8b` +- API Base URL: from the table above +- API key: + - local/self-hosted Ollama: any non-empty value + - Ollama cloud/proxied auth: real key or token required by that endpoint + +Save. SurfSense validates the connection immediately. + +## 3) Common setups + +### A) SurfSense in Docker Desktop, Ollama on your host + +Use: + +```text +http://host.docker.internal:11434 +``` + +### B) Ollama as a service in the same Compose + +Use API Base URL: + +```text +http://ollama:11434 +``` + +Minimal service example: + +```yaml +ollama: + image: ollama/ollama:latest + volumes: + - ollama_data:/root/.ollama + ports: + - "11434:11434" +``` + +### C) Ollama on another machine + +Ollama binds to `127.0.0.1` by default. Make it reachable on the network: + +- Set `OLLAMA_HOST=0.0.0.0:11434` on the machine/service running Ollama +- Open firewall port `11434` +- Use `http://:11434` in SurfSense's API Base URL + +## 4) Quick troubleshooting + +| Error | Cause | Fix | +|---|---|---| +| `Cannot connect to host localhost:11434` | Wrong URL from Dockerized backend | Use `host.docker.internal` or `ollama` | +| `Cannot connect to host :11434` | Ollama not exposed on network or firewall blocked | Set `OLLAMA_HOST=0.0.0.0:11434`, allow port 11434 | +| URL starts with `/%20http://...` | Leading space in URL | Re-enter API Base URL without spaces | +| `model not found` | Model not pulled on Ollama | Run `ollama pull ` | + +If needed, test from the backend container using the same host you put in **API Base URL**: + +```bash +docker compose exec backend curl -v /api/tags +``` + +## See also + +- [Docker Installation](/docs/docker-installation/docker-compose) \ No newline at end of file diff --git a/surfsense_web/contracts/types/stripe.types.ts b/surfsense_web/contracts/types/stripe.types.ts index c4c6f2d74..c8b017044 100644 --- a/surfsense_web/contracts/types/stripe.types.ts +++ b/surfsense_web/contracts/types/stripe.types.ts @@ -49,6 +49,8 @@ export const tokenStripeStatusResponse = z.object({ premium_tokens_remaining: z.number().default(0), }); +export const tokenPurchaseStatusEnum = pagePurchaseStatusEnum; + export const tokenPurchase = z.object({ id: z.uuid(), stripe_checkout_session_id: z.string(), @@ -57,7 +59,7 @@ export const tokenPurchase = z.object({ tokens_granted: z.number(), amount_total: z.number().nullable(), currency: z.string().nullable(), - status: z.string(), + status: tokenPurchaseStatusEnum, completed_at: z.string().nullable(), created_at: z.string(), }); @@ -75,5 +77,6 @@ export type GetPagePurchasesResponse = z.infer; export type CreateTokenCheckoutSessionRequest = z.infer; export type CreateTokenCheckoutSessionResponse = z.infer; export type TokenStripeStatusResponse = z.infer; +export type TokenPurchaseStatus = z.infer; export type TokenPurchase = z.infer; export type GetTokenPurchasesResponse = z.infer; diff --git a/surfsense_web/instrumentation-client.ts b/surfsense_web/instrumentation-client.ts index dff2e9bfe..3ae97fc0b 100644 --- a/surfsense_web/instrumentation-client.ts +++ b/surfsense_web/instrumentation-client.ts @@ -1,18 +1,65 @@ import posthog from "posthog-js"; -function initPostHog() { +/** + * PostHog initialisation for the Next.js renderer. + * + * The same bundle ships in two contexts: + * 1. A normal browser session on surfsense.com -> platform = "web" + * 2. The Electron desktop app (renders the Next app from localhost) + * -> platform = "desktop" + * + * When running inside Electron we also seed `posthog-js` with the main + * process's machine distinctId so that events fired from both the renderer + * (e.g. `chat_message_sent`, page views) and the Electron main process + * (e.g. `desktop_quick_ask_opened`) share a single PostHog person before + * login, and can be merged into the authenticated user afterwards. + */ + +function isElectron(): boolean { + return typeof window !== "undefined" && !!window.electronAPI; +} + +function currentPlatform(): "desktop" | "web" { + return isElectron() ? "desktop" : "web"; +} + +async function resolveBootstrapDistinctId(): Promise { + if (!isElectron() || !window.electronAPI?.getAnalyticsContext) return undefined; + try { + const ctx = await window.electronAPI.getAnalyticsContext(); + return ctx?.machineId || ctx?.distinctId || undefined; + } catch { + return undefined; + } +} + +async function initPostHog() { try { if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) return; + const platform = currentPlatform(); + const bootstrapDistinctId = await resolveBootstrapDistinctId(); + posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, { api_host: "https://assets.surfsense.com", ui_host: "https://us.posthog.com", defaults: "2026-01-30", capture_pageview: "history_change", capture_pageleave: true, + ...(bootstrapDistinctId + ? { + bootstrap: { + distinctID: bootstrapDistinctId, + isIdentifiedID: false, + }, + } + : {}), before_send: (event) => { if (event?.properties) { - event.properties.platform = "web"; + event.properties.platform = platform; + if (platform === "desktop") { + event.properties.is_desktop = true; + } const params = new URLSearchParams(window.location.search); const ref = params.get("ref"); @@ -30,9 +77,14 @@ function initPostHog() { event.properties.$set = { ...event.properties.$set, - platform: "web", + platform, last_seen_at: new Date().toISOString(), }; + + event.properties.$set_once = { + ...event.properties.$set_once, + first_seen_platform: platform, + }; } return event; }, @@ -51,8 +103,12 @@ if (typeof window !== "undefined") { window.posthog = posthog; if ("requestIdleCallback" in window) { - requestIdleCallback(initPostHog); + requestIdleCallback(() => { + void initPostHog(); + }); } else { - setTimeout(initPostHog, 3500); + setTimeout(() => { + void initPostHog(); + }, 3500); } } diff --git a/surfsense_web/lib/posthog/events.ts b/surfsense_web/lib/posthog/events.ts index 53aaa71b9..34ed3044d 100644 --- a/surfsense_web/lib/posthog/events.ts +++ b/surfsense_web/lib/posthog/events.ts @@ -1,4 +1,5 @@ import posthog from "posthog-js"; +import { getConnectorTelemetryMeta } from "@/components/assistant-ui/connector-popup/constants/connector-constants"; /** * PostHog Analytics Event Definitions @@ -13,8 +14,8 @@ import posthog from "posthog-js"; * - auth: Authentication events * - search_space: Search space management * - document: Document management - * - chat: Chat and messaging - * - connector: External connector events + * - chat: Chat and messaging (authenticated + anonymous) + * - connector: External connector events (all lifecycle stages) * - contact: Contact form events * - settings: Settings changes * - marketing: Marketing/referral tracking @@ -28,6 +29,17 @@ function safeCapture(event: string, properties?: Record) { } } +/** + * Drop undefined values so PostHog doesn't log `"foo": undefined` noise. + */ +function compact>(obj: T): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(obj)) { + if (v !== undefined) out[k] = v; + } + return out; +} + // ============================================ // AUTH EVENTS // ============================================ @@ -127,6 +139,28 @@ export function trackChatError(searchSpaceId: number, chatId: number, error?: st }); } +/** + * Track a message sent from the unauthenticated "free" / anonymous chat + * flow. This is intentionally a separate event from `chat_message_sent` + * so WAU / retention queries on the authenticated event stay clean while + * still giving us visibility into top-of-funnel usage on /free/*. + */ +export function trackAnonymousChatMessageSent(options: { + modelSlug: string; + messageLength?: number; + hasUploadedDoc?: boolean; + webSearchEnabled?: boolean; + surface?: "free_chat_page" | "free_model_page"; +}) { + safeCapture("anonymous_chat_message_sent", { + model_slug: options.modelSlug, + message_length: options.messageLength, + has_uploaded_doc: options.hasUploadedDoc ?? false, + web_search_enabled: options.webSearchEnabled, + surface: options.surface, + }); +} + // ============================================ // DOCUMENT EVENTS // ============================================ @@ -179,37 +213,88 @@ export function trackYouTubeImport(searchSpaceId: number, url: string) { } // ============================================ -// CONNECTOR EVENTS +// CONNECTOR EVENTS (generic lifecycle dispatcher) // ============================================ +// +// All connector events go through `trackConnectorEvent`. The connector's +// human-readable title and its group (oauth/composio/crawler/other) are +// auto-attached from the shared registry in `connector-constants.ts`, so +// adding a new connector to that list is the only change required for it +// to show up correctly in PostHog dashboards. -export function trackConnectorSetupStarted(searchSpaceId: number, connectorType: string) { - safeCapture("connector_setup_started", { - search_space_id: searchSpaceId, - connector_type: connectorType, +export type ConnectorEventStage = + | "setup_started" + | "setup_success" + | "setup_failure" + | "oauth_initiated" + | "connected" + | "deleted" + | "synced"; + +export interface ConnectorEventOptions { + searchSpaceId?: number | null; + connectorId?: number | null; + /** Source of the action (e.g. "oauth_callback", "non_oauth_form", "webcrawler_quick_add"). */ + source?: string; + /** Free-form error message for failure events. */ + error?: string; + /** Extra properties specific to the stage (e.g. frequency_minutes for sync events). */ + extra?: Record; +} + +/** + * Generic connector lifecycle tracker. Every connector analytics event + * should funnel through here so the enrichment stays consistent. + */ +export function trackConnectorEvent( + stage: ConnectorEventStage, + connectorType: string, + options: ConnectorEventOptions = {} +) { + const meta = getConnectorTelemetryMeta(connectorType); + safeCapture(`connector_${stage}`, { + ...compact({ + search_space_id: options.searchSpaceId ?? undefined, + connector_id: options.connectorId ?? undefined, + source: options.source, + error: options.error, + }), + connector_type: meta.connector_type, + connector_title: meta.connector_title, + connector_group: meta.connector_group, + is_oauth: meta.is_oauth, + ...(options.extra ?? {}), }); } +// ---- Convenience wrappers kept for backward compatibility ---- + +export function trackConnectorSetupStarted( + searchSpaceId: number, + connectorType: string, + source?: string +) { + trackConnectorEvent("setup_started", connectorType, { searchSpaceId, source }); +} + export function trackConnectorSetupSuccess( searchSpaceId: number, connectorType: string, connectorId: number ) { - safeCapture("connector_setup_success", { - search_space_id: searchSpaceId, - connector_type: connectorType, - connector_id: connectorId, - }); + trackConnectorEvent("setup_success", connectorType, { searchSpaceId, connectorId }); } export function trackConnectorSetupFailure( - searchSpaceId: number, + searchSpaceId: number | null | undefined, connectorType: string, - error?: string + error?: string, + source?: string ) { - safeCapture("connector_setup_failure", { - search_space_id: searchSpaceId, - connector_type: connectorType, + trackConnectorEvent("setup_failure", connectorType, { + searchSpaceId: searchSpaceId ?? undefined, error, + source, }); } @@ -218,11 +303,7 @@ export function trackConnectorDeleted( connectorType: string, connectorId: number ) { - safeCapture("connector_deleted", { - search_space_id: searchSpaceId, - connector_type: connectorType, - connector_id: connectorId, - }); + trackConnectorEvent("deleted", connectorType, { searchSpaceId, connectorId }); } export function trackConnectorSynced( @@ -230,11 +311,7 @@ export function trackConnectorSynced( connectorType: string, connectorId: number ) { - safeCapture("connector_synced", { - search_space_id: searchSpaceId, - connector_type: connectorType, - connector_id: connectorId, - }); + trackConnectorEvent("synced", connectorType, { searchSpaceId, connectorId }); } // ============================================ @@ -345,10 +422,9 @@ export function trackConnectorConnected( connectorType: string, connectorId?: number ) { - safeCapture("connector_connected", { - search_space_id: searchSpaceId, - connector_type: connectorType, - connector_id: connectorId, + trackConnectorEvent("connected", connectorType, { + searchSpaceId, + connectorId: connectorId ?? undefined, }); } @@ -467,8 +543,13 @@ export function trackReferralLanding(refCode: string, landingUrl: string) { // ============================================ /** - * Identify a user for PostHog analytics - * Call this after successful authentication + * Identify a user for PostHog analytics. + * Call this after successful authentication. + * + * In the Electron desktop app the same call is mirrored into the + * main-process PostHog client so desktop-only events (e.g. + * `desktop_quick_ask_opened`, `desktop_autocomplete_accepted`) are + * attributed to the logged-in user rather than an anonymous machine ID. */ export function identifyUser(userId: string, properties?: Record) { try { @@ -476,10 +557,19 @@ export function identifyUser(userId: string, properties?: Record=6.9.0'} - '@babel/runtime@7.29.2': resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} @@ -2188,12 +2184,6 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/core@2.5.1': - resolution: {integrity: sha512-Dwlc+3HAZqpgTYq0MUyZABjFkcrKTePwuiFVLjahGD8cx3enqihmpAmdgNFO1R4m/sIe5afjJrA25Prqy4NXlA==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/core@2.6.0': resolution: {integrity: sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==} engines: {node: ^18.19.0 || >=20.6.0} @@ -2606,12 +2596,6 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' - '@opentelemetry/resources@2.5.1': - resolution: {integrity: sha512-BViBCdE/GuXRlp9k7nS1w6wJvY5fnFX5XvuEtWsTAOQFIO89Eru7lGW3WbfbxtCuZ/GbrJfAziXG0w0dpxL7eQ==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': '>=1.3.0 <1.10.0' - '@opentelemetry/resources@2.6.0': resolution: {integrity: sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ==} engines: {node: ^18.19.0 || >=20.6.0} @@ -4372,8 +4356,8 @@ packages: peerDependencies: react: '>= 16' - '@tabler/icons@3.37.1': - resolution: {integrity: sha512-neLCWkuyNHEPXCyYu6nbN4S3g/59BTa4qyITAugYVpq1YzYNDOZooW7/vRWH98ZItXAudxdKU8muFT7y1PqzuA==} + '@tabler/icons@3.41.1': + resolution: {integrity: sha512-OaRnVbRmH2nHtFeg+RmMJ/7m2oBIF9XCJAUD5gQnMrpK9f05ydj8MZrAf3NZQqOXyxGN1UBL0D5IKLLEUfr74Q==} '@tailwindcss/node@4.2.1': resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==} @@ -4814,6 +4798,7 @@ packages: '@xmldom/xmldom@0.8.11': resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version abstract-logging@2.0.1: resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} @@ -7012,11 +6997,6 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - nanoid@5.1.6: - resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==} - engines: {node: ^18 || >=20} - hasBin: true - nanoid@5.1.7: resolution: {integrity: sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==} engines: {node: ^18 || >=20} @@ -7421,8 +7401,8 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} - protobufjs@7.5.4: - resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} + protobufjs@7.5.5: + resolution: {integrity: sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==} engines: {node: '>=12.0.0'} proxy-compare@2.6.0: @@ -9387,8 +9367,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/runtime@7.28.6': {} - '@babel/runtime@7.29.2': {} '@babel/standalone@7.29.2': {} @@ -9886,7 +9864,7 @@ snapshots: dependencies: lodash.camelcase: 4.3.0 long: 5.3.2 - protobufjs: 7.5.4 + protobufjs: 7.5.5 yargs: 17.7.2 '@hookform/resolvers@5.2.2(react-hook-form@7.71.2(react@19.2.4))': @@ -10246,7 +10224,7 @@ snapshots: '@opentelemetry/resource-detector-azure': 0.10.0(@opentelemetry/api@1.9.0) '@opentelemetry/resource-detector-container': 0.7.11(@opentelemetry/api@1.9.0) '@opentelemetry/resource-detector-gcp': 0.37.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-node': 0.203.0(@opentelemetry/api@1.9.0) transitivePeerDependencies: - encoding @@ -10270,11 +10248,6 @@ snapshots: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.39.0 - '@opentelemetry/core@2.5.1(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/semantic-conventions': 1.39.0 - '@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -10397,7 +10370,7 @@ snapshots: '@opentelemetry/instrumentation-amqplib@0.50.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.39.0 transitivePeerDependencies: @@ -10415,7 +10388,7 @@ snapshots: '@opentelemetry/instrumentation-aws-sdk@0.58.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.39.0 transitivePeerDependencies: @@ -10441,7 +10414,7 @@ snapshots: '@opentelemetry/instrumentation-connect@0.47.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.39.0 '@types/connect': 3.4.38 @@ -10473,7 +10446,7 @@ snapshots: '@opentelemetry/instrumentation-express@0.52.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.39.0 transitivePeerDependencies: @@ -10482,7 +10455,7 @@ snapshots: '@opentelemetry/instrumentation-fastify@0.48.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.39.0 transitivePeerDependencies: @@ -10491,7 +10464,7 @@ snapshots: '@opentelemetry/instrumentation-fs@0.23.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) transitivePeerDependencies: - supports-color @@ -10521,7 +10494,7 @@ snapshots: '@opentelemetry/instrumentation-hapi@0.50.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.39.0 transitivePeerDependencies: @@ -10565,7 +10538,7 @@ snapshots: '@opentelemetry/instrumentation-koa@0.51.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.39.0 transitivePeerDependencies: @@ -10598,7 +10571,7 @@ snapshots: '@opentelemetry/instrumentation-mongoose@0.50.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.39.0 transitivePeerDependencies: @@ -10650,7 +10623,7 @@ snapshots: '@opentelemetry/instrumentation-pg@0.56.1(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.39.0 '@opentelemetry/sql-common': 0.41.2(@opentelemetry/api@1.9.0) @@ -10663,7 +10636,7 @@ snapshots: dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/api-logs': 0.203.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) transitivePeerDependencies: - supports-color @@ -10680,7 +10653,7 @@ snapshots: '@opentelemetry/instrumentation-restify@0.49.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.39.0 transitivePeerDependencies: @@ -10721,7 +10694,7 @@ snapshots: '@opentelemetry/instrumentation-undici@0.14.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) transitivePeerDependencies: - supports-color @@ -10772,7 +10745,7 @@ snapshots: '@opentelemetry/sdk-logs': 0.203.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-metrics': 2.0.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 2.0.1(@opentelemetry/api@1.9.0) - protobufjs: 7.5.4 + protobufjs: 7.5.5 '@opentelemetry/otlp-transformer@0.208.0(@opentelemetry/api@1.9.0)': dependencies: @@ -10783,7 +10756,7 @@ snapshots: '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-metrics': 2.2.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0) - protobufjs: 7.5.4 + protobufjs: 7.5.5 '@opentelemetry/propagator-b3@2.0.1(@opentelemetry/api@1.9.0)': dependencies: @@ -10800,34 +10773,34 @@ snapshots: '@opentelemetry/resource-detector-alibaba-cloud@0.31.11(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/resource-detector-aws@2.13.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.39.0 '@opentelemetry/resource-detector-azure@0.10.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.39.0 '@opentelemetry/resource-detector-container@0.7.11(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/resource-detector-gcp@0.37.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.39.0 gcp-metadata: 6.1.1 transitivePeerDependencies: @@ -10846,12 +10819,6 @@ snapshots: '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.39.0 - '@opentelemetry/resources@2.5.1(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.39.0 - '@opentelemetry/resources@2.6.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -10952,7 +10919,7 @@ snapshots: '@opentelemetry/sql-common@0.41.2(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) '@orama/orama@3.1.18': {} @@ -11067,7 +11034,7 @@ snapshots: jotai-optics: 0.4.0(jotai@2.8.4(@types/react@19.2.14)(react@19.2.4))(optics-ts@2.4.1) jotai-x: 2.3.3(@types/react@19.2.14)(jotai@2.8.4(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) lodash: 4.17.23 - nanoid: 5.1.6 + nanoid: 5.1.7 optics-ts: 2.4.1 react: 19.2.4 react-compiler-runtime: 1.0.0(react@19.2.4) @@ -11265,11 +11232,11 @@ snapshots: '@radix-ui/primitive@1.0.0': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 '@radix-ui/primitive@1.0.1': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 '@radix-ui/primitive@1.1.3': {} @@ -11403,12 +11370,12 @@ snapshots: '@radix-ui/react-compose-refs@1.0.0(react@19.2.4)': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 react: 19.2.4 '@radix-ui/react-compose-refs@1.0.1(@types/react@19.2.14)(react@19.2.4)': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 react: 19.2.4 optionalDependencies: '@types/react': 19.2.14 @@ -11435,12 +11402,12 @@ snapshots: '@radix-ui/react-context@1.0.0(react@19.2.4)': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 react: 19.2.4 '@radix-ui/react-context@1.0.1(@types/react@19.2.14)(react@19.2.4)': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 react: 19.2.4 optionalDependencies: '@types/react': 19.2.14 @@ -11459,7 +11426,7 @@ snapshots: '@radix-ui/react-dialog@1.0.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 '@radix-ui/primitive': 1.0.0 '@radix-ui/react-compose-refs': 1.0.0(react@19.2.4) '@radix-ui/react-context': 1.0.0(react@19.2.4) @@ -11481,7 +11448,7 @@ snapshots: '@radix-ui/react-dialog@1.0.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 '@radix-ui/primitive': 1.0.1 '@radix-ui/react-compose-refs': 1.0.1(@types/react@19.2.14)(react@19.2.4) '@radix-ui/react-context': 1.0.1(@types/react@19.2.14)(react@19.2.4) @@ -11532,7 +11499,7 @@ snapshots: '@radix-ui/react-dismissable-layer@1.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 '@radix-ui/primitive': 1.0.0 '@radix-ui/react-compose-refs': 1.0.0(react@19.2.4) '@radix-ui/react-primitive': 1.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -11543,7 +11510,7 @@ snapshots: '@radix-ui/react-dismissable-layer@1.0.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 '@radix-ui/primitive': 1.0.1 '@radix-ui/react-compose-refs': 1.0.1(@types/react@19.2.14)(react@19.2.4) '@radix-ui/react-primitive': 1.0.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -11585,12 +11552,12 @@ snapshots: '@radix-ui/react-focus-guards@1.0.0(react@19.2.4)': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 react: 19.2.4 '@radix-ui/react-focus-guards@1.0.1(@types/react@19.2.14)(react@19.2.4)': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 react: 19.2.4 optionalDependencies: '@types/react': 19.2.14 @@ -11603,7 +11570,7 @@ snapshots: '@radix-ui/react-focus-scope@1.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 '@radix-ui/react-compose-refs': 1.0.0(react@19.2.4) '@radix-ui/react-primitive': 1.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-use-callback-ref': 1.0.0(react@19.2.4) @@ -11612,7 +11579,7 @@ snapshots: '@radix-ui/react-focus-scope@1.0.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 '@radix-ui/react-compose-refs': 1.0.1(@types/react@19.2.14)(react@19.2.4) '@radix-ui/react-primitive': 1.0.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@19.2.14)(react@19.2.4) @@ -11666,13 +11633,13 @@ snapshots: '@radix-ui/react-id@1.0.0(react@19.2.4)': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 '@radix-ui/react-use-layout-effect': 1.0.0(react@19.2.4) react: 19.2.4 '@radix-ui/react-id@1.0.1(@types/react@19.2.14)(react@19.2.4)': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@19.2.14)(react@19.2.4) react: 19.2.4 optionalDependencies: @@ -11848,14 +11815,14 @@ snapshots: '@radix-ui/react-portal@1.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 '@radix-ui/react-primitive': 1.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) '@radix-ui/react-portal@1.0.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 '@radix-ui/react-primitive': 1.0.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) @@ -11875,7 +11842,7 @@ snapshots: '@radix-ui/react-presence@1.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 '@radix-ui/react-compose-refs': 1.0.0(react@19.2.4) '@radix-ui/react-use-layout-effect': 1.0.0(react@19.2.4) react: 19.2.4 @@ -11883,7 +11850,7 @@ snapshots: '@radix-ui/react-presence@1.0.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 '@radix-ui/react-compose-refs': 1.0.1(@types/react@19.2.14)(react@19.2.4) '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@19.2.14)(react@19.2.4) react: 19.2.4 @@ -11904,14 +11871,14 @@ snapshots: '@radix-ui/react-primitive@1.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 '@radix-ui/react-slot': 1.0.0(react@19.2.4) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) '@radix-ui/react-primitive@1.0.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 '@radix-ui/react-slot': 1.0.2(@types/react@19.2.14)(react@19.2.4) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) @@ -12067,13 +12034,13 @@ snapshots: '@radix-ui/react-slot@1.0.0(react@19.2.4)': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 '@radix-ui/react-compose-refs': 1.0.0(react@19.2.4) react: 19.2.4 '@radix-ui/react-slot@1.0.2(@types/react@19.2.14)(react@19.2.4)': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 '@radix-ui/react-compose-refs': 1.0.1(@types/react@19.2.14)(react@19.2.4) react: 19.2.4 optionalDependencies: @@ -12207,12 +12174,12 @@ snapshots: '@radix-ui/react-use-callback-ref@1.0.0(react@19.2.4)': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 react: 19.2.4 '@radix-ui/react-use-callback-ref@1.0.1(@types/react@19.2.14)(react@19.2.4)': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 react: 19.2.4 optionalDependencies: '@types/react': 19.2.14 @@ -12225,13 +12192,13 @@ snapshots: '@radix-ui/react-use-controllable-state@1.0.0(react@19.2.4)': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 '@radix-ui/react-use-callback-ref': 1.0.0(react@19.2.4) react: 19.2.4 '@radix-ui/react-use-controllable-state@1.0.1(@types/react@19.2.14)(react@19.2.4)': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@19.2.14)(react@19.2.4) react: 19.2.4 optionalDependencies: @@ -12254,13 +12221,13 @@ snapshots: '@radix-ui/react-use-escape-keydown@1.0.0(react@19.2.4)': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 '@radix-ui/react-use-callback-ref': 1.0.0(react@19.2.4) react: 19.2.4 '@radix-ui/react-use-escape-keydown@1.0.3(@types/react@19.2.14)(react@19.2.4)': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@19.2.14)(react@19.2.4) react: 19.2.4 optionalDependencies: @@ -12282,12 +12249,12 @@ snapshots: '@radix-ui/react-use-layout-effect@1.0.0(react@19.2.4)': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 react: 19.2.4 '@radix-ui/react-use-layout-effect@1.0.1(@types/react@19.2.14)(react@19.2.4)': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 react: 19.2.4 optionalDependencies: '@types/react': 19.2.14 @@ -12389,7 +12356,7 @@ snapshots: '@opentelemetry/api-logs': 0.203.0 '@opentelemetry/auto-instrumentations-node': 0.62.2(@opentelemetry/api@1.9.0)(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0)) '@opentelemetry/exporter-metrics-otlp-http': 0.203.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-metrics': 2.2.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-node': 0.203.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-node': 2.6.0(@opentelemetry/api@1.9.0) @@ -12417,7 +12384,7 @@ snapshots: js-xxhash: 4.0.0 json-custom-numbers: 3.1.1 kasi: 1.1.2 - nanoid: 5.1.6 + nanoid: 5.1.7 parse-prometheus-text-format: 1.1.1 pg-format: pg-format-fix@1.0.5 postgres: 3.4.7 @@ -12756,10 +12723,10 @@ snapshots: '@tabler/icons-react@3.37.1(react@19.2.4)': dependencies: - '@tabler/icons': 3.37.1 + '@tabler/icons': 3.41.1 react: 19.2.4 - '@tabler/icons@3.37.1': {} + '@tabler/icons@3.41.1': {} '@tailwindcss/node@4.2.1': dependencies: @@ -15875,8 +15842,6 @@ snapshots: nanoid@3.3.11: {} - nanoid@5.1.6: {} - nanoid@5.1.7: {} napi-build-utils@2.0.0: {} @@ -16256,7 +16221,7 @@ snapshots: '@opentelemetry/api': 1.9.0 '@opentelemetry/api-logs': 0.208.0 '@opentelemetry/exporter-logs-otlp-http': 0.208.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0) '@posthog/core': 1.23.1 '@posthog/types': 1.352.1 @@ -16323,7 +16288,7 @@ snapshots: property-information@7.1.0: {} - protobufjs@7.5.4: + protobufjs@7.5.5: dependencies: '@protobufjs/aspromise': 1.1.2 '@protobufjs/base64': 1.1.2 @@ -16560,7 +16525,7 @@ snapshots: react-syntax-highlighter@15.6.6(react@19.2.4): dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 highlight.js: 10.7.3 highlightjs-vue: 1.0.0 lowlight: 1.20.0 @@ -16645,7 +16610,7 @@ snapshots: redux@4.2.1: dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 reflect.getprototypeof@1.0.10: dependencies: diff --git a/surfsense_web/types/window.d.ts b/surfsense_web/types/window.d.ts index 004aefcd5..a80520684 100644 --- a/surfsense_web/types/window.d.ts +++ b/surfsense_web/types/window.d.ts @@ -102,9 +102,29 @@ interface ElectronAPI { setShortcuts: ( config: Partial<{ generalAssist: string; quickAsk: string; autocomplete: string }> ) => Promise<{ generalAssist: string; quickAsk: string; autocomplete: string }>; + // Launch on system startup + getAutoLaunch: () => Promise<{ + enabled: boolean; + openAsHidden: boolean; + supported: boolean; + }>; + setAutoLaunch: ( + enabled: boolean, + openAsHidden?: boolean + ) => Promise<{ enabled: boolean; openAsHidden: boolean; supported: boolean }>; // Active search space getActiveSearchSpace: () => Promise; setActiveSearchSpace: (id: string) => Promise; + // Analytics bridge (PostHog mirror into the Electron main process) + analyticsIdentify: (userId: string, properties?: Record) => Promise; + analyticsReset: () => Promise; + analyticsCapture: (event: string, properties?: Record) => Promise; + getAnalyticsContext: () => Promise<{ + distinctId: string; + machineId: string; + appVersion: string; + platform: string; + }>; } declare global {