diff --git a/.gitignore b/.gitignore index db3a13b..5216aee 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ dist/ .env .env.local apps/*/dist/ +apps/*/out/ +apps/*/release/ apps/*/node_modules/ apps/*/.output/ apps/*/.wxt/ @@ -15,4 +17,4 @@ apps/*/firefox-mv*/ .superpowers/* .playwright-mcp/* *.log -*.tmp \ No newline at end of file +*.tmp diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts index 25c798f..7b42c97 100644 --- a/apps/admin-web/src/lib/api.ts +++ b/apps/admin-web/src/lib/api.ts @@ -869,28 +869,32 @@ export const brandsApi = { export const mediaApi = { platforms() { - return apiClient.get("/api/tenant/media/platforms"); + return removedMediaApi("platforms"); }, accounts() { - return apiClient.get("/api/tenant/media/platform-accounts"); + return removedMediaApi("accounts"); }, createPluginSession(payload: CreatePluginSessionRequest) { - return apiClient.post( - "/api/tenant/media/plugin-sessions", - payload, - ); + void payload; + return removedMediaApi("create_plugin_session"); }, registerPluginInstallation(payload: RegisterPluginInstallationRequest) { - return apiClient.post( - "/api/tenant/media/plugin-installations/register", - payload, - ); + void payload; + return removedMediaApi("register_plugin_installation"); }, unbindAccount(id: number) { - return apiClient.remove(`/api/tenant/media/platform-accounts/${id}`); + void id; + return removedMediaApi("unbind_account"); }, }; +function removedMediaApi(action: string): Promise { + return Promise.reject(new ApiClientError({ + message: "desktop_media_api_removed", + detail: `legacy media API action "${action}" has been removed; use desktop client accounts and publish jobs instead`, + })); +} + export const monitoringApi = { dashboardComposite(params: { brand_id?: number; diff --git a/apps/browser-extension/src/monitoring-runtime.ts b/apps/browser-extension/src/monitoring-runtime.ts index 77f07da..2d5dba0 100644 --- a/apps/browser-extension/src/monitoring-runtime.ts +++ b/apps/browser-extension/src/monitoring-runtime.ts @@ -1,5 +1,4 @@ import type { - ApiEnvelope, JsonValue, MonitoringHeartbeatPlatformPayload, MonitoringHeartbeatResponse, @@ -161,30 +160,9 @@ function toMonitoringPlatformPayload(platform: StoredMonitoringPlatformState): M } async function postMonitoringApi(path: string, payload: unknown): Promise { - const state = await getExtensionState(); - if (!state.api_base_url) { - throw new Error("monitoring_api_base_url_missing"); - } - if (!state.plugin_installation_id || !state.installation_token) { - throw new Error("monitoring_installation_credentials_missing"); - } - - const response = await fetch(new URL(path.replace(/^\//, ""), normalizeBaseUrl(state.api_base_url)).toString(), { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Geo-Installation-Id": String(state.plugin_installation_id), - "X-Geo-Installation-Token": state.installation_token, - }, - body: JSON.stringify(payload), - }); - - const body = (await response.json()) as ApiEnvelope | { message?: string }; - if (!response.ok || !("data" in body)) { - throw new Error(body.message || "monitoring_plugin_request_failed"); - } - - return body.data; + void path; + void payload; + throw new Error("browser_extension_monitoring_removed_use_desktop_client"); } async function detectAllMonitoringPlatforms(): Promise { @@ -255,11 +233,12 @@ async function markMonitoringTaskSkipped(task: MonitoringLeaseTask, reason: stri error_message: normalizeText(errorMessage), completed_at: new Date().toISOString(), }; - return await postMonitoringApi(`/api/plugin/monitoring/tasks/${task.task_id}/skip`, payload); + return await postMonitoringApi("skip_task", payload); } async function submitMonitoringTaskResult(task: MonitoringLeaseTask, payload: MonitoringTaskResultPayload): Promise { - return await postMonitoringApi(`/api/plugin/monitoring/tasks/${task.task_id}/result`, payload); + void task; + return await postMonitoringApi("submit_result", payload); } async function processMonitoringLeasedTask( @@ -290,7 +269,7 @@ async function resumeMonitoringTasks(aiPlatformIDs: string[]): Promise("/api/plugin/monitoring/tasks/resume", payload); + const resumed = await postMonitoringApi("resume_tasks", payload); return resumed.tasks; } @@ -299,7 +278,7 @@ async function leaseMonitoringTasks(aiPlatformIDs: string[]): Promise("/api/plugin/monitoring/tasks/lease", payload); + return await postMonitoringApi("lease_tasks", payload); } function mergeMonitoringQueueTasks( @@ -580,7 +559,7 @@ async function handleMonitoringSyncHeartbeat(allowCachedRemote: boolean): Promis } try { - const heartbeat = await postMonitoringApi("/api/plugin/monitoring/heartbeat", { + const heartbeat = await postMonitoringApi("heartbeat", { platforms: persistedPlatforms.map(toMonitoringPlatformPayload), }); diff --git a/apps/desktop-client/DESIGN.md b/apps/desktop-client/DESIGN.md new file mode 100644 index 0000000..3a70171 --- /dev/null +++ b/apps/desktop-client/DESIGN.md @@ -0,0 +1,367 @@ +# Design System Inspiration of Linear + +## 1. Visual Theme & Atmosphere + +Linear's website is a masterclass in dark-mode-first product design — a near-black canvas (`#08090a`) where content emerges from darkness like starlight. The overall impression is one of extreme precision engineering: every element exists in a carefully calibrated hierarchy of luminance, from barely-visible borders (`rgba(255,255,255,0.05)`) to soft, luminous text (`#f7f8f8`). This is not a dark theme applied to a light design — it is darkness as the native medium, where information density is managed through subtle gradations of white opacity rather than color variation. + +The typography system is built entirely on Inter Variable with OpenType features `"cv01"` and `"ss03"` enabled globally, giving the typeface a cleaner, more geometric character. Inter is used at a remarkable range of weights — from 300 (light body) through 510 (medium, Linear's signature weight) to 590 (semibold emphasis). The 510 weight is particularly distinctive: it sits between regular and medium, creating a subtle emphasis that doesn't shout. At display sizes (72px, 64px, 48px), Inter uses aggressive negative letter-spacing (-1.584px to -1.056px), creating compressed, authoritative headlines that feel engineered rather than designed. Berkeley Mono serves as the monospace companion for code and technical labels, with fallbacks to ui-monospace, SF Mono, and Menlo. + +The color system is almost entirely achromatic — dark backgrounds with white/gray text — punctuated by a single brand accent: Linear's signature indigo-violet (`#5e6ad2` for backgrounds, `#7170ff` for interactive accents). This accent color is used sparingly and intentionally, appearing only on CTAs, active states, and brand elements. The border system uses ultra-thin, semi-transparent white borders (`rgba(255,255,255,0.05)` to `rgba(255,255,255,0.08)`) that create structure without visual noise, like wireframes drawn in moonlight. + +**Key Characteristics:** +- Dark-mode-native: `#08090a` marketing background, `#0f1011` panel background, `#191a1b` elevated surfaces +- Inter Variable with `"cv01", "ss03"` globally — geometric alternates for a cleaner aesthetic +- Signature weight 510 (between regular and medium) for most UI text +- Aggressive negative letter-spacing at display sizes (-1.584px at 72px, -1.056px at 48px) +- Brand indigo-violet: `#5e6ad2` (bg) / `#7170ff` (accent) / `#828fff` (hover) — the only chromatic color in the system +- Semi-transparent white borders throughout: `rgba(255,255,255,0.05)` to `rgba(255,255,255,0.08)` +- Button backgrounds at near-zero opacity: `rgba(255,255,255,0.02)` to `rgba(255,255,255,0.05)` +- Multi-layered shadows with inset variants for depth on dark surfaces +- Radix UI primitives as the component foundation (6 detected primitives) +- Success green (`#27a644`, `#10b981`) used only for status indicators + +## 2. Color Palette & Roles + +### Background Surfaces +- **Marketing Black** (`#010102` / `#08090a`): The deepest background — the canvas for hero sections and marketing pages. Near-pure black with an imperceptible blue-cool undertone. +- **Panel Dark** (`#0f1011`): Sidebar and panel backgrounds. One step up from the marketing black. +- **Level 3 Surface** (`#191a1b`): Elevated surface areas, card backgrounds, dropdowns. +- **Secondary Surface** (`#28282c`): The lightest dark surface — used for hover states and slightly elevated components. + +### Text & Content +- **Primary Text** (`#f7f8f8`): Near-white with a barely-warm cast. The default text color — not pure white, preventing eye strain on dark backgrounds. +- **Secondary Text** (`#d0d6e0`): Cool silver-gray for body text, descriptions, and secondary content. +- **Tertiary Text** (`#8a8f98`): Muted gray for placeholders, metadata, and de-emphasized content. +- **Quaternary Text** (`#62666d`): The most subdued text — timestamps, disabled states, subtle labels. + +### Brand & Accent +- **Brand Indigo** (`#5e6ad2`): Primary brand color — used for CTA button backgrounds, brand marks, and key interactive surfaces. +- **Accent Violet** (`#7170ff`): Brighter variant for interactive elements — links, active states, selected items. +- **Accent Hover** (`#828fff`): Lighter, more saturated variant for hover states on accent elements. +- **Security Lavender** (`#7a7fad`): Muted indigo used specifically for security-related UI elements. + +### Status Colors +- **Green** (`#27a644`): Primary success/active status. Used for "in progress" indicators. +- **Emerald** (`#10b981`): Secondary success — pill badges, completion states. + +### Border & Divider +- **Border Primary** (`#23252a`): Solid dark border for prominent separations. +- **Border Secondary** (`#34343a`): Slightly lighter solid border. +- **Border Tertiary** (`#3e3e44`): Lightest solid border variant. +- **Border Subtle** (`rgba(255,255,255,0.05)`): Ultra-subtle semi-transparent border — the default. +- **Border Standard** (`rgba(255,255,255,0.08)`): Standard semi-transparent border for cards, inputs, code blocks. +- **Line Tint** (`#141516`): Nearly invisible line for the subtlest divisions. +- **Line Tertiary** (`#18191a`): Slightly more visible divider line. + +### Light Mode Neutrals (for light theme contexts) +- **Light Background** (`#f7f8f8`): Page background in light mode. +- **Light Surface** (`#f3f4f5` / `#f5f6f7`): Subtle surface tinting. +- **Light Border** (`#d0d6e0`): Visible border in light contexts. +- **Light Border Alt** (`#e6e6e6`): Alternative lighter border. +- **Pure White** (`#ffffff`): Card surfaces, highlights. + +### Overlay +- **Overlay Primary** (`rgba(0,0,0,0.85)`): Modal/dialog backdrop — extremely dark for focus isolation. + +## 3. Typography Rules + +### Font Family +- **Primary**: `Inter Variable`, with fallbacks: `SF Pro Display, -apple-system, system-ui, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Open Sans, Helvetica Neue` +- **Monospace**: `Berkeley Mono`, with fallbacks: `ui-monospace, SF Mono, Menlo` +- **OpenType Features**: `"cv01", "ss03"` enabled globally — cv01 provides an alternate lowercase 'a' (single-story), ss03 adjusts specific letterforms for a cleaner geometric appearance. + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|------|--------|-------------|----------------|-------| +| Display XL | Inter Variable | 72px (4.50rem) | 510 | 1.00 (tight) | -1.584px | Hero headlines, maximum impact | +| Display Large | Inter Variable | 64px (4.00rem) | 510 | 1.00 (tight) | -1.408px | Secondary hero text | +| Display | Inter Variable | 48px (3.00rem) | 510 | 1.00 (tight) | -1.056px | Section headlines | +| Heading 1 | Inter Variable | 32px (2.00rem) | 400 | 1.13 (tight) | -0.704px | Major section titles | +| Heading 2 | Inter Variable | 24px (1.50rem) | 400 | 1.33 | -0.288px | Sub-section headings | +| Heading 3 | Inter Variable | 20px (1.25rem) | 590 | 1.33 | -0.24px | Feature titles, card headers | +| Body Large | Inter Variable | 18px (1.13rem) | 400 | 1.60 (relaxed) | -0.165px | Introduction text, feature descriptions | +| Body Emphasis | Inter Variable | 17px (1.06rem) | 590 | 1.60 (relaxed) | normal | Emphasized body, sub-headings in content | +| Body | Inter Variable | 16px (1.00rem) | 400 | 1.50 | normal | Standard reading text | +| Body Medium | Inter Variable | 16px (1.00rem) | 510 | 1.50 | normal | Navigation, labels | +| Body Semibold | Inter Variable | 16px (1.00rem) | 590 | 1.50 | normal | Strong emphasis | +| Small | Inter Variable | 15px (0.94rem) | 400 | 1.60 (relaxed) | -0.165px | Secondary body text | +| Small Medium | Inter Variable | 15px (0.94rem) | 510 | 1.60 (relaxed) | -0.165px | Emphasized small text | +| Small Semibold | Inter Variable | 15px (0.94rem) | 590 | 1.60 (relaxed) | -0.165px | Strong small text | +| Small Light | Inter Variable | 15px (0.94rem) | 300 | 1.47 | -0.165px | De-emphasized body | +| Caption Large | Inter Variable | 14px (0.88rem) | 510–590 | 1.50 | -0.182px | Sub-labels, category headers | +| Caption | Inter Variable | 13px (0.81rem) | 400–510 | 1.50 | -0.13px | Metadata, timestamps | +| Label | Inter Variable | 12px (0.75rem) | 400–590 | 1.40 | normal | Button text, small labels | +| Micro | Inter Variable | 11px (0.69rem) | 510 | 1.40 | normal | Tiny labels | +| Tiny | Inter Variable | 10px (0.63rem) | 400–510 | 1.50 | -0.15px | Overline text, sometimes uppercase | +| Link Large | Inter Variable | 16px (1.00rem) | 400 | 1.50 | normal | Standard links | +| Link Medium | Inter Variable | 15px (0.94rem) | 510 | 2.67 | normal | Spaced navigation links | +| Link Small | Inter Variable | 14px (0.88rem) | 510 | 1.50 | normal | Compact links | +| Link Caption | Inter Variable | 13px (0.81rem) | 400–510 | 1.50 | -0.13px | Footer, metadata links | +| Mono Body | Berkeley Mono | 14px (0.88rem) | 400 | 1.50 | normal | Code blocks | +| Mono Caption | Berkeley Mono | 13px (0.81rem) | 400 | 1.50 | normal | Code labels | +| Mono Label | Berkeley Mono | 12px (0.75rem) | 400 | 1.40 | normal | Code metadata, sometimes uppercase | + +### Principles +- **510 is the signature weight**: Linear uses Inter Variable's 510 weight (between regular 400 and medium 500) as its default emphasis weight. This creates a subtly bolded feel without the heaviness of traditional medium or semibold. +- **Compression at scale**: Display sizes use progressively tighter letter-spacing — -1.584px at 72px, -1.408px at 64px, -1.056px at 48px, -0.704px at 32px. Below 24px, spacing relaxes toward normal. +- **OpenType as identity**: `"cv01", "ss03"` aren't decorative — they transform Inter into Linear's distinctive typeface, giving it a more geometric, purposeful character. +- **Three-tier weight system**: 400 (reading), 510 (emphasis/UI), 590 (strong emphasis). The 300 weight appears only in deliberately de-emphasized contexts. + +## 4. Component Stylings + +### Buttons + +**Ghost Button (Default)** +- Background: `rgba(255,255,255,0.02)` +- Text: `#e2e4e7` (near-white) +- Padding: comfortable +- Radius: 6px +- Border: `1px solid rgb(36, 40, 44)` +- Outline: none +- Focus shadow: `rgba(0,0,0,0.1) 0px 4px 12px` +- Use: Standard actions, secondary CTAs + +**Subtle Button** +- Background: `rgba(255,255,255,0.04)` +- Text: `#d0d6e0` (silver-gray) +- Padding: 0px 6px +- Radius: 6px +- Use: Toolbar actions, contextual buttons + +**Primary Brand Button (Inferred)** +- Background: `#5e6ad2` (brand indigo) +- Text: `#ffffff` +- Padding: 8px 16px +- Radius: 6px +- Hover: `#828fff` shift +- Use: Primary CTAs ("Start building", "Sign up") + +**Icon Button (Circle)** +- Background: `rgba(255,255,255,0.03)` or `rgba(255,255,255,0.05)` +- Text: `#f7f8f8` or `#ffffff` +- Radius: 50% +- Border: `1px solid rgba(255,255,255,0.08)` +- Use: Close, menu toggle, icon-only actions + +**Pill Button** +- Background: transparent +- Text: `#d0d6e0` +- Padding: 0px 10px 0px 5px +- Radius: 9999px +- Border: `1px solid rgb(35, 37, 42)` +- Use: Filter chips, tags, status indicators + +**Small Toolbar Button** +- Background: `rgba(255,255,255,0.05)` +- Text: `#62666d` (muted) +- Radius: 2px +- Border: `1px solid rgba(255,255,255,0.05)` +- Shadow: `rgba(0,0,0,0.03) 0px 1.2px 0px 0px` +- Font: 12px weight 510 +- Use: Toolbar actions, quick-access controls + +### Cards & Containers +- Background: `rgba(255,255,255,0.02)` to `rgba(255,255,255,0.05)` (never solid — always translucent) +- Border: `1px solid rgba(255,255,255,0.08)` (standard) or `1px solid rgba(255,255,255,0.05)` (subtle) +- Radius: 8px (standard), 12px (featured), 22px (large panels) +- Shadow: `rgba(0,0,0,0.2) 0px 0px 0px 1px` or layered multi-shadow stacks +- Hover: subtle background opacity increase + +### Inputs & Forms + +**Text Area** +- Background: `rgba(255,255,255,0.02)` +- Text: `#d0d6e0` +- Border: `1px solid rgba(255,255,255,0.08)` +- Padding: 12px 14px +- Radius: 6px + +**Search Input** +- Background: transparent +- Text: `#f7f8f8` +- Padding: 1px 32px (icon-aware) + +**Button-style Input** +- Text: `#8a8f98` +- Padding: 1px 6px +- Radius: 5px +- Focus shadow: multi-layer stack + +### Badges & Pills + +**Success Pill** +- Background: `#10b981` +- Text: `#f7f8f8` +- Radius: 50% (circular) +- Font: 10px weight 510 +- Use: Status dots, completion indicators + +**Neutral Pill** +- Background: transparent +- Text: `#d0d6e0` +- Padding: 0px 10px 0px 5px +- Radius: 9999px +- Border: `1px solid rgb(35, 37, 42)` +- Font: 12px weight 510 +- Use: Tags, filter chips, category labels + +**Subtle Badge** +- Background: `rgba(255,255,255,0.05)` +- Text: `#f7f8f8` +- Padding: 0px 8px 0px 2px +- Radius: 2px +- Border: `1px solid rgba(255,255,255,0.05)` +- Font: 10px weight 510 +- Use: Inline labels, version tags + +### Navigation +- Dark sticky header on near-black background +- Linear logomark left-aligned (SVG icon) +- Links: Inter Variable 13–14px weight 510, `#d0d6e0` text +- Active/hover: text lightens to `#f7f8f8` +- CTA: Brand indigo button or ghost button +- Mobile: hamburger collapse +- Search: command palette trigger (`/` or `Cmd+K`) + +### Image Treatment +- Product screenshots on dark backgrounds with subtle border (`rgba(255,255,255,0.08)`) +- Top-rounded images: `12px 12px 0px 0px` radius +- Dashboard/issue previews dominate feature sections +- Subtle shadow beneath screenshots: `rgba(0,0,0,0.4) 0px 2px 4px` + +## 5. Layout Principles + +### Spacing System +- Base unit: 8px +- Scale: 1px, 4px, 7px, 8px, 11px, 12px, 16px, 19px, 20px, 22px, 24px, 28px, 32px, 35px +- The 7px and 11px values suggest micro-adjustments for optical alignment +- Primary rhythm: 8px, 16px, 24px, 32px (standard 8px grid) + +### Grid & Container +- Max content width: approximately 1200px +- Hero: centered single-column with generous vertical padding +- Feature sections: 2–3 column grids for feature cards +- Full-width dark sections with internal max-width constraints +- Changelog: single-column timeline layout + +### Whitespace Philosophy +- **Darkness as space**: On Linear's dark canvas, empty space isn't white — it's absence. The near-black background IS the whitespace, and content emerges from it. +- **Compressed headlines, expanded surroundings**: Display text at 72px with -1.584px tracking is dense and compressed, but sits within vast dark padding. The contrast between typographic density and spatial generosity creates tension. +- **Section isolation**: Each feature section is separated by generous vertical padding (80px+) with no visible dividers — the dark background provides natural separation. + +### Border Radius Scale +- Micro (2px): Inline badges, toolbar buttons, subtle tags +- Standard (4px): Small containers, list items +- Comfortable (6px): Buttons, inputs, functional elements +- Card (8px): Cards, dropdowns, popovers +- Panel (12px): Panels, featured cards, section containers +- Large (22px): Large panel elements +- Full Pill (9999px): Chips, filter pills, status tags +- Circle (50%): Icon buttons, avatars, status dots + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Flat (Level 0) | No shadow, `#010102` bg | Page background, deepest canvas | +| Subtle (Level 1) | `rgba(0,0,0,0.03) 0px 1.2px 0px` | Toolbar buttons, micro-elevation | +| Surface (Level 2) | `rgba(255,255,255,0.05)` bg + `1px solid rgba(255,255,255,0.08)` border | Cards, input fields, containers | +| Inset (Level 2b) | `rgba(0,0,0,0.2) 0px 0px 12px 0px inset` | Recessed panels, inner shadows | +| Ring (Level 3) | `rgba(0,0,0,0.2) 0px 0px 0px 1px` | Border-as-shadow technique | +| Elevated (Level 4) | `rgba(0,0,0,0.4) 0px 2px 4px` | Floating elements, dropdowns | +| Dialog (Level 5) | Multi-layer stack: `rgba(0,0,0,0) 0px 8px 2px, rgba(0,0,0,0.01) 0px 5px 2px, rgba(0,0,0,0.04) 0px 3px 2px, rgba(0,0,0,0.07) 0px 1px 1px, rgba(0,0,0,0.08) 0px 0px 1px` | Popovers, command palette, modals | +| Focus | `rgba(0,0,0,0.1) 0px 4px 12px` + additional layers | Keyboard focus on interactive elements | + +**Shadow Philosophy**: On dark surfaces, traditional shadows (dark on dark) are nearly invisible. Linear solves this by using semi-transparent white borders as the primary depth indicator. Elevation isn't communicated through shadow darkness but through background luminance steps — each level slightly increases the white opacity of the surface background (`0.02` → `0.04` → `0.05`), creating a subtle stacking effect. The inset shadow technique (`rgba(0,0,0,0.2) 0px 0px 12px 0px inset`) creates a unique "sunken" effect for recessed panels, adding dimensional depth that traditional dark themes lack. + +## 7. Do's and Don'ts + +### Do +- Use Inter Variable with `"cv01", "ss03"` on ALL text — these features are fundamental to Linear's typeface identity +- Use weight 510 as your default emphasis weight — it's Linear's signature between-weight +- Apply aggressive negative letter-spacing at display sizes (-1.584px at 72px, -1.056px at 48px) +- Build on near-black backgrounds: `#08090a` for marketing, `#0f1011` for panels, `#191a1b` for elevated surfaces +- Use semi-transparent white borders (`rgba(255,255,255,0.05)` to `rgba(255,255,255,0.08)`) instead of solid dark borders +- Keep button backgrounds nearly transparent: `rgba(255,255,255,0.02)` to `rgba(255,255,255,0.05)` +- Reserve brand indigo (`#5e6ad2` / `#7170ff`) for primary CTAs and interactive accents only +- Use `#f7f8f8` for primary text — not pure `#ffffff`, which would be too harsh +- Apply the luminance stacking model: deeper = darker bg, elevated = slightly lighter bg + +### Don't +- Don't use pure white (`#ffffff`) as primary text — `#f7f8f8` prevents eye strain +- Don't use solid colored backgrounds for buttons — transparency is the system (rgba white at 0.02–0.05) +- Don't apply the brand indigo decoratively — it's reserved for interactive/CTA elements only +- Don't use positive letter-spacing on display text — Inter at large sizes always runs negative +- Don't use visible/opaque borders on dark backgrounds — borders should be whisper-thin semi-transparent white +- Don't skip the OpenType features (`"cv01", "ss03"`) — without them, it's generic Inter, not Linear's Inter +- Don't use weight 700 (bold) — Linear's maximum weight is 590, with 510 as the workhorse +- Don't introduce warm colors into the UI chrome — the palette is cool gray with blue-violet accent only +- Don't use drop shadows for elevation on dark surfaces — use background luminance stepping instead + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile Small | <600px | Single column, compact padding | +| Mobile | 600–640px | Standard mobile layout | +| Tablet | 640–768px | Two-column grids begin | +| Desktop Small | 768–1024px | Full card grids, expanded padding | +| Desktop | 1024–1280px | Standard desktop, full navigation | +| Large Desktop | >1280px | Full layout, generous margins | + +### Touch Targets +- Buttons use comfortable padding with 6px radius minimum +- Navigation links at 13–14px with adequate spacing +- Pill tags have 10px horizontal padding for touch accessibility +- Icon buttons at 50% radius ensure circular, easy-to-tap targets +- Search trigger is prominently placed with generous hit area + +### Collapsing Strategy +- Hero: 72px → 48px → 32px display text, tracking adjusts proportionally +- Navigation: horizontal links + CTAs → hamburger menu at 768px +- Feature cards: 3-column → 2-column → single column stacked +- Product screenshots: maintain aspect ratio, may reduce padding +- Changelog: timeline maintains single-column through all sizes +- Footer: multi-column → stacked single column +- Section spacing: 80px+ → 48px on mobile + +### Image Behavior +- Dashboard screenshots maintain border treatment at all sizes +- Hero visuals simplify on mobile (fewer floating UI elements) +- Product screenshots use responsive sizing with consistent radius +- Dark background ensures screenshots blend naturally at any viewport + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Primary CTA: Brand Indigo (`#5e6ad2`) +- Page Background: Marketing Black (`#08090a`) +- Panel Background: Panel Dark (`#0f1011`) +- Surface: Level 3 (`#191a1b`) +- Heading text: Primary White (`#f7f8f8`) +- Body text: Silver Gray (`#d0d6e0`) +- Muted text: Tertiary Gray (`#8a8f98`) +- Subtle text: Quaternary Gray (`#62666d`) +- Accent: Violet (`#7170ff`) +- Accent Hover: Light Violet (`#828fff`) +- Border (default): `rgba(255,255,255,0.08)` +- Border (subtle): `rgba(255,255,255,0.05)` +- Focus ring: Multi-layer shadow stack + +### Example Component Prompts +- "Create a hero section on `#08090a` background. Headline at 48px Inter Variable weight 510, line-height 1.00, letter-spacing -1.056px, color `#f7f8f8`, font-feature-settings `'cv01', 'ss03'`. Subtitle at 18px weight 400, line-height 1.60, color `#8a8f98`. Brand CTA button (`#5e6ad2`, 6px radius, 8px 16px padding) and ghost button (`rgba(255,255,255,0.02)` bg, `1px solid rgba(255,255,255,0.08)` border, 6px radius)." +- "Design a card on dark background: `rgba(255,255,255,0.02)` background, `1px solid rgba(255,255,255,0.08)` border, 8px radius. Title at 20px Inter Variable weight 590, letter-spacing -0.24px, color `#f7f8f8`. Body at 15px weight 400, color `#8a8f98`, letter-spacing -0.165px." +- "Build a pill badge: transparent background, `#d0d6e0` text, 9999px radius, 0px 10px padding, `1px solid #23252a` border, 12px Inter Variable weight 510." +- "Create navigation: dark sticky header on `#0f1011`. Inter Variable 13px weight 510 for links, `#d0d6e0` text. Brand indigo CTA `#5e6ad2` right-aligned with 6px radius. Bottom border: `1px solid rgba(255,255,255,0.05)`." +- "Design a command palette: `#191a1b` background, `1px solid rgba(255,255,255,0.08)` border, 12px radius, multi-layer shadow stack. Input at 16px Inter Variable weight 400, `#f7f8f8` text. Results list with 13px weight 510 labels in `#d0d6e0` and 12px metadata in `#62666d`." + +### Iteration Guide +1. Always set font-feature-settings `"cv01", "ss03"` on all Inter text — this is non-negotiable for Linear's look +2. Letter-spacing scales with font size: -1.584px at 72px, -1.056px at 48px, -0.704px at 32px, normal below 16px +3. Three weights: 400 (read), 510 (emphasize/navigate), 590 (announce) +4. Surface elevation via background opacity: `rgba(255,255,255, 0.02 → 0.04 → 0.05)` — never solid backgrounds on dark +5. Brand indigo (`#5e6ad2` / `#7170ff`) is the only chromatic color — everything else is grayscale +6. Borders are always semi-transparent white, never solid dark colors on dark backgrounds +7. Berkeley Mono for any code or technical content, Inter Variable for everything else diff --git a/apps/desktop-client/electron-builder.yml b/apps/desktop-client/electron-builder.yml new file mode 100644 index 0000000..208f4ea --- /dev/null +++ b/apps/desktop-client/electron-builder.yml @@ -0,0 +1,18 @@ +appId: com.geo.rankly.desktop +productName: GEO Rankly Desktop +directories: + output: release +files: + - out/** +asar: true +mac: + category: public.app-category.productivity + target: + - dmg + - zip +win: + target: + - nsis +linux: + target: + - AppImage diff --git a/apps/desktop-client/electron.vite.config.ts b/apps/desktop-client/electron.vite.config.ts new file mode 100644 index 0000000..6dc722c --- /dev/null +++ b/apps/desktop-client/electron.vite.config.ts @@ -0,0 +1,63 @@ +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import vue from "@vitejs/plugin-vue"; +import { defineConfig, externalizeDepsPlugin } from "electron-vite"; + +const rootDir = fileURLToPath(new URL(".", import.meta.url)); + +export default defineConfig({ + main: { + plugins: [externalizeDepsPlugin()], + build: { + rollupOptions: { + input: { + bootstrap: resolve(rootDir, "src/main/bootstrap.ts"), + }, + output: { + format: "cjs", + entryFileNames: "[name].cjs", + chunkFileNames: "[name]-[hash].cjs", + }, + }, + }, + resolve: { + alias: { + "@main": resolve(rootDir, "src/main"), + }, + }, + }, + preload: { + plugins: [externalizeDepsPlugin()], + build: { + rollupOptions: { + input: { + bridge: resolve(rootDir, "src/preload/bridge.ts"), + }, + output: { + format: "cjs", + entryFileNames: "[name].cjs", + chunkFileNames: "[name]-[hash].cjs", + }, + }, + }, + resolve: { + alias: { + "@preload": resolve(rootDir, "src/preload"), + }, + }, + }, + renderer: { + plugins: [vue()], + resolve: { + alias: { + "@renderer": resolve(rootDir, "src/renderer"), + "@geo/shared-types": resolve(rootDir, "../../packages/shared-types/src/index.ts"), + "@geo/http-client": resolve(rootDir, "../../packages/http-client/src/index.ts"), + "@geo/ui-shared": resolve(rootDir, "../../packages/ui-shared/src/index.ts"), + "@geo/ui-shared/tokens": resolve(rootDir, "../../packages/ui-shared/src/tokens/index.ts"), + "@geo/ui-shared/tokens.css": resolve(rootDir, "../../packages/ui-shared/src/tokens/index.css"), + }, + }, + }, +}); diff --git a/apps/desktop-client/package.json b/apps/desktop-client/package.json new file mode 100644 index 0000000..91d762c --- /dev/null +++ b/apps/desktop-client/package.json @@ -0,0 +1,38 @@ +{ + "name": "@geo/desktop-client", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "out/main/bootstrap.cjs", + "scripts": { + "dev": "unset ELECTRON_RUN_AS_NODE && electron-vite dev", + "build": "electron-vite build", + "package:mac": "electron-builder --mac --arm64 ", + "test": "vitest run", + "typecheck": "vue-tsc --noEmit" + }, + "dependencies": { + "@ant-design/icons-vue": "^7.0.1", + "@geo/http-client": "workspace:*", + "@geo/shared-types": "workspace:*", + "@geo/ui-shared": "workspace:*", + "ant-design-vue": "^4.2.6", + "better-sqlite3": "^12.8.0", + "electron-updater": "^6.0.0", + "pino": "^9.0.0", + "vue": "^3.5.31", + "vue-router": "^4.5.1" + }, + "devDependencies": { + "@playwright/test": "^1.0.0", + "@types/node": "^24.0.0", + "@vitejs/plugin-vue": "^5.2.4", + "electron": "41.2.0", + "electron-builder": "^25.0.0", + "electron-vite": "^2.0.0", + "typescript": "^5.9.3", + "vite": "^5.4.19", + "vitest": "^2.0.0", + "vue-tsc": "^3.2.6" + } +} diff --git a/apps/desktop-client/src/main/account-binder.ts b/apps/desktop-client/src/main/account-binder.ts new file mode 100644 index 0000000..f391f71 --- /dev/null +++ b/apps/desktop-client/src/main/account-binder.ts @@ -0,0 +1,1478 @@ +import { createHash } from "node:crypto"; +import { readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; + +import { BrowserWindow, app, nativeTheme, session as electronSession } from "electron/main"; +import type { Session, WebContents } from "electron/main"; +import type { DesktopAccountInfo } from "@geo/shared-types"; + +import { + normalizeRemoteUrl, + pageFetchJson, + sessionCookieFetchJson, + sessionCookieHeader, + sessionCookieValue, + sessionFetchJson, + sessionFetchText, +} from "./adapters/common"; +import { + attachSessionHandle, + createPendingSessionHandle, + createSessionHandle, + createSessionHandleForPartition, + forgetSessionHandle, + getPersistedPartition, + getSessionHandle, + type SessionHandle, +} from "./session-registry"; +import { + attachWindowDiagnostics, + isWindowReadyForDetection, + loadWindowURLSafely, +} from "./external-window"; +import { upsertDesktopAccount } from "./transport/api-client"; +import { STANDARD_USER_AGENT } from "./user-agent"; + +interface DetectedAccount { + platformUid: string; + displayName: string; + avatarUrl: string | null; +} + +interface DetectContext { + session: Session; + webContents: WebContents; +} + +interface PublishPlatformBindingDefinition { + id: string; + label: string; + loginUrl: string; + consoleUrl: string; + detect(context: DetectContext): Promise; +} + +interface PublishAccountIdentity { + id: string; + platform: string; + platformUid: string; + displayName: string; +} + +export interface PublishAccountProfile { + platformUid: string; + displayName: string; + avatarUrl: string | null; +} + +type ToutiaoMediaInfoResponse = { + data?: { + user?: { + id_str?: string | number; + screen_name?: string; + https_avatar_url?: string; + }; + user_info?: { + id_str?: string | number; + user_id_str?: string | number; + screen_name?: string; + name?: string; + https_avatar_url?: string; + avatar_url?: string; + }; + media?: { + id?: string | number; + media_id?: string | number; + media_name?: string; + screen_name?: string; + https_avatar_url?: string; + avatar_url?: string; + }; + }; +}; + +type BaijiahaoAppInfoResponse = { + data?: { + user?: { + userid?: string | number; + name?: string; + uname?: string; + username?: string; + avatar?: string; + }; + }; +}; + +type SohuRegisterInfoResponse = { + data?: { + account?: { + id?: string | number; + nickName?: string; + avatar?: string; + }; + }; +}; + +type QiehaoBasicInfoResponse = { + data?: { + cpInfo?: { + mediaId?: string | number; + mediaName?: string; + header?: string; + }; + }; +}; + +type ZhihuMeResponse = { + uid?: string; + id?: string; + name?: string; + avatar_url?: string; +}; + +type WangyiInfoResponse = { + data?: { + mediaInfo?: { + userId?: string | number; + tname?: string; + icon?: string; + }; + }; +}; + +type JianshuUser = { + id?: string | number; + nickname?: string; + avatar?: string; +}; + +type BilibiliNavResponse = { + code?: number; + data?: { + isLogin?: boolean; + mid?: string | number; + uname?: string; + face?: string; + }; +}; + +type JuejinProfileResponse = { + data?: { + bui_user?: { + user_id?: string; + screen_name?: string; + avatar_large?: string; + avatar_url?: string; + }; + }; +}; + +type SmzdmCurrentUser = { + smzdm_id?: string | number; + nickname?: string; + audit_nickname?: string; + avatar?: string; +}; + +type ZolUserInfoResponse = { + data?: { + userId?: string | number; + nickName?: string; + photo?: string; + }; +}; + +type DongchediAccountInfoResponse = { + data?: { + user_id_str?: string; + name?: string; + avatar_url?: string; + }; +}; + +const TOUTIAO_LOGIN_URL = "https://mp.toutiao.com/auth/page/login"; +const TOUTIAO_CONSOLE_HOME_URL = "https://mp.toutiao.com/profile_v4/index"; +const TOUTIAO_WORKBENCH_URL_PATTERN = /^https:\/\/mp\.toutiao\.com\/profile_v4\//i; + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +function normalizeComparableText(value: string): string { + return value.trim().replace(/\s+/g, "").toLowerCase(); +} + +function partitionsRootPath(): string { + return join(app.getPath("userData"), "Partitions"); +} + +function listPendingPartitions(platformId: string): string[] { + try { + return readdirSync(partitionsRootPath(), { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name.startsWith(`pending-${platformId}-`)) + .map((entry) => { + const absolutePath = join(partitionsRootPath(), entry.name); + let mtimeMs = 0; + try { + mtimeMs = statSync(absolutePath).mtimeMs; + } catch { + mtimeMs = 0; + } + return { + partition: `persist:${entry.name}`, + mtimeMs, + }; + }) + .sort((left, right) => right.mtimeMs - left.mtimeMs) + .map((item) => item.partition); + } catch { + return []; + } +} + +function normalizeText(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +function stringId(value: unknown): string { + if (typeof value === "number") { + return Number.isFinite(value) ? String(value) : ""; + } + return normalizeText(value) ?? ""; +} + +function hashText(value: string): string { + return createHash("sha1").update(value).digest("hex").slice(0, 20); +} + +function boundedIdentity(prefix: string, raw: string): string { + const normalizedPrefix = prefix.trim().replace(/:+$/, ""); + const normalizedRaw = raw.trim(); + if (!normalizedRaw) { + return `${normalizedPrefix}:${hashText(prefix)}`; + } + + const candidate = `${normalizedPrefix}:${normalizedRaw}`; + if (candidate.length <= 120) { + return candidate; + } + + return `${normalizedPrefix}:${hashText(normalizedRaw)}`; +} + +function detectedAccountFromFields(input: { + uid?: unknown; + name?: unknown; + avatar?: unknown; +}): DetectedAccount | null { + const platformUid = stringId(input.uid); + const displayName = normalizeText(input.name); + if (!platformUid || !displayName) { + return null; + } + + return { + platformUid, + displayName, + avatarUrl: normalizeRemoteUrl(stringId(input.avatar) || normalizeText(input.avatar)), + }; +} + +function extractToutiaoAccount(response: ToutiaoMediaInfoResponse | null | undefined): DetectedAccount | null { + const candidates = [ + detectedAccountFromFields({ + uid: response?.data?.user?.id_str, + name: response?.data?.user?.screen_name, + avatar: response?.data?.user?.https_avatar_url, + }), + detectedAccountFromFields({ + uid: response?.data?.user_info?.id_str ?? response?.data?.user_info?.user_id_str, + name: response?.data?.user_info?.screen_name ?? response?.data?.user_info?.name, + avatar: response?.data?.user_info?.https_avatar_url ?? response?.data?.user_info?.avatar_url, + }), + detectedAccountFromFields({ + uid: response?.data?.media?.media_id ?? response?.data?.media?.id, + name: response?.data?.media?.screen_name ?? response?.data?.media?.media_name, + avatar: response?.data?.media?.https_avatar_url ?? response?.data?.media?.avatar_url, + }), + ]; + + return candidates.find((item) => item !== null) ?? null; +} + +async function detectToutiaoFromPageState( + webContents: WebContents, +): Promise | null> { + if (!webContents || webContents.isDestroyed()) { + return null; + } + + const serialized = await webContents.executeJavaScript(`(() => { + try { + const normalizeText = (value) => typeof value === "string" ? value.trim() : ""; + const stringId = (value) => typeof value === "number" + ? Number.isFinite(value) ? String(value) : "" + : normalizeText(value); + const pickAccount = (value) => { + if (!value || typeof value !== "object") { + return null; + } + + const uid = stringId( + value.id_str + ?? value.user_id_str + ?? value.media_id + ?? value.mediaId + ?? value.uid + ?? value.user_id + ?? value.id, + ); + const displayName = normalizeText( + value.screen_name + ?? value.nick_name + ?? value.nickname + ?? value.user_name + ?? value.media_name + ?? value.mediaName + ?? value.name, + ); + const avatarUrl = normalizeText( + value.https_avatar_url + ?? value.avatar_url + ?? value.avatar + ?? value.head_img, + ); + + if (!uid || !displayName) { + return null; + } + + return { + platformUid: uid, + displayName, + avatarUrl: avatarUrl || null, + }; + }; + const extractUidFromText = (value) => { + if (!value) { + return ""; + } + + const source = String(value); + const patterns = [ + /"id_str":"([^"]+)"/, + /"user_id_str":"([^"]+)"/, + /"media_id":"?([^",}]+)"?/, + /"mediaId":"?([^",}]+)"?/, + /\b(?:id_str|user_id_str|media_id|mediaId|uid|user_id)\b[^0-9A-Za-z_-]*([0-9A-Za-z_-]{4,})/i, + ]; + + for (const pattern of patterns) { + const match = source.match(pattern); + if (match?.[1]) { + return match[1]; + } + } + + return ""; + }; + const collectStorageHints = (storage) => { + const hints = []; + if (!storage) { + return hints; + } + + try { + for (let index = 0; index < storage.length && index < 80; index += 1) { + const key = storage.key(index); + if (!key) { + continue; + } + + const value = storage.getItem(key) || ""; + if ( + /(uid|user|media|creator|account|id)/i.test(key) + || /(id_str|user_id_str|media_id|mediaId|uid|user_id)/i.test(value) + ) { + hints.push(key + "=" + value); + } + } + } catch { + return hints; + } + + return hints; + }; + const detectHeaderName = () => { + const ignoredPatterns = [ + /^消息$/, + /^更多$/, + /^立即完善$/, + /^主页$/, + /^创作$/, + /^管理$/, + /^数据$/, + /^第\d+天$/, + /^在头条创作/, + /^扫码登录$/, + /^验证码登录$/, + /^密码登录$/, + /^手机号$/, + /^获取验证码$/, + /^登录$/, + ]; + + const candidates = Array.from(document.querySelectorAll("body *")) + .map((element) => { + const rect = element.getBoundingClientRect(); + const text = (element.textContent || "").trim().replace(/\s+/g, ""); + return { text, rect }; + }) + .filter(({ text, rect }) => + text + && text.length >= 2 + && text.length <= 40 + && rect.top >= 0 + && rect.top <= 220 + && rect.left >= window.innerWidth * 0.55 + && rect.width <= 320 + && rect.height <= 120, + ) + .map(({ text, rect }) => ({ text, rect })) + .sort((left, right) => { + if (right.rect.left !== left.rect.left) { + return right.rect.left - left.rect.left; + } + return left.rect.top - right.rect.top; + }) + .map(({ text }) => text); + + return candidates.find((text) => !ignoredPatterns.some((pattern) => pattern.test(text))) || ""; + }; + const detectHeaderAvatar = () => { + const images = Array.from(document.images) + .map((image) => { + const rect = image.getBoundingClientRect(); + return { + src: image.currentSrc || image.src || "", + rect, + }; + }) + .filter(({ src, rect }) => + src + && rect.top >= 0 + && rect.top <= 220 + && rect.left >= window.innerWidth * 0.55 + && rect.width >= 24 + && rect.width <= 120 + && rect.height >= 24 + && rect.height <= 120, + ) + .sort((left, right) => { + if (right.rect.left !== left.rect.left) { + return right.rect.left - left.rect.left; + } + return left.rect.top - right.rect.top; + }); + + return images[0]?.src || ""; + }; + const isLoggedInWorkbench = () => { + const text = document.body.innerText || ""; + return ( + location.hostname.includes("mp.toutiao.com") + && !/\/auth\/page\/login/.test(location.pathname) + && ( + /在头条创作的第\s*\d+\s*天/.test(text) + || (text.includes("主页") && text.includes("创作") && text.includes("管理")) + ) + ); + }; + + const roots = [ + window, + window.__INITIAL_STATE__, + window.__INITIAL_DATA__, + window.__REDUX_STATE__, + window.__NEXT_DATA__, + window.__STORE__ && typeof window.__STORE__.getState === "function" + ? window.__STORE__.getState() + : null, + ]; + const queue = []; + const seen = new WeakSet(); + const enqueue = (value) => { + if (!value || typeof value !== "object" || seen.has(value)) { + return; + } + seen.add(value); + queue.push(value); + }; + + roots.forEach(enqueue); + + let account = null; + while (queue.length && !account) { + const current = queue.shift(); + account = pickAccount(current); + if (account) { + break; + } + + const children = Array.isArray(current) ? current : Object.values(current); + for (const child of children.slice(0, 120)) { + enqueue(child); + } + } + + if (!account) { + const html = document.documentElement.outerHTML; + const patternGroups = [ + { + uid: /"id_str":"([^"]+)"/, + name: /"screen_name":"([^"]+)"/, + avatar: /"https_avatar_url":"([^"]+)"/, + }, + { + uid: /"user_id_str":"([^"]+)"/, + name: /"name":"([^"]+)"/, + avatar: /"avatar_url":"([^"]+)"/, + }, + { + uid: /"media_id":"?([^",}]+)"?/, + name: /"media_name":"([^"]+)"/, + avatar: /"avatar_url":"([^"]+)"/, + }, + ]; + + for (const group of patternGroups) { + const uid = html.match(group.uid)?.[1] ?? ""; + const name = html.match(group.name)?.[1] ?? ""; + if (uid && name) { + account = { + platformUid: uid, + displayName: name, + avatarUrl: html.match(group.avatar)?.[1] ?? null, + }; + break; + } + } + } + + const isLoginPage = /\/auth\/page\/login/.test(location.pathname); + + if (!account && !isLoginPage) { + const displayName = detectHeaderName(); + if (displayName) { + const storageHints = [ + ...collectStorageHints(window.localStorage), + ...collectStorageHints(window.sessionStorage), + document.cookie, + document.documentElement.outerHTML, + ]; + const fallbackUid = storageHints.map(extractUidFromText).find(Boolean) || ("toutiao-name:" + displayName); + account = { + platformUid: fallbackUid, + displayName, + avatarUrl: detectHeaderAvatar() || null, + }; + } + } + + if (!account && isLoggedInWorkbench()) { + account = { + platformUid: "", + displayName: detectHeaderName() || "头条号账号", + avatarUrl: detectHeaderAvatar() || null, + }; + } + + return JSON.stringify(account); + } catch { + return JSON.stringify(null); + } + })()`, true).catch(() => null); + + if (typeof serialized !== "string" || !serialized) { + return null; + } + + try { + const parsed = JSON.parse(serialized) as Partial | null; + if (!parsed?.displayName) { + return null; + } + return { + platformUid: stringId(parsed.platformUid), + displayName: parsed.displayName, + avatarUrl: normalizeRemoteUrl(parsed.avatarUrl), + }; + } catch { + return null; + } +} + +async function deriveToutiaoFallbackUid(session: Session, displayName: string): Promise { + const cookieCandidates = [ + ["mp.toutiao.com", "uid_tt"], + ["mp.toutiao.com", "uid_tt_ss"], + ["mp.toutiao.com", "sessionid"], + ["mp.toutiao.com", "sessionid_ss"], + ["mp.toutiao.com", "ttwid"], + ["toutiao.com", "uid_tt"], + ["toutiao.com", "uid_tt_ss"], + ["toutiao.com", "ttwid"], + ] as const; + + for (const [domain, name] of cookieCandidates) { + const value = await sessionCookieValue(session, domain, name).catch(() => ""); + const normalized = normalizeText(value); + if (normalized) { + return boundedIdentity(`toutiao-cookie-${name}`, normalized); + } + } + + const cookieHeader = await sessionCookieHeader(session, "mp.toutiao.com").catch(() => ""); + if (cookieHeader.trim()) { + return boundedIdentity("toutiao-cookie-hash", cookieHeader); + } + + return boundedIdentity("toutiao-name", displayName); +} + +function sanitizeDetectedAccount(account: DetectedAccount): DetectedAccount { + const displayName = normalizeText(account.displayName) ?? "未命名账号"; + const platformUid = boundedIdentity("platform", account.platformUid || displayName); + + return { + platformUid, + displayName, + avatarUrl: normalizeRemoteUrl(account.avatarUrl), + }; +} + +async function detectToutiao({ session, webContents }: DetectContext): Promise { + const pageResponse = await pageFetchJson( + webContents, + "https://mp.toutiao.com/mp/agw/media/get_media_info", + { + headers: { + accept: "application/json, text/plain, */*", + }, + }, + ); + const accountFromPage = extractToutiaoAccount(pageResponse); + if (accountFromPage) { + return accountFromPage; + } + + const sessionResponse = await sessionCookieFetchJson( + session, + "https://mp.toutiao.com/mp/agw/media/get_media_info", + { + headers: { + accept: "application/json, text/plain, */*", + }, + }, + ); + const accountFromSession = extractToutiaoAccount(sessionResponse); + if (accountFromSession) { + return accountFromSession; + } + + const accountFromPageState = await detectToutiaoFromPageState(webContents); + if (!accountFromPageState?.displayName) { + const currentURL = webContents.isDestroyed() ? "" : webContents.getURL(); + if (/^https:\/\/mp\.toutiao\.com\//i.test(currentURL) && !/\/auth\/page\/login/i.test(currentURL)) { + const fallbackDisplayName = normalizeText(webContents.getTitle()) || "头条号账号"; + return sanitizeDetectedAccount({ + platformUid: await deriveToutiaoFallbackUid(session, fallbackDisplayName), + displayName: fallbackDisplayName, + avatarUrl: null, + }); + } + return null; + } + + return sanitizeDetectedAccount({ + platformUid: + accountFromPageState.platformUid + || await deriveToutiaoFallbackUid(session, accountFromPageState.displayName), + displayName: accountFromPageState.displayName, + avatarUrl: accountFromPageState.avatarUrl, + }); +} + +async function detectToutiaoFromSessionOnly(session: Session): Promise { + const response = await sessionCookieFetchJson( + session, + "https://mp.toutiao.com/mp/agw/media/get_media_info", + { + headers: { + accept: "application/json, text/plain, */*", + }, + }, + ); + return extractToutiaoAccount(response); +} + +async function detectToutiaoFromSession(session: Session): Promise { + const detected = await detectToutiaoFromSessionOnly(session); + if (detected) { + return sanitizeDetectedAccount(detected); + } + return null; +} + +async function hasToutiaoAuthCookies(session: Session): Promise { + const candidates = [ + ["toutiao.com", "sessionid"], + ["toutiao.com", "sessionid_ss"], + ["toutiao.com", "sid_tt"], + ["toutiao.com", "sid_ucp_v1"], + ["mp.toutiao.com", "sessionid"], + ["mp.toutiao.com", "sessionid_ss"], + ] as const; + + for (const [domain, name] of candidates) { + const value = await sessionCookieValue(session, domain, name).catch(() => ""); + if (normalizeText(value)) { + return true; + } + } + return false; +} + +async function recoverToutiaoSessionHandle(account: PublishAccountIdentity): Promise { + const pendingPartitions = listPendingPartitions("toutiaohao"); + if (!pendingPartitions.length) { + return null; + } + + const expectedUid = normalizeText(account.platformUid) ?? ""; + const expectedName = normalizeComparableText(account.displayName); + let fallbackPartition: string | null = null; + + for (const partition of pendingPartitions) { + const candidateSession = electronSession.fromPartition(partition); + if (!(await hasToutiaoAuthCookies(candidateSession))) { + continue; + } + + fallbackPartition ??= partition; + + const detected = await detectToutiaoFromSessionOnly(candidateSession).catch(() => null); + if (!detected) { + continue; + } + + if (expectedUid && detected.platformUid === expectedUid) { + console.info("[desktop-session] recovered toutiao partition by uid", { + accountId: account.id, + partition, + platformUid: detected.platformUid, + }); + return createSessionHandleForPartition(account.id, partition); + } + + if (expectedName && normalizeComparableText(detected.displayName) === expectedName) { + console.info("[desktop-session] recovered toutiao partition by name", { + accountId: account.id, + partition, + displayName: detected.displayName, + }); + return createSessionHandleForPartition(account.id, partition); + } + } + + if (!fallbackPartition) { + return null; + } + + console.info("[desktop-session] recovered toutiao partition by auth cookie fallback", { + accountId: account.id, + partition: fallbackPartition, + }); + return createSessionHandleForPartition(account.id, fallbackPartition); +} + +export async function ensurePublishAccountSessionHandle( + account: PublishAccountIdentity, +): Promise { + const existing = getSessionHandle(account.id); + if (existing) { + return existing; + } + + const persisted = getPersistedPartition(account.id); + if (persisted) { + return createSessionHandle(account.id); + } + + if (account.platform === "toutiaohao") { + const recovered = await recoverToutiaoSessionHandle(account); + if (recovered) { + return recovered; + } + } + + return createSessionHandle(account.id); +} + +export async function resolvePublishAccountProfile( + account: PublishAccountIdentity, +): Promise { + const handle = await ensurePublishAccountSessionHandle(account); + + if (account.platform === "toutiaohao") { + return await detectToutiaoFromSession(handle.session); + } + + return null; +} + +function isToutiaoLoginURL(url: string): boolean { + return url.startsWith(TOUTIAO_LOGIN_URL); +} + +async function waitForWindowNavigationSettled(window: BrowserWindow, timeoutMs = 8_000): Promise { + const startedAt = Date.now(); + let lastURL = ""; + let stableSince = Date.now(); + + while (!window.isDestroyed() && Date.now() - startedAt < timeoutMs) { + const currentURL = window.webContents.getURL(); + if (currentURL !== lastURL) { + lastURL = currentURL; + stableSince = Date.now(); + } + + if (!window.webContents.isLoading() && Date.now() - stableSince >= 450) { + return; + } + + await sleep(150); + } +} + +async function verifyToutiaoConsoleAccess(window: BrowserWindow, session: Session): Promise { + if (window.isDestroyed()) { + return false; + } + + await loadWindowURLSafely(window, TOUTIAO_CONSOLE_HOME_URL, "头条号创作台校验"); + await waitForWindowNavigationSettled(window); + + if (window.isDestroyed()) { + return false; + } + + const currentURL = window.webContents.getURL(); + if (isToutiaoLoginURL(currentURL)) { + return false; + } + + if (TOUTIAO_WORKBENCH_URL_PATTERN.test(currentURL)) { + return true; + } + + const pageResponse = await pageFetchJson( + window.webContents, + "https://mp.toutiao.com/mp/agw/media/get_media_info", + { + headers: { + accept: "application/json, text/plain, */*", + }, + }, + ); + if (extractToutiaoAccount(pageResponse)) { + return true; + } + + const sessionResponse = await sessionCookieFetchJson( + session, + "https://mp.toutiao.com/mp/agw/media/get_media_info", + { + headers: { + accept: "application/json, text/plain, */*", + }, + }, + ); + + return extractToutiaoAccount(sessionResponse) !== null; +} + +async function detectBaijiahao({ session }: DetectContext): Promise { + const response = await sessionFetchJson( + session, + "https://baijiahao.baidu.com/builder/app/appinfo", + { + headers: { + accept: "application/json, text/plain, */*", + }, + }, + ).catch(() => null); + + const user = response?.data?.user; + const uid = stringId(user?.userid); + const nickname = user?.name || user?.uname || user?.username || ""; + if (!uid || !nickname) { + return null; + } + + return { + platformUid: uid, + displayName: nickname, + avatarUrl: normalizeRemoteUrl(user?.avatar), + }; +} + +async function detectSohu({ session }: DetectContext): Promise { + const response = await sessionFetchJson( + session, + "https://mp.sohu.com/mpbp/bp/account/register-info", + ).catch(() => null); + + const account = response?.data?.account; + const uid = stringId(account?.id); + if (!uid || !account?.nickName) { + return null; + } + + return { + platformUid: uid, + displayName: account.nickName, + avatarUrl: normalizeRemoteUrl(account.avatar), + }; +} + +async function detectQiehao({ session }: DetectContext): Promise { + const cookie = await sessionCookieHeader(session, "om.qq.com"); + const response = await sessionFetchJson( + session, + "https://om.qq.com/maccountsetting/basicinfo?relogin=1", + { + headers: { + accept: "application/json, text/plain, */*", + ...(cookie ? { cookie } : {}), + }, + }, + ).catch(() => null); + + const cpInfo = response?.data?.cpInfo; + const uid = stringId(cpInfo?.mediaId); + if (!uid || !cpInfo?.mediaName) { + return null; + } + + return { + platformUid: uid, + displayName: cpInfo.mediaName, + avatarUrl: normalizeRemoteUrl(cpInfo.header), + }; +} + +async function detectZhihu({ session }: DetectContext): Promise { + const response = await sessionFetchJson( + session, + "https://www.zhihu.com/api/v4/me", + { + headers: { + "x-requested-with": "fetch", + }, + }, + ).catch(() => null); + + const uid = stringId(response?.uid || response?.id); + if (!uid || !response?.name) { + return null; + } + + return { + platformUid: uid, + displayName: response.name, + avatarUrl: normalizeRemoteUrl(response.avatar_url), + }; +} + +async function detectWangyihao({ session }: DetectContext): Promise { + const response = await sessionFetchJson( + session, + "https://mp.163.com/wemedia/info.do", + ).catch(() => null); + + const media = response?.data?.mediaInfo; + const uid = stringId(media?.userId); + if (!uid || !media?.tname) { + return null; + } + + return { + platformUid: uid, + displayName: media.tname, + avatarUrl: normalizeRemoteUrl(media.icon), + }; +} + +async function detectJianshu({ session }: DetectContext): Promise { + const response = await sessionFetchJson( + session, + "https://www.jianshu.com/author/current_user", + { + headers: { + accept: "application/json", + }, + }, + ).catch(() => null); + + const uid = stringId(response?.id); + if (!uid || !response?.nickname) { + return null; + } + + return { + platformUid: uid, + displayName: response.nickname, + avatarUrl: normalizeRemoteUrl(response.avatar), + }; +} + +async function detectBilibili({ session }: DetectContext): Promise { + const cookie = await sessionCookieHeader(session, "bilibili.com"); + const response = await sessionFetchJson( + session, + "https://api.bilibili.com/x/web-interface/nav?build=0&mobi_app=web", + { + headers: { + accept: "application/json, text/plain, */*", + ...(cookie ? { cookie } : {}), + }, + }, + ).catch(() => null); + + const user = response?.data; + const uid = stringId(user?.mid); + if (response?.code !== 0 || !user?.isLogin || !uid || !user.uname) { + return null; + } + + return { + platformUid: uid, + displayName: user.uname, + avatarUrl: normalizeRemoteUrl(user.face), + }; +} + +async function detectJuejin({ session }: DetectContext): Promise { + const response = await sessionFetchJson( + session, + "https://api.juejin.cn/user_api/v1/user/get_profile", + { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json", + }, + body: "{}", + }, + ).catch(() => null); + + const user = response?.data?.bui_user; + if (!user?.user_id || !user.screen_name) { + return null; + } + + return { + platformUid: user.user_id, + displayName: user.screen_name, + avatarUrl: normalizeRemoteUrl(user.avatar_url ?? user.avatar_large), + }; +} + +async function detectSmzdm({ session }: DetectContext): Promise { + const response = await sessionFetchJson( + session, + "https://zhiyou.smzdm.com/user/info/jsonp_get_current", + ).catch(() => null); + + const uid = stringId(response?.smzdm_id); + const nickname = response?.nickname || response?.audit_nickname || ""; + if (!uid || !nickname) { + return null; + } + + return { + platformUid: uid, + displayName: nickname, + avatarUrl: normalizeRemoteUrl(response?.avatar), + }; +} + +async function detectWeixinGzh({ session }: DetectContext): Promise { + const html = await sessionFetchText(session, "https://mp.weixin.qq.com/").catch(() => ""); + if (!html) { + return null; + } + + const token = html.match(/data:\s*\{[\s\S]*?t:\s*["']([^"']+)["']/)?.[1] ?? ""; + const userName = html.match(/user_name:\s*["']([^"']+)["']/)?.[1] ?? ""; + const nickName = html.match(/nick_name:\s*["']([^"']+)["']/)?.[1] ?? ""; + const avatarFromHead = html.match(/class="weui-desktop-account__thumb"[^>]*src="([^"]+)"/)?.[1] ?? ""; + const avatarFromMeta = html.match(/head_img:\s*['"]([^'"]+)['"]/)?.[1] ?? ""; + const avatarUrl = (avatarFromHead || avatarFromMeta || "").replace(/^http:\/\//, "https://"); + + if (!token || !userName || !nickName) { + return null; + } + + return { + platformUid: userName, + displayName: nickName, + avatarUrl: normalizeRemoteUrl(avatarUrl), + }; +} + +async function detectZol({ session }: DetectContext): Promise { + const response = await sessionFetchJson( + session, + "https://open-api.zol.com.cn/api/v1/creator.user.getinfo", + { + headers: { + accept: "application/json, text/plain, */*", + }, + }, + ).catch(() => null); + + const user = response?.data; + const uid = stringId(user?.userId); + if (!uid || !user?.nickName) { + return null; + } + + return { + platformUid: uid, + displayName: user.nickName, + avatarUrl: normalizeRemoteUrl(user.photo), + }; +} + +async function detectDongchedi({ session }: DetectContext): Promise { + const response = await sessionFetchJson( + session, + "https://mp.dcdapp.com/passport/account/info/v2/?aid=2302&account_sdk_source=web", + ).catch(() => null); + + const user = response?.data; + if (!user?.user_id_str || !user.name) { + return null; + } + + return { + platformUid: user.user_id_str, + displayName: user.name, + avatarUrl: normalizeRemoteUrl(user.avatar_url), + }; +} + +const publishBindingDefinitions: Record = { + toutiaohao: { + id: "toutiaohao", + label: "头条号", + loginUrl: TOUTIAO_LOGIN_URL, + consoleUrl: TOUTIAO_CONSOLE_HOME_URL, + detect: detectToutiao, + }, + baijiahao: { + id: "baijiahao", + label: "百家号", + loginUrl: "https://baijiahao.baidu.com/builder/theme/bjh/login", + consoleUrl: "https://baijiahao.baidu.com/builder/rc/edit?type=news", + detect: detectBaijiahao, + }, + sohuhao: { + id: "sohuhao", + label: "搜狐号", + loginUrl: "https://mp.sohu.com/mpfe/v4/login", + consoleUrl: "https://mp.sohu.com/mpfe/v4/news/create", + detect: detectSohu, + }, + qiehao: { + id: "qiehao", + label: "企鹅号", + loginUrl: "https://om.qq.com", + consoleUrl: "https://om.qq.com/main.html#/article/add", + detect: detectQiehao, + }, + zhihu: { + id: "zhihu", + label: "知乎", + loginUrl: "https://www.zhihu.com/signin", + consoleUrl: "https://zhuanlan.zhihu.com/write", + detect: detectZhihu, + }, + wangyihao: { + id: "wangyihao", + label: "网易号", + loginUrl: "https://mp.163.com/login.html", + consoleUrl: "https://mp.163.com/index.html#/article/create", + detect: detectWangyihao, + }, + jianshu: { + id: "jianshu", + label: "简书", + loginUrl: "https://www.jianshu.com/sign_in", + consoleUrl: "https://www.jianshu.com/writer", + detect: detectJianshu, + }, + bilibili: { + id: "bilibili", + label: "bilibili", + loginUrl: "https://www.bilibili.com", + consoleUrl: "https://member.bilibili.com/platform/upload/text/edit", + detect: detectBilibili, + }, + juejin: { + id: "juejin", + label: "稀土掘金", + loginUrl: "https://juejin.cn/login", + consoleUrl: "https://juejin.cn/editor/drafts/new/v3", + detect: detectJuejin, + }, + smzdm: { + id: "smzdm", + label: "什么值得买", + loginUrl: "https://zhiyou.smzdm.com/user/login", + consoleUrl: "https://post.smzdm.com/", + detect: detectSmzdm, + }, + weixin_gzh: { + id: "weixin_gzh", + label: "微信公众号", + loginUrl: "https://mp.weixin.qq.com/cgi-bin/loginpage", + consoleUrl: "https://mp.weixin.qq.com/cgi-bin/home", + detect: detectWeixinGzh, + }, + zol: { + id: "zol", + label: "中关村在线", + loginUrl: "https://post.zol.com.cn/v2/manage/works/all", + consoleUrl: "https://post.zol.com.cn/v2/manage/works/all", + detect: detectZol, + }, + dongchedi: { + id: "dongchedi", + label: "懂车帝", + loginUrl: "https://mp.dcdapp.com/login", + consoleUrl: "https://mp.dcdapp.com/content/article/create", + detect: detectDongchedi, + }, +}; + +function createBoundWindow(title: string, targetURL: string, sessionHandle: Session): BrowserWindow { + const window = new BrowserWindow({ + width: 1320, + height: 900, + minWidth: 1100, + minHeight: 760, + title, + autoHideMenuBar: true, + backgroundColor: nativeTheme.shouldUseDarkColors ? "#15191a" : "#f4f1ea", + webPreferences: { + session: sessionHandle, + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + }, + }); + + window.webContents.setUserAgent(STANDARD_USER_AGENT); + attachWindowDiagnostics(window, title); + + window.webContents.setWindowOpenHandler(({ url }) => { + if (/^https?:\/\//i.test(url)) { + setImmediate(() => { + if (!window.isDestroyed()) { + void loadWindowURLSafely(window, url, `${title} popup`); + } + }); + } + return { action: "deny" }; + }); + + void loadWindowURLSafely(window, targetURL, title); + return window; +} + +export async function bindPublishAccount(platformId: string): Promise { + const definition = publishBindingDefinitions[platformId]; + if (!definition) { + throw new Error(`desktop_publish_platform_not_supported:${platformId}`); + } + + const handle = createPendingSessionHandle(platformId); + + return await new Promise((resolve, reject) => { + const window = createBoundWindow(`绑定 ${definition.label}`, definition.loginUrl, handle.session); + let finished = false; + let checking = false; + let detectReady = false; + + const settleSuccess = (account: DesktopAccountInfo) => { + if (finished) { + return; + } + finished = true; + clearInterval(intervalHandle); + attachSessionHandle(account.id, handle); + resolve(account); + if (!window.isDestroyed()) { + window.setTitle(`${definition.label}(${account.display_name}) 已绑定`); + setTimeout(() => { + if (!window.isDestroyed()) { + window.close(); + } + }, 800); + } + }; + + const settleFailure = (error: Error) => { + if (finished) { + return; + } + finished = true; + clearInterval(intervalHandle); + forgetSessionHandle(handle.accountId); + if (!window.isDestroyed()) { + window.close(); + } + reject(error); + }; + + const syncDetectionState = () => { + if (window.isDestroyed()) { + detectReady = false; + return; + } + detectReady = isWindowReadyForDetection(window); + }; + + window.webContents.on("did-finish-load", syncDetectionState); + window.webContents.on("did-navigate", syncDetectionState); + window.webContents.on("did-navigate-in-page", syncDetectionState); + window.webContents.on( + "did-fail-load", + (_event, errorCode, _errorDescription, _validatedURL, isMainFrame) => { + if (isMainFrame && errorCode !== -3) { + detectReady = false; + } + }, + ); + + const detectAndBind = async () => { + const currentURL = window.isDestroyed() ? "" : window.webContents.getURL(); + const canProbe = detectReady || /^https?:\/\//i.test(currentURL); + if (finished || checking || !canProbe || window.isDestroyed()) { + return; + } + checking = true; + try { + console.info("[desktop-bind] detect tick", { + platform: definition.id, + detectReady, + url: currentURL, + }); + const detected = await definition + .detect({ session: handle.session, webContents: window.webContents }) + .catch((error) => { + console.warn(`[desktop-bind] ${definition.id} detect failed`, { + message: error instanceof Error ? error.message : String(error), + }); + return null; + }); + if (!detected) { + return; + } + let normalizedDetected = sanitizeDetectedAccount(detected); + console.info("[desktop-bind] detect success", { + platform: definition.id, + displayName: normalizedDetected.displayName, + platformUid: normalizedDetected.platformUid, + url: currentURL, + }); + + if (definition.id === "toutiaohao") { + const consoleReady = await verifyToutiaoConsoleAccess(window, handle.session); + console.info("[desktop-bind] toutiao console verification", { + platform: definition.id, + ready: consoleReady, + url: window.isDestroyed() ? "" : window.webContents.getURL(), + }); + if (!consoleReady) { + return; + } + + const detectedFromWorkbench = await definition + .detect({ session: handle.session, webContents: window.webContents }) + .catch(() => null); + if (detectedFromWorkbench) { + normalizedDetected = sanitizeDetectedAccount(detectedFromWorkbench); + } + } + + const account = await upsertDesktopAccount({ + platform: definition.id, + platform_uid: normalizedDetected.platformUid, + display_name: normalizedDetected.displayName, + account_fingerprint: normalizedDetected.platformUid, + health: "live", + verified_at: new Date().toISOString(), + tags: [], + }); + + settleSuccess(account); + } catch (error) { + console.error("[desktop-bind] upsert failed", { + platform: definition.id, + url: currentURL, + message: error instanceof Error ? error.message : String(error), + }); + settleFailure(error instanceof Error ? error : new Error("desktop_account_bind_failed")); + } finally { + checking = false; + } + }; + + const intervalHandle = setInterval(() => { + void detectAndBind(); + }, 1800); + + window.on("closed", () => { + if (!finished) { + clearInterval(intervalHandle); + forgetSessionHandle(handle.accountId); + reject(new Error("desktop_account_bind_window_closed")); + } + }); + + syncDetectionState(); + void detectAndBind(); + }); +} + +export async function openPublishAccountConsole(account: PublishAccountIdentity): Promise { + const definition = publishBindingDefinitions[account.platform]; + if (!definition) { + throw new Error(`desktop_publish_platform_not_supported:${account.platform}`); + } + + const handle = await ensurePublishAccountSessionHandle(account); + const window = createBoundWindow(`${definition.label} 创作台`, definition.consoleUrl, handle.session); + window.show(); + window.focus(); +} diff --git a/apps/desktop-client/src/main/adapters/base.ts b/apps/desktop-client/src/main/adapters/base.ts new file mode 100644 index 0000000..e6cfd50 --- /dev/null +++ b/apps/desktop-client/src/main/adapters/base.ts @@ -0,0 +1,40 @@ +import type { DesktopArticleContent, JsonValue } from "@geo/shared-types"; +import type { Session, WebContentsView } from "electron"; + +export type AdapterTaskPhase = "initial" | "resume"; +export type AdapterCompletionStatus = "succeeded" | "failed" | "unknown"; + +export interface AdapterContext { + taskId: string; + accountId: string; + session: Session; + view: WebContentsView; + signal: AbortSignal; + mode: "auto" | "manual"; + phase: AdapterTaskPhase; + reportProgress(stage: string): void; +} + +export interface PublishAdapterContext extends AdapterContext { + article: DesktopArticleContent & { + publish_type: "publish" | "draft"; + }; +} + +export interface AdapterExecutionResult { + status: AdapterCompletionStatus; + summary: string; + payload?: Record; + error?: Record; + reviewUrl?: string | null; +} + +export interface PublishAdapter { + platform: string; + publish(context: PublishAdapterContext, payload: Record): Promise; +} + +export interface MonitorAdapter { + provider: string; + query(context: AdapterContext, payload: Record): Promise; +} diff --git a/apps/desktop-client/src/main/adapters/common.ts b/apps/desktop-client/src/main/adapters/common.ts new file mode 100644 index 0000000..e0de487 --- /dev/null +++ b/apps/desktop-client/src/main/adapters/common.ts @@ -0,0 +1,327 @@ +import type { DesktopArticleContent } from "@geo/shared-types"; +import type { Session, WebContents, WebContentsView } from "electron/main"; + +import { STANDARD_ACCEPT_LANGUAGES, STANDARD_USER_AGENT } from "../user-agent"; + +export function normalizeText(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +export function normalizeRemoteUrl(value?: string | null): string | null { + const trimmed = value?.trim(); + if (!trimmed) { + return null; + } + if (/^(data|blob):/i.test(trimmed)) { + return trimmed; + } + if (/^https?:\/\//i.test(trimmed)) { + return trimmed; + } + if (/^\/\//.test(trimmed)) { + return `https:${trimmed}`; + } + if (/^[a-z][a-z\d+\-.]*:/i.test(trimmed)) { + return trimmed; + } + return `https://${trimmed.replace(/^\/+/, "")}`; +} + +export function normalizeArticleHtml(article: DesktopArticleContent): string { + let next = article.html_content?.trim() ?? ""; + next = next.replace(//gi, ""); + next = next.replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, ""); + next = next.replace(/\sstyle="[^"]*"/gi, ""); + next = next.replace(/\sdata-[a-z-]+="[^"]*"/gi, ""); + next = next.replace(/]*>\s*()\s*<\/figure>/gi, "$1"); + return next.trim(); +} + +export function extractImageSources(html: string): string[] { + const sources = new Set(); + for (const match of html.matchAll(/]*src=(['"])(.*?)\1[^>]*>/gi)) { + const src = match[2]?.trim(); + if (src) { + sources.add(src); + } + } + return [...sources]; +} + +export async function fetchImageBlob(sourceUrl: string): Promise { + const normalizedUrl = normalizeRemoteUrl(sourceUrl); + if (!normalizedUrl) { + return null; + } + + const response = await fetch(normalizedUrl).catch(() => null); + if (!response?.ok) { + return null; + } + + const blob = await response.blob().catch(() => null); + if (!blob || !blob.type.startsWith("image/")) { + return null; + } + + return blob; +} + +export async function uploadHtmlImages( + html: string, + uploader: (sourceUrl: string) => Promise, +): Promise<{ html: string; uploaded: Map }> { + const sources = extractImageSources(html); + if (!sources.length) { + return { + html, + uploaded: new Map(), + }; + } + + const uploaded = new Map(); + for (const source of sources) { + const target = await uploader(source); + if (target) { + uploaded.set(source, target); + } + } + + let next = html; + for (const [from, to] of uploaded.entries()) { + next = next.split(from).join(to); + } + + return { html: next, uploaded }; +} + +export async function sessionFetchText( + session: Session, + input: string, + init?: RequestInit, +): Promise { + const headers = new Headers(init?.headers); + if (!headers.has("user-agent")) { + headers.set("user-agent", STANDARD_USER_AGENT); + } + if (!headers.has("accept-language")) { + headers.set("accept-language", STANDARD_ACCEPT_LANGUAGES); + } + + const response = await session.fetch(input, { + ...init, + headers, + }); + const text = await response.text(); + if (!response.ok) { + throw new Error(text || `request_failed_${response.status}`); + } + return text; +} + +export async function sessionFetchJson( + session: Session, + input: string, + init?: RequestInit, +): Promise { + const text = await sessionFetchText(session, input, init); + if (!text) { + return {} as T; + } + return JSON.parse(text) as T; +} + +export interface PageFetchInit { + method?: string; + headers?: Record; + body?: string; + credentials?: "include" | "same-origin" | "omit"; +} + +export async function sessionCookieFetchJson( + session: Session, + url: string, + init?: { method?: string; headers?: Record; body?: string }, +): Promise { + let parsedURL: URL; + try { + parsedURL = new URL(url); + } catch { + return null; + } + + let cookieHeader = ""; + try { + const cookies = await session.cookies.get({ url }); + cookieHeader = cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join("; "); + } catch { + cookieHeader = ""; + } + + const headers: Record = { + "user-agent": STANDARD_USER_AGENT, + "accept-language": STANDARD_ACCEPT_LANGUAGES, + accept: "application/json, text/plain, */*", + referer: `${parsedURL.protocol}//${parsedURL.host}/`, + origin: `${parsedURL.protocol}//${parsedURL.host}`, + }; + if (cookieHeader) { + headers.cookie = cookieHeader; + } + if (init?.headers) { + for (const [key, value] of Object.entries(init.headers)) { + headers[key.toLowerCase()] = value; + } + } + + let response: Response; + try { + response = await fetch(url, { + method: init?.method ?? "GET", + headers, + body: init?.body, + }); + } catch { + return null; + } + + if (!response.ok) { + return null; + } + + let text: string; + try { + text = await response.text(); + } catch { + return null; + } + + if (!text) { + return {} as T; + } + try { + return JSON.parse(text) as T; + } catch { + return null; + } +} + +export async function pageFetchJson( + webContents: WebContents, + url: string, + init?: PageFetchInit, +): Promise { + if (!webContents || webContents.isDestroyed()) { + return null; + } + + const effectiveInit: PageFetchInit = { + credentials: "include", + ...init, + }; + + const script = `(async () => { + try { + const response = await fetch(${JSON.stringify(url)}, ${JSON.stringify(effectiveInit)}); + if (!response.ok) { + return JSON.stringify({ __ok: false, status: response.status }); + } + const text = await response.text(); + if (!text) { + return JSON.stringify({ __ok: true, empty: true }); + } + return JSON.stringify({ __ok: true, body: text }); + } catch (error) { + return JSON.stringify({ __ok: false, error: String(error && error.message || error) }); + } + })()`; + + let serialized: string; + try { + serialized = (await webContents.executeJavaScript(script, true)) as string; + } catch { + return null; + } + + if (typeof serialized !== "string" || !serialized) { + return null; + } + + let envelope: { __ok: boolean; body?: string; empty?: boolean; status?: number; error?: string }; + try { + envelope = JSON.parse(serialized); + } catch { + return null; + } + + if (!envelope.__ok) { + return null; + } + if (envelope.empty) { + return {} as T; + } + if (!envelope.body) { + return null; + } + + try { + return JSON.parse(envelope.body) as T; + } catch { + return null; + } +} + +export async function sessionCookieValue( + session: Session, + domain: string, + name: string, +): Promise { + const cookies = await session.cookies.get({ domain }); + return cookies.find((item) => item.name === name)?.value ?? ""; +} + +export async function sessionCookieHeader(session: Session, domain: string): Promise { + const cookies = await session.cookies.get({ domain }); + return cookies.map((item) => `${item.name}=${item.value}`).join("; "); +} + +export async function ensureViewLoaded( + view: WebContentsView, + url: string, + signal?: AbortSignal, +): Promise { + if (signal?.aborted) { + throw new Error("adapter_aborted"); + } + + if (view.webContents.isLoading()) { + await waitForLoadStop(view, signal); + } + + const currentURL = view.webContents.getURL(); + if (currentURL === url) { + return; + } + + await view.webContents.loadURL(url); + await waitForLoadStop(view, signal); +} + +async function waitForLoadStop(view: WebContentsView, signal?: AbortSignal): Promise { + if (!view.webContents.isLoading()) { + return; + } + + while (view.webContents.isLoading()) { + if (signal?.aborted) { + throw new Error("adapter_aborted"); + } + await new Promise((resolve) => { + setTimeout(resolve, 120); + }); + } +} diff --git a/apps/desktop-client/src/main/adapters/doubao.ts b/apps/desktop-client/src/main/adapters/doubao.ts new file mode 100644 index 0000000..127ac79 --- /dev/null +++ b/apps/desktop-client/src/main/adapters/doubao.ts @@ -0,0 +1,780 @@ +import type { JsonValue, MonitoringSourceItem } from "@geo/shared-types"; + +import { ensureViewLoaded, normalizeText } from "./common"; +import type { MonitorAdapter } from "./base"; + +const DOUBAO_APP_ID = "497858"; +const DOUBAO_BOT_ID = "7338286299411103781"; +const DOUBAO_CHAT_URL = "https://www.doubao.com/chat/completion"; +const DOUBAO_REFERER = "https://www.doubao.com/chat/"; + +const SEARCH_RESULT_KEYS = new Set([ + "references", + "reference_list", + "referenceList", + "reference_cards", + "referenceCards", + "reference_docs", + "referenceDocs", + "sources", + "source_list", + "sourceList", + "source_cards", + "sourceCards", + "search_results", + "searchResults", + "search_result_list", + "searchResultList", + "search_refs", + "searchRefs", + "web_results", + "webResults", + "grounding", + "grounding_results", + "groundingResults", + "browse_results", + "browseResults", + "browse_references", + "browseReferences", +]); + +const CONTENT_CITATION_KEYS = new Set([ + "citations", + "citation_list", + "citationList", + "content_citations", + "contentCitations", + "answer_citations", + "answerCitations", +]); + +type DoubaoRuntimeState = { + fp: string; + ms_token: string; + device_id: string; + web_id: string; + tea_uuid: string; +}; + +type DoubaoStreamEvent = { + event: string; + payload: unknown; +}; + +type DoubaoStreamSummary = { + answer: string | null; + provider_request_id: string | null; + request_id: string | null; + conversation_id: string | null; + citations: MonitoringSourceItem[]; + search_results: MonitoringSourceItem[]; + event_count: number; + error_message: string | null; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizeOptionalString(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +function safeParseJSON(raw: string): unknown { + try { + return JSON.parse(raw); + } catch { + return raw; + } +} + +function getDirectString(record: Record, keys: string[]): string | null { + for (const key of keys) { + const value = normalizeOptionalString(record[key]); + if (value) { + return value; + } + } + return null; +} + +function findFirstStringByKey(source: unknown, keys: string[], depth = 0): string | null { + if (depth > 8 || source == null) { + return null; + } + if (Array.isArray(source)) { + for (const item of source) { + const found = findFirstStringByKey(item, keys, depth + 1); + if (found) { + return found; + } + } + return null; + } + if (!isRecord(source)) { + return null; + } + + for (const key of keys) { + const direct = normalizeOptionalString(source[key]); + if (direct) { + return direct; + } + } + + for (const value of Object.values(source)) { + const found = findFirstStringByKey(value, keys, depth + 1); + if (found) { + return found; + } + } + + return null; +} + +function readNestedString(source: unknown, path: string[]): string | null { + let current: unknown = source; + for (const key of path) { + if (!isRecord(current) || !(key in current)) { + return null; + } + current = current[key]; + } + return normalizeOptionalString(current); +} + +function mergeAnswerText(current: string | null, nextFragment: string | null): string | null { + if (!nextFragment) { + return current; + } + if (!current) { + return nextFragment; + } + if (current === nextFragment || current.includes(nextFragment)) { + return current; + } + if (nextFragment.startsWith(current)) { + return nextFragment; + } + return `${current}${nextFragment}`; +} + +function resolveSourceBucket(key: string): "search" | "citation" | null { + const normalized = key.replace(/[^a-z0-9]/gi, "").toLowerCase(); + if (!normalized) { + return null; + } + if (CONTENT_CITATION_KEYS.has(key) || normalized.includes("citation")) { + return "citation"; + } + if (SEARCH_RESULT_KEYS.has(key) || normalized.includes("reference") || normalized.includes("source") || normalized.includes("search")) { + return "search"; + } + return null; +} + +function normalizeSourceUrl(value: string | null): string | null { + const input = normalizeText(value); + if (!input) { + return null; + } + + try { + const url = new URL(input); + url.hash = ""; + return url.toString(); + } catch { + return input; + } +} + +function buildSourceItem(input: unknown): MonitoringSourceItem | null { + if (typeof input === "string") { + const url = normalizeSourceUrl(input); + return url ? { url, normalized_url: url } : null; + } + if (!isRecord(input)) { + return null; + } + + const url = normalizeSourceUrl( + getDirectString(input, [ + "url", + "link", + "uri", + "href", + "cited_url", + "target_url", + "targetUrl", + "source_url", + "sourceUrl", + "page_url", + "pageUrl", + "jump_url", + "jumpUrl", + ]), + ); + if (!url) { + return null; + } + + let host: string | null = null; + try { + host = new URL(url).hostname || null; + } catch { + host = null; + } + + return { + url, + title: getDirectString(input, ["title", "name", "cited_title", "page_title", "display_title", "page_name"]), + site_name: getDirectString(input, ["site_name", "siteName", "source", "platform_name", "domain", "site", "host_name"]), + site_key: getDirectString(input, ["site_key", "siteKey", "domain_key"]), + normalized_url: url, + host, + resolution_status: getDirectString(input, ["resolution_status", "resolutionStatus"]), + resolution_confidence: getDirectString(input, ["resolution_confidence", "resolutionConfidence"]), + }; +} + +function dedupeSourceItems(items: MonitoringSourceItem[]): MonitoringSourceItem[] { + const keyed = new Map(); + for (const item of items) { + const key = normalizeSourceUrl(item.normalized_url ?? item.url); + if (!key) { + continue; + } + + const existing = keyed.get(key); + if (!existing) { + keyed.set(key, { + ...item, + normalized_url: key, + }); + continue; + } + + keyed.set(key, { + ...existing, + title: existing.title ?? item.title ?? null, + site_name: existing.site_name ?? item.site_name ?? null, + site_key: existing.site_key ?? item.site_key ?? null, + host: existing.host ?? item.host ?? null, + resolution_status: existing.resolution_status ?? item.resolution_status ?? null, + resolution_confidence: existing.resolution_confidence ?? item.resolution_confidence ?? null, + }); + } + + return Array.from(keyed.values()); +} + +function collectSources( + payload: unknown, + searchResults: MonitoringSourceItem[], + citations: MonitoringSourceItem[], + bucket: "search" | "citation" | null = null, + depth = 0, +): void { + if (depth > 12 || payload == null) { + return; + } + + const push = (item: MonitoringSourceItem, targetBucket: "search" | "citation" | null) => { + if (targetBucket === "citation") { + citations.push(item); + return; + } + searchResults.push(item); + }; + + if (typeof payload === "string") { + if (!bucket) { + return; + } + const item = buildSourceItem(payload); + if (item) { + push(item, bucket); + } + return; + } + + if (Array.isArray(payload)) { + for (const item of payload) { + collectSources(item, searchResults, citations, bucket, depth + 1); + } + return; + } + + if (!isRecord(payload)) { + return; + } + + const directSourceItem = buildSourceItem(payload); + if (directSourceItem && bucket) { + push(directSourceItem, bucket); + } + + for (const [key, value] of Object.entries(payload)) { + const nextBucket = resolveSourceBucket(key) ?? bucket; + if (Array.isArray(value) || isRecord(value) || typeof value === "string") { + collectSources(value, searchResults, citations, nextBucket, depth + 1); + } + } +} + +function collectAnswerFragments(payload: unknown, result: string[] = []): string[] { + if (payload == null) { + return result; + } + if (Array.isArray(payload)) { + for (const item of payload) { + collectAnswerFragments(item, result); + } + return result; + } + if (!isRecord(payload)) { + return result; + } + + const textBlockText = readNestedString(payload, ["text_block", "text"]); + if (textBlockText) { + result.push(textBlockText); + } + + const deltaText = normalizeOptionalString(payload.delta); + if (deltaText) { + result.push(deltaText); + } + + const nestedDeltaText = readNestedString(payload, ["delta", "text"]); + if (nestedDeltaText) { + result.push(nestedDeltaText); + } + + const assistantText = + (normalizeOptionalString(payload.role) === "assistant" || + normalizeOptionalString(payload.message_type) === "assistant") && + normalizeOptionalString(payload.text); + if (assistantText) { + result.push(assistantText); + } + + for (const value of Object.values(payload)) { + if (Array.isArray(value) || isRecord(value)) { + collectAnswerFragments(value, result); + } + } + + return result; +} + +function parseSSERecord(record: string): DoubaoStreamEvent | null { + const lines = record.split(/\r?\n/); + let eventName = "message"; + const dataLines: string[] = []; + + for (const line of lines) { + if (!line || line.startsWith(":")) { + continue; + } + if (line.startsWith("event:")) { + eventName = line.slice(6).trim() || eventName; + continue; + } + if (line.startsWith("data:")) { + dataLines.push(line.slice(5).trimStart()); + } + } + + if (!dataLines.length) { + return null; + } + + return { + event: eventName, + payload: safeParseJSON(dataLines.join("\n")), + }; +} + +function formatDoubaoStreamError(payload: unknown): string { + const errorCode = findFirstStringByKey(payload, ["error_code", "code"]); + const errorMessage = findFirstStringByKey(payload, ["error_msg", "message", "detail"]); + const verifyScene = findFirstStringByKey(payload, ["verify_scene"]); + + if (verifyScene) { + return `doubao verification required (${verifyScene})`; + } + if (errorCode && errorMessage) { + return `doubao stream error ${errorCode}: ${errorMessage}`; + } + if (errorCode) { + return `doubao stream error ${errorCode}`; + } + if (errorMessage) { + return `doubao stream error: ${errorMessage}`; + } + return "doubao stream error"; +} + +async function parseDoubaoStream(response: Response): Promise { + const summary: DoubaoStreamSummary = { + answer: null, + provider_request_id: null, + request_id: null, + conversation_id: null, + citations: [], + search_results: [], + event_count: 0, + error_message: null, + }; + + const body = response.body; + if (!body) { + throw new Error("doubao response stream missing"); + } + + const decoder = new TextDecoder(); + const reader = body.getReader(); + let buffer = ""; + + const consumeRecord = (rawRecord: string) => { + const parsed = parseSSERecord(rawRecord); + if (!parsed) { + return; + } + + summary.event_count += 1; + if (parsed.event.toUpperCase().includes("ERROR")) { + summary.error_message = formatDoubaoStreamError(parsed.payload); + return; + } + + if (!summary.conversation_id) { + summary.conversation_id = + readNestedString(parsed.payload, ["ack_client_meta", "conversation_id"]) ?? + readNestedString(parsed.payload, ["client_meta", "conversation_id"]) ?? + findFirstStringByKey(parsed.payload, ["conversation_id"]); + } + if (!summary.provider_request_id) { + summary.provider_request_id = findFirstStringByKey(parsed.payload, ["log_id", "request_id", "req_id"]); + } + if (!summary.request_id) { + summary.request_id = findFirstStringByKey(parsed.payload, ["message_id", "local_message_id", "reply_id"]); + } + + const fragments = collectAnswerFragments(parsed.payload); + for (const fragment of fragments) { + summary.answer = mergeAnswerText(summary.answer, fragment); + } + + collectSources(parsed.payload, summary.search_results, summary.citations); + }; + + while (true) { + const { done, value } = await reader.read(); + if (done) { + buffer += decoder.decode(); + buffer = buffer.replace(/\r\n/g, "\n"); + break; + } + + buffer += decoder.decode(value, { stream: true }); + buffer = buffer.replace(/\r\n/g, "\n"); + + let separatorIndex = buffer.indexOf("\n\n"); + while (separatorIndex !== -1) { + const rawRecord = buffer.slice(0, separatorIndex).trim(); + buffer = buffer.slice(separatorIndex + 2); + if (rawRecord) { + consumeRecord(rawRecord); + } + separatorIndex = buffer.indexOf("\n\n"); + } + } + + const tail = buffer.trim(); + if (tail) { + consumeRecord(tail); + } + + summary.citations = dedupeSourceItems(summary.citations); + summary.search_results = dedupeSourceItems(summary.search_results); + return summary; +} + +function buildDoubaoRequestUrl(runtimeState: DoubaoRuntimeState): string { + const url = new URL(DOUBAO_CHAT_URL); + url.searchParams.set("aid", DOUBAO_APP_ID); + url.searchParams.set("device_platform", "web"); + url.searchParams.set("device_id", runtimeState.device_id); + url.searchParams.set("web_id", runtimeState.web_id); + url.searchParams.set("tea_uuid", runtimeState.tea_uuid); + url.searchParams.set("msToken", runtimeState.ms_token); + url.searchParams.set("fp", runtimeState.fp); + return url.toString(); +} + +function buildDoubaoRequestBody(questionText: string, runtimeState: DoubaoRuntimeState): Record { + return { + client_meta: { + local_conversation_id: `local_${Date.now()}${Math.floor(Math.random() * 1_000_000)}`, + conversation_id: "", + bot_id: DOUBAO_BOT_ID, + last_section_id: "", + last_message_index: null, + }, + messages: [ + { + local_message_id: crypto.randomUUID(), + content_block: [ + { + block_type: 10000, + content: { + text_block: { + text: questionText, + icon_url: "", + icon_url_dark: "", + summary: "", + }, + pc_event_block: "", + }, + block_id: crypto.randomUUID(), + parent_id: "", + meta_info: [], + append_fields: [], + }, + ], + message_status: 0, + }, + ], + option: { + need_deep_think: 1, + need_create_conversation: true, + conversation_init_option: { + need_ack_conversation: true, + }, + sse_recv_event_options: { + support_chunk_delta: true, + }, + }, + ext: { + use_deep_think: "1", + fp: runtimeState.fp, + conversation_init_option: JSON.stringify({ + need_ack_conversation: true, + }), + commerce_credit_config_enable: "0", + sub_conv_firstmet_type: "1", + }, + }; +} + +function parseRuntimeState(value: unknown): DoubaoRuntimeState | null { + if (!isRecord(value)) { + return null; + } + + const fp = normalizeOptionalString(value.fp); + const msToken = normalizeOptionalString(value.ms_token); + const deviceID = normalizeOptionalString(value.device_id); + const webID = normalizeOptionalString(value.web_id); + const teaUUID = normalizeOptionalString(value.tea_uuid); + if (!fp || !msToken || !deviceID || !webID || !teaUUID) { + return null; + } + + return { + fp, + ms_token: msToken, + device_id: deviceID, + web_id: webID, + tea_uuid: teaUUID, + }; +} + +async function loadDoubaoRuntimeState(context: Parameters[0]): Promise { + context.reportProgress("doubao.bootstrap_view"); + await ensureViewLoaded(context.view, DOUBAO_REFERER, context.signal); + + context.reportProgress("doubao.read_runtime_state"); + const state = await context.view.webContents.executeJavaScript( + `(() => { + try { + const safeParse = (value) => { + if (!value) return null; + try { + return JSON.parse(value); + } catch { + return value; + } + }; + const samanthaState = safeParse(window.localStorage.getItem("samantha_web_web_id")); + const teaState = safeParse(window.localStorage.getItem("__tea_cache_tokens_497858")); + const pick = (source, paths) => { + for (const path of paths) { + let current = source; + let ok = true; + for (const key of path) { + if (!current || typeof current !== "object" || !(key in current)) { + ok = false; + break; + } + current = current[key]; + } + if (ok && typeof current === "string" && current.trim()) { + return current.trim(); + } + } + return null; + }; + const readCookie = (name) => { + const segments = document.cookie.split(";"); + for (const segment of segments) { + const [rawName, ...rest] = segment.trim().split("="); + if (rawName === name) { + const joined = rest.join("=").trim(); + return joined || null; + } + } + return null; + }; + return { + fp: readCookie("s_v_web_id"), + ms_token: window.localStorage.getItem("xmst"), + device_id: pick(samanthaState, [["web_id"], ["device_id"], ["deviceId"], ["id"]]), + web_id: pick(teaState, [["web_id"], ["webId"], ["id"]]), + tea_uuid: pick(teaState, [["web_id"], ["tea_uuid"], ["teaUuid"], ["user_unique_id"], ["uuid"]]), + }; + } catch (error) { + return { + error: error instanceof Error ? error.message : "runtime_state_read_failed", + }; + } + })();`, + true, + ); + + const runtimeState = parseRuntimeState(state); + if (!runtimeState) { + throw new Error("doubao_runtime_state_missing"); + } + return runtimeState; +} + +function extractQuestionText(payload: Record): string { + const candidates = [ + payload.question_text, + payload.query, + payload.question, + payload.prompt, + payload.content, + ]; + + for (const candidate of candidates) { + const text = normalizeOptionalString(candidate); + if (text) { + return text; + } + } + + throw new Error("doubao_question_text_missing"); +} + +function toJsonSources(items: MonitoringSourceItem[]): JsonValue[] { + return items.map((item) => ({ + url: item.url, + title: item.title ?? null, + site_name: item.site_name ?? null, + site_key: item.site_key ?? null, + normalized_url: item.normalized_url ?? null, + host: item.host ?? null, + resolution_status: item.resolution_status ?? null, + resolution_confidence: item.resolution_confidence ?? null, + })); +} + +function buildAdapterError( + code: string, + message: string, + extras: Record = {}, +): Record { + return { + code, + message, + ...extras, + }; +} + +export const doubaoAdapter: MonitorAdapter = { + provider: "doubao", + async query(context, payload) { + const questionText = extractQuestionText(payload); + const runtimeState = await loadDoubaoRuntimeState(context); + const requestBody = buildDoubaoRequestBody(questionText, runtimeState); + + context.reportProgress("doubao.query"); + const response = await context.session.fetch(buildDoubaoRequestUrl(runtimeState), { + method: "POST", + headers: { + Accept: "text/event-stream", + "Content-Type": "application/json", + referer: DOUBAO_REFERER, + }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + const bodyText = await response.text().catch(() => ""); + const detail = normalizeText(bodyText)?.slice(0, 240); + return { + status: "failed", + summary: detail ? `豆包请求失败:${detail}` : `豆包请求失败(${response.status})。`, + error: buildAdapterError( + "doubao_request_failed", + detail || `doubao_request_failed_${response.status}`, + { http_status: response.status }, + ), + }; + } + + context.reportProgress("doubao.parse_stream"); + const streamSummary = await parseDoubaoStream(response); + if (streamSummary.error_message) { + return { + status: "failed", + summary: streamSummary.error_message, + error: buildAdapterError("doubao_stream_error", streamSummary.error_message), + }; + } + + if (!streamSummary.answer && !streamSummary.citations.length && !streamSummary.search_results.length) { + return { + status: "unknown", + summary: "豆包返回为空,已回写 unknown 等待后续对账。", + error: buildAdapterError("doubao_empty_response", "doubao returned no answer or sources"), + }; + } + + return { + status: "succeeded", + summary: "豆包监控任务执行成功。", + payload: { + platform: "doubao", + provider_model: "doubao-web-thinking", + provider_request_id: streamSummary.provider_request_id, + request_id: streamSummary.request_id ?? streamSummary.conversation_id, + conversation_id: streamSummary.conversation_id, + answer: streamSummary.answer, + citation_count: streamSummary.citations.length, + search_result_count: streamSummary.search_results.length, + event_count: streamSummary.event_count, + citations: toJsonSources(streamSummary.citations), + search_results: toJsonSources(streamSummary.search_results), + }, + }; + }, +}; diff --git a/apps/desktop-client/src/main/adapters/index.ts b/apps/desktop-client/src/main/adapters/index.ts new file mode 100644 index 0000000..f61b13c --- /dev/null +++ b/apps/desktop-client/src/main/adapters/index.ts @@ -0,0 +1,3 @@ +export * from "./base"; +export * from "./doubao"; +export * from "./toutiao"; diff --git a/apps/desktop-client/src/main/adapters/toutiao.ts b/apps/desktop-client/src/main/adapters/toutiao.ts new file mode 100644 index 0000000..a0de742 --- /dev/null +++ b/apps/desktop-client/src/main/adapters/toutiao.ts @@ -0,0 +1,296 @@ +import type { JsonValue } from "@geo/shared-types"; + +import { + fetchImageBlob, + normalizeArticleHtml, + sessionFetchJson, + uploadHtmlImages, +} from "./common"; +import type { PublishAdapter } from "./base"; + +type ToutiaoMediaInfoResponse = { + data?: { + user?: { + id_str?: string; + screen_name?: string; + https_avatar_url?: string; + }; + media?: { + has_third_party_ad_permission?: boolean; + has_toutiao_ad_permission?: boolean; + }; + }; + code?: number; + message?: string; +}; + +type ToutiaoUploadPictureResponse = { + url?: string; + web_uri?: string; + origin_web_uri?: string; + rigin_web_uri?: string; + width?: number; + height?: number; +}; + +type ToutiaoSpiceImageResponse = { + data?: { + image_url?: string; + image_uri?: string; + image_width?: number; + image_height?: number; + }; +}; + +type ToutiaoPublishResponse = { + code?: number; + message?: string; + data?: { + pgc_id?: string | number; + }; +}; + +async function fetchMediaInfo(context: Parameters[0]) { + const response = await sessionFetchJson( + context.session, + "https://mp.toutiao.com/mp/agw/media/get_media_info", + { + headers: { + accept: "application/json, text/plain, */*", + }, + }, + ).catch(() => null); + + return response?.data ?? null; +} + +async function uploadCover( + context: Parameters[0], + sourceUrl: string, +) { + const blob = await fetchImageBlob(sourceUrl); + if (!blob) { + return null; + } + + if (context.article.publish_type === "publish") { + const form = new FormData(); + form.append("upfile", blob, "cover.png"); + const uploaded = await sessionFetchJson( + context.session, + "https://mp.toutiao.com/mp/agw/article_material/photo/upload_picture", + { + method: "POST", + body: form, + }, + ).catch(() => null); + + if (!uploaded?.url || !uploaded.web_uri) { + return null; + } + + return { + id: 0, + url: uploaded.url, + uri: uploaded.web_uri, + origin_uri: uploaded.origin_web_uri ?? uploaded.rigin_web_uri ?? "", + thumb_width: uploaded.width ?? 0, + thumb_height: uploaded.height ?? 0, + }; + } + + const form = new FormData(); + form.append("image", blob, "cover.png"); + const first = await sessionFetchJson( + context.session, + "https://mp.toutiao.com/spice/image?device_platform=web", + { + method: "POST", + body: form, + }, + ).catch(() => null); + + const imageUrl = first?.data?.image_url; + if (!imageUrl) { + return null; + } + + const second = new FormData(); + second.append("imageUrl", imageUrl); + const final = await sessionFetchJson( + context.session, + "https://mp.toutiao.com/spice/image?device_platform=web&need_cover_url=1", + { + method: "POST", + body: second, + }, + ).catch(() => null); + + if (!final?.data?.image_url || !first.data?.image_uri) { + return null; + } + + return { + id: "", + url: final.data.image_url, + uri: first.data.image_uri, + ic_uri: "", + thumb_width: first.data.image_width ?? 0, + thumb_height: first.data.image_height ?? 0, + extra: { + from_content_uri: "", + from_content: "0", + }, + }; +} + +async function uploadContentImage( + context: Parameters[0], + sourceUrl: string, +): Promise { + const blob = await fetchImageBlob(sourceUrl); + if (!blob) { + return null; + } + + const form = new FormData(); + form.append("image", blob, "image.png"); + const uploaded = await sessionFetchJson( + context.session, + "https://mp.toutiao.com/spice/image?device_platform=web", + { + method: "POST", + body: form, + }, + ).catch(() => null); + + return uploaded?.data?.image_url ?? null; +} + +function buildResultPayload( + articleId: string, + publishType: "publish" | "draft", + mediaName: string, +): Record { + const manageURL = + publishType === "draft" + ? "https://mp.toutiao.com/profile_v4/graphic/publish" + : "https://mp.toutiao.com/profile_v4/index"; + + return { + platform: "toutiaohao", + media_name: mediaName, + external_article_id: articleId, + external_manage_url: manageURL, + external_article_url: + publishType === "publish" ? `https://www.toutiao.com/article/${articleId}/` : null, + publish_type: publishType, + }; +} + +export const toutiaoAdapter: PublishAdapter = { + platform: "toutiaohao", + async publish(context) { + context.reportProgress("toutiao.media_info"); + const mediaInfo = await fetchMediaInfo(context); + const user = mediaInfo?.user; + if (!user?.id_str || !user.screen_name) { + return { + status: "failed", + summary: "头条号登录态失效,无法执行发布。", + error: { + code: "toutiaohao_not_logged_in", + message: "未检测到头条号登录态", + }, + }; + } + + context.reportProgress("toutiao.normalize_html"); + const html = normalizeArticleHtml(context.article); + if (!html) { + return { + status: "failed", + summary: "文章内容为空,无法推送到头条号。", + error: { + code: "article_content_empty", + message: "html_content is empty", + }, + }; + } + + context.reportProgress("toutiao.upload_content_images"); + const processed = await uploadHtmlImages(html, async (sourceUrl) => + uploadContentImage(context, sourceUrl), + ); + + context.reportProgress("toutiao.upload_cover"); + const cover = context.article.cover_asset_url?.trim() + ? await uploadCover(context, context.article.cover_asset_url.trim()) + : null; + + const hasAdPermission = Boolean( + mediaInfo?.media?.has_third_party_ad_permission || mediaInfo?.media?.has_toutiao_ad_permission, + ); + + const body = new URLSearchParams({ + content: processed.html, + title: context.article.title, + mp_editor_stat: JSON.stringify({ code_block: 1 }), + is_refute_rumor: "0", + save: context.article.publish_type === "publish" ? "1" : "0", + timer_status: "0", + draft_form_data: JSON.stringify({ coverType: 1 }), + article_ad_type: hasAdPermission ? "3" : "2", + pgc_feed_covers: JSON.stringify(cover ? [cover] : []), + is_fans_article: "0", + govern_forward: "0", + praise: "0", + disable_praise: "0", + tree_plan_article: "0", + activity_tag: "0", + trends_writing_tag: "0", + claim_exclusive: "0", + info_source: JSON.stringify({ + source_type: 5, + source_author_uid: "", + time_format: "", + position: {}, + }), + }); + + context.reportProgress("toutiao.submit"); + const response = await sessionFetchJson( + context.session, + "https://mp.toutiao.com/mp/agw/article/publish?source=mp&type=article&aid=1231", + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + referer: "https://mp.toutiao.com/profile_v4/graphic/publish", + }, + body, + }, + ); + + const articleId = response.data?.pgc_id != null ? String(response.data.pgc_id) : ""; + if (response.code !== 0 || !articleId) { + return { + status: "failed", + summary: response.message || "头条号发布失败。", + error: { + code: "toutiaohao_publish_failed", + message: response.message || "toutiaohao_publish_failed", + }, + }; + } + + const payload = buildResultPayload(articleId, context.article.publish_type, user.screen_name); + const draft = context.article.publish_type === "draft"; + return { + status: "succeeded", + summary: draft ? "头条号草稿已创建,等待人工审核。" : "头条号发布成功。", + payload, + reviewUrl: typeof payload.external_manage_url === "string" ? payload.external_manage_url : null, + }; + }, +}; diff --git a/apps/desktop-client/src/main/bootstrap.ts b/apps/desktop-client/src/main/bootstrap.ts new file mode 100644 index 0000000..0e1b94b --- /dev/null +++ b/apps/desktop-client/src/main/bootstrap.ts @@ -0,0 +1,193 @@ +import { join } from "node:path"; + +import { BrowserWindow, WebContentsView, app, ipcMain, nativeTheme } from "electron/main"; +import type { + BrowserWindow as ElectronBrowserWindow, + WebContentsView as ElectronWebContentsView, +} from "electron/main"; +import type { DesktopAccountInfo, DesktopRuntimeSessionSyncRequest } from "@geo/shared-types"; + +import { bindPublishAccount, openPublishAccountConsole } from "./account-binder"; +import { openTaskReview, refreshRuntimeAccounts, resolveParkedTask, syncRuntimeSession } from "./runtime-controller"; +import { createRuntimeSnapshot } from "./runtime-snapshot"; +import { initScheduler } from "./scheduler"; +import { initSessionRegistry } from "./session-registry"; +import { initSingleInstance } from "./single-instance"; +import { initTransport } from "./transport/api-client"; +import { initTray } from "./tray"; +import { STANDARD_USER_AGENT } from "./user-agent"; + +app.commandLine.appendSwitch( + "disable-features", + "PostQuantumKyber,EncryptedClientHello,UseDnsHttpsSvcb,UseDnsHttpsSvcbAlpn", +); +app.commandLine.appendSwitch("disable-quic"); +app.commandLine.appendSwitch("disable-background-networking"); +app.userAgentFallback = STANDARD_USER_AGENT; + +console.info("[desktop-main] boot", { + electron: process.versions.electron, + chrome: process.versions.chrome, + node: process.versions.node, + ua: STANDARD_USER_AGENT, +}); + +process.on("unhandledRejection", (reason) => { + console.error("[desktop-main] unhandled rejection", reason); +}); + +process.on("uncaughtException", (error) => { + console.error("[desktop-main] uncaught exception", error); +}); + +let mainWindow: ElectronBrowserWindow | null = null; + +function rendererURL(): string | null { + return process.env.ELECTRON_RENDERER_URL ?? null; +} + +function preloadPath(): string { + return join(__dirname, "../preload/bridge.cjs"); +} + +function syncViewBounds(window: ElectronBrowserWindow, view: ElectronWebContentsView): void { + const bounds = window.getContentBounds(); + view.setBounds({ x: 0, y: 0, width: bounds.width, height: bounds.height }); +} + +async function mountRendererView(window: ElectronBrowserWindow): Promise { + const view = new WebContentsView({ + webPreferences: { + preload: preloadPath(), + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + }, + }); + + view.webContents.setWindowOpenHandler(() => ({ action: "deny" })); + window.contentView.addChildView(view); + syncViewBounds(window, view); + window.on("resize", () => syncViewBounds(window, view)); + + const devServerURL = rendererURL(); + if (devServerURL) { + await view.webContents.loadURL(devServerURL); + view.webContents.openDevTools({ mode: "detach" }); + return; + } + + await view.webContents.loadFile(join(__dirname, "../renderer/index.html")); +} + +async function createMainWindow(): Promise { + const window = new BrowserWindow({ + width: 1320, + height: 860, + title: "GEO Rankly Desktop", + backgroundColor: nativeTheme.shouldUseDarkColors ? "#15191a" : "#f4f1ea", + }); + await mountRendererView(window); + return window; +} + +function flattenError(error: unknown): Error { + if (error instanceof Error) { + const copy = new Error(error.message || "ipc_handler_failed"); + copy.name = error.name; + return copy; + } + return new Error(typeof error === "string" ? error : "ipc_handler_failed"); +} + +function safeHandle( + channel: string, + handler: (...args: Args) => Promise | Result, +): void { + ipcMain.handle(channel, async (...args) => { + try { + return await handler(...(args as unknown as Args)); + } catch (error) { + throw flattenError(error); + } + }); +} + +function registerBridgeHandlers(): void { + ipcMain.handle("desktop:ping", () => "pong"); + ipcMain.handle("desktop:runtime-snapshot", () => createRuntimeSnapshot()); + safeHandle("desktop:bind-publish-account", async (_event, platformId: string) => { + const account = (await bindPublishAccount(platformId)) as DesktopAccountInfo; + await refreshRuntimeAccounts(); + return account; + }); + safeHandle( + "desktop:open-publish-account-console", + async ( + _event, + account: { id: string; platform: string; platformUid: string; displayName: string }, + ) => { + await openPublishAccountConsole(account); + return null; + }, + ); + safeHandle("desktop:task-open-review", async (_event, taskId: string) => { + await openTaskReview(taskId); + return null; + }); + safeHandle( + "desktop:task-resolve-parked", + async (_event, taskId: string, status: "succeeded" | "failed" | "unknown") => { + await resolveParkedTask(taskId, status); + return null; + }, + ); + ipcMain.handle( + "desktop:runtime-session-sync", + (_event, session: DesktopRuntimeSessionSyncRequest | null) => { + syncRuntimeSession(session); + return null; + }, + ); +} + +if (!initSingleInstance(() => { + if (mainWindow) { + mainWindow.show(); + mainWindow.focus(); + } +})) { + app.quit(); +} + +app.whenReady().then(async () => { + registerBridgeHandlers(); + await initSessionRegistry(); + initTransport(); + initScheduler(); + mainWindow = await createMainWindow(); + initTray(() => { + if (!mainWindow) { + return; + } + mainWindow.show(); + mainWindow.focus(); + }); +}).catch((error) => { + console.error("[desktop-main] app.whenReady failed", error); +}); + +app.on("activate", async () => { + if (mainWindow) { + mainWindow.show(); + mainWindow.focus(); + return; + } + mainWindow = await createMainWindow(); +}); + +app.on("window-all-closed", () => { + if (process.platform !== "darwin") { + app.quit(); + } +}); diff --git a/apps/desktop-client/src/main/circuit-breaker.ts b/apps/desktop-client/src/main/circuit-breaker.ts new file mode 100644 index 0000000..24eb9db --- /dev/null +++ b/apps/desktop-client/src/main/circuit-breaker.ts @@ -0,0 +1,18 @@ +interface CircuitBreakerState { + status: "closed" | "open" | "half-open"; + updatedAt: number; +} + +const state: CircuitBreakerState = { + status: "closed", + updatedAt: Date.now(), +}; + +export function setCircuitBreakerState(status: CircuitBreakerState["status"]): void { + state.status = status; + state.updatedAt = Date.now(); +} + +export function getCircuitBreakerState(): CircuitBreakerState { + return { ...state }; +} diff --git a/apps/desktop-client/src/main/crash-recovery.ts b/apps/desktop-client/src/main/crash-recovery.ts new file mode 100644 index 0000000..0b8a322 --- /dev/null +++ b/apps/desktop-client/src/main/crash-recovery.ts @@ -0,0 +1,6 @@ +export function collectRecoverySnapshot() { + return { + recoveredAt: Date.now(), + pendingTaskCount: 0, + }; +} diff --git a/apps/desktop-client/src/main/external-window.ts b/apps/desktop-client/src/main/external-window.ts new file mode 100644 index 0000000..89887ee --- /dev/null +++ b/apps/desktop-client/src/main/external-window.ts @@ -0,0 +1,225 @@ +import type { BrowserWindow } from "electron/main"; + +import { STANDARD_USER_AGENT } from "./user-agent"; + +const ignoredNavigationFailurePattern = /ERR_ABORTED|ERR_BLOCKED_BY_CLIENT/i; +const errorPagePrefix = "data:text/html"; + +interface WindowNavigationState { + readyForDetection: boolean; + lastMainFrameError: string | null; +} + +const navigationStateRegistry = new WeakMap(); + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("\"", """) + .replaceAll("'", "'"); +} + +function errorPageDataURL(context: string, targetURL: string, message: string): string { + const html = ` + + + + + ${escapeHtml(context)} + + + +
+

授权页加载失败

+

${escapeHtml(context)} 在打开远程页面时出现网络或 TLS 握手异常。窗口已保留,不再直接闪退。

+

如果这是偶发网络抖动,点“重新加载”即可;如果持续失败,再看控制台里打印的 URL 和错误码定位。

+
${escapeHtml(message)}
+
+${escapeHtml(targetURL)}
+
+ + 打开目标地址 +
+
+ +`; + + return `data:text/html;charset=UTF-8,${encodeURIComponent(html)}`; +} + +export function attachWindowDiagnostics(window: BrowserWindow, context: string): void { + navigationStateRegistry.set(window, { + readyForDetection: false, + lastMainFrameError: null, + }); + + window.webContents.on("did-start-loading", () => { + const state = navigationStateRegistry.get(window); + if (!state) { + return; + } + state.readyForDetection = false; + }); + + window.webContents.on( + "did-fail-load", + (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { + if (!isMainFrame || errorCode === -3) { + return; + } + + const state = navigationStateRegistry.get(window); + if (state) { + state.readyForDetection = false; + state.lastMainFrameError = `${errorCode}:${errorDescription}`; + } + + console.warn(`[desktop-window] ${context} main-frame load failed`, { + errorCode, + errorDescription, + validatedURL, + }); + }, + ); + + window.webContents.on("did-finish-load", () => { + const state = navigationStateRegistry.get(window); + if (!state) { + return; + } + + const currentURL = window.webContents.getURL(); + state.readyForDetection = !currentURL.startsWith(errorPagePrefix); + if (state.readyForDetection) { + state.lastMainFrameError = null; + } + }); + + window.webContents.on("render-process-gone", (_event, details) => { + const state = navigationStateRegistry.get(window); + if (state) { + state.readyForDetection = false; + } + + console.error(`[desktop-window] ${context} renderer gone`, details); + }); +} + +export function isWindowReadyForDetection(window: BrowserWindow): boolean { + const state = navigationStateRegistry.get(window); + if (!state) { + return !window.webContents.getURL().startsWith(errorPagePrefix); + } + return state.readyForDetection; +} + +export async function loadWindowURLSafely( + window: BrowserWindow, + targetURL: string, + context: string, +): Promise { + try { + await window.loadURL(targetURL, { userAgent: STANDARD_USER_AGENT }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + + if (ignoredNavigationFailurePattern.test(message) || window.isDestroyed()) { + return; + } + + console.error(`[desktop-window] ${context} loadURL failed`, { + targetURL, + message, + }); + + if (window.webContents.getURL().startsWith(errorPagePrefix)) { + return; + } + + try { + await window.loadURL(errorPageDataURL(context, targetURL, message)); + } catch (fallbackError) { + const fallbackMessage = + fallbackError instanceof Error ? fallbackError.message : String(fallbackError); + console.error(`[desktop-window] ${context} error-page loadURL failed`, { + targetURL, + message: fallbackMessage, + }); + } + } +} diff --git a/apps/desktop-client/src/main/keep-alive.ts b/apps/desktop-client/src/main/keep-alive.ts new file mode 100644 index 0000000..eed8464 --- /dev/null +++ b/apps/desktop-client/src/main/keep-alive.ts @@ -0,0 +1,18 @@ +interface KeepAlivePlan { + mode: "probe-only" | "heartbeat" | "disabled"; + updatedAt: number; +} + +const keepAlivePlan: KeepAlivePlan = { + mode: "probe-only", + updatedAt: Date.now(), +}; + +export function updateKeepAlivePlan(mode: KeepAlivePlan["mode"]): void { + keepAlivePlan.mode = mode; + keepAlivePlan.updatedAt = Date.now(); +} + +export function getKeepAlivePlan(): KeepAlivePlan { + return { ...keepAlivePlan }; +} diff --git a/apps/desktop-client/src/main/lease-manager.ts b/apps/desktop-client/src/main/lease-manager.ts new file mode 100644 index 0000000..8ea0f5c --- /dev/null +++ b/apps/desktop-client/src/main/lease-manager.ts @@ -0,0 +1,90 @@ +interface LeaseSnapshot { + activeLeaseId: string | null; + activeTaskId: string | null; + attemptId: string | null; + startedAt: number | null; + leaseExpiresAt: number | null; + lastExtendedAt: number | null; + lastReleasedAt: number | null; + lastOutcome: "leased" | "extended" | "parked" | "completed" | "failed" | "cleared" | null; +} + +interface ActiveLeaseState { + taskId: string; + attemptId: string | null; + leaseExpiresAt: number | null; +} + +const leaseState: LeaseSnapshot = { + activeLeaseId: null, + activeTaskId: null, + attemptId: null, + startedAt: null, + leaseExpiresAt: null, + lastExtendedAt: null, + lastReleasedAt: null, + lastOutcome: null, +}; + +function normalizeLeaseExpiresAt(value: number | string | null | undefined): number | null { + if (typeof value === "number") { + return Number.isFinite(value) ? value : null; + } + + if (typeof value !== "string" || !value.trim()) { + return null; + } + + const timestamp = Date.parse(value); + return Number.isNaN(timestamp) ? null : timestamp; +} + +export function setActiveLease(input: { + taskId: string; + attemptId?: string | null; + leaseExpiresAt?: number | string | null; +} | null): void { + if (!input) { + leaseState.activeLeaseId = null; + leaseState.activeTaskId = null; + leaseState.attemptId = null; + leaseState.startedAt = null; + leaseState.leaseExpiresAt = null; + leaseState.lastOutcome = "cleared"; + leaseState.lastReleasedAt = Date.now(); + return; + } + + const normalized: ActiveLeaseState = { + taskId: input.taskId, + attemptId: input.attemptId ?? null, + leaseExpiresAt: normalizeLeaseExpiresAt(input.leaseExpiresAt), + }; + + leaseState.activeLeaseId = normalized.taskId; + leaseState.activeTaskId = normalized.taskId; + leaseState.attemptId = normalized.attemptId; + leaseState.startedAt = Date.now(); + leaseState.leaseExpiresAt = normalized.leaseExpiresAt; + leaseState.lastOutcome = "leased"; +} + +export function noteLeaseExtended(leaseExpiresAt?: number | string | null): void { + leaseState.lastExtendedAt = Date.now(); + leaseState.leaseExpiresAt = normalizeLeaseExpiresAt(leaseExpiresAt) ?? leaseState.leaseExpiresAt; + leaseState.lastOutcome = "extended"; +} + +export function noteLeaseReleased(reason: Exclude): void { + leaseState.activeLeaseId = null; + leaseState.activeTaskId = null; + leaseState.attemptId = null; + leaseState.startedAt = null; + leaseState.leaseExpiresAt = null; + leaseState.lastReleasedAt = Date.now(); + leaseState.lastOutcome = reason; +} + +export function getLeaseManagerSnapshot(): LeaseSnapshot { + return { ...leaseState }; +} diff --git a/apps/desktop-client/src/main/rate-limiter.ts b/apps/desktop-client/src/main/rate-limiter.ts new file mode 100644 index 0000000..cc49944 --- /dev/null +++ b/apps/desktop-client/src/main/rate-limiter.ts @@ -0,0 +1,18 @@ +interface RateLimiterSnapshot { + refreshedAt: number; + bucketCount: number; +} + +const state: RateLimiterSnapshot = { + refreshedAt: 0, + bucketCount: 0, +}; + +export function touchRateLimiter(bucketCount: number): void { + state.refreshedAt = Date.now(); + state.bucketCount = bucketCount; +} + +export function getRateLimiterSnapshot(): RateLimiterSnapshot { + return { ...state }; +} diff --git a/apps/desktop-client/src/main/review-window.ts b/apps/desktop-client/src/main/review-window.ts new file mode 100644 index 0000000..5a08941 --- /dev/null +++ b/apps/desktop-client/src/main/review-window.ts @@ -0,0 +1,54 @@ +import { BrowserWindow, nativeTheme } from "electron/main"; +import type { Session } from "electron/main"; + +import { attachWindowDiagnostics, loadWindowURLSafely } from "./external-window"; + +interface ReviewWindowRecord { + taskId: string; + window: BrowserWindow; +} + +const reviewWindows = new Map(); + +export async function openReviewWindow(options: { + taskId: string; + title: string; + url: string; + session: Session; +}): Promise { + const existing = reviewWindows.get(options.taskId); + if (existing && !existing.window.isDestroyed()) { + existing.window.show(); + existing.window.focus(); + if (existing.window.webContents.getURL() !== options.url) { + await loadWindowURLSafely(existing.window, options.url, options.title); + } + return; + } + + const window = new BrowserWindow({ + width: 1440, + height: 960, + minWidth: 1120, + minHeight: 760, + title: options.title, + autoHideMenuBar: true, + backgroundColor: nativeTheme.shouldUseDarkColors ? "#15191a" : "#f4f1ea", + webPreferences: { + session: options.session, + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + }, + }); + + attachWindowDiagnostics(window, options.title); + reviewWindows.set(options.taskId, { taskId: options.taskId, window }); + window.on("closed", () => { + reviewWindows.delete(options.taskId); + }); + + await loadWindowURLSafely(window, options.url, options.title); + window.show(); + window.focus(); +} diff --git a/apps/desktop-client/src/main/runtime-controller.ts b/apps/desktop-client/src/main/runtime-controller.ts new file mode 100644 index 0000000..cb8076e --- /dev/null +++ b/apps/desktop-client/src/main/runtime-controller.ts @@ -0,0 +1,1605 @@ +import { ApiClientError } from "@geo/http-client"; +import type { + DesktopArticleContent, + DesktopAccountInfo, + DesktopClientInfo, + DesktopRuntimeSessionSyncRequest, + DesktopTaskEventMessage, + DesktopTaskInfo, + JsonValue, + LeaseDesktopTaskResponse, +} from "@geo/shared-types"; + +import { + doubaoAdapter, + toutiaoAdapter, + type AdapterExecutionResult, + type MonitorAdapter, + type PublishAdapter, +} from "./adapters"; +import { ensurePublishAccountSessionHandle, resolvePublishAccountProfile, type PublishAccountProfile } from "./account-binder"; +import { acquireHotView } from "./view-pool"; +import { getLeaseManagerSnapshot, noteLeaseExtended, noteLeaseReleased, setActiveLease } from "./lease-manager"; +import { + initScheduler, + noteSchedulerAccountsSync, + noteSchedulerHeartbeat, + noteSchedulerPull, + noteSchedulerSseEvent, + noteSchedulerTaskFinished, + setSchedulerCurrentTask, + setSchedulerError, + setSchedulerNextHeartbeat, + setSchedulerNextPull, + setSchedulerPhase, + setSchedulerQueueDepth, +} from "./scheduler"; +import { createSessionHandle, getSessionHandle } from "./session-registry"; +import { openReviewWindow } from "./review-window"; +import { + completeDesktopTask, + configureTransport, + extendDesktopTask, + getDesktopArticleContent, + heartbeatDesktopClient, + leaseDesktopTask, + listDesktopAccounts, + noteTransportHeartbeat, + noteTransportPull, + parkDesktopTask, + setTransportAuthState, + setTransportSseState, +} from "./transport/api-client"; +import { SseClient, SseClientError } from "./transport/sse-client"; + +type RuntimeTone = "info" | "success" | "warn" | "danger"; +type RuntimeTaskStatus = + | "queued" + | "in_progress" + | "waiting_user" + | "succeeded" + | "failed" + | "unknown" + | "aborted"; +type RuntimeTaskMode = "auto" | "manual"; +type RuntimeTaskRouting = "rabbitmq-primary" | "db-recovery"; + +const heartbeatIntervalMs = 25_000; +const pullIntervalMs = 60_000; +const accountsSyncIntervalMs = 90_000; +const leaseExtendIntervalMs = 60_000; +const maxActivityItems = 60; + +interface RuntimeTaskRecord { + id: string; + jobId: string; + title: string; + kind: "publish" | "monitor"; + platform: string; + accountId: string; + accountName: string; + clientId: string; + status: RuntimeTaskStatus; + mode: RuntimeTaskMode; + routing: RuntimeTaskRouting; + leaseExpiresAt: number | null; + updatedAt: number; + summary: string; + payload: Record; + error: Record | null; + result: Record | null; + attemptId: string | null; + leaseToken: string | null; +} + +export interface RuntimeControllerActivity { + id: string; + severity: RuntimeTone; + title: string; + detail: string; + at: number; +} + +export interface RuntimeControllerSnapshot { + session: DesktopRuntimeSessionSyncRequest | null; + client: DesktopClientInfo | null; + running: boolean; + sseConnected: boolean; + queueDepth: number; + accounts: DesktopAccountInfo[]; + accountProfiles: Record; + tasks: Array>; + activity: RuntimeControllerActivity[]; + lastHeartbeatAt: number; + lastHeartbeatStatus: "idle" | "success" | "failed"; + lastPullAt: number; + lastPullStatus: "idle" | "success" | "failed"; + lastAccountsSyncAt: number; + lastServerTime: string | null; + lastError: string | null; +} + +interface PendingTaskRequest { + taskId: string; + routing: RuntimeTaskRouting; +} + +interface RuntimeState { + session: DesktopRuntimeSessionSyncRequest | null; + client: DesktopClientInfo | null; + running: boolean; + sseConnected: boolean; + queue: PendingTaskRequest[]; + queuedTaskIds: Set; + tasks: Map; + accounts: DesktopAccountInfo[]; + accountProfiles: Map; + activity: RuntimeControllerActivity[]; + currentTaskId: string | null; + currentExecutionAbort: AbortController | null; + leaseInFlight: boolean; + heartbeatTimer: ReturnType | null; + pullTimer: ReturnType | null; + accountsTimer: ReturnType | null; + sseClient: SseClient | null; + lastHeartbeatAt: number; + lastHeartbeatStatus: "idle" | "success" | "failed"; + lastPullAt: number; + lastPullStatus: "idle" | "success" | "failed"; + lastAccountsSyncAt: number; + lastServerTime: string | null; + lastError: string | null; + activitySeq: number; +} + +const state: RuntimeState = { + session: null, + client: null, + running: false, + sseConnected: false, + queue: [], + queuedTaskIds: new Set(), + tasks: new Map(), + accounts: [], + accountProfiles: new Map(), + activity: [], + currentTaskId: null, + currentExecutionAbort: null, + leaseInFlight: false, + heartbeatTimer: null, + pullTimer: null, + accountsTimer: null, + sseClient: null, + lastHeartbeatAt: 0, + lastHeartbeatStatus: "idle", + lastPullAt: 0, + lastPullStatus: "idle", + lastAccountsSyncAt: 0, + lastServerTime: null, + lastError: null, + activitySeq: 0, +}; + +export function syncRuntimeSession(next: DesktopRuntimeSessionSyncRequest | null): void { + const normalized = normalizeSession(next); + const changed = !sameSession(state.session, normalized); + + state.session = normalized; + state.client = normalized?.desktop_client ?? null; + + if (!canRunLive(normalized)) { + stopRuntime(); + clearRuntimeState(); + configureTransport(null); + setTransportAuthState("pending"); + return; + } + + configureTransport(normalized); + setTransportAuthState("registered"); + + if (changed) { + stopRuntime(); + clearRuntimeState(); + recordActivity( + "info", + "Desktop Runtime Armed", + `Client ${normalized.desktop_client?.device_name ?? normalized.desktop_client?.id ?? "unknown"} 已准备消费桌面任务。`, + ); + } + + if (!state.running) { + startRuntime(); + } +} + +export function getRuntimeControllerSnapshot(): RuntimeControllerSnapshot { + return { + session: state.session ? { ...state.session } : null, + client: state.client ? { ...state.client } : null, + running: state.running, + sseConnected: state.sseConnected, + queueDepth: state.queue.length, + accounts: state.accounts.map((item) => ({ ...item })), + accountProfiles: Object.fromEntries( + [...state.accountProfiles.entries()].map(([accountId, profile]) => [accountId, { ...profile }]), + ), + tasks: [...state.tasks.values()] + .sort((left, right) => right.updatedAt - left.updatedAt) + .map(({ leaseToken: _leaseToken, ...task }) => ({ ...task })), + activity: [...state.activity], + lastHeartbeatAt: state.lastHeartbeatAt, + lastHeartbeatStatus: state.lastHeartbeatStatus, + lastPullAt: state.lastPullAt, + lastPullStatus: state.lastPullStatus, + lastAccountsSyncAt: state.lastAccountsSyncAt, + lastServerTime: state.lastServerTime, + lastError: state.lastError, + }; +} + +export async function openTaskReview(taskId: string): Promise { + const task = state.tasks.get(taskId); + if (!task) { + throw new Error("desktop_task_not_found"); + } + if (task.kind !== "publish") { + throw new Error("desktop_task_review_only_for_publish"); + } + + const reviewURL = resolveTaskReviewUrl(task); + if (!reviewURL) { + throw new Error("desktop_task_review_url_missing"); + } + + const sessionHandle = createSessionHandle(task.accountId || task.id); + await openReviewWindow({ + taskId: task.id, + title: `${task.title} · 审核`, + url: reviewURL, + session: sessionHandle.session, + }); + + recordActivity("info", "Review Window Opened", `${task.title} 已打开审核页。`); +} + +export async function resolveParkedTask( + taskId: string, + status: "succeeded" | "failed" | "unknown", +): Promise { + const existing = state.tasks.get(taskId); + if (!existing) { + throw new Error("desktop_task_not_found"); + } + if (existing.status !== "waiting_user") { + throw new Error("desktop_task_not_waiting_user"); + } + if (!canRunLive(state.session)) { + throw new Error("desktop_runtime_not_authenticated"); + } + if (state.currentTaskId && state.currentTaskId !== taskId) { + throw new Error("desktop_runtime_busy"); + } + + state.currentTaskId = taskId; + setSchedulerCurrentTask(taskId); + setSchedulerPhase("running"); + + try { + const leased = await leaseDesktopTask({ task_id: taskId }, { fromParked: true }); + if (!leased.task || !leased.lease_token) { + throw new Error("desktop_task_resume_lease_missing"); + } + + setActiveLease({ + taskId, + attemptId: leased.attempt_id ?? null, + leaseExpiresAt: leased.lease_expires_at ?? leased.task.lease_expires_at ?? null, + }); + + const resumed = upsertTaskFromInfo(leased.task, { + routing: existing.routing, + mode: existing.mode, + attemptId: leased.attempt_id ?? null, + leaseToken: leased.lease_token, + summary: "已从 parked 重新领取租约,准备回写人工审核结果。", + }); + + const resolutionPayload = buildManualResolutionPayload(resumed, status); + const completed = await completeDesktopTask(taskId, { + lease_token: leased.lease_token, + status, + payload: resolutionPayload.payload, + error: resolutionPayload.error, + }); + + noteLeaseReleased(status === "failed" ? "failed" : "completed"); + upsertTaskFromInfo(completed, { + routing: existing.routing, + mode: existing.mode, + summary: resolutionPayload.summary, + attemptId: null, + leaseToken: null, + }); + + recordActivity( + status === "failed" ? "danger" : status === "unknown" ? "warn" : "success", + "Parked Task Resolved", + `${existing.title} 已按人工审核结果回写为 ${status}。`, + ); + } finally { + state.currentTaskId = null; + setSchedulerCurrentTask(null); + noteSchedulerTaskFinished(); + processQueue(); + } +} + +function startRuntime(): void { + if (!canRunLive(state.session) || state.running) { + return; + } + + initScheduler(); + state.running = true; + setSchedulerPhase("running"); + setSchedulerError(null); + recordActivity("success", "Desktop Runtime Started", "已启动 SSE、heartbeat 和 pull fallback。"); + + connectEventStream(); + scheduleHeartbeatLoop(); + schedulePullLoop(); + scheduleAccountsSyncLoop(); + + void sendHeartbeat("startup"); + void syncAccounts("startup"); + processQueue(); +} + +function stopRuntime(): void { + state.running = false; + state.sseConnected = false; + state.leaseInFlight = false; + state.currentExecutionAbort?.abort(); + state.currentExecutionAbort = null; + state.currentTaskId = null; + + if (state.heartbeatTimer) { + clearInterval(state.heartbeatTimer); + state.heartbeatTimer = null; + } + if (state.pullTimer) { + clearInterval(state.pullTimer); + state.pullTimer = null; + } + if (state.accountsTimer) { + clearInterval(state.accountsTimer); + state.accountsTimer = null; + } + if (state.sseClient) { + state.sseClient.stop(); + state.sseClient = null; + } + + setTransportSseState("idle"); + setSchedulerPhase("idle"); + setSchedulerCurrentTask(null); + setSchedulerQueueDepth(0); + setSchedulerNextHeartbeat(null); + setSchedulerNextPull(null); + + if (getLeaseManagerSnapshot().activeTaskId) { + noteLeaseReleased("cleared"); + } +} + +function clearRuntimeState(): void { + state.queue = []; + state.queuedTaskIds.clear(); + state.tasks.clear(); + state.accounts = []; + state.accountProfiles.clear(); + state.currentTaskId = null; + state.lastHeartbeatAt = 0; + state.lastHeartbeatStatus = "idle"; + state.lastPullAt = 0; + state.lastPullStatus = "idle"; + state.lastAccountsSyncAt = 0; + state.lastServerTime = null; + state.lastError = null; + setSchedulerQueueDepth(0); +} + +function scheduleHeartbeatLoop(): void { + if (state.heartbeatTimer) { + clearInterval(state.heartbeatTimer); + } + + setSchedulerNextHeartbeat(Date.now() + heartbeatIntervalMs); + state.heartbeatTimer = setInterval(() => { + setSchedulerNextHeartbeat(Date.now() + heartbeatIntervalMs); + void sendHeartbeat("timer"); + }, heartbeatIntervalMs); +} + +function schedulePullLoop(): void { + if (state.pullTimer) { + clearInterval(state.pullTimer); + } + + setSchedulerNextPull(Date.now() + pullIntervalMs); + state.pullTimer = setInterval(() => { + setSchedulerNextPull(Date.now() + pullIntervalMs); + processQueue(); + }, pullIntervalMs); +} + +function scheduleAccountsSyncLoop(): void { + if (state.accountsTimer) { + clearInterval(state.accountsTimer); + } + + state.accountsTimer = setInterval(() => { + void syncAccounts("timer"); + }, accountsSyncIntervalMs); +} + +function connectEventStream(): void { + if (!canRunLive(state.session)) { + return; + } + + state.sseClient?.stop(); + + const sseClient = new SseClient({ + url: buildApiUrl(state.session.api_base_url, "/api/desktop/events"), + headers: { + Authorization: `Bearer ${state.session.client_token}`, + }, + }); + + sseClient.on("state", (payload) => { + if (payload === "streaming") { + setTransportSseState("streaming"); + return; + } + if (payload === "connecting") { + setTransportSseState("connecting"); + return; + } + setTransportSseState("idle"); + }); + + sseClient.on("connected", (payload) => { + state.sseConnected = true; + noteSchedulerSseEvent(); + setSchedulerError(null); + + if (isConnectedEvent(payload)) { + state.lastServerTime = payload.server_time; + } + + recordActivity("success", "Desktop Stream Connected", "服务端已建立定向任务事件流。"); + }); + + sseClient.on("ping", () => { + state.sseConnected = true; + noteSchedulerSseEvent(); + }); + + const taskEventHandler = (payload: unknown) => { + if (!isDesktopTaskEvent(payload)) { + return; + } + state.sseConnected = true; + noteSchedulerSseEvent(); + handleTaskEvent(payload); + }; + + sseClient.on("task_available", taskEventHandler); + sseClient.on("task_leased", taskEventHandler); + sseClient.on("task_extended", taskEventHandler); + sseClient.on("task_parked", taskEventHandler); + sseClient.on("task_completed", taskEventHandler); + sseClient.on("task_canceled", taskEventHandler); + sseClient.on("task_reconciled", taskEventHandler); + + sseClient.on("error", (payload) => { + state.sseConnected = false; + + if (payload instanceof SseClientError && payload.status === 401) { + handleAuthExpired(payload); + return; + } + + const message = errorMessage(payload); + state.lastError = message; + setSchedulerError(message); + recordActivity("warn", "Desktop Stream Reconnecting", message); + }); + + sseClient.on("reconnect", (payload) => { + if (!isReconnectPayload(payload)) { + return; + } + recordActivity("info", "Desktop Stream Backoff", `将在 ${payload.delayMs}ms 后重连 SSE。`); + }); + + state.sseClient = sseClient; + sseClient.start(); +} + +function handleTaskEvent(event: DesktopTaskEventMessage): void { + const existing = state.tasks.get(event.task_id); + const next: RuntimeTaskRecord = { + id: event.task_id, + jobId: event.job_id, + title: existing?.title ?? defaultTaskTitle(event.kind, existing?.platform), + kind: event.kind, + platform: existing?.platform ?? inferPlatformFromAccount(existing?.accountId ?? null), + accountId: existing?.accountId ?? "", + accountName: existing?.accountName ?? "待同步账号", + clientId: event.target_client_id, + status: event.status, + mode: existing?.mode ?? "auto", + routing: existing?.routing ?? "rabbitmq-primary", + leaseExpiresAt: event.status === "in_progress" ? existing?.leaseExpiresAt ?? null : null, + updatedAt: parseTimestamp(event.updated_at) ?? Date.now(), + summary: summaryFromEventType(event.type, event.status), + payload: existing?.payload ?? {}, + error: existing?.error ?? null, + result: existing?.result ?? null, + attemptId: event.status === "in_progress" ? existing?.attemptId ?? null : null, + leaseToken: event.status === "in_progress" ? existing?.leaseToken ?? null : null, + }; + + state.tasks.set(event.task_id, next); + + if (event.type === "task_available") { + enqueueTask(event.task_id, "rabbitmq-primary"); + processQueue(); + } +} + +async function sendHeartbeat(source: "startup" | "timer"): Promise { + if (!canRunLive(state.session)) { + return; + } + + const previousStatus = state.lastHeartbeatStatus; + + try { + const response = await heartbeatDesktopClient({ + device_name: state.client?.device_name ?? `Desktop ${process.platform}`, + os: state.client?.os ?? process.platform, + cpu_arch: state.client?.cpu_arch ?? process.arch, + client_version: state.client?.client_version ?? "0.1.0-dev", + channel: state.client?.channel ?? "dev", + }); + + state.client = response.client; + state.lastHeartbeatAt = Date.now(); + state.lastHeartbeatStatus = "success"; + state.lastServerTime = response.server_time; + state.lastError = null; + + noteTransportHeartbeat(true); + noteSchedulerHeartbeat(); + setSchedulerError(null); + + if (source === "startup" || previousStatus === "failed") { + recordActivity("success", "Heartbeat Healthy", "client heartbeat 已写回服务端。"); + } + } catch (error) { + state.lastHeartbeatAt = Date.now(); + state.lastHeartbeatStatus = "failed"; + state.lastError = errorMessage(error); + + noteTransportHeartbeat(false); + setSchedulerError(state.lastError); + + if (isApiClientError(error, 401)) { + handleAuthExpired(error); + return; + } + + if (source === "startup" || previousStatus !== "failed") { + recordActivity("danger", "Heartbeat Failed", state.lastError); + } + } +} + +async function syncAccounts(source: "startup" | "timer" | "manual"): Promise { + if (!canRunLive(state.session)) { + return; + } + + try { + const accounts = await listDesktopAccounts(); + state.lastAccountsSyncAt = Date.now(); + state.lastError = null; + + noteSchedulerAccountsSync(); + setSchedulerError(null); + + if (source === "startup") { + recordActivity("info", "Accounts Synced", `已同步 ${state.accounts.length} 个桌面账号。`); + } + + state.accountProfiles.clear(); + state.accounts = await Promise.all(accounts.map(async (account) => { + await ensurePublishAccountSessionHandle({ + id: account.id, + platform: account.platform, + platformUid: account.platform_uid, + displayName: account.display_name, + }); + + const profile = await resolvePublishAccountProfile({ + id: account.id, + platform: account.platform, + platformUid: account.platform_uid, + displayName: account.display_name, + }).catch(() => null); + + if (profile) { + state.accountProfiles.set(account.id, profile); + return { + ...account, + display_name: profile.displayName, + platform_uid: profile.platformUid, + }; + } + + return account; + })); + + refreshAccountNames(); + } catch (error) { + const message = errorMessage(error); + state.lastError = message; + setSchedulerError(message); + + if (isApiClientError(error, 401)) { + handleAuthExpired(error); + return; + } + + recordActivity("warn", "Account Sync Failed", message); + } +} + +export async function refreshRuntimeAccounts(): Promise { + await syncAccounts("manual"); +} + +function enqueueTask(taskId: string, routing: RuntimeTaskRouting): void { + if (!taskId || state.currentTaskId === taskId || state.queuedTaskIds.has(taskId)) { + return; + } + + state.queue.push({ taskId, routing }); + state.queuedTaskIds.add(taskId); + setSchedulerQueueDepth(state.queue.length); +} + +function dequeueTask(): PendingTaskRequest | null { + const next = state.queue.shift() ?? null; + if (next) { + state.queuedTaskIds.delete(next.taskId); + } + setSchedulerQueueDepth(state.queue.length); + return next; +} + +function processQueue(): void { + if (!state.running || state.currentTaskId || state.leaseInFlight) { + return; + } + + const queued = dequeueTask(); + if (queued) { + void leaseSpecificTask(queued); + return; + } + + void pullNextTask(); +} + +async function leaseSpecificTask(request: PendingTaskRequest): Promise { + if (!canRunLive(state.session) || state.currentTaskId || state.leaseInFlight) { + return; + } + + state.leaseInFlight = true; + + try { + const leased = await leaseDesktopTask({ task_id: request.taskId }); + if (!leased.task) { + return; + } + state.leaseInFlight = false; + await executeLeasedTask(leased, request.routing); + } catch (error) { + if (isApiClientError(error, 401)) { + handleAuthExpired(error); + return; + } + + state.lastError = errorMessage(error); + setSchedulerError(state.lastError); + recordActivity("warn", "Specific Lease Missed", `${request.taskId} 未能成功领取:${state.lastError}`); + } finally { + if (state.leaseInFlight) { + state.leaseInFlight = false; + if (state.queue.length > 0) { + processQueue(); + } + } + } +} + +async function pullNextTask(): Promise { + if (!canRunLive(state.session) || state.currentTaskId || state.leaseInFlight) { + return; + } + + state.leaseInFlight = true; + + try { + const leased = await leaseDesktopTask({}); + state.lastPullAt = Date.now(); + state.lastPullStatus = "success"; + noteTransportPull(true); + noteSchedulerPull(); + + if (!leased.task) { + return; + } + + state.leaseInFlight = false; + await executeLeasedTask(leased, "db-recovery"); + } catch (error) { + state.lastPullAt = Date.now(); + state.lastPullStatus = "failed"; + state.lastError = errorMessage(error); + + noteTransportPull(false); + setSchedulerError(state.lastError); + + if (isApiClientError(error, 401)) { + handleAuthExpired(error); + return; + } + + recordActivity("warn", "Pull Fallback Failed", state.lastError); + } finally { + state.leaseInFlight = false; + } +} + +async function executeLeasedTask( + leased: LeaseDesktopTaskResponse, + routing: RuntimeTaskRouting, +): Promise { + if (!leased.task || !leased.lease_token) { + return; + } + + const task = leased.task; + const payload = normalizeJsonObject(task.payload); + const mode = resolveTaskMode(payload); + + state.currentTaskId = task.id; + setSchedulerCurrentTask(task.id); + setSchedulerPhase("running"); + + setActiveLease({ + taskId: task.id, + attemptId: leased.attempt_id ?? null, + leaseExpiresAt: leased.lease_expires_at ?? task.lease_expires_at ?? null, + }); + + const taskRecord = upsertTaskFromInfo(task, { + routing, + mode, + attemptId: leased.attempt_id ?? null, + leaseToken: leased.lease_token, + summary: + mode === "manual" && task.kind === "publish" + ? "已领取租约,准备切到 waiting_user parked 状态。" + : `已领取租约,开始执行 ${routing === "rabbitmq-primary" ? "MQ 唤醒" : "pull fallback"} 消费。`, + }); + + recordActivity( + "info", + "Task Leased", + `${taskRecord.title} 已被当前 desktop client 领取,routing=${routing}。`, + ); + + const abortController = new AbortController(); + state.currentExecutionAbort = abortController; + + const extendHandle = setInterval(() => { + void extendActiveLease(taskRecord.id, leased.lease_token as string); + }, leaseExtendIntervalMs); + + try { + if (mode === "manual" && task.kind === "publish") { + await prepareManualPublishReview(taskRecord, abortController.signal); + return; + } + + const execution = await executeTaskAdapter(taskRecord, abortController.signal); + const completed = await completeDesktopTask(taskRecord.id, { + lease_token: leased.lease_token, + status: execution.status, + payload: execution.payload, + error: execution.error, + }); + + noteLeaseReleased(execution.status === "failed" ? "failed" : "completed"); + upsertTaskFromInfo(completed, { + routing, + summary: execution.summary, + attemptId: null, + leaseToken: null, + }); + + recordActivity( + execution.status === "failed" ? "danger" : execution.status === "unknown" ? "warn" : "success", + execution.status === "failed" + ? "Task Completed As Failed" + : execution.status === "unknown" + ? "Task Completed As Unknown" + : "Task Succeeded", + `${taskRecord.title} ${execution.summary}`, + ); + } catch (error) { + const failureMessage = errorMessage(error); + const structuredError = toStructuredError(error); + + try { + const completed = await completeDesktopTask(taskRecord.id, { + lease_token: leased.lease_token, + status: "failed", + error: structuredError, + }); + + upsertTaskFromInfo(completed, { + routing, + summary: failureMessage || "桌面任务执行失败。", + attemptId: null, + leaseToken: null, + }); + } catch (completeError) { + const existing = state.tasks.get(taskRecord.id); + if (existing) { + existing.status = "failed"; + existing.error = structuredError; + existing.summary = `${failureMessage || "桌面任务执行失败。"}(result 回写失败:${errorMessage(completeError)})`; + existing.updatedAt = Date.now(); + state.tasks.set(taskRecord.id, existing); + } + } + + noteLeaseReleased("failed"); + recordActivity("danger", "Task Failed", `${taskRecord.title} 执行失败:${failureMessage || "unknown_error"}`); + } finally { + clearInterval(extendHandle); + state.currentExecutionAbort = null; + state.currentTaskId = null; + setSchedulerCurrentTask(null); + noteSchedulerTaskFinished(); + processQueue(); + } +} + +async function prepareManualPublishReview(task: RuntimeTaskRecord, signal: AbortSignal): Promise { + const preparation = await executeTaskAdapter(task, signal); + if (preparation.status === "failed") { + const completed = await completeDesktopTask(task.id, { + lease_token: task.leaseToken as string, + status: "failed", + payload: preparation.payload, + error: preparation.error, + }); + + noteLeaseReleased("failed"); + upsertTaskFromInfo(completed, { + routing: task.routing, + mode: task.mode, + summary: preparation.summary, + attemptId: null, + leaseToken: null, + }); + + recordActivity("danger", "Manual Review Prepare Failed", `${task.title} 无法进入人工审核态。`); + return; + } + + const reviewUrl = preparation.reviewUrl ?? resolveTaskReviewUrl({ + ...task, + result: preparation.payload ?? task.result, + }); + if (!reviewUrl) { + const completed = await completeDesktopTask(task.id, { + lease_token: task.leaseToken as string, + status: "unknown", + payload: preparation.payload, + error: preparation.error ?? { + code: "manual_review_url_missing", + message: "manual publish review url is not available for current platform", + }, + }); + + noteLeaseReleased("completed"); + upsertTaskFromInfo(completed, { + routing: task.routing, + mode: task.mode, + summary: "当前平台暂未提供可打开的审核页,任务回写为 unknown,等待后续对账。", + attemptId: null, + leaseToken: null, + }); + + recordActivity("warn", "Manual Review Unavailable", `${task.title} 暂无可用审核页。`); + return; + } + + if (reviewUrl) { + try { + await openTaskReviewWindow(task, reviewUrl); + } catch (error) { + recordActivity("warn", "Review Window Open Failed", errorMessage(error)); + } + } + + await parkLeasedTask(task, { + summary: + preparation.summary || "任务已进入人工审核态,等待当前客户端操作员确认。", + payload: preparation.payload ?? null, + error: preparation.error ?? null, + }); +} + +async function parkLeasedTask( + task: RuntimeTaskRecord, + options: { + summary: string; + payload?: Record | null; + error?: Record | null; + }, +): Promise { + if (!task.leaseToken) { + return; + } + + try { + if (options.payload) { + rememberTaskLocalResult(task.id, options.payload); + } + if (options.error) { + rememberTaskLocalError(task.id, options.error); + } + + const parked = await parkDesktopTask(task.id, { + lease_token: task.leaseToken, + reason: "waiting_user", + }); + + noteLeaseReleased("parked"); + upsertTaskFromInfo(parked, { + routing: task.routing, + summary: options.summary, + attemptId: null, + leaseToken: null, + }); + + recordActivity("warn", "Task Parked", `${task.title} 已进入人工审核态。`); + } catch (error) { + recordActivity("danger", "Task Park Failed", `${task.title} 进入人工审核态失败:${errorMessage(error)}`); + } +} + +async function extendActiveLease(taskId: string, leaseToken: string): Promise { + try { + const task = await extendDesktopTask(taskId, { lease_token: leaseToken }); + + noteLeaseExtended(task.lease_expires_at ?? null); + + const existing = state.tasks.get(taskId); + if (existing) { + existing.leaseExpiresAt = parseTimestamp(task.lease_expires_at) ?? existing.leaseExpiresAt; + existing.updatedAt = Date.now(); + existing.summary = "租约已自动续期,任务仍在执行。"; + state.tasks.set(taskId, existing); + } + } catch (error) { + const message = errorMessage(error); + state.lastError = message; + setSchedulerError(message); + recordActivity("warn", "Lease Extend Failed", `${taskId} 续租失败:${message}`); + } +} + +async function executeTaskAdapter( + task: RuntimeTaskRecord, + signal: AbortSignal, +): Promise { + const sessionHandle = createSessionHandle(task.accountId || task.id); + const payload = task.payload; + + if (task.kind === "publish") { + const adapter = selectPublishAdapter(task.platform); + if (!adapter) { + return buildScaffoldResult(task, payload, "publish adapter is not implemented yet"); + } + + const viewHandle = acquireHotView(task.accountId || task.id); + + const result = await adapter.publish( + { + taskId: task.id, + accountId: task.accountId || task.id, + session: sessionHandle.session, + view: viewHandle.view, + signal, + mode: task.mode, + phase: "initial", + article: await loadPublishArticle(task), + reportProgress(stage: string) { + updateTaskProgress(task.id, `publish adapter progress: ${stage}`); + }, + }, + payload, + ); + + return result; + } + + const adapter = selectMonitorAdapter(task.platform); + if (!adapter) { + return buildScaffoldResult(task, payload, "monitor adapter is not implemented yet"); + } + + const viewHandle = acquireHotView(task.accountId || task.id); + + const result = await adapter.query( + { + taskId: task.id, + accountId: task.accountId || task.id, + session: sessionHandle.session, + view: viewHandle.view, + signal, + mode: task.mode, + phase: "initial", + reportProgress(stage: string) { + updateTaskProgress(task.id, `monitor adapter progress: ${stage}`); + }, + }, + payload, + ); + + return result; +} + +function buildScaffoldResult( + task: RuntimeTaskRecord, + adapterPayload: Record, + detail: string, +): AdapterExecutionResult { + return { + status: "unknown", + payload: { + ...adapterPayload, + runtime_mode: "desktop-scaffold", + task_kind: task.kind, + task_platform: task.platform, + }, + error: { + code: "desktop_task_scaffold_only", + message: "desktop runtime adapter is scaffold-only and requires later reconcile", + detail, + }, + summary: `${task.title} 已被 desktop runtime 消费,但当前 adapter 仍是 scaffold,仅回写 unknown 供后续 reconcile。`, + }; +} + +async function loadPublishArticle( + task: RuntimeTaskRecord, +): Promise { + const articleId = resolveArticleId(task.payload); + if (articleId === null) { + throw new Error("desktop_publish_article_id_missing"); + } + + const article = await getDesktopArticleContent(articleId); + return { + ...article, + title: article.title?.trim() || task.title, + publish_type: task.mode === "manual" ? "draft" : "publish", + }; +} + +function resolveArticleId(payload: Record): number | null { + const direct = toPositiveInt(payload.article_id); + if (direct !== null) { + return direct; + } + + const contentRef = payload.content_ref; + if (!contentRef || typeof contentRef !== "object" || Array.isArray(contentRef)) { + return null; + } + + const typed = contentRef as Record; + return toPositiveInt(typed.id ?? typed.article_id ?? null); +} + +function toPositiveInt(value: JsonValue | null): number | null { + if (typeof value === "number" && Number.isInteger(value) && value > 0) { + return value; + } + if (typeof value === "string" && /^\d+$/.test(value.trim())) { + const parsed = Number.parseInt(value.trim(), 10); + return parsed > 0 ? parsed : null; + } + return null; +} + +async function openTaskReviewWindow(task: RuntimeTaskRecord, reviewURL: string): Promise { + const sessionHandle = createSessionHandle(task.accountId || task.id); + await openReviewWindow({ + taskId: task.id, + title: `${task.title} · 审核`, + url: reviewURL, + session: sessionHandle.session, + }); +} + +function resolveTaskReviewUrl(task: RuntimeTaskRecord): string | null { + const candidate = + firstString(task.result?.external_manage_url) ?? + firstString(task.result?.review_url) ?? + firstString(task.payload.external_manage_url) ?? + firstString(task.payload.review_url); + + if (candidate) { + return candidate; + } + + if (task.platform === "toutiaohao") { + return "https://mp.toutiao.com/profile_v4/graphic/publish"; + } + + return null; +} + +function firstString(value: JsonValue | undefined): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function buildManualResolutionPayload( + task: RuntimeTaskRecord, + status: "succeeded" | "failed" | "unknown", +): { + payload: Record; + error?: Record; + summary: string; +} { + const payload: Record = { + ...(task.result ?? {}), + manual_resolution: status, + resolved_at: new Date().toISOString(), + resolved_by: "desktop_operator", + }; + + if (status === "succeeded") { + return { + payload, + summary: "人工审核已确认完成,任务回写为 succeeded。", + }; + } + + if (status === "unknown") { + return { + payload, + error: { + code: "manual_review_unconfirmed", + message: "operator could not confirm final publish result", + }, + summary: "人工审核后仍无法确认最终状态,任务回写为 unknown。", + }; + } + + return { + payload, + error: { + code: "manual_review_rejected", + message: "operator marked parked task as failed", + }, + summary: "人工审核已拒绝该任务,任务回写为 failed。", + }; +} + +function rememberTaskLocalResult(taskId: string, payload: Record): void { + const existing = state.tasks.get(taskId); + if (!existing) { + return; + } + state.tasks.set(taskId, { + ...existing, + result: { + ...(existing.result ?? {}), + ...payload, + }, + }); +} + +function rememberTaskLocalError(taskId: string, error: Record): void { + const existing = state.tasks.get(taskId); + if (!existing) { + return; + } + state.tasks.set(taskId, { + ...existing, + error: { + ...(existing.error ?? {}), + ...error, + }, + }); +} + +function updateTaskProgress(taskId: string, summary: string): void { + const existing = state.tasks.get(taskId); + if (!existing) { + return; + } + + existing.summary = summary; + existing.updatedAt = Date.now(); + state.tasks.set(taskId, existing); +} + +function upsertTaskFromInfo( + task: DesktopTaskInfo, + options: { + routing?: RuntimeTaskRouting; + mode?: RuntimeTaskMode; + summary?: string; + attemptId?: string | null; + leaseToken?: string | null; + } = {}, +): RuntimeTaskRecord { + const existing = state.tasks.get(task.id); + const payload = normalizeJsonObject(task.payload); + const mode = options.mode ?? existing?.mode ?? resolveTaskMode(payload); + const record: RuntimeTaskRecord = { + id: task.id, + jobId: task.job_id, + title: resolveTaskTitle(task, payload, existing), + kind: task.kind, + platform: task.platform, + accountId: task.target_account_id, + accountName: resolveAccountName(task.target_account_id, existing?.accountName), + clientId: task.target_client_id, + status: task.status, + mode, + routing: options.routing ?? existing?.routing ?? "db-recovery", + leaseExpiresAt: parseTimestamp(task.lease_expires_at) ?? null, + updatedAt: parseTimestamp(task.updated_at) ?? Date.now(), + summary: options.summary ?? existing?.summary ?? summaryFromTaskStatus(task.status, mode), + payload, + error: + normalizeJsonObjectOrNull(task.error) + ?? (task.status === "waiting_user" ? existing?.error ?? null : null), + result: + normalizeJsonObjectOrNull(task.result) + ?? (task.status === "waiting_user" ? existing?.result ?? null : null), + attemptId: + task.status === "in_progress" + ? options.attemptId ?? existing?.attemptId ?? task.active_attempt_id ?? null + : null, + leaseToken: task.status === "in_progress" ? options.leaseToken ?? existing?.leaseToken ?? null : null, + }; + + state.tasks.set(task.id, record); + return record; +} + +function refreshAccountNames(): void { + for (const [taskId, task] of state.tasks.entries()) { + const nextName = resolveAccountName(task.accountId, task.accountName); + if (nextName === task.accountName) { + continue; + } + state.tasks.set(taskId, { ...task, accountName: nextName }); + } +} + +function recordActivity( + severity: RuntimeTone, + title: string, + detail: string, +): void { + const next: RuntimeControllerActivity = { + id: `rt-${Date.now()}-${state.activitySeq += 1}`, + severity, + title, + detail, + at: Date.now(), + }; + + state.activity.unshift(next); + if (state.activity.length > maxActivityItems) { + state.activity.length = maxActivityItems; + } +} + +function handleAuthExpired(error: unknown): void { + const message = errorMessage(error); + state.lastError = message; + setTransportAuthState("expired"); + recordActivity("danger", "Desktop Client Token Expired", message); + stopRuntime(); +} + +function selectPublishAdapter(platform: string): PublishAdapter | null { + if (platform === toutiaoAdapter.platform) { + return toutiaoAdapter; + } + return null; +} + +function selectMonitorAdapter(platform: string): MonitorAdapter | null { + if (platform === doubaoAdapter.provider) { + return doubaoAdapter; + } + return null; +} + +function normalizeSession( + session: DesktopRuntimeSessionSyncRequest | null, +): DesktopRuntimeSessionSyncRequest | null { + if (!session) { + return null; + } + + return { + api_base_url: session.api_base_url.trim() || "http://localhost:8080", + mode: session.mode, + client_token: session.client_token?.trim() || null, + desktop_client: session.desktop_client ? { ...session.desktop_client } : null, + }; +} + +function sameSession( + left: DesktopRuntimeSessionSyncRequest | null, + right: DesktopRuntimeSessionSyncRequest | null, +): boolean { + if (!left && !right) { + return true; + } + if (!left || !right) { + return false; + } + + return ( + left.api_base_url === right.api_base_url + && left.mode === right.mode + && left.client_token === right.client_token + && left.desktop_client?.id === right.desktop_client?.id + ); +} + +function canRunLive(session: DesktopRuntimeSessionSyncRequest | null): session is DesktopRuntimeSessionSyncRequest { + return Boolean( + session + && session.mode === "authenticated" + && session.client_token + && session.desktop_client, + ); +} + +function resolveAccountName(accountId: string, fallback = "待同步账号"): string { + const account = state.accounts.find((item) => item.id === accountId); + return account?.display_name ?? fallback; +} + +function resolveTaskTitle( + task: DesktopTaskInfo, + payload: Record, + existing?: RuntimeTaskRecord, +): string { + const title = typeof payload.title === "string" ? payload.title.trim() : ""; + if (title) { + return title; + } + return existing?.title ?? defaultTaskTitle(task.kind, task.platform); +} + +function defaultTaskTitle(kind: "publish" | "monitor", platform?: string): string { + return `${kind === "publish" ? "发布任务" : "监控任务"}${platform ? ` · ${platform}` : ""}`; +} + +function resolveTaskMode(payload: Record): RuntimeTaskMode { + return payload.mode === "manual" ? "manual" : "auto"; +} + +function summaryFromTaskStatus(status: RuntimeTaskStatus, mode: RuntimeTaskMode): string { + switch (status) { + case "queued": + return "任务已排队,等待客户端领取租约。"; + case "in_progress": + return "任务正在执行,结果回写需要携带有效 lease_token。"; + case "waiting_user": + return "任务已 parked,等待原客户端上的人工确认。"; + case "succeeded": + return mode === "manual" ? "任务已完成人工审核并成功提交。" : "任务执行成功。"; + case "failed": + return "任务执行失败。"; + case "aborted": + return "任务已被取消。"; + default: + return "任务进入 unknown/reconcile 分支,等待进一步确认。"; + } +} + +function summaryFromEventType(type: DesktopTaskEventMessage["type"], status: RuntimeTaskStatus): string { + switch (type) { + case "task_available": + return "RabbitMQ fanout 已送达当前客户端,等待领取租约。"; + case "task_leased": + return "服务端已确认租约归属。"; + case "task_extended": + return "任务租约已续期。"; + case "task_parked": + return "任务已 parked 到 waiting_user。"; + case "task_canceled": + return "任务已被取消。"; + case "task_reconciled": + return "任务已完成人工对账并回写。"; + default: + return summaryFromTaskStatus(status, "auto"); + } +} + +function inferPlatformFromAccount(accountId: string | null): string { + if (!accountId) { + return "desktop"; + } + return state.accounts.find((item) => item.id === accountId)?.platform ?? "desktop"; +} + +function buildApiUrl(baseURL: string, pathname: string): string { + const normalized = baseURL.endsWith("/") ? baseURL : `${baseURL}/`; + return new URL(pathname.replace(/^\//, ""), normalized).toString(); +} + +function normalizeJsonObject(value: Record | null | undefined): Record { + if (!value) { + return {}; + } + return { ...value }; +} + +function normalizeJsonObjectOrNull( + value: Record | null | undefined, +): Record | null { + if (!value) { + return null; + } + return { ...value }; +} + +function normalizeUnknownResult(value: Record): Record { + const output: Record = {}; + for (const [key, item] of Object.entries(value)) { + output[key] = normalizeJsonValue(item); + } + return output; +} + +function normalizeJsonValue(value: unknown): JsonValue { + if ( + value === null + || typeof value === "string" + || typeof value === "number" + || typeof value === "boolean" + ) { + return value; + } + + if (Array.isArray(value)) { + return value.map((item) => normalizeJsonValue(item)); + } + + if (typeof value === "object" && value) { + const output: Record = {}; + for (const [key, item] of Object.entries(value)) { + output[key] = normalizeJsonValue(item); + } + return output; + } + + return String(value); +} + +function toStructuredError(error: unknown): Record { + if (error instanceof ApiClientError) { + return { + code: error.code, + message: error.message, + detail: error.detail ?? null, + request_id: error.requestId ?? null, + status: error.status ?? null, + }; + } + + if (error instanceof Error) { + return { + code: "desktop_runtime_error", + message: error.message, + name: error.name, + }; + } + + return { + code: "desktop_runtime_error", + message: String(error), + }; +} + +function errorMessage(error: unknown): string { + if (error instanceof ApiClientError) { + return error.detail ? `${error.message}: ${error.detail}` : error.message; + } + if (error instanceof Error) { + return error.message; + } + return String(error); +} + +function parseTimestamp(value: string | null | undefined): number | null { + if (!value) { + return null; + } + + const timestamp = Date.parse(value); + return Number.isNaN(timestamp) ? null : timestamp; +} + +function isApiClientError(error: unknown, status: number): error is ApiClientError { + return error instanceof ApiClientError && error.status === status; +} + +function isDesktopTaskEvent(value: unknown): value is DesktopTaskEventMessage { + if (typeof value !== "object" || value === null) { + return false; + } + + const event = value as Partial; + return Boolean( + typeof event.type === "string" + && typeof event.task_id === "string" + && typeof event.job_id === "string" + && typeof event.target_client_id === "string" + && typeof event.kind === "string" + && typeof event.status === "string" + && typeof event.updated_at === "string", + ); +} + +function isConnectedEvent(value: unknown): value is { server_time: string } { + if (typeof value !== "object" || value === null) { + return false; + } + return typeof (value as { server_time?: string }).server_time === "string"; +} + +function isReconnectPayload(value: unknown): value is { delayMs: number } { + if (typeof value !== "object" || value === null) { + return false; + } + return typeof (value as { delayMs?: number }).delayMs === "number"; +} diff --git a/apps/desktop-client/src/main/runtime-snapshot.ts b/apps/desktop-client/src/main/runtime-snapshot.ts new file mode 100644 index 0000000..34b93b1 --- /dev/null +++ b/apps/desktop-client/src/main/runtime-snapshot.ts @@ -0,0 +1,432 @@ +import { app } from "electron/main"; + +import { getCircuitBreakerState } from "./circuit-breaker"; +import { collectRecoverySnapshot } from "./crash-recovery"; +import { getKeepAlivePlan } from "./keep-alive"; +import { getLeaseManagerSnapshot } from "./lease-manager"; +import { getRateLimiterSnapshot } from "./rate-limiter"; +import { getRuntimeControllerSnapshot } from "./runtime-controller"; +import { getSchedulerState } from "./scheduler"; +import { getSessionHandle, listSessionHandleSnapshots } from "./session-registry"; +import { getTransportSnapshot } from "./transport/api-client"; +import { describeVaultBackend } from "./vault"; +import { listHotViews } from "./view-pool"; + +function minutesAgo(now: number, minutes: number): number { + return now - minutes * 60_000; +} + +function minutesAhead(now: number, minutes: number): number { + return now + minutes * 60_000; +} + +function parseTimestamp(value: string | null | undefined): number | null { + if (!value) { + return null; + } + + const timestamp = Date.parse(value); + return Number.isNaN(timestamp) ? null : timestamp; +} + +export function createRuntimeSnapshot() { + const controller = getRuntimeControllerSnapshot(); + if (!controller.session || controller.session.mode === "preview") { + return createPreviewRuntimeSnapshot(); + } + return createLiveRuntimeSnapshot(controller); +} + +function createLiveRuntimeSnapshot(controller: ReturnType) { + const now = Date.now(); + // Electron Session instances are not safe to ship across IPC to the renderer. + const sessions = listSessionHandleSnapshots(); + const hotViews = listHotViews(); + const leaseManager = getLeaseManagerSnapshot(); + const rateLimiter = getRateLimiterSnapshot(); + const scheduler = getSchedulerState(); + const transport = getTransportSnapshot(); + + const primaryClientId = controller.client?.id ?? controller.session?.desktop_client?.id ?? "desktop-self"; + const lastSeenAt = + controller.lastHeartbeatAt + || parseTimestamp(controller.client?.last_seen_at) + || now; + const clientOnline = controller.running && controller.lastHeartbeatStatus !== "failed"; + const queueDepth = controller.tasks.filter((task) => + ["queued", "in_progress", "waiting_user"].includes(task.status), + ).length; + + const clients = [ + { + id: primaryClientId, + label: "This device", + deviceName: controller.client?.device_name ?? controller.session?.desktop_client?.device_name ?? "Current Desktop", + os: controller.client?.os ?? process.platform, + status: clientOnline ? "online" : "offline", + lastSeenAt, + queueDepth, + heartbeat: clientOnline ? "healthy" : "stale", + channel: controller.client?.channel ?? "dev", + }, + ] as const; + + const accounts = controller.accounts.map((account) => { + const profile = controller.accountProfiles[account.id] ?? null; + const sessionHandle = getSessionHandle(account.id); + const isHot = hotViews.some((item) => item.accountId === account.id); + const accountQueueDepth = controller.tasks.filter((task) => + task.accountId === account.id && ["queued", "in_progress", "waiting_user"].includes(task.status), + ).length; + + return { + id: account.id, + platform: account.platform, + displayName: profile?.displayName ?? account.display_name, + platformUid: profile?.platformUid ?? account.platform_uid, + avatarUrl: profile?.avatarUrl ?? null, + health: account.health, + tags: account.tags, + clientId: account.client_id ?? primaryClientId, + partition: sessionHandle?.partition ?? `persist:acc-${account.id}`, + sessionState: isHot ? "hot" : sessionHandle ? "warm" : "cold", + lastSyncAt: + controller.lastAccountsSyncAt + || parseTimestamp(account.verified_at) + || now, + queueDepth: accountQueueDepth, + online: clientOnline && account.client_id === primaryClientId, + }; + }); + + const tasks = controller.tasks.map((task) => ({ ...task })); + + const healthCounts = accounts.reduce>((acc, item) => { + acc[item.health] = (acc[item.health] ?? 0) + 1; + return acc; + }, {}); + + return { + previewMode: false, + generatedAt: now, + app: { + name: "GEO Rankly Desktop", + version: app.getVersion(), + channel: process.env.NODE_ENV === "development" ? "dev" : "release", + platform: process.platform, + sessionCount: sessions.length, + hotViewCount: hotViews.length, + }, + workspace: { + id: `ws-${controller.client?.workspace_id ?? controller.session?.desktop_client?.workspace_id ?? "current"}`, + name: `Workspace #${controller.client?.workspace_id ?? controller.session?.desktop_client?.workspace_id ?? "current"}`, + strategy: controller.sseConnected ? "rabbitmq-first / db-fallback" : "db-fallback / heartbeat recovery", + nextSweepAt: scheduler.nextPullAt ?? minutesAhead(now, 1), + lastFullSyncAt: controller.lastAccountsSyncAt || now, + }, + summary: { + onlineClients: clients.filter((item) => item.status === "online").length, + accountsBound: accounts.length, + queuedTasks: tasks.filter((item) => item.status === "queued").length, + issuesOpen: + tasks.filter((item) => ["waiting_user", "unknown", "failed"].includes(item.status)).length + + accounts.filter((item) => item.health !== "live").length, + healthCounts, + }, + clients, + accounts, + tasks, + activity: controller.activity, + subsystems: { + transport, + circuitBreaker: getCircuitBreakerState(), + keepAlive: getKeepAlivePlan(), + leaseManager, + rateLimiter, + recovery: collectRecoverySnapshot(), + runtimeController: { + running: controller.running, + sseConnected: controller.sseConnected, + queueDepth: controller.queueDepth, + lastHeartbeatAt: controller.lastHeartbeatAt, + lastHeartbeatStatus: controller.lastHeartbeatStatus, + lastPullAt: controller.lastPullAt, + lastPullStatus: controller.lastPullStatus, + lastAccountsSyncAt: controller.lastAccountsSyncAt, + lastServerTime: controller.lastServerTime, + lastError: controller.lastError, + }, + scheduler, + sessions, + hotViews, + vault: describeVaultBackend(), + }, + }; +} + +function createPreviewRuntimeSnapshot() { + const now = Date.now(); + const sessions = listSessionHandleSnapshots(); + const hotViews = listHotViews(); + const leaseManager = getLeaseManagerSnapshot(); + const rateLimiter = getRateLimiterSnapshot(); + + const clients = [ + { + id: "cli-self-macbook", + label: "This device", + deviceName: "LiangXu MacBook Pro", + os: process.platform, + status: "online", + lastSeenAt: minutesAgo(now, 1), + queueDepth: 3, + heartbeat: "healthy", + channel: "stable", + }, + { + id: "cli-studio-macmini", + label: "Remote node", + deviceName: "Studio Mac mini", + os: "darwin", + status: "offline", + lastSeenAt: minutesAgo(now, 38), + queueDepth: 1, + heartbeat: "stale", + channel: "stable", + }, + ] as const; + + const accounts = [ + { + id: "acc-doubao-core", + platform: "doubao", + displayName: "Doubao Monitor Core", + platformUid: "db-core-01", + avatarUrl: null, + health: "live", + tags: ["AI监控", "核心"], + clientId: clients[0].id, + partition: "persist:acc-doubao-core", + sessionState: hotViews.length > 0 ? "hot" : "warm", + lastSyncAt: minutesAgo(now, 3), + queueDepth: 1, + online: true, + }, + { + id: "acc-toutiao-brand", + platform: "toutiaohao", + displayName: "Toutiao Brand Primary", + platformUid: "tt-brand-01", + avatarUrl: null, + health: "live", + tags: ["发布", "品牌"], + clientId: clients[0].id, + partition: "persist:acc-toutiao-brand", + sessionState: "warm", + lastSyncAt: minutesAgo(now, 8), + queueDepth: 2, + online: true, + }, + { + id: "acc-toutiao-matrix", + platform: "toutiaohao", + displayName: "Toutiao Matrix B", + platformUid: "tt-matrix-b", + avatarUrl: null, + health: "captcha", + tags: ["发布", "待修复"], + clientId: clients[0].id, + partition: "persist:acc-toutiao-matrix", + sessionState: "cold", + lastSyncAt: minutesAgo(now, 42), + queueDepth: 1, + online: true, + }, + { + id: "acc-qwen-research", + platform: "qwen", + displayName: "Qwen Research Backup", + platformUid: "qwen-research-02", + avatarUrl: null, + health: "risk", + tags: ["AI监控", "备用"], + clientId: clients[1].id, + partition: "persist:acc-qwen-research", + sessionState: "cold", + lastSyncAt: minutesAgo(now, 96), + queueDepth: 1, + online: false, + }, + ] as const; + + const tasks = [ + { + id: "task-monitor-doubao-001", + jobId: "job-monitor-daily-001", + title: "品牌提及采样 · Doubao", + kind: "monitor", + platform: "doubao", + accountId: accounts[0].id, + accountName: accounts[0].displayName, + clientId: accounts[0].clientId, + status: "in_progress", + mode: "auto", + routing: "rabbitmq-primary", + leaseExpiresAt: leaseManager.startedAt ? minutesAhead(leaseManager.startedAt, 10) : minutesAhead(now, 8), + updatedAt: minutesAgo(now, 2), + summary: "当前由本机执行,租约续期正常。", + }, + { + id: "task-publish-toutiao-021", + jobId: "job-launch-q2-021", + title: "新品发布 · 头条号主号", + kind: "publish", + platform: "toutiaohao", + accountId: accounts[1].id, + accountName: accounts[1].displayName, + clientId: accounts[1].clientId, + status: "waiting_user", + mode: "manual", + routing: "rabbitmq-primary", + leaseExpiresAt: null, + updatedAt: minutesAgo(now, 6), + summary: "审核页已就绪,等待桌面端人工确认。", + }, + { + id: "task-publish-toutiao-022", + jobId: "job-launch-q2-021", + title: "新品发布 · 矩阵 B", + kind: "publish", + platform: "toutiaohao", + accountId: accounts[2].id, + accountName: accounts[2].displayName, + clientId: accounts[2].clientId, + status: "queued", + mode: "auto", + routing: "rabbitmq-primary", + leaseExpiresAt: null, + updatedAt: minutesAgo(now, 1), + summary: "已入 MQ 队列,等待目标客户端拉取租约。", + }, + { + id: "task-monitor-qwen-003", + jobId: "job-monitor-daily-003", + title: "竞品问答补采样 · Qwen", + kind: "monitor", + platform: "qwen", + accountId: accounts[3].id, + accountName: accounts[3].displayName, + clientId: accounts[3].clientId, + status: "unknown", + mode: "auto", + routing: "db-recovery", + leaseExpiresAt: null, + updatedAt: minutesAgo(now, 49), + summary: "原客户端离线,已切到 DB 补偿队列等待 reconcile。", + }, + { + id: "task-publish-archive-007", + jobId: "job-content-rollout-007", + title: "专题文章回填 · 归档批次", + kind: "publish", + platform: "toutiaohao", + accountId: accounts[1].id, + accountName: accounts[1].displayName, + clientId: accounts[1].clientId, + status: "succeeded", + mode: "auto", + routing: "rabbitmq-primary", + leaseExpiresAt: null, + updatedAt: minutesAgo(now, 34), + summary: "发布完成,回执已写回服务端。", + }, + ] as const; + + const activity = [ + { + id: "evt-01", + severity: "info", + title: "MQ fanout ready", + detail: "desktop.task.event 已完成广播初始化,客户端优先走消息唤醒。", + at: minutesAgo(now, 2), + }, + { + id: "evt-02", + severity: "warn", + title: "Captcha risk detected", + detail: "Toutiao Matrix B 在最近一次 resync 中被标记为 captcha。", + at: minutesAgo(now, 11), + }, + { + id: "evt-03", + severity: "danger", + title: "Offline recovery armed", + detail: "Qwen Research Backup 目标客户端离线,任务已进入 DB fallback 观察态。", + at: minutesAgo(now, 49), + }, + { + id: "evt-04", + severity: "success", + title: "Publish receipt committed", + detail: "归档批次回执已完成 result callback,Web 端可见成功 URL。", + at: minutesAgo(now, 34), + }, + ] as const; + + const healthCounts = accounts.reduce>((acc, item) => { + acc[item.health] = (acc[item.health] ?? 0) + 1; + return acc; + }, {}); + + return { + previewMode: true, + generatedAt: now, + app: { + name: "GEO Rankly Desktop", + version: app.getVersion(), + channel: process.env.NODE_ENV === "development" ? "dev" : "release", + platform: process.platform, + sessionCount: sessions.length, + hotViewCount: hotViews.length, + }, + workspace: { + id: "ws-default", + name: "Default Workspace", + strategy: "rabbitmq-first / db-fallback", + nextSweepAt: minutesAhead(now, 5), + lastFullSyncAt: minutesAgo(now, 4), + }, + summary: { + onlineClients: clients.filter((item) => item.status === "online").length, + accountsBound: accounts.length, + queuedTasks: tasks.filter((item) => item.status === "queued").length, + issuesOpen: + tasks.filter((item) => ["waiting_user", "unknown"].includes(item.status)).length + + accounts.filter((item) => item.health !== "live").length, + healthCounts, + }, + clients, + accounts, + tasks, + activity, + subsystems: { + transport: getTransportSnapshot(), + circuitBreaker: getCircuitBreakerState(), + keepAlive: getKeepAlivePlan(), + leaseManager: { + ...leaseManager, + activeLeaseId: leaseManager.activeLeaseId ?? tasks[0].id, + }, + rateLimiter: { + ...rateLimiter, + refreshedAt: rateLimiter.refreshedAt || minutesAgo(now, 7), + bucketCount: rateLimiter.bucketCount || 6, + }, + recovery: collectRecoverySnapshot(), + scheduler: getSchedulerState(), + sessions, + hotViews, + vault: describeVaultBackend(), + }, + }; +} diff --git a/apps/desktop-client/src/main/scheduler.ts b/apps/desktop-client/src/main/scheduler.ts new file mode 100644 index 0000000..0da5c02 --- /dev/null +++ b/apps/desktop-client/src/main/scheduler.ts @@ -0,0 +1,86 @@ +interface SchedulerSnapshot { + initializedAt: number; + phase: "idle" | "running" | "paused"; + queueDepth: number; + currentTaskId: string | null; + lastSseEventAt: number | null; + lastHeartbeatAt: number | null; + lastPullAt: number | null; + lastAccountsSyncAt: number | null; + lastTaskStartedAt: number | null; + lastTaskFinishedAt: number | null; + nextHeartbeatAt: number | null; + nextPullAt: number | null; + lastError: string | null; +} + +const schedulerState: SchedulerSnapshot = { + initializedAt: 0, + phase: "idle", + queueDepth: 0, + currentTaskId: null, + lastSseEventAt: null, + lastHeartbeatAt: null, + lastPullAt: null, + lastAccountsSyncAt: null, + lastTaskStartedAt: null, + lastTaskFinishedAt: null, + nextHeartbeatAt: null, + nextPullAt: null, + lastError: null, +}; + +export function initScheduler(): void { + schedulerState.initializedAt = schedulerState.initializedAt || Date.now(); +} + +export function setSchedulerPhase(phase: SchedulerSnapshot["phase"]): void { + schedulerState.phase = phase; +} + +export function setSchedulerQueueDepth(queueDepth: number): void { + schedulerState.queueDepth = Math.max(0, queueDepth); +} + +export function setSchedulerCurrentTask(taskId: string | null): void { + schedulerState.currentTaskId = taskId; + if (taskId) { + schedulerState.lastTaskStartedAt = Date.now(); + } +} + +export function noteSchedulerTaskFinished(): void { + schedulerState.lastTaskFinishedAt = Date.now(); +} + +export function noteSchedulerSseEvent(): void { + schedulerState.lastSseEventAt = Date.now(); +} + +export function noteSchedulerHeartbeat(): void { + schedulerState.lastHeartbeatAt = Date.now(); +} + +export function noteSchedulerPull(): void { + schedulerState.lastPullAt = Date.now(); +} + +export function noteSchedulerAccountsSync(): void { + schedulerState.lastAccountsSyncAt = Date.now(); +} + +export function setSchedulerNextHeartbeat(timestamp: number | null): void { + schedulerState.nextHeartbeatAt = timestamp; +} + +export function setSchedulerNextPull(timestamp: number | null): void { + schedulerState.nextPullAt = timestamp; +} + +export function setSchedulerError(message: string | null): void { + schedulerState.lastError = message; +} + +export function getSchedulerState(): SchedulerSnapshot { + return { ...schedulerState }; +} diff --git a/apps/desktop-client/src/main/session-registry.ts b/apps/desktop-client/src/main/session-registry.ts new file mode 100644 index 0000000..76d62af --- /dev/null +++ b/apps/desktop-client/src/main/session-registry.ts @@ -0,0 +1,182 @@ +import { randomUUID } from "node:crypto"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +import { app, session } from "electron/main"; +import type { Session } from "electron/main"; + +import { STANDARD_ACCEPT_LANGUAGES, STANDARD_USER_AGENT } from "./user-agent"; + +export interface SessionHandle { + accountId: string; + partition: string; + session: Session; +} + +export interface SessionHandleSnapshot { + accountId: string; + partition: string; +} + +const registry = new Map(); +const sessionsWithUA = new WeakSet(); +let persistedPartitionsCache: Record | null = null; + +function persistedPartitionsPath(): string { + return join(app.getPath("userData"), "desktop-session-partitions.json"); +} + +function readPersistedPartitions(): Record { + if (persistedPartitionsCache) { + return persistedPartitionsCache; + } + + try { + persistedPartitionsCache = JSON.parse( + readFileSync(persistedPartitionsPath(), "utf8"), + ) as Record; + } catch { + persistedPartitionsCache = {}; + } + + return persistedPartitionsCache; +} + +function writePersistedPartitions(next: Record): void { + persistedPartitionsCache = next; + const target = persistedPartitionsPath(); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, JSON.stringify(next, null, 2), "utf8"); +} + +function persistedPartitionFor(accountId: string): string | null { + return readPersistedPartitions()[accountId] ?? null; +} + +export function getPersistedPartition(accountId: string): string | null { + return persistedPartitionFor(accountId); +} + +function rememberPersistedPartition(accountId: string, partition: string): void { + const current = readPersistedPartitions(); + if (current[accountId] === partition) { + return; + } + + writePersistedPartitions({ + ...current, + [accountId]: partition, + }); +} + +function partitionFor(accountId: string): string { + return `persist:acc-${accountId}`; +} + +function pendingPartitionFor(seed: string): string { + return `persist:pending-${seed}-${randomUUID()}`; +} + +function applyStandardUserAgent(target: Session): Session { + if (!sessionsWithUA.has(target)) { + target.setUserAgent(STANDARD_USER_AGENT, STANDARD_ACCEPT_LANGUAGES); + sessionsWithUA.add(target); + } + return target; +} + +function prepareSession(target: Session): Session { + applyStandardUserAgent(target); + target.clearHostResolverCache().catch((error) => { + console.warn("[desktop-session] clearHostResolverCache failed", error); + }); + return target; +} + +export async function initSessionRegistry(): Promise { + registry.clear(); +} + +export function createSessionHandle(accountId?: string): SessionHandle { + const resolvedAccountID = accountId ?? randomUUID(); + const existing = registry.get(resolvedAccountID); + if (existing) { + return existing; + } + + const partition = persistedPartitionFor(resolvedAccountID) ?? partitionFor(resolvedAccountID); + const handle: SessionHandle = { + accountId: resolvedAccountID, + partition, + session: prepareSession(session.fromPartition(partition)), + }; + registry.set(resolvedAccountID, handle); + return handle; +} + +export function createSessionHandleForPartition(accountId: string, partition: string): SessionHandle { + const existing = registry.get(accountId); + if (existing && existing.partition === partition) { + return existing; + } + + const handle: SessionHandle = { + accountId, + partition, + session: prepareSession(session.fromPartition(partition)), + }; + registry.set(accountId, handle); + rememberPersistedPartition(accountId, partition); + return handle; +} + +export function createPendingSessionHandle(seed = "bind"): SessionHandle { + const accountId = `pending:${seed}:${randomUUID()}`; + const partition = pendingPartitionFor(seed); + const handle: SessionHandle = { + accountId, + partition, + session: prepareSession(session.fromPartition(partition)), + }; + registry.set(accountId, handle); + return handle; +} + +export function attachSessionHandle(accountId: string, handle: SessionHandle): SessionHandle { + const existing = registry.get(accountId); + if (existing && existing !== handle) { + registry.delete(existing.accountId); + } + + if (handle.accountId !== accountId) { + registry.delete(handle.accountId); + } + + const next: SessionHandle = { + accountId, + partition: handle.partition, + session: handle.session, + }; + registry.set(accountId, next); + rememberPersistedPartition(accountId, handle.partition); + return next; +} + +export function forgetSessionHandle(accountId: string): void { + registry.delete(accountId); +} + +export function getSessionHandle(accountId: string): SessionHandle | undefined { + return registry.get(accountId); +} + +export function listSessionHandles(): SessionHandle[] { + return [...registry.values()]; +} + +export function listSessionHandleSnapshots(): SessionHandleSnapshot[] { + return [...registry.values()].map(({ accountId, partition }) => ({ + accountId, + partition, + })); +} diff --git a/apps/desktop-client/src/main/single-instance.ts b/apps/desktop-client/src/main/single-instance.ts new file mode 100644 index 0000000..ef91868 --- /dev/null +++ b/apps/desktop-client/src/main/single-instance.ts @@ -0,0 +1,21 @@ +import { app } from "electron/main"; + +let initialized = false; + +export function initSingleInstance(onSecondInstance?: () => void): boolean { + if (initialized) { + return true; + } + + initialized = true; + const hasLock = app.requestSingleInstanceLock(); + if (!hasLock) { + return false; + } + + app.on("second-instance", () => { + onSecondInstance?.(); + }); + + return true; +} diff --git a/apps/desktop-client/src/main/tracing/recorder.ts b/apps/desktop-client/src/main/tracing/recorder.ts new file mode 100644 index 0000000..4c6b040 --- /dev/null +++ b/apps/desktop-client/src/main/tracing/recorder.ts @@ -0,0 +1,13 @@ +export interface TraceRecord { + type: string; + createdAt: number; + payload: Record; +} + +export function createTraceRecord(type: string, payload: Record): TraceRecord { + return { + type, + createdAt: Date.now(), + payload, + }; +} diff --git a/apps/desktop-client/src/main/tracing/redactor.ts b/apps/desktop-client/src/main/tracing/redactor.ts new file mode 100644 index 0000000..0b1742f --- /dev/null +++ b/apps/desktop-client/src/main/tracing/redactor.ts @@ -0,0 +1,10 @@ +const redactKeys = new Set(["authorization", "cookie", "password", "token"]); + +export function redactPayload(payload: Record): Record { + return Object.fromEntries( + Object.entries(payload).map(([key, value]) => [ + key, + redactKeys.has(key.toLowerCase()) ? "[REDACTED]" : value, + ]), + ); +} diff --git a/apps/desktop-client/src/main/tracing/trace-writer.ts b/apps/desktop-client/src/main/tracing/trace-writer.ts new file mode 100644 index 0000000..c17b59e --- /dev/null +++ b/apps/desktop-client/src/main/tracing/trace-writer.ts @@ -0,0 +1,5 @@ +import type { TraceRecord } from "./recorder"; + +export function serializeTrace(records: TraceRecord[]): string { + return JSON.stringify(records, null, 2); +} diff --git a/apps/desktop-client/src/main/transport/api-client.ts b/apps/desktop-client/src/main/transport/api-client.ts new file mode 100644 index 0000000..c2b690e --- /dev/null +++ b/apps/desktop-client/src/main/transport/api-client.ts @@ -0,0 +1,191 @@ +import { createApiClient, type ApiClient } from "@geo/http-client"; +import type { + CompleteDesktopTaskRequest, + DesktopArticleContent, + DesktopAccountInfo, + DesktopClientHeartbeatRequest, + DesktopClientHeartbeatResponse, + DesktopTaskInfo, + DesktopRuntimeSessionSyncRequest, + ExtendDesktopTaskRequest, + LeaseDesktopTaskRequest, + LeaseDesktopTaskResponse, + ParkDesktopTaskRequest, + UpsertDesktopAccountRequest, +} from "@geo/shared-types"; + +type TransportAuthState = "pending" | "registered" | "expired"; +type TransportSseState = "idle" | "connecting" | "streaming"; + +interface DesktopTransportSession { + baseURL: string; + clientToken: string | null; +} + +let desktopApiClient: ApiClient | null = null; +let transportSession: DesktopTransportSession = { + baseURL: process.env.DESKTOP_API_BASE_URL ?? "http://localhost:8080", + clientToken: null, +}; + +const transportState = { + baseURL: transportSession.baseURL, + initializedAt: 0, + mode: "rabbitmq-first" as const, + auth: "pending" as TransportAuthState, + sseState: "idle" as TransportSseState, + lastHeartbeatAt: 0, + lastHeartbeatStatus: "idle" as "idle" | "success" | "failed", + lastPullAt: 0, + lastPullStatus: "idle" as "idle" | "success" | "failed", +}; + +function createDesktopClient(baseURL: string): ApiClient { + const client = createApiClient({ + baseURL, + timeoutMs: 15000, + }); + + client.raw.interceptors.request.use((config) => { + if (transportSession.clientToken) { + config.headers = config.headers ?? {}; + (config.headers as Record).Authorization = `Bearer ${transportSession.clientToken}`; + } + return config; + }); + + return client; +} + +export function initTransport(): ApiClient { + if (desktopApiClient) { + return desktopApiClient; + } + + transportState.initializedAt = transportState.initializedAt || Date.now(); + transportState.baseURL = transportSession.baseURL; + desktopApiClient = createDesktopClient(transportSession.baseURL); + return desktopApiClient; +} + +export function configureTransport(session: DesktopRuntimeSessionSyncRequest | null): ApiClient | null { + if (!session) { + transportSession = { + baseURL: process.env.DESKTOP_API_BASE_URL ?? "http://localhost:8080", + clientToken: null, + }; + transportState.baseURL = transportSession.baseURL; + transportState.auth = "pending"; + desktopApiClient = null; + return null; + } + + transportSession = { + baseURL: session.api_base_url.trim() || "http://localhost:8080", + clientToken: session.client_token, + }; + transportState.baseURL = transportSession.baseURL; + transportState.auth = session.client_token ? "registered" : "pending"; + transportState.initializedAt = Date.now(); + desktopApiClient = createDesktopClient(transportSession.baseURL); + return desktopApiClient; +} + +export function setTransportAuthState(next: TransportAuthState): void { + transportState.auth = next; +} + +export function setTransportSseState(next: TransportSseState): void { + transportState.sseState = next; +} + +export function noteTransportHeartbeat(success: boolean): void { + transportState.lastHeartbeatAt = Date.now(); + transportState.lastHeartbeatStatus = success ? "success" : "failed"; +} + +export function noteTransportPull(success: boolean): void { + transportState.lastPullAt = Date.now(); + transportState.lastPullStatus = success ? "success" : "failed"; +} + +export function getDesktopApiClient(): ApiClient { + return desktopApiClient ?? initTransport(); +} + +export function getTransportSnapshot() { + return { + ...transportState, + hasClient: desktopApiClient !== null, + hasClientToken: Boolean(transportSession.clientToken), + }; +} + +export async function heartbeatDesktopClient( + payload: DesktopClientHeartbeatRequest, +): Promise { + return getDesktopApiClient().post( + "/api/desktop/clients/heartbeat", + payload, + ); +} + +export async function listDesktopAccounts(): Promise { + return getDesktopApiClient().get("/api/desktop/accounts"); +} + +export async function getDesktopArticleContent(articleId: number): Promise { + return getDesktopApiClient().get(`/api/desktop/content/articles/${articleId}`); +} + +export async function upsertDesktopAccount( + payload: UpsertDesktopAccountRequest, +): Promise { + return getDesktopApiClient().post( + "/api/desktop/accounts", + payload, + ); +} + +export async function leaseDesktopTask( + request: LeaseDesktopTaskRequest, + options: { fromParked?: boolean } = {}, +): Promise { + const taskId = request.task_id?.trim(); + const path = taskId ? `/api/desktop/tasks/${taskId}/lease` : "/api/desktop/tasks/lease"; + const query = options.fromParked ? "?from_parked=true" : ""; + return getDesktopApiClient().post( + `${path}${query}`, + taskId ? { kind: request.kind } : request, + ); +} + +export async function extendDesktopTask( + taskId: string, + payload: ExtendDesktopTaskRequest, +): Promise { + return getDesktopApiClient().post( + `/api/desktop/tasks/${taskId}/extend`, + payload, + ); +} + +export async function parkDesktopTask( + taskId: string, + payload: ParkDesktopTaskRequest, +): Promise { + return getDesktopApiClient().post( + `/api/desktop/tasks/${taskId}/park`, + payload, + ); +} + +export async function completeDesktopTask( + taskId: string, + payload: CompleteDesktopTaskRequest, +): Promise { + return getDesktopApiClient().post( + `/api/desktop/tasks/${taskId}/result`, + payload, + ); +} diff --git a/apps/desktop-client/src/main/transport/sse-client.ts b/apps/desktop-client/src/main/transport/sse-client.ts new file mode 100644 index 0000000..b71345f --- /dev/null +++ b/apps/desktop-client/src/main/transport/sse-client.ts @@ -0,0 +1,237 @@ +export type SseEventHandler = (payload: unknown) => void; + +export interface SseClientOptions { + url: string; + headers?: Record; + initialRetryDelayMs?: number; + maxRetryDelayMs?: number; +} + +export class SseClientError extends Error { + status?: number; + + constructor(message: string, options: { status?: number } = {}) { + super(message); + this.name = "SseClientError"; + this.status = options.status; + } +} + +type SseClientState = "idle" | "connecting" | "streaming"; + +interface ParsedSseEvent { + event: string; + data: unknown; +} + +export class SseClient { + #listeners = new Map>(); + #options: SseClientOptions; + #running = false; + #abortController: AbortController | null = null; + #retryDelayMs: number; + + constructor(options: SseClientOptions) { + this.#options = options; + this.#retryDelayMs = options.initialRetryDelayMs ?? 1000; + } + + on(event: string, handler: SseEventHandler): () => void { + const bucket = this.#listeners.get(event) ?? new Set(); + bucket.add(handler); + this.#listeners.set(event, bucket); + + return () => { + bucket.delete(handler); + if (bucket.size === 0) { + this.#listeners.delete(event); + } + }; + } + + start(): void { + if (this.#running) { + return; + } + + this.#running = true; + void this.#run(); + } + + stop(): void { + this.#running = false; + this.#setState("idle"); + this.#abortController?.abort(); + this.#abortController = null; + } + + emit(event: string, payload: unknown): void { + for (const handler of this.#listeners.get(event) ?? []) { + handler(payload); + } + } + + async #run(): Promise { + while (this.#running) { + try { + await this.#connectOnce(); + this.#retryDelayMs = this.#options.initialRetryDelayMs ?? 1000; + } catch (error) { + if (!this.#running) { + break; + } + + this.emit("error", error); + this.#setState("connecting"); + + const waitMs = this.#retryDelayMs; + this.emit("reconnect", { delayMs: waitMs }); + await sleep(waitMs); + this.#retryDelayMs = Math.min( + this.#retryDelayMs * 2, + this.#options.maxRetryDelayMs ?? 30_000, + ); + } + } + } + + async #connectOnce(): Promise { + this.#setState("connecting"); + this.#abortController = new AbortController(); + + const response = await fetch(this.#options.url, { + method: "GET", + headers: { + Accept: "text/event-stream", + ...(this.#options.headers ?? {}), + }, + signal: this.#abortController.signal, + }); + + if (!response.ok) { + throw new SseClientError(`desktop_events_http_${response.status}`, { + status: response.status, + }); + } + + if (!response.body) { + throw new SseClientError("desktop_events_body_missing"); + } + + this.#setState("streaming"); + this.emit("open", null); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (this.#running) { + const { done, value } = await reader.read(); + if (done) { + break; + } + + buffer += decoder.decode(value, { stream: true }); + buffer = this.#flushBuffer(buffer); + } + + buffer += decoder.decode(); + this.#flushRemaining(buffer); + + if (this.#running && !this.#abortController.signal.aborted) { + throw new SseClientError("desktop_events_stream_closed"); + } + } + + #flushBuffer(buffer: string): string { + const normalized = buffer.replace(/\r\n/g, "\n"); + let cursor = 0; + + while (cursor >= 0) { + const boundary = normalized.indexOf("\n\n", cursor); + if (boundary === -1) { + return normalized.slice(cursor); + } + + const chunk = normalized.slice(cursor, boundary); + cursor = boundary + 2; + + const parsed = parseSseChunk(chunk); + if (!parsed) { + continue; + } + + this.emit(parsed.event, parsed.data); + this.emit("message", parsed); + } + + return ""; + } + + #flushRemaining(buffer: string): void { + const normalized = buffer.replace(/\r\n/g, "\n").trim(); + if (!normalized) { + return; + } + + const parsed = parseSseChunk(normalized); + if (!parsed) { + return; + } + + this.emit(parsed.event, parsed.data); + this.emit("message", parsed); + } + + #setState(state: SseClientState): void { + this.emit("state", state); + } +} + +function parseSseChunk(chunk: string): ParsedSseEvent | null { + if (!chunk.trim()) { + return null; + } + + let event = "message"; + const dataLines: string[] = []; + + for (const rawLine of chunk.split("\n")) { + const line = rawLine.trimEnd(); + if (!line || line.startsWith(":")) { + continue; + } + + if (line.startsWith("event:")) { + event = line.slice(6).trim() || "message"; + continue; + } + + if (line.startsWith("data:")) { + dataLines.push(line.slice(5).trimStart()); + } + } + + const rawData = dataLines.join("\n"); + if (!rawData) { + return { event, data: null }; + } + + try { + return { + event, + data: JSON.parse(rawData), + }; + } catch { + return { + event, + data: rawData, + }; + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + globalThis.setTimeout(resolve, ms); + }); +} diff --git a/apps/desktop-client/src/main/tray.ts b/apps/desktop-client/src/main/tray.ts new file mode 100644 index 0000000..5d21735 --- /dev/null +++ b/apps/desktop-client/src/main/tray.ts @@ -0,0 +1,27 @@ +import { nativeImage } from "electron/common"; +import { Menu, Tray } from "electron/main"; +import type { Tray as ElectronTray } from "electron/main"; + +let tray: ElectronTray | null = null; + +function createFallbackIcon() { + return nativeImage.createEmpty(); +} + +export function initTray(onOpen: () => void): ElectronTray { + if (tray) { + return tray; + } + + tray = new Tray(createFallbackIcon()); + tray.setToolTip("GEO Rankly Desktop"); + tray.setContextMenu( + Menu.buildFromTemplate([ + { label: "Open", click: () => onOpen() }, + { type: "separator" }, + { role: "quit", label: "Quit" }, + ]), + ); + tray.on("click", () => onOpen()); + return tray; +} diff --git a/apps/desktop-client/src/main/user-agent.ts b/apps/desktop-client/src/main/user-agent.ts new file mode 100644 index 0000000..bff5904 --- /dev/null +++ b/apps/desktop-client/src/main/user-agent.ts @@ -0,0 +1,13 @@ +const CHROME_UA_BY_PLATFORM: Record = { + darwin: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36", + win32: + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36", + linux: + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36", +}; + +export const STANDARD_USER_AGENT: string = + CHROME_UA_BY_PLATFORM[process.platform] ?? CHROME_UA_BY_PLATFORM.darwin; + +export const STANDARD_ACCEPT_LANGUAGES = "zh-CN,zh;q=0.9,en;q=0.8"; diff --git a/apps/desktop-client/src/main/vault.ts b/apps/desktop-client/src/main/vault.ts new file mode 100644 index 0000000..2edbecb --- /dev/null +++ b/apps/desktop-client/src/main/vault.ts @@ -0,0 +1,53 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +import { app, safeStorage } from "electron/main"; + +type VaultStore = Record; + +function vaultPath(): string { + return join(app.getPath("userData"), "desktop-vault.json"); +} + +function loadStore(): VaultStore { + try { + return JSON.parse(readFileSync(vaultPath(), "utf8")) as VaultStore; + } catch { + return {}; + } +} + +function persistStore(store: VaultStore): void { + const target = vaultPath(); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, JSON.stringify(store, null, 2), "utf8"); +} + +export function describeVaultBackend() { + return { + encryptionAvailable: safeStorage.isEncryptionAvailable(), + backend: + typeof safeStorage.getSelectedStorageBackend === "function" + ? safeStorage.getSelectedStorageBackend() + : "unknown", + path: vaultPath(), + }; +} + +export function writeEncrypted(key: string, value: string): void { + if (!safeStorage.isEncryptionAvailable()) { + throw new Error("safeStorage encryption is unavailable"); + } + + const store = loadStore(); + store[key] = safeStorage.encryptString(value).toString("base64"); + persistStore(store); +} + +export function readEncrypted(key: string): string | null { + const encoded = loadStore()[key]; + if (!encoded) { + return null; + } + return safeStorage.decryptString(Buffer.from(encoded, "base64")); +} diff --git a/apps/desktop-client/src/main/view-pool.ts b/apps/desktop-client/src/main/view-pool.ts new file mode 100644 index 0000000..79e4baf --- /dev/null +++ b/apps/desktop-client/src/main/view-pool.ts @@ -0,0 +1,51 @@ +import { WebContentsView } from "electron/main"; +import type { WebContentsView as ElectronWebContentsView } from "electron/main"; + +export interface HotViewHandle { + accountId: string; + view: ElectronWebContentsView; + activatedAt: number; +} + +const hotViews = new Map(); + +export function acquireHotView(accountId: string): HotViewHandle { + const existing = hotViews.get(accountId); + if (existing) { + existing.activatedAt = Date.now(); + return existing; + } + + const handle: HotViewHandle = { + accountId, + view: new WebContentsView({ + webPreferences: { + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + }, + }), + activatedAt: Date.now(), + }; + hotViews.set(accountId, handle); + return handle; +} + +export function demoteView(accountId: string): void { + const handle = hotViews.get(accountId); + if (!handle) { + return; + } + + if (!handle.view.webContents.isDestroyed()) { + handle.view.webContents.close(); + } + hotViews.delete(accountId); +} + +export function listHotViews(): Array> { + return [...hotViews.values()].map(({ accountId, activatedAt }) => ({ + accountId, + activatedAt, + })); +} diff --git a/apps/desktop-client/src/preload/bridge.ts b/apps/desktop-client/src/preload/bridge.ts new file mode 100644 index 0000000..241c270 --- /dev/null +++ b/apps/desktop-client/src/preload/bridge.ts @@ -0,0 +1,28 @@ +import { contextBridge, ipcRenderer } from "electron/renderer"; +import type { DesktopAccountInfo, DesktopRuntimeSessionSyncRequest } from "@geo/shared-types"; +import type { DesktopRuntimeSnapshot } from "../renderer/types"; + +const desktopBridge = { + app: { + ping: () => ipcRenderer.invoke("desktop:ping") as Promise, + runtimeSnapshot: () => + ipcRenderer.invoke("desktop:runtime-snapshot") as Promise, + bindPublishAccount: (platformId: string) => + ipcRenderer.invoke("desktop:bind-publish-account", platformId) as Promise, + openPublishAccountConsole: (account: { + id: string; + platform: string; + platformUid: string; + displayName: string; + }) => + ipcRenderer.invoke("desktop:open-publish-account-console", account) as Promise, + openTaskReview: (taskId: string) => + ipcRenderer.invoke("desktop:task-open-review", taskId) as Promise, + resolveParkedTask: (taskId: string, status: "succeeded" | "failed" | "unknown") => + ipcRenderer.invoke("desktop:task-resolve-parked", taskId, status) as Promise, + syncRuntimeSession: (session: DesktopRuntimeSessionSyncRequest | null) => + ipcRenderer.invoke("desktop:runtime-session-sync", session) as Promise, + }, +}; + +contextBridge.exposeInMainWorld("desktopBridge", desktopBridge); diff --git a/apps/desktop-client/src/renderer/App.vue b/apps/desktop-client/src/renderer/App.vue new file mode 100644 index 0000000..79e5233 --- /dev/null +++ b/apps/desktop-client/src/renderer/App.vue @@ -0,0 +1,18 @@ + + + diff --git a/apps/desktop-client/src/renderer/assets/logos/logo_baijiahao.png b/apps/desktop-client/src/renderer/assets/logos/logo_baijiahao.png new file mode 100644 index 0000000..fcc5602 Binary files /dev/null and b/apps/desktop-client/src/renderer/assets/logos/logo_baijiahao.png differ diff --git a/apps/desktop-client/src/renderer/assets/logos/logo_bilibili.png b/apps/desktop-client/src/renderer/assets/logos/logo_bilibili.png new file mode 100644 index 0000000..7e2d13f Binary files /dev/null and b/apps/desktop-client/src/renderer/assets/logos/logo_bilibili.png differ diff --git a/apps/desktop-client/src/renderer/assets/logos/logo_dongchedi.svg b/apps/desktop-client/src/renderer/assets/logos/logo_dongchedi.svg new file mode 100644 index 0000000..a397c81 --- /dev/null +++ b/apps/desktop-client/src/renderer/assets/logos/logo_dongchedi.svg @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/apps/desktop-client/src/renderer/assets/logos/logo_jianshu.svg b/apps/desktop-client/src/renderer/assets/logos/logo_jianshu.svg new file mode 100644 index 0000000..656b6dd --- /dev/null +++ b/apps/desktop-client/src/renderer/assets/logos/logo_jianshu.svg @@ -0,0 +1,4 @@ + + + 简书 + \ No newline at end of file diff --git a/apps/desktop-client/src/renderer/assets/logos/logo_juejin.png b/apps/desktop-client/src/renderer/assets/logos/logo_juejin.png new file mode 100644 index 0000000..6ee5fc0 Binary files /dev/null and b/apps/desktop-client/src/renderer/assets/logos/logo_juejin.png differ diff --git a/apps/desktop-client/src/renderer/assets/logos/logo_qiehao.png b/apps/desktop-client/src/renderer/assets/logos/logo_qiehao.png new file mode 100644 index 0000000..744bcc7 Binary files /dev/null and b/apps/desktop-client/src/renderer/assets/logos/logo_qiehao.png differ diff --git a/apps/desktop-client/src/renderer/assets/logos/logo_smzdm.svg b/apps/desktop-client/src/renderer/assets/logos/logo_smzdm.svg new file mode 100644 index 0000000..f85ad4c --- /dev/null +++ b/apps/desktop-client/src/renderer/assets/logos/logo_smzdm.svg @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/apps/desktop-client/src/renderer/assets/logos/logo_souhu.png b/apps/desktop-client/src/renderer/assets/logos/logo_souhu.png new file mode 100644 index 0000000..9a63b32 Binary files /dev/null and b/apps/desktop-client/src/renderer/assets/logos/logo_souhu.png differ diff --git a/apps/desktop-client/src/renderer/assets/logos/logo_toutiao.png b/apps/desktop-client/src/renderer/assets/logos/logo_toutiao.png new file mode 100644 index 0000000..9995c7a Binary files /dev/null and b/apps/desktop-client/src/renderer/assets/logos/logo_toutiao.png differ diff --git a/apps/desktop-client/src/renderer/assets/logos/logo_wangyihao.png b/apps/desktop-client/src/renderer/assets/logos/logo_wangyihao.png new file mode 100644 index 0000000..bdcef05 Binary files /dev/null and b/apps/desktop-client/src/renderer/assets/logos/logo_wangyihao.png differ diff --git a/apps/desktop-client/src/renderer/assets/logos/logo_weixin_gzh.svg b/apps/desktop-client/src/renderer/assets/logos/logo_weixin_gzh.svg new file mode 100644 index 0000000..f306286 --- /dev/null +++ b/apps/desktop-client/src/renderer/assets/logos/logo_weixin_gzh.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/apps/desktop-client/src/renderer/assets/logos/logo_zhihu.png b/apps/desktop-client/src/renderer/assets/logos/logo_zhihu.png new file mode 100644 index 0000000..b5381c2 Binary files /dev/null and b/apps/desktop-client/src/renderer/assets/logos/logo_zhihu.png differ diff --git a/apps/desktop-client/src/renderer/assets/logos/logo_zol.png b/apps/desktop-client/src/renderer/assets/logos/logo_zol.png new file mode 100644 index 0000000..64432a2 Binary files /dev/null and b/apps/desktop-client/src/renderer/assets/logos/logo_zol.png differ diff --git a/apps/desktop-client/src/renderer/components/DesktopShell.vue b/apps/desktop-client/src/renderer/components/DesktopShell.vue new file mode 100644 index 0000000..02d9edf --- /dev/null +++ b/apps/desktop-client/src/renderer/components/DesktopShell.vue @@ -0,0 +1,420 @@ + + + + + diff --git a/apps/desktop-client/src/renderer/components/MetricCard.vue b/apps/desktop-client/src/renderer/components/MetricCard.vue new file mode 100644 index 0000000..d948a25 --- /dev/null +++ b/apps/desktop-client/src/renderer/components/MetricCard.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/apps/desktop-client/src/renderer/components/StatusBadge.vue b/apps/desktop-client/src/renderer/components/StatusBadge.vue new file mode 100644 index 0000000..ec3251c --- /dev/null +++ b/apps/desktop-client/src/renderer/components/StatusBadge.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/apps/desktop-client/src/renderer/components/SurfaceCard.vue b/apps/desktop-client/src/renderer/components/SurfaceCard.vue new file mode 100644 index 0000000..8be3f57 --- /dev/null +++ b/apps/desktop-client/src/renderer/components/SurfaceCard.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/apps/desktop-client/src/renderer/composables/useDesktopRuntime.ts b/apps/desktop-client/src/renderer/composables/useDesktopRuntime.ts new file mode 100644 index 0000000..9931964 --- /dev/null +++ b/apps/desktop-client/src/renderer/composables/useDesktopRuntime.ts @@ -0,0 +1,71 @@ +import { computed, onMounted, onUnmounted, readonly, shallowRef } from "vue"; + +import { createPreviewRuntimeSnapshot } from "../mock/runtime-preview"; +import type { DesktopRuntimeSnapshot } from "../types"; + +const snapshot = shallowRef(null); +const loading = shallowRef(false); +const error = shallowRef(null); + +let intervalHandle: ReturnType | null = null; +let subscribers = 0; + +async function refreshRuntimeSnapshot() { + loading.value = true; + error.value = null; + + try { + if (window.desktopBridge?.app?.runtimeSnapshot) { + snapshot.value = await window.desktopBridge.app.runtimeSnapshot(); + return; + } + + snapshot.value = createPreviewRuntimeSnapshot(); + } catch (err) { + error.value = err instanceof Error ? err.message : "runtime snapshot unavailable"; + } finally { + loading.value = false; + } +} + +function startPolling() { + if (intervalHandle) { + return; + } + + intervalHandle = setInterval(() => { + void refreshRuntimeSnapshot(); + }, 15_000); +} + +function stopPolling() { + if (subscribers > 0 || !intervalHandle) { + return; + } + + clearInterval(intervalHandle); + intervalHandle = null; +} + +export function useDesktopRuntime() { + onMounted(() => { + subscribers += 1; + if (!snapshot.value && !loading.value) { + void refreshRuntimeSnapshot(); + } + startPolling(); + }); + + onUnmounted(() => { + subscribers = Math.max(0, subscribers - 1); + stopPolling(); + }); + + return { + snapshot: readonly(snapshot), + loading: readonly(loading), + error: readonly(error), + refresh: refreshRuntimeSnapshot, + hasSnapshot: computed(() => snapshot.value !== null), + }; +} diff --git a/apps/desktop-client/src/renderer/composables/useDesktopSession.ts b/apps/desktop-client/src/renderer/composables/useDesktopSession.ts new file mode 100644 index 0000000..1daa59d --- /dev/null +++ b/apps/desktop-client/src/renderer/composables/useDesktopSession.ts @@ -0,0 +1,272 @@ +import { computed, readonly, shallowRef } from "vue"; + +import { createApiClient } from "@geo/http-client"; +import type { + AuthTokens, + DesktopClientInfo, + DesktopClientRegisterRequest, + DesktopClientRegisterResponse, + DesktopRuntimeSessionSyncRequest, + LoginRequest, + LoginResponse, + RefreshResponse, + UserInfo, +} from "@geo/shared-types"; + +type SessionMode = "authenticated" | "preview"; + +interface DesktopSession { + mode: SessionMode; + accessToken: string | null; + refreshToken: string | null; + user: UserInfo | null; + clientToken: string | null; + clientTokenExpiresAt: number | null; + desktopClient: DesktopClientInfo | null; +} + +const sessionStorageKey = "geo.desktop.session.v1"; +const apiBaseURLStorageKey = "geo.desktop.api-base-url"; + +const session = shallowRef(readStoredSession()); +const apiBaseURL = shallowRef(readStoredApiBaseURL()); +const pending = shallowRef(false); +const error = shallowRef(null); + +function readStoredSession(): DesktopSession | null { + if (typeof window === "undefined") { + return null; + } + + try { + const raw = window.localStorage.getItem(sessionStorageKey); + if (!raw) { + return null; + } + return JSON.parse(raw) as DesktopSession; + } catch { + return null; + } +} + +function persistSession(next: DesktopSession | null): void { + session.value = next; + + if (typeof window === "undefined") { + return; + } + + if (!next) { + window.localStorage.removeItem(sessionStorageKey); + } else { + window.localStorage.setItem(sessionStorageKey, JSON.stringify(next)); + } + + void syncRuntimeSessionToMain(next); +} + +function readStoredApiBaseURL(): string { + if (typeof window === "undefined") { + return "http://localhost:8080"; + } + + return window.localStorage.getItem(apiBaseURLStorageKey) ?? "http://localhost:8080"; +} + +function persistApiBaseURL(value: string): void { + const trimmed = value.trim() || "http://localhost:8080"; + apiBaseURL.value = trimmed; + + if (typeof window !== "undefined") { + window.localStorage.setItem(apiBaseURLStorageKey, trimmed); + } + + void syncRuntimeSessionToMain(); +} + +function createPublicClient() { + return createApiClient({ + baseURL: apiBaseURL.value, + timeoutMs: 15000, + }); +} + +function createAuthenticatedClient() { + return createApiClient({ + baseURL: apiBaseURL.value, + timeoutMs: 15000, + auth: { + getAccessToken: () => session.value?.accessToken ?? null, + getRefreshToken: () => session.value?.refreshToken ?? null, + setTokens: (tokens: AuthTokens) => { + if (!session.value) { + return; + } + + persistSession({ + ...session.value, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + }); + }, + clearTokens: () => persistSession(null), + async refresh(refreshToken: string): Promise { + const next = await createPublicClient().post( + "/api/auth/refresh", + { refresh_token: refreshToken }, + ); + + return { + accessToken: next.access_token, + refreshToken: next.refresh_token, + }; + }, + }, + }); +} + +function registerPayload(): DesktopClientRegisterRequest { + return { + device_name: `Desktop ${navigator.platform || "unknown"}`, + os: navigator.platform || "unknown", + cpu_arch: "renderer-preview", + client_version: "0.1.0-dev", + channel: "dev", + }; +} + +function createPreviewUser(): UserInfo { + return { + id: 0, + email: "preview@geo-rankly.local", + name: "开发预览账号", + avatar_url: null, + tenant_id: 0, + primary_workspace_id: 0, + tenant_role: "owner", + permissions: ["desktop.preview"], + membership: null, + kol_profile: null, + }; +} + +function createPreviewClient(): DesktopClientInfo { + const now = new Date().toISOString(); + return { + id: "preview-client", + tenant_id: 0, + workspace_id: 0, + user_id: 0, + device_name: `Preview ${navigator.platform || "Desktop"}`, + os: navigator.platform || "browser", + cpu_arch: "renderer-preview", + client_version: "0.1.0-preview", + channel: "dev", + created_at: now, + last_seen_at: now, + last_rotated_at: now, + revoked_at: null, + }; +} + +function buildRuntimeSessionPayload(next: DesktopSession | null): DesktopRuntimeSessionSyncRequest | null { + if (!next) { + return null; + } + + return { + api_base_url: apiBaseURL.value, + mode: next.mode, + client_token: next.clientToken, + desktop_client: next.desktopClient, + }; +} + +async function syncRuntimeSessionToMain(next: DesktopSession | null = session.value): Promise { + if (typeof window === "undefined") { + return; + } + + if (!window.desktopBridge?.app?.syncRuntimeSession) { + return; + } + + await window.desktopBridge.app.syncRuntimeSession(buildRuntimeSessionPayload(next)); +} + +async function login(payload: LoginRequest): Promise { + pending.value = true; + error.value = null; + + try { + const authData = await createPublicClient().post( + "/api/auth/login", + payload, + ); + + persistSession({ + mode: "authenticated", + accessToken: authData.access_token, + refreshToken: authData.refresh_token, + user: authData.user, + clientToken: null, + clientTokenExpiresAt: null, + desktopClient: null, + }); + + const registered = await createAuthenticatedClient().post< + DesktopClientRegisterResponse, + DesktopClientRegisterRequest + >("/api/desktop/clients/register", registerPayload()); + + persistSession({ + mode: "authenticated", + accessToken: authData.access_token, + refreshToken: authData.refresh_token, + user: authData.user, + clientToken: registered.client_token, + clientTokenExpiresAt: registered.expires_at, + desktopClient: registered.client, + }); + } catch (err) { + error.value = err instanceof Error ? err.message : "登录失败,请稍后重试"; + persistSession(null); + throw err; + } finally { + pending.value = false; + } +} + +function enterPreview(): void { + error.value = null; + persistSession({ + mode: "preview", + accessToken: null, + refreshToken: null, + user: createPreviewUser(), + clientToken: "preview-client-token", + clientTokenExpiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000, + desktopClient: createPreviewClient(), + }); +} + +function logout(): void { + error.value = null; + persistSession(null); +} + +export function useDesktopSession() { + return { + session: readonly(session), + apiBaseURL: readonly(apiBaseURL), + pending: readonly(pending), + error: readonly(error), + isAuthenticated: computed(() => Boolean(session.value?.clientToken)), + isPreviewMode: computed(() => session.value?.mode === "preview"), + setApiBaseURL: persistApiBaseURL, + syncRuntimeSession: syncRuntimeSessionToMain, + login, + logout, + enterPreview, + }; +} diff --git a/apps/desktop-client/src/renderer/env.d.ts b/apps/desktop-client/src/renderer/env.d.ts new file mode 100644 index 0000000..ec987e9 --- /dev/null +++ b/apps/desktop-client/src/renderer/env.d.ts @@ -0,0 +1,25 @@ +import type { DesktopAccountInfo, DesktopRuntimeSessionSyncRequest } from "@geo/shared-types"; +import type { DesktopRuntimeSnapshot } from "./types"; + +export {}; + +declare global { + interface Window { + desktopBridge: { + app: { + ping(): Promise; + runtimeSnapshot(): Promise; + bindPublishAccount(platformId: string): Promise; + openPublishAccountConsole(account: { + id: string; + platform: string; + platformUid: string; + displayName: string; + }): Promise; + openTaskReview(taskId: string): Promise; + resolveParkedTask(taskId: string, status: "succeeded" | "failed" | "unknown"): Promise; + syncRuntimeSession(session: DesktopRuntimeSessionSyncRequest | null): Promise; + }; + }; + } +} diff --git a/apps/desktop-client/src/renderer/index.html b/apps/desktop-client/src/renderer/index.html new file mode 100644 index 0000000..8a61990 --- /dev/null +++ b/apps/desktop-client/src/renderer/index.html @@ -0,0 +1,20 @@ + + + + + + + + GEO Rankly Desktop + + +
+ + + diff --git a/apps/desktop-client/src/renderer/lib/client-errors.ts b/apps/desktop-client/src/renderer/lib/client-errors.ts new file mode 100644 index 0000000..96dd841 --- /dev/null +++ b/apps/desktop-client/src/renderer/lib/client-errors.ts @@ -0,0 +1,105 @@ +import { Modal } from "ant-design-vue"; + +type ClientErrorTone = "error" | "warning"; +type ClientActionKind = "bind-account" | "open-console"; + +interface ClientErrorPresentation { + tone: ClientErrorTone; + title: string; + content: string; +} + +const REMOTE_METHOD_PREFIX = /^Error invoking remote method '[^']+':\s*/i; +const ERROR_PREFIX = /^Error:\s*/i; + +function rawErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + if (typeof error === "string") { + return error; + } + return ""; +} + +export function unwrapClientErrorMessage(error: unknown, fallback: string): string { + let message = rawErrorMessage(error).trim(); + let previous = ""; + + while (message && message !== previous) { + previous = message; + message = message + .replace(REMOTE_METHOD_PREFIX, "") + .replace(ERROR_PREFIX, "") + .trim(); + } + + return message || fallback; +} + +function presentClientError(kind: ClientActionKind, error: unknown): ClientErrorPresentation { + const message = unwrapClientErrorMessage( + error, + kind === "bind-account" ? "账号绑定失败" : "打开创作台失败", + ); + + if (message === "desktop_account_bind_window_closed") { + return { + tone: "warning", + title: "授权已中断", + content: "授权窗口已关闭,本次绑定没有完成。重新点击“绑定账号”即可继续。", + }; + } + + if (message === "desktop_account_upsert_failed") { + return { + tone: "error", + title: "保存授权账号失败", + content: "平台侧授权已经完成,但客户端回写账号信息失败。请稍后重试。", + }; + } + + if (message === "desktop_account_bind_failed") { + return { + tone: "error", + title: "账号绑定失败", + content: "授权流程执行失败,请稍后重新发起绑定。", + }; + } + + if (message.startsWith("desktop_publish_platform_not_supported:")) { + const platformId = message.split(":")[1] || "当前平台"; + return { + tone: "error", + title: "平台暂不支持", + content: `${platformId} 暂时还不支持这个操作。`, + }; + } + + if (kind === "open-console") { + return { + tone: "error", + title: "打开创作台失败", + content: message, + }; + } + + return { + tone: "error", + title: "账号绑定失败", + content: message, + }; +} + +export function showClientActionError(kind: ClientActionKind, error: unknown): void { + const presentation = presentClientError(kind, error); + const showModal = presentation.tone === "warning" ? Modal.warning : Modal.error; + + showModal({ + title: presentation.title, + content: presentation.content, + okText: "知道了", + centered: true, + maskClosable: true, + }); +} diff --git a/apps/desktop-client/src/renderer/lib/formatters.ts b/apps/desktop-client/src/renderer/lib/formatters.ts new file mode 100644 index 0000000..6ff7705 --- /dev/null +++ b/apps/desktop-client/src/renderer/lib/formatters.ts @@ -0,0 +1,50 @@ +const relativeFormatter = new Intl.RelativeTimeFormat("zh-CN", { numeric: "auto" }); + +export function formatRelativeTime(value: number): string { + const deltaSeconds = Math.round((value - Date.now()) / 1000); + const absSeconds = Math.abs(deltaSeconds); + + if (absSeconds < 60) { + return relativeFormatter.format(deltaSeconds, "second"); + } + if (absSeconds < 3600) { + return relativeFormatter.format(Math.round(deltaSeconds / 60), "minute"); + } + if (absSeconds < 86_400) { + return relativeFormatter.format(Math.round(deltaSeconds / 3600), "hour"); + } + return relativeFormatter.format(Math.round(deltaSeconds / 86_400), "day"); +} + +export function formatClock(value: number | null): string { + if (!value) { + return "N/A"; + } + return new Intl.DateTimeFormat("zh-CN", { + hour: "2-digit", + minute: "2-digit", + month: "2-digit", + day: "2-digit", + }).format(value); +} + +export function formatDateTime(value: number | null): string { + if (!value) { + return "N/A"; + } + return new Intl.DateTimeFormat("zh-CN", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }).format(value); +} + +export function titleCaseToken(value: string): string { + return value + .split(/[_-]/g) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} diff --git a/apps/desktop-client/src/renderer/lib/media-catalog.ts b/apps/desktop-client/src/renderer/lib/media-catalog.ts new file mode 100644 index 0000000..32dc008 --- /dev/null +++ b/apps/desktop-client/src/renderer/lib/media-catalog.ts @@ -0,0 +1,192 @@ +import logoBaijiahao from "../assets/logos/logo_baijiahao.png"; +import logoBilibili from "../assets/logos/logo_bilibili.png"; +import logoDongchedi from "../assets/logos/logo_dongchedi.svg"; +import logoJianshu from "../assets/logos/logo_jianshu.svg"; +import logoJuejin from "../assets/logos/logo_juejin.png"; +import logoQiehao from "../assets/logos/logo_qiehao.png"; +import logoSmzdm from "../assets/logos/logo_smzdm.svg"; +import logoSouhu from "../assets/logos/logo_souhu.png"; +import logoToutiao from "../assets/logos/logo_toutiao.png"; +import logoWangyihao from "../assets/logos/logo_wangyihao.png"; +import logoWeixinGzh from "../assets/logos/logo_weixin_gzh.svg"; +import logoZhihu from "../assets/logos/logo_zhihu.png"; +import logoZol from "../assets/logos/logo_zol.png"; + +export interface DesktopMediaDefinition { + id: string; + label: string; + shortName: string; + accent: string; + loginUrl: string | null; + logoUrl: string | null; + category: "publish" | "monitoring"; + description: string; +} + +export const desktopPublishMediaCatalog: DesktopMediaDefinition[] = [ + { + id: "toutiaohao", + label: "头条号", + shortName: "头", + accent: "#f5222d", + loginUrl: "https://mp.toutiao.com/auth/page/login", + logoUrl: logoToutiao, + category: "publish", + description: "发布媒体 / 浏览器登录检测", + }, + { + id: "baijiahao", + label: "百家号", + shortName: "百", + accent: "#31445a", + loginUrl: "https://baijiahao.baidu.com/builder/theme/bjh/login", + logoUrl: logoBaijiahao, + category: "publish", + description: "发布媒体 / 浏览器登录检测", + }, + { + id: "sohuhao", + label: "搜狐号", + shortName: "搜", + accent: "#fa8c16", + loginUrl: "https://mp.sohu.com/mpfe/v4/login", + logoUrl: logoSouhu, + category: "publish", + description: "发布媒体 / 浏览器登录检测", + }, + { + id: "qiehao", + label: "企鹅号", + shortName: "Q", + accent: "#111827", + loginUrl: "https://om.qq.com", + logoUrl: logoQiehao, + category: "publish", + description: "发布媒体 / 浏览器登录检测", + }, + { + id: "zhihu", + label: "知乎", + shortName: "知", + accent: "#1677ff", + loginUrl: "https://www.zhihu.com/signin", + logoUrl: logoZhihu, + category: "publish", + description: "发布媒体 / 浏览器登录检测", + }, + { + id: "wangyihao", + label: "网易号", + shortName: "网", + accent: "#ff4d4f", + loginUrl: "https://mp.163.com/login.html", + logoUrl: logoWangyihao, + category: "publish", + description: "发布媒体 / 浏览器登录检测", + }, + { + id: "jianshu", + label: "简书", + shortName: "简", + accent: "#f56a5e", + loginUrl: "https://www.jianshu.com/sign_in", + logoUrl: logoJianshu, + category: "publish", + description: "发布媒体 / 浏览器登录检测", + }, + { + id: "bilibili", + label: "bilibili", + shortName: "B", + accent: "#eb2f96", + loginUrl: "https://www.bilibili.com", + logoUrl: logoBilibili, + category: "publish", + description: "发布媒体 / 浏览器登录检测", + }, + { + id: "juejin", + label: "稀土掘金", + shortName: "掘", + accent: "#1677ff", + loginUrl: "https://juejin.cn/login", + logoUrl: logoJuejin, + category: "publish", + description: "发布媒体 / 浏览器登录检测", + }, + { + id: "smzdm", + label: "什么值得买", + shortName: "值", + accent: "#f5222d", + loginUrl: "https://zhiyou.smzdm.com/user/login", + logoUrl: logoSmzdm, + category: "publish", + description: "发布媒体 / 浏览器登录检测", + }, + { + id: "weixin_gzh", + label: "微信公众号", + shortName: "微", + accent: "#13c26b", + loginUrl: "https://mp.weixin.qq.com/cgi-bin/loginpage", + logoUrl: logoWeixinGzh, + category: "publish", + description: "发布媒体 / 浏览器登录检测", + }, + { + id: "zol", + label: "中关村在线", + shortName: "Z", + accent: "#ff4d4f", + loginUrl: "https://post.zol.com.cn/v2/manage/works/all", + logoUrl: logoZol, + category: "publish", + description: "发布媒体 / 浏览器登录检测", + }, + { + id: "dongchedi", + label: "懂车帝", + shortName: "懂", + accent: "#fadb14", + loginUrl: "https://mp.dcdapp.com/login", + logoUrl: logoDongchedi, + category: "publish", + description: "发布媒体 / 浏览器登录检测", + }, +]; + +export const desktopMonitoringMediaCatalog: DesktopMediaDefinition[] = [ + { + id: "deepseek", + label: "DeepSeek", + shortName: "D", + accent: "#4468ff", + loginUrl: "https://chat.deepseek.com/", + logoUrl: null, + category: "monitoring", + description: "AI 监测 / 登录后可采集", + }, + { + id: "qwen", + label: "通义千问", + shortName: "Q", + accent: "#6b46ff", + loginUrl: "https://www.qianwen.com/", + logoUrl: null, + category: "monitoring", + description: "AI 监测 / 匿名可用", + }, + { + id: "doubao", + label: "豆包", + shortName: "豆", + accent: "#00a870", + loginUrl: "https://www.doubao.com/", + logoUrl: null, + category: "monitoring", + description: "AI 监测 / 匿名可用", + }, +]; + +export const desktopMediaCatalog = [...desktopPublishMediaCatalog, ...desktopMonitoringMediaCatalog]; diff --git a/apps/desktop-client/src/renderer/lib/runtime-ui.ts b/apps/desktop-client/src/renderer/lib/runtime-ui.ts new file mode 100644 index 0000000..e49a0ca --- /dev/null +++ b/apps/desktop-client/src/renderer/lib/runtime-ui.ts @@ -0,0 +1,29 @@ +import type { RuntimeAccount, RuntimeTask, RuntimeTone } from "../types"; + +export function healthTone(health: RuntimeAccount["health"]): RuntimeTone { + switch (health) { + case "live": + return "success"; + case "captcha": + return "warn"; + case "risk": + return "danger"; + default: + return "info"; + } +} + +export function taskTone(status: RuntimeTask["status"]): RuntimeTone { + switch (status) { + case "succeeded": + return "success"; + case "waiting_user": + return "warn"; + case "failed": + case "aborted": + case "unknown": + return "danger"; + default: + return "info"; + } +} diff --git a/apps/desktop-client/src/renderer/main.ts b/apps/desktop-client/src/renderer/main.ts new file mode 100644 index 0000000..d47c431 --- /dev/null +++ b/apps/desktop-client/src/renderer/main.ts @@ -0,0 +1,18 @@ +import { createApp } from "vue"; +import Antd from "ant-design-vue"; + +import App from "./App.vue"; +import { router } from "./routes"; + +import "ant-design-vue/dist/reset.css"; +import "../../../../packages/ui-shared/src/tokens/index.css"; + +const applyTheme = (prefersDark: boolean) => { + document.documentElement.dataset.theme = prefersDark ? "dark" : "light"; +}; + +const themeQuery = window.matchMedia("(prefers-color-scheme: dark)"); +applyTheme(themeQuery.matches); +themeQuery.addEventListener("change", (event) => applyTheme(event.matches)); + +createApp(App).use(Antd).use(router).mount("#app"); diff --git a/apps/desktop-client/src/renderer/routes.ts b/apps/desktop-client/src/renderer/routes.ts new file mode 100644 index 0000000..c19a22c --- /dev/null +++ b/apps/desktop-client/src/renderer/routes.ts @@ -0,0 +1,19 @@ +import { createRouter, createWebHashHistory } from "vue-router"; + +import AccountsView from "./views/AccountsView.vue"; +import AiPlatformsView from "./views/AiPlatformsView.vue"; +import DiagnosticsView from "./views/DiagnosticsView.vue"; +import HomeView from "./views/HomeView.vue"; +import TasksView from "./views/TasksView.vue"; + +export const router = createRouter({ + history: createWebHashHistory(), + routes: [ + { path: "/", name: "home", component: HomeView }, + { path: "/tasks", name: "tasks", component: TasksView }, + { path: "/media-platforms", name: "media-platforms", component: AccountsView }, + { path: "/ai-platforms", name: "ai-platforms", component: AiPlatformsView }, + { path: "/accounts", redirect: "/media-platforms" }, + { path: "/diagnostics", name: "diagnostics", component: DiagnosticsView }, + ], +}); diff --git a/apps/desktop-client/src/renderer/types.ts b/apps/desktop-client/src/renderer/types.ts new file mode 100644 index 0000000..640c240 --- /dev/null +++ b/apps/desktop-client/src/renderer/types.ts @@ -0,0 +1,86 @@ +export type RuntimeTone = "info" | "success" | "warn" | "danger"; + +export interface RuntimeClient { + id: string; + label: string; + deviceName: string; + os: string; + status: "online" | "offline"; + lastSeenAt: number; + queueDepth: number; + heartbeat: "healthy" | "stale"; + channel: string; +} + +export interface RuntimeAccount { + id: string; + platform: string; + displayName: string; + platformUid: string; + avatarUrl: string | null; + health: "live" | "expired" | "captcha" | "risk"; + tags: string[]; + clientId: string; + partition: string; + sessionState: "hot" | "warm" | "cold"; + lastSyncAt: number; + queueDepth: number; + online: boolean; +} + +export interface RuntimeTask { + id: string; + jobId: string; + title: string; + kind: "publish" | "monitor"; + platform: string; + accountId: string; + accountName: string; + clientId: string; + status: "queued" | "in_progress" | "waiting_user" | "succeeded" | "failed" | "unknown" | "aborted"; + mode: "auto" | "manual"; + routing: "rabbitmq-primary" | "db-recovery"; + leaseExpiresAt: number | null; + updatedAt: number; + summary: string; +} + +export interface RuntimeActivity { + id: string; + severity: RuntimeTone; + title: string; + detail: string; + at: number; +} + +export interface DesktopRuntimeSnapshot { + previewMode: boolean; + generatedAt: number; + app: { + name: string; + version: string; + channel: string; + platform: string; + sessionCount: number; + hotViewCount: number; + }; + workspace: { + id: string; + name: string; + strategy: string; + nextSweepAt: number; + lastFullSyncAt: number; + }; + summary: { + onlineClients: number; + accountsBound: number; + queuedTasks: number; + issuesOpen: number; + healthCounts: Record; + }; + clients: RuntimeClient[]; + accounts: RuntimeAccount[]; + tasks: RuntimeTask[]; + activity: RuntimeActivity[]; + subsystems: Record; +} diff --git a/apps/desktop-client/src/renderer/views/AccountsView.vue b/apps/desktop-client/src/renderer/views/AccountsView.vue new file mode 100644 index 0000000..ee843d8 --- /dev/null +++ b/apps/desktop-client/src/renderer/views/AccountsView.vue @@ -0,0 +1,785 @@ + + + + + diff --git a/apps/desktop-client/src/renderer/views/AiPlatformsView.vue b/apps/desktop-client/src/renderer/views/AiPlatformsView.vue new file mode 100644 index 0000000..d456ae5 --- /dev/null +++ b/apps/desktop-client/src/renderer/views/AiPlatformsView.vue @@ -0,0 +1,403 @@ + + + + + diff --git a/apps/desktop-client/src/renderer/views/DiagnosticsView.vue b/apps/desktop-client/src/renderer/views/DiagnosticsView.vue new file mode 100644 index 0000000..10da3d8 --- /dev/null +++ b/apps/desktop-client/src/renderer/views/DiagnosticsView.vue @@ -0,0 +1,188 @@ + + + + + diff --git a/apps/desktop-client/src/renderer/views/HomeView.vue b/apps/desktop-client/src/renderer/views/HomeView.vue new file mode 100644 index 0000000..cd48678 --- /dev/null +++ b/apps/desktop-client/src/renderer/views/HomeView.vue @@ -0,0 +1,577 @@ + + + + + diff --git a/apps/desktop-client/src/renderer/views/LoginView.vue b/apps/desktop-client/src/renderer/views/LoginView.vue new file mode 100644 index 0000000..6a4484c --- /dev/null +++ b/apps/desktop-client/src/renderer/views/LoginView.vue @@ -0,0 +1,414 @@ + + + + + diff --git a/apps/desktop-client/src/renderer/views/TasksView.vue b/apps/desktop-client/src/renderer/views/TasksView.vue new file mode 100644 index 0000000..381ef90 --- /dev/null +++ b/apps/desktop-client/src/renderer/views/TasksView.vue @@ -0,0 +1,350 @@ + + + + + diff --git a/apps/desktop-client/tsconfig.json b/apps/desktop-client/tsconfig.json new file mode 100644 index 0000000..4e12896 --- /dev/null +++ b/apps/desktop-client/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../packages/tsconfig/base.json", + "compilerOptions": { + "types": ["node", "electron-vite/node", "vite/client"], + "baseUrl": ".", + "paths": { + "@main/*": ["src/main/*"], + "@preload/*": ["src/preload/*"], + "@renderer/*": ["src/renderer/*"], + "@geo/shared-types": ["../../packages/shared-types/src/index.ts"], + "@geo/http-client": ["../../packages/http-client/src/index.ts"], + "@geo/ui-shared": ["../../packages/ui-shared/src/index.ts"], + "@geo/ui-shared/tokens": ["../../packages/ui-shared/src/tokens/index.ts"] + } + }, + "include": [ + "electron.vite.config.ts", + "src/**/*.ts", + "src/**/*.d.ts", + "src/**/*.vue" + ] +} diff --git a/package.json b/package.json index bc46589..ba4f71e 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,23 @@ { "name": "geo-rankly", "private": true, - "packageManager": "pnpm@10.28.2", + "packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319", "scripts": { "dev:admin": "pnpm --filter admin-web dev", "build:admin": "pnpm --filter admin-web build", "typecheck:admin": "pnpm --filter admin-web typecheck", + "dev:desktop": "pnpm --filter @geo/desktop-client dev", + "build:desktop": "pnpm --filter @geo/desktop-client build", + "typecheck:desktop": "pnpm --filter @geo/desktop-client typecheck", "dev:extension": "pnpm --filter browser-extension dev", "build:extension": "pnpm --filter browser-extension build", "typecheck:extension": "pnpm --filter browser-extension exec vue-tsc --noEmit" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "better-sqlite3", + "electron", + "esbuild" + ] } } diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 2b5aa29..f12a062 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -28,6 +28,7 @@ export interface UserInfo { name: string; avatar_url: string | null; tenant_id: number; + primary_workspace_id: number; tenant_role: string; permissions: string[]; membership: MembershipInfo | null; @@ -62,6 +63,208 @@ export interface RefreshResponse { expires_at: number; } +export interface DesktopClientInfo { + id: string; + tenant_id: number; + workspace_id: number; + user_id: number; + device_name: string | null; + os: string | null; + cpu_arch: string | null; + client_version: string | null; + channel: string | null; + created_at: string; + last_seen_at: string | null; + last_rotated_at: string | null; + revoked_at: string | null; +} + +export interface DesktopClientRegisterRequest { + device_name: string; + os: string; + cpu_arch?: string; + client_version: string; + channel?: string; +} + +export interface DesktopClientRegisterResponse { + client_id: string; + client_token: string; + expires_at: number; + client: DesktopClientInfo; +} + +export interface DesktopClientHeartbeatRequest { + device_name?: string; + os?: string; + cpu_arch?: string; + client_version?: string; + channel?: string; +} + +export interface DesktopClientHeartbeatResponse { + client: DesktopClientInfo; + server_time: string; +} + +export interface DesktopClientRotateResponse { + client_token: string; + expires_at: number; + client: DesktopClientInfo; +} + +export interface DesktopAccountInfo { + id: string; + platform: string; + platform_uid: string; + display_name: string; + health: "live" | "expired" | "captcha" | "risk"; + client_id: string | null; + account_fingerprint: string | null; + verified_at: string | null; + tags: string[]; + sync_version: number; + deleted_at: string | null; + delete_requested_at: string | null; +} + +export interface UpsertDesktopAccountRequest { + platform: string; + platform_uid: string; + account_fingerprint?: string | null; + display_name: string; + health: "live" | "expired" | "captcha" | "risk"; + verified_at?: string | null; + tags?: string[]; + if_sync_version?: number | null; +} + +export interface PatchDesktopAccountRequest { + display_name?: string; + health?: "live" | "expired" | "captcha" | "risk"; + verified_at?: string | null; + tags?: string[]; + if_sync_version: number; +} + +export interface DesktopTaskInfo { + id: string; + job_id: string; + tenant_id: number; + workspace_id: number; + target_account_id: string; + target_client_id: string; + platform: string; + kind: "publish" | "monitor"; + payload: Record | null; + status: "queued" | "in_progress" | "waiting_user" | "succeeded" | "failed" | "unknown" | "aborted"; + dedup_key: string | null; + active_attempt_id: string | null; + lease_expires_at: string | null; + attempts: number; + parked_reason: string | null; + result: Record | null; + error: Record | null; + created_at: string; + updated_at: string; +} + +export interface LeaseDesktopTaskRequest { + task_id?: string; + kind?: "publish" | "monitor"; +} + +export interface LeaseDesktopTaskResponse { + task: DesktopTaskInfo | null; + attempt_id?: string; + lease_token?: string; + lease_expires_at?: string | null; +} + +export interface ExtendDesktopTaskRequest { + lease_token: string; +} + +export interface ParkDesktopTaskRequest { + lease_token: string; + reason: "waiting_user" | "captcha"; +} + +export interface CompleteDesktopTaskRequest { + lease_token: string; + status: "succeeded" | "failed" | "unknown"; + payload?: Record; + error?: Record; +} + +export interface CancelDesktopTaskRequest { + lease_token?: string | null; + reason?: string | null; +} + +export interface ReconcileDesktopTaskRequest { + status: "succeeded" | "failed" | "aborted" | "retry"; + result?: Record; + error?: Record; +} + +export interface DesktopTaskEventMessage { + type: + | "task_available" + | "task_leased" + | "task_extended" + | "task_parked" + | "task_completed" + | "task_canceled" + | "task_reconciled"; + task_id: string; + job_id: string; + workspace_id: number; + target_client_id: string; + status: + | "queued" + | "in_progress" + | "waiting_user" + | "succeeded" + | "failed" + | "unknown" + | "aborted"; + kind: "publish" | "monitor"; + updated_at: string; +} + +export interface DesktopArticleContent { + article_id: number; + title: string; + html_content: string | null; + markdown_content: string | null; + cover_asset_url: string | null; +} + +export interface DesktopRuntimeSessionSyncRequest { + api_base_url: string; + mode: "authenticated" | "preview"; + client_token: string | null; + desktop_client: DesktopClientInfo | null; +} + +export interface CreatePublishJobAccountRequest { + account_id: string; + mode: "auto" | "manual"; +} + +export interface CreatePublishJobRequest { + title: string; + content_ref: Record; + accounts: CreatePublishJobAccountRequest[]; + scheduled_at?: string | null; +} + +export interface CreatePublishJobResponse { + job_id: string; + task_ids: string[]; +} + export interface WorkspaceOverview { article_count: number; published_count: number; diff --git a/packages/ui-shared/package.json b/packages/ui-shared/package.json new file mode 100644 index 0000000..167933b --- /dev/null +++ b/packages/ui-shared/package.json @@ -0,0 +1,17 @@ +{ + "name": "@geo/ui-shared", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./tokens": "./src/tokens/index.ts", + "./tokens.css": "./src/tokens/index.css", + "./composables": "./src/composables/index.ts", + "./components": "./src/components/index.ts" + }, + "peerDependencies": { + "ant-design-vue": "^4.2.6", + "vue": "^3.5.31" + } +} diff --git a/packages/ui-shared/src/components/index.ts b/packages/ui-shared/src/components/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/packages/ui-shared/src/components/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/ui-shared/src/composables/index.ts b/packages/ui-shared/src/composables/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/packages/ui-shared/src/composables/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/ui-shared/src/index.ts b/packages/ui-shared/src/index.ts new file mode 100644 index 0000000..d7a4850 --- /dev/null +++ b/packages/ui-shared/src/index.ts @@ -0,0 +1,3 @@ +export * from "./tokens/index"; +export * from "./composables/index"; +export * from "./components/index"; diff --git a/packages/ui-shared/src/tokens/index.css b/packages/ui-shared/src/tokens/index.css new file mode 100644 index 0000000..4c91197 --- /dev/null +++ b/packages/ui-shared/src/tokens/index.css @@ -0,0 +1,98 @@ +:root { + --geo-font-sans: "IBM Plex Sans", "Aptos", "Segoe UI Variable", "Segoe UI", sans-serif; + --geo-font-mono: "JetBrains Mono", "SFMono-Regular", "Cascadia Code", "Consolas", monospace; + --geo-color-bg-primary: #eef3f8; + --geo-color-bg-secondary: #e4ebf3; + --geo-color-bg-tertiary: #d6e1ec; + --geo-color-bg-elevated: rgba(255, 255, 255, 0.78); + --geo-color-bg-panel: rgba(247, 250, 253, 0.86); + --geo-color-text-primary: #111827; + --geo-color-text-secondary: #4b5563; + --geo-color-text-tertiary: #7b8794; + --geo-color-border: rgba(15, 23, 42, 0.08); + --geo-color-border-strong: rgba(15, 23, 42, 0.14); + --geo-color-brand: #0f766e; + --geo-color-brand-strong: #115e59; + --geo-color-brand-soft: rgba(15, 118, 110, 0.12); + --geo-color-success: #15803d; + --geo-color-success-soft: rgba(21, 128, 61, 0.12); + --geo-color-warn: #b45309; + --geo-color-warn-soft: rgba(180, 83, 9, 0.12); + --geo-color-danger: #b91c1c; + --geo-color-danger-soft: rgba(185, 28, 28, 0.12); + --geo-color-info: #2563eb; + --geo-color-info-soft: rgba(37, 99, 235, 0.12); + --geo-radius-sm: 12px; + --geo-radius-md: 20px; + --geo-radius-lg: 32px; + --geo-shadow-soft: 0 28px 60px rgba(15, 23, 42, 0.12); + --geo-shadow-card: 0 18px 36px rgba(15, 23, 42, 0.08); + --geo-shadow-edge: inset 0 1px 0 rgba(255, 255, 255, 0.6); +} + +html[data-theme="dark"] { + --geo-color-bg-primary: #0a1015; + --geo-color-bg-secondary: #101820; + --geo-color-bg-tertiary: #16212c; + --geo-color-bg-elevated: rgba(16, 24, 32, 0.82); + --geo-color-bg-panel: rgba(10, 16, 21, 0.76); + --geo-color-text-primary: #ecf3fb; + --geo-color-text-secondary: #aebac7; + --geo-color-text-tertiary: #708090; + --geo-color-border: rgba(226, 232, 240, 0.08); + --geo-color-border-strong: rgba(226, 232, 240, 0.16); + --geo-color-brand: #46b7ab; + --geo-color-brand-strong: #63d0c4; + --geo-color-brand-soft: rgba(70, 183, 171, 0.14); + --geo-color-success: #5dd39e; + --geo-color-success-soft: rgba(93, 211, 158, 0.12); + --geo-color-warn: #f2a65a; + --geo-color-warn-soft: rgba(242, 166, 90, 0.14); + --geo-color-danger: #f36b7f; + --geo-color-danger-soft: rgba(243, 107, 127, 0.14); + --geo-color-info: #7aa2ff; + --geo-color-info-soft: rgba(122, 162, 255, 0.14); + --geo-shadow-soft: 0 32px 72px rgba(0, 0, 0, 0.42); + --geo-shadow-card: 0 18px 42px rgba(0, 0, 0, 0.28); + --geo-shadow-edge: inset 0 1px 0 rgba(255, 255, 255, 0.04); +} + +html, +body, +#app { + min-height: 100%; +} + +html { + color-scheme: light; +} + +html[data-theme="dark"] { + color-scheme: dark; +} + +body { + margin: 0; + font-family: var(--geo-font-sans); + background: + radial-gradient(circle at top left, rgba(15, 118, 110, 0.14), transparent 32%), + radial-gradient(circle at top right, rgba(37, 99, 235, 0.08), transparent 26%), + linear-gradient(180deg, var(--geo-color-bg-primary), var(--geo-color-bg-secondary)); + color: var(--geo-color-text-primary); +} + +* { + box-sizing: border-box; +} + +button, +input, +textarea, +select { + font: inherit; +} + +code, +pre { + font-family: var(--geo-font-mono); +} diff --git a/packages/ui-shared/src/tokens/index.ts b/packages/ui-shared/src/tokens/index.ts new file mode 100644 index 0000000..a328a9f --- /dev/null +++ b/packages/ui-shared/src/tokens/index.ts @@ -0,0 +1,13 @@ +export const themeTokens = { + colorBgPrimary: "var(--geo-color-bg-primary)", + colorBgSecondary: "var(--geo-color-bg-secondary)", + colorTextPrimary: "var(--geo-color-text-primary)", + colorTextSecondary: "var(--geo-color-text-secondary)", + colorBorder: "var(--geo-color-border)", + colorBrand: "var(--geo-color-brand)", + colorSuccess: "var(--geo-color-success)", + colorWarn: "var(--geo-color-warn)", + colorDanger: "var(--geo-color-danger)", +} as const; + +export type ThemeTokenKey = keyof typeof themeTokens; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 27415a9..95aba23 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,9 +34,6 @@ importers: '@milkdown/vue': specifier: ^7.20.0 version: 7.20.0(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.7)(typescript@5.9.3)(vue@3.5.31(typescript@5.9.3)) - nanoid: - specifier: ^5.1.7 - version: 5.1.7 '@tanstack/vue-query': specifier: ^5.96.0 version: 5.96.0(vue@3.5.31(typescript@5.9.3)) @@ -55,6 +52,9 @@ importers: markdown-it: specifier: ^14.1.1 version: 14.1.1 + nanoid: + specifier: ^5.1.7 + version: 5.1.7 pinia: specifier: ^3.0.4 version: 3.0.4(typescript@5.9.3)(vue@3.5.31(typescript@5.9.3)) @@ -110,7 +110,7 @@ importers: version: 0.1.38 '@wxt-dev/module-vue': specifier: ^1.0.3 - version: 1.0.3(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(esbuild@0.27.5)(jiti@2.6.1))(vue@3.5.31(typescript@5.9.3))(wxt@0.20.20(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(jiti@2.6.1)) + version: 1.0.3(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(esbuild@0.27.5)(jiti@2.6.1))(vue@3.5.31(typescript@5.9.3))(wxt@0.20.20(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(jiti@2.6.1)(rollup@4.60.2)) typescript: specifier: ^5.9.3 version: 5.9.3 @@ -119,7 +119,71 @@ importers: version: 3.2.6(typescript@5.9.3) wxt: specifier: ^0.20.20 - version: 0.20.20(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(jiti@2.6.1) + version: 0.20.20(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(jiti@2.6.1)(rollup@4.60.2) + + apps/desktop-client: + dependencies: + '@ant-design/icons-vue': + specifier: ^7.0.1 + version: 7.0.1(vue@3.5.31(typescript@5.9.3)) + '@geo/http-client': + specifier: workspace:* + version: link:../../packages/http-client + '@geo/shared-types': + specifier: workspace:* + version: link:../../packages/shared-types + '@geo/ui-shared': + specifier: workspace:* + version: link:../../packages/ui-shared + ant-design-vue: + specifier: ^4.2.6 + version: 4.2.6(vue@3.5.31(typescript@5.9.3)) + better-sqlite3: + specifier: ^12.8.0 + version: 12.9.0 + electron-updater: + specifier: ^6.0.0 + version: 6.8.3 + pino: + specifier: ^9.0.0 + version: 9.7.0 + vue: + specifier: ^3.5.31 + version: 3.5.31(typescript@5.9.3) + vue-router: + specifier: ^4.5.1 + version: 4.6.4(vue@3.5.31(typescript@5.9.3)) + devDependencies: + '@playwright/test': + specifier: ^1.0.0 + version: 1.59.1 + '@types/node': + specifier: ^24.0.0 + version: 24.12.0 + '@vitejs/plugin-vue': + specifier: ^5.2.4 + version: 5.2.4(vite@5.4.21(@types/node@24.12.0)(lightningcss@1.32.0))(vue@3.5.31(typescript@5.9.3)) + electron: + specifier: 41.2.0 + version: 41.2.0 + electron-builder: + specifier: ^25.0.0 + version: 25.1.8(electron-builder-squirrel-windows@25.1.8) + electron-vite: + specifier: ^2.0.0 + version: 2.3.0(vite@5.4.21(@types/node@24.12.0)(lightningcss@1.32.0)) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: ^5.4.19 + version: 5.4.21(@types/node@24.12.0)(lightningcss@1.32.0) + vitest: + specifier: ^2.0.0 + version: 2.1.9(@types/node@24.12.0)(lightningcss@1.32.0) + vue-tsc: + specifier: ^3.2.6 + version: 3.2.6(typescript@5.9.3) packages/http-client: dependencies: @@ -132,8 +196,20 @@ importers: packages/shared-types: {} + packages/ui-shared: + dependencies: + ant-design-vue: + specifier: ^4.2.6 + version: 4.2.6(vue@3.5.31(typescript@5.9.3)) + vue: + specifier: ^3.5.31 + version: 3.5.31(typescript@5.9.3) + packages: + 7zip-bin@5.2.0: + resolution: {integrity: sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==} + '@1natsu/wait-element@4.1.2': resolution: {integrity: sha512-qWxSJD+Q5b8bKOvESFifvfZ92DuMsY+03SBNjTO34ipJLP6mZ9yK4bQz/vlh48aEQXoJfaZBqUwKL5BdI5iiWw==} @@ -162,6 +238,40 @@ packages: resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} @@ -170,11 +280,25 @@ packages: resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + engines: {node: '>=6.9.0'} + '@babel/parser@7.29.2': resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} engines: {node: '>=6.0.0'} hasBin: true + '@babel/plugin-transform-arrow-functions@7.27.1': + resolution: {integrity: sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/runtime@7.28.2': resolution: {integrity: sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA==} engines: {node: '>=6.9.0'} @@ -183,6 +307,14 @@ packages: resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} @@ -284,6 +416,10 @@ packages: resolution: {integrity: sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==} engines: {node: '>=10'} + '@develar/schema-utils@2.6.5': + resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==} + engines: {node: '>= 8.9.0'} + '@devicefarmer/adbkit-logcat@2.1.3': resolution: {integrity: sha512-yeaGFjNBc/6+svbDeul1tNHtNChw6h8pSHAt5D+JsedUrMTN7tla7B15WLDyekxsuS2XlZHRxpuC6m92wiwCNw==} engines: {node: '>= 4'} @@ -297,6 +433,33 @@ packages: engines: {node: '>= 0.10.4'} hasBin: true + '@electron/asar@3.4.1': + resolution: {integrity: sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==} + engines: {node: '>=10.12.0'} + hasBin: true + + '@electron/get@2.0.3': + resolution: {integrity: sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==} + engines: {node: '>=12'} + + '@electron/notarize@2.5.0': + resolution: {integrity: sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==} + engines: {node: '>= 10.0.0'} + + '@electron/osx-sign@1.3.1': + resolution: {integrity: sha512-BAfviURMHpmb1Yb50YbCxnOY0wfwaLXH5KJ4+80zS0gUkzDX3ec23naTlEqKsN+PwYn+a1cCzM7BJ4Wcd3sGzw==} + engines: {node: '>=12.0.0'} + hasBin: true + + '@electron/rebuild@3.6.1': + resolution: {integrity: sha512-f6596ZHpEq/YskUd8emYvOUne89ij8mQgjYFA5ru25QwbrRO+t1SImofdDv7kKOuWCmVOuU5tvfkbgGxIl3E/w==} + engines: {node: '>=12.13.0'} + hasBin: true + + '@electron/universal@2.0.1': + resolution: {integrity: sha512-fKpv9kg4SPmt+hY7SVBnIYULE9QJl8L3sCfcBsnqbJwwBwAeTLokJ9TRt9y7bK0JAzIW2y78TVVjvnQEms/yyA==} + engines: {node: '>=16.4'} + '@emnapi/core@1.9.1': resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==} @@ -312,102 +475,204 @@ packages: '@emotion/unitless@0.8.1': resolution: {integrity: sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==} + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.27.5': resolution: {integrity: sha512-nGsF/4C7uzUj+Nj/4J+Zt0bYQ6bz33Phz8Lb2N80Mti1HjGclTJdXZ+9APC4kLvONbjxN1zfvYNd8FEcbBK/MQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.27.5': resolution: {integrity: sha512-Oeghq+XFgh1pUGd1YKs4DDoxzxkoUkvko+T/IVKwlghKLvvjbGFB3ek8VEDBmNvqhwuL0CQS3cExdzpmUyIrgA==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.27.5': resolution: {integrity: sha512-Cv781jd0Rfj/paoNrul1/r4G0HLvuFKYh7C9uHZ2Pl8YXstzvCyyeWENTFR9qFnRzNMCjXmsulZuvosDg10Mog==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.27.5': resolution: {integrity: sha512-nQD7lspbzerlmtNOxYMFAGmhxgzn8Z7m9jgFkh6kpkjsAhZee1w8tJW3ZlW+N9iRePz0oPUDrYrXidCPSImD0Q==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.27.5': resolution: {integrity: sha512-I+Ya/MgC6rr8oRWGRDF3BXDfP8K1BVUggHqN6VI2lUZLdDi1IM1v2cy0e3lCPbP+pVcK3Tv8cgUhHse1kaNZZw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.27.5': resolution: {integrity: sha512-MCjQUtC8wWJn/pIPM7vQaO69BFgwPD1jriEdqwTCKzWjGgkMbcg+M5HzrOhPhuYe1AJjXlHmD142KQf+jnYj8A==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.27.5': resolution: {integrity: sha512-X6xVS+goSH0UelYXnuf4GHLwpOdc8rgK/zai+dKzBMnncw7BTQIwquOodE7EKvY2UVUetSqyAfyZC1D+oqLQtg==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.27.5': resolution: {integrity: sha512-233X1FGo3a8x1ekLB6XT69LfZ83vqz+9z3TSEQCTYfMNY880A97nr81KbPcAMl9rmOFp11wO0dP+eB18KU/Ucg==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.27.5': resolution: {integrity: sha512-euKkilsNOv7x/M1NKsx5znyprbpsRFIzTV6lWziqJch7yWYayfLtZzDxDTl+LSQDJYAjd9TVb/Kt5UKIrj2e4A==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.27.5': resolution: {integrity: sha512-0wkVrYHG4sdCCN/bcwQ7yYMXACkaHc3UFeaEOwSVW6e5RycMageYAFv+JS2bKLwHyeKVUvtoVH+5/RHq0fgeFw==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.27.5': resolution: {integrity: sha512-hVRQX4+P3MS36NxOy24v/Cdsimy/5HYePw+tmPqnNN1fxV0bPrFWR6TMqwXPwoTM2VzbkA+4lbHWUKDd5ZDA/w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.27.5': resolution: {integrity: sha512-mKqqRuOPALI8nDzhOBmIS0INvZOOFGGg5n1osGIXAx8oersceEbKd4t1ACNTHM3sJBXGFAlEgqM+svzjPot+ZQ==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.27.5': resolution: {integrity: sha512-EE/QXH9IyaAj1qeuIV5+/GZkBTipgGO782Ff7Um3vPS9cvLhJJeATy4Ggxikz2inZ46KByamMn6GqtqyVjhenA==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.27.5': resolution: {integrity: sha512-0V2iF1RGxBf1b7/BjurA5jfkl7PtySjom1r6xOK2q9KWw/XCpAdtB6KNMO+9xx69yYfSCRR9FE0TyKfHA2eQMw==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.27.5': resolution: {integrity: sha512-rYxThBx6G9HN6tFNuvB/vykeLi4VDsm5hE5pVwzqbAjZEARQrWu3noZSfbEnPZ/CRXP3271GyFk/49up2W190g==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.27.5': resolution: {integrity: sha512-uEP2q/4qgd8goEUc4QIdU/1P2NmEtZ/zX5u3OpLlCGhJIuBIv0s0wr7TB2nBrd3/A5XIdEkkS5ZLF0ULuvaaYQ==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.27.5': resolution: {integrity: sha512-+Gq47Wqq6PLOOZuBzVSII2//9yyHNKZLuwfzCemqexqOQCSz0zy0O26kIzyp9EMNMK+nZ0tFHBZrCeVUuMs/ew==} engines: {node: '>=18'} @@ -420,6 +685,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.27.5': resolution: {integrity: sha512-28t+Sj3CPN8vkMOlZotOmDgilQwVvxWZl7b8rxpn73Tt/gCnvrHxQUMng4uu3itdFvrtba/1nHejvxqz8xgEMA==} engines: {node: '>=18'} @@ -432,6 +703,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.27.5': resolution: {integrity: sha512-WfGVaa1oz5A7+ZFPkERIbIhKT4olvGl1tyzTRaB5yoZRLqC0KwaO95FeZtOdQj/oKkjW57KcVF944m62/0GYtA==} engines: {node: '>=18'} @@ -444,24 +721,48 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.27.5': resolution: {integrity: sha512-aC1gpJkkaUADHuAdQfuVTnqVUTLqqUNhAvEwHwVWcnVVZvNlDPGA0UveZsfXJJ9T6k9Po4eHi3c02gbdwO3g6w==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.27.5': resolution: {integrity: sha512-0UNx2aavV0fk6UpZcwXFLztA2r/k9jTUa7OW7SAea1VYUhkug99MW1uZeXEnPn5+cHOd0n8myQay6TlFnBR07w==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.27.5': resolution: {integrity: sha512-5nlJ3AeJWCTSzR7AEqVjT/faWyqKU86kCi1lLmxVqmNR+j4HrYdns+eTGjS/vmrzCIe8inGQckUadvS0+JkKdQ==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.27.5': resolution: {integrity: sha512-PWypQR+d4FLfkhBIV+/kHsUELAnMpx1bRvvsn3p+/sAERbnCzFrtDRG2Xw5n+2zPxBK2+iaP+vetsRl4Ti7WgA==} engines: {node: '>=18'} @@ -477,6 +778,9 @@ packages: '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@gar/promisify@1.1.3': + resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} + '@intlify/core-base@10.0.8': resolution: {integrity: sha512-FoHslNWSoHjdUBLy35bpm9PV/0LVI/DSv9L6Km6J2ad8r/mm0VaGg06C40FqlE8u2ADcGUM60lyoU7Myo4WNZQ==} engines: {node: '>= 16'} @@ -489,6 +793,10 @@ packages: resolution: {integrity: sha512-BcmHpb5bQyeVNrptC3UhzpBZB/YHHDoEREOUERrmF2BRxsyOEuRrq+Z96C/D4+2KJb8kuHiouzAei7BXlG0YYw==} engines: {node: '>= 16'} + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -559,6 +867,14 @@ packages: '@lezer/yaml@1.0.4': resolution: {integrity: sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==} + '@malept/cross-spawn-promise@2.0.0': + resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==} + engines: {node: '>= 12.13.0'} + + '@malept/flatpak-bundler@0.4.0': + resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} + engines: {node: '>= 10.0.0'} + '@marijn/find-cluster-break@1.0.2': resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} @@ -643,12 +959,30 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@npmcli/fs@2.1.2': + resolution: {integrity: sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + '@npmcli/move-file@2.0.1': + resolution: {integrity: sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + deprecated: This functionality has been moved to @npmcli/fs + '@ocavue/utils@1.6.0': resolution: {integrity: sha512-8W3q1hxx9qFdrYgPtbElllG/tqYkO/dMhlRUiqasO0SuDFTj78azSQjhIrBTFWxlBPPsSZN6zXYHmb3RwN2Jtg==} '@oxc-project/types@0.122.0': resolution: {integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@playwright/test@1.59.1': + resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} + engines: {node: '>=18'} + hasBin: true + '@pnpm/config.env-replace@1.1.0': resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==} engines: {node: '>=12.22.0'} @@ -762,9 +1096,155 @@ packages: '@rolldown/pluginutils@1.0.0-rc.2': resolution: {integrity: sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==} + '@rollup/rollup-android-arm-eabi@4.60.2': + resolution: {integrity: sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.2': + resolution: {integrity: sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.2': + resolution: {integrity: sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.2': + resolution: {integrity: sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.2': + resolution: {integrity: sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.2': + resolution: {integrity: sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.2': + resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.2': + resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.2': + resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.2': + resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.2': + resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.2': + resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.2': + resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.2': + resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.2': + resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.2': + resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.2': + resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.2': + resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.2': + resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.2': + resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.2': + resolution: {integrity: sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.2': + resolution: {integrity: sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.2': + resolution: {integrity: sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.2': + resolution: {integrity: sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.2': + resolution: {integrity: sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==} + cpu: [x64] + os: [win32] + '@simonwep/pickr@1.8.2': resolution: {integrity: sha512-/l5w8BIkrpP6n1xsetx9MWPWlU6OblN5YgZZphxan0Tq4BByTCETL6lyIeY8lagalS2Nbt4F2W034KHLIiunKA==} + '@sindresorhus/is@4.6.0': + resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} + engines: {node: '>=10'} + + '@szmarczak/http-timer@4.0.6': + resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} + engines: {node: '>=10'} + '@tanstack/match-sorter-utils@8.19.4': resolution: {integrity: sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==} engines: {node: '>=12'} @@ -781,9 +1261,16 @@ packages: '@vue/composition-api': optional: true + '@tootallnate/once@2.0.0': + resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} + engines: {node: '>= 10'} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/cacheable-request@6.0.3': + resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} + '@types/chrome@0.1.38': resolution: {integrity: sha512-5aK4m9wZqoWAoB98aElESLm/5pXpqJnFWMNoiCs/XdPsXR6wNdVkJFSdQ9Wr4PnTuUrxD0SuNuDHh3EG5QeBzA==} @@ -799,15 +1286,24 @@ packages: '@types/filewriter@0.0.33': resolution: {integrity: sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==} + '@types/fs-extra@9.0.13': + resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} + '@types/har-format@1.2.16': resolution: {integrity: sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==} '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/http-cache-semantics@4.2.0': + resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} + '@types/katex@0.16.8': resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} + '@types/keyv@3.1.4': + resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} + '@types/linkify-it@5.0.0': resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} @@ -835,15 +1331,34 @@ packages: '@types/node@24.12.0': resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==} + '@types/plist@3.0.5': + resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==} + + '@types/responselike@1.0.3': + resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/verror@1.10.11': + resolution: {integrity: sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==} + '@types/web-bluetooth@0.0.21': resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + '@types/yauzl@2.10.3': + resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + + '@vitejs/plugin-vue@5.2.4': + resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 + vue: ^3.2.25 + '@vitejs/plugin-vue@6.0.5': resolution: {integrity: sha512-bL3AxKuQySfk1iGcBsQnoRVexTPJq0Z/ixFVM8OhVJAP6ZXXXLtM7NFKWhLl30Kg7uTBqIaPXbh+nuQCuBDedg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -851,6 +1366,35 @@ packages: vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 vue: ^3.2.25 + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + '@volar/language-core@2.4.28': resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} @@ -937,6 +1481,13 @@ packages: '@wxt-dev/storage@1.2.8': resolution: {integrity: sha512-GWCFKgF5+d7eslOxUDFC70ypA9njupmJb1nQM8uZoX0J3sWT2BO5xJLzb1sYahWAfID9p2BMtnUBN1lkWxPsbQ==} + '@xmldom/xmldom@0.8.13': + resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} + engines: {node: '>=10.0.0'} + + abbrev@1.1.1: + resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} + acorn@8.16.0: resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} @@ -946,6 +1497,30 @@ packages: resolution: {integrity: sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==} engines: {node: '>=12.0'} + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + agentkeepalive@4.6.0: + resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} + engines: {node: '>= 8.0.0'} + + aggregate-error@3.1.0: + resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} + engines: {node: '>=8'} + + ajv-keywords@3.5.2: + resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} + peerDependencies: + ajv: ^6.9.1 + + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + alien-signals@3.1.2: resolution: {integrity: sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==} @@ -978,6 +1553,36 @@ packages: peerDependencies: vue: '>=3.2.0' + app-builder-bin@5.0.0-alpha.10: + resolution: {integrity: sha512-Ev4jj3D7Bo+O0GPD2NMvJl+PGiBAfS7pUGawntBNpCbxtpncfUixqFj9z9Jme7V7s3LBGqsWZZP54fxBX3JKJw==} + + app-builder-lib@25.1.8: + resolution: {integrity: sha512-pCqe7dfsQFBABC1jeKZXQWhGcCPF3rPCXDdfqVKjIeWBcXzyC1iOWZdfFhGl+S9MyE/k//DFmC6FzuGAUudNDg==} + engines: {node: '>=14.0.0'} + peerDependencies: + dmg-builder: 25.1.8 + electron-builder-squirrel-windows: 25.1.8 + + aproba@2.1.0: + resolution: {integrity: sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==} + + archiver-utils@2.1.0: + resolution: {integrity: sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==} + engines: {node: '>= 6'} + + archiver-utils@3.0.4: + resolution: {integrity: sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==} + engines: {node: '>= 10'} + + archiver@5.3.2: + resolution: {integrity: sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==} + engines: {node: '>= 10'} + + are-we-there-yet@3.0.1: + resolution: {integrity: sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + deprecated: This package is no longer supported. + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -992,6 +1597,22 @@ packages: resolution: {integrity: sha512-1OvF9IbWwaeiM9VhzYXVQacMibxpXOMYVNIvMtKRyX9SImBXpKcFr8XvFDeEslCyuH/t6KRt7HEO94AlP8Iatw==} engines: {node: '>=12'} + assert-plus@1.0.0: + resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} + engines: {node: '>=0.8'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + astral-regex@2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} + + async-exit-hook@2.0.1: + resolution: {integrity: sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==} + engines: {node: '>=0.12.0'} + async-mutex@0.5.0: resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==} @@ -1004,6 +1625,10 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + at-least-node@1.0.0: + resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} + engines: {node: '>= 4.0.0'} + atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} @@ -1024,15 +1649,40 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + baseline-browser-mapping@2.10.20: + resolution: {integrity: sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + better-sqlite3@12.9.0: + resolution: {integrity: sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x} + + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + birpc@2.9.0: resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + bluebird-lst@1.0.9: + resolution: {integrity: sha512-7B1Rtx82hjnSD4PGLAjVWeYH3tHAcVUmChh85a3lltKQm6FresXh9ErQo6oAv6CqxttczC3/kEg8SY5NluPuUw==} + bluebird@3.7.2: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + boolean@3.2.0: + resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + boxen@8.0.1: resolution: {integrity: sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==} engines: {node: '>=18'} @@ -1040,16 +1690,41 @@ packages: brace-expansion@1.1.13: resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} + brace-expansion@2.1.0: + resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} + brace-expansion@5.0.5: resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} engines: {node: 18 || 20 || >=22} + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + builder-util-runtime@9.2.10: + resolution: {integrity: sha512-6p/gfG1RJSQeIbz8TK5aPNkoztgY1q5TgmGFMAXcY8itsGW6Y2ld1ALsZ5UJn8rog7hKF3zHx5iQbNQ8uLcRlw==} + engines: {node: '>=12.0.0'} + + builder-util-runtime@9.5.1: + resolution: {integrity: sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==} + engines: {node: '>=12.0.0'} + + builder-util@25.1.7: + resolution: {integrity: sha512-7jPjzBwEGRbwNcep0gGNpLXG9P94VA3CPAZQCzxkFXiV2GMQKlziMbY//rXPI7WKfhsvGgFXjTcXdBEwgXw9ww==} + bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} @@ -1070,6 +1745,18 @@ packages: resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} engines: {node: '>=20.19.0'} + cacache@16.1.3: + resolution: {integrity: sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + cacheable-lookup@5.0.4: + resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==} + engines: {node: '>=10.6.0'} + + cacheable-request@7.0.4: + resolution: {integrity: sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==} + engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -1078,9 +1765,20 @@ packages: resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} engines: {node: '>=16'} + caniuse-lite@1.0.30001788: + resolution: {integrity: sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + chalk@5.6.2: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} @@ -1088,15 +1786,33 @@ packages: character-entities@2.0.2: resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + chokidar@5.0.0: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + chownr@2.0.0: + resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} + engines: {node: '>=10'} + chrome-launcher@1.2.0: resolution: {integrity: sha512-JbuGuBNss258bvGil7FT4HKdC3SC2K7UAEUqiPy3ACS3Yxo3hAW6bvFpCu2HsIJLgTqxgEX6BkujvzZfLpUD0Q==} engines: {node: '>=12.13.0'} hasBin: true + chromium-pickle-js@0.2.0: + resolution: {integrity: sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==} + + ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + ci-info@4.4.0: resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} engines: {node: '>=8'} @@ -1104,14 +1820,30 @@ packages: citty@0.2.2: resolution: {integrity: sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==} + clean-stack@2.2.0: + resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} + engines: {node: '>=6'} + cli-boxes@3.0.0: resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} engines: {node: '>=10'} + cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cli-truncate@2.1.0: + resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} + engines: {node: '>=8'} + cli-truncate@5.2.0: resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} engines: {node: '>=20'} @@ -1120,6 +1852,13 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} + clone-response@1.0.3: + resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==} + + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -1134,6 +1873,10 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + color-support@1.1.3: + resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} + hasBin: true + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -1142,6 +1885,10 @@ packages: resolution: {integrity: sha512-bmkUukX8wAOjHdN26xj5c4ctEV22TQ7dQYhSmuckKhToXrkUn0iIaolHdIxYYqD55nhpSPA9zPQ1yP57GdXP2A==} engines: {node: '>= 0.6.x'} + commander@5.1.0: + resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} + engines: {node: '>= 6'} + commander@8.3.0: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} @@ -1150,6 +1897,14 @@ packages: resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} engines: {node: ^12.20.0 || >=14} + compare-version@0.1.2: + resolution: {integrity: sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==} + engines: {node: '>=0.10.0'} + + compress-commons@4.1.2: + resolution: {integrity: sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==} + engines: {node: '>= 10'} + compute-scroll-into-view@1.0.20: resolution: {integrity: sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==} @@ -1169,6 +1924,9 @@ packages: config-chain@1.1.13: resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} + config-file-ts@0.2.8-rc1: + resolution: {integrity: sha512-GtNECbVI82bT4RiDIzBSVuTKoSHufnU7Ce7/42bkWZJZFLjmDF2WBpVsvRkhKCfKBnTBb3qZrBwPpFBU/Myvhg==} + configstore@7.1.0: resolution: {integrity: sha512-N4oog6YJWbR9kGyXvS7jEykLDXIE2C0ILYqNBZBp9iwiJpoCBWYsuAdW6PPFn6w06jjnC+3JstVvWHO4cZqvRg==} engines: {node: '>=18'} @@ -1177,6 +1935,12 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + console-control-strings@1.1.0: + resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + copy-anything@4.0.5: resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} engines: {node: '>=18'} @@ -1184,12 +1948,31 @@ packages: core-js@3.49.0: resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} + core-util-is@1.0.2: + resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + + crc32-stream@4.0.3: + resolution: {integrity: sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==} + engines: {node: '>= 10'} + + crc@3.8.0: + resolution: {integrity: sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==} + crelt@1.0.6: resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + css-select@5.2.2: resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} @@ -1230,6 +2013,14 @@ packages: decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -1242,6 +2033,17 @@ packages: resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} engines: {node: '>=18'} + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + + defer-to-connect@2.0.1: + resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} + engines: {node: '>=10'} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + define-lazy-prop@2.0.0: resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} engines: {node: '>=8'} @@ -1250,6 +2052,10 @@ packages: resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} engines: {node: '>=12'} + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + defu@6.1.6: resolution: {integrity: sha512-f8mefEW4WIVg4LckePx3mALjQSPQgFlg9U8yaPdlsbdYcHQyj9n2zL2LJEA52smeYxOvmd/nB7TpMtHGMTHcug==} @@ -1257,6 +2063,9 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + delegates@1.0.0: + resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -1268,9 +2077,24 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + detect-node@2.1.0: + resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dir-compare@4.2.0: + resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} + + dmg-builder@25.1.8: + resolution: {integrity: sha512-NoXo6Liy2heSklTI5OIZbCgXC1RzrDQsZkeEwXhdOro3FT1VBOvbubvscdPnjVuQ4AMwwv61oaH96AbiYg9EnQ==} + + dmg-license@1.0.11: + resolution: {integrity: sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==} + engines: {node: '>=8'} + os: [darwin] + hasBin: true + dom-align@1.12.4: resolution: {integrity: sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw==} @@ -1297,6 +2121,10 @@ packages: resolution: {integrity: sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==} engines: {node: '>=18'} + dotenv-expand@11.0.7: + resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==} + engines: {node: '>=12'} + dotenv-expand@12.0.3: resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==} engines: {node: '>=12'} @@ -1313,15 +2141,65 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ejs@3.1.10: + resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} + engines: {node: '>=0.10.0'} + hasBin: true + + electron-builder-squirrel-windows@25.1.8: + resolution: {integrity: sha512-2ntkJ+9+0GFP6nAISiMabKt6eqBB0kX1QqHNWFWAXgi0VULKGisM46luRFpIBiU3u/TDmhZMM8tzvo2Abn3ayg==} + + electron-builder@25.1.8: + resolution: {integrity: sha512-poRgAtUHHOnlzZnc9PK4nzG53xh74wj2Jy7jkTrqZ0MWPoHGh1M2+C//hGeYdA+4K8w4yiVCNYoLXF7ySj2Wig==} + engines: {node: '>=14.0.0'} + hasBin: true + + electron-publish@25.1.7: + resolution: {integrity: sha512-+jbTkR9m39eDBMP4gfbqglDd6UvBC7RLh5Y0MhFSsc6UkGHj9Vj9TWobxevHYMMqmoujL11ZLjfPpMX+Pt6YEg==} + + electron-to-chromium@1.5.340: + resolution: {integrity: sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA==} + + electron-updater@6.8.3: + resolution: {integrity: sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ==} + + electron-vite@2.3.0: + resolution: {integrity: sha512-lsN2FymgJlp4k6MrcsphGqZQ9fKRdJKasoaiwIrAewN1tapYI/KINLdfEL7n10LuF0pPSNf/IqjzZbB5VINctg==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@swc/core': ^1.0.0 + vite: ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + '@swc/core': + optional: true + + electron@41.2.0: + resolution: {integrity: sha512-0OKLiymqfV0WK68RBXqAm3Myad2TpI5wwxLCBEUcH5Nugo3YfSk7p1Js/AL9266qTz5xZioUnxt9hG8FFwax0g==} + engines: {node: '>= 12.20.55'} + hasBin: true + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + encoding@0.1.13: + resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -1330,10 +2208,17 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} + err-code@2.0.3: + resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -1345,6 +2230,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.0.0: resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} @@ -1359,6 +2247,11 @@ packages: es6-error@4.1.1: resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.27.5: resolution: {integrity: sha512-zdQoHBjuDqKsvV5OPaWansOwfSQ0Js+Uj9J85TBvj3bFW1JjWTSULMRwdQAc8qMeIScbClxeMK0jlrtB9linhA==} engines: {node: '>=18'} @@ -1389,16 +2282,45 @@ packages: eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + exponential-backoff@3.1.3: + resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + extract-zip@2.0.1: + resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} + engines: {node: '>= 10.17.0'} + hasBin: true + + extsprintf@1.4.1: + resolution: {integrity: sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==} + engines: {'0': node >=0.6.0} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + fast-redact@3.5.0: resolution: {integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==} engines: {node: '>=6'} + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1408,6 +2330,12 @@ packages: picomatch: optional: true + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + + filelist@1.0.6: + resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} + filesize@11.0.15: resolution: {integrity: sha512-30TpbYxQxCpi4XdVjkwXYQ37CzZltV38+P7MYroQ+4NK/Dmx9mxixFNrolzcmEIBsjT/uowC9T7kiy2+C12r1A==} engines: {node: '>= 10.8.0'} @@ -1426,6 +2354,10 @@ packages: debug: optional: true + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + form-data-encoder@4.1.0: resolution: {integrity: sha512-G6NsmEW15s0Uw9XnCg+33H3ViYRyiM0hMrMhhqQOR8NFc5GhYrI+6I3u7OTw7b91J2g8rtvMBZJDbcGb2YUniw==} engines: {node: '>= 18'} @@ -1438,10 +2370,37 @@ packages: resolution: {integrity: sha512-8e1++BCiTzUno9v5IZ2J6bv4RU+3UKDmqWUQD0MIMVCd9AdhWkO1gw57oo1mNEX1dMq2EGI+FbWz4B92pscSQg==} engines: {node: '>= 18'} + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fs-extra@10.1.0: + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} + engines: {node: '>=12'} + fs-extra@11.3.4: resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==} engines: {node: '>=14.14'} + fs-extra@8.1.0: + resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} + engines: {node: '>=6 <7 || >=8'} + + fs-extra@9.1.0: + resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} + engines: {node: '>=10'} + + fs-minipass@2.1.0: + resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} + engines: {node: '>= 8'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1454,6 +2413,15 @@ packages: resolution: {integrity: sha512-rci1g6U0rdTg6bAaBboP7XdRu01dzTAaKXxFf+PUqGuCv6Xu7o8NZdY1D5MvKGIjb6EdS1g3VlXOgksir1uGkg==} hasBin: true + gauge@4.0.4: + resolution: {integrity: sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + deprecated: This package is no longer supported. + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} @@ -1473,21 +2441,54 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + giget@3.2.0: resolution: {integrity: sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A==} hasBin: true + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + glob-to-regexp@0.4.1: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + glob@8.1.0: + resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} + engines: {node: '>=12'} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + global-agent@3.0.0: + resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} + engines: {node: '>=10.0'} + global-directory@4.0.1: resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} engines: {node: '>=18'} + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + got@11.8.6: + resolution: {integrity: sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==} + engines: {node: '>=10.19.0'} + graceful-fs@4.2.10: resolution: {integrity: sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==} @@ -1500,6 +2501,13 @@ packages: growly@1.3.0: resolution: {integrity: sha512-+xGQY0YyAWCnqy7Cd++hc2JqMYzlm0dG30Jd0beaA64sROr8C4nt8Yc9V5Ro3avlSUDTN0ulqP/VBKi1/lLygw==} + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -1508,6 +2516,9 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} + has-unicode@2.0.1: + resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -1518,18 +2529,75 @@ packages: hookable@6.1.0: resolution: {integrity: sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw==} + hosted-git-info@4.1.0: + resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} + engines: {node: '>=10'} + html-escaper@3.0.3: resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} htmlparser2@10.1.0: resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + + http-proxy-agent@5.0.0: + resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} + engines: {node: '>= 6'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + http2-wrapper@1.0.3: + resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==} + engines: {node: '>=10.19.0'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + humanize-ms@1.2.1: + resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} + + iconv-corefoundation@1.1.7: + resolution: {integrity: sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==} + engines: {node: ^8.11.2 || >=10} + os: [darwin] + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + immediate@3.0.6: resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} import-meta-resolve@4.2.0: resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + infer-owner@1.0.4: + resolution: {integrity: sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -1544,6 +2612,10 @@ packages: resolution: {integrity: sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + ip-address@10.1.0: + resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + engines: {node: '>= 12'} + is-absolute@0.1.7: resolution: {integrity: sha512-Xi9/ZSn4NFapG8RP98iNPMOeaV3mXPisxKxzKtHVqr3g56j/fBn+yZmnxSVAA8lmZbl2J9b/a4kJvfU3hqQYgA==} engines: {node: '>=0.10.0'} @@ -1551,6 +2623,10 @@ packages: is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + is-ci@3.0.1: + resolution: {integrity: sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==} + hasBin: true + is-docker@2.2.1: resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} engines: {node: '>=8'} @@ -1587,6 +2663,13 @@ packages: resolution: {integrity: sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ==} engines: {node: '>=18'} + is-interactive@1.0.0: + resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} + engines: {node: '>=8'} + + is-lambda@1.0.1: + resolution: {integrity: sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==} + is-npm@6.1.0: resolution: {integrity: sha512-O2z4/kNgyjhQwVR1Wpkbfc19JIhggF97NZNCpWTnjH7kVcZMUrnut9XSN7txI7VdyIYk5ZatOq3zvSuWpU8hoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -1618,6 +2701,10 @@ packages: resolution: {integrity: sha512-wBOr+rNM4gkAZqoLRJI4myw5WzzIdQosFAAbnvfXP5z1LyzgAI3ivOKehC5KfqlQJZoihVhirgtCBj378Eg8GA==} engines: {node: '>=0.10.0'} + is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + is-what@5.5.0: resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} engines: {node: '>=18'} @@ -1633,6 +2720,14 @@ packages: isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isbinaryfile@4.0.10: + resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==} + engines: {node: '>= 8.0.0'} + + isbinaryfile@5.0.7: + resolution: {integrity: sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==} + engines: {node: '>= 18.0.0'} + isexe@1.1.2: resolution: {integrity: sha512-d2eJzK691yZwPHcv1LbeAOa91yMJ9QmfTgSO1oXB65ezVhXQsxBac2vEB4bMVms9cGzaA99n6V2viHMq82VLDw==} @@ -1643,6 +2738,14 @@ packages: resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} engines: {node: '>=0.10.0'} + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jake@10.9.4: + resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} + engines: {node: '>=10'} + hasBin: true + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -1656,15 +2759,36 @@ packages: js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-parse-even-better-errors@3.0.2: resolution: {integrity: sha512-fi0NG4bPjCHunUJffmLd0gxssIgkNmArMvis4iNah6Owg1MCJjWhEcDLmsK6iGkJq3tHwbDkTlce70/tmXN4cQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} hasBin: true + jsonfile@4.0.0: + resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + jsonfile@6.2.0: resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} @@ -1685,6 +2809,9 @@ packages: resolution: {integrity: sha512-EkxoDTk8ufHqHlf9QxGwcxeLkWRR3iOuYfRpfORgYfqc8s13bgb+YtRY59NK5ZpRaCwq1kqA6a5lpX8C/eLphQ==} hasBin: true + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kleur@3.0.3: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} @@ -1697,6 +2824,13 @@ packages: resolution: {integrity: sha512-7W0vV3rqv5tokqkBAFV1LbR7HPOWzXQDpDgEuib/aJ1jsZZx6x3c2mBI+TJhJzOhkGeaLbCKEHXEXLfirtG2JA==} engines: {node: '>=18'} + lazy-val@1.0.5: + resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} + + lazystream@1.0.1: + resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} + engines: {node: '>= 0.6.3'} + lie@3.3.0: resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} @@ -1804,12 +2938,28 @@ packages: lodash-es@4.17.23: resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + + lodash.difference@4.5.0: + resolution: {integrity: sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==} + + lodash.escaperegexp@4.1.2: + resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + + lodash.flatten@4.4.0: + resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==} + lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} lodash.isboolean@3.0.3: resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + lodash.isinteger@4.0.4: resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} @@ -1828,9 +2978,16 @@ packages: lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash.union@4.6.0: + resolution: {integrity: sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==} + lodash@4.17.23: resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + log-update@6.1.0: resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} engines: {node: '>=18'} @@ -1842,6 +2999,27 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lowercase-keys@2.0.0: + resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} + engines: {node: '>=8'} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + + lru-cache@7.18.3: + resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} + engines: {node: '>=12'} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1851,6 +3029,10 @@ packages: make-error@1.3.6: resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + make-fetch-happen@10.2.1: + resolution: {integrity: sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + many-keys-map@2.0.1: resolution: {integrity: sha512-DHnZAD4phTbZ+qnJdjoNEVU1NecYoSdbOOoVmTDH46AuxDkEVh3MxTVpXq10GtcTC6mndN9dkv1rNfpjRcLnOw==} @@ -1869,6 +3051,10 @@ packages: marky@1.3.0: resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==} + matcher@3.0.0: + resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} + engines: {node: '>=10'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -2010,10 +3196,27 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + mimic-function@5.0.1: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} + mimic-response@1.0.1: + resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} + engines: {node: '>=4'} + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -2021,12 +3224,64 @@ packages: minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass-collect@1.0.2: + resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==} + engines: {node: '>= 8'} + + minipass-fetch@2.1.2: + resolution: {integrity: sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + minipass-flush@1.0.7: + resolution: {integrity: sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==} + engines: {node: '>= 8'} + + minipass-pipeline@1.2.4: + resolution: {integrity: sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==} + engines: {node: '>=8'} + + minipass-sized@1.0.3: + resolution: {integrity: sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==} + engines: {node: '>=8'} + + minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + + minipass@5.0.0: + resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} + engines: {node: '>=8'} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@2.1.2: + resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} + engines: {node: '>= 8'} + mitt@3.0.1: resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} @@ -2060,6 +3315,23 @@ packages: nanospinner@1.2.2: resolution: {integrity: sha512-Zt/AmG6qRU3e+WnzGGLuMCEAO/dAu45stNbHY223tUxldaDAeE+FxSPsd9Q+j+paejmm0ZbrNVs5Sraqy3dRxA==} + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + + negotiator@0.6.4: + resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} + engines: {node: '>= 0.6'} + + node-abi@3.89.0: + resolution: {integrity: sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==} + engines: {node: '>=10'} + + node-addon-api@1.7.2: + resolution: {integrity: sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==} + + node-api-version@0.2.1: + resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==} + node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} @@ -2067,13 +3339,35 @@ packages: resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==} engines: {node: '>= 6.13.0'} + node-gyp@9.4.1: + resolution: {integrity: sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ==} + engines: {node: ^12.13 || ^14.13 || >=16} + hasBin: true + node-notifier@10.0.1: resolution: {integrity: sha512-YX7TSyDukOZ0g+gmzjB6abKu+hTGvO8+8+gIFDsRCU2t8fLV/P2unmt+LGFaIa4y64aX98Qksa97rgz4vMNeLQ==} + node-releases@2.0.37: + resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} + + nopt@6.0.0: + resolution: {integrity: sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + hasBin: true + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + normalize-url@6.1.0: + resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} + engines: {node: '>=10'} + + npmlog@6.0.2: + resolution: {integrity: sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + deprecated: This package is no longer supported. + nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} @@ -2082,6 +3376,10 @@ packages: engines: {node: '>=18'} hasBin: true + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} @@ -2095,6 +3393,13 @@ packages: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + onetime@7.0.0: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} @@ -2107,6 +3412,10 @@ packages: resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} engines: {node: '>=12'} + ora@5.4.1: + resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} + engines: {node: '>=10'} + orderedmap@2.1.1: resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} @@ -2114,6 +3423,21 @@ packages: resolution: {integrity: sha512-jd0cvB8qQ5uVt0lvCIexBaROw1KyKm5sbulg2fWOHjETisuCzWyt+eTZKEMs8v6HwzoGs8xik26jg7eCM6pS+A==} engines: {node: '>= 0.4.0'} + p-cancelable@2.1.1: + resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} + engines: {node: '>=8'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-map@4.0.0: + resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} + engines: {node: '>=10'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + package-json@10.0.1: resolution: {integrity: sha512-ua1L4OgXSBdsu1FPb7F3tYH0F48a6kxvod4pLUlGY9COeJAJQNX/sNH2IiEmsxw7lqYiAwrdHMjz1FctOsyDQg==} engines: {node: '>=18'} @@ -2128,9 +3452,35 @@ packages: path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + pe-library@0.4.1: + resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==} + engines: {node: '>=12', npm: '>=6'} + + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + perfect-debounce@1.0.0: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} @@ -2173,6 +3523,20 @@ packages: pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + playwright-core@1.59.1: + resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.59.1: + resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==} + engines: {node: '>=18'} + hasBin: true + + plist@3.1.0: + resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} + engines: {node: '>=10.4.0'} + postcss@8.5.8: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} @@ -2181,12 +3545,34 @@ packages: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} process-warning@5.0.0: resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + + promise-inflight@1.0.1: + resolution: {integrity: sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==} + peerDependencies: + bluebird: '*' + peerDependenciesMeta: + bluebird: + optional: true + + promise-retry@2.0.1: + resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} + engines: {node: '>=10'} + promise-toolbox@0.21.0: resolution: {integrity: sha512-NV8aTmpwrZv+Iys54sSFOBx3tuVaOBvvrft5PNppnxy9xpU/akHbaWIril22AB22zaPgrgwKdD0KsrM0ptUtpg==} engines: {node: '>=6'} @@ -2266,10 +3652,17 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + punycode.js@2.3.1: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + pupa@3.3.0: resolution: {integrity: sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA==} engines: {node: '>=12.20'} @@ -2280,6 +3673,10 @@ packages: quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + quick-lru@5.1.1: + resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} + engines: {node: '>=10'} + rc9@3.0.1: resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} @@ -2287,9 +3684,20 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true + read-binary-file-arch@1.0.6: + resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==} + hasBin: true + readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdir-glob@1.1.3: + resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} + readdirp@5.0.0: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} @@ -2331,21 +3739,53 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + resedit@1.7.2: + resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==} + engines: {node: '>=12', npm: '>=6'} + resize-observer-polyfill@1.5.1: resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} + resolve-alpn@1.2.1: + resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} + + responselike@2.0.1: + resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==} + + restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + restore-cursor@5.1.0: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + roarr@2.15.4: + resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} + engines: {node: '>=8.0'} + rolldown@1.0.0-rc.12: resolution: {integrity: sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rollup@4.60.2: + resolution: {integrity: sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + rope-sequence@1.3.4: resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} @@ -2363,6 +3803,12 @@ packages: resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} engines: {node: '>=10'} + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sanitize-filename@1.6.4: + resolution: {integrity: sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==} + sax@1.6.0: resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} engines: {node: '>=11.0.0'} @@ -2373,11 +3819,25 @@ packages: scule@1.3.0: resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + semver-compare@1.0.0: + resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + semver@7.7.4: resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} hasBin: true + serialize-error@7.0.1: + resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} + engines: {node: '>=10'} + + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + set-value@4.1.0: resolution: {integrity: sha512-zTEg4HL0RwVrqcWs3ztF+x1vkxfm0lP+MQQFPiMJTKVceBwEV0A569Ou8l9IYQG8jOZdMVI1hGsc0tmeD2o/Lw==} engines: {node: '>=11.0'} @@ -2388,19 +3848,47 @@ packages: shallow-equal@1.2.1: resolution: {integrity: sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==} + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + shell-quote@1.7.3: resolution: {integrity: sha512-Vpfqwm4EnqGdlsBFNmHhxhElJYrdfcxPThu+ryKS5J8L/fhAwLazFZtq+S+TWZ9ANj2piSQLGj6NQg+lKPmxrw==} shellwords@0.1.1: resolution: {integrity: sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + + simple-update-notifier@2.0.0: + resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} + engines: {node: '>=10'} + sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + slice-ansi@3.0.0: + resolution: {integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==} + engines: {node: '>=8'} + slice-ansi@7.1.2: resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} engines: {node: '>=18'} @@ -2409,6 +3897,18 @@ packages: resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} engines: {node: '>=20'} + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + + socks-proxy-agent@7.0.0: + resolution: {integrity: sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==} + engines: {node: '>= 10'} + + socks@2.8.7: + resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} @@ -2441,10 +3941,31 @@ packages: split@1.0.1: resolution: {integrity: sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==} + sprintf-js@1.1.3: + resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + + ssri@9.0.1: + resolution: {integrity: sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + stat-mode@1.0.0: + resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==} + engines: {node: '>= 6'} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + string-width@7.2.0: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} @@ -2491,10 +4012,33 @@ packages: stylis@4.3.6: resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==} + sumchecker@3.0.1: + resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} + engines: {node: '>= 8.0'} + superjson@2.2.6: resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} engines: {node: '>=16'} + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + tar@6.2.1: + resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} + engines: {node: '>=10'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + temp-file@3.4.0: + resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==} + thread-stream@3.1.0: resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} @@ -2505,6 +4049,15 @@ packages: through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + tiny-typed-emitter@2.1.0: + resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.0.4: resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} engines: {node: '>=18'} @@ -2513,6 +4066,21 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + + tmp-promise@3.0.3: + resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} + tmp@0.2.5: resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} engines: {node: '>=14.14'} @@ -2520,9 +4088,19 @@ packages: trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + truncate-utf8-bytes@1.0.2: + resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + type-fest@0.13.1: + resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} + engines: {node: '>=10'} + type-fest@3.13.1: resolution: {integrity: sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==} engines: {node: '>=14.16'} @@ -2558,6 +4136,14 @@ packages: resolution: {integrity: sha512-ZSOkrDw380w+KIPniY3smyXh2h7H9v2MNr9zejDuh239o5sdea44DRAYrv+rfUi2QGT186P2h0GPGKvy8avQ5g==} engines: {node: '>=18.12.0'} + unique-filename@2.0.1: + resolution: {integrity: sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + unique-slug@3.0.0: + resolution: {integrity: sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + unist-util-is@6.0.1: resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} @@ -2573,6 +4159,10 @@ packages: unist-util-visit@5.1.0: resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + universalify@0.1.2: + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} + universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} @@ -2585,10 +4175,22 @@ packages: resolution: {integrity: sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==} engines: {node: ^20.19.0 || >=22.12.0} + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + update-notifier@7.3.1: resolution: {integrity: sha512-+dwUY4L35XFYEzE+OAL3sarJdUioVovq+8f7lcIJ7wnmnYQV5UD1Y/lcwaMSyaQ6Bj3JMj1XSTjZbNLHn/19yA==} engines: {node: '>=18'} + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + utf8-byte-length@1.0.5: + resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -2596,17 +4198,57 @@ packages: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true + verror@1.10.1: + resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==} + engines: {node: '>=0.6.0'} + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + vite-node@6.0.0: resolution: {integrity: sha512-oj4PVrT+pDh6GYf5wfUXkcZyekYS8kKPfLPXVl8qe324Ec6l4K2DUKNadRbZ3LQl0qGcDz+PyOo7ZAh00Y+JjQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + vite@8.0.3: resolution: {integrity: sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2650,6 +4292,31 @@ packages: yaml: optional: true + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vscode-uri@3.1.0: resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} @@ -2709,6 +4376,9 @@ packages: resolution: {integrity: sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==} engines: {node: '>=10.13.0'} + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + web-ext-run@0.2.4: resolution: {integrity: sha512-rQicL7OwuqWdQWI33JkSXKcp7cuv1mJG8u3jRQwx/8aDsmhbTHs9ZRmNYOL+LX0wX8edIEQX8jj4bB60GoXtKA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -2731,6 +4401,14 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wide-align@1.1.5: + resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==} + widest-line@5.0.0: resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==} engines: {node: '>=18'} @@ -2746,10 +4424,17 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + wrap-ansi@9.0.2: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + wsl-utils@0.3.1: resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} engines: {node: '>=20'} @@ -2775,10 +4460,20 @@ packages: resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} engines: {node: '>=4.0'} + xmlbuilder@15.1.1: + resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} + engines: {node: '>=8.0'} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -2787,9 +4482,20 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + zip-dir@2.0.0: resolution: {integrity: sha512-uhlsJZWz26FLYXOD6WVuq+fIcZ3aBPGo/cFdiLlv3KNwpa52IF3ISV8fLhQLiqVu5No3VhlqlgthN6gehil1Dg==} + zip-stream@4.1.1: + resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} + engines: {node: '>= 10'} + zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} @@ -2798,17 +4504,21 @@ packages: snapshots: + 7zip-bin@5.2.0: {} + '@1natsu/wait-element@4.1.2': dependencies: defu: 6.1.6 many-keys-map: 2.0.1 - '@aklinker1/rollup-plugin-visualizer@5.12.0': + '@aklinker1/rollup-plugin-visualizer@5.12.0(rollup@4.60.2)': dependencies: open: 8.4.2 picomatch: 2.3.2 source-map: 0.7.6 yargs: 17.7.2 + optionalDependencies: + rollup: 4.60.2 '@ant-design/colors@6.0.0': dependencies: @@ -2828,18 +4538,106 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/compat-data@7.29.0': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.28.6': {} + '@babel/helper-string-parser@7.27.1': {} '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.29.2': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + '@babel/parser@7.29.2': dependencies: '@babel/types': 7.29.0 + '@babel/plugin-transform-arrow-functions@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/runtime@7.28.2': {} '@babel/runtime@7.29.2': {} + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + '@babel/types@7.29.0': dependencies: '@babel/helper-string-parser': 7.27.1 @@ -3102,6 +4900,11 @@ snapshots: '@ctrl/tinycolor@3.6.1': {} + '@develar/schema-utils@2.6.5': + dependencies: + ajv: 6.14.0 + ajv-keywords: 3.5.2(ajv@6.14.0) + '@devicefarmer/adbkit-logcat@2.1.3': {} '@devicefarmer/adbkit-monkey@1.2.1': {} @@ -3118,6 +4921,77 @@ snapshots: transitivePeerDependencies: - supports-color + '@electron/asar@3.4.1': + dependencies: + commander: 5.1.0 + glob: 7.2.3 + minimatch: 3.1.5 + + '@electron/get@2.0.3': + dependencies: + debug: 4.4.3 + env-paths: 2.2.1 + fs-extra: 8.1.0 + got: 11.8.6 + progress: 2.0.3 + semver: 6.3.1 + sumchecker: 3.0.1 + optionalDependencies: + global-agent: 3.0.0 + transitivePeerDependencies: + - supports-color + + '@electron/notarize@2.5.0': + dependencies: + debug: 4.4.3 + fs-extra: 9.1.0 + promise-retry: 2.0.1 + transitivePeerDependencies: + - supports-color + + '@electron/osx-sign@1.3.1': + dependencies: + compare-version: 0.1.2 + debug: 4.4.3 + fs-extra: 10.1.0 + isbinaryfile: 4.0.10 + minimist: 1.2.8 + plist: 3.1.0 + transitivePeerDependencies: + - supports-color + + '@electron/rebuild@3.6.1': + dependencies: + '@malept/cross-spawn-promise': 2.0.0 + chalk: 4.1.2 + debug: 4.4.3 + detect-libc: 2.1.2 + fs-extra: 10.1.0 + got: 11.8.6 + node-abi: 3.89.0 + node-api-version: 0.2.1 + node-gyp: 9.4.1 + ora: 5.4.1 + read-binary-file-arch: 1.0.6 + semver: 7.7.4 + tar: 6.2.1 + yargs: 17.7.2 + transitivePeerDependencies: + - bluebird + - supports-color + + '@electron/universal@2.0.1': + dependencies: + '@electron/asar': 3.4.1 + '@malept/cross-spawn-promise': 2.0.0 + debug: 4.4.3 + dir-compare: 4.2.0 + fs-extra: 11.3.4 + minimatch: 9.0.9 + plist: 3.1.0 + transitivePeerDependencies: + - supports-color + '@emnapi/core@1.9.1': dependencies: '@emnapi/wasi-threads': 1.2.0 @@ -3138,81 +5012,150 @@ snapshots: '@emotion/unitless@0.8.1': {} + '@esbuild/aix-ppc64@0.21.5': + optional: true + '@esbuild/aix-ppc64@0.27.5': optional: true + '@esbuild/android-arm64@0.21.5': + optional: true + '@esbuild/android-arm64@0.27.5': optional: true + '@esbuild/android-arm@0.21.5': + optional: true + '@esbuild/android-arm@0.27.5': optional: true + '@esbuild/android-x64@0.21.5': + optional: true + '@esbuild/android-x64@0.27.5': optional: true + '@esbuild/darwin-arm64@0.21.5': + optional: true + '@esbuild/darwin-arm64@0.27.5': optional: true + '@esbuild/darwin-x64@0.21.5': + optional: true + '@esbuild/darwin-x64@0.27.5': optional: true + '@esbuild/freebsd-arm64@0.21.5': + optional: true + '@esbuild/freebsd-arm64@0.27.5': optional: true + '@esbuild/freebsd-x64@0.21.5': + optional: true + '@esbuild/freebsd-x64@0.27.5': optional: true + '@esbuild/linux-arm64@0.21.5': + optional: true + '@esbuild/linux-arm64@0.27.5': optional: true + '@esbuild/linux-arm@0.21.5': + optional: true + '@esbuild/linux-arm@0.27.5': optional: true + '@esbuild/linux-ia32@0.21.5': + optional: true + '@esbuild/linux-ia32@0.27.5': optional: true + '@esbuild/linux-loong64@0.21.5': + optional: true + '@esbuild/linux-loong64@0.27.5': optional: true + '@esbuild/linux-mips64el@0.21.5': + optional: true + '@esbuild/linux-mips64el@0.27.5': optional: true + '@esbuild/linux-ppc64@0.21.5': + optional: true + '@esbuild/linux-ppc64@0.27.5': optional: true + '@esbuild/linux-riscv64@0.21.5': + optional: true + '@esbuild/linux-riscv64@0.27.5': optional: true + '@esbuild/linux-s390x@0.21.5': + optional: true + '@esbuild/linux-s390x@0.27.5': optional: true + '@esbuild/linux-x64@0.21.5': + optional: true + '@esbuild/linux-x64@0.27.5': optional: true '@esbuild/netbsd-arm64@0.27.5': optional: true + '@esbuild/netbsd-x64@0.21.5': + optional: true + '@esbuild/netbsd-x64@0.27.5': optional: true '@esbuild/openbsd-arm64@0.27.5': optional: true + '@esbuild/openbsd-x64@0.21.5': + optional: true + '@esbuild/openbsd-x64@0.27.5': optional: true '@esbuild/openharmony-arm64@0.27.5': optional: true + '@esbuild/sunos-x64@0.21.5': + optional: true + '@esbuild/sunos-x64@0.27.5': optional: true + '@esbuild/win32-arm64@0.21.5': + optional: true + '@esbuild/win32-arm64@0.27.5': optional: true + '@esbuild/win32-ia32@0.21.5': + optional: true + '@esbuild/win32-ia32@0.27.5': optional: true + '@esbuild/win32-x64@0.21.5': + optional: true + '@esbuild/win32-x64@0.27.5': optional: true @@ -3227,6 +5170,8 @@ snapshots: '@floating-ui/utils@0.2.11': {} + '@gar/promisify@1.1.3': {} + '@intlify/core-base@10.0.8': dependencies: '@intlify/message-compiler': 10.0.8 @@ -3239,6 +5184,15 @@ snapshots: '@intlify/shared@10.0.8': {} + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -3355,6 +5309,19 @@ snapshots: '@lezer/highlight': 1.2.3 '@lezer/lr': 1.4.8 + '@malept/cross-spawn-promise@2.0.0': + dependencies: + cross-spawn: 7.0.6 + + '@malept/flatpak-bundler@0.4.0': + dependencies: + debug: 4.4.3 + fs-extra: 9.1.0 + lodash: 4.17.23 + tmp-promise: 3.0.3 + transitivePeerDependencies: + - supports-color + '@marijn/find-cluster-break@1.0.2': {} '@milkdown/components@7.20.0(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)(typescript@5.9.3)': @@ -3646,10 +5613,27 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@npmcli/fs@2.1.2': + dependencies: + '@gar/promisify': 1.1.3 + semver: 7.7.4 + + '@npmcli/move-file@2.0.1': + dependencies: + mkdirp: 1.0.4 + rimraf: 3.0.2 + '@ocavue/utils@1.6.0': {} '@oxc-project/types@0.122.0': {} + '@pkgjs/parseargs@0.11.0': + optional: true + + '@playwright/test@1.59.1': + dependencies: + playwright: 1.59.1 + '@pnpm/config.env-replace@1.1.0': {} '@pnpm/network.ca-file@1.0.2': @@ -3716,11 +5700,92 @@ snapshots: '@rolldown/pluginutils@1.0.0-rc.2': {} + '@rollup/rollup-android-arm-eabi@4.60.2': + optional: true + + '@rollup/rollup-android-arm64@4.60.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.2': + optional: true + + '@rollup/rollup-darwin-x64@4.60.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.2': + optional: true + '@simonwep/pickr@1.8.2': dependencies: core-js: 3.49.0 nanopop: 2.4.2 + '@sindresorhus/is@4.6.0': {} + + '@szmarczak/http-timer@4.0.6': + dependencies: + defer-to-connect: 2.0.1 + '@tanstack/match-sorter-utils@8.19.4': dependencies: remove-accents: 0.5.0 @@ -3735,11 +5800,20 @@ snapshots: vue: 3.5.31(typescript@5.9.3) vue-demi: 0.14.10(vue@3.5.31(typescript@5.9.3)) + '@tootallnate/once@2.0.0': {} + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 optional: true + '@types/cacheable-request@6.0.3': + dependencies: + '@types/http-cache-semantics': 4.2.0 + '@types/keyv': 3.1.4 + '@types/node': 24.12.0 + '@types/responselike': 1.0.3 + '@types/chrome@0.1.38': dependencies: '@types/filesystem': 0.0.36 @@ -3757,14 +5831,24 @@ snapshots: '@types/filewriter@0.0.33': {} + '@types/fs-extra@9.0.13': + dependencies: + '@types/node': 24.12.0 + '@types/har-format@1.2.16': {} '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 + '@types/http-cache-semantics@4.2.0': {} + '@types/katex@0.16.8': {} + '@types/keyv@3.1.4': + dependencies: + '@types/node': 24.12.0 + '@types/linkify-it@5.0.0': {} '@types/lodash-es@4.17.12': @@ -3792,19 +5876,82 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/plist@3.0.5': + dependencies: + '@types/node': 24.12.0 + xmlbuilder: 11.0.1 + optional: true + + '@types/responselike@1.0.3': + dependencies: + '@types/node': 24.12.0 + '@types/trusted-types@2.0.7': optional: true '@types/unist@3.0.3': {} + '@types/verror@1.10.11': + optional: true + '@types/web-bluetooth@0.0.21': {} + '@types/yauzl@2.10.3': + dependencies: + '@types/node': 24.12.0 + optional: true + + '@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@24.12.0)(lightningcss@1.32.0))(vue@3.5.31(typescript@5.9.3))': + dependencies: + vite: 5.4.21(@types/node@24.12.0)(lightningcss@1.32.0) + vue: 3.5.31(typescript@5.9.3) + '@vitejs/plugin-vue@6.0.5(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(esbuild@0.27.5)(jiti@2.6.1))(vue@3.5.31(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.2 vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(esbuild@0.27.5)(jiti@2.6.1) vue: 3.5.31(typescript@5.9.3) + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@24.12.0)(lightningcss@1.32.0))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@24.12.0)(lightningcss@1.32.0) + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + '@volar/language-core@2.4.28': dependencies: '@volar/source-map': 2.4.28 @@ -3929,10 +6076,10 @@ snapshots: '@types/filesystem': 0.0.36 '@types/har-format': 1.2.16 - '@wxt-dev/module-vue@1.0.3(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(esbuild@0.27.5)(jiti@2.6.1))(vue@3.5.31(typescript@5.9.3))(wxt@0.20.20(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(jiti@2.6.1))': + '@wxt-dev/module-vue@1.0.3(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(esbuild@0.27.5)(jiti@2.6.1))(vue@3.5.31(typescript@5.9.3))(wxt@0.20.20(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(jiti@2.6.1)(rollup@4.60.2))': dependencies: '@vitejs/plugin-vue': 6.0.5(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(esbuild@0.27.5)(jiti@2.6.1))(vue@3.5.31(typescript@5.9.3)) - wxt: 0.20.20(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(jiti@2.6.1) + wxt: 0.20.20(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(jiti@2.6.1)(rollup@4.60.2) transitivePeerDependencies: - vite - vue @@ -3943,10 +6090,42 @@ snapshots: async-mutex: 0.5.0 dequal: 2.0.3 + '@xmldom/xmldom@0.8.13': {} + + abbrev@1.1.1: {} + acorn@8.16.0: {} adm-zip@0.5.17: {} + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + agent-base@7.1.4: {} + + agentkeepalive@4.6.0: + dependencies: + humanize-ms: 1.2.1 + + aggregate-error@3.1.0: + dependencies: + clean-stack: 2.2.0 + indent-string: 4.0.0 + + ajv-keywords@3.5.2(ajv@6.14.0): + dependencies: + ajv: 6.14.0 + + ajv@6.14.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + alien-signals@3.1.2: {} ansi-align@3.0.1: @@ -3993,6 +6172,91 @@ snapshots: vue-types: 3.0.2(vue@3.5.31(typescript@5.9.3)) warning: 4.0.3 + app-builder-bin@5.0.0-alpha.10: {} + + app-builder-lib@25.1.8(dmg-builder@25.1.8)(electron-builder-squirrel-windows@25.1.8): + dependencies: + '@develar/schema-utils': 2.6.5 + '@electron/notarize': 2.5.0 + '@electron/osx-sign': 1.3.1 + '@electron/rebuild': 3.6.1 + '@electron/universal': 2.0.1 + '@malept/flatpak-bundler': 0.4.0 + '@types/fs-extra': 9.0.13 + async-exit-hook: 2.0.1 + bluebird-lst: 1.0.9 + builder-util: 25.1.7 + builder-util-runtime: 9.2.10 + chromium-pickle-js: 0.2.0 + config-file-ts: 0.2.8-rc1 + debug: 4.4.3 + dmg-builder: 25.1.8(electron-builder-squirrel-windows@25.1.8) + dotenv: 16.6.1 + dotenv-expand: 11.0.7 + ejs: 3.1.10 + electron-builder-squirrel-windows: 25.1.8(dmg-builder@25.1.8) + electron-publish: 25.1.7 + form-data: 4.0.5 + fs-extra: 10.1.0 + hosted-git-info: 4.1.0 + is-ci: 3.0.1 + isbinaryfile: 5.0.7 + js-yaml: 4.1.1 + json5: 2.2.3 + lazy-val: 1.0.5 + minimatch: 10.2.5 + resedit: 1.7.2 + sanitize-filename: 1.6.4 + semver: 7.7.4 + tar: 6.2.1 + temp-file: 3.4.0 + transitivePeerDependencies: + - bluebird + - supports-color + + aproba@2.1.0: {} + + archiver-utils@2.1.0: + dependencies: + glob: 7.2.3 + graceful-fs: 4.2.11 + lazystream: 1.0.1 + lodash.defaults: 4.2.0 + lodash.difference: 4.5.0 + lodash.flatten: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.union: 4.6.0 + normalize-path: 3.0.0 + readable-stream: 2.3.8 + + archiver-utils@3.0.4: + dependencies: + glob: 7.2.3 + graceful-fs: 4.2.11 + lazystream: 1.0.1 + lodash.defaults: 4.2.0 + lodash.difference: 4.5.0 + lodash.flatten: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.union: 4.6.0 + normalize-path: 3.0.0 + readable-stream: 3.6.2 + + archiver@5.3.2: + dependencies: + archiver-utils: 2.1.0 + async: 3.2.6 + buffer-crc32: 0.2.13 + readable-stream: 3.6.2 + readdir-glob: 1.1.3 + tar-stream: 2.2.0 + zip-stream: 4.1.1 + + are-we-there-yet@3.0.1: + dependencies: + delegates: 1.0.0 + readable-stream: 3.6.2 + argparse@2.0.1: {} array-differ@4.0.0: {} @@ -4001,6 +6265,16 @@ snapshots: array-union@3.0.1: {} + assert-plus@1.0.0: + optional: true + + assertion-error@2.0.1: {} + + astral-regex@2.0.0: + optional: true + + async-exit-hook@2.0.1: {} + async-mutex@0.5.0: dependencies: tslib: 2.8.1 @@ -4011,6 +6285,8 @@ snapshots: asynckit@0.4.0: {} + at-least-node@1.0.0: {} + atomic-sleep@1.0.0: {} atomically@2.1.1: @@ -4032,12 +6308,38 @@ snapshots: balanced-match@4.0.4: {} + base64-js@1.5.1: {} + + baseline-browser-mapping@2.10.20: {} + + better-sqlite3@12.9.0: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + birpc@2.9.0: {} + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + bluebird-lst@1.0.9: + dependencies: + bluebird: 3.7.2 + bluebird@3.7.2: {} boolbase@1.0.0: {} + boolean@3.2.0: + optional: true + boxen@8.0.1: dependencies: ansi-align: 3.0.1 @@ -4054,14 +6356,68 @@ snapshots: balanced-match: 1.0.2 concat-map: 0.0.1 + brace-expansion@2.1.0: + dependencies: + balanced-match: 1.0.2 + brace-expansion@5.0.5: dependencies: balanced-match: 4.0.4 + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.20 + caniuse-lite: 1.0.30001788 + electron-to-chromium: 1.5.340 + node-releases: 2.0.37 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + buffer-crc32@0.2.13: {} + buffer-equal-constant-time@1.0.1: {} buffer-from@1.1.2: {} + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + builder-util-runtime@9.2.10: + dependencies: + debug: 4.4.3 + sax: 1.6.0 + transitivePeerDependencies: + - supports-color + + builder-util-runtime@9.5.1: + dependencies: + debug: 4.4.3 + sax: 1.6.0 + transitivePeerDependencies: + - supports-color + + builder-util@25.1.7: + dependencies: + 7zip-bin: 5.2.0 + '@types/debug': 4.1.13 + app-builder-bin: 5.0.0-alpha.10 + bluebird-lst: 1.0.9 + builder-util-runtime: 9.2.10 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + fs-extra: 10.1.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-ci: 3.0.1 + js-yaml: 4.1.1 + source-map-support: 0.5.21 + stat-mode: 1.0.0 + temp-file: 3.4.0 + transitivePeerDependencies: + - supports-color + bundle-name@4.1.0: dependencies: run-applescript: 7.1.0 @@ -4087,6 +6443,41 @@ snapshots: cac@7.0.0: {} + cacache@16.1.3: + dependencies: + '@npmcli/fs': 2.1.2 + '@npmcli/move-file': 2.0.1 + chownr: 2.0.0 + fs-minipass: 2.1.0 + glob: 8.1.0 + infer-owner: 1.0.4 + lru-cache: 7.18.3 + minipass: 3.3.6 + minipass-collect: 1.0.2 + minipass-flush: 1.0.7 + minipass-pipeline: 1.2.4 + mkdirp: 1.0.4 + p-map: 4.0.0 + promise-inflight: 1.0.1 + rimraf: 3.0.2 + ssri: 9.0.1 + tar: 6.2.1 + unique-filename: 2.0.1 + transitivePeerDependencies: + - bluebird + + cacheable-lookup@5.0.4: {} + + cacheable-request@7.0.4: + dependencies: + clone-response: 1.0.3 + get-stream: 5.2.0 + http-cache-semantics: 4.2.0 + keyv: 4.5.4 + lowercase-keys: 2.0.0 + normalize-url: 6.1.0 + responselike: 2.0.1 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -4094,16 +6485,37 @@ snapshots: camelcase@8.0.0: {} + caniuse-lite@1.0.30001788: {} + ccount@2.0.1: {} + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + chalk@5.6.2: {} character-entities@2.0.2: {} + check-error@2.1.3: {} + chokidar@5.0.0: dependencies: readdirp: 5.0.0 + chownr@1.1.4: {} + + chownr@2.0.0: {} + chrome-launcher@1.2.0: dependencies: '@types/node': 24.12.0 @@ -4113,16 +6525,34 @@ snapshots: transitivePeerDependencies: - supports-color + chromium-pickle-js@0.2.0: {} + + ci-info@3.9.0: {} + ci-info@4.4.0: {} citty@0.2.2: {} + clean-stack@2.2.0: {} + cli-boxes@3.0.0: {} + cli-cursor@3.1.0: + dependencies: + restore-cursor: 3.1.0 + cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 + cli-spinners@2.9.2: {} + + cli-truncate@2.1.0: + dependencies: + slice-ansi: 3.0.0 + string-width: 4.2.3 + optional: true + cli-truncate@5.2.0: dependencies: slice-ansi: 8.0.0 @@ -4134,6 +6564,12 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + clone-response@1.0.3: + dependencies: + mimic-response: 1.0.1 + + clone@1.0.4: {} + clsx@2.1.1: {} codemirror@6.0.2: @@ -4152,6 +6588,8 @@ snapshots: color-name@1.1.4: {} + color-support@1.1.3: {} + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -4160,10 +6598,21 @@ snapshots: dependencies: graceful-readlink: 1.0.1 + commander@5.1.0: {} + commander@8.3.0: {} commander@9.5.0: {} + compare-version@0.1.2: {} + + compress-commons@4.1.2: + dependencies: + buffer-crc32: 0.2.13 + crc32-stream: 4.0.3 + normalize-path: 3.0.0 + readable-stream: 3.6.2 + compute-scroll-into-view@1.0.20: {} concat-map@0.0.1: {} @@ -4184,6 +6633,11 @@ snapshots: ini: 1.3.8 proto-list: 1.2.4 + config-file-ts@0.2.8-rc1: + dependencies: + glob: 10.5.0 + typescript: 5.9.3 + configstore@7.1.0: dependencies: atomically: 2.1.1 @@ -4193,16 +6647,41 @@ snapshots: consola@3.4.2: {} + console-control-strings@1.1.0: {} + + convert-source-map@2.0.0: {} + copy-anything@4.0.5: dependencies: is-what: 5.5.0 core-js@3.49.0: {} + core-util-is@1.0.2: + optional: true + core-util-is@1.0.3: {} + crc-32@1.2.2: {} + + crc32-stream@4.0.3: + dependencies: + crc-32: 1.2.2 + readable-stream: 3.6.2 + + crc@3.8.0: + dependencies: + buffer: 5.7.1 + optional: true + crelt@1.0.6: {} + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + css-select@5.2.2: dependencies: boolbase: 1.0.0 @@ -4233,6 +6712,12 @@ snapshots: dependencies: character-entities: 2.0.2 + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-eql@5.0.2: {} + deep-extend@0.6.0: {} default-browser-id@5.0.1: {} @@ -4242,24 +6727,81 @@ snapshots: bundle-name: 4.1.0 default-browser-id: 5.0.1 + defaults@1.0.4: + dependencies: + clone: 1.0.4 + + defer-to-connect@2.0.1: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + optional: true + define-lazy-prop@2.0.0: {} define-lazy-prop@3.0.0: {} + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + optional: true + defu@6.1.6: {} delayed-stream@1.0.0: {} + delegates@1.0.0: {} + dequal@2.0.3: {} destr@2.0.5: {} detect-libc@2.1.2: {} + detect-node@2.1.0: + optional: true + devlop@1.1.0: dependencies: dequal: 2.0.3 + dir-compare@4.2.0: + dependencies: + minimatch: 3.1.5 + p-limit: 3.1.0 + + dmg-builder@25.1.8(electron-builder-squirrel-windows@25.1.8): + dependencies: + app-builder-lib: 25.1.8(dmg-builder@25.1.8)(electron-builder-squirrel-windows@25.1.8) + builder-util: 25.1.7 + builder-util-runtime: 9.2.10 + fs-extra: 10.1.0 + iconv-lite: 0.6.3 + js-yaml: 4.1.1 + optionalDependencies: + dmg-license: 1.0.11 + transitivePeerDependencies: + - bluebird + - electron-builder-squirrel-windows + - supports-color + + dmg-license@1.0.11: + dependencies: + '@types/plist': 3.0.5 + '@types/verror': 1.10.11 + ajv: 6.14.0 + crc: 3.8.0 + iconv-corefoundation: 1.1.7 + plist: 3.1.0 + smart-buffer: 4.2.0 + verror: 1.10.1 + optional: true + dom-align@1.12.4: {} dom-scroll-into-view@2.0.1: {} @@ -4290,6 +6832,10 @@ snapshots: dependencies: type-fest: 4.41.0 + dotenv-expand@11.0.7: + dependencies: + dotenv: 16.6.1 + dotenv-expand@12.0.3: dependencies: dotenv: 16.6.1 @@ -4304,20 +6850,116 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: dependencies: safe-buffer: 5.2.1 + ejs@3.1.10: + dependencies: + jake: 10.9.4 + + electron-builder-squirrel-windows@25.1.8(dmg-builder@25.1.8): + dependencies: + app-builder-lib: 25.1.8(dmg-builder@25.1.8)(electron-builder-squirrel-windows@25.1.8) + archiver: 5.3.2 + builder-util: 25.1.7 + fs-extra: 10.1.0 + transitivePeerDependencies: + - bluebird + - dmg-builder + - supports-color + + electron-builder@25.1.8(electron-builder-squirrel-windows@25.1.8): + dependencies: + app-builder-lib: 25.1.8(dmg-builder@25.1.8)(electron-builder-squirrel-windows@25.1.8) + builder-util: 25.1.7 + builder-util-runtime: 9.2.10 + chalk: 4.1.2 + dmg-builder: 25.1.8(electron-builder-squirrel-windows@25.1.8) + fs-extra: 10.1.0 + is-ci: 3.0.1 + lazy-val: 1.0.5 + simple-update-notifier: 2.0.0 + yargs: 17.7.2 + transitivePeerDependencies: + - bluebird + - electron-builder-squirrel-windows + - supports-color + + electron-publish@25.1.7: + dependencies: + '@types/fs-extra': 9.0.13 + builder-util: 25.1.7 + builder-util-runtime: 9.2.10 + chalk: 4.1.2 + fs-extra: 10.1.0 + lazy-val: 1.0.5 + mime: 2.6.0 + transitivePeerDependencies: + - supports-color + + electron-to-chromium@1.5.340: {} + + electron-updater@6.8.3: + dependencies: + builder-util-runtime: 9.5.1 + fs-extra: 10.1.0 + js-yaml: 4.1.1 + lazy-val: 1.0.5 + lodash.escaperegexp: 4.1.2 + lodash.isequal: 4.5.0 + semver: 7.7.4 + tiny-typed-emitter: 2.1.0 + transitivePeerDependencies: + - supports-color + + electron-vite@2.3.0(vite@5.4.21(@types/node@24.12.0)(lightningcss@1.32.0)): + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0) + cac: 6.7.14 + esbuild: 0.21.5 + magic-string: 0.30.21 + picocolors: 1.1.1 + vite: 5.4.21(@types/node@24.12.0)(lightningcss@1.32.0) + transitivePeerDependencies: + - supports-color + + electron@41.2.0: + dependencies: + '@electron/get': 2.0.3 + '@types/node': 24.12.0 + extract-zip: 2.0.1 + transitivePeerDependencies: + - supports-color + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} + emoji-regex@9.2.2: {} + + encoding@0.1.13: + dependencies: + iconv-lite: 0.6.3 + optional: true + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + entities@4.5.0: {} entities@7.0.1: {} + env-paths@2.2.1: {} + environment@1.1.0: {} + err-code@2.0.3: {} + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -4326,6 +6968,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-module-lexer@2.0.0: {} es-object-atoms@1.1.1: @@ -4341,6 +6985,32 @@ snapshots: es6-error@4.1.1: {} + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + esbuild@0.27.5: optionalDependencies: '@esbuild/aix-ppc64': 0.27.5 @@ -4386,16 +7056,49 @@ snapshots: eventemitter3@5.0.4: {} + expand-template@2.0.3: {} + + expect-type@1.3.0: {} + + exponential-backoff@3.1.3: {} + exsolve@1.0.8: {} extend@3.0.2: {} + extract-zip@2.0.1: + dependencies: + debug: 4.4.3 + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.10.3 + transitivePeerDependencies: + - supports-color + + extsprintf@1.4.1: + optional: true + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + fast-redact@3.5.0: {} + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 + file-uri-to-path@1.0.0: {} + + filelist@1.0.6: + dependencies: + minimatch: 5.1.9 + filesize@11.0.15: {} firefox-profile@4.7.0: @@ -4408,6 +7111,11 @@ snapshots: follow-redirects@1.15.11: {} + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + form-data-encoder@4.1.0: {} form-data@4.0.5: @@ -4420,12 +7128,42 @@ snapshots: formdata-node@6.0.3: {} + fs-constants@1.0.0: {} + + fs-extra@10.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 + fs-extra@11.3.4: dependencies: graceful-fs: 4.2.11 jsonfile: 6.2.0 universalify: 2.0.1 + fs-extra@8.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fs-extra@9.1.0: + dependencies: + at-least-node: 1.0.0 + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 + + fs-minipass@2.1.0: + dependencies: + minipass: 3.3.6 + + fs.realpath@1.0.0: {} + + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -4440,6 +7178,19 @@ snapshots: which: 1.2.4 winreg: 0.0.12 + gauge@4.0.4: + dependencies: + aproba: 2.1.0 + color-support: 1.1.3 + console-control-strings: 1.1.0 + has-unicode: 2.0.1 + signal-exit: 3.0.7 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wide-align: 1.1.5 + + gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} get-east-asian-width@1.5.0: {} @@ -4464,16 +7215,78 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 + get-stream@5.2.0: + dependencies: + pump: 3.0.4 + giget@3.2.0: {} + github-from-package@0.0.0: {} + glob-to-regexp@0.4.1: {} + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + glob@8.1.0: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 5.1.9 + once: 1.4.0 + + global-agent@3.0.0: + dependencies: + boolean: 3.2.0 + es6-error: 4.1.1 + matcher: 3.0.0 + roarr: 2.15.4 + semver: 7.7.4 + serialize-error: 7.0.1 + optional: true + global-directory@4.0.1: dependencies: ini: 4.1.1 + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + optional: true + gopd@1.2.0: {} + got@11.8.6: + dependencies: + '@sindresorhus/is': 4.6.0 + '@szmarczak/http-timer': 4.0.6 + '@types/cacheable-request': 6.0.3 + '@types/responselike': 1.0.3 + cacheable-lookup: 5.0.4 + cacheable-request: 7.0.4 + decompress-response: 6.0.0 + http2-wrapper: 1.0.3 + lowercase-keys: 2.0.0 + p-cancelable: 2.1.1 + responselike: 2.0.1 + graceful-fs@4.2.10: {} graceful-fs@4.2.11: {} @@ -4482,12 +7295,21 @@ snapshots: growly@1.3.0: {} + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + optional: true + has-symbols@1.1.0: {} has-tostringtag@1.0.2: dependencies: has-symbols: 1.1.0 + has-unicode@2.0.1: {} + hasown@2.0.2: dependencies: function-bind: 1.1.2 @@ -4496,6 +7318,10 @@ snapshots: hookable@6.1.0: {} + hosted-git-info@4.1.0: + dependencies: + lru-cache: 6.0.0 + html-escaper@3.0.3: {} htmlparser2@10.1.0: @@ -4505,10 +7331,73 @@ snapshots: domutils: 3.2.2 entities: 7.0.1 + http-cache-semantics@4.2.0: {} + + http-proxy-agent@5.0.0: + dependencies: + '@tootallnate/once': 2.0.0 + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + http2-wrapper@1.0.3: + dependencies: + quick-lru: 5.1.1 + resolve-alpn: 1.2.1 + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + humanize-ms@1.2.1: + dependencies: + ms: 2.1.3 + + iconv-corefoundation@1.1.7: + dependencies: + cli-truncate: 2.1.0 + node-addon-api: 1.7.2 + optional: true + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + immediate@3.0.6: {} import-meta-resolve@4.2.0: {} + imurmurhash@0.1.4: {} + + indent-string@4.0.0: {} + + infer-owner@1.0.4: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + inherits@2.0.4: {} ini@1.3.8: {} @@ -4517,12 +7406,18 @@ snapshots: ini@4.1.3: {} + ip-address@10.1.0: {} + is-absolute@0.1.7: dependencies: is-relative: 0.1.3 is-arrayish@0.2.1: {} + is-ci@3.0.1: + dependencies: + ci-info: 3.9.0 + is-docker@2.2.1: {} is-docker@3.0.0: {} @@ -4546,6 +7441,10 @@ snapshots: global-directory: 4.0.1 is-path-inside: 4.0.0 + is-interactive@1.0.0: {} + + is-lambda@1.0.1: {} + is-npm@6.1.0: {} is-path-inside@4.0.0: {} @@ -4564,6 +7463,8 @@ snapshots: is-relative@0.1.3: {} + is-unicode-supported@0.1.0: {} + is-what@5.5.0: {} is-wsl@2.2.0: @@ -4576,12 +7477,28 @@ snapshots: isarray@1.0.0: {} + isbinaryfile@4.0.10: {} + + isbinaryfile@5.0.7: {} + isexe@1.1.2: {} isexe@2.0.0: {} isobject@3.0.1: {} + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jake@10.9.4: + dependencies: + async: 3.2.6 + filelist: 1.0.6 + picocolors: 1.1.1 + jiti@2.6.1: {} js-md5@0.8.3: {} @@ -4590,10 +7507,27 @@ snapshots: js-tokens@9.0.1: {} + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + json-parse-even-better-errors@3.0.2: {} + json-schema-traverse@0.4.1: {} + + json-stringify-safe@5.0.1: + optional: true + json5@2.2.3: {} + jsonfile@4.0.0: + optionalDependencies: + graceful-fs: 4.2.11 + jsonfile@6.2.0: dependencies: universalify: 2.0.1 @@ -4635,6 +7569,10 @@ snapshots: dependencies: commander: 8.3.0 + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + kleur@3.0.3: {} ky@1.14.3: {} @@ -4643,6 +7581,12 @@ snapshots: dependencies: package-json: 10.0.1 + lazy-val@1.0.5: {} + + lazystream@1.0.1: + dependencies: + readable-stream: 2.3.8 + lie@3.3.0: dependencies: immediate: 3.0.6 @@ -4733,10 +7677,20 @@ snapshots: lodash-es@4.17.23: {} + lodash.defaults@4.2.0: {} + + lodash.difference@4.5.0: {} + + lodash.escaperegexp@4.1.2: {} + + lodash.flatten@4.4.0: {} + lodash.includes@4.3.0: {} lodash.isboolean@3.0.3: {} + lodash.isequal@4.5.0: {} + lodash.isinteger@4.0.4: {} lodash.isnumber@3.0.3: {} @@ -4749,8 +7703,15 @@ snapshots: lodash.once@4.1.1: {} + lodash.union@4.6.0: {} + lodash@4.17.23: {} + log-symbols@4.1.0: + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + log-update@6.1.0: dependencies: ansi-escapes: 7.3.0 @@ -4765,6 +7726,22 @@ snapshots: dependencies: js-tokens: 4.0.0 + loupe@3.2.1: {} + + lowercase-keys@2.0.0: {} + + lru-cache@10.4.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + + lru-cache@7.18.3: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -4777,6 +7754,28 @@ snapshots: make-error@1.3.6: {} + make-fetch-happen@10.2.1: + dependencies: + agentkeepalive: 4.6.0 + cacache: 16.1.3 + http-cache-semantics: 4.2.0 + http-proxy-agent: 5.0.0 + https-proxy-agent: 5.0.1 + is-lambda: 1.0.1 + lru-cache: 7.18.3 + minipass: 3.3.6 + minipass-collect: 1.0.2 + minipass-fetch: 2.1.2 + minipass-flush: 1.0.7 + minipass-pipeline: 1.2.4 + negotiator: 0.6.4 + promise-retry: 2.0.1 + socks-proxy-agent: 7.0.0 + ssri: 9.0.1 + transitivePeerDependencies: + - bluebird + - supports-color + many-keys-map@2.0.1: {} markdown-it@14.1.1: @@ -4794,6 +7793,11 @@ snapshots: marky@1.3.0: {} + matcher@3.0.0: + dependencies: + escape-string-regexp: 4.0.0 + optional: true + math-intrinsics@1.1.0: {} mdast-util-definitions@6.0.0: @@ -5125,8 +8129,16 @@ snapshots: dependencies: mime-db: 1.52.0 + mime@2.6.0: {} + + mimic-fn@2.1.0: {} + mimic-function@5.0.1: {} + mimic-response@1.0.1: {} + + mimic-response@3.1.0: {} + minimatch@10.2.5: dependencies: brace-expansion: 5.0.5 @@ -5135,10 +8147,59 @@ snapshots: dependencies: brace-expansion: 1.1.13 + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.0 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.0 + minimist@1.2.8: {} + minipass-collect@1.0.2: + dependencies: + minipass: 3.3.6 + + minipass-fetch@2.1.2: + dependencies: + minipass: 3.3.6 + minipass-sized: 1.0.3 + minizlib: 2.1.2 + optionalDependencies: + encoding: 0.1.13 + + minipass-flush@1.0.7: + dependencies: + minipass: 3.3.6 + + minipass-pipeline@1.2.4: + dependencies: + minipass: 3.3.6 + + minipass-sized@1.0.3: + dependencies: + minipass: 3.3.6 + + minipass@3.3.6: + dependencies: + yallist: 4.0.0 + + minipass@5.0.0: {} + + minipass@7.1.3: {} + + minizlib@2.1.2: + dependencies: + minipass: 3.3.6 + yallist: 4.0.0 + mitt@3.0.1: {} + mkdirp-classic@0.5.3: {} + + mkdirp@1.0.4: {} + mlly@1.8.2: dependencies: acorn: 8.16.0 @@ -5169,10 +8230,42 @@ snapshots: dependencies: picocolors: 1.1.1 + napi-build-utils@2.0.0: {} + + negotiator@0.6.4: {} + + node-abi@3.89.0: + dependencies: + semver: 7.7.4 + + node-addon-api@1.7.2: + optional: true + + node-api-version@0.2.1: + dependencies: + semver: 7.7.4 + node-fetch-native@1.6.7: {} node-forge@1.4.0: {} + node-gyp@9.4.1: + dependencies: + env-paths: 2.2.1 + exponential-backoff: 3.1.3 + glob: 7.2.3 + graceful-fs: 4.2.11 + make-fetch-happen: 10.2.1 + nopt: 6.0.0 + npmlog: 6.0.2 + rimraf: 3.0.2 + semver: 7.7.4 + tar: 6.2.1 + which: 2.0.2 + transitivePeerDependencies: + - bluebird + - supports-color + node-notifier@10.0.1: dependencies: growly: 1.3.0 @@ -5182,8 +8275,23 @@ snapshots: uuid: 8.3.2 which: 2.0.2 + node-releases@2.0.37: {} + + nopt@6.0.0: + dependencies: + abbrev: 1.1.1 + normalize-path@3.0.0: {} + normalize-url@6.1.0: {} + + npmlog@6.0.2: + dependencies: + are-we-there-yet: 3.0.1 + console-control-strings: 1.1.0 + gauge: 4.0.4 + set-blocking: 2.0.0 + nth-check@2.1.1: dependencies: boolbase: 1.0.0 @@ -5194,6 +8302,9 @@ snapshots: pathe: 2.0.3 tinyexec: 1.0.4 + object-keys@1.1.1: + optional: true + obug@2.1.1: {} ofetch@1.5.1: @@ -5206,6 +8317,14 @@ snapshots: on-exit-leak-free@2.1.2: {} + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + onetime@7.0.0: dependencies: mimic-function: 5.0.1 @@ -5225,10 +8344,34 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 + ora@5.4.1: + dependencies: + bl: 4.1.0 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-spinners: 2.9.2 + is-interactive: 1.0.0 + is-unicode-supported: 0.1.0 + log-symbols: 4.1.0 + strip-ansi: 6.0.1 + wcwidth: 1.0.1 + orderedmap@2.1.1: {} os-shim@0.1.3: {} + p-cancelable@2.1.1: {} + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-map@4.0.0: + dependencies: + aggregate-error: 3.1.0 + + package-json-from-dist@1.0.1: {} + package-json@10.0.1: dependencies: ky: 1.14.3 @@ -5248,8 +8391,25 @@ snapshots: path-browserify@1.0.1: {} + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + + pathe@1.1.2: {} + pathe@2.0.3: {} + pathval@2.0.1: {} + + pe-library@0.4.1: {} + + pend@1.2.0: {} + perfect-debounce@1.0.0: {} perfect-debounce@2.1.0: {} @@ -5299,6 +8459,20 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 + playwright-core@1.59.1: {} + + playwright@1.59.1: + dependencies: + playwright-core: 1.59.1 + optionalDependencies: + fsevents: 2.3.2 + + plist@3.1.0: + dependencies: + '@xmldom/xmldom': 0.8.13 + base64-js: 1.5.1 + xmlbuilder: 15.1.1 + postcss@8.5.8: dependencies: nanoid: 3.3.11 @@ -5307,10 +8481,34 @@ snapshots: powershell-utils@0.1.0: {} + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.89.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + process-nextick-args@2.0.1: {} process-warning@5.0.0: {} + progress@2.0.3: {} + + promise-inflight@1.0.1: {} + + promise-retry@2.0.1: + dependencies: + err-code: 2.0.3 + retry: 0.12.0 + promise-toolbox@0.21.0: dependencies: make-error: 1.3.6 @@ -5428,8 +8626,15 @@ snapshots: ofetch: 1.5.1 zod: 4.3.6 + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode.js@2.3.1: {} + punycode@2.3.1: {} + pupa@3.3.0: dependencies: escape-goat: 4.0.0 @@ -5438,6 +8643,8 @@ snapshots: quick-format-unescaped@4.0.4: {} + quick-lru@5.1.1: {} + rc9@3.0.1: dependencies: defu: 6.1.6 @@ -5450,6 +8657,12 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 + read-binary-file-arch@1.0.6: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + readable-stream@2.3.8: dependencies: core-util-is: 1.0.3 @@ -5460,6 +8673,16 @@ snapshots: string_decoder: 1.1.1 util-deprecate: 1.0.2 + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readdir-glob@1.1.3: + dependencies: + minimatch: 5.1.9 + readdirp@5.0.0: {} real-require@0.2.0: {} @@ -5526,15 +8749,46 @@ snapshots: require-directory@2.1.1: {} + resedit@1.7.2: + dependencies: + pe-library: 0.4.1 + resize-observer-polyfill@1.5.1: {} + resolve-alpn@1.2.1: {} + + responselike@2.0.1: + dependencies: + lowercase-keys: 2.0.0 + + restore-cursor@3.1.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + restore-cursor@5.1.0: dependencies: onetime: 7.0.0 signal-exit: 4.1.0 + retry@0.12.0: {} + rfdc@1.4.1: {} + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + roarr@2.15.4: + dependencies: + boolean: 3.2.0 + detect-node: 2.1.0 + globalthis: 1.0.4 + json-stringify-safe: 5.0.1 + semver-compare: 1.0.0 + sprintf-js: 1.1.3 + optional: true + rolldown@1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1): dependencies: '@oxc-project/types': 0.122.0 @@ -5559,6 +8813,37 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' + rollup@4.60.2: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.2 + '@rollup/rollup-android-arm64': 4.60.2 + '@rollup/rollup-darwin-arm64': 4.60.2 + '@rollup/rollup-darwin-x64': 4.60.2 + '@rollup/rollup-freebsd-arm64': 4.60.2 + '@rollup/rollup-freebsd-x64': 4.60.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.2 + '@rollup/rollup-linux-arm-musleabihf': 4.60.2 + '@rollup/rollup-linux-arm64-gnu': 4.60.2 + '@rollup/rollup-linux-arm64-musl': 4.60.2 + '@rollup/rollup-linux-loong64-gnu': 4.60.2 + '@rollup/rollup-linux-loong64-musl': 4.60.2 + '@rollup/rollup-linux-ppc64-gnu': 4.60.2 + '@rollup/rollup-linux-ppc64-musl': 4.60.2 + '@rollup/rollup-linux-riscv64-gnu': 4.60.2 + '@rollup/rollup-linux-riscv64-musl': 4.60.2 + '@rollup/rollup-linux-s390x-gnu': 4.60.2 + '@rollup/rollup-linux-x64-gnu': 4.60.2 + '@rollup/rollup-linux-x64-musl': 4.60.2 + '@rollup/rollup-openbsd-x64': 4.60.2 + '@rollup/rollup-openharmony-arm64': 4.60.2 + '@rollup/rollup-win32-arm64-msvc': 4.60.2 + '@rollup/rollup-win32-ia32-msvc': 4.60.2 + '@rollup/rollup-win32-x64-gnu': 4.60.2 + '@rollup/rollup-win32-x64-msvc': 4.60.2 + fsevents: 2.3.3 + rope-sequence@1.3.4: {} run-applescript@7.1.0: {} @@ -5569,6 +8854,12 @@ snapshots: safe-stable-stringify@2.5.0: {} + safer-buffer@2.1.2: {} + + sanitize-filename@1.6.4: + dependencies: + truncate-utf8-bytes: 1.0.2 + sax@1.6.0: {} scroll-into-view-if-needed@2.2.31: @@ -5577,8 +8868,20 @@ snapshots: scule@1.3.0: {} + semver-compare@1.0.0: + optional: true + + semver@6.3.1: {} + semver@7.7.4: {} + serialize-error@7.0.1: + dependencies: + type-fest: 0.13.1 + optional: true + + set-blocking@2.0.0: {} + set-value@4.1.0: dependencies: is-plain-object: 2.0.4 @@ -5588,14 +8891,43 @@ snapshots: shallow-equal@1.2.1: {} + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + shell-quote@1.7.3: {} shellwords@0.1.1: {} + siginfo@2.0.0: {} + + signal-exit@3.0.7: {} + signal-exit@4.1.0: {} + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + + simple-update-notifier@2.0.0: + dependencies: + semver: 7.7.4 + sisteransi@1.0.5: {} + slice-ansi@3.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + optional: true + slice-ansi@7.1.2: dependencies: ansi-styles: 6.2.3 @@ -5606,6 +8938,21 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + smart-buffer@4.2.0: {} + + socks-proxy-agent@7.0.0: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + socks: 2.8.7 + transitivePeerDependencies: + - supports-color + + socks@2.8.7: + dependencies: + ip-address: 10.1.0 + smart-buffer: 4.2.0 + sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 @@ -5634,12 +8981,31 @@ snapshots: dependencies: through: 2.3.8 + sprintf-js@1.1.3: + optional: true + + ssri@9.0.1: + dependencies: + minipass: 3.3.6 + + stackback@0.0.2: {} + + stat-mode@1.0.0: {} + + std-env@3.10.0: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + string-width@7.2.0: dependencies: emoji-regex: 10.6.0 @@ -5683,10 +9049,49 @@ snapshots: stylis@4.3.6: {} + sumchecker@3.0.1: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + superjson@2.2.6: dependencies: copy-anything: 4.0.5 + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + tar@6.2.1: + dependencies: + chownr: 2.0.0 + fs-minipass: 2.1.0 + minipass: 5.0.0 + minizlib: 2.1.2 + mkdirp: 1.0.4 + yallist: 4.0.0 + + temp-file@3.4.0: + dependencies: + async-exit-hook: 2.0.1 + fs-extra: 10.1.0 + thread-stream@3.1.0: dependencies: real-require: 0.2.0 @@ -5695,6 +9100,12 @@ snapshots: through@2.3.8: {} + tiny-typed-emitter@2.1.0: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + tinyexec@1.0.4: {} tinyglobby@0.2.15: @@ -5702,12 +9113,33 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + + tmp-promise@3.0.3: + dependencies: + tmp: 0.2.5 + tmp@0.2.5: {} trough@2.2.0: {} + truncate-utf8-bytes@1.0.2: + dependencies: + utf8-byte-length: 1.0.5 + tslib@2.8.1: {} + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + type-fest@0.13.1: + optional: true + type-fest@3.13.1: {} type-fest@4.41.0: {} @@ -5751,6 +9183,14 @@ snapshots: unplugin: 3.0.0 unplugin-utils: 0.3.1 + unique-filename@2.0.1: + dependencies: + unique-slug: 3.0.0 + + unique-slug@3.0.0: + dependencies: + imurmurhash: 0.1.4 + unist-util-is@6.0.1: dependencies: '@types/unist': 3.0.3 @@ -5775,6 +9215,8 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 + universalify@0.1.2: {} + universalify@2.0.1: {} unplugin-utils@0.3.1: @@ -5788,6 +9230,12 @@ snapshots: picomatch: 4.0.4 webpack-virtual-modules: 0.6.2 + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + update-notifier@7.3.1: dependencies: boxen: 8.0.1 @@ -5801,10 +9249,23 @@ snapshots: semver: 7.7.4 xdg-basedir: 5.1.0 + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + utf8-byte-length@1.0.5: {} + util-deprecate@1.0.2: {} uuid@8.3.2: {} + verror@1.10.1: + dependencies: + assert-plus: 1.0.0 + core-util-is: 1.0.2 + extsprintf: 1.4.1 + optional: true + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 @@ -5815,6 +9276,24 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite-node@2.1.9(@types/node@24.12.0)(lightningcss@1.32.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@24.12.0)(lightningcss@1.32.0) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vite-node@6.0.0(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(esbuild@0.27.5)(jiti@2.6.1): dependencies: cac: 7.0.0 @@ -5838,6 +9317,16 @@ snapshots: - tsx - yaml + vite@5.4.21(@types/node@24.12.0)(lightningcss@1.32.0): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.8 + rollup: 4.60.2 + optionalDependencies: + '@types/node': 24.12.0 + fsevents: 2.3.3 + lightningcss: 1.32.0 + vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(esbuild@0.27.5)(jiti@2.6.1): dependencies: lightningcss: 1.32.0 @@ -5854,6 +9343,41 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' + vitest@2.1.9(@types/node@24.12.0)(lightningcss@1.32.0): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@24.12.0)(lightningcss@1.32.0)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@24.12.0)(lightningcss@1.32.0) + vite-node: 2.1.9(@types/node@24.12.0)(lightningcss@1.32.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.12.0 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vscode-uri@3.1.0: {} vue-demi@0.14.10(vue@3.5.31(typescript@5.9.3)): @@ -5906,6 +9430,10 @@ snapshots: glob-to-regexp: 0.4.1 graceful-fs: 4.2.11 + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + web-ext-run@0.2.4: dependencies: '@babel/runtime': 7.28.2 @@ -5946,6 +9474,15 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wide-align@1.1.5: + dependencies: + string-width: 4.2.3 + widest-line@5.0.0: dependencies: string-width: 7.2.0 @@ -5964,21 +9501,29 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + wrap-ansi@9.0.2: dependencies: ansi-styles: 6.2.3 string-width: 7.2.0 strip-ansi: 7.2.0 + wrappy@1.0.2: {} + wsl-utils@0.3.1: dependencies: is-wsl: 3.1.1 powershell-utils: 0.1.0 - wxt@0.20.20(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(jiti@2.6.1): + wxt@0.20.20(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(jiti@2.6.1)(rollup@4.60.2): dependencies: '@1natsu/wait-element': 4.1.2 - '@aklinker1/rollup-plugin-visualizer': 5.12.0 + '@aklinker1/rollup-plugin-visualizer': 5.12.0(rollup@4.60.2) '@webext-core/fake-browser': 1.3.4 '@webext-core/isolated-element': 1.1.5 '@webext-core/match-patterns': 1.0.3 @@ -6048,8 +9593,14 @@ snapshots: xmlbuilder@11.0.1: {} + xmlbuilder@15.1.1: {} + y18n@5.0.8: {} + yallist@3.1.1: {} + + yallist@4.0.0: {} + yargs-parser@21.1.1: {} yargs@17.7.2: @@ -6062,11 +9613,24 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + + yocto-queue@0.1.0: {} + zip-dir@2.0.0: dependencies: async: 3.2.6 jszip: 3.10.1 + zip-stream@4.1.1: + dependencies: + archiver-utils: 3.0.4 + compress-commons: 4.1.2 + readable-stream: 3.6.2 + zod@4.3.6: {} zwitch@2.0.4: {} diff --git a/server/cmd/dev-seed/main.go b/server/cmd/dev-seed/main.go index d069f57..e09ad16 100644 --- a/server/cmd/dev-seed/main.go +++ b/server/cmd/dev-seed/main.go @@ -24,6 +24,7 @@ import ( type seedState struct { TenantID int64 UserID int64 + WorkspaceID int64 TestTenantID int64 TestUserID int64 PlanID int64 @@ -180,6 +181,13 @@ func seedBusinessData(ctx context.Context, pool *pgxpool.Pool, membershipCfg con if err := ensureMembership(ctx, tx, state.TenantID, state.UserID); err != nil { return nil, err } + state.WorkspaceID, err = ensureDefaultWorkspace(ctx, tx, state.TenantID) + if err != nil { + return nil, err + } + if err := ensurePrimaryWorkspaceMembership(ctx, tx, state.WorkspaceID, state.TenantID, state.UserID); err != nil { + return nil, err + } state.PlanID, err = ensurePlan(ctx, tx, defaultPlan.Code) if err != nil { return nil, err @@ -355,6 +363,43 @@ func ensureMembershipWithRole(ctx context.Context, tx pgx.Tx, tenantID, userID i return wrapSeedErr("insert membership", err) } +func ensureDefaultWorkspace(ctx context.Context, tx pgx.Tx, tenantID int64) (int64, error) { + var workspaceID int64 + err := tx.QueryRow(ctx, ` + INSERT INTO workspaces (tenant_id, name, slug, is_default) + VALUES ($1, 'Default', 'default', TRUE) + ON CONFLICT (tenant_id, slug) DO UPDATE + SET name = EXCLUDED.name, + is_default = TRUE, + updated_at = NOW() + RETURNING id + `, tenantID).Scan(&workspaceID) + return workspaceID, wrapSeedErr("insert workspace", err) +} + +func ensurePrimaryWorkspaceMembership(ctx context.Context, tx pgx.Tx, workspaceID, tenantID, userID int64) error { + if _, err := tx.Exec(ctx, ` + UPDATE workspace_memberships + SET is_primary = FALSE + WHERE user_id = $1 + AND tenant_id = $2 + AND is_primary = TRUE + AND workspace_id <> $3 + `, userID, tenantID, workspaceID); err != nil { + return wrapSeedErr("clear primary workspace", err) + } + + _, err := tx.Exec(ctx, ` + INSERT INTO workspace_memberships (workspace_id, user_id, tenant_id, role, is_primary) + VALUES ($1, $2, $3, 'member', TRUE) + ON CONFLICT (workspace_id, user_id) DO UPDATE + SET tenant_id = EXCLUDED.tenant_id, + role = EXCLUDED.role, + is_primary = TRUE + `, workspaceID, userID, tenantID) + return wrapSeedErr("insert workspace membership", err) +} + func ensureSecondaryTenantUser( ctx context.Context, tx pgx.Tx, @@ -379,6 +424,13 @@ func ensureSecondaryTenantUser( if err := ensureMembershipWithRole(ctx, tx, tenantID, userID, "tenant_admin"); err != nil { return 0, 0, err } + workspaceID, err := ensureDefaultWorkspace(ctx, tx, tenantID) + if err != nil { + return 0, 0, err + } + if err := ensurePrimaryWorkspaceMembership(ctx, tx, workspaceID, tenantID, userID); err != nil { + return 0, 0, err + } if err := ensureSubscription(ctx, tx, tenantID, planID, defaultPlan.SubscriptionDuration); err != nil { return 0, 0, err } @@ -636,12 +688,13 @@ func ensureMonitoringQuota(ctx context.Context, tx pgx.Tx, state *seedState) err enabledPlatforms, _ := json.Marshal([]string{"deepseek", "qwen", "doubao"}) _, err := tx.Exec(ctx, ` INSERT INTO tenant_monitoring_quotas ( - tenant_id, max_brands, collection_mode, question_selection_mode, collect_frequency, + tenant_id, workspace_id, max_brands, collection_mode, question_selection_mode, collect_frequency, enabled_platforms, primary_installation_id, task_daily_hard_cap, plan_tier ) - VALUES ($1, 1, 'plugin', 'best_effort_all', 'daily', $2::jsonb, $3, 36, 'free') + VALUES ($1, $2, 1, 'plugin', 'best_effort_all', 'daily', $3::jsonb, $4, 36, 'free') ON CONFLICT (tenant_id) DO UPDATE - SET collection_mode = EXCLUDED.collection_mode, + SET workspace_id = COALESCE(tenant_monitoring_quotas.workspace_id, EXCLUDED.workspace_id), + collection_mode = EXCLUDED.collection_mode, question_selection_mode = EXCLUDED.question_selection_mode, collect_frequency = EXCLUDED.collect_frequency, enabled_platforms = EXCLUDED.enabled_platforms, @@ -649,7 +702,7 @@ func ensureMonitoringQuota(ctx context.Context, tx pgx.Tx, state *seedState) err task_daily_hard_cap = EXCLUDED.task_daily_hard_cap, plan_tier = EXCLUDED.plan_tier, updated_at = NOW() - `, state.TenantID, string(enabledPlatforms), state.PluginInstallationID) + `, state.TenantID, state.WorkspaceID, string(enabledPlatforms), state.PluginInstallationID) return wrapSeedErr("upsert monitoring quota", err) } diff --git a/server/cmd/tenant-api/main.go b/server/cmd/tenant-api/main.go index 6185d14..97c7aee 100644 --- a/server/cmd/tenant-api/main.go +++ b/server/cmd/tenant-api/main.go @@ -29,6 +29,7 @@ func main() { defer workerCancel() app.GenerationStreams.Run(workerCtx) + app.DesktopTaskStreams.Run(workerCtx) monitoringCallbackService := tenantapp.NewMonitoringCallbackService(app.DB, app.MonitoringDB, app.RabbitMQ, app.LLM, app.Logger) tenantapp.NewMonitoringResultIngestWorker(monitoringCallbackService, app.RabbitMQ, app.Logger, app.Config.MonitoringWorkers).Start(workerCtx) diff --git a/server/internal/bootstrap/bootstrap.go b/server/internal/bootstrap/bootstrap.go index 02ae8d0..cca37af 100644 --- a/server/internal/bootstrap/bootstrap.go +++ b/server/internal/bootstrap/bootstrap.go @@ -46,6 +46,7 @@ type App struct { ObjectStorage objectstorage.Client AuditLogs *auditlog.AsyncWriter GenerationStreams *stream.GenerationHub + DesktopTaskStreams *stream.DesktopTaskHub Cache cache.Cache KolProfiles repository.KolProfileRepository KolPackages repository.KolPackageRepository @@ -109,6 +110,7 @@ func New(configPath string) (*App, error) { objectStorageClient := objectstorage.New(cfg.ObjectStorage, logger) auditLogs := auditlog.NewAsyncWriter(pool, logger) generationStreams := stream.NewGenerationHub(mqClient) + desktopTaskStreams := stream.NewDesktopTaskHub(mqClient) appCache := cache.New(cfg.Cache.Driver, rdb) kolProfiles := repository.NewKolProfileRepository(pool) kolPackages := repository.NewKolPackageRepository(pool) @@ -169,6 +171,7 @@ func New(configPath string) (*App, error) { ObjectStorage: objectStorageClient, AuditLogs: auditLogs, GenerationStreams: generationStreams, + DesktopTaskStreams: desktopTaskStreams, Cache: appCache, KolProfiles: kolProfiles, KolPackages: kolPackages, diff --git a/server/internal/shared/auth/claims.go b/server/internal/shared/auth/claims.go index 53b969e..afadda8 100644 --- a/server/internal/shared/auth/claims.go +++ b/server/internal/shared/auth/claims.go @@ -5,8 +5,9 @@ import ( ) type Claims struct { - UserID int64 `json:"user_id"` - TenantID int64 `json:"tenant_id"` - Role string `json:"role"` + UserID int64 `json:"user_id"` + TenantID int64 `json:"tenant_id"` + PrimaryWorkspaceID int64 `json:"primary_workspace_id"` + Role string `json:"role"` jwt.RegisteredClaims } diff --git a/server/internal/shared/auth/context.go b/server/internal/shared/auth/context.go index d24e4a2..d7f8621 100644 --- a/server/internal/shared/auth/context.go +++ b/server/internal/shared/auth/context.go @@ -3,9 +3,10 @@ package auth import "context" type Actor struct { - UserID int64 - TenantID int64 - Role string + UserID int64 + TenantID int64 + PrimaryWorkspaceID int64 + Role string } type actorKey struct{} diff --git a/server/internal/shared/auth/context_test.go b/server/internal/shared/auth/context_test.go index 0ad98a7..d10c1ab 100644 --- a/server/internal/shared/auth/context_test.go +++ b/server/internal/shared/auth/context_test.go @@ -8,7 +8,7 @@ import ( ) func TestWithActorAndFromCtx(t *testing.T) { - actor := Actor{UserID: 1, TenantID: 10, Role: "editor"} + actor := Actor{UserID: 1, TenantID: 10, PrimaryWorkspaceID: 100, Role: "editor"} ctx := WithActor(context.Background(), actor) got, ok := ActorFromCtx(ctx) @@ -28,9 +28,22 @@ func TestMustActorPanics(t *testing.T) { } func TestMustActorReturns(t *testing.T) { - actor := Actor{UserID: 5, TenantID: 20, Role: "tenant_admin"} + actor := Actor{UserID: 5, TenantID: 20, PrimaryWorkspaceID: 200, Role: "tenant_admin"} ctx := WithActor(context.Background(), actor) got := MustActor(ctx) assert.Equal(t, actor, got) } + +func TestActorCarriesPrimaryWorkspaceID(t *testing.T) { + ctx := WithActor(context.Background(), Actor{ + UserID: 10, + TenantID: 20, + PrimaryWorkspaceID: 30, + Role: "member", + }) + + actor, ok := ActorFromCtx(ctx) + assert.True(t, ok) + assert.Equal(t, int64(30), actor.PrimaryWorkspaceID) +} diff --git a/server/internal/shared/auth/jwt.go b/server/internal/shared/auth/jwt.go index ae36562..e3a76b5 100644 --- a/server/internal/shared/auth/jwt.go +++ b/server/internal/shared/auth/jwt.go @@ -31,15 +31,16 @@ type TokenPair struct { ExpiresAt int64 `json:"expires_at"` } -func (m *Manager) Issue(userID, tenantID int64, role string) (*TokenPair, error) { +func (m *Manager) Issue(userID, tenantID, primaryWorkspaceID int64, role string) (*TokenPair, error) { now := time.Now() accessJTI := uuid.New().String() refreshJTI := uuid.New().String() accessClaims := Claims{ - UserID: userID, - TenantID: tenantID, - Role: role, + UserID: userID, + TenantID: tenantID, + PrimaryWorkspaceID: primaryWorkspaceID, + Role: role, RegisteredClaims: jwt.RegisteredClaims{ ID: accessJTI, Subject: "access", @@ -53,9 +54,10 @@ func (m *Manager) Issue(userID, tenantID int64, role string) (*TokenPair, error) } refreshClaims := Claims{ - UserID: userID, - TenantID: tenantID, - Role: role, + UserID: userID, + TenantID: tenantID, + PrimaryWorkspaceID: primaryWorkspaceID, + Role: role, RegisteredClaims: jwt.RegisteredClaims{ ID: refreshJTI, Subject: "refresh", diff --git a/server/internal/shared/auth/jwt_test.go b/server/internal/shared/auth/jwt_test.go index ac70491..52381aa 100644 --- a/server/internal/shared/auth/jwt_test.go +++ b/server/internal/shared/auth/jwt_test.go @@ -11,7 +11,7 @@ import ( func TestIssueAndParse(t *testing.T) { mgr := NewManager("test-secret-key", 15*time.Minute, 7*24*time.Hour) - pair, err := mgr.Issue(42, 10, "tenant_admin") + pair, err := mgr.Issue(42, 10, 100, "tenant_admin") require.NoError(t, err) assert.NotEmpty(t, pair.AccessToken) assert.NotEmpty(t, pair.RefreshToken) @@ -23,6 +23,7 @@ func TestIssueAndParse(t *testing.T) { require.NoError(t, err) assert.Equal(t, int64(42), claims.UserID) assert.Equal(t, int64(10), claims.TenantID) + assert.Equal(t, int64(100), claims.PrimaryWorkspaceID) assert.Equal(t, "tenant_admin", claims.Role) assert.Equal(t, "access", claims.Subject) assert.Equal(t, pair.AccessJTI, claims.ID) @@ -31,7 +32,7 @@ func TestIssueAndParse(t *testing.T) { func TestParseRefreshToken(t *testing.T) { mgr := NewManager("test-secret-key", 15*time.Minute, 7*24*time.Hour) - pair, err := mgr.Issue(1, 1, "viewer") + pair, err := mgr.Issue(1, 1, 10, "viewer") require.NoError(t, err) claims, err := mgr.Parse(pair.RefreshToken) @@ -43,7 +44,7 @@ func TestParseRefreshToken(t *testing.T) { func TestParseExpired(t *testing.T) { mgr := NewManager("test-secret-key", -1*time.Second, -1*time.Second) - pair, err := mgr.Issue(1, 1, "viewer") + pair, err := mgr.Issue(1, 1, 10, "viewer") require.NoError(t, err) _, err = mgr.Parse(pair.AccessToken) @@ -54,7 +55,7 @@ func TestParseWrongSecret(t *testing.T) { mgr1 := NewManager("secret-one", 15*time.Minute, 7*24*time.Hour) mgr2 := NewManager("secret-two", 15*time.Minute, 7*24*time.Hour) - pair, err := mgr1.Issue(1, 1, "viewer") + pair, err := mgr1.Issue(1, 1, 10, "viewer") require.NoError(t, err) _, err = mgr2.Parse(pair.AccessToken) diff --git a/server/internal/shared/auth/middleware.go b/server/internal/shared/auth/middleware.go index 1a9869f..f46bae6 100644 --- a/server/internal/shared/auth/middleware.go +++ b/server/internal/shared/auth/middleware.go @@ -87,9 +87,10 @@ func Middleware(jwtMgr *Manager, sessions *SessionStore) gin.HandlerFunc { } actor := Actor{ - UserID: claims.UserID, - TenantID: claims.TenantID, - Role: claims.Role, + UserID: claims.UserID, + TenantID: claims.TenantID, + PrimaryWorkspaceID: claims.PrimaryWorkspaceID, + Role: claims.Role, } c.Request = c.Request.WithContext(WithActor(c.Request.Context(), actor)) c.Next() diff --git a/server/internal/shared/auth/middleware_test.go b/server/internal/shared/auth/middleware_test.go index 72d57ea..5c7dc8d 100644 --- a/server/internal/shared/auth/middleware_test.go +++ b/server/internal/shared/auth/middleware_test.go @@ -30,7 +30,7 @@ func setupTestMiddleware(t *testing.T) (*Manager, *SessionStore, *miniredis.Mini func TestAuthMiddleware_ValidToken(t *testing.T) { mgr, sessions, _ := setupTestMiddleware(t) - pair, err := mgr.Issue(1, 10, "editor") + pair, err := mgr.Issue(1, 10, 100, "editor") require.NoError(t, err) r := gin.New() @@ -52,6 +52,7 @@ func TestAuthMiddleware_ValidToken(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) assert.Equal(t, int64(1), gotActor.UserID) assert.Equal(t, int64(10), gotActor.TenantID) + assert.Equal(t, int64(100), gotActor.PrimaryWorkspaceID) assert.Equal(t, "editor", gotActor.Role) } @@ -87,7 +88,7 @@ func TestAuthMiddleware_InvalidToken(t *testing.T) { func TestAuthMiddleware_RefreshTokenRejected(t *testing.T) { mgr, sessions, _ := setupTestMiddleware(t) - pair, err := mgr.Issue(1, 10, "editor") + pair, err := mgr.Issue(1, 10, 100, "editor") require.NoError(t, err) r := gin.New() @@ -105,7 +106,7 @@ func TestAuthMiddleware_RefreshTokenRejected(t *testing.T) { func TestAuthMiddleware_BlacklistedToken(t *testing.T) { mgr, sessions, mr := setupTestMiddleware(t) - pair, err := mgr.Issue(1, 10, "editor") + pair, err := mgr.Issue(1, 10, 100, "editor") require.NoError(t, err) // Blacklist the access token's JTI diff --git a/server/internal/shared/config/config.go b/server/internal/shared/config/config.go index d56659a..eb03a05 100644 --- a/server/internal/shared/config/config.go +++ b/server/internal/shared/config/config.go @@ -77,6 +77,7 @@ type RabbitMQConfig struct { GenerationDLQ string `mapstructure:"generation_dlq"` GenerationDLQRouteKey string `mapstructure:"generation_dlq_routing_key"` GenerationEventExchange string `mapstructure:"generation_event_exchange"` + DesktopTaskEventExchange string `mapstructure:"desktop_task_event_exchange"` TemplateAssistExchange string `mapstructure:"template_assist_exchange"` TemplateAssistRoutingKey string `mapstructure:"template_assist_routing_key"` TemplateAssistQueue string `mapstructure:"template_assist_queue"` @@ -471,6 +472,9 @@ func normalizeRabbitMQConfig(cfg *RabbitMQConfig) { if strings.TrimSpace(cfg.GenerationEventExchange) == "" { cfg.GenerationEventExchange = "generation.event" } + if strings.TrimSpace(cfg.DesktopTaskEventExchange) == "" { + cfg.DesktopTaskEventExchange = "desktop.task.event" + } if strings.TrimSpace(cfg.TemplateAssistExchange) == "" { cfg.TemplateAssistExchange = "template.assist" } diff --git a/server/internal/shared/messaging/rabbitmq/client.go b/server/internal/shared/messaging/rabbitmq/client.go index d456078..54d598f 100644 --- a/server/internal/shared/messaging/rabbitmq/client.go +++ b/server/internal/shared/messaging/rabbitmq/client.go @@ -98,6 +98,10 @@ func (c *Client) PublishGenerationEvent(ctx context.Context, body []byte) error return c.publishFanout(ctx, c.cfg.GenerationEventExchange, body) } +func (c *Client) PublishDesktopTaskEvent(ctx context.Context, body []byte) error { + return c.publishFanout(ctx, c.cfg.DesktopTaskEventExchange, body) +} + func (c *Client) PublishTemplateAssistTask(ctx context.Context, body []byte) error { return c.publish(ctx, c.cfg.TemplateAssistExchange, c.cfg.TemplateAssistRoutingKey, body) } @@ -180,6 +184,10 @@ func (c *Client) ConsumeGenerationEvents(consumerName string) (<-chan amqp.Deliv return c.consumeFanout(consumerName, c.cfg.GenerationEventExchange) } +func (c *Client) ConsumeDesktopTaskEvents(consumerName string) (<-chan amqp.Delivery, *amqp.Channel, error) { + return c.consumeFanout(consumerName, c.cfg.DesktopTaskEventExchange) +} + func (c *Client) ConsumeTemplateAssistTask(consumerName string) (<-chan amqp.Delivery, *amqp.Channel, error) { return c.consume(consumerName, c.cfg.TemplateAssistQueue) } @@ -558,6 +566,18 @@ func (c *Client) ensureTopology(conn *amqp.Connection) error { return fmt.Errorf("declare rabbitmq exchange %s: %w", c.cfg.GenerationEventExchange, err) } + if err := ch.ExchangeDeclare( + c.cfg.DesktopTaskEventExchange, + "fanout", + false, + false, + false, + false, + nil, + ); err != nil { + return fmt.Errorf("declare rabbitmq exchange %s: %w", c.cfg.DesktopTaskEventExchange, err) + } + if err := c.ensureWorkQueue( ch, c.cfg.TemplateAssistExchange, diff --git a/server/internal/shared/middleware/logger_test.go b/server/internal/shared/middleware/logger_test.go index a4c949e..b586ce5 100644 --- a/server/internal/shared/middleware/logger_test.go +++ b/server/internal/shared/middleware/logger_test.go @@ -36,7 +36,7 @@ func TestLoggerIncludesDetailedAuthFailureContext(t *testing.T) { mgr := sharedauth.NewManager("logger-test-secret", -1*time.Minute, 7*24*time.Hour) sessions := sharedauth.NewSessionStore(rdb) - pair, err := mgr.Issue(7, 42, "editor") + pair, err := mgr.Issue(7, 42, 420, "editor") require.NoError(t, err) router := gin.New() diff --git a/server/internal/shared/stream/desktop.go b/server/internal/shared/stream/desktop.go new file mode 100644 index 0000000..20aaadc --- /dev/null +++ b/server/internal/shared/stream/desktop.go @@ -0,0 +1,158 @@ +package stream + +import ( + "context" + "encoding/json" + "fmt" + "os" + "sync" + "time" + + "github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq" +) + +type DesktopTaskEvent struct { + Type string `json:"type"` + TaskID string `json:"task_id"` + JobID string `json:"job_id"` + WorkspaceID int64 `json:"workspace_id"` + TargetClientID string `json:"target_client_id"` + Status string `json:"status"` + Kind string `json:"kind"` + UpdatedAt time.Time `json:"updated_at"` +} + +type desktopClientStream struct { + subscribers map[int]chan DesktopTaskEvent + nextSubscriber int +} + +type DesktopTaskHub struct { + mu sync.Mutex + streams map[string]*desktopClientStream + rabbitMQ *rabbitmq.Client + instanceID string + consumerName string +} + +func NewDesktopTaskHub(rabbitMQClient *rabbitmq.Client) *DesktopTaskHub { + instanceID := fmt.Sprintf("%d-%d", os.Getpid(), time.Now().UnixNano()) + return &DesktopTaskHub{ + streams: make(map[string]*desktopClientStream), + rabbitMQ: rabbitMQClient, + instanceID: instanceID, + consumerName: "desktop-task-stream-" + instanceID, + } +} + +func (h *DesktopTaskHub) Run(ctx context.Context) { + if h == nil || h.rabbitMQ == nil { + return + } + go h.run(ctx) +} + +func (h *DesktopTaskHub) Publish(event DesktopTaskEvent) { + if h == nil || event.TargetClientID == "" { + return + } + + h.mu.Lock() + stream := h.ensureLocked(event.TargetClientID) + subscribers := make([]chan DesktopTaskEvent, 0, len(stream.subscribers)) + for _, subscriber := range stream.subscribers { + subscribers = append(subscribers, subscriber) + } + h.mu.Unlock() + + for _, subscriber := range subscribers { + select { + case subscriber <- event: + default: + } + } +} + +func (h *DesktopTaskHub) Subscribe(clientID string) (<-chan DesktopTaskEvent, func()) { + if h == nil || clientID == "" { + return nil, func() {} + } + + h.mu.Lock() + stream := h.ensureLocked(clientID) + subscriberID := stream.nextSubscriber + stream.nextSubscriber++ + + ch := make(chan DesktopTaskEvent, 64) + stream.subscribers[subscriberID] = ch + h.mu.Unlock() + + cancel := func() { + h.mu.Lock() + defer h.mu.Unlock() + + current := h.streams[clientID] + if current == nil { + close(ch) + return + } + + delete(current.subscribers, subscriberID) + if len(current.subscribers) == 0 { + delete(h.streams, clientID) + } + close(ch) + } + + return ch, cancel +} + +func (h *DesktopTaskHub) run(ctx context.Context) { + for { + deliveries, ch, err := h.rabbitMQ.ConsumeDesktopTaskEvents(h.consumerName) + if err != nil { + select { + case <-ctx.Done(): + return + case <-time.After(2 * time.Second): + continue + } + } + + closed := false + for !closed { + select { + case <-ctx.Done(): + _ = ch.Close() + return + case delivery, ok := <-deliveries: + if !ok { + closed = true + continue + } + h.handleDelivery(delivery.Body) + _ = delivery.Ack(false) + } + } + _ = ch.Close() + } +} + +func (h *DesktopTaskHub) handleDelivery(body []byte) { + var event DesktopTaskEvent + if err := json.Unmarshal(body, &event); err != nil { + return + } + h.Publish(event) +} + +func (h *DesktopTaskHub) ensureLocked(clientID string) *desktopClientStream { + stream := h.streams[clientID] + if stream == nil { + stream = &desktopClientStream{ + subscribers: make(map[int]chan DesktopTaskEvent), + } + h.streams[clientID] = stream + } + return stream +} diff --git a/server/internal/tenant/app/auth_service.go b/server/internal/tenant/app/auth_service.go index 3a034ce..542b364 100644 --- a/server/internal/tenant/app/auth_service.go +++ b/server/internal/tenant/app/auth_service.go @@ -49,15 +49,16 @@ type LoginResponse struct { } type UserInfo struct { - ID int64 `json:"id"` - Email string `json:"email"` - Name string `json:"name"` - AvatarURL *string `json:"avatar_url"` - TenantID int64 `json:"tenant_id"` - TenantRole string `json:"tenant_role"` - Permissions []string `json:"permissions"` - Membership *MembershipInfo `json:"membership"` - KolProfile *KolProfileBrief `json:"kol_profile"` + ID int64 `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + AvatarURL *string `json:"avatar_url"` + TenantID int64 `json:"tenant_id"` + PrimaryWorkspaceID int64 `json:"primary_workspace_id"` + TenantRole string `json:"tenant_role"` + Permissions []string `json:"permissions"` + Membership *MembershipInfo `json:"membership"` + KolProfile *KolProfileBrief `json:"kol_profile"` } type MembershipInfo struct { @@ -97,7 +98,7 @@ func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*LoginRespon return nil, response.ErrForbidden(40301, "no_tenant", "user has no active tenant membership") } - pair, err := s.jwt.Issue(user.ID, membership.TenantID, membership.TenantRole) + pair, err := s.jwt.Issue(user.ID, membership.TenantID, membership.PrimaryWorkspaceID, membership.TenantRole) if err != nil { return nil, fmt.Errorf("issue token: %w", err) } @@ -121,15 +122,16 @@ func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*LoginRespon RefreshToken: pair.RefreshToken, ExpiresAt: pair.ExpiresAt, User: UserInfo{ - ID: user.ID, - Email: user.Email, - Name: user.Name, - AvatarURL: user.AvatarURL, - TenantID: membership.TenantID, - TenantRole: membership.TenantRole, - Permissions: auth.PermissionsForRole(membership.TenantRole), - Membership: memberInfo, - KolProfile: kolProfile, + ID: user.ID, + Email: user.Email, + Name: user.Name, + AvatarURL: user.AvatarURL, + TenantID: membership.TenantID, + PrimaryWorkspaceID: membership.PrimaryWorkspaceID, + TenantRole: membership.TenantRole, + Permissions: auth.PermissionsForRole(membership.TenantRole), + Membership: memberInfo, + KolProfile: kolProfile, }, }, nil } @@ -150,7 +152,7 @@ func (s *AuthService) Refresh(ctx context.Context, req RefreshRequest) (*Refresh return nil, response.ErrUnauthorized(40120, "invalid_refresh_token", "refresh token is invalid or expired") } - pair, err := s.jwt.Issue(claims.UserID, claims.TenantID, claims.Role) + pair, err := s.jwt.Issue(claims.UserID, claims.TenantID, claims.PrimaryWorkspaceID, claims.Role) if err != nil { return nil, fmt.Errorf("issue token: %w", err) } @@ -196,15 +198,16 @@ func (s *AuthService) Me(ctx context.Context, actor auth.Actor) (*UserInfo, erro } return &UserInfo{ - ID: user.ID, - Email: user.Email, - Name: user.Name, - AvatarURL: user.AvatarURL, - TenantID: actor.TenantID, - TenantRole: actor.Role, - Permissions: auth.PermissionsForRole(actor.Role), - Membership: memberInfo, - KolProfile: kolProfile, + ID: user.ID, + Email: user.Email, + Name: user.Name, + AvatarURL: user.AvatarURL, + TenantID: actor.TenantID, + PrimaryWorkspaceID: actor.PrimaryWorkspaceID, + TenantRole: actor.Role, + Permissions: auth.PermissionsForRole(actor.Role), + Membership: memberInfo, + KolProfile: kolProfile, }, nil } diff --git a/server/internal/tenant/app/desktop_account_service.go b/server/internal/tenant/app/desktop_account_service.go new file mode 100644 index 0000000..8ad55b4 --- /dev/null +++ b/server/internal/tenant/app/desktop_account_service.go @@ -0,0 +1,205 @@ +package app + +import ( + "context" + "errors" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + + "github.com/geo-platform/tenant-api/internal/shared/auth" + "github.com/geo-platform/tenant-api/internal/shared/response" + "github.com/geo-platform/tenant-api/internal/tenant/repository" +) + +type DesktopAccountService struct { + repo repository.DesktopAccountRepository +} + +func NewDesktopAccountService(repo repository.DesktopAccountRepository) *DesktopAccountService { + return &DesktopAccountService{repo: repo} +} + +type DesktopAccountView struct { + ID string `json:"id"` + Platform string `json:"platform"` + PlatformUID string `json:"platform_uid"` + DisplayName string `json:"display_name"` + Health string `json:"health"` + ClientID *string `json:"client_id"` + AccountFingerprint *string `json:"account_fingerprint"` + VerifiedAt *time.Time `json:"verified_at"` + Tags []string `json:"tags"` + SyncVersion int64 `json:"sync_version"` + DeletedAt *time.Time `json:"deleted_at"` + DeleteRequestedAt *time.Time `json:"delete_requested_at"` +} + +type UpsertDesktopAccountRequest struct { + Platform string `json:"platform" binding:"required"` + PlatformUID string `json:"platform_uid" binding:"required"` + AccountFingerprint *string `json:"account_fingerprint"` + DisplayName string `json:"display_name" binding:"required"` + Health string `json:"health" binding:"required,oneof=live expired captcha risk"` + VerifiedAt *time.Time `json:"verified_at"` + Tags []string `json:"tags"` + IfSyncVersion *int64 `json:"if_sync_version"` +} + +type PatchDesktopAccountRequest struct { + DisplayName *string `json:"display_name"` + Health *string `json:"health" binding:"omitempty,oneof=live expired captcha risk"` + VerifiedAt *time.Time `json:"verified_at"` + Tags *[]string `json:"tags"` + IfSyncVersion int64 `json:"if_sync_version" binding:"required"` +} + +func (s *DesktopAccountService) ListByClient(ctx context.Context, client *repository.DesktopClient) ([]DesktopAccountView, error) { + if client == nil { + return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required") + } + + rows, err := s.repo.ListByClient(ctx, client.WorkspaceID, client.ID) + if err != nil { + return nil, response.ErrInternal(50081, "desktop_accounts_query_failed", "failed to list desktop accounts") + } + + items := make([]DesktopAccountView, 0, len(rows)) + for _, item := range rows { + items = append(items, buildDesktopAccountView(&item)) + } + return items, nil +} + +func (s *DesktopAccountService) Upsert(ctx context.Context, client *repository.DesktopClient, req UpsertDesktopAccountRequest) (*DesktopAccountView, error) { + if client == nil { + return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required") + } + + if req.IfSyncVersion != nil { + existing, err := s.repo.GetByIdentity(ctx, client.WorkspaceID, req.Platform, req.PlatformUID) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return nil, response.ErrInternal(50082, "desktop_account_lookup_failed", "failed to inspect desktop account before upsert") + } + if existing == nil || errors.Is(err, pgx.ErrNoRows) { + return nil, response.ErrConflict(40981, "desktop_account_sync_version_required_existing", "if_sync_version is only valid when account already exists") + } + } + + account, err := s.repo.Upsert(ctx, repository.UpsertDesktopAccountParams{ + DesktopID: uuid.New(), + TenantID: client.TenantID, + WorkspaceID: client.WorkspaceID, + UserID: client.UserID, + ClientID: client.ID, + Platform: req.Platform, + PlatformUID: req.PlatformUID, + AccountFingerprint: req.AccountFingerprint, + DisplayName: req.DisplayName, + Health: req.Health, + VerifiedAt: req.VerifiedAt, + Tags: req.Tags, + IfSyncVersion: req.IfSyncVersion, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, response.ErrConflict(40982, "desktop_account_sync_conflict", "desktop account sync version conflict") + } + return nil, response.ErrInternal(50083, "desktop_account_upsert_failed", "failed to upsert desktop account") + } + + view := buildDesktopAccountView(account) + return &view, nil +} + +func (s *DesktopAccountService) Patch(ctx context.Context, client *repository.DesktopClient, desktopID uuid.UUID, req PatchDesktopAccountRequest) (*DesktopAccountView, error) { + if client == nil { + return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required") + } + + account, err := s.repo.Patch(ctx, repository.PatchDesktopAccountParams{ + DesktopID: desktopID, + WorkspaceID: client.WorkspaceID, + DisplayName: req.DisplayName, + Health: req.Health, + VerifiedAt: req.VerifiedAt, + Tags: req.Tags, + IfSyncVersion: req.IfSyncVersion, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, response.ErrConflict(40982, "desktop_account_sync_conflict", "desktop account sync version conflict") + } + return nil, response.ErrInternal(50084, "desktop_account_patch_failed", "failed to patch desktop account") + } + + view := buildDesktopAccountView(account) + return &view, nil +} + +func (s *DesktopAccountService) Tombstone(ctx context.Context, client *repository.DesktopClient, desktopID uuid.UUID, ifSyncVersion int64) (*DesktopAccountView, error) { + if client == nil { + return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required") + } + + account, err := s.repo.Tombstone(ctx, desktopID, client.WorkspaceID, ifSyncVersion) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, response.ErrConflict(40982, "desktop_account_sync_conflict", "desktop account sync version conflict") + } + return nil, response.ErrInternal(50085, "desktop_account_delete_failed", "failed to delete desktop account") + } + + view := buildDesktopAccountView(account) + return &view, nil +} + +func (s *DesktopAccountService) RequestDelete(ctx context.Context, actor auth.Actor, desktopID uuid.UUID, undo bool) (*DesktopAccountView, error) { + if actor.PrimaryWorkspaceID == 0 { + return nil, response.ErrUnauthorized(40132, "workspace_scope_missing", "workspace context is required") + } + + var ( + account *repository.DesktopAccount + err error + ) + + if undo { + account, err = s.repo.ClearDeleteRequested(ctx, desktopID, actor.PrimaryWorkspaceID) + } else { + account, err = s.repo.MarkDeleteRequested(ctx, desktopID, actor.PrimaryWorkspaceID) + } + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, response.ErrNotFound(40481, "desktop_account_not_found", "desktop account not found") + } + return nil, response.ErrInternal(50086, "desktop_account_request_delete_failed", "failed to update desktop account delete request") + } + + view := buildDesktopAccountView(account) + return &view, nil +} + +func buildDesktopAccountView(account *repository.DesktopAccount) DesktopAccountView { + var clientID *string + if account.ClientID != nil { + value := account.ClientID.String() + clientID = &value + } + + return DesktopAccountView{ + ID: account.DesktopID.String(), + Platform: account.Platform, + PlatformUID: account.PlatformUID, + DisplayName: account.DisplayName, + Health: account.Health, + ClientID: clientID, + AccountFingerprint: account.AccountFingerprint, + VerifiedAt: account.VerifiedAt, + Tags: account.Tags, + SyncVersion: account.SyncVersion, + DeletedAt: account.DeletedAt, + DeleteRequestedAt: account.DeleteRequestedAt, + } +} diff --git a/server/internal/tenant/app/desktop_client_service.go b/server/internal/tenant/app/desktop_client_service.go new file mode 100644 index 0000000..1b03f00 --- /dev/null +++ b/server/internal/tenant/app/desktop_client_service.go @@ -0,0 +1,229 @@ +package app + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + + "github.com/geo-platform/tenant-api/internal/shared/auth" + "github.com/geo-platform/tenant-api/internal/shared/response" + "github.com/geo-platform/tenant-api/internal/tenant/repository" +) + +const DesktopClientTokenTTL = 30 * 24 * time.Hour + +type DesktopClientService struct { + repo repository.DesktopClientRepository +} + +func NewDesktopClientService(repo repository.DesktopClientRepository) *DesktopClientService { + return &DesktopClientService{repo: repo} +} + +type RegisterDesktopClientRequest struct { + DeviceName string `json:"device_name" binding:"required"` + OS string `json:"os" binding:"required"` + CPUArch string `json:"cpu_arch"` + ClientVersion string `json:"client_version" binding:"required"` + Channel string `json:"channel"` +} + +type HeartbeatDesktopClientRequest struct { + DeviceName *string `json:"device_name"` + OS *string `json:"os"` + CPUArch *string `json:"cpu_arch"` + ClientVersion *string `json:"client_version"` + Channel *string `json:"channel"` +} + +type DesktopClientView struct { + ID string `json:"id"` + TenantID int64 `json:"tenant_id"` + WorkspaceID int64 `json:"workspace_id"` + UserID int64 `json:"user_id"` + DeviceName *string `json:"device_name"` + OS *string `json:"os"` + CPUArch *string `json:"cpu_arch"` + ClientVersion *string `json:"client_version"` + Channel *string `json:"channel"` + CreatedAt time.Time `json:"created_at"` + LastSeenAt *time.Time `json:"last_seen_at"` + LastRotatedAt *time.Time `json:"last_rotated_at"` + RevokedAt *time.Time `json:"revoked_at"` +} + +type RegisterDesktopClientResponse struct { + ClientID string `json:"client_id"` + ClientToken string `json:"client_token"` + ExpiresAt int64 `json:"expires_at"` + Client DesktopClientView `json:"client"` +} + +type RotateDesktopClientResponse struct { + ClientToken string `json:"client_token"` + ExpiresAt int64 `json:"expires_at"` + Client DesktopClientView `json:"client"` +} + +type HeartbeatDesktopClientResponse struct { + Client DesktopClientView `json:"client"` + ServerTime time.Time `json:"server_time"` +} + +func HashDesktopClientToken(raw string) []byte { + sum := sha256.Sum256([]byte(raw)) + return sum[:] +} + +func (s *DesktopClientService) Register(ctx context.Context, actor auth.Actor, req RegisterDesktopClientRequest) (*RegisterDesktopClientResponse, error) { + if actor.TenantID == 0 || actor.PrimaryWorkspaceID == 0 || actor.UserID == 0 { + return nil, response.ErrUnauthorized(40107, "desktop_register_requires_actor", "desktop client registration requires authenticated workspace context") + } + + rawToken, tokenHash, err := newDesktopClientToken() + if err != nil { + return nil, response.ErrInternal(50071, "desktop_client_token_failed", "failed to generate desktop client token") + } + + client, err := s.repo.Register(ctx, repository.RegisterDesktopClientParams{ + ID: uuid.New(), + TenantID: actor.TenantID, + WorkspaceID: actor.PrimaryWorkspaceID, + UserID: actor.UserID, + TokenHash: tokenHash, + DeviceName: requiredStringPtr(req.DeviceName), + OS: requiredStringPtr(req.OS), + CPUArch: trimmedStringPtr(req.CPUArch), + ClientVersion: requiredStringPtr(req.ClientVersion), + Channel: trimmedStringPtr(req.Channel), + }) + if err != nil { + return nil, response.ErrInternal(50072, "desktop_client_register_failed", "failed to persist desktop client") + } + + return &RegisterDesktopClientResponse{ + ClientID: client.ID.String(), + ClientToken: rawToken, + ExpiresAt: time.Now().Add(DesktopClientTokenTTL).Unix(), + Client: buildDesktopClientView(client), + }, nil +} + +func (s *DesktopClientService) Rotate(ctx context.Context, client *repository.DesktopClient) (*RotateDesktopClientResponse, error) { + if client == nil { + return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required") + } + + rawToken, tokenHash, err := newDesktopClientToken() + if err != nil { + return nil, response.ErrInternal(50073, "desktop_client_rotate_token_failed", "failed to generate next desktop client token") + } + + updated, err := s.repo.RotateToken(ctx, repository.RotateDesktopClientTokenParams{ + ID: client.ID, + WorkspaceID: client.WorkspaceID, + TokenHash: tokenHash, + }) + if err != nil { + if err == pgx.ErrNoRows { + return nil, response.ErrUnauthorized(40109, "desktop_client_revoked", "desktop client is revoked") + } + return nil, response.ErrInternal(50074, "desktop_client_rotate_failed", "failed to rotate desktop client token") + } + + return &RotateDesktopClientResponse{ + ClientToken: rawToken, + ExpiresAt: time.Now().Add(DesktopClientTokenTTL).Unix(), + Client: buildDesktopClientView(updated), + }, nil +} + +func (s *DesktopClientService) Heartbeat(ctx context.Context, client *repository.DesktopClient, req HeartbeatDesktopClientRequest) (*HeartbeatDesktopClientResponse, error) { + if client == nil { + return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required") + } + + updated, err := s.repo.Heartbeat(ctx, repository.HeartbeatDesktopClientParams{ + ID: client.ID, + WorkspaceID: client.WorkspaceID, + DeviceName: req.DeviceName, + OS: req.OS, + CPUArch: req.CPUArch, + ClientVersion: req.ClientVersion, + Channel: req.Channel, + }) + if err != nil { + if err == pgx.ErrNoRows { + return nil, response.ErrUnauthorized(40109, "desktop_client_revoked", "desktop client is revoked") + } + return nil, response.ErrInternal(50075, "desktop_client_heartbeat_failed", "failed to persist desktop client heartbeat") + } + + return &HeartbeatDesktopClientResponse{ + Client: buildDesktopClientView(updated), + ServerTime: time.Now().UTC(), + }, nil +} + +func (s *DesktopClientService) Revoke(ctx context.Context, client *repository.DesktopClient) (*DesktopClientView, error) { + if client == nil { + return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required") + } + + updated, err := s.repo.Revoke(ctx, client.ID, client.WorkspaceID) + if err != nil { + if err == pgx.ErrNoRows { + return nil, response.ErrUnauthorized(40109, "desktop_client_revoked", "desktop client is revoked") + } + return nil, response.ErrInternal(50076, "desktop_client_revoke_failed", "failed to revoke desktop client") + } + + view := buildDesktopClientView(updated) + return &view, nil +} + +func newDesktopClientToken() (string, []byte, error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", nil, fmt.Errorf("generate desktop client token: %w", err) + } + token := hex.EncodeToString(raw) + return token, HashDesktopClientToken(token), nil +} + +func buildDesktopClientView(client *repository.DesktopClient) DesktopClientView { + return DesktopClientView{ + ID: client.ID.String(), + TenantID: client.TenantID, + WorkspaceID: client.WorkspaceID, + UserID: client.UserID, + DeviceName: client.DeviceName, + OS: client.OS, + CPUArch: client.CPUArch, + ClientVersion: client.ClientVersion, + Channel: client.Channel, + CreatedAt: client.CreatedAt, + LastSeenAt: client.LastSeenAt, + LastRotatedAt: client.LastRotatedAt, + RevokedAt: client.RevokedAt, + } +} + +func requiredStringPtr(value string) *string { + trimmed := value + return &trimmed +} + +func trimmedStringPtr(value string) *string { + if value == "" { + return nil + } + trimmed := value + return &trimmed +} diff --git a/server/internal/tenant/app/desktop_content_service.go b/server/internal/tenant/app/desktop_content_service.go new file mode 100644 index 0000000..ae55ea4 --- /dev/null +++ b/server/internal/tenant/app/desktop_content_service.go @@ -0,0 +1,66 @@ +package app + +import ( + "context" + + "github.com/geo-platform/tenant-api/internal/shared/auditlog" + sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache" + "github.com/geo-platform/tenant-api/internal/shared/objectstorage" + "github.com/geo-platform/tenant-api/internal/shared/response" + "github.com/geo-platform/tenant-api/internal/tenant/repository" + "github.com/jackc/pgx/v5/pgxpool" +) + +type DesktopContentService struct { + articles *ArticleService +} + +type DesktopArticleContentView struct { + ArticleID int64 `json:"article_id"` + Title string `json:"title"` + HTMLContent *string `json:"html_content"` + MarkdownContent *string `json:"markdown_content"` + CoverAssetURL *string `json:"cover_asset_url"` +} + +func NewDesktopContentService( + pool *pgxpool.Pool, + auditLogs *auditlog.AsyncWriter, + objectStorage objectstorage.Client, + cache sharedcache.Cache, +) *DesktopContentService { + return &DesktopContentService{ + articles: NewArticleService(pool, auditLogs, objectStorage).WithCache(cache), + } +} + +func (s *DesktopContentService) Article( + ctx context.Context, + client *repository.DesktopClient, + articleID int64, +) (*DesktopArticleContentView, error) { + if client == nil { + return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required") + } + + detail, found, err := s.articles.loadArticleDetail(ctx, client.TenantID, articleID) + if err != nil { + return nil, err + } + if !found || detail == nil { + return nil, response.ErrNotFound(40411, "article_not_found", "article not found") + } + + title := "" + if detail.Title != nil { + title = *detail.Title + } + + return &DesktopArticleContentView{ + ArticleID: detail.ID, + Title: title, + HTMLContent: detail.HTMLContent, + MarkdownContent: detail.MarkdownContent, + CoverAssetURL: detail.CoverAssetURL, + }, nil +} diff --git a/server/internal/tenant/app/desktop_task_events.go b/server/internal/tenant/app/desktop_task_events.go new file mode 100644 index 0000000..38cbab5 --- /dev/null +++ b/server/internal/tenant/app/desktop_task_events.go @@ -0,0 +1,33 @@ +package app + +import ( + "context" + "encoding/json" + "time" + + "github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq" +) + +type DesktopTaskEvent struct { + Type string `json:"type"` + TaskID string `json:"task_id"` + JobID string `json:"job_id"` + WorkspaceID int64 `json:"workspace_id"` + TargetClientID string `json:"target_client_id"` + Status string `json:"status"` + Kind string `json:"kind"` + UpdatedAt time.Time `json:"updated_at"` +} + +func publishDesktopTaskEvent(ctx context.Context, client *rabbitmq.Client, event DesktopTaskEvent) error { + if client == nil { + return nil + } + + payload, err := json.Marshal(event) + if err != nil { + return err + } + + return client.PublishDesktopTaskEvent(ctx, payload) +} diff --git a/server/internal/tenant/app/desktop_task_service.go b/server/internal/tenant/app/desktop_task_service.go new file mode 100644 index 0000000..58c0fa6 --- /dev/null +++ b/server/internal/tenant/app/desktop_task_service.go @@ -0,0 +1,476 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "go.uber.org/zap" + + "github.com/geo-platform/tenant-api/internal/shared/auth" + "github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq" + "github.com/geo-platform/tenant-api/internal/shared/response" + "github.com/geo-platform/tenant-api/internal/tenant/repository" +) + +type DesktopTaskService struct { + repo repository.DesktopTaskRepository + messaging *rabbitmq.Client + logger *zap.Logger +} + +func NewDesktopTaskService(repo repository.DesktopTaskRepository, messaging *rabbitmq.Client, logger *zap.Logger) *DesktopTaskService { + return &DesktopTaskService{ + repo: repo, + messaging: messaging, + logger: logger, + } +} + +type DesktopTaskView struct { + ID string `json:"id"` + JobID string `json:"job_id"` + TenantID int64 `json:"tenant_id"` + WorkspaceID int64 `json:"workspace_id"` + TargetAccountID string `json:"target_account_id"` + TargetClientID string `json:"target_client_id"` + Platform string `json:"platform"` + Kind string `json:"kind"` + Payload json.RawMessage `json:"payload"` + Status string `json:"status"` + DedupKey *string `json:"dedup_key"` + ActiveAttemptID *string `json:"active_attempt_id"` + LeaseExpiresAt *time.Time `json:"lease_expires_at"` + Attempts int `json:"attempts"` + ParkedReason *string `json:"parked_reason"` + Result json.RawMessage `json:"result"` + Error json.RawMessage `json:"error"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type LeaseDesktopTaskRequest struct { + TaskID *string `json:"task_id"` + Kind *string `json:"kind" binding:"omitempty,oneof=publish monitor"` +} + +type LeaseDesktopTaskResponse struct { + Task *DesktopTaskView `json:"task"` + AttemptID *string `json:"attempt_id,omitempty"` + LeaseToken *string `json:"lease_token,omitempty"` + LeaseExpiresAt *time.Time `json:"lease_expires_at,omitempty"` +} + +type ExtendDesktopTaskRequest struct { + LeaseToken string `json:"lease_token" binding:"required"` +} + +type ParkDesktopTaskRequest struct { + LeaseToken string `json:"lease_token" binding:"required"` + Reason string `json:"reason" binding:"required,oneof=waiting_user captcha"` +} + +type CompleteDesktopTaskRequest struct { + LeaseToken string `json:"lease_token" binding:"required"` + Status string `json:"status" binding:"required,oneof=succeeded failed unknown"` + Payload map[string]any `json:"payload"` + Error map[string]any `json:"error"` +} + +type CancelDesktopTaskRequest struct { + LeaseToken *string `json:"lease_token"` + Reason *string `json:"reason"` +} + +type ReconcileDesktopTaskRequest struct { + Status string `json:"status" binding:"required,oneof=succeeded failed aborted retry"` + Result map[string]any `json:"result"` + Error map[string]any `json:"error"` +} + +func (s *DesktopTaskService) Lease(ctx context.Context, client *repository.DesktopClient, req LeaseDesktopTaskRequest, routeTaskID *uuid.UUID, fromParked bool) (*LeaseDesktopTaskResponse, error) { + if client == nil { + return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required") + } + + taskID := routeTaskID + if taskID == nil && req.TaskID != nil && strings.TrimSpace(*req.TaskID) != "" { + parsed, err := uuid.Parse(strings.TrimSpace(*req.TaskID)) + if err != nil { + return nil, response.ErrBadRequest(40084, "invalid_desktop_task_id", "task id must be a uuid") + } + taskID = &parsed + } + + rawToken, tokenHash, err := newDesktopClientToken() + if err != nil { + return nil, response.ErrInternal(50087, "desktop_task_lease_token_failed", "failed to generate desktop task lease token") + } + + attemptID := uuid.New() + leaseParams := repository.DesktopTaskLeaseParams{ + AttemptID: attemptID, + LeaseTokenHash: tokenHash, + } + + var task *repository.DesktopTask + switch { + case fromParked: + if taskID == nil { + return nil, response.ErrBadRequest(40085, "missing_desktop_task_id", "task id is required when from_parked=true") + } + task, err = s.repo.LeaseParked(ctx, *taskID, client.ID, leaseParams) + case taskID != nil: + task, err = s.repo.LeaseQueuedByDesktopID(ctx, *taskID, client.ID, leaseParams) + default: + task, err = s.repo.LeaseNextQueued(ctx, client.ID, req.Kind, leaseParams) + } + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + if taskID == nil && !fromParked { + return &LeaseDesktopTaskResponse{}, nil + } + return nil, s.classifyLeaseError(ctx, client, taskID, fromParked) + } + return nil, response.ErrInternal(50088, "desktop_task_lease_failed", "failed to lease desktop task") + } + + if _, attemptErr := s.repo.CreateAttempt(ctx, repository.CreateDesktopTaskAttemptParams{ + DesktopID: attemptID, + TaskID: task.DesktopID, + ClientID: client.ID, + LeaseTokenHash: tokenHash, + }); attemptErr != nil { + s.logWarn("desktop task attempt insert failed", attemptErr, zap.String("task_id", task.DesktopID.String())) + } + + s.publishTaskEvent(ctx, task, "task_leased") + + view := buildDesktopTaskView(task) + attemptIDText := attemptID.String() + return &LeaseDesktopTaskResponse{ + Task: &view, + AttemptID: &attemptIDText, + LeaseToken: &rawToken, + LeaseExpiresAt: task.LeaseExpiresAt, + }, nil +} + +func (s *DesktopTaskService) Extend(ctx context.Context, client *repository.DesktopClient, desktopID uuid.UUID, req ExtendDesktopTaskRequest) (*DesktopTaskView, error) { + if client == nil { + return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required") + } + + task, err := s.repo.ExtendLease(ctx, desktopID, HashDesktopClientToken(strings.TrimSpace(req.LeaseToken))) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, response.ErrConflict(40983, "desktop_task_lease_invalid", "desktop task lease token is invalid or expired") + } + return nil, response.ErrInternal(50089, "desktop_task_extend_failed", "failed to extend desktop task lease") + } + + s.publishTaskEvent(ctx, task, "task_extended") + + view := buildDesktopTaskView(task) + return &view, nil +} + +func (s *DesktopTaskService) Park(ctx context.Context, client *repository.DesktopClient, desktopID uuid.UUID, req ParkDesktopTaskRequest) (*DesktopTaskView, error) { + if client == nil { + return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required") + } + + previousTask, _ := s.repo.GetByDesktopID(ctx, desktopID, client.WorkspaceID) + previousAttemptID := activeAttemptID(previousTask) + + task, err := s.repo.Park(ctx, desktopID, HashDesktopClientToken(strings.TrimSpace(req.LeaseToken)), req.Reason) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, response.ErrConflict(40983, "desktop_task_lease_invalid", "desktop task lease token is invalid or expired") + } + return nil, response.ErrInternal(50090, "desktop_task_park_failed", "failed to park desktop task") + } + + if previousAttemptID != nil { + if finishErr := s.repo.FinishAttempt(ctx, repository.FinishDesktopTaskAttemptParams{ + TaskID: task.DesktopID, + AttemptID: *previousAttemptID, + FinalStatus: literalStringPtr("waiting_user"), + }); finishErr != nil { + s.logWarn("desktop task attempt finish failed after park", finishErr, zap.String("task_id", task.DesktopID.String())) + } + } + + s.publishTaskEvent(ctx, task, "task_parked") + + view := buildDesktopTaskView(task) + return &view, nil +} + +func (s *DesktopTaskService) Complete(ctx context.Context, client *repository.DesktopClient, desktopID uuid.UUID, req CompleteDesktopTaskRequest) (*DesktopTaskView, error) { + if client == nil { + return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required") + } + + previousTask, _ := s.repo.GetByDesktopID(ctx, desktopID, client.WorkspaceID) + previousAttemptID := activeAttemptID(previousTask) + + resultJSON, err := marshalOptionalJSON(req.Payload) + if err != nil { + return nil, response.ErrBadRequest(40086, "invalid_desktop_task_payload", "payload must be serializable") + } + errorJSON, err := marshalOptionalJSON(req.Error) + if err != nil { + return nil, response.ErrBadRequest(40087, "invalid_desktop_task_error", "error must be serializable") + } + + task, err := s.repo.Complete(ctx, desktopID, HashDesktopClientToken(strings.TrimSpace(req.LeaseToken)), req.Status, resultJSON, errorJSON) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, response.ErrConflict(40983, "desktop_task_lease_invalid", "desktop task lease token is invalid or expired") + } + return nil, response.ErrInternal(50091, "desktop_task_complete_failed", "failed to complete desktop task") + } + + if previousAttemptID != nil { + if finishErr := s.repo.FinishAttempt(ctx, repository.FinishDesktopTaskAttemptParams{ + TaskID: task.DesktopID, + AttemptID: *previousAttemptID, + FinalStatus: &req.Status, + Error: errorJSON, + }); finishErr != nil { + s.logWarn("desktop task attempt finish failed after complete", finishErr, zap.String("task_id", task.DesktopID.String())) + } + } + + s.publishTaskEvent(ctx, task, "task_completed") + + view := buildDesktopTaskView(task) + return &view, nil +} + +func (s *DesktopTaskService) Cancel(ctx context.Context, client *repository.DesktopClient, desktopID uuid.UUID, req CancelDesktopTaskRequest) (*DesktopTaskView, error) { + if client == nil { + return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required") + } + + previousTask, _ := s.repo.GetByDesktopID(ctx, desktopID, client.WorkspaceID) + previousAttemptID := activeAttemptID(previousTask) + + errorJSON, err := marshalOptionalJSON(map[string]any{ + "reason": optionalStringValue(req.Reason), + "source": "desktop_client", + }) + if err != nil { + return nil, response.ErrBadRequest(40088, "invalid_desktop_task_cancel", "cancel payload must be serializable") + } + + var task *repository.DesktopTask + if req.LeaseToken != nil && strings.TrimSpace(*req.LeaseToken) != "" { + task, err = s.repo.CancelByLease(ctx, desktopID, HashDesktopClientToken(strings.TrimSpace(*req.LeaseToken)), errorJSON) + } else { + task, err = s.repo.CancelByClient(ctx, desktopID, client.ID, errorJSON) + } + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, response.ErrConflict(40984, "desktop_task_cancel_conflict", "desktop task cannot be canceled in its current state") + } + return nil, response.ErrInternal(50092, "desktop_task_cancel_failed", "failed to cancel desktop task") + } + + if previousAttemptID != nil && req.LeaseToken != nil && strings.TrimSpace(*req.LeaseToken) != "" { + if finishErr := s.repo.FinishAttempt(ctx, repository.FinishDesktopTaskAttemptParams{ + TaskID: task.DesktopID, + AttemptID: *previousAttemptID, + FinalStatus: literalStringPtr("aborted"), + Error: errorJSON, + }); finishErr != nil { + s.logWarn("desktop task attempt finish failed after cancel", finishErr, zap.String("task_id", task.DesktopID.String())) + } + } + + s.publishTaskEvent(ctx, task, "task_canceled") + + view := buildDesktopTaskView(task) + return &view, nil +} + +func (s *DesktopTaskService) TenantCancel(ctx context.Context, actor auth.Actor, desktopID uuid.UUID, req CancelDesktopTaskRequest) (*DesktopTaskView, error) { + if actor.PrimaryWorkspaceID == 0 { + return nil, response.ErrUnauthorized(40132, "workspace_scope_missing", "workspace context is required") + } + + errorJSON, err := marshalOptionalJSON(map[string]any{ + "reason": optionalStringValue(req.Reason), + "source": "tenant_web", + }) + if err != nil { + return nil, response.ErrBadRequest(40088, "invalid_desktop_task_cancel", "cancel payload must be serializable") + } + + task, err := s.repo.TenantCancel(ctx, desktopID, actor.PrimaryWorkspaceID, errorJSON) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, response.ErrConflict(40984, "desktop_task_cancel_conflict", "desktop task cannot be canceled in its current state") + } + return nil, response.ErrInternal(50093, "desktop_task_tenant_cancel_failed", "failed to cancel desktop task") + } + + s.publishTaskEvent(ctx, task, "task_canceled") + + view := buildDesktopTaskView(task) + return &view, nil +} + +func (s *DesktopTaskService) Reconcile(ctx context.Context, actor auth.Actor, desktopID uuid.UUID, req ReconcileDesktopTaskRequest) (*DesktopTaskView, error) { + if actor.PrimaryWorkspaceID == 0 { + return nil, response.ErrUnauthorized(40132, "workspace_scope_missing", "workspace context is required") + } + + resultJSON, err := marshalOptionalJSON(req.Result) + if err != nil { + return nil, response.ErrBadRequest(40089, "invalid_desktop_task_reconcile_result", "result must be serializable") + } + errorJSON, err := marshalOptionalJSON(req.Error) + if err != nil { + return nil, response.ErrBadRequest(40090, "invalid_desktop_task_reconcile_error", "error must be serializable") + } + + task, err := s.repo.Reconcile(ctx, desktopID, actor.PrimaryWorkspaceID, req.Status, resultJSON, errorJSON) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, response.ErrConflict(40985, "desktop_task_reconcile_conflict", "desktop task is not waiting for reconcile") + } + return nil, response.ErrInternal(50094, "desktop_task_reconcile_failed", "failed to reconcile desktop task") + } + + s.publishTaskEvent(ctx, task, "task_reconciled") + + view := buildDesktopTaskView(task) + return &view, nil +} + +func (s *DesktopTaskService) classifyLeaseError(ctx context.Context, client *repository.DesktopClient, taskID *uuid.UUID, fromParked bool) error { + if taskID == nil { + return response.ErrConflict(40986, "desktop_task_unavailable", "no desktop task is currently available") + } + + task, err := s.repo.GetByDesktopID(ctx, *taskID, client.WorkspaceID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return response.ErrNotFound(40482, "desktop_task_not_found", "desktop task not found") + } + return response.ErrInternal(50095, "desktop_task_lookup_failed", "failed to inspect desktop task state") + } + + if task.TargetClientID != client.ID { + return response.ErrForbidden(40381, "desktop_task_not_target_client", "desktop task is assigned to another desktop client") + } + + if fromParked { + switch task.Status { + case "aborted": + return response.ErrConflict(40987, "desktop_task_aborted", "desktop task has already been aborted") + case "succeeded", "failed", "unknown": + return response.ErrConflict(40988, "desktop_task_terminal", "desktop task is already in a terminal state") + case "in_progress": + return response.ErrConflict(40989, "desktop_task_already_in_progress", "desktop task is already in progress") + default: + return response.ErrConflict(40990, "desktop_task_not_parked", "desktop task is not waiting_user") + } + } + + if task.Status == "in_progress" { + return response.ErrConflict(40989, "desktop_task_already_in_progress", "desktop task is already in progress") + } + + return response.ErrConflict(40991, "desktop_task_not_queued", "desktop task is not queued") +} + +func buildDesktopTaskView(task *repository.DesktopTask) DesktopTaskView { + var activeAttemptID *string + if task.ActiveAttemptID != nil { + value := task.ActiveAttemptID.String() + activeAttemptID = &value + } + + return DesktopTaskView{ + ID: task.DesktopID.String(), + JobID: task.JobID.String(), + TenantID: task.TenantID, + WorkspaceID: task.WorkspaceID, + TargetAccountID: task.TargetAccountID.String(), + TargetClientID: task.TargetClientID.String(), + Platform: task.Platform, + Kind: task.Kind, + Payload: json.RawMessage(task.Payload), + Status: task.Status, + DedupKey: task.DedupKey, + ActiveAttemptID: activeAttemptID, + LeaseExpiresAt: task.LeaseExpiresAt, + Attempts: task.Attempts, + ParkedReason: task.ParkedReason, + Result: json.RawMessage(task.Result), + Error: json.RawMessage(task.Error), + CreatedAt: task.CreatedAt, + UpdatedAt: task.UpdatedAt, + } +} + +func marshalOptionalJSON(value any) ([]byte, error) { + if value == nil { + return nil, nil + } + return json.Marshal(value) +} + +func optionalStringValue(value *string) string { + if value == nil { + return "" + } + return strings.TrimSpace(*value) +} + +func literalStringPtr(value string) *string { + return &value +} + +func activeAttemptID(task *repository.DesktopTask) *uuid.UUID { + if task == nil || task.ActiveAttemptID == nil { + return nil + } + value := *task.ActiveAttemptID + return &value +} + +func (s *DesktopTaskService) publishTaskEvent(ctx context.Context, task *repository.DesktopTask, eventType string) { + if task == nil { + return + } + + err := publishDesktopTaskEvent(ctx, s.messaging, DesktopTaskEvent{ + Type: eventType, + TaskID: task.DesktopID.String(), + JobID: task.JobID.String(), + WorkspaceID: task.WorkspaceID, + TargetClientID: task.TargetClientID.String(), + Status: task.Status, + Kind: task.Kind, + UpdatedAt: task.UpdatedAt, + }) + if err != nil { + s.logWarn("desktop task event publish failed", err, zap.String("task_id", task.DesktopID.String()), zap.String("event_type", eventType)) + } +} + +func (s *DesktopTaskService) logWarn(message string, err error, fields ...zap.Field) { + if s.logger == nil || err == nil { + return + } + fields = append(fields, zap.Error(err)) + s.logger.Warn(message, fields...) +} diff --git a/server/internal/tenant/app/media_service.go b/server/internal/tenant/app/media_service.go index 4ba1316..08b1897 100644 --- a/server/internal/tenant/app/media_service.go +++ b/server/internal/tenant/app/media_service.go @@ -314,11 +314,16 @@ func (s *MediaService) RegisterPluginInstallation(ctx context.Context, req Regis func ensureMonitoringPrimaryInstallation(ctx context.Context, tx pgx.Tx, tenantID, installationID int64) error { if _, err := tx.Exec(ctx, ` INSERT INTO tenant_monitoring_quotas ( - tenant_id, primary_installation_id + tenant_id, workspace_id, primary_installation_id + ) + VALUES ( + $1, + (SELECT id FROM workspaces WHERE tenant_id = $1 AND is_default = TRUE LIMIT 1), + $2 ) - VALUES ($1, $2) ON CONFLICT (tenant_id) DO UPDATE - SET primary_installation_id = COALESCE(tenant_monitoring_quotas.primary_installation_id, EXCLUDED.primary_installation_id), + SET workspace_id = COALESCE(tenant_monitoring_quotas.workspace_id, EXCLUDED.workspace_id), + primary_installation_id = COALESCE(tenant_monitoring_quotas.primary_installation_id, EXCLUDED.primary_installation_id), updated_at = NOW() `, tenantID, installationID); err != nil { return response.ErrInternal(50068, "monitoring_quota_update_failed", "failed to assign monitoring plugin installation") @@ -652,7 +657,11 @@ func (s *MediaService) HandleBindCallback(ctx context.Context, req PluginBindCal err = tx.QueryRow(ctx, ` SELECT id FROM platform_accounts - WHERE tenant_id = $1 AND platform_id = $2 AND platform_uid = $3 AND deleted_at IS NULL + WHERE tenant_id = $1 + AND workspace_id = (SELECT id FROM workspaces WHERE tenant_id = $1 AND is_default = TRUE LIMIT 1) + AND platform_id = $2 + AND platform_uid = $3 + AND deleted_at IS NULL LIMIT 1 `, session.TenantID, req.PlatformID, req.PlatformUID).Scan(&platformAccountID) @@ -660,10 +669,29 @@ func (s *MediaService) HandleBindCallback(ctx context.Context, req PluginBindCal case errors.Is(err, pgx.ErrNoRows): if err := tx.QueryRow(ctx, ` INSERT INTO platform_accounts ( - tenant_id, user_id, platform_id, platform_uid, nickname, avatar_url, status, - metadata_json, last_check_at + tenant_id, workspace_id, user_id, platform_id, platform_uid, nickname, avatar_url, status, + metadata_json, last_check_at, desktop_id, client_id, account_fingerprint, + display_name, health, verified_at, tags, sync_version + ) + VALUES ( + $1, + (SELECT id FROM workspaces WHERE tenant_id = $1 AND is_default = TRUE LIMIT 1), + $2, $3, $4, $5, $6, $7, $8, NOW(), + gen_random_uuid(), NULL, NULL, + $5, + CASE + WHEN $7 = 'active' THEN 'live' + WHEN $7 = 'captcha' THEN 'captcha' + WHEN $7 = 'risk' THEN 'risk' + ELSE 'expired' + END, + NOW(), + CASE + WHEN $8 IS NOT NULL AND jsonb_typeof($8 -> 'tags') = 'array' THEN $8 -> 'tags' + ELSE NULL + END, + 1 ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW()) RETURNING id `, session.TenantID, session.UserID, req.PlatformID, req.PlatformUID, req.Nickname, req.AvatarURL, status, metadataJSON).Scan(&platformAccountID); err != nil { return nil, response.ErrInternal(50045, "platform_account_insert_failed", "failed to create platform account") @@ -675,10 +703,23 @@ func (s *MediaService) HandleBindCallback(ctx context.Context, req PluginBindCal UPDATE platform_accounts SET user_id = $1, nickname = $2, + display_name = $2, avatar_url = $3, status = $4, + health = CASE + WHEN $4 = 'active' THEN 'live' + WHEN $4 = 'captcha' THEN 'captcha' + WHEN $4 = 'risk' THEN 'risk' + ELSE 'expired' + END, metadata_json = $5, last_check_at = NOW(), + verified_at = NOW(), + tags = CASE + WHEN $5 IS NOT NULL AND jsonb_typeof($5 -> 'tags') = 'array' THEN $5 -> 'tags' + ELSE tags + END, + sync_version = sync_version + 1, updated_at = NOW() WHERE id = $6 `, session.UserID, req.Nickname, req.AvatarURL, status, metadataJSON, platformAccountID); err != nil { diff --git a/server/internal/tenant/app/monitoring_callback_service.go b/server/internal/tenant/app/monitoring_callback_service.go index 794a68c..c184e09 100644 --- a/server/internal/tenant/app/monitoring_callback_service.go +++ b/server/internal/tenant/app/monitoring_callback_service.go @@ -785,11 +785,16 @@ func (s *MonitoringCallbackService) isMonitoringInstallationRecentlyOnline(ctx c func (s *MonitoringCallbackService) persistHeartbeatPrimaryInstallationID(ctx context.Context, tenantID, installationID int64) error { if _, err := s.businessPool.Exec(ctx, ` INSERT INTO tenant_monitoring_quotas ( - tenant_id, primary_installation_id + tenant_id, workspace_id, primary_installation_id + ) + VALUES ( + $1, + (SELECT id FROM workspaces WHERE tenant_id = $1 AND is_default = TRUE LIMIT 1), + $2 ) - VALUES ($1, $2) ON CONFLICT (tenant_id) DO UPDATE - SET primary_installation_id = EXCLUDED.primary_installation_id, + SET workspace_id = COALESCE(tenant_monitoring_quotas.workspace_id, EXCLUDED.workspace_id), + primary_installation_id = EXCLUDED.primary_installation_id, updated_at = NOW() `, tenantID, installationID); err != nil { return response.ErrInternal(50041, "update_failed", "failed to persist primary monitoring installation") diff --git a/server/internal/tenant/app/monitoring_service.go b/server/internal/tenant/app/monitoring_service.go index 10ae8b2..c91f07c 100644 --- a/server/internal/tenant/app/monitoring_service.go +++ b/server/internal/tenant/app/monitoring_service.go @@ -637,11 +637,16 @@ func (s *MonitoringService) findLatestOnlineInstallation(ctx context.Context, te func (s *MonitoringService) persistPrimaryInstallationID(ctx context.Context, tenantID, installationID int64) error { if _, err := s.businessPool.Exec(ctx, ` INSERT INTO tenant_monitoring_quotas ( - tenant_id, primary_installation_id + tenant_id, workspace_id, primary_installation_id + ) + VALUES ( + $1, + (SELECT id FROM workspaces WHERE tenant_id = $1 AND is_default = TRUE LIMIT 1), + $2 ) - VALUES ($1, $2) ON CONFLICT (tenant_id) DO UPDATE - SET primary_installation_id = EXCLUDED.primary_installation_id, + SET workspace_id = COALESCE(tenant_monitoring_quotas.workspace_id, EXCLUDED.workspace_id), + primary_installation_id = EXCLUDED.primary_installation_id, updated_at = NOW() `, tenantID, installationID); err != nil { return response.ErrInternal(50041, "update_failed", "failed to persist primary monitoring installation") diff --git a/server/internal/tenant/app/publish_job_service.go b/server/internal/tenant/app/publish_job_service.go new file mode 100644 index 0000000..999f5d5 --- /dev/null +++ b/server/internal/tenant/app/publish_job_service.go @@ -0,0 +1,169 @@ +package app + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "go.uber.org/zap" + + "github.com/geo-platform/tenant-api/internal/shared/auth" + "github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq" + "github.com/geo-platform/tenant-api/internal/shared/response" + "github.com/geo-platform/tenant-api/internal/tenant/repository" +) + +type PublishJobService struct { + pool *pgxpool.Pool + messaging *rabbitmq.Client + logger *zap.Logger +} + +func NewPublishJobService(pool *pgxpool.Pool, messaging *rabbitmq.Client, logger *zap.Logger) *PublishJobService { + return &PublishJobService{ + pool: pool, + messaging: messaging, + logger: logger, + } +} + +type CreatePublishJobAccountRequest struct { + AccountID string `json:"account_id" binding:"required"` + Mode string `json:"mode" binding:"required,oneof=auto manual"` +} + +type CreatePublishJobRequest struct { + Title string `json:"title" binding:"required"` + ContentRef map[string]any `json:"content_ref" binding:"required"` + Accounts []CreatePublishJobAccountRequest `json:"accounts" binding:"required,min=1"` + ScheduledAt *time.Time `json:"scheduled_at"` +} + +type CreatePublishJobResponse struct { + JobID string `json:"job_id"` + TaskIDs []string `json:"task_ids"` +} + +func (s *PublishJobService) Create(ctx context.Context, actor auth.Actor, req CreatePublishJobRequest) (*CreatePublishJobResponse, error) { + if actor.TenantID == 0 || actor.PrimaryWorkspaceID == 0 || actor.UserID == 0 { + return nil, response.ErrUnauthorized(40132, "workspace_scope_missing", "workspace context is required") + } + + if s.pool == nil { + return nil, response.ErrServiceUnavailable(50342, "desktop_publish_job_store_unavailable", "desktop publish job store is unavailable") + } + + contentRefJSON, err := marshalOptionalJSON(req.ContentRef) + if err != nil { + return nil, response.ErrBadRequest(40091, "invalid_content_ref", "content_ref must be serializable") + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, response.ErrInternal(50096, "desktop_publish_job_begin_failed", "failed to start desktop publish job transaction") + } + defer tx.Rollback(ctx) + + accountRepo := repository.NewDesktopAccountRepository(tx) + taskRepo := repository.NewDesktopTaskRepository(tx) + + jobID := uuid.New() + job, err := taskRepo.CreatePublishJob(ctx, repository.CreateDesktopPublishJobParams{ + DesktopID: jobID, + TenantID: actor.TenantID, + WorkspaceID: actor.PrimaryWorkspaceID, + CreatedByUserID: actor.UserID, + Title: req.Title, + ContentRef: contentRefJSON, + ScheduledAt: req.ScheduledAt, + }) + if err != nil { + return nil, response.ErrInternal(50097, "desktop_publish_job_create_failed", "failed to create desktop publish job") + } + + taskIDs := make([]string, 0, len(req.Accounts)) + createdTasks := make([]*repository.DesktopTask, 0, len(req.Accounts)) + for _, accountReq := range req.Accounts { + accountID, parseErr := uuid.Parse(strings.TrimSpace(accountReq.AccountID)) + if parseErr != nil { + return nil, response.ErrBadRequest(40092, "invalid_desktop_account_id", "account_id must be a uuid") + } + + account, lookupErr := accountRepo.GetByDesktopID(ctx, accountID, actor.PrimaryWorkspaceID) + if lookupErr != nil { + if errors.Is(lookupErr, pgx.ErrNoRows) { + return nil, response.ErrBadRequest(40093, "desktop_account_not_found", "one or more selected desktop accounts do not exist") + } + return nil, response.ErrInternal(50098, "desktop_account_lookup_failed", "failed to load selected desktop account") + } + if account.ClientID == nil { + return nil, response.ErrConflict(40992, "desktop_account_client_missing", "one or more selected desktop accounts are not currently bound to a desktop client") + } + + payloadJSON, payloadErr := marshalOptionalJSON(map[string]any{ + "title": req.Title, + "content_ref": req.ContentRef, + "mode": accountReq.Mode, + "account_id": account.DesktopID.String(), + "platform": account.Platform, + }) + if payloadErr != nil { + return nil, response.ErrBadRequest(40094, "invalid_desktop_task_payload", "publish job payload must be serializable") + } + + task, createErr := taskRepo.CreateTask(ctx, repository.CreateDesktopTaskParams{ + DesktopID: uuid.New(), + JobID: job.DesktopID, + TenantID: actor.TenantID, + WorkspaceID: actor.PrimaryWorkspaceID, + TargetAccountID: account.DesktopID, + TargetClientID: *account.ClientID, + Platform: account.Platform, + Kind: "publish", + Payload: payloadJSON, + Status: "queued", + }) + if createErr != nil { + return nil, response.ErrInternal(50099, "desktop_task_create_failed", "failed to create desktop publish task") + } + + createdTasks = append(createdTasks, task) + taskIDs = append(taskIDs, task.DesktopID.String()) + } + + if err := tx.Commit(ctx); err != nil { + return nil, response.ErrInternal(50100, "desktop_publish_job_commit_failed", "failed to commit desktop publish job") + } + + for _, task := range createdTasks { + if publishErr := publishDesktopTaskEvent(context.Background(), s.messaging, DesktopTaskEvent{ + Type: "task_available", + TaskID: task.DesktopID.String(), + JobID: task.JobID.String(), + WorkspaceID: task.WorkspaceID, + TargetClientID: task.TargetClientID.String(), + Status: task.Status, + Kind: task.Kind, + UpdatedAt: task.UpdatedAt, + }); publishErr != nil { + s.logWarn("desktop task available event publish failed", publishErr, zap.String("task_id", task.DesktopID.String())) + } + } + + return &CreatePublishJobResponse{ + JobID: job.DesktopID.String(), + TaskIDs: taskIDs, + }, nil +} + +func (s *PublishJobService) logWarn(message string, err error, fields ...zap.Field) { + if s.logger == nil || err == nil { + return + } + fields = append(fields, zap.Error(err)) + s.logger.Warn(message, fields...) +} diff --git a/server/internal/tenant/repository/auth_repo.go b/server/internal/tenant/repository/auth_repo.go index f44b8ad..18c6366 100644 --- a/server/internal/tenant/repository/auth_repo.go +++ b/server/internal/tenant/repository/auth_repo.go @@ -20,11 +20,12 @@ type UserRecord struct { } type MembershipRecord struct { - ID int64 - TenantID int64 - UserID int64 - TenantRole string - CreatedAt time.Time + ID int64 + TenantID int64 + UserID int64 + PrimaryWorkspaceID int64 + TenantRole string + CreatedAt time.Time } type AuthRepository interface { @@ -87,11 +88,12 @@ func (r *authRepository) GetTenantMembership(ctx context.Context, userID int64) } return &MembershipRecord{ - ID: row.ID, - TenantID: row.TenantID, - UserID: row.UserID, - TenantRole: row.TenantRole, - CreatedAt: timeFromTimestamp(row.CreatedAt), + ID: row.ID, + TenantID: row.TenantID, + UserID: row.UserID, + PrimaryWorkspaceID: row.PrimaryWorkspaceID, + TenantRole: row.TenantRole, + CreatedAt: timeFromTimestamp(row.CreatedAt), }, nil } @@ -105,10 +107,11 @@ func (r *authRepository) GetTenantMembershipByTenantAndUser(ctx context.Context, } return &MembershipRecord{ - ID: row.ID, - TenantID: row.TenantID, - UserID: row.UserID, - TenantRole: row.TenantRole, - CreatedAt: timeFromTimestamp(row.CreatedAt), + ID: row.ID, + TenantID: row.TenantID, + UserID: row.UserID, + PrimaryWorkspaceID: row.PrimaryWorkspaceID, + TenantRole: row.TenantRole, + CreatedAt: timeFromTimestamp(row.CreatedAt), }, nil } diff --git a/server/internal/tenant/repository/common.go b/server/internal/tenant/repository/common.go index ebb7ff2..da040c4 100644 --- a/server/internal/tenant/repository/common.go +++ b/server/internal/tenant/repository/common.go @@ -1,8 +1,10 @@ package repository import ( + "encoding/json" "time" + "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "github.com/geo-platform/tenant-api/internal/tenant/repository/generated" @@ -86,3 +88,40 @@ func pgTimestamp(value *time.Time) pgtype.Timestamptz { } return pgtype.Timestamptz{Time: *value, Valid: true} } + +func pgUUID(value uuid.UUID) pgtype.UUID { + return pgtype.UUID{Bytes: value, Valid: true} +} + +func uuidFromPG(value pgtype.UUID) uuid.UUID { + if !value.Valid { + return uuid.Nil + } + return uuid.UUID(value.Bytes) +} + +func nullableUUID(value pgtype.UUID) *uuid.UUID { + if !value.Valid { + return nil + } + parsed := uuid.UUID(value.Bytes) + return &parsed +} + +func marshalJSON(value any) ([]byte, error) { + if value == nil { + return nil, nil + } + return json.Marshal(value) +} + +func unmarshalStringSlice(data []byte) ([]string, error) { + if len(data) == 0 { + return nil, nil + } + var items []string + if err := json.Unmarshal(data, &items); err != nil { + return nil, err + } + return items, nil +} diff --git a/server/internal/tenant/repository/desktop_account_repo.go b/server/internal/tenant/repository/desktop_account_repo.go new file mode 100644 index 0000000..8585fce --- /dev/null +++ b/server/internal/tenant/repository/desktop_account_repo.go @@ -0,0 +1,459 @@ +package repository + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + + "github.com/geo-platform/tenant-api/internal/tenant/repository/generated" +) + +type DesktopAccount struct { + DesktopID uuid.UUID + TenantID int64 + WorkspaceID int64 + UserID int64 + ClientID *uuid.UUID + Platform string + PlatformUID string + AccountFingerprint *string + DisplayName string + Health string + VerifiedAt *time.Time + Tags []string + SyncVersion int64 + DeletedAt *time.Time + DeleteRequestedAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +type UpsertDesktopAccountParams struct { + DesktopID uuid.UUID + TenantID int64 + WorkspaceID int64 + UserID int64 + ClientID uuid.UUID + Platform string + PlatformUID string + AccountFingerprint *string + DisplayName string + Health string + VerifiedAt *time.Time + Tags []string + IfSyncVersion *int64 +} + +type PatchDesktopAccountParams struct { + DesktopID uuid.UUID + WorkspaceID int64 + DisplayName *string + Health *string + VerifiedAt *time.Time + Tags *[]string + IfSyncVersion int64 +} + +type DesktopAccountRepository interface { + ListByClient(ctx context.Context, workspaceID int64, clientID uuid.UUID) ([]DesktopAccount, error) + GetByDesktopID(ctx context.Context, desktopID uuid.UUID, workspaceID int64) (*DesktopAccount, error) + GetByIdentity(ctx context.Context, workspaceID int64, platform, platformUID string) (*DesktopAccount, error) + Upsert(ctx context.Context, params UpsertDesktopAccountParams) (*DesktopAccount, error) + Patch(ctx context.Context, params PatchDesktopAccountParams) (*DesktopAccount, error) + Tombstone(ctx context.Context, desktopID uuid.UUID, workspaceID, ifSyncVersion int64) (*DesktopAccount, error) + MarkDeleteRequested(ctx context.Context, desktopID uuid.UUID, workspaceID int64) (*DesktopAccount, error) + ClearDeleteRequested(ctx context.Context, desktopID uuid.UUID, workspaceID int64) (*DesktopAccount, error) +} + +type desktopAccountRepository struct { + q generated.Querier +} + +func NewDesktopAccountRepository(db generated.DBTX) DesktopAccountRepository { + return &desktopAccountRepository{q: newQuerier(db)} +} + +func (r *desktopAccountRepository) ListByClient(ctx context.Context, workspaceID int64, clientID uuid.UUID) ([]DesktopAccount, error) { + rows, err := r.q.ListDesktopAccountsByClient(ctx, generated.ListDesktopAccountsByClientParams{ + WorkspaceID: workspaceID, + ClientID: pgUUID(clientID), + }) + if err != nil { + return nil, err + } + + items := make([]DesktopAccount, 0, len(rows)) + for _, row := range rows { + item, err := desktopAccountFromListRow(row) + if err != nil { + return nil, err + } + items = append(items, *item) + } + return items, nil +} + +func (r *desktopAccountRepository) GetByDesktopID(ctx context.Context, desktopID uuid.UUID, workspaceID int64) (*DesktopAccount, error) { + row, err := r.q.GetDesktopAccountByDesktopID(ctx, generated.GetDesktopAccountByDesktopIDParams{ + DesktopID: pgUUID(desktopID), + WorkspaceID: workspaceID, + }) + if err != nil { + return nil, err + } + return desktopAccountFromGetRow(row) +} + +func (r *desktopAccountRepository) GetByIdentity(ctx context.Context, workspaceID int64, platform, platformUID string) (*DesktopAccount, error) { + row, err := r.q.GetDesktopAccountByIdentity(ctx, generated.GetDesktopAccountByIdentityParams{ + WorkspaceID: workspaceID, + PlatformID: platform, + PlatformUid: platformUID, + }) + if err != nil { + return nil, err + } + return desktopAccountFromIdentityRow(row) +} + +func (r *desktopAccountRepository) Upsert(ctx context.Context, params UpsertDesktopAccountParams) (*DesktopAccount, error) { + tagsJSON, err := marshalJSON(params.Tags) + if err != nil { + return nil, fmt.Errorf("marshal tags: %w", err) + } + + var metadataJSON []byte + if len(tagsJSON) > 0 { + metadataJSON, err = marshalJSON(map[string]any{"tags": params.Tags}) + if err != nil { + return nil, fmt.Errorf("marshal metadata: %w", err) + } + } + + row, err := r.q.UpsertDesktopAccount(ctx, generated.UpsertDesktopAccountParams{ + DesktopID: pgUUID(params.DesktopID), + TenantID: params.TenantID, + WorkspaceID: params.WorkspaceID, + UserID: params.UserID, + ClientID: pgUUID(params.ClientID), + PlatformID: params.Platform, + PlatformUid: params.PlatformUID, + DisplayName: params.DisplayName, + LegacyStatus: legacyStatusFromHealth(params.Health), + MetadataJson: metadataJSON, + VerifiedAt: pgTimestamp(params.VerifiedAt), + AccountFingerprint: pgText(params.AccountFingerprint), + Health: params.Health, + Tags: tagsJSON, + IfSyncVersion: pgInt8(params.IfSyncVersion), + }) + if err != nil { + return nil, err + } + return desktopAccountFromUpsertRow(row) +} + +func (r *desktopAccountRepository) Patch(ctx context.Context, params PatchDesktopAccountParams) (*DesktopAccount, error) { + var tagsJSON []byte + var err error + if params.Tags != nil { + tagsJSON, err = marshalJSON(*params.Tags) + if err != nil { + return nil, fmt.Errorf("marshal tags: %w", err) + } + } + + row, err := r.q.PatchDesktopAccount(ctx, generated.PatchDesktopAccountParams{ + DisplayName: pgText(params.DisplayName), + Health: pgText(params.Health), + LegacyStatus: pgText(legacyStatusPtrFromHealth(params.Health)), + VerifiedAt: pgTimestamp(params.VerifiedAt), + Tags: tagsJSON, + DesktopID: pgUUID(params.DesktopID), + WorkspaceID: params.WorkspaceID, + IfSyncVersion: params.IfSyncVersion, + }) + if err != nil { + return nil, err + } + return desktopAccountFromPatchRow(row) +} + +func (r *desktopAccountRepository) Tombstone(ctx context.Context, desktopID uuid.UUID, workspaceID, ifSyncVersion int64) (*DesktopAccount, error) { + row, err := r.q.TombstoneDesktopAccount(ctx, generated.TombstoneDesktopAccountParams{ + DesktopID: pgUUID(desktopID), + WorkspaceID: workspaceID, + IfSyncVersion: ifSyncVersion, + }) + if err != nil { + return nil, err + } + return desktopAccountFromTombstoneRow(row) +} + +func (r *desktopAccountRepository) MarkDeleteRequested(ctx context.Context, desktopID uuid.UUID, workspaceID int64) (*DesktopAccount, error) { + row, err := r.q.MarkDesktopAccountDeleteRequested(ctx, generated.MarkDesktopAccountDeleteRequestedParams{ + DesktopID: pgUUID(desktopID), + WorkspaceID: workspaceID, + }) + if err != nil { + return nil, err + } + return desktopAccountFromMarkDeleteRow(row) +} + +func (r *desktopAccountRepository) ClearDeleteRequested(ctx context.Context, desktopID uuid.UUID, workspaceID int64) (*DesktopAccount, error) { + row, err := r.q.ClearDesktopAccountDeleteRequested(ctx, generated.ClearDesktopAccountDeleteRequestedParams{ + DesktopID: pgUUID(desktopID), + WorkspaceID: workspaceID, + }) + if err != nil { + return nil, err + } + return desktopAccountFromClearDeleteRow(row) +} + +func desktopAccountFromListRow(row generated.ListDesktopAccountsByClientRow) (*DesktopAccount, error) { + return buildDesktopAccount( + row.DesktopID, + row.TenantID, + row.WorkspaceID, + row.UserID, + row.ClientID, + row.PlatformID, + row.PlatformUid, + row.AccountFingerprint, + row.DisplayName, + row.Health, + row.VerifiedAt, + row.Tags, + row.SyncVersion, + row.DeletedAt, + row.DeleteRequestedAt, + row.CreatedAt, + row.UpdatedAt, + ) +} + +func desktopAccountFromGetRow(row generated.GetDesktopAccountByDesktopIDRow) (*DesktopAccount, error) { + return buildDesktopAccount( + row.DesktopID, + row.TenantID, + row.WorkspaceID, + row.UserID, + row.ClientID, + row.PlatformID, + row.PlatformUid, + row.AccountFingerprint, + row.DisplayName, + row.Health, + row.VerifiedAt, + row.Tags, + row.SyncVersion, + row.DeletedAt, + row.DeleteRequestedAt, + row.CreatedAt, + row.UpdatedAt, + ) +} + +func desktopAccountFromIdentityRow(row generated.GetDesktopAccountByIdentityRow) (*DesktopAccount, error) { + return buildDesktopAccount( + row.DesktopID, + row.TenantID, + row.WorkspaceID, + row.UserID, + row.ClientID, + row.PlatformID, + row.PlatformUid, + row.AccountFingerprint, + row.DisplayName, + row.Health, + row.VerifiedAt, + row.Tags, + row.SyncVersion, + row.DeletedAt, + row.DeleteRequestedAt, + row.CreatedAt, + row.UpdatedAt, + ) +} + +func desktopAccountFromUpsertRow(row generated.UpsertDesktopAccountRow) (*DesktopAccount, error) { + return buildDesktopAccount( + row.DesktopID, + row.TenantID, + row.WorkspaceID, + row.UserID, + row.ClientID, + row.PlatformID, + row.PlatformUid, + row.AccountFingerprint, + row.DisplayName, + row.Health, + row.VerifiedAt, + row.Tags, + row.SyncVersion, + row.DeletedAt, + row.DeleteRequestedAt, + row.CreatedAt, + row.UpdatedAt, + ) +} + +func desktopAccountFromPatchRow(row generated.PatchDesktopAccountRow) (*DesktopAccount, error) { + return buildDesktopAccount( + row.DesktopID, + row.TenantID, + row.WorkspaceID, + row.UserID, + row.ClientID, + row.PlatformID, + row.PlatformUid, + row.AccountFingerprint, + row.DisplayName, + row.Health, + row.VerifiedAt, + row.Tags, + row.SyncVersion, + row.DeletedAt, + row.DeleteRequestedAt, + row.CreatedAt, + row.UpdatedAt, + ) +} + +func desktopAccountFromTombstoneRow(row generated.TombstoneDesktopAccountRow) (*DesktopAccount, error) { + return buildDesktopAccount( + row.DesktopID, + row.TenantID, + row.WorkspaceID, + row.UserID, + row.ClientID, + row.PlatformID, + row.PlatformUid, + row.AccountFingerprint, + row.DisplayName, + row.Health, + row.VerifiedAt, + row.Tags, + row.SyncVersion, + row.DeletedAt, + row.DeleteRequestedAt, + row.CreatedAt, + row.UpdatedAt, + ) +} + +func desktopAccountFromMarkDeleteRow(row generated.MarkDesktopAccountDeleteRequestedRow) (*DesktopAccount, error) { + return buildDesktopAccount( + row.DesktopID, + row.TenantID, + row.WorkspaceID, + row.UserID, + row.ClientID, + row.PlatformID, + row.PlatformUid, + row.AccountFingerprint, + row.DisplayName, + row.Health, + row.VerifiedAt, + row.Tags, + row.SyncVersion, + row.DeletedAt, + row.DeleteRequestedAt, + row.CreatedAt, + row.UpdatedAt, + ) +} + +func desktopAccountFromClearDeleteRow(row generated.ClearDesktopAccountDeleteRequestedRow) (*DesktopAccount, error) { + return buildDesktopAccount( + row.DesktopID, + row.TenantID, + row.WorkspaceID, + row.UserID, + row.ClientID, + row.PlatformID, + row.PlatformUid, + row.AccountFingerprint, + row.DisplayName, + row.Health, + row.VerifiedAt, + row.Tags, + row.SyncVersion, + row.DeletedAt, + row.DeleteRequestedAt, + row.CreatedAt, + row.UpdatedAt, + ) +} + +func buildDesktopAccount( + desktopID pgtype.UUID, + tenantID int64, + workspaceID int64, + userID int64, + clientID pgtype.UUID, + platform string, + platformUID string, + accountFingerprint pgtype.Text, + displayName string, + health string, + verifiedAt pgtype.Timestamptz, + tags []byte, + syncVersion int64, + deletedAt pgtype.Timestamptz, + deleteRequestedAt pgtype.Timestamptz, + createdAt pgtype.Timestamptz, + updatedAt pgtype.Timestamptz, +) (*DesktopAccount, error) { + tagList, err := unmarshalStringSlice(tags) + if err != nil { + return nil, fmt.Errorf("unmarshal desktop account tags: %w", err) + } + + return &DesktopAccount{ + DesktopID: uuidFromPG(desktopID), + TenantID: tenantID, + WorkspaceID: workspaceID, + UserID: userID, + ClientID: nullableUUID(clientID), + Platform: platform, + PlatformUID: platformUID, + AccountFingerprint: nullableText(accountFingerprint), + DisplayName: displayName, + Health: health, + VerifiedAt: optionalTime(verifiedAt), + Tags: tagList, + SyncVersion: syncVersion, + DeletedAt: optionalTime(deletedAt), + DeleteRequestedAt: optionalTime(deleteRequestedAt), + CreatedAt: timeFromTimestamp(createdAt), + UpdatedAt: timeFromTimestamp(updatedAt), + }, nil +} + +func legacyStatusFromHealth(health string) string { + switch health { + case "live": + return "active" + case "captcha": + return "captcha" + case "risk": + return "risk" + default: + return "expired" + } +} + +func legacyStatusPtrFromHealth(health *string) *string { + if health == nil { + return nil + } + value := legacyStatusFromHealth(*health) + return &value +} diff --git a/server/internal/tenant/repository/desktop_client_repo.go b/server/internal/tenant/repository/desktop_client_repo.go new file mode 100644 index 0000000..e1f05d8 --- /dev/null +++ b/server/internal/tenant/repository/desktop_client_repo.go @@ -0,0 +1,174 @@ +package repository + +import ( + "context" + "time" + + "github.com/google/uuid" + + "github.com/geo-platform/tenant-api/internal/tenant/repository/generated" +) + +type DesktopClient struct { + ID uuid.UUID + TenantID int64 + WorkspaceID int64 + UserID int64 + TokenHash []byte + DeviceName *string + OS *string + CPUArch *string + ClientVersion *string + Channel *string + CreatedAt time.Time + LastSeenAt *time.Time + LastRotatedAt *time.Time + RevokedAt *time.Time +} + +type RegisterDesktopClientParams struct { + ID uuid.UUID + TenantID int64 + WorkspaceID int64 + UserID int64 + TokenHash []byte + DeviceName *string + OS *string + CPUArch *string + ClientVersion *string + Channel *string +} + +type HeartbeatDesktopClientParams struct { + ID uuid.UUID + WorkspaceID int64 + DeviceName *string + OS *string + CPUArch *string + ClientVersion *string + Channel *string +} + +type RotateDesktopClientTokenParams struct { + ID uuid.UUID + WorkspaceID int64 + TokenHash []byte +} + +type DesktopClientRepository interface { + Register(ctx context.Context, params RegisterDesktopClientParams) (*DesktopClient, error) + GetByTokenHash(ctx context.Context, tokenHash []byte) (*DesktopClient, error) + RotateToken(ctx context.Context, params RotateDesktopClientTokenParams) (*DesktopClient, error) + Heartbeat(ctx context.Context, params HeartbeatDesktopClientParams) (*DesktopClient, error) + Revoke(ctx context.Context, id uuid.UUID, workspaceID int64) (*DesktopClient, error) + ListByWorkspace(ctx context.Context, workspaceID int64) ([]DesktopClient, error) +} + +type desktopClientRepository struct { + q generated.Querier +} + +func NewDesktopClientRepository(db generated.DBTX) DesktopClientRepository { + return &desktopClientRepository{q: newQuerier(db)} +} + +func (r *desktopClientRepository) Register(ctx context.Context, params RegisterDesktopClientParams) (*DesktopClient, error) { + row, err := r.q.RegisterDesktopClient(ctx, generated.RegisterDesktopClientParams{ + ID: pgUUID(params.ID), + TenantID: params.TenantID, + WorkspaceID: params.WorkspaceID, + UserID: params.UserID, + TokenHash: params.TokenHash, + DeviceName: pgText(params.DeviceName), + Os: pgText(params.OS), + CpuArch: pgText(params.CPUArch), + ClientVersion: pgText(params.ClientVersion), + Channel: pgText(params.Channel), + }) + if err != nil { + return nil, err + } + return desktopClientFromGenerated(row), nil +} + +func (r *desktopClientRepository) GetByTokenHash(ctx context.Context, tokenHash []byte) (*DesktopClient, error) { + row, err := r.q.GetDesktopClientByTokenHash(ctx, tokenHash) + if err != nil { + return nil, err + } + return desktopClientFromGenerated(row), nil +} + +func (r *desktopClientRepository) RotateToken(ctx context.Context, params RotateDesktopClientTokenParams) (*DesktopClient, error) { + row, err := r.q.RotateDesktopClientToken(ctx, generated.RotateDesktopClientTokenParams{ + ID: pgUUID(params.ID), + WorkspaceID: params.WorkspaceID, + TokenHash: params.TokenHash, + }) + if err != nil { + return nil, err + } + return desktopClientFromGenerated(row), nil +} + +func (r *desktopClientRepository) Heartbeat(ctx context.Context, params HeartbeatDesktopClientParams) (*DesktopClient, error) { + row, err := r.q.HeartbeatDesktopClient(ctx, generated.HeartbeatDesktopClientParams{ + ID: pgUUID(params.ID), + WorkspaceID: params.WorkspaceID, + DeviceName: pgText(params.DeviceName), + Os: pgText(params.OS), + CpuArch: pgText(params.CPUArch), + ClientVersion: pgText(params.ClientVersion), + Channel: pgText(params.Channel), + }) + if err != nil { + return nil, err + } + return desktopClientFromGenerated(row), nil +} + +func (r *desktopClientRepository) Revoke(ctx context.Context, id uuid.UUID, workspaceID int64) (*DesktopClient, error) { + row, err := r.q.RevokeDesktopClient(ctx, generated.RevokeDesktopClientParams{ + ID: pgUUID(id), + WorkspaceID: workspaceID, + }) + if err != nil { + return nil, err + } + return desktopClientFromGenerated(row), nil +} + +func (r *desktopClientRepository) ListByWorkspace(ctx context.Context, workspaceID int64) ([]DesktopClient, error) { + rows, err := r.q.ListDesktopClientsByWorkspace(ctx, workspaceID) + if err != nil { + return nil, err + } + + items := make([]DesktopClient, 0, len(rows)) + for _, row := range rows { + item := desktopClientFromGenerated(row) + if item != nil { + items = append(items, *item) + } + } + return items, nil +} + +func desktopClientFromGenerated(row generated.DesktopClient) *DesktopClient { + return &DesktopClient{ + ID: uuidFromPG(row.ID), + TenantID: row.TenantID, + WorkspaceID: row.WorkspaceID, + UserID: row.UserID, + TokenHash: row.TokenHash, + DeviceName: nullableText(row.DeviceName), + OS: nullableText(row.Os), + CPUArch: nullableText(row.CpuArch), + ClientVersion: nullableText(row.ClientVersion), + Channel: nullableText(row.Channel), + CreatedAt: timeFromTimestamp(row.CreatedAt), + LastSeenAt: optionalTime(row.LastSeenAt), + LastRotatedAt: optionalTime(row.LastRotatedAt), + RevokedAt: optionalTime(row.RevokedAt), + } +} diff --git a/server/internal/tenant/repository/desktop_task_repo.go b/server/internal/tenant/repository/desktop_task_repo.go new file mode 100644 index 0000000..9fe4fba --- /dev/null +++ b/server/internal/tenant/repository/desktop_task_repo.go @@ -0,0 +1,381 @@ +package repository + +import ( + "context" + "time" + + "github.com/google/uuid" + + "github.com/geo-platform/tenant-api/internal/tenant/repository/generated" +) + +type DesktopPublishJob struct { + DesktopID uuid.UUID + TenantID int64 + WorkspaceID int64 + CreatedByUserID int64 + Title string + ContentRef []byte + ScheduledAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +type CreateDesktopPublishJobParams struct { + DesktopID uuid.UUID + TenantID int64 + WorkspaceID int64 + CreatedByUserID int64 + Title string + ContentRef []byte + ScheduledAt *time.Time +} + +type DesktopTask struct { + DesktopID uuid.UUID + JobID uuid.UUID + TenantID int64 + WorkspaceID int64 + TargetAccountID uuid.UUID + TargetClientID uuid.UUID + Platform string + Kind string + Payload []byte + Status string + DedupKey *string + ActiveAttemptID *uuid.UUID + LeaseExpiresAt *time.Time + Attempts int + ParkedReason *string + Result []byte + Error []byte + CreatedAt time.Time + UpdatedAt time.Time +} + +type CreateDesktopTaskParams struct { + DesktopID uuid.UUID + JobID uuid.UUID + TenantID int64 + WorkspaceID int64 + TargetAccountID uuid.UUID + TargetClientID uuid.UUID + Platform string + Kind string + Payload []byte + Status string + DedupKey *string + ParkedReason *string +} + +type DesktopTaskLeaseParams struct { + AttemptID uuid.UUID + LeaseTokenHash []byte +} + +type DesktopTaskAttempt struct { + DesktopID uuid.UUID + TaskID uuid.UUID + ClientID uuid.UUID + StartedAt time.Time + EndedAt *time.Time + FinalStatus *string + Error []byte + CreatedAt time.Time +} + +type CreateDesktopTaskAttemptParams struct { + DesktopID uuid.UUID + TaskID uuid.UUID + ClientID uuid.UUID + LeaseTokenHash []byte +} + +type FinishDesktopTaskAttemptParams struct { + TaskID uuid.UUID + AttemptID uuid.UUID + FinalStatus *string + Error []byte +} + +type DesktopTaskRepository interface { + CreatePublishJob(ctx context.Context, params CreateDesktopPublishJobParams) (*DesktopPublishJob, error) + CreateTask(ctx context.Context, params CreateDesktopTaskParams) (*DesktopTask, error) + GetByDesktopID(ctx context.Context, desktopID uuid.UUID, workspaceID int64) (*DesktopTask, error) + LeaseNextQueued(ctx context.Context, clientID uuid.UUID, kind *string, params DesktopTaskLeaseParams) (*DesktopTask, error) + LeaseQueuedByDesktopID(ctx context.Context, desktopID, clientID uuid.UUID, params DesktopTaskLeaseParams) (*DesktopTask, error) + LeaseParked(ctx context.Context, desktopID, clientID uuid.UUID, params DesktopTaskLeaseParams) (*DesktopTask, error) + ExtendLease(ctx context.Context, desktopID uuid.UUID, leaseTokenHash []byte) (*DesktopTask, error) + Park(ctx context.Context, desktopID uuid.UUID, leaseTokenHash []byte, parkedReason string) (*DesktopTask, error) + Complete(ctx context.Context, desktopID uuid.UUID, leaseTokenHash []byte, status string, resultJSON, errorJSON []byte) (*DesktopTask, error) + CancelByLease(ctx context.Context, desktopID uuid.UUID, leaseTokenHash []byte, errorJSON []byte) (*DesktopTask, error) + CancelByClient(ctx context.Context, desktopID, clientID uuid.UUID, errorJSON []byte) (*DesktopTask, error) + TenantCancel(ctx context.Context, desktopID uuid.UUID, workspaceID int64, errorJSON []byte) (*DesktopTask, error) + Reconcile(ctx context.Context, desktopID uuid.UUID, workspaceID int64, status string, resultJSON, errorJSON []byte) (*DesktopTask, error) + MarkUnknownByClient(ctx context.Context, clientID uuid.UUID, workspaceID int64, errorJSON []byte) (int64, error) + CreateAttempt(ctx context.Context, params CreateDesktopTaskAttemptParams) (*DesktopTaskAttempt, error) + FinishAttempt(ctx context.Context, params FinishDesktopTaskAttemptParams) error +} + +type desktopTaskRepository struct { + q generated.Querier +} + +func NewDesktopTaskRepository(db generated.DBTX) DesktopTaskRepository { + return &desktopTaskRepository{q: newQuerier(db)} +} + +func (r *desktopTaskRepository) CreatePublishJob(ctx context.Context, params CreateDesktopPublishJobParams) (*DesktopPublishJob, error) { + row, err := r.q.CreateDesktopPublishJob(ctx, generated.CreateDesktopPublishJobParams{ + DesktopID: pgUUID(params.DesktopID), + TenantID: params.TenantID, + WorkspaceID: params.WorkspaceID, + CreatedByUserID: params.CreatedByUserID, + Title: params.Title, + ContentRef: params.ContentRef, + ScheduledAt: pgTimestamp(params.ScheduledAt), + }) + if err != nil { + return nil, err + } + return desktopPublishJobFromGenerated(row), nil +} + +func (r *desktopTaskRepository) CreateTask(ctx context.Context, params CreateDesktopTaskParams) (*DesktopTask, error) { + row, err := r.q.CreateDesktopTask(ctx, generated.CreateDesktopTaskParams{ + DesktopID: pgUUID(params.DesktopID), + JobID: pgUUID(params.JobID), + TenantID: params.TenantID, + WorkspaceID: params.WorkspaceID, + TargetAccountID: pgUUID(params.TargetAccountID), + TargetClientID: pgUUID(params.TargetClientID), + PlatformID: params.Platform, + Kind: params.Kind, + Payload: params.Payload, + Status: params.Status, + DedupKey: pgText(params.DedupKey), + ParkedReason: pgText(params.ParkedReason), + }) + if err != nil { + return nil, err + } + return desktopTaskFromGenerated(row), nil +} + +func (r *desktopTaskRepository) GetByDesktopID(ctx context.Context, desktopID uuid.UUID, workspaceID int64) (*DesktopTask, error) { + row, err := r.q.GetDesktopTaskByDesktopID(ctx, generated.GetDesktopTaskByDesktopIDParams{ + DesktopID: pgUUID(desktopID), + WorkspaceID: workspaceID, + }) + if err != nil { + return nil, err + } + return desktopTaskFromGenerated(row), nil +} + +func (r *desktopTaskRepository) LeaseNextQueued(ctx context.Context, clientID uuid.UUID, kind *string, params DesktopTaskLeaseParams) (*DesktopTask, error) { + row, err := r.q.LeaseNextQueuedDesktopTask(ctx, generated.LeaseNextQueuedDesktopTaskParams{ + AttemptID: pgUUID(params.AttemptID), + LeaseTokenHash: params.LeaseTokenHash, + ClientID: pgUUID(clientID), + Kind: pgText(kind), + }) + if err != nil { + return nil, err + } + return desktopTaskFromGenerated(row), nil +} + +func (r *desktopTaskRepository) LeaseQueuedByDesktopID(ctx context.Context, desktopID, clientID uuid.UUID, params DesktopTaskLeaseParams) (*DesktopTask, error) { + row, err := r.q.LeaseQueuedDesktopTaskByDesktopID(ctx, generated.LeaseQueuedDesktopTaskByDesktopIDParams{ + AttemptID: pgUUID(params.AttemptID), + LeaseTokenHash: params.LeaseTokenHash, + DesktopID: pgUUID(desktopID), + ClientID: pgUUID(clientID), + }) + if err != nil { + return nil, err + } + return desktopTaskFromGenerated(row), nil +} + +func (r *desktopTaskRepository) LeaseParked(ctx context.Context, desktopID, clientID uuid.UUID, params DesktopTaskLeaseParams) (*DesktopTask, error) { + row, err := r.q.LeaseParkedDesktopTask(ctx, generated.LeaseParkedDesktopTaskParams{ + AttemptID: pgUUID(params.AttemptID), + LeaseTokenHash: params.LeaseTokenHash, + DesktopID: pgUUID(desktopID), + ClientID: pgUUID(clientID), + }) + if err != nil { + return nil, err + } + return desktopTaskFromGenerated(row), nil +} + +func (r *desktopTaskRepository) ExtendLease(ctx context.Context, desktopID uuid.UUID, leaseTokenHash []byte) (*DesktopTask, error) { + row, err := r.q.ExtendDesktopTaskLease(ctx, generated.ExtendDesktopTaskLeaseParams{ + DesktopID: pgUUID(desktopID), + LeaseTokenHash: leaseTokenHash, + }) + if err != nil { + return nil, err + } + return desktopTaskFromGenerated(row), nil +} + +func (r *desktopTaskRepository) Park(ctx context.Context, desktopID uuid.UUID, leaseTokenHash []byte, parkedReason string) (*DesktopTask, error) { + row, err := r.q.ParkDesktopTask(ctx, generated.ParkDesktopTaskParams{ + ParkedReason: pgText(&parkedReason), + DesktopID: pgUUID(desktopID), + LeaseTokenHash: leaseTokenHash, + }) + if err != nil { + return nil, err + } + return desktopTaskFromGenerated(row), nil +} + +func (r *desktopTaskRepository) Complete(ctx context.Context, desktopID uuid.UUID, leaseTokenHash []byte, status string, resultJSON, errorJSON []byte) (*DesktopTask, error) { + row, err := r.q.CompleteDesktopTask(ctx, generated.CompleteDesktopTaskParams{ + Status: status, + Result: resultJSON, + Error: errorJSON, + DesktopID: pgUUID(desktopID), + LeaseTokenHash: leaseTokenHash, + }) + if err != nil { + return nil, err + } + return desktopTaskFromGenerated(row), nil +} + +func (r *desktopTaskRepository) CancelByLease(ctx context.Context, desktopID uuid.UUID, leaseTokenHash []byte, errorJSON []byte) (*DesktopTask, error) { + row, err := r.q.CancelDesktopTaskByLease(ctx, generated.CancelDesktopTaskByLeaseParams{ + Error: errorJSON, + DesktopID: pgUUID(desktopID), + LeaseTokenHash: leaseTokenHash, + }) + if err != nil { + return nil, err + } + return desktopTaskFromGenerated(row), nil +} + +func (r *desktopTaskRepository) CancelByClient(ctx context.Context, desktopID, clientID uuid.UUID, errorJSON []byte) (*DesktopTask, error) { + row, err := r.q.CancelDesktopTaskByClient(ctx, generated.CancelDesktopTaskByClientParams{ + Error: errorJSON, + DesktopID: pgUUID(desktopID), + ClientID: pgUUID(clientID), + }) + if err != nil { + return nil, err + } + return desktopTaskFromGenerated(row), nil +} + +func (r *desktopTaskRepository) TenantCancel(ctx context.Context, desktopID uuid.UUID, workspaceID int64, errorJSON []byte) (*DesktopTask, error) { + row, err := r.q.TenantCancelDesktopTask(ctx, generated.TenantCancelDesktopTaskParams{ + Error: errorJSON, + DesktopID: pgUUID(desktopID), + WorkspaceID: workspaceID, + }) + if err != nil { + return nil, err + } + return desktopTaskFromGenerated(row), nil +} + +func (r *desktopTaskRepository) Reconcile(ctx context.Context, desktopID uuid.UUID, workspaceID int64, status string, resultJSON, errorJSON []byte) (*DesktopTask, error) { + row, err := r.q.ReconcileDesktopTask(ctx, generated.ReconcileDesktopTaskParams{ + Status: status, + Result: resultJSON, + Error: errorJSON, + DesktopID: pgUUID(desktopID), + WorkspaceID: workspaceID, + }) + if err != nil { + return nil, err + } + return desktopTaskFromGenerated(row), nil +} + +func (r *desktopTaskRepository) MarkUnknownByClient(ctx context.Context, clientID uuid.UUID, workspaceID int64, errorJSON []byte) (int64, error) { + return r.q.MarkDesktopTasksUnknownByClient(ctx, generated.MarkDesktopTasksUnknownByClientParams{ + Error: errorJSON, + ClientID: pgUUID(clientID), + WorkspaceID: workspaceID, + }) +} + +func (r *desktopTaskRepository) CreateAttempt(ctx context.Context, params CreateDesktopTaskAttemptParams) (*DesktopTaskAttempt, error) { + row, err := r.q.CreateDesktopTaskAttempt(ctx, generated.CreateDesktopTaskAttemptParams{ + DesktopID: pgUUID(params.DesktopID), + TaskID: pgUUID(params.TaskID), + ClientID: pgUUID(params.ClientID), + LeaseTokenHash: params.LeaseTokenHash, + }) + if err != nil { + return nil, err + } + return desktopTaskAttemptFromGenerated(row), nil +} + +func (r *desktopTaskRepository) FinishAttempt(ctx context.Context, params FinishDesktopTaskAttemptParams) error { + return r.q.FinishDesktopTaskAttempt(ctx, generated.FinishDesktopTaskAttemptParams{ + FinalStatus: pgText(params.FinalStatus), + Error: params.Error, + TaskID: pgUUID(params.TaskID), + AttemptID: pgUUID(params.AttemptID), + }) +} + +func desktopPublishJobFromGenerated(row generated.DesktopPublishJob) *DesktopPublishJob { + return &DesktopPublishJob{ + DesktopID: uuidFromPG(row.DesktopID), + TenantID: row.TenantID, + WorkspaceID: row.WorkspaceID, + CreatedByUserID: row.CreatedByUserID, + Title: row.Title, + ContentRef: row.ContentRef, + ScheduledAt: optionalTime(row.ScheduledAt), + CreatedAt: timeFromTimestamp(row.CreatedAt), + UpdatedAt: timeFromTimestamp(row.UpdatedAt), + } +} + +func desktopTaskFromGenerated(row generated.DesktopTask) *DesktopTask { + return &DesktopTask{ + DesktopID: uuidFromPG(row.DesktopID), + JobID: uuidFromPG(row.JobID), + TenantID: row.TenantID, + WorkspaceID: row.WorkspaceID, + TargetAccountID: uuidFromPG(row.TargetAccountID), + TargetClientID: uuidFromPG(row.TargetClientID), + Platform: row.PlatformID, + Kind: row.Kind, + Payload: row.Payload, + Status: row.Status, + DedupKey: nullableText(row.DedupKey), + ActiveAttemptID: nullableUUID(row.ActiveAttemptID), + LeaseExpiresAt: optionalTime(row.LeaseExpiresAt), + Attempts: int(row.Attempts), + ParkedReason: nullableText(row.ParkedReason), + Result: row.Result, + Error: row.Error, + CreatedAt: timeFromTimestamp(row.CreatedAt), + UpdatedAt: timeFromTimestamp(row.UpdatedAt), + } +} + +func desktopTaskAttemptFromGenerated(row generated.DesktopTaskAttempt) *DesktopTaskAttempt { + return &DesktopTaskAttempt{ + DesktopID: uuidFromPG(row.DesktopID), + TaskID: uuidFromPG(row.TaskID), + ClientID: uuidFromPG(row.ClientID), + StartedAt: timeFromTimestamp(row.StartedAt), + EndedAt: optionalTime(row.EndedAt), + FinalStatus: nullableText(row.FinalStatus), + Error: row.Error, + CreatedAt: timeFromTimestamp(row.CreatedAt), + } +} diff --git a/server/internal/tenant/repository/generated/auth.sql.go b/server/internal/tenant/repository/generated/auth.sql.go index b64830a..5db9eaa 100644 --- a/server/internal/tenant/repository/generated/auth.sql.go +++ b/server/internal/tenant/repository/generated/auth.sql.go @@ -12,18 +12,28 @@ import ( ) const getTenantMembership = `-- name: GetTenantMembership :one -SELECT id, tenant_id, user_id, tenant_role, created_at -FROM tenant_memberships -WHERE user_id = $1 AND deleted_at IS NULL +SELECT tm.id, + tm.tenant_id, + tm.user_id, + tm.tenant_role, + tm.created_at, + wm.workspace_id AS primary_workspace_id +FROM tenant_memberships tm +JOIN workspace_memberships wm + ON wm.tenant_id = tm.tenant_id + AND wm.user_id = tm.user_id + AND wm.is_primary = TRUE +WHERE tm.user_id = $1 AND tm.deleted_at IS NULL LIMIT 1 ` type GetTenantMembershipRow struct { - ID int64 `json:"id"` - TenantID int64 `json:"tenant_id"` - UserID int64 `json:"user_id"` - TenantRole string `json:"tenant_role"` - CreatedAt pgtype.Timestamptz `json:"created_at"` + ID int64 `json:"id"` + TenantID int64 `json:"tenant_id"` + UserID int64 `json:"user_id"` + TenantRole string `json:"tenant_role"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + PrimaryWorkspaceID int64 `json:"primary_workspace_id"` } func (q *Queries) GetTenantMembership(ctx context.Context, userID int64) (GetTenantMembershipRow, error) { @@ -35,14 +45,24 @@ func (q *Queries) GetTenantMembership(ctx context.Context, userID int64) (GetTen &i.UserID, &i.TenantRole, &i.CreatedAt, + &i.PrimaryWorkspaceID, ) return i, err } const getTenantMembershipByTenantAndUser = `-- name: GetTenantMembershipByTenantAndUser :one -SELECT id, tenant_id, user_id, tenant_role, created_at -FROM tenant_memberships -WHERE tenant_id = $1 AND user_id = $2 AND deleted_at IS NULL +SELECT tm.id, + tm.tenant_id, + tm.user_id, + tm.tenant_role, + tm.created_at, + wm.workspace_id AS primary_workspace_id +FROM tenant_memberships tm +JOIN workspace_memberships wm + ON wm.tenant_id = tm.tenant_id + AND wm.user_id = tm.user_id + AND wm.is_primary = TRUE +WHERE tm.tenant_id = $1 AND tm.user_id = $2 AND tm.deleted_at IS NULL ` type GetTenantMembershipByTenantAndUserParams struct { @@ -51,11 +71,12 @@ type GetTenantMembershipByTenantAndUserParams struct { } type GetTenantMembershipByTenantAndUserRow struct { - ID int64 `json:"id"` - TenantID int64 `json:"tenant_id"` - UserID int64 `json:"user_id"` - TenantRole string `json:"tenant_role"` - CreatedAt pgtype.Timestamptz `json:"created_at"` + ID int64 `json:"id"` + TenantID int64 `json:"tenant_id"` + UserID int64 `json:"user_id"` + TenantRole string `json:"tenant_role"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + PrimaryWorkspaceID int64 `json:"primary_workspace_id"` } func (q *Queries) GetTenantMembershipByTenantAndUser(ctx context.Context, arg GetTenantMembershipByTenantAndUserParams) (GetTenantMembershipByTenantAndUserRow, error) { @@ -67,6 +88,7 @@ func (q *Queries) GetTenantMembershipByTenantAndUser(ctx context.Context, arg Ge &i.UserID, &i.TenantRole, &i.CreatedAt, + &i.PrimaryWorkspaceID, ) return i, err } diff --git a/server/internal/tenant/repository/generated/desktop_account.sql.go b/server/internal/tenant/repository/generated/desktop_account.sql.go new file mode 100644 index 0000000..77d1b74 --- /dev/null +++ b/server/internal/tenant/repository/generated/desktop_account.sql.go @@ -0,0 +1,748 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: desktop_account.sql + +package generated + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const clearDesktopAccountDeleteRequested = `-- name: ClearDesktopAccountDeleteRequested :one +UPDATE platform_accounts +SET delete_requested_at = NULL, + sync_version = sync_version + 1, + updated_at = now() +WHERE desktop_id = $1 + AND workspace_id = $2 + AND deleted_at IS NULL +RETURNING + desktop_id, + tenant_id, + workspace_id, + user_id, + client_id, + platform_id, + platform_uid, + account_fingerprint, + display_name, + health, + verified_at, + tags, + sync_version, + deleted_at, + delete_requested_at, + created_at, + updated_at +` + +type ClearDesktopAccountDeleteRequestedParams struct { + DesktopID pgtype.UUID `json:"desktop_id"` + WorkspaceID int64 `json:"workspace_id"` +} + +type ClearDesktopAccountDeleteRequestedRow struct { + DesktopID pgtype.UUID `json:"desktop_id"` + TenantID int64 `json:"tenant_id"` + WorkspaceID int64 `json:"workspace_id"` + UserID int64 `json:"user_id"` + ClientID pgtype.UUID `json:"client_id"` + PlatformID string `json:"platform_id"` + PlatformUid string `json:"platform_uid"` + AccountFingerprint pgtype.Text `json:"account_fingerprint"` + DisplayName string `json:"display_name"` + Health string `json:"health"` + VerifiedAt pgtype.Timestamptz `json:"verified_at"` + Tags []byte `json:"tags"` + SyncVersion int64 `json:"sync_version"` + DeletedAt pgtype.Timestamptz `json:"deleted_at"` + DeleteRequestedAt pgtype.Timestamptz `json:"delete_requested_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) ClearDesktopAccountDeleteRequested(ctx context.Context, arg ClearDesktopAccountDeleteRequestedParams) (ClearDesktopAccountDeleteRequestedRow, error) { + row := q.db.QueryRow(ctx, clearDesktopAccountDeleteRequested, arg.DesktopID, arg.WorkspaceID) + var i ClearDesktopAccountDeleteRequestedRow + err := row.Scan( + &i.DesktopID, + &i.TenantID, + &i.WorkspaceID, + &i.UserID, + &i.ClientID, + &i.PlatformID, + &i.PlatformUid, + &i.AccountFingerprint, + &i.DisplayName, + &i.Health, + &i.VerifiedAt, + &i.Tags, + &i.SyncVersion, + &i.DeletedAt, + &i.DeleteRequestedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getDesktopAccountByDesktopID = `-- name: GetDesktopAccountByDesktopID :one +SELECT + desktop_id, + tenant_id, + workspace_id, + user_id, + client_id, + platform_id, + platform_uid, + account_fingerprint, + display_name, + health, + verified_at, + tags, + sync_version, + deleted_at, + delete_requested_at, + created_at, + updated_at +FROM platform_accounts +WHERE desktop_id = $1 + AND workspace_id = $2 +LIMIT 1 +` + +type GetDesktopAccountByDesktopIDParams struct { + DesktopID pgtype.UUID `json:"desktop_id"` + WorkspaceID int64 `json:"workspace_id"` +} + +type GetDesktopAccountByDesktopIDRow struct { + DesktopID pgtype.UUID `json:"desktop_id"` + TenantID int64 `json:"tenant_id"` + WorkspaceID int64 `json:"workspace_id"` + UserID int64 `json:"user_id"` + ClientID pgtype.UUID `json:"client_id"` + PlatformID string `json:"platform_id"` + PlatformUid string `json:"platform_uid"` + AccountFingerprint pgtype.Text `json:"account_fingerprint"` + DisplayName string `json:"display_name"` + Health string `json:"health"` + VerifiedAt pgtype.Timestamptz `json:"verified_at"` + Tags []byte `json:"tags"` + SyncVersion int64 `json:"sync_version"` + DeletedAt pgtype.Timestamptz `json:"deleted_at"` + DeleteRequestedAt pgtype.Timestamptz `json:"delete_requested_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) GetDesktopAccountByDesktopID(ctx context.Context, arg GetDesktopAccountByDesktopIDParams) (GetDesktopAccountByDesktopIDRow, error) { + row := q.db.QueryRow(ctx, getDesktopAccountByDesktopID, arg.DesktopID, arg.WorkspaceID) + var i GetDesktopAccountByDesktopIDRow + err := row.Scan( + &i.DesktopID, + &i.TenantID, + &i.WorkspaceID, + &i.UserID, + &i.ClientID, + &i.PlatformID, + &i.PlatformUid, + &i.AccountFingerprint, + &i.DisplayName, + &i.Health, + &i.VerifiedAt, + &i.Tags, + &i.SyncVersion, + &i.DeletedAt, + &i.DeleteRequestedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getDesktopAccountByIdentity = `-- name: GetDesktopAccountByIdentity :one +SELECT + desktop_id, + tenant_id, + workspace_id, + user_id, + client_id, + platform_id, + platform_uid, + account_fingerprint, + display_name, + health, + verified_at, + tags, + sync_version, + deleted_at, + delete_requested_at, + created_at, + updated_at +FROM platform_accounts +WHERE workspace_id = $1 + AND platform_id = $2 + AND platform_uid = $3 + AND deleted_at IS NULL +LIMIT 1 +` + +type GetDesktopAccountByIdentityParams struct { + WorkspaceID int64 `json:"workspace_id"` + PlatformID string `json:"platform_id"` + PlatformUid string `json:"platform_uid"` +} + +type GetDesktopAccountByIdentityRow struct { + DesktopID pgtype.UUID `json:"desktop_id"` + TenantID int64 `json:"tenant_id"` + WorkspaceID int64 `json:"workspace_id"` + UserID int64 `json:"user_id"` + ClientID pgtype.UUID `json:"client_id"` + PlatformID string `json:"platform_id"` + PlatformUid string `json:"platform_uid"` + AccountFingerprint pgtype.Text `json:"account_fingerprint"` + DisplayName string `json:"display_name"` + Health string `json:"health"` + VerifiedAt pgtype.Timestamptz `json:"verified_at"` + Tags []byte `json:"tags"` + SyncVersion int64 `json:"sync_version"` + DeletedAt pgtype.Timestamptz `json:"deleted_at"` + DeleteRequestedAt pgtype.Timestamptz `json:"delete_requested_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) GetDesktopAccountByIdentity(ctx context.Context, arg GetDesktopAccountByIdentityParams) (GetDesktopAccountByIdentityRow, error) { + row := q.db.QueryRow(ctx, getDesktopAccountByIdentity, arg.WorkspaceID, arg.PlatformID, arg.PlatformUid) + var i GetDesktopAccountByIdentityRow + err := row.Scan( + &i.DesktopID, + &i.TenantID, + &i.WorkspaceID, + &i.UserID, + &i.ClientID, + &i.PlatformID, + &i.PlatformUid, + &i.AccountFingerprint, + &i.DisplayName, + &i.Health, + &i.VerifiedAt, + &i.Tags, + &i.SyncVersion, + &i.DeletedAt, + &i.DeleteRequestedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listDesktopAccountsByClient = `-- name: ListDesktopAccountsByClient :many +SELECT + desktop_id, + tenant_id, + workspace_id, + user_id, + client_id, + platform_id, + platform_uid, + account_fingerprint, + display_name, + health, + verified_at, + tags, + sync_version, + deleted_at, + delete_requested_at, + created_at, + updated_at +FROM platform_accounts +WHERE workspace_id = $1 + AND client_id = $2 +ORDER BY platform_id, display_name, created_at DESC +` + +type ListDesktopAccountsByClientParams struct { + WorkspaceID int64 `json:"workspace_id"` + ClientID pgtype.UUID `json:"client_id"` +} + +type ListDesktopAccountsByClientRow struct { + DesktopID pgtype.UUID `json:"desktop_id"` + TenantID int64 `json:"tenant_id"` + WorkspaceID int64 `json:"workspace_id"` + UserID int64 `json:"user_id"` + ClientID pgtype.UUID `json:"client_id"` + PlatformID string `json:"platform_id"` + PlatformUid string `json:"platform_uid"` + AccountFingerprint pgtype.Text `json:"account_fingerprint"` + DisplayName string `json:"display_name"` + Health string `json:"health"` + VerifiedAt pgtype.Timestamptz `json:"verified_at"` + Tags []byte `json:"tags"` + SyncVersion int64 `json:"sync_version"` + DeletedAt pgtype.Timestamptz `json:"deleted_at"` + DeleteRequestedAt pgtype.Timestamptz `json:"delete_requested_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) ListDesktopAccountsByClient(ctx context.Context, arg ListDesktopAccountsByClientParams) ([]ListDesktopAccountsByClientRow, error) { + rows, err := q.db.Query(ctx, listDesktopAccountsByClient, arg.WorkspaceID, arg.ClientID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListDesktopAccountsByClientRow + for rows.Next() { + var i ListDesktopAccountsByClientRow + if err := rows.Scan( + &i.DesktopID, + &i.TenantID, + &i.WorkspaceID, + &i.UserID, + &i.ClientID, + &i.PlatformID, + &i.PlatformUid, + &i.AccountFingerprint, + &i.DisplayName, + &i.Health, + &i.VerifiedAt, + &i.Tags, + &i.SyncVersion, + &i.DeletedAt, + &i.DeleteRequestedAt, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const markDesktopAccountDeleteRequested = `-- name: MarkDesktopAccountDeleteRequested :one +UPDATE platform_accounts +SET delete_requested_at = now(), + sync_version = sync_version + 1, + updated_at = now() +WHERE desktop_id = $1 + AND workspace_id = $2 + AND deleted_at IS NULL +RETURNING + desktop_id, + tenant_id, + workspace_id, + user_id, + client_id, + platform_id, + platform_uid, + account_fingerprint, + display_name, + health, + verified_at, + tags, + sync_version, + deleted_at, + delete_requested_at, + created_at, + updated_at +` + +type MarkDesktopAccountDeleteRequestedParams struct { + DesktopID pgtype.UUID `json:"desktop_id"` + WorkspaceID int64 `json:"workspace_id"` +} + +type MarkDesktopAccountDeleteRequestedRow struct { + DesktopID pgtype.UUID `json:"desktop_id"` + TenantID int64 `json:"tenant_id"` + WorkspaceID int64 `json:"workspace_id"` + UserID int64 `json:"user_id"` + ClientID pgtype.UUID `json:"client_id"` + PlatformID string `json:"platform_id"` + PlatformUid string `json:"platform_uid"` + AccountFingerprint pgtype.Text `json:"account_fingerprint"` + DisplayName string `json:"display_name"` + Health string `json:"health"` + VerifiedAt pgtype.Timestamptz `json:"verified_at"` + Tags []byte `json:"tags"` + SyncVersion int64 `json:"sync_version"` + DeletedAt pgtype.Timestamptz `json:"deleted_at"` + DeleteRequestedAt pgtype.Timestamptz `json:"delete_requested_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) MarkDesktopAccountDeleteRequested(ctx context.Context, arg MarkDesktopAccountDeleteRequestedParams) (MarkDesktopAccountDeleteRequestedRow, error) { + row := q.db.QueryRow(ctx, markDesktopAccountDeleteRequested, arg.DesktopID, arg.WorkspaceID) + var i MarkDesktopAccountDeleteRequestedRow + err := row.Scan( + &i.DesktopID, + &i.TenantID, + &i.WorkspaceID, + &i.UserID, + &i.ClientID, + &i.PlatformID, + &i.PlatformUid, + &i.AccountFingerprint, + &i.DisplayName, + &i.Health, + &i.VerifiedAt, + &i.Tags, + &i.SyncVersion, + &i.DeletedAt, + &i.DeleteRequestedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const patchDesktopAccount = `-- name: PatchDesktopAccount :one +UPDATE platform_accounts +SET display_name = COALESCE($1::text, display_name), + nickname = COALESCE($1::text, nickname), + health = COALESCE($2, health), + status = COALESCE($3, status), + verified_at = COALESCE($4, verified_at), + last_check_at = COALESCE($4, last_check_at), + tags = COALESCE($5, tags), + sync_version = sync_version + 1, + updated_at = now() +WHERE desktop_id = $6 + AND workspace_id = $7 + AND sync_version = $8 + AND deleted_at IS NULL +RETURNING + desktop_id, + tenant_id, + workspace_id, + user_id, + client_id, + platform_id, + platform_uid, + account_fingerprint, + display_name, + health, + verified_at, + tags, + sync_version, + deleted_at, + delete_requested_at, + created_at, + updated_at +` + +type PatchDesktopAccountParams struct { + DisplayName pgtype.Text `json:"display_name"` + Health pgtype.Text `json:"health"` + LegacyStatus pgtype.Text `json:"legacy_status"` + VerifiedAt pgtype.Timestamptz `json:"verified_at"` + Tags []byte `json:"tags"` + DesktopID pgtype.UUID `json:"desktop_id"` + WorkspaceID int64 `json:"workspace_id"` + IfSyncVersion int64 `json:"if_sync_version"` +} + +type PatchDesktopAccountRow struct { + DesktopID pgtype.UUID `json:"desktop_id"` + TenantID int64 `json:"tenant_id"` + WorkspaceID int64 `json:"workspace_id"` + UserID int64 `json:"user_id"` + ClientID pgtype.UUID `json:"client_id"` + PlatformID string `json:"platform_id"` + PlatformUid string `json:"platform_uid"` + AccountFingerprint pgtype.Text `json:"account_fingerprint"` + DisplayName string `json:"display_name"` + Health string `json:"health"` + VerifiedAt pgtype.Timestamptz `json:"verified_at"` + Tags []byte `json:"tags"` + SyncVersion int64 `json:"sync_version"` + DeletedAt pgtype.Timestamptz `json:"deleted_at"` + DeleteRequestedAt pgtype.Timestamptz `json:"delete_requested_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) PatchDesktopAccount(ctx context.Context, arg PatchDesktopAccountParams) (PatchDesktopAccountRow, error) { + row := q.db.QueryRow(ctx, patchDesktopAccount, + arg.DisplayName, + arg.Health, + arg.LegacyStatus, + arg.VerifiedAt, + arg.Tags, + arg.DesktopID, + arg.WorkspaceID, + arg.IfSyncVersion, + ) + var i PatchDesktopAccountRow + err := row.Scan( + &i.DesktopID, + &i.TenantID, + &i.WorkspaceID, + &i.UserID, + &i.ClientID, + &i.PlatformID, + &i.PlatformUid, + &i.AccountFingerprint, + &i.DisplayName, + &i.Health, + &i.VerifiedAt, + &i.Tags, + &i.SyncVersion, + &i.DeletedAt, + &i.DeleteRequestedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const tombstoneDesktopAccount = `-- name: TombstoneDesktopAccount :one +UPDATE platform_accounts +SET deleted_at = now(), + sync_version = sync_version + 1, + updated_at = now() +WHERE desktop_id = $1 + AND workspace_id = $2 + AND sync_version = $3 + AND deleted_at IS NULL +RETURNING + desktop_id, + tenant_id, + workspace_id, + user_id, + client_id, + platform_id, + platform_uid, + account_fingerprint, + display_name, + health, + verified_at, + tags, + sync_version, + deleted_at, + delete_requested_at, + created_at, + updated_at +` + +type TombstoneDesktopAccountParams struct { + DesktopID pgtype.UUID `json:"desktop_id"` + WorkspaceID int64 `json:"workspace_id"` + IfSyncVersion int64 `json:"if_sync_version"` +} + +type TombstoneDesktopAccountRow struct { + DesktopID pgtype.UUID `json:"desktop_id"` + TenantID int64 `json:"tenant_id"` + WorkspaceID int64 `json:"workspace_id"` + UserID int64 `json:"user_id"` + ClientID pgtype.UUID `json:"client_id"` + PlatformID string `json:"platform_id"` + PlatformUid string `json:"platform_uid"` + AccountFingerprint pgtype.Text `json:"account_fingerprint"` + DisplayName string `json:"display_name"` + Health string `json:"health"` + VerifiedAt pgtype.Timestamptz `json:"verified_at"` + Tags []byte `json:"tags"` + SyncVersion int64 `json:"sync_version"` + DeletedAt pgtype.Timestamptz `json:"deleted_at"` + DeleteRequestedAt pgtype.Timestamptz `json:"delete_requested_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) TombstoneDesktopAccount(ctx context.Context, arg TombstoneDesktopAccountParams) (TombstoneDesktopAccountRow, error) { + row := q.db.QueryRow(ctx, tombstoneDesktopAccount, arg.DesktopID, arg.WorkspaceID, arg.IfSyncVersion) + var i TombstoneDesktopAccountRow + err := row.Scan( + &i.DesktopID, + &i.TenantID, + &i.WorkspaceID, + &i.UserID, + &i.ClientID, + &i.PlatformID, + &i.PlatformUid, + &i.AccountFingerprint, + &i.DisplayName, + &i.Health, + &i.VerifiedAt, + &i.Tags, + &i.SyncVersion, + &i.DeletedAt, + &i.DeleteRequestedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const upsertDesktopAccount = `-- name: UpsertDesktopAccount :one +INSERT INTO platform_accounts ( + desktop_id, + tenant_id, + workspace_id, + user_id, + client_id, + platform_id, + platform_uid, + nickname, + status, + metadata_json, + last_check_at, + account_fingerprint, + display_name, + health, + verified_at, + tags, + sync_version +) +VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8::text, + $9, + $10, + $11, + $12, + $8::text, + $13, + $11, + $14, + 1 +) +ON CONFLICT (workspace_id, platform_id, platform_uid) WHERE deleted_at IS NULL +DO UPDATE SET + client_id = EXCLUDED.client_id, + user_id = EXCLUDED.user_id, + nickname = EXCLUDED.nickname, + status = EXCLUDED.status, + metadata_json = COALESCE(EXCLUDED.metadata_json, platform_accounts.metadata_json), + last_check_at = COALESCE(EXCLUDED.last_check_at, platform_accounts.last_check_at), + account_fingerprint = EXCLUDED.account_fingerprint, + display_name = EXCLUDED.display_name, + health = EXCLUDED.health, + verified_at = COALESCE(EXCLUDED.verified_at, platform_accounts.verified_at), + tags = EXCLUDED.tags, + sync_version = platform_accounts.sync_version + 1, + delete_requested_at = NULL, + updated_at = now() +WHERE platform_accounts.workspace_id = EXCLUDED.workspace_id + AND ( + $15::bigint IS NULL + OR platform_accounts.sync_version = $15::bigint + ) +RETURNING + desktop_id, + tenant_id, + workspace_id, + user_id, + client_id, + platform_id, + platform_uid, + account_fingerprint, + display_name, + health, + verified_at, + tags, + sync_version, + deleted_at, + delete_requested_at, + created_at, + updated_at +` + +type UpsertDesktopAccountParams struct { + DesktopID pgtype.UUID `json:"desktop_id"` + TenantID int64 `json:"tenant_id"` + WorkspaceID int64 `json:"workspace_id"` + UserID int64 `json:"user_id"` + ClientID pgtype.UUID `json:"client_id"` + PlatformID string `json:"platform_id"` + PlatformUid string `json:"platform_uid"` + DisplayName string `json:"display_name"` + LegacyStatus string `json:"legacy_status"` + MetadataJson []byte `json:"metadata_json"` + VerifiedAt pgtype.Timestamptz `json:"verified_at"` + AccountFingerprint pgtype.Text `json:"account_fingerprint"` + Health string `json:"health"` + Tags []byte `json:"tags"` + IfSyncVersion pgtype.Int8 `json:"if_sync_version"` +} + +type UpsertDesktopAccountRow struct { + DesktopID pgtype.UUID `json:"desktop_id"` + TenantID int64 `json:"tenant_id"` + WorkspaceID int64 `json:"workspace_id"` + UserID int64 `json:"user_id"` + ClientID pgtype.UUID `json:"client_id"` + PlatformID string `json:"platform_id"` + PlatformUid string `json:"platform_uid"` + AccountFingerprint pgtype.Text `json:"account_fingerprint"` + DisplayName string `json:"display_name"` + Health string `json:"health"` + VerifiedAt pgtype.Timestamptz `json:"verified_at"` + Tags []byte `json:"tags"` + SyncVersion int64 `json:"sync_version"` + DeletedAt pgtype.Timestamptz `json:"deleted_at"` + DeleteRequestedAt pgtype.Timestamptz `json:"delete_requested_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) UpsertDesktopAccount(ctx context.Context, arg UpsertDesktopAccountParams) (UpsertDesktopAccountRow, error) { + row := q.db.QueryRow(ctx, upsertDesktopAccount, + arg.DesktopID, + arg.TenantID, + arg.WorkspaceID, + arg.UserID, + arg.ClientID, + arg.PlatformID, + arg.PlatformUid, + arg.DisplayName, + arg.LegacyStatus, + arg.MetadataJson, + arg.VerifiedAt, + arg.AccountFingerprint, + arg.Health, + arg.Tags, + arg.IfSyncVersion, + ) + var i UpsertDesktopAccountRow + err := row.Scan( + &i.DesktopID, + &i.TenantID, + &i.WorkspaceID, + &i.UserID, + &i.ClientID, + &i.PlatformID, + &i.PlatformUid, + &i.AccountFingerprint, + &i.DisplayName, + &i.Health, + &i.VerifiedAt, + &i.Tags, + &i.SyncVersion, + &i.DeletedAt, + &i.DeleteRequestedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/server/internal/tenant/repository/generated/desktop_client.sql.go b/server/internal/tenant/repository/generated/desktop_client.sql.go new file mode 100644 index 0000000..ccf511c --- /dev/null +++ b/server/internal/tenant/repository/generated/desktop_client.sql.go @@ -0,0 +1,287 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: desktop_client.sql + +package generated + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const getDesktopClientByTokenHash = `-- name: GetDesktopClientByTokenHash :one +SELECT id, tenant_id, workspace_id, user_id, token_hash, device_name, os, cpu_arch, client_version, channel, created_at, last_seen_at, last_rotated_at, revoked_at +FROM desktop_clients +WHERE token_hash = $1 + AND revoked_at IS NULL +LIMIT 1 +` + +func (q *Queries) GetDesktopClientByTokenHash(ctx context.Context, tokenHash []byte) (DesktopClient, error) { + row := q.db.QueryRow(ctx, getDesktopClientByTokenHash, tokenHash) + var i DesktopClient + err := row.Scan( + &i.ID, + &i.TenantID, + &i.WorkspaceID, + &i.UserID, + &i.TokenHash, + &i.DeviceName, + &i.Os, + &i.CpuArch, + &i.ClientVersion, + &i.Channel, + &i.CreatedAt, + &i.LastSeenAt, + &i.LastRotatedAt, + &i.RevokedAt, + ) + return i, err +} + +const heartbeatDesktopClient = `-- name: HeartbeatDesktopClient :one +UPDATE desktop_clients +SET device_name = COALESCE($1, device_name), + os = COALESCE($2, os), + cpu_arch = COALESCE($3, cpu_arch), + client_version = COALESCE($4, client_version), + channel = COALESCE($5, channel), + last_seen_at = now() +WHERE id = $6 + AND workspace_id = $7 + AND revoked_at IS NULL +RETURNING id, tenant_id, workspace_id, user_id, token_hash, device_name, os, cpu_arch, client_version, channel, created_at, last_seen_at, last_rotated_at, revoked_at +` + +type HeartbeatDesktopClientParams struct { + DeviceName pgtype.Text `json:"device_name"` + Os pgtype.Text `json:"os"` + CpuArch pgtype.Text `json:"cpu_arch"` + ClientVersion pgtype.Text `json:"client_version"` + Channel pgtype.Text `json:"channel"` + ID pgtype.UUID `json:"id"` + WorkspaceID int64 `json:"workspace_id"` +} + +func (q *Queries) HeartbeatDesktopClient(ctx context.Context, arg HeartbeatDesktopClientParams) (DesktopClient, error) { + row := q.db.QueryRow(ctx, heartbeatDesktopClient, + arg.DeviceName, + arg.Os, + arg.CpuArch, + arg.ClientVersion, + arg.Channel, + arg.ID, + arg.WorkspaceID, + ) + var i DesktopClient + err := row.Scan( + &i.ID, + &i.TenantID, + &i.WorkspaceID, + &i.UserID, + &i.TokenHash, + &i.DeviceName, + &i.Os, + &i.CpuArch, + &i.ClientVersion, + &i.Channel, + &i.CreatedAt, + &i.LastSeenAt, + &i.LastRotatedAt, + &i.RevokedAt, + ) + return i, err +} + +const listDesktopClientsByWorkspace = `-- name: ListDesktopClientsByWorkspace :many +SELECT id, tenant_id, workspace_id, user_id, token_hash, device_name, os, cpu_arch, client_version, channel, created_at, last_seen_at, last_rotated_at, revoked_at +FROM desktop_clients +WHERE workspace_id = $1 + AND revoked_at IS NULL +ORDER BY last_seen_at DESC NULLS LAST, created_at DESC +` + +func (q *Queries) ListDesktopClientsByWorkspace(ctx context.Context, workspaceID int64) ([]DesktopClient, error) { + rows, err := q.db.Query(ctx, listDesktopClientsByWorkspace, workspaceID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []DesktopClient + for rows.Next() { + var i DesktopClient + if err := rows.Scan( + &i.ID, + &i.TenantID, + &i.WorkspaceID, + &i.UserID, + &i.TokenHash, + &i.DeviceName, + &i.Os, + &i.CpuArch, + &i.ClientVersion, + &i.Channel, + &i.CreatedAt, + &i.LastSeenAt, + &i.LastRotatedAt, + &i.RevokedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const registerDesktopClient = `-- name: RegisterDesktopClient :one +INSERT INTO desktop_clients ( + id, + tenant_id, + workspace_id, + user_id, + token_hash, + device_name, + os, + cpu_arch, + client_version, + channel +) +VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9, + $10 +) +RETURNING id, tenant_id, workspace_id, user_id, token_hash, device_name, os, cpu_arch, client_version, channel, created_at, last_seen_at, last_rotated_at, revoked_at +` + +type RegisterDesktopClientParams struct { + ID pgtype.UUID `json:"id"` + TenantID int64 `json:"tenant_id"` + WorkspaceID int64 `json:"workspace_id"` + UserID int64 `json:"user_id"` + TokenHash []byte `json:"token_hash"` + DeviceName pgtype.Text `json:"device_name"` + Os pgtype.Text `json:"os"` + CpuArch pgtype.Text `json:"cpu_arch"` + ClientVersion pgtype.Text `json:"client_version"` + Channel pgtype.Text `json:"channel"` +} + +func (q *Queries) RegisterDesktopClient(ctx context.Context, arg RegisterDesktopClientParams) (DesktopClient, error) { + row := q.db.QueryRow(ctx, registerDesktopClient, + arg.ID, + arg.TenantID, + arg.WorkspaceID, + arg.UserID, + arg.TokenHash, + arg.DeviceName, + arg.Os, + arg.CpuArch, + arg.ClientVersion, + arg.Channel, + ) + var i DesktopClient + err := row.Scan( + &i.ID, + &i.TenantID, + &i.WorkspaceID, + &i.UserID, + &i.TokenHash, + &i.DeviceName, + &i.Os, + &i.CpuArch, + &i.ClientVersion, + &i.Channel, + &i.CreatedAt, + &i.LastSeenAt, + &i.LastRotatedAt, + &i.RevokedAt, + ) + return i, err +} + +const revokeDesktopClient = `-- name: RevokeDesktopClient :one +UPDATE desktop_clients +SET revoked_at = now() +WHERE id = $1 + AND workspace_id = $2 + AND revoked_at IS NULL +RETURNING id, tenant_id, workspace_id, user_id, token_hash, device_name, os, cpu_arch, client_version, channel, created_at, last_seen_at, last_rotated_at, revoked_at +` + +type RevokeDesktopClientParams struct { + ID pgtype.UUID `json:"id"` + WorkspaceID int64 `json:"workspace_id"` +} + +func (q *Queries) RevokeDesktopClient(ctx context.Context, arg RevokeDesktopClientParams) (DesktopClient, error) { + row := q.db.QueryRow(ctx, revokeDesktopClient, arg.ID, arg.WorkspaceID) + var i DesktopClient + err := row.Scan( + &i.ID, + &i.TenantID, + &i.WorkspaceID, + &i.UserID, + &i.TokenHash, + &i.DeviceName, + &i.Os, + &i.CpuArch, + &i.ClientVersion, + &i.Channel, + &i.CreatedAt, + &i.LastSeenAt, + &i.LastRotatedAt, + &i.RevokedAt, + ) + return i, err +} + +const rotateDesktopClientToken = `-- name: RotateDesktopClientToken :one +UPDATE desktop_clients +SET token_hash = $1, + last_rotated_at = now() +WHERE id = $2 + AND workspace_id = $3 + AND revoked_at IS NULL +RETURNING id, tenant_id, workspace_id, user_id, token_hash, device_name, os, cpu_arch, client_version, channel, created_at, last_seen_at, last_rotated_at, revoked_at +` + +type RotateDesktopClientTokenParams struct { + TokenHash []byte `json:"token_hash"` + ID pgtype.UUID `json:"id"` + WorkspaceID int64 `json:"workspace_id"` +} + +func (q *Queries) RotateDesktopClientToken(ctx context.Context, arg RotateDesktopClientTokenParams) (DesktopClient, error) { + row := q.db.QueryRow(ctx, rotateDesktopClientToken, arg.TokenHash, arg.ID, arg.WorkspaceID) + var i DesktopClient + err := row.Scan( + &i.ID, + &i.TenantID, + &i.WorkspaceID, + &i.UserID, + &i.TokenHash, + &i.DeviceName, + &i.Os, + &i.CpuArch, + &i.ClientVersion, + &i.Channel, + &i.CreatedAt, + &i.LastSeenAt, + &i.LastRotatedAt, + &i.RevokedAt, + ) + return i, err +} diff --git a/server/internal/tenant/repository/generated/desktop_task.sql.go b/server/internal/tenant/repository/generated/desktop_task.sql.go new file mode 100644 index 0000000..4bee930 --- /dev/null +++ b/server/internal/tenant/repository/generated/desktop_task.sql.go @@ -0,0 +1,850 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: desktop_task.sql + +package generated + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const cancelDesktopTaskByClient = `-- name: CancelDesktopTaskByClient :one +UPDATE desktop_tasks +SET status = 'aborted', + error = $1, + active_attempt_id = NULL, + lease_token_hash = NULL, + lease_expires_at = NULL, + parked_reason = NULL, + updated_at = now() +WHERE desktop_id = $2 + AND target_client_id = $3 + AND status IN ('queued', 'waiting_user') +RETURNING id, desktop_id, job_id, tenant_id, workspace_id, target_account_id, target_client_id, platform_id, kind, payload, status, dedup_key, active_attempt_id, lease_token_hash, lease_expires_at, attempts, parked_reason, result, error, created_at, updated_at +` + +type CancelDesktopTaskByClientParams struct { + Error []byte `json:"error"` + DesktopID pgtype.UUID `json:"desktop_id"` + ClientID pgtype.UUID `json:"client_id"` +} + +func (q *Queries) CancelDesktopTaskByClient(ctx context.Context, arg CancelDesktopTaskByClientParams) (DesktopTask, error) { + row := q.db.QueryRow(ctx, cancelDesktopTaskByClient, arg.Error, arg.DesktopID, arg.ClientID) + var i DesktopTask + err := row.Scan( + &i.ID, + &i.DesktopID, + &i.JobID, + &i.TenantID, + &i.WorkspaceID, + &i.TargetAccountID, + &i.TargetClientID, + &i.PlatformID, + &i.Kind, + &i.Payload, + &i.Status, + &i.DedupKey, + &i.ActiveAttemptID, + &i.LeaseTokenHash, + &i.LeaseExpiresAt, + &i.Attempts, + &i.ParkedReason, + &i.Result, + &i.Error, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const cancelDesktopTaskByLease = `-- name: CancelDesktopTaskByLease :one +UPDATE desktop_tasks +SET status = 'aborted', + error = $1, + active_attempt_id = NULL, + lease_token_hash = NULL, + lease_expires_at = NULL, + parked_reason = NULL, + updated_at = now() +WHERE desktop_id = $2 + AND lease_token_hash = $3 + AND status = 'in_progress' +RETURNING id, desktop_id, job_id, tenant_id, workspace_id, target_account_id, target_client_id, platform_id, kind, payload, status, dedup_key, active_attempt_id, lease_token_hash, lease_expires_at, attempts, parked_reason, result, error, created_at, updated_at +` + +type CancelDesktopTaskByLeaseParams struct { + Error []byte `json:"error"` + DesktopID pgtype.UUID `json:"desktop_id"` + LeaseTokenHash []byte `json:"lease_token_hash"` +} + +func (q *Queries) CancelDesktopTaskByLease(ctx context.Context, arg CancelDesktopTaskByLeaseParams) (DesktopTask, error) { + row := q.db.QueryRow(ctx, cancelDesktopTaskByLease, arg.Error, arg.DesktopID, arg.LeaseTokenHash) + var i DesktopTask + err := row.Scan( + &i.ID, + &i.DesktopID, + &i.JobID, + &i.TenantID, + &i.WorkspaceID, + &i.TargetAccountID, + &i.TargetClientID, + &i.PlatformID, + &i.Kind, + &i.Payload, + &i.Status, + &i.DedupKey, + &i.ActiveAttemptID, + &i.LeaseTokenHash, + &i.LeaseExpiresAt, + &i.Attempts, + &i.ParkedReason, + &i.Result, + &i.Error, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const completeDesktopTask = `-- name: CompleteDesktopTask :one +UPDATE desktop_tasks +SET status = $1, + result = $2, + error = $3, + active_attempt_id = NULL, + lease_token_hash = NULL, + lease_expires_at = NULL, + parked_reason = NULL, + updated_at = now() +WHERE desktop_id = $4 + AND lease_token_hash = $5 + AND status = 'in_progress' +RETURNING id, desktop_id, job_id, tenant_id, workspace_id, target_account_id, target_client_id, platform_id, kind, payload, status, dedup_key, active_attempt_id, lease_token_hash, lease_expires_at, attempts, parked_reason, result, error, created_at, updated_at +` + +type CompleteDesktopTaskParams struct { + Status string `json:"status"` + Result []byte `json:"result"` + Error []byte `json:"error"` + DesktopID pgtype.UUID `json:"desktop_id"` + LeaseTokenHash []byte `json:"lease_token_hash"` +} + +func (q *Queries) CompleteDesktopTask(ctx context.Context, arg CompleteDesktopTaskParams) (DesktopTask, error) { + row := q.db.QueryRow(ctx, completeDesktopTask, + arg.Status, + arg.Result, + arg.Error, + arg.DesktopID, + arg.LeaseTokenHash, + ) + var i DesktopTask + err := row.Scan( + &i.ID, + &i.DesktopID, + &i.JobID, + &i.TenantID, + &i.WorkspaceID, + &i.TargetAccountID, + &i.TargetClientID, + &i.PlatformID, + &i.Kind, + &i.Payload, + &i.Status, + &i.DedupKey, + &i.ActiveAttemptID, + &i.LeaseTokenHash, + &i.LeaseExpiresAt, + &i.Attempts, + &i.ParkedReason, + &i.Result, + &i.Error, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const createDesktopPublishJob = `-- name: CreateDesktopPublishJob :one +INSERT INTO desktop_publish_jobs ( + desktop_id, + tenant_id, + workspace_id, + created_by_user_id, + title, + content_ref, + scheduled_at +) +VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7 +) +RETURNING id, desktop_id, tenant_id, workspace_id, created_by_user_id, title, content_ref, scheduled_at, created_at, updated_at +` + +type CreateDesktopPublishJobParams struct { + DesktopID pgtype.UUID `json:"desktop_id"` + TenantID int64 `json:"tenant_id"` + WorkspaceID int64 `json:"workspace_id"` + CreatedByUserID int64 `json:"created_by_user_id"` + Title string `json:"title"` + ContentRef []byte `json:"content_ref"` + ScheduledAt pgtype.Timestamptz `json:"scheduled_at"` +} + +func (q *Queries) CreateDesktopPublishJob(ctx context.Context, arg CreateDesktopPublishJobParams) (DesktopPublishJob, error) { + row := q.db.QueryRow(ctx, createDesktopPublishJob, + arg.DesktopID, + arg.TenantID, + arg.WorkspaceID, + arg.CreatedByUserID, + arg.Title, + arg.ContentRef, + arg.ScheduledAt, + ) + var i DesktopPublishJob + err := row.Scan( + &i.ID, + &i.DesktopID, + &i.TenantID, + &i.WorkspaceID, + &i.CreatedByUserID, + &i.Title, + &i.ContentRef, + &i.ScheduledAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const createDesktopTask = `-- name: CreateDesktopTask :one +INSERT INTO desktop_tasks ( + desktop_id, + job_id, + tenant_id, + workspace_id, + target_account_id, + target_client_id, + platform_id, + kind, + payload, + status, + dedup_key, + parked_reason +) +VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9, + $10, + $11, + $12 +) +RETURNING id, desktop_id, job_id, tenant_id, workspace_id, target_account_id, target_client_id, platform_id, kind, payload, status, dedup_key, active_attempt_id, lease_token_hash, lease_expires_at, attempts, parked_reason, result, error, created_at, updated_at +` + +type CreateDesktopTaskParams struct { + DesktopID pgtype.UUID `json:"desktop_id"` + JobID pgtype.UUID `json:"job_id"` + TenantID int64 `json:"tenant_id"` + WorkspaceID int64 `json:"workspace_id"` + TargetAccountID pgtype.UUID `json:"target_account_id"` + TargetClientID pgtype.UUID `json:"target_client_id"` + PlatformID string `json:"platform_id"` + Kind string `json:"kind"` + Payload []byte `json:"payload"` + Status string `json:"status"` + DedupKey pgtype.Text `json:"dedup_key"` + ParkedReason pgtype.Text `json:"parked_reason"` +} + +func (q *Queries) CreateDesktopTask(ctx context.Context, arg CreateDesktopTaskParams) (DesktopTask, error) { + row := q.db.QueryRow(ctx, createDesktopTask, + arg.DesktopID, + arg.JobID, + arg.TenantID, + arg.WorkspaceID, + arg.TargetAccountID, + arg.TargetClientID, + arg.PlatformID, + arg.Kind, + arg.Payload, + arg.Status, + arg.DedupKey, + arg.ParkedReason, + ) + var i DesktopTask + err := row.Scan( + &i.ID, + &i.DesktopID, + &i.JobID, + &i.TenantID, + &i.WorkspaceID, + &i.TargetAccountID, + &i.TargetClientID, + &i.PlatformID, + &i.Kind, + &i.Payload, + &i.Status, + &i.DedupKey, + &i.ActiveAttemptID, + &i.LeaseTokenHash, + &i.LeaseExpiresAt, + &i.Attempts, + &i.ParkedReason, + &i.Result, + &i.Error, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const createDesktopTaskAttempt = `-- name: CreateDesktopTaskAttempt :one +INSERT INTO desktop_task_attempts ( + desktop_id, + task_id, + client_id, + lease_token_hash +) +VALUES ( + $1, + $2, + $3, + $4 +) +RETURNING id, desktop_id, task_id, client_id, lease_token_hash, started_at, ended_at, final_status, error, created_at +` + +type CreateDesktopTaskAttemptParams struct { + DesktopID pgtype.UUID `json:"desktop_id"` + TaskID pgtype.UUID `json:"task_id"` + ClientID pgtype.UUID `json:"client_id"` + LeaseTokenHash []byte `json:"lease_token_hash"` +} + +func (q *Queries) CreateDesktopTaskAttempt(ctx context.Context, arg CreateDesktopTaskAttemptParams) (DesktopTaskAttempt, error) { + row := q.db.QueryRow(ctx, createDesktopTaskAttempt, + arg.DesktopID, + arg.TaskID, + arg.ClientID, + arg.LeaseTokenHash, + ) + var i DesktopTaskAttempt + err := row.Scan( + &i.ID, + &i.DesktopID, + &i.TaskID, + &i.ClientID, + &i.LeaseTokenHash, + &i.StartedAt, + &i.EndedAt, + &i.FinalStatus, + &i.Error, + &i.CreatedAt, + ) + return i, err +} + +const extendDesktopTaskLease = `-- name: ExtendDesktopTaskLease :one +UPDATE desktop_tasks +SET lease_expires_at = now() + interval '10 minutes', + updated_at = now() +WHERE desktop_id = $1 + AND lease_token_hash = $2 + AND status = 'in_progress' +RETURNING id, desktop_id, job_id, tenant_id, workspace_id, target_account_id, target_client_id, platform_id, kind, payload, status, dedup_key, active_attempt_id, lease_token_hash, lease_expires_at, attempts, parked_reason, result, error, created_at, updated_at +` + +type ExtendDesktopTaskLeaseParams struct { + DesktopID pgtype.UUID `json:"desktop_id"` + LeaseTokenHash []byte `json:"lease_token_hash"` +} + +func (q *Queries) ExtendDesktopTaskLease(ctx context.Context, arg ExtendDesktopTaskLeaseParams) (DesktopTask, error) { + row := q.db.QueryRow(ctx, extendDesktopTaskLease, arg.DesktopID, arg.LeaseTokenHash) + var i DesktopTask + err := row.Scan( + &i.ID, + &i.DesktopID, + &i.JobID, + &i.TenantID, + &i.WorkspaceID, + &i.TargetAccountID, + &i.TargetClientID, + &i.PlatformID, + &i.Kind, + &i.Payload, + &i.Status, + &i.DedupKey, + &i.ActiveAttemptID, + &i.LeaseTokenHash, + &i.LeaseExpiresAt, + &i.Attempts, + &i.ParkedReason, + &i.Result, + &i.Error, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const finishDesktopTaskAttempt = `-- name: FinishDesktopTaskAttempt :exec +UPDATE desktop_task_attempts +SET ended_at = now(), + final_status = $1, + error = $2 +WHERE task_id = $3 + AND desktop_id = $4 +` + +type FinishDesktopTaskAttemptParams struct { + FinalStatus pgtype.Text `json:"final_status"` + Error []byte `json:"error"` + TaskID pgtype.UUID `json:"task_id"` + AttemptID pgtype.UUID `json:"attempt_id"` +} + +func (q *Queries) FinishDesktopTaskAttempt(ctx context.Context, arg FinishDesktopTaskAttemptParams) error { + _, err := q.db.Exec(ctx, finishDesktopTaskAttempt, + arg.FinalStatus, + arg.Error, + arg.TaskID, + arg.AttemptID, + ) + return err +} + +const getDesktopTaskByDesktopID = `-- name: GetDesktopTaskByDesktopID :one +SELECT id, desktop_id, job_id, tenant_id, workspace_id, target_account_id, target_client_id, platform_id, kind, payload, status, dedup_key, active_attempt_id, lease_token_hash, lease_expires_at, attempts, parked_reason, result, error, created_at, updated_at +FROM desktop_tasks +WHERE desktop_id = $1 + AND workspace_id = $2 +LIMIT 1 +` + +type GetDesktopTaskByDesktopIDParams struct { + DesktopID pgtype.UUID `json:"desktop_id"` + WorkspaceID int64 `json:"workspace_id"` +} + +func (q *Queries) GetDesktopTaskByDesktopID(ctx context.Context, arg GetDesktopTaskByDesktopIDParams) (DesktopTask, error) { + row := q.db.QueryRow(ctx, getDesktopTaskByDesktopID, arg.DesktopID, arg.WorkspaceID) + var i DesktopTask + err := row.Scan( + &i.ID, + &i.DesktopID, + &i.JobID, + &i.TenantID, + &i.WorkspaceID, + &i.TargetAccountID, + &i.TargetClientID, + &i.PlatformID, + &i.Kind, + &i.Payload, + &i.Status, + &i.DedupKey, + &i.ActiveAttemptID, + &i.LeaseTokenHash, + &i.LeaseExpiresAt, + &i.Attempts, + &i.ParkedReason, + &i.Result, + &i.Error, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const leaseNextQueuedDesktopTask = `-- name: LeaseNextQueuedDesktopTask :one +WITH candidate AS ( + SELECT dt.desktop_id + FROM desktop_tasks AS dt + WHERE dt.target_client_id = $3 + AND dt.status = 'queued' + AND ( + $4::text IS NULL + OR dt.kind = $4::text + ) + ORDER BY dt.created_at ASC, dt.desktop_id ASC + LIMIT 1 + FOR UPDATE SKIP LOCKED +) +UPDATE desktop_tasks AS t +SET active_attempt_id = $1, + lease_token_hash = $2, + lease_expires_at = now() + interval '10 minutes', + status = 'in_progress', + attempts = t.attempts + 1, + parked_reason = NULL, + updated_at = now() +FROM candidate +WHERE t.desktop_id = candidate.desktop_id +RETURNING t.id, t.desktop_id, t.job_id, t.tenant_id, t.workspace_id, t.target_account_id, t.target_client_id, t.platform_id, t.kind, t.payload, t.status, t.dedup_key, t.active_attempt_id, t.lease_token_hash, t.lease_expires_at, t.attempts, t.parked_reason, t.result, t.error, t.created_at, t.updated_at +` + +type LeaseNextQueuedDesktopTaskParams struct { + AttemptID pgtype.UUID `json:"attempt_id"` + LeaseTokenHash []byte `json:"lease_token_hash"` + ClientID pgtype.UUID `json:"client_id"` + Kind pgtype.Text `json:"kind"` +} + +func (q *Queries) LeaseNextQueuedDesktopTask(ctx context.Context, arg LeaseNextQueuedDesktopTaskParams) (DesktopTask, error) { + row := q.db.QueryRow(ctx, leaseNextQueuedDesktopTask, + arg.AttemptID, + arg.LeaseTokenHash, + arg.ClientID, + arg.Kind, + ) + var i DesktopTask + err := row.Scan( + &i.ID, + &i.DesktopID, + &i.JobID, + &i.TenantID, + &i.WorkspaceID, + &i.TargetAccountID, + &i.TargetClientID, + &i.PlatformID, + &i.Kind, + &i.Payload, + &i.Status, + &i.DedupKey, + &i.ActiveAttemptID, + &i.LeaseTokenHash, + &i.LeaseExpiresAt, + &i.Attempts, + &i.ParkedReason, + &i.Result, + &i.Error, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const leaseParkedDesktopTask = `-- name: LeaseParkedDesktopTask :one +UPDATE desktop_tasks +SET active_attempt_id = $1, + lease_token_hash = $2, + lease_expires_at = now() + interval '10 minutes', + status = 'in_progress', + attempts = attempts + 1, + parked_reason = NULL, + updated_at = now() +WHERE desktop_id = $3 + AND target_client_id = $4 + AND status = 'waiting_user' + AND active_attempt_id IS NULL +RETURNING id, desktop_id, job_id, tenant_id, workspace_id, target_account_id, target_client_id, platform_id, kind, payload, status, dedup_key, active_attempt_id, lease_token_hash, lease_expires_at, attempts, parked_reason, result, error, created_at, updated_at +` + +type LeaseParkedDesktopTaskParams struct { + AttemptID pgtype.UUID `json:"attempt_id"` + LeaseTokenHash []byte `json:"lease_token_hash"` + DesktopID pgtype.UUID `json:"desktop_id"` + ClientID pgtype.UUID `json:"client_id"` +} + +func (q *Queries) LeaseParkedDesktopTask(ctx context.Context, arg LeaseParkedDesktopTaskParams) (DesktopTask, error) { + row := q.db.QueryRow(ctx, leaseParkedDesktopTask, + arg.AttemptID, + arg.LeaseTokenHash, + arg.DesktopID, + arg.ClientID, + ) + var i DesktopTask + err := row.Scan( + &i.ID, + &i.DesktopID, + &i.JobID, + &i.TenantID, + &i.WorkspaceID, + &i.TargetAccountID, + &i.TargetClientID, + &i.PlatformID, + &i.Kind, + &i.Payload, + &i.Status, + &i.DedupKey, + &i.ActiveAttemptID, + &i.LeaseTokenHash, + &i.LeaseExpiresAt, + &i.Attempts, + &i.ParkedReason, + &i.Result, + &i.Error, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const leaseQueuedDesktopTaskByDesktopID = `-- name: LeaseQueuedDesktopTaskByDesktopID :one +UPDATE desktop_tasks +SET active_attempt_id = $1, + lease_token_hash = $2, + lease_expires_at = now() + interval '10 minutes', + status = 'in_progress', + attempts = attempts + 1, + parked_reason = NULL, + updated_at = now() +WHERE desktop_id = $3 + AND target_client_id = $4 + AND status = 'queued' +RETURNING id, desktop_id, job_id, tenant_id, workspace_id, target_account_id, target_client_id, platform_id, kind, payload, status, dedup_key, active_attempt_id, lease_token_hash, lease_expires_at, attempts, parked_reason, result, error, created_at, updated_at +` + +type LeaseQueuedDesktopTaskByDesktopIDParams struct { + AttemptID pgtype.UUID `json:"attempt_id"` + LeaseTokenHash []byte `json:"lease_token_hash"` + DesktopID pgtype.UUID `json:"desktop_id"` + ClientID pgtype.UUID `json:"client_id"` +} + +func (q *Queries) LeaseQueuedDesktopTaskByDesktopID(ctx context.Context, arg LeaseQueuedDesktopTaskByDesktopIDParams) (DesktopTask, error) { + row := q.db.QueryRow(ctx, leaseQueuedDesktopTaskByDesktopID, + arg.AttemptID, + arg.LeaseTokenHash, + arg.DesktopID, + arg.ClientID, + ) + var i DesktopTask + err := row.Scan( + &i.ID, + &i.DesktopID, + &i.JobID, + &i.TenantID, + &i.WorkspaceID, + &i.TargetAccountID, + &i.TargetClientID, + &i.PlatformID, + &i.Kind, + &i.Payload, + &i.Status, + &i.DedupKey, + &i.ActiveAttemptID, + &i.LeaseTokenHash, + &i.LeaseExpiresAt, + &i.Attempts, + &i.ParkedReason, + &i.Result, + &i.Error, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const markDesktopTasksUnknownByClient = `-- name: MarkDesktopTasksUnknownByClient :execrows +UPDATE desktop_tasks +SET status = 'unknown', + error = $1, + active_attempt_id = NULL, + lease_token_hash = NULL, + lease_expires_at = NULL, + parked_reason = NULL, + updated_at = now() +WHERE target_client_id = $2 + AND workspace_id = $3 + AND status IN ('waiting_user', 'in_progress') +` + +type MarkDesktopTasksUnknownByClientParams struct { + Error []byte `json:"error"` + ClientID pgtype.UUID `json:"client_id"` + WorkspaceID int64 `json:"workspace_id"` +} + +func (q *Queries) MarkDesktopTasksUnknownByClient(ctx context.Context, arg MarkDesktopTasksUnknownByClientParams) (int64, error) { + result, err := q.db.Exec(ctx, markDesktopTasksUnknownByClient, arg.Error, arg.ClientID, arg.WorkspaceID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const parkDesktopTask = `-- name: ParkDesktopTask :one +UPDATE desktop_tasks +SET status = 'waiting_user', + active_attempt_id = NULL, + lease_token_hash = NULL, + lease_expires_at = NULL, + parked_reason = $1, + updated_at = now() +WHERE desktop_id = $2 + AND lease_token_hash = $3 + AND status = 'in_progress' +RETURNING id, desktop_id, job_id, tenant_id, workspace_id, target_account_id, target_client_id, platform_id, kind, payload, status, dedup_key, active_attempt_id, lease_token_hash, lease_expires_at, attempts, parked_reason, result, error, created_at, updated_at +` + +type ParkDesktopTaskParams struct { + ParkedReason pgtype.Text `json:"parked_reason"` + DesktopID pgtype.UUID `json:"desktop_id"` + LeaseTokenHash []byte `json:"lease_token_hash"` +} + +func (q *Queries) ParkDesktopTask(ctx context.Context, arg ParkDesktopTaskParams) (DesktopTask, error) { + row := q.db.QueryRow(ctx, parkDesktopTask, arg.ParkedReason, arg.DesktopID, arg.LeaseTokenHash) + var i DesktopTask + err := row.Scan( + &i.ID, + &i.DesktopID, + &i.JobID, + &i.TenantID, + &i.WorkspaceID, + &i.TargetAccountID, + &i.TargetClientID, + &i.PlatformID, + &i.Kind, + &i.Payload, + &i.Status, + &i.DedupKey, + &i.ActiveAttemptID, + &i.LeaseTokenHash, + &i.LeaseExpiresAt, + &i.Attempts, + &i.ParkedReason, + &i.Result, + &i.Error, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const reconcileDesktopTask = `-- name: ReconcileDesktopTask :one +UPDATE desktop_tasks +SET status = CASE + WHEN $1::text = 'retry' THEN 'queued' + ELSE $1::text + END, + result = CASE + WHEN $1::text = 'retry' THEN NULL + ELSE $2 + END, + error = $3, + active_attempt_id = NULL, + lease_token_hash = NULL, + lease_expires_at = NULL, + parked_reason = NULL, + attempts = attempts + CASE WHEN $1::text = 'retry' THEN 1 ELSE 0 END, + updated_at = now() +WHERE desktop_id = $4 + AND workspace_id = $5 + AND status = 'unknown' +RETURNING id, desktop_id, job_id, tenant_id, workspace_id, target_account_id, target_client_id, platform_id, kind, payload, status, dedup_key, active_attempt_id, lease_token_hash, lease_expires_at, attempts, parked_reason, result, error, created_at, updated_at +` + +type ReconcileDesktopTaskParams struct { + Status string `json:"status"` + Result []byte `json:"result"` + Error []byte `json:"error"` + DesktopID pgtype.UUID `json:"desktop_id"` + WorkspaceID int64 `json:"workspace_id"` +} + +func (q *Queries) ReconcileDesktopTask(ctx context.Context, arg ReconcileDesktopTaskParams) (DesktopTask, error) { + row := q.db.QueryRow(ctx, reconcileDesktopTask, + arg.Status, + arg.Result, + arg.Error, + arg.DesktopID, + arg.WorkspaceID, + ) + var i DesktopTask + err := row.Scan( + &i.ID, + &i.DesktopID, + &i.JobID, + &i.TenantID, + &i.WorkspaceID, + &i.TargetAccountID, + &i.TargetClientID, + &i.PlatformID, + &i.Kind, + &i.Payload, + &i.Status, + &i.DedupKey, + &i.ActiveAttemptID, + &i.LeaseTokenHash, + &i.LeaseExpiresAt, + &i.Attempts, + &i.ParkedReason, + &i.Result, + &i.Error, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const tenantCancelDesktopTask = `-- name: TenantCancelDesktopTask :one +UPDATE desktop_tasks +SET status = 'aborted', + error = $1, + active_attempt_id = NULL, + lease_token_hash = NULL, + lease_expires_at = NULL, + parked_reason = NULL, + updated_at = now() +WHERE desktop_id = $2 + AND workspace_id = $3 + AND status IN ('queued', 'waiting_user') +RETURNING id, desktop_id, job_id, tenant_id, workspace_id, target_account_id, target_client_id, platform_id, kind, payload, status, dedup_key, active_attempt_id, lease_token_hash, lease_expires_at, attempts, parked_reason, result, error, created_at, updated_at +` + +type TenantCancelDesktopTaskParams struct { + Error []byte `json:"error"` + DesktopID pgtype.UUID `json:"desktop_id"` + WorkspaceID int64 `json:"workspace_id"` +} + +func (q *Queries) TenantCancelDesktopTask(ctx context.Context, arg TenantCancelDesktopTaskParams) (DesktopTask, error) { + row := q.db.QueryRow(ctx, tenantCancelDesktopTask, arg.Error, arg.DesktopID, arg.WorkspaceID) + var i DesktopTask + err := row.Scan( + &i.ID, + &i.DesktopID, + &i.JobID, + &i.TenantID, + &i.WorkspaceID, + &i.TargetAccountID, + &i.TargetClientID, + &i.PlatformID, + &i.Kind, + &i.Payload, + &i.Status, + &i.DedupKey, + &i.ActiveAttemptID, + &i.LeaseTokenHash, + &i.LeaseExpiresAt, + &i.Attempts, + &i.ParkedReason, + &i.Result, + &i.Error, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/server/internal/tenant/repository/generated/models.go b/server/internal/tenant/repository/generated/models.go index a4f4f25..d3f6aa5 100644 --- a/server/internal/tenant/repository/generated/models.go +++ b/server/internal/tenant/repository/generated/models.go @@ -119,6 +119,81 @@ type Competitor struct { DeletedAt pgtype.Timestamptz `json:"deleted_at"` } +type DesktopClient struct { + ID pgtype.UUID `json:"id"` + TenantID int64 `json:"tenant_id"` + WorkspaceID int64 `json:"workspace_id"` + UserID int64 `json:"user_id"` + TokenHash []byte `json:"token_hash"` + DeviceName pgtype.Text `json:"device_name"` + Os pgtype.Text `json:"os"` + CpuArch pgtype.Text `json:"cpu_arch"` + ClientVersion pgtype.Text `json:"client_version"` + Channel pgtype.Text `json:"channel"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + LastSeenAt pgtype.Timestamptz `json:"last_seen_at"` + LastRotatedAt pgtype.Timestamptz `json:"last_rotated_at"` + RevokedAt pgtype.Timestamptz `json:"revoked_at"` +} + +type DesktopPublishJob struct { + ID int64 `json:"id"` + DesktopID pgtype.UUID `json:"desktop_id"` + TenantID int64 `json:"tenant_id"` + WorkspaceID int64 `json:"workspace_id"` + CreatedByUserID int64 `json:"created_by_user_id"` + Title string `json:"title"` + ContentRef []byte `json:"content_ref"` + ScheduledAt pgtype.Timestamptz `json:"scheduled_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type DesktopTask struct { + ID int64 `json:"id"` + DesktopID pgtype.UUID `json:"desktop_id"` + JobID pgtype.UUID `json:"job_id"` + TenantID int64 `json:"tenant_id"` + WorkspaceID int64 `json:"workspace_id"` + TargetAccountID pgtype.UUID `json:"target_account_id"` + TargetClientID pgtype.UUID `json:"target_client_id"` + PlatformID string `json:"platform_id"` + Kind string `json:"kind"` + Payload []byte `json:"payload"` + Status string `json:"status"` + DedupKey pgtype.Text `json:"dedup_key"` + ActiveAttemptID pgtype.UUID `json:"active_attempt_id"` + LeaseTokenHash []byte `json:"lease_token_hash"` + LeaseExpiresAt pgtype.Timestamptz `json:"lease_expires_at"` + Attempts int32 `json:"attempts"` + ParkedReason pgtype.Text `json:"parked_reason"` + Result []byte `json:"result"` + Error []byte `json:"error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type DesktopTaskAttempt struct { + ID int64 `json:"id"` + DesktopID pgtype.UUID `json:"desktop_id"` + TaskID pgtype.UUID `json:"task_id"` + ClientID pgtype.UUID `json:"client_id"` + LeaseTokenHash []byte `json:"lease_token_hash"` + StartedAt pgtype.Timestamptz `json:"started_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` + FinalStatus pgtype.Text `json:"final_status"` + Error []byte `json:"error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type DesktopTaskTrace struct { + TaskID pgtype.UUID `json:"task_id"` + AttemptID pgtype.UUID `json:"attempt_id"` + Size pgtype.Int8 `json:"size"` + UploadedAt pgtype.Timestamptz `json:"uploaded_at"` + ObjectKey pgtype.Text `json:"object_key"` +} + type GenerationTask struct { ID int64 `json:"id"` TenantID int64 `json:"tenant_id"` @@ -376,19 +451,29 @@ type Plan struct { } type PlatformAccount struct { - ID int64 `json:"id"` - TenantID int64 `json:"tenant_id"` - UserID int64 `json:"user_id"` - PlatformID string `json:"platform_id"` - PlatformUid string `json:"platform_uid"` - Nickname string `json:"nickname"` - AvatarUrl pgtype.Text `json:"avatar_url"` - Status string `json:"status"` - MetadataJson []byte `json:"metadata_json"` - LastCheckAt pgtype.Timestamptz `json:"last_check_at"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - DeletedAt pgtype.Timestamptz `json:"deleted_at"` + ID int64 `json:"id"` + TenantID int64 `json:"tenant_id"` + UserID int64 `json:"user_id"` + PlatformID string `json:"platform_id"` + PlatformUid string `json:"platform_uid"` + Nickname string `json:"nickname"` + AvatarUrl pgtype.Text `json:"avatar_url"` + Status string `json:"status"` + MetadataJson []byte `json:"metadata_json"` + LastCheckAt pgtype.Timestamptz `json:"last_check_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + DeletedAt pgtype.Timestamptz `json:"deleted_at"` + WorkspaceID int64 `json:"workspace_id"` + DesktopID pgtype.UUID `json:"desktop_id"` + ClientID pgtype.UUID `json:"client_id"` + AccountFingerprint pgtype.Text `json:"account_fingerprint"` + DisplayName string `json:"display_name"` + Health string `json:"health"` + VerifiedAt pgtype.Timestamptz `json:"verified_at"` + Tags []byte `json:"tags"` + SyncVersion int64 `json:"sync_version"` + DeleteRequestedAt pgtype.Timestamptz `json:"delete_requested_at"` } type PlatformUserRole struct { @@ -602,6 +687,7 @@ type TenantMonitoringQuota struct { PlanTier string `json:"plan_tier"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + WorkspaceID int64 `json:"workspace_id"` } type TenantPlanSubscription struct { @@ -641,3 +727,23 @@ type User struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` DeletedAt pgtype.Timestamptz `json:"deleted_at"` } + +type Workspace struct { + ID int64 `json:"id"` + TenantID int64 `json:"tenant_id"` + Name string `json:"name"` + Slug string `json:"slug"` + IsDefault bool `json:"is_default"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type WorkspaceMembership struct { + ID int64 `json:"id"` + WorkspaceID int64 `json:"workspace_id"` + UserID int64 `json:"user_id"` + TenantID int64 `json:"tenant_id"` + Role string `json:"role"` + IsPrimary bool `json:"is_primary"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} diff --git a/server/internal/tenant/repository/generated/querier.go b/server/internal/tenant/repository/generated/querier.go index 44bbda8..52ea712 100644 --- a/server/internal/tenant/repository/generated/querier.go +++ b/server/internal/tenant/repository/generated/querier.go @@ -10,6 +10,10 @@ import ( type Querier interface { ApproveKolSubscription(ctx context.Context, arg ApproveKolSubscriptionParams) error + CancelDesktopTaskByClient(ctx context.Context, arg CancelDesktopTaskByClientParams) (DesktopTask, error) + CancelDesktopTaskByLease(ctx context.Context, arg CancelDesktopTaskByLeaseParams) (DesktopTask, error) + ClearDesktopAccountDeleteRequested(ctx context.Context, arg ClearDesktopAccountDeleteRequestedParams) (ClearDesktopAccountDeleteRequestedRow, error) + CompleteDesktopTask(ctx context.Context, arg CompleteDesktopTaskParams) (DesktopTask, error) ConfirmReservation(ctx context.Context, arg ConfirmReservationParams) error CountActiveImagesInFolder(ctx context.Context, arg CountActiveImagesInFolderParams) (int32, error) CountArticles(ctx context.Context, arg CountArticlesParams) (int64, error) @@ -27,6 +31,9 @@ type Querier interface { CreateAuditLog(ctx context.Context, arg CreateAuditLogParams) error CreateBrand(ctx context.Context, arg CreateBrandParams) (CreateBrandRow, error) CreateCompetitor(ctx context.Context, arg CreateCompetitorParams) (CreateCompetitorRow, error) + CreateDesktopPublishJob(ctx context.Context, arg CreateDesktopPublishJobParams) (DesktopPublishJob, error) + CreateDesktopTask(ctx context.Context, arg CreateDesktopTaskParams) (DesktopTask, error) + CreateDesktopTaskAttempt(ctx context.Context, arg CreateDesktopTaskAttemptParams) (DesktopTaskAttempt, error) CreateGenerationTask(ctx context.Context, arg CreateGenerationTaskParams) (int64, error) CreateImageFolder(ctx context.Context, arg CreateImageFolderParams) (int64, error) CreateKeyword(ctx context.Context, arg CreateKeywordParams) (CreateKeywordRow, error) @@ -47,12 +54,18 @@ type Querier interface { DeleteImageReferencesByArticle(ctx context.Context, arg DeleteImageReferencesByArticleParams) error DeleteImageReferencesByArticleScope(ctx context.Context, arg DeleteImageReferencesByArticleScopeParams) error EnsureImageStorageUsageRow(ctx context.Context, tenantID int64) error + ExtendDesktopTaskLease(ctx context.Context, arg ExtendDesktopTaskLeaseParams) (DesktopTask, error) + FinishDesktopTaskAttempt(ctx context.Context, arg FinishDesktopTaskAttemptParams) error GetActiveKolSubscription(ctx context.Context, arg GetActiveKolSubscriptionParams) (KolSubscription, error) GetActivePlanForTenant(ctx context.Context, tenantID int64) (GetActivePlanForTenantRow, error) GetArticleByID(ctx context.Context, arg GetArticleByIDParams) (GetArticleByIDRow, error) GetArticleVersions(ctx context.Context, articleID int64) ([]GetArticleVersionsRow, error) GetBrandByID(ctx context.Context, arg GetBrandByIDParams) (GetBrandByIDRow, error) GetCurrentBalance(ctx context.Context, arg GetCurrentBalanceParams) (int32, error) + GetDesktopAccountByDesktopID(ctx context.Context, arg GetDesktopAccountByDesktopIDParams) (GetDesktopAccountByDesktopIDRow, error) + GetDesktopAccountByIdentity(ctx context.Context, arg GetDesktopAccountByIdentityParams) (GetDesktopAccountByIdentityRow, error) + GetDesktopClientByTokenHash(ctx context.Context, tokenHash []byte) (DesktopClient, error) + GetDesktopTaskByDesktopID(ctx context.Context, arg GetDesktopTaskByDesktopIDParams) (DesktopTask, error) GetImageAsset(ctx context.Context, arg GetImageAssetParams) (GetImageAssetRow, error) GetImageFolder(ctx context.Context, arg GetImageFolderParams) (GetImageFolderRow, error) GetImageStorageUsage(ctx context.Context, tenantID int64) (GetImageStorageUsageRow, error) @@ -78,6 +91,7 @@ type Querier interface { GetTenantMembershipByTenantAndUser(ctx context.Context, arg GetTenantMembershipByTenantAndUserParams) (GetTenantMembershipByTenantAndUserRow, error) GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error) GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, error) + HeartbeatDesktopClient(ctx context.Context, arg HeartbeatDesktopClientParams) (DesktopClient, error) InsertImageAsset(ctx context.Context, arg InsertImageAssetParams) (int64, error) InsertQuotaLedger(ctx context.Context, arg InsertQuotaLedgerParams) (int64, error) // Cross-tenant aggregation: KOL owns packages across subscriber tenants. @@ -86,6 +100,9 @@ type Querier interface { KolDashboardTrend(ctx context.Context, arg KolDashboardTrendParams) ([]KolDashboardTrendRow, error) // Cross-tenant aggregation for KOL dashboard. KolUsageCountByPackage(ctx context.Context, packageIds []int64) ([]KolUsageCountByPackageRow, error) + LeaseNextQueuedDesktopTask(ctx context.Context, arg LeaseNextQueuedDesktopTaskParams) (DesktopTask, error) + LeaseParkedDesktopTask(ctx context.Context, arg LeaseParkedDesktopTaskParams) (DesktopTask, error) + LeaseQueuedDesktopTaskByDesktopID(ctx context.Context, arg LeaseQueuedDesktopTaskByDesktopIDParams) (DesktopTask, error) ListActiveImagesByFolder(ctx context.Context, arg ListActiveImagesByFolderParams) ([]ListActiveImagesByFolderRow, error) ListActiveKolPromptsByPackage(ctx context.Context, arg ListActiveKolPromptsByPackageParams) ([]ListActiveKolPromptsByPackageRow, error) // Cross-tenant read: used by platform admin for subscription fan-out. @@ -95,6 +112,8 @@ type Querier interface { ListArticles(ctx context.Context, arg ListArticlesParams) ([]ListArticlesRow, error) ListBrands(ctx context.Context, tenantID int64) ([]ListBrandsRow, error) ListCompetitors(ctx context.Context, arg ListCompetitorsParams) ([]ListCompetitorsRow, error) + ListDesktopAccountsByClient(ctx context.Context, arg ListDesktopAccountsByClientParams) ([]ListDesktopAccountsByClientRow, error) + ListDesktopClientsByWorkspace(ctx context.Context, workspaceID int64) ([]DesktopClient, error) ListImageAssets(ctx context.Context, arg ListImageAssetsParams) ([]ListImageAssetsRow, error) ListImageFolders(ctx context.Context, tenantID int64) ([]ListImageFoldersRow, error) ListImageReferenceArticles(ctx context.Context, arg ListImageReferenceArticlesParams) ([]ListImageReferenceArticlesRow, error) @@ -122,6 +141,8 @@ type Querier interface { ListScheduleTasks(ctx context.Context, arg ListScheduleTasksParams) ([]ListScheduleTasksRow, error) ListSubscriptionPromptsByTenant(ctx context.Context, tenantID int64) ([]ListSubscriptionPromptsByTenantRow, error) ListTemplates(ctx context.Context, tenantID int64) ([]ListTemplatesRow, error) + MarkDesktopAccountDeleteRequested(ctx context.Context, arg MarkDesktopAccountDeleteRequestedParams) (MarkDesktopAccountDeleteRequestedRow, error) + MarkDesktopTasksUnknownByClient(ctx context.Context, arg MarkDesktopTasksUnknownByClientParams) (int64, error) MarkImagesInFolderPendingDelete(ctx context.Context, arg MarkImagesInFolderPendingDeleteParams) error MarkKolAssistCompleted(ctx context.Context, arg MarkKolAssistCompletedParams) error MarkKolAssistFailed(ctx context.Context, arg MarkKolAssistFailedParams) error @@ -131,11 +152,17 @@ type Querier interface { MarkKolUsageFailed(ctx context.Context, arg MarkKolUsageFailedParams) error MarkKolUsageFailedByTask(ctx context.Context, arg MarkKolUsageFailedByTaskParams) error NextKolPromptRevisionNo(ctx context.Context, arg NextKolPromptRevisionNoParams) (int32, error) + ParkDesktopTask(ctx context.Context, arg ParkDesktopTaskParams) (DesktopTask, error) + PatchDesktopAccount(ctx context.Context, arg PatchDesktopAccountParams) (PatchDesktopAccountRow, error) + ReconcileDesktopTask(ctx context.Context, arg ReconcileDesktopTaskParams) (DesktopTask, error) RefundReservation(ctx context.Context, arg RefundReservationParams) error + RegisterDesktopClient(ctx context.Context, arg RegisterDesktopClientParams) (DesktopClient, error) ReleaseImageStorageQuota(ctx context.Context, arg ReleaseImageStorageQuotaParams) error + RevokeDesktopClient(ctx context.Context, arg RevokeDesktopClientParams) (DesktopClient, error) RevokeKolSubscription(ctx context.Context, arg RevokeKolSubscriptionParams) error RevokeSubscriptionPromptsByPrompt(ctx context.Context, arg RevokeSubscriptionPromptsByPromptParams) error RevokeSubscriptionPromptsBySubscription(ctx context.Context, arg RevokeSubscriptionPromptsBySubscriptionParams) error + RotateDesktopClientToken(ctx context.Context, arg RotateDesktopClientTokenParams) (DesktopClient, error) SoftDeleteArticle(ctx context.Context, arg SoftDeleteArticleParams) error SoftDeleteBrand(ctx context.Context, arg SoftDeleteBrandParams) error SoftDeleteCompetitor(ctx context.Context, arg SoftDeleteCompetitorParams) error @@ -149,6 +176,8 @@ type Querier interface { SoftDeleteQuestion(ctx context.Context, arg SoftDeleteQuestionParams) error SoftDeleteQuestionsByBrand(ctx context.Context, arg SoftDeleteQuestionsByBrandParams) error SoftDeleteScheduleTask(ctx context.Context, arg SoftDeleteScheduleTaskParams) error + TenantCancelDesktopTask(ctx context.Context, arg TenantCancelDesktopTaskParams) (DesktopTask, error) + TombstoneDesktopAccount(ctx context.Context, arg TombstoneDesktopAccountParams) (TombstoneDesktopAccountRow, error) UpdateArticleCurrentVersion(ctx context.Context, arg UpdateArticleCurrentVersionParams) error UpdateArticleGenerateStatus(ctx context.Context, arg UpdateArticleGenerateStatusParams) error UpdateBrand(ctx context.Context, arg UpdateBrandParams) error @@ -175,6 +204,7 @@ type Querier interface { UpdateScheduleTask(ctx context.Context, arg UpdateScheduleTaskParams) error UpdateScheduleTaskStatus(ctx context.Context, arg UpdateScheduleTaskStatusParams) error UpdateTaskRecordStatus(ctx context.Context, arg UpdateTaskRecordStatusParams) error + UpsertDesktopAccount(ctx context.Context, arg UpsertDesktopAccountParams) (UpsertDesktopAccountRow, error) UpsertImageReference(ctx context.Context, arg UpsertImageReferenceParams) error } diff --git a/server/internal/tenant/repository/queries/auth.sql b/server/internal/tenant/repository/queries/auth.sql index befd707..5c5ec7e 100644 --- a/server/internal/tenant/repository/queries/auth.sql +++ b/server/internal/tenant/repository/queries/auth.sql @@ -9,12 +9,30 @@ FROM users WHERE id = sqlc.arg(id) AND deleted_at IS NULL; -- name: GetTenantMembership :one -SELECT id, tenant_id, user_id, tenant_role, created_at -FROM tenant_memberships -WHERE user_id = sqlc.arg(user_id) AND deleted_at IS NULL +SELECT tm.id, + tm.tenant_id, + tm.user_id, + tm.tenant_role, + tm.created_at, + wm.workspace_id AS primary_workspace_id +FROM tenant_memberships tm +JOIN workspace_memberships wm + ON wm.tenant_id = tm.tenant_id + AND wm.user_id = tm.user_id + AND wm.is_primary = TRUE +WHERE tm.user_id = sqlc.arg(user_id) AND tm.deleted_at IS NULL LIMIT 1; -- name: GetTenantMembershipByTenantAndUser :one -SELECT id, tenant_id, user_id, tenant_role, created_at -FROM tenant_memberships -WHERE tenant_id = sqlc.arg(tenant_id) AND user_id = sqlc.arg(user_id) AND deleted_at IS NULL; +SELECT tm.id, + tm.tenant_id, + tm.user_id, + tm.tenant_role, + tm.created_at, + wm.workspace_id AS primary_workspace_id +FROM tenant_memberships tm +JOIN workspace_memberships wm + ON wm.tenant_id = tm.tenant_id + AND wm.user_id = tm.user_id + AND wm.is_primary = TRUE +WHERE tm.tenant_id = sqlc.arg(tenant_id) AND tm.user_id = sqlc.arg(user_id) AND tm.deleted_at IS NULL; diff --git a/server/internal/tenant/repository/queries/desktop_account.sql b/server/internal/tenant/repository/queries/desktop_account.sql new file mode 100644 index 0000000..70b8b19 --- /dev/null +++ b/server/internal/tenant/repository/queries/desktop_account.sql @@ -0,0 +1,268 @@ +-- name: ListDesktopAccountsByClient :many +SELECT + desktop_id, + tenant_id, + workspace_id, + user_id, + client_id, + platform_id, + platform_uid, + account_fingerprint, + display_name, + health, + verified_at, + tags, + sync_version, + deleted_at, + delete_requested_at, + created_at, + updated_at +FROM platform_accounts +WHERE workspace_id = sqlc.arg(workspace_id) + AND client_id = sqlc.arg(client_id) +ORDER BY platform_id, display_name, created_at DESC; + +-- name: GetDesktopAccountByDesktopID :one +SELECT + desktop_id, + tenant_id, + workspace_id, + user_id, + client_id, + platform_id, + platform_uid, + account_fingerprint, + display_name, + health, + verified_at, + tags, + sync_version, + deleted_at, + delete_requested_at, + created_at, + updated_at +FROM platform_accounts +WHERE desktop_id = sqlc.arg(desktop_id) + AND workspace_id = sqlc.arg(workspace_id) +LIMIT 1; + +-- name: GetDesktopAccountByIdentity :one +SELECT + desktop_id, + tenant_id, + workspace_id, + user_id, + client_id, + platform_id, + platform_uid, + account_fingerprint, + display_name, + health, + verified_at, + tags, + sync_version, + deleted_at, + delete_requested_at, + created_at, + updated_at +FROM platform_accounts +WHERE workspace_id = sqlc.arg(workspace_id) + AND platform_id = sqlc.arg(platform_id) + AND platform_uid = sqlc.arg(platform_uid) + AND deleted_at IS NULL +LIMIT 1; + +-- name: UpsertDesktopAccount :one +INSERT INTO platform_accounts ( + desktop_id, + tenant_id, + workspace_id, + user_id, + client_id, + platform_id, + platform_uid, + nickname, + status, + metadata_json, + last_check_at, + account_fingerprint, + display_name, + health, + verified_at, + tags, + sync_version +) +VALUES ( + sqlc.arg(desktop_id), + sqlc.arg(tenant_id), + sqlc.arg(workspace_id), + sqlc.arg(user_id), + sqlc.arg(client_id), + sqlc.arg(platform_id), + sqlc.arg(platform_uid), + sqlc.arg(display_name)::text, + sqlc.arg(legacy_status), + sqlc.narg(metadata_json), + sqlc.narg(verified_at), + sqlc.narg(account_fingerprint), + sqlc.arg(display_name)::text, + sqlc.arg(health), + sqlc.narg(verified_at), + sqlc.narg(tags), + 1 +) +ON CONFLICT (workspace_id, platform_id, platform_uid) WHERE deleted_at IS NULL +DO UPDATE SET + client_id = EXCLUDED.client_id, + user_id = EXCLUDED.user_id, + nickname = EXCLUDED.nickname, + status = EXCLUDED.status, + metadata_json = COALESCE(EXCLUDED.metadata_json, platform_accounts.metadata_json), + last_check_at = COALESCE(EXCLUDED.last_check_at, platform_accounts.last_check_at), + account_fingerprint = EXCLUDED.account_fingerprint, + display_name = EXCLUDED.display_name, + health = EXCLUDED.health, + verified_at = COALESCE(EXCLUDED.verified_at, platform_accounts.verified_at), + tags = EXCLUDED.tags, + sync_version = platform_accounts.sync_version + 1, + delete_requested_at = NULL, + updated_at = now() +WHERE platform_accounts.workspace_id = EXCLUDED.workspace_id + AND ( + sqlc.narg(if_sync_version)::bigint IS NULL + OR platform_accounts.sync_version = sqlc.narg(if_sync_version)::bigint + ) +RETURNING + desktop_id, + tenant_id, + workspace_id, + user_id, + client_id, + platform_id, + platform_uid, + account_fingerprint, + display_name, + health, + verified_at, + tags, + sync_version, + deleted_at, + delete_requested_at, + created_at, + updated_at; + +-- name: PatchDesktopAccount :one +UPDATE platform_accounts +SET display_name = COALESCE(sqlc.narg(display_name)::text, display_name), + nickname = COALESCE(sqlc.narg(display_name)::text, nickname), + health = COALESCE(sqlc.narg(health), health), + status = COALESCE(sqlc.narg(legacy_status), status), + verified_at = COALESCE(sqlc.narg(verified_at), verified_at), + last_check_at = COALESCE(sqlc.narg(verified_at), last_check_at), + tags = COALESCE(sqlc.narg(tags), tags), + sync_version = sync_version + 1, + updated_at = now() +WHERE desktop_id = sqlc.arg(desktop_id) + AND workspace_id = sqlc.arg(workspace_id) + AND sync_version = sqlc.arg(if_sync_version) + AND deleted_at IS NULL +RETURNING + desktop_id, + tenant_id, + workspace_id, + user_id, + client_id, + platform_id, + platform_uid, + account_fingerprint, + display_name, + health, + verified_at, + tags, + sync_version, + deleted_at, + delete_requested_at, + created_at, + updated_at; + +-- name: TombstoneDesktopAccount :one +UPDATE platform_accounts +SET deleted_at = now(), + sync_version = sync_version + 1, + updated_at = now() +WHERE desktop_id = sqlc.arg(desktop_id) + AND workspace_id = sqlc.arg(workspace_id) + AND sync_version = sqlc.arg(if_sync_version) + AND deleted_at IS NULL +RETURNING + desktop_id, + tenant_id, + workspace_id, + user_id, + client_id, + platform_id, + platform_uid, + account_fingerprint, + display_name, + health, + verified_at, + tags, + sync_version, + deleted_at, + delete_requested_at, + created_at, + updated_at; + +-- name: MarkDesktopAccountDeleteRequested :one +UPDATE platform_accounts +SET delete_requested_at = now(), + sync_version = sync_version + 1, + updated_at = now() +WHERE desktop_id = sqlc.arg(desktop_id) + AND workspace_id = sqlc.arg(workspace_id) + AND deleted_at IS NULL +RETURNING + desktop_id, + tenant_id, + workspace_id, + user_id, + client_id, + platform_id, + platform_uid, + account_fingerprint, + display_name, + health, + verified_at, + tags, + sync_version, + deleted_at, + delete_requested_at, + created_at, + updated_at; + +-- name: ClearDesktopAccountDeleteRequested :one +UPDATE platform_accounts +SET delete_requested_at = NULL, + sync_version = sync_version + 1, + updated_at = now() +WHERE desktop_id = sqlc.arg(desktop_id) + AND workspace_id = sqlc.arg(workspace_id) + AND deleted_at IS NULL +RETURNING + desktop_id, + tenant_id, + workspace_id, + user_id, + client_id, + platform_id, + platform_uid, + account_fingerprint, + display_name, + health, + verified_at, + tags, + sync_version, + deleted_at, + delete_requested_at, + created_at, + updated_at; diff --git a/server/internal/tenant/repository/queries/desktop_client.sql b/server/internal/tenant/repository/queries/desktop_client.sql new file mode 100644 index 0000000..41cb58b --- /dev/null +++ b/server/internal/tenant/repository/queries/desktop_client.sql @@ -0,0 +1,70 @@ +-- name: RegisterDesktopClient :one +INSERT INTO desktop_clients ( + id, + tenant_id, + workspace_id, + user_id, + token_hash, + device_name, + os, + cpu_arch, + client_version, + channel +) +VALUES ( + sqlc.arg(id), + sqlc.arg(tenant_id), + sqlc.arg(workspace_id), + sqlc.arg(user_id), + sqlc.arg(token_hash), + sqlc.narg(device_name), + sqlc.narg(os), + sqlc.narg(cpu_arch), + sqlc.narg(client_version), + sqlc.narg(channel) +) +RETURNING *; + +-- name: GetDesktopClientByTokenHash :one +SELECT * +FROM desktop_clients +WHERE token_hash = sqlc.arg(token_hash) + AND revoked_at IS NULL +LIMIT 1; + +-- name: RotateDesktopClientToken :one +UPDATE desktop_clients +SET token_hash = sqlc.arg(token_hash), + last_rotated_at = now() +WHERE id = sqlc.arg(id) + AND workspace_id = sqlc.arg(workspace_id) + AND revoked_at IS NULL +RETURNING *; + +-- name: HeartbeatDesktopClient :one +UPDATE desktop_clients +SET device_name = COALESCE(sqlc.narg(device_name), device_name), + os = COALESCE(sqlc.narg(os), os), + cpu_arch = COALESCE(sqlc.narg(cpu_arch), cpu_arch), + client_version = COALESCE(sqlc.narg(client_version), client_version), + channel = COALESCE(sqlc.narg(channel), channel), + last_seen_at = now() +WHERE id = sqlc.arg(id) + AND workspace_id = sqlc.arg(workspace_id) + AND revoked_at IS NULL +RETURNING *; + +-- name: RevokeDesktopClient :one +UPDATE desktop_clients +SET revoked_at = now() +WHERE id = sqlc.arg(id) + AND workspace_id = sqlc.arg(workspace_id) + AND revoked_at IS NULL +RETURNING *; + +-- name: ListDesktopClientsByWorkspace :many +SELECT * +FROM desktop_clients +WHERE workspace_id = sqlc.arg(workspace_id) + AND revoked_at IS NULL +ORDER BY last_seen_at DESC NULLS LAST, created_at DESC; diff --git a/server/internal/tenant/repository/queries/desktop_task.sql b/server/internal/tenant/repository/queries/desktop_task.sql new file mode 100644 index 0000000..0e5032b --- /dev/null +++ b/server/internal/tenant/repository/queries/desktop_task.sql @@ -0,0 +1,250 @@ +-- name: CreateDesktopPublishJob :one +INSERT INTO desktop_publish_jobs ( + desktop_id, + tenant_id, + workspace_id, + created_by_user_id, + title, + content_ref, + scheduled_at +) +VALUES ( + sqlc.arg(desktop_id), + sqlc.arg(tenant_id), + sqlc.arg(workspace_id), + sqlc.arg(created_by_user_id), + sqlc.arg(title), + sqlc.arg(content_ref), + sqlc.narg(scheduled_at) +) +RETURNING *; + +-- name: CreateDesktopTask :one +INSERT INTO desktop_tasks ( + desktop_id, + job_id, + tenant_id, + workspace_id, + target_account_id, + target_client_id, + platform_id, + kind, + payload, + status, + dedup_key, + parked_reason +) +VALUES ( + sqlc.arg(desktop_id), + sqlc.arg(job_id), + sqlc.arg(tenant_id), + sqlc.arg(workspace_id), + sqlc.arg(target_account_id), + sqlc.arg(target_client_id), + sqlc.arg(platform_id), + sqlc.arg(kind), + sqlc.arg(payload), + sqlc.arg(status), + sqlc.narg(dedup_key), + sqlc.narg(parked_reason) +) +RETURNING *; + +-- name: GetDesktopTaskByDesktopID :one +SELECT * +FROM desktop_tasks +WHERE desktop_id = sqlc.arg(desktop_id) + AND workspace_id = sqlc.arg(workspace_id) +LIMIT 1; + +-- name: LeaseNextQueuedDesktopTask :one +WITH candidate AS ( + SELECT dt.desktop_id + FROM desktop_tasks AS dt + WHERE dt.target_client_id = sqlc.arg(client_id) + AND dt.status = 'queued' + AND ( + sqlc.narg(kind)::text IS NULL + OR dt.kind = sqlc.narg(kind)::text + ) + ORDER BY dt.created_at ASC, dt.desktop_id ASC + LIMIT 1 + FOR UPDATE SKIP LOCKED +) +UPDATE desktop_tasks AS t +SET active_attempt_id = sqlc.arg(attempt_id), + lease_token_hash = sqlc.arg(lease_token_hash), + lease_expires_at = now() + interval '10 minutes', + status = 'in_progress', + attempts = t.attempts + 1, + parked_reason = NULL, + updated_at = now() +FROM candidate +WHERE t.desktop_id = candidate.desktop_id +RETURNING t.*; + +-- name: LeaseQueuedDesktopTaskByDesktopID :one +UPDATE desktop_tasks +SET active_attempt_id = sqlc.arg(attempt_id), + lease_token_hash = sqlc.arg(lease_token_hash), + lease_expires_at = now() + interval '10 minutes', + status = 'in_progress', + attempts = attempts + 1, + parked_reason = NULL, + updated_at = now() +WHERE desktop_id = sqlc.arg(desktop_id) + AND target_client_id = sqlc.arg(client_id) + AND status = 'queued' +RETURNING *; + +-- name: LeaseParkedDesktopTask :one +UPDATE desktop_tasks +SET active_attempt_id = sqlc.arg(attempt_id), + lease_token_hash = sqlc.arg(lease_token_hash), + lease_expires_at = now() + interval '10 minutes', + status = 'in_progress', + attempts = attempts + 1, + parked_reason = NULL, + updated_at = now() +WHERE desktop_id = sqlc.arg(desktop_id) + AND target_client_id = sqlc.arg(client_id) + AND status = 'waiting_user' + AND active_attempt_id IS NULL +RETURNING *; + +-- name: ExtendDesktopTaskLease :one +UPDATE desktop_tasks +SET lease_expires_at = now() + interval '10 minutes', + updated_at = now() +WHERE desktop_id = sqlc.arg(desktop_id) + AND lease_token_hash = sqlc.arg(lease_token_hash) + AND status = 'in_progress' +RETURNING *; + +-- name: ParkDesktopTask :one +UPDATE desktop_tasks +SET status = 'waiting_user', + active_attempt_id = NULL, + lease_token_hash = NULL, + lease_expires_at = NULL, + parked_reason = sqlc.arg(parked_reason), + updated_at = now() +WHERE desktop_id = sqlc.arg(desktop_id) + AND lease_token_hash = sqlc.arg(lease_token_hash) + AND status = 'in_progress' +RETURNING *; + +-- name: CompleteDesktopTask :one +UPDATE desktop_tasks +SET status = sqlc.arg(status), + result = sqlc.narg(result), + error = sqlc.narg(error), + active_attempt_id = NULL, + lease_token_hash = NULL, + lease_expires_at = NULL, + parked_reason = NULL, + updated_at = now() +WHERE desktop_id = sqlc.arg(desktop_id) + AND lease_token_hash = sqlc.arg(lease_token_hash) + AND status = 'in_progress' +RETURNING *; + +-- name: CancelDesktopTaskByLease :one +UPDATE desktop_tasks +SET status = 'aborted', + error = sqlc.narg(error), + active_attempt_id = NULL, + lease_token_hash = NULL, + lease_expires_at = NULL, + parked_reason = NULL, + updated_at = now() +WHERE desktop_id = sqlc.arg(desktop_id) + AND lease_token_hash = sqlc.arg(lease_token_hash) + AND status = 'in_progress' +RETURNING *; + +-- name: CancelDesktopTaskByClient :one +UPDATE desktop_tasks +SET status = 'aborted', + error = sqlc.narg(error), + active_attempt_id = NULL, + lease_token_hash = NULL, + lease_expires_at = NULL, + parked_reason = NULL, + updated_at = now() +WHERE desktop_id = sqlc.arg(desktop_id) + AND target_client_id = sqlc.arg(client_id) + AND status IN ('queued', 'waiting_user') +RETURNING *; + +-- name: TenantCancelDesktopTask :one +UPDATE desktop_tasks +SET status = 'aborted', + error = sqlc.narg(error), + active_attempt_id = NULL, + lease_token_hash = NULL, + lease_expires_at = NULL, + parked_reason = NULL, + updated_at = now() +WHERE desktop_id = sqlc.arg(desktop_id) + AND workspace_id = sqlc.arg(workspace_id) + AND status IN ('queued', 'waiting_user') +RETURNING *; + +-- name: ReconcileDesktopTask :one +UPDATE desktop_tasks +SET status = CASE + WHEN sqlc.arg(status)::text = 'retry' THEN 'queued' + ELSE sqlc.arg(status)::text + END, + result = CASE + WHEN sqlc.arg(status)::text = 'retry' THEN NULL + ELSE sqlc.narg(result) + END, + error = sqlc.narg(error), + active_attempt_id = NULL, + lease_token_hash = NULL, + lease_expires_at = NULL, + parked_reason = NULL, + attempts = attempts + CASE WHEN sqlc.arg(status)::text = 'retry' THEN 1 ELSE 0 END, + updated_at = now() +WHERE desktop_id = sqlc.arg(desktop_id) + AND workspace_id = sqlc.arg(workspace_id) + AND status = 'unknown' +RETURNING *; + +-- name: MarkDesktopTasksUnknownByClient :execrows +UPDATE desktop_tasks +SET status = 'unknown', + error = sqlc.narg(error), + active_attempt_id = NULL, + lease_token_hash = NULL, + lease_expires_at = NULL, + parked_reason = NULL, + updated_at = now() +WHERE target_client_id = sqlc.arg(client_id) + AND workspace_id = sqlc.arg(workspace_id) + AND status IN ('waiting_user', 'in_progress'); + +-- name: CreateDesktopTaskAttempt :one +INSERT INTO desktop_task_attempts ( + desktop_id, + task_id, + client_id, + lease_token_hash +) +VALUES ( + sqlc.arg(desktop_id), + sqlc.arg(task_id), + sqlc.arg(client_id), + sqlc.arg(lease_token_hash) +) +RETURNING *; + +-- name: FinishDesktopTaskAttempt :exec +UPDATE desktop_task_attempts +SET ended_at = now(), + final_status = sqlc.narg(final_status), + error = sqlc.narg(error) +WHERE task_id = sqlc.arg(task_id) + AND desktop_id = sqlc.arg(attempt_id); diff --git a/server/internal/tenant/transport/desktop_account_handler.go b/server/internal/tenant/transport/desktop_account_handler.go new file mode 100644 index 0000000..92e8ba2 --- /dev/null +++ b/server/internal/tenant/transport/desktop_account_handler.go @@ -0,0 +1,113 @@ +package transport + +import ( + "strconv" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "github.com/geo-platform/tenant-api/internal/bootstrap" + "github.com/geo-platform/tenant-api/internal/shared/auth" + "github.com/geo-platform/tenant-api/internal/shared/response" + "github.com/geo-platform/tenant-api/internal/tenant/app" + "github.com/geo-platform/tenant-api/internal/tenant/repository" +) + +type DesktopAccountHandler struct { + svc *app.DesktopAccountService +} + +func NewDesktopAccountHandler(a *bootstrap.App) *DesktopAccountHandler { + return &DesktopAccountHandler{ + svc: app.NewDesktopAccountService( + repository.NewDesktopAccountRepository(a.DB), + ), + } +} + +func (h *DesktopAccountHandler) List(c *gin.Context) { + if clientQuery := c.Query("client"); clientQuery != "" && clientQuery != "self" { + response.Error(c, response.ErrBadRequest(40081, "invalid_client_query", "client query must be self")) + return + } + + data, err := h.svc.ListByClient(c.Request.Context(), MustDesktopClient(c.Request.Context())) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, data) +} + +func (h *DesktopAccountHandler) Upsert(c *gin.Context) { + var req app.UpsertDesktopAccountRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error())) + return + } + + data, err := h.svc.Upsert(c.Request.Context(), MustDesktopClient(c.Request.Context()), req) + if err != nil { + response.Error(c, err) + return + } + response.SuccessWithStatus(c, 201, data) +} + +func (h *DesktopAccountHandler) Patch(c *gin.Context) { + desktopID, err := uuid.Parse(c.Param("id")) + if err != nil { + response.Error(c, response.ErrBadRequest(40082, "invalid_desktop_account_id", "desktop account id must be a uuid")) + return + } + + var req app.PatchDesktopAccountRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error())) + return + } + + data, err := h.svc.Patch(c.Request.Context(), MustDesktopClient(c.Request.Context()), desktopID, req) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, data) +} + +func (h *DesktopAccountHandler) Delete(c *gin.Context) { + desktopID, err := uuid.Parse(c.Param("id")) + if err != nil { + response.Error(c, response.ErrBadRequest(40082, "invalid_desktop_account_id", "desktop account id must be a uuid")) + return + } + + ifSyncVersion, err := strconv.ParseInt(c.Query("if_sync_version"), 10, 64) + if err != nil { + response.Error(c, response.ErrBadRequest(40083, "invalid_sync_version", "if_sync_version must be an integer")) + return + } + + data, err := h.svc.Tombstone(c.Request.Context(), MustDesktopClient(c.Request.Context()), desktopID, ifSyncVersion) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, data) +} + +func (h *DesktopAccountHandler) RequestDelete(c *gin.Context) { + desktopID, err := uuid.Parse(c.Param("id")) + if err != nil { + response.Error(c, response.ErrBadRequest(40082, "invalid_desktop_account_id", "desktop account id must be a uuid")) + return + } + + actor := auth.MustActor(c.Request.Context()) + data, err := h.svc.RequestDelete(c.Request.Context(), actor, desktopID, c.Query("undo") == "true") + if err != nil { + response.Error(c, err) + return + } + response.Success(c, data) +} diff --git a/server/internal/tenant/transport/desktop_client_auth.go b/server/internal/tenant/transport/desktop_client_auth.go new file mode 100644 index 0000000..9add3d1 --- /dev/null +++ b/server/internal/tenant/transport/desktop_client_auth.go @@ -0,0 +1,61 @@ +package transport + +import ( + "context" + "strings" + + "github.com/gin-gonic/gin" + + "github.com/geo-platform/tenant-api/internal/shared/response" + "github.com/geo-platform/tenant-api/internal/tenant/app" + "github.com/geo-platform/tenant-api/internal/tenant/repository" +) + +type desktopClientKey struct{} + +func DesktopClientMiddleware(repo repository.DesktopClientRepository) gin.HandlerFunc { + return func(c *gin.Context) { + token, err := bearerClientToken(c.GetHeader("Authorization")) + if err != nil { + response.Error(c, response.ErrUnauthorized(40130, "missing_client_token", "desktop client token is required")) + c.Abort() + return + } + + client, err := repo.GetByTokenHash(c.Request.Context(), app.HashDesktopClientToken(token)) + if err != nil { + response.Error(c, response.ErrUnauthorized(40131, "invalid_client_token", "desktop client token is invalid or revoked")) + c.Abort() + return + } + + ctx := WithDesktopClient(c.Request.Context(), client) + c.Request = c.Request.WithContext(ctx) + c.Next() + } +} + +func WithDesktopClient(ctx context.Context, client *repository.DesktopClient) context.Context { + return context.WithValue(ctx, desktopClientKey{}, client) +} + +func DesktopClientFromCtx(ctx context.Context) (*repository.DesktopClient, bool) { + client, ok := ctx.Value(desktopClientKey{}).(*repository.DesktopClient) + return client, ok && client != nil +} + +func MustDesktopClient(ctx context.Context) *repository.DesktopClient { + client, ok := DesktopClientFromCtx(ctx) + if !ok { + panic("desktop client not in context") + } + return client +} + +func bearerClientToken(header string) (string, error) { + parts := strings.SplitN(strings.TrimSpace(header), " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") || strings.TrimSpace(parts[1]) == "" { + return "", response.ErrUnauthorized(40130, "missing_client_token", "desktop client token is required") + } + return strings.TrimSpace(parts[1]), nil +} diff --git a/server/internal/tenant/transport/desktop_client_auth_test.go b/server/internal/tenant/transport/desktop_client_auth_test.go new file mode 100644 index 0000000..391691d --- /dev/null +++ b/server/internal/tenant/transport/desktop_client_auth_test.go @@ -0,0 +1,91 @@ +package transport + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + + "github.com/geo-platform/tenant-api/internal/tenant/repository" +) + +type stubDesktopClientRepo struct { + client *repository.DesktopClient +} + +func (s *stubDesktopClientRepo) Register(ctx context.Context, params repository.RegisterDesktopClientParams) (*repository.DesktopClient, error) { + panic("unexpected call") +} + +func (s *stubDesktopClientRepo) GetByTokenHash(ctx context.Context, tokenHash []byte) (*repository.DesktopClient, error) { + if s.client == nil { + return nil, context.Canceled + } + return s.client, nil +} + +func (s *stubDesktopClientRepo) RotateToken(ctx context.Context, params repository.RotateDesktopClientTokenParams) (*repository.DesktopClient, error) { + panic("unexpected call") +} + +func (s *stubDesktopClientRepo) Heartbeat(ctx context.Context, params repository.HeartbeatDesktopClientParams) (*repository.DesktopClient, error) { + panic("unexpected call") +} + +func (s *stubDesktopClientRepo) Revoke(ctx context.Context, id uuid.UUID, workspaceID int64) (*repository.DesktopClient, error) { + panic("unexpected call") +} + +func (s *stubDesktopClientRepo) ListByWorkspace(ctx context.Context, workspaceID int64) ([]repository.DesktopClient, error) { + panic("unexpected call") +} + +func TestDesktopClientMiddleware_RequiresBearerToken(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(DesktopClientMiddleware(&stubDesktopClientRepo{})) + router.POST("/test", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodPost, "/test", nil) + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + assert.Equal(t, http.StatusUnauthorized, resp.Code) +} + +func TestDesktopClientMiddleware_SetsClientContext(t *testing.T) { + gin.SetMode(gin.TestMode) + expectedID := uuid.New() + router := gin.New() + router.Use(DesktopClientMiddleware(&stubDesktopClientRepo{ + client: &repository.DesktopClient{ + ID: expectedID, + TenantID: 1, + WorkspaceID: 2, + UserID: 3, + CreatedAt: time.Now(), + }, + })) + + var gotID uuid.UUID + router.POST("/test", func(c *gin.Context) { + client := MustDesktopClient(c.Request.Context()) + gotID = client.ID + c.Status(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodPost, "/test", nil) + req.Header.Set("Authorization", "Bearer client-token") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + assert.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, expectedID, gotID) +} diff --git a/server/internal/tenant/transport/desktop_client_handler.go b/server/internal/tenant/transport/desktop_client_handler.go new file mode 100644 index 0000000..d28515a --- /dev/null +++ b/server/internal/tenant/transport/desktop_client_handler.go @@ -0,0 +1,73 @@ +package transport + +import ( + "github.com/gin-gonic/gin" + + "github.com/geo-platform/tenant-api/internal/bootstrap" + "github.com/geo-platform/tenant-api/internal/shared/auth" + "github.com/geo-platform/tenant-api/internal/shared/response" + "github.com/geo-platform/tenant-api/internal/tenant/app" + "github.com/geo-platform/tenant-api/internal/tenant/repository" +) + +type DesktopClientHandler struct { + svc *app.DesktopClientService +} + +func NewDesktopClientHandler(a *bootstrap.App) *DesktopClientHandler { + return &DesktopClientHandler{ + svc: app.NewDesktopClientService( + repository.NewDesktopClientRepository(a.DB), + ), + } +} + +func (h *DesktopClientHandler) Register(c *gin.Context) { + actor := auth.MustActor(c.Request.Context()) + + var req app.RegisterDesktopClientRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error())) + return + } + + data, err := h.svc.Register(c.Request.Context(), actor, req) + if err != nil { + response.Error(c, err) + return + } + response.SuccessWithStatus(c, 201, data) +} + +func (h *DesktopClientHandler) Rotate(c *gin.Context) { + data, err := h.svc.Rotate(c.Request.Context(), MustDesktopClient(c.Request.Context())) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, data) +} + +func (h *DesktopClientHandler) Heartbeat(c *gin.Context) { + var req app.HeartbeatDesktopClientRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error())) + return + } + + data, err := h.svc.Heartbeat(c.Request.Context(), MustDesktopClient(c.Request.Context()), req) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, data) +} + +func (h *DesktopClientHandler) Revoke(c *gin.Context) { + data, err := h.svc.Revoke(c.Request.Context(), MustDesktopClient(c.Request.Context())) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, data) +} diff --git a/server/internal/tenant/transport/desktop_content_handler.go b/server/internal/tenant/transport/desktop_content_handler.go new file mode 100644 index 0000000..812544a --- /dev/null +++ b/server/internal/tenant/transport/desktop_content_handler.go @@ -0,0 +1,42 @@ +package transport + +import ( + "strconv" + + "github.com/gin-gonic/gin" + + "github.com/geo-platform/tenant-api/internal/bootstrap" + "github.com/geo-platform/tenant-api/internal/shared/response" + "github.com/geo-platform/tenant-api/internal/tenant/app" +) + +type DesktopContentHandler struct { + svc *app.DesktopContentService +} + +func NewDesktopContentHandler(a *bootstrap.App) *DesktopContentHandler { + return &DesktopContentHandler{ + svc: app.NewDesktopContentService( + a.DB, + a.AuditLogs, + a.ObjectStorage, + a.Cache, + ), + } +} + +func (h *DesktopContentHandler) Article(c *gin.Context) { + articleID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil || articleID <= 0 { + response.Error(c, response.ErrBadRequest(40095, "invalid_article_id", "article id must be a positive integer")) + return + } + + data, svcErr := h.svc.Article(c.Request.Context(), MustDesktopClient(c.Request.Context()), articleID) + if svcErr != nil { + response.Error(c, svcErr) + return + } + + response.Success(c, data) +} diff --git a/server/internal/tenant/transport/desktop_events_handler.go b/server/internal/tenant/transport/desktop_events_handler.go new file mode 100644 index 0000000..0a94337 --- /dev/null +++ b/server/internal/tenant/transport/desktop_events_handler.go @@ -0,0 +1,66 @@ +package transport + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + + "github.com/geo-platform/tenant-api/internal/bootstrap" +) + +type DesktopEventsHandler struct { + streams *bootstrap.App +} + +func NewDesktopEventsHandler(a *bootstrap.App) *DesktopEventsHandler { + return &DesktopEventsHandler{streams: a} +} + +func (h *DesktopEventsHandler) Stream(c *gin.Context) { + client := MustDesktopClient(c.Request.Context()) + events, cancel := h.streams.DesktopTaskStreams.Subscribe(client.ID.String()) + defer cancel() + + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Header("X-Accel-Buffering", "no") + + flusher, ok := c.Writer.(http.Flusher) + if !ok { + return + } + + if err := writeSSE(c, "connected", gin.H{ + "client_id": client.ID.String(), + "workspace_id": client.WorkspaceID, + "server_time": time.Now().UTC(), + }); err != nil { + return + } + flusher.Flush() + + heartbeat := time.NewTicker(15 * time.Second) + defer heartbeat.Stop() + + for { + select { + case <-c.Request.Context().Done(): + return + case <-heartbeat.C: + if err := writeSSE(c, "ping", gin.H{"ts": time.Now().UTC()}); err != nil { + return + } + flusher.Flush() + case event, ok := <-events: + if !ok { + return + } + if err := writeSSE(c, event.Type, event); err != nil { + return + } + flusher.Flush() + } + } +} diff --git a/server/internal/tenant/transport/desktop_task_handler.go b/server/internal/tenant/transport/desktop_task_handler.go new file mode 100644 index 0000000..f27da4e --- /dev/null +++ b/server/internal/tenant/transport/desktop_task_handler.go @@ -0,0 +1,180 @@ +package transport + +import ( + "errors" + "io" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "github.com/geo-platform/tenant-api/internal/bootstrap" + "github.com/geo-platform/tenant-api/internal/shared/auth" + "github.com/geo-platform/tenant-api/internal/shared/response" + "github.com/geo-platform/tenant-api/internal/tenant/app" + "github.com/geo-platform/tenant-api/internal/tenant/repository" +) + +type DesktopTaskHandler struct { + svc *app.DesktopTaskService +} + +func NewDesktopTaskHandler(a *bootstrap.App) *DesktopTaskHandler { + return &DesktopTaskHandler{ + svc: app.NewDesktopTaskService( + repository.NewDesktopTaskRepository(a.DB), + a.RabbitMQ, + a.Logger, + ), + } +} + +func (h *DesktopTaskHandler) Lease(c *gin.Context) { + var req app.LeaseDesktopTaskRequest + if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) { + response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error())) + return + } + + var routeTaskID *uuid.UUID + if rawID := c.Param("id"); rawID != "" { + parsed, err := uuid.Parse(rawID) + if err != nil { + response.Error(c, response.ErrBadRequest(40084, "invalid_desktop_task_id", "task id must be a uuid")) + return + } + routeTaskID = &parsed + } + + data, err := h.svc.Lease(c.Request.Context(), MustDesktopClient(c.Request.Context()), req, routeTaskID, c.Query("from_parked") == "true") + if err != nil { + response.Error(c, err) + return + } + response.Success(c, data) +} + +func (h *DesktopTaskHandler) Extend(c *gin.Context) { + desktopID, err := uuid.Parse(c.Param("id")) + if err != nil { + response.Error(c, response.ErrBadRequest(40084, "invalid_desktop_task_id", "task id must be a uuid")) + return + } + + var req app.ExtendDesktopTaskRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error())) + return + } + + data, err := h.svc.Extend(c.Request.Context(), MustDesktopClient(c.Request.Context()), desktopID, req) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, data) +} + +func (h *DesktopTaskHandler) Park(c *gin.Context) { + desktopID, err := uuid.Parse(c.Param("id")) + if err != nil { + response.Error(c, response.ErrBadRequest(40084, "invalid_desktop_task_id", "task id must be a uuid")) + return + } + + var req app.ParkDesktopTaskRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error())) + return + } + + data, err := h.svc.Park(c.Request.Context(), MustDesktopClient(c.Request.Context()), desktopID, req) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, data) +} + +func (h *DesktopTaskHandler) Result(c *gin.Context) { + desktopID, err := uuid.Parse(c.Param("id")) + if err != nil { + response.Error(c, response.ErrBadRequest(40084, "invalid_desktop_task_id", "task id must be a uuid")) + return + } + + var req app.CompleteDesktopTaskRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error())) + return + } + + data, err := h.svc.Complete(c.Request.Context(), MustDesktopClient(c.Request.Context()), desktopID, req) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, data) +} + +func (h *DesktopTaskHandler) Cancel(c *gin.Context) { + desktopID, err := uuid.Parse(c.Param("id")) + if err != nil { + response.Error(c, response.ErrBadRequest(40084, "invalid_desktop_task_id", "task id must be a uuid")) + return + } + + var req app.CancelDesktopTaskRequest + if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) { + response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error())) + return + } + + data, err := h.svc.Cancel(c.Request.Context(), MustDesktopClient(c.Request.Context()), desktopID, req) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, data) +} + +func (h *DesktopTaskHandler) TenantCancel(c *gin.Context) { + desktopID, err := uuid.Parse(c.Param("id")) + if err != nil { + response.Error(c, response.ErrBadRequest(40084, "invalid_desktop_task_id", "task id must be a uuid")) + return + } + + var req app.CancelDesktopTaskRequest + if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) { + response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error())) + return + } + + data, err := h.svc.TenantCancel(c.Request.Context(), auth.MustActor(c.Request.Context()), desktopID, req) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, data) +} + +func (h *DesktopTaskHandler) Reconcile(c *gin.Context) { + desktopID, err := uuid.Parse(c.Param("id")) + if err != nil { + response.Error(c, response.ErrBadRequest(40084, "invalid_desktop_task_id", "task id must be a uuid")) + return + } + + var req app.ReconcileDesktopTaskRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error())) + return + } + + data, err := h.svc.Reconcile(c.Request.Context(), auth.MustActor(c.Request.Context()), desktopID, req) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, data) +} diff --git a/server/internal/tenant/transport/monitoring_callback_handler.go b/server/internal/tenant/transport/monitoring_callback_handler.go deleted file mode 100644 index 1b2c0ef..0000000 --- a/server/internal/tenant/transport/monitoring_callback_handler.go +++ /dev/null @@ -1,162 +0,0 @@ -package transport - -import ( - "errors" - "io" - "strconv" - - "github.com/gin-gonic/gin" - - "github.com/geo-platform/tenant-api/internal/bootstrap" - "github.com/geo-platform/tenant-api/internal/shared/response" - "github.com/geo-platform/tenant-api/internal/tenant/app" -) - -type MonitoringCallbackHandler struct { - svc *app.MonitoringCallbackService -} - -func NewMonitoringCallbackHandler(a *bootstrap.App) *MonitoringCallbackHandler { - return &MonitoringCallbackHandler{ - svc: app.NewMonitoringCallbackService(a.DB, a.MonitoringDB, a.RabbitMQ, a.LLM, a.Logger), - } -} - -func (h *MonitoringCallbackHandler) Heartbeat(c *gin.Context) { - installationID, installationToken, ok := parseMonitoringInstallationHeaders(c) - if !ok { - return - } - - var req app.MonitoringHeartbeatRequest - if err := bindOptionalJSON(c, &req); err != nil { - response.Error(c, response.ErrBadRequest(40041, "invalid_params", err.Error())) - return - } - - data, svcErr := h.svc.Heartbeat(c.Request.Context(), installationID, installationToken, req) - if svcErr != nil { - response.Error(c, svcErr) - return - } - response.Success(c, data) -} - -func (h *MonitoringCallbackHandler) LeaseTasks(c *gin.Context) { - installationID, installationToken, ok := parseMonitoringInstallationHeaders(c) - if !ok { - return - } - - var req app.MonitoringLeaseTasksRequest - if err := bindOptionalJSON(c, &req); err != nil { - response.Error(c, response.ErrBadRequest(40041, "invalid_params", err.Error())) - return - } - - data, svcErr := h.svc.LeaseTasks(c.Request.Context(), installationID, installationToken, req) - if svcErr != nil { - response.Error(c, svcErr) - return - } - response.Success(c, data) -} - -func (h *MonitoringCallbackHandler) ResumeTasks(c *gin.Context) { - installationID, installationToken, ok := parseMonitoringInstallationHeaders(c) - if !ok { - return - } - - var req app.MonitoringResumeTasksRequest - if err := bindOptionalJSON(c, &req); err != nil { - response.Error(c, response.ErrBadRequest(40041, "invalid_params", err.Error())) - return - } - - data, svcErr := h.svc.ResumeTasks(c.Request.Context(), installationID, installationToken, req) - if svcErr != nil { - response.Error(c, svcErr) - return - } - response.Success(c, data) -} - -func (h *MonitoringCallbackHandler) TaskResult(c *gin.Context) { - taskID, err := strconv.ParseInt(c.Param("id"), 10, 64) - if err != nil { - response.Error(c, response.ErrBadRequest(40041, "invalid_task_id", "task id must be a number")) - return - } - - installationID, installationToken, ok := parseMonitoringInstallationHeaders(c) - if !ok { - return - } - - var req app.MonitoringTaskResultRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.Error(c, response.ErrBadRequest(40041, "invalid_params", err.Error())) - return - } - - data, svcErr := h.svc.HandleTaskResult(c.Request.Context(), installationID, installationToken, taskID, req) - if svcErr != nil { - response.Error(c, svcErr) - return - } - response.Success(c, data) -} - -func (h *MonitoringCallbackHandler) SkipTask(c *gin.Context) { - taskID, err := strconv.ParseInt(c.Param("id"), 10, 64) - if err != nil { - response.Error(c, response.ErrBadRequest(40041, "invalid_task_id", "task id must be a number")) - return - } - - installationID, installationToken, ok := parseMonitoringInstallationHeaders(c) - if !ok { - return - } - - var req app.MonitoringSkipTaskRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.Error(c, response.ErrBadRequest(40041, "invalid_params", err.Error())) - return - } - - data, svcErr := h.svc.SkipTask(c.Request.Context(), installationID, installationToken, taskID, req) - if svcErr != nil { - response.Error(c, svcErr) - return - } - response.Success(c, data) -} - -func parseMonitoringInstallationHeaders(c *gin.Context) (int64, string, bool) { - installationID, err := strconv.ParseInt(c.GetHeader("X-Geo-Installation-Id"), 10, 64) - if err != nil { - response.Error(c, response.ErrUnauthorized(40141, "invalid_installation_id", "installation id header is invalid")) - return 0, "", false - } - - installationToken := c.GetHeader("X-Geo-Installation-Token") - if installationToken == "" { - response.Error(c, response.ErrUnauthorized(40141, "missing_installation_token", "installation token header is required")) - return 0, "", false - } - - return installationID, installationToken, true -} - -func bindOptionalJSON(c *gin.Context, target interface{}) error { - err := c.ShouldBindJSON(target) - if err == nil { - return nil - } - if errors.Is(err, io.EOF) { - return nil - } - return err -} diff --git a/server/internal/tenant/transport/publish_job_handler.go b/server/internal/tenant/transport/publish_job_handler.go new file mode 100644 index 0000000..45c92e5 --- /dev/null +++ b/server/internal/tenant/transport/publish_job_handler.go @@ -0,0 +1,35 @@ +package transport + +import ( + "github.com/gin-gonic/gin" + + "github.com/geo-platform/tenant-api/internal/bootstrap" + "github.com/geo-platform/tenant-api/internal/shared/auth" + "github.com/geo-platform/tenant-api/internal/shared/response" + "github.com/geo-platform/tenant-api/internal/tenant/app" +) + +type PublishJobHandler struct { + svc *app.PublishJobService +} + +func NewPublishJobHandler(a *bootstrap.App) *PublishJobHandler { + return &PublishJobHandler{ + svc: app.NewPublishJobService(a.DB, a.RabbitMQ, a.Logger), + } +} + +func (h *PublishJobHandler) Create(c *gin.Context) { + var req app.CreatePublishJobRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error())) + return + } + + data, err := h.svc.Create(c.Request.Context(), auth.MustActor(c.Request.Context()), req) + if err != nil { + response.Error(c, err) + return + } + response.SuccessWithStatus(c, 201, data) +} diff --git a/server/internal/tenant/transport/router.go b/server/internal/tenant/transport/router.go index 48af8db..1e99172 100644 --- a/server/internal/tenant/transport/router.go +++ b/server/internal/tenant/transport/router.go @@ -21,14 +21,6 @@ func RegisterRoutes(app *bootstrap.App) { callbacks.POST("/bind", pluginHandler.PluginBindCallback) callbacks.POST("/publish", pluginHandler.PluginPublishCallback) - monitoringCallbackHandler := NewMonitoringCallbackHandler(app) - monitoringPlugin := app.Engine.Group("/api/plugin/monitoring") - monitoringPlugin.POST("/heartbeat", monitoringCallbackHandler.Heartbeat) - monitoringPlugin.POST("/tasks/lease", monitoringCallbackHandler.LeaseTasks) - monitoringPlugin.POST("/tasks/resume", monitoringCallbackHandler.ResumeTasks) - monitoringPlugin.POST("/tasks/:id/result", monitoringCallbackHandler.TaskResult) - monitoringPlugin.POST("/tasks/:id/skip", monitoringCallbackHandler.SkipTask) - protected := app.Engine.Group("/api") protected.Use(auth.Middleware(app.JWT, app.Sessions)) protected.Use(middleware.TenantScope()) @@ -36,8 +28,38 @@ func RegisterRoutes(app *bootstrap.App) { protected.GET("/auth/me", authHandler.Me) protected.POST("/auth/logout", authHandler.Logout) + desktopClientHandler := NewDesktopClientHandler(app) + desktopAccountHandler := NewDesktopAccountHandler(app) + desktopTaskHandler := NewDesktopTaskHandler(app) + desktopEventsHandler := NewDesktopEventsHandler(app) + desktopContentHandler := NewDesktopContentHandler(app) + publishJobHandler := NewPublishJobHandler(app) + protected.POST("/desktop/clients/register", desktopClientHandler.Register) + + desktopAuth := app.Engine.Group("/api/desktop") + desktopAuth.Use(DesktopClientMiddleware(repository.NewDesktopClientRepository(app.DB))) + desktopAuth.POST("/clients/rotate", desktopClientHandler.Rotate) + desktopAuth.POST("/clients/heartbeat", desktopClientHandler.Heartbeat) + desktopAuth.POST("/clients/revoke", desktopClientHandler.Revoke) + desktopAuth.GET("/events", desktopEventsHandler.Stream) + desktopAuth.GET("/accounts", desktopAccountHandler.List) + desktopAuth.GET("/content/articles/:id", desktopContentHandler.Article) + desktopAuth.POST("/accounts", desktopAccountHandler.Upsert) + desktopAuth.PATCH("/accounts/:id", desktopAccountHandler.Patch) + desktopAuth.DELETE("/accounts/:id", desktopAccountHandler.Delete) + desktopAuth.POST("/tasks/lease", desktopTaskHandler.Lease) + desktopAuth.POST("/tasks/:id/lease", desktopTaskHandler.Lease) + desktopAuth.POST("/tasks/:id/extend", desktopTaskHandler.Extend) + desktopAuth.POST("/tasks/:id/park", desktopTaskHandler.Park) + desktopAuth.POST("/tasks/:id/cancel", desktopTaskHandler.Cancel) + desktopAuth.POST("/tasks/:id/result", desktopTaskHandler.Result) + tenantProtected := protected.Group("/tenant") tenantProtected.Use(RequireActiveTenantSubscription(repository.NewTenantPlanRepository(app.DB))) + tenantProtected.POST("/accounts/:id/request-delete", desktopAccountHandler.RequestDelete) + tenantProtected.POST("/tasks/:id/reconcile", desktopTaskHandler.Reconcile) + tenantProtected.POST("/tasks/:id/cancel", desktopTaskHandler.TenantCancel) + tenantProtected.POST("/publish-jobs", publishJobHandler.Create) workspace := tenantProtected.Group("/workspace") wsHandler := NewWorkspaceHandler(app) @@ -125,13 +147,6 @@ func RegisterRoutes(app *bootstrap.App) { articles.POST("/:id/publish-batches", pluginHandler.CreatePublishBatch) articles.DELETE("/:id", artHandler.Delete) - media := tenantProtected.Group("/media") - media.GET("/platforms", pluginHandler.ListPlatforms) - media.GET("/platform-accounts", pluginHandler.ListPlatformAccounts) - media.POST("/plugin-installations/register", pluginHandler.RegisterPluginInstallation) - media.POST("/plugin-sessions", pluginHandler.CreatePluginSession) - media.DELETE("/platform-accounts/:id", pluginHandler.DeletePlatformAccount) - brands := tenantProtected.Group("/brands") brandHandler := NewBrandHandler(app) brands.GET("", brandHandler.List) diff --git a/server/internal/tenant/transport/router_test.go b/server/internal/tenant/transport/router_test.go index e146d00..aafccbc 100644 --- a/server/internal/tenant/transport/router_test.go +++ b/server/internal/tenant/transport/router_test.go @@ -25,6 +25,11 @@ func TestProtectedRoutes_RequireAuth(t *testing.T) { }{ {http.MethodGet, "/api/auth/me"}, {http.MethodPost, "/api/auth/logout"}, + {http.MethodPost, "/api/desktop/clients/register"}, + {http.MethodPost, "/api/tenant/accounts/:id/request-delete"}, + {http.MethodPost, "/api/tenant/tasks/:id/reconcile"}, + {http.MethodPost, "/api/tenant/tasks/:id/cancel"}, + {http.MethodPost, "/api/tenant/publish-jobs"}, {http.MethodGet, "/api/tenant/workspace/overview"}, {http.MethodGet, "/api/tenant/templates"}, {http.MethodGet, "/api/tenant/articles"}, @@ -52,11 +57,6 @@ func TestPublicRoutes_NoAuth(t *testing.T) { }{ {http.MethodPost, "/api/auth/login"}, {http.MethodPost, "/api/auth/refresh"}, - {http.MethodPost, "/api/plugin/monitoring/heartbeat"}, - {http.MethodPost, "/api/plugin/monitoring/tasks/lease"}, - {http.MethodPost, "/api/plugin/monitoring/tasks/resume"}, - {http.MethodPost, "/api/plugin/monitoring/tasks/:id/result"}, - {http.MethodPost, "/api/plugin/monitoring/tasks/:id/skip"}, {http.MethodGet, "/api/health/live"}, {http.MethodGet, "/api/health/ready"}, } @@ -68,6 +68,24 @@ func TestPublicRoutes_NoAuth(t *testing.T) { } } +func TestDesktopClientRoutes_ClientTokenAuth(t *testing.T) { + routes := []struct { + method string + path string + }{ + {http.MethodGet, "/api/desktop/events"}, + {http.MethodGet, "/api/desktop/accounts"}, + {http.MethodPost, "/api/desktop/tasks/lease"}, + {http.MethodPost, "/api/desktop/tasks/:id/result"}, + } + + for _, rt := range routes { + t.Run(rt.method+" "+rt.path, func(t *testing.T) { + assert.NotEmpty(t, rt.path) + }) + } +} + func TestHealthEndpoint_Live(t *testing.T) { r := gin.New() r.GET("/api/health/live", func(c *gin.Context) { diff --git a/server/migrations/20260420100000_create_workspaces.down.sql b/server/migrations/20260420100000_create_workspaces.down.sql new file mode 100644 index 0000000..7b4147d --- /dev/null +++ b/server/migrations/20260420100000_create_workspaces.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS workspace_memberships; +DROP TABLE IF EXISTS workspaces; diff --git a/server/migrations/20260420100000_create_workspaces.up.sql b/server/migrations/20260420100000_create_workspaces.up.sql new file mode 100644 index 0000000..7c377ae --- /dev/null +++ b/server/migrations/20260420100000_create_workspaces.up.sql @@ -0,0 +1,43 @@ +CREATE TABLE workspaces ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL REFERENCES tenants(id), + name TEXT NOT NULL, + slug TEXT NOT NULL, + is_default BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (tenant_id, slug) +); + +CREATE UNIQUE INDEX idx_workspaces_tenant_default + ON workspaces (tenant_id) + WHERE is_default = TRUE; + +CREATE TABLE workspace_memberships ( + id BIGSERIAL PRIMARY KEY, + workspace_id BIGINT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + tenant_id BIGINT NOT NULL REFERENCES tenants(id), + role TEXT NOT NULL DEFAULT 'member', + is_primary BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (workspace_id, user_id) +); + +CREATE UNIQUE INDEX idx_memberships_user_primary + ON workspace_memberships (user_id) + WHERE is_primary = TRUE; + +INSERT INTO workspaces (tenant_id, name, slug, is_default) +SELECT id, 'Default', 'default', TRUE +FROM tenants +ON CONFLICT (tenant_id, slug) DO NOTHING; + +INSERT INTO workspace_memberships (workspace_id, user_id, tenant_id, role, is_primary) +SELECT w.id, tm.user_id, tm.tenant_id, 'member', TRUE +FROM tenant_memberships tm +JOIN workspaces w + ON w.tenant_id = tm.tenant_id + AND w.is_default = TRUE +WHERE tm.deleted_at IS NULL +ON CONFLICT (workspace_id, user_id) DO NOTHING; diff --git a/server/migrations/20260420100010_add_workspace_to_desktop_tables.down.sql b/server/migrations/20260420100010_add_workspace_to_desktop_tables.down.sql new file mode 100644 index 0000000..512e3a4 --- /dev/null +++ b/server/migrations/20260420100010_add_workspace_to_desktop_tables.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE platform_accounts DROP COLUMN IF EXISTS workspace_id; +ALTER TABLE tenant_monitoring_quotas DROP COLUMN IF EXISTS workspace_id; diff --git a/server/migrations/20260420100010_add_workspace_to_desktop_tables.up.sql b/server/migrations/20260420100010_add_workspace_to_desktop_tables.up.sql new file mode 100644 index 0000000..2c60344 --- /dev/null +++ b/server/migrations/20260420100010_add_workspace_to_desktop_tables.up.sql @@ -0,0 +1,31 @@ +ALTER TABLE tenant_monitoring_quotas + ADD COLUMN IF NOT EXISTS workspace_id BIGINT REFERENCES workspaces(id); + +UPDATE tenant_monitoring_quotas q +SET workspace_id = w.id +FROM workspaces w +WHERE w.tenant_id = q.tenant_id + AND w.is_default = TRUE + AND q.workspace_id IS NULL; + +ALTER TABLE tenant_monitoring_quotas + ALTER COLUMN workspace_id SET NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_monitoring_quotas_workspace + ON tenant_monitoring_quotas (workspace_id); + +ALTER TABLE platform_accounts + ADD COLUMN IF NOT EXISTS workspace_id BIGINT REFERENCES workspaces(id); + +UPDATE platform_accounts a +SET workspace_id = w.id +FROM workspaces w +WHERE w.tenant_id = a.tenant_id + AND w.is_default = TRUE + AND a.workspace_id IS NULL; + +ALTER TABLE platform_accounts + ALTER COLUMN workspace_id SET NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_platform_accounts_workspace + ON platform_accounts (workspace_id); diff --git a/server/migrations/20260421100000_create_desktop_clients.down.sql b/server/migrations/20260421100000_create_desktop_clients.down.sql new file mode 100644 index 0000000..db609a0 --- /dev/null +++ b/server/migrations/20260421100000_create_desktop_clients.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS desktop_clients; diff --git a/server/migrations/20260421100000_create_desktop_clients.up.sql b/server/migrations/20260421100000_create_desktop_clients.up.sql new file mode 100644 index 0000000..1275cc3 --- /dev/null +++ b/server/migrations/20260421100000_create_desktop_clients.up.sql @@ -0,0 +1,24 @@ +CREATE TABLE desktop_clients ( + id UUID PRIMARY KEY, + tenant_id BIGINT NOT NULL REFERENCES tenants(id), + workspace_id BIGINT NOT NULL REFERENCES workspaces(id), + user_id BIGINT NOT NULL REFERENCES users(id), + token_hash BYTEA NOT NULL UNIQUE, + device_name TEXT, + os TEXT, + cpu_arch TEXT, + client_version TEXT, + channel TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_seen_at TIMESTAMPTZ, + last_rotated_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ +); + +CREATE INDEX idx_desktop_clients_workspace + ON desktop_clients (workspace_id) + WHERE revoked_at IS NULL; + +CREATE INDEX idx_desktop_clients_user + ON desktop_clients (user_id) + WHERE revoked_at IS NULL; diff --git a/server/migrations/20260421100010_add_desktop_fields_to_platform_accounts.down.sql b/server/migrations/20260421100010_add_desktop_fields_to_platform_accounts.down.sql new file mode 100644 index 0000000..377474c --- /dev/null +++ b/server/migrations/20260421100010_add_desktop_fields_to_platform_accounts.down.sql @@ -0,0 +1,18 @@ +DROP INDEX IF EXISTS idx_platform_accounts_client_id; +DROP INDEX IF EXISTS uniq_platform_accounts_workspace_identity_active; +DROP INDEX IF EXISTS uniq_platform_accounts_desktop_id; + +CREATE UNIQUE INDEX IF NOT EXISTS uk_platform_accounts_identity_active + ON platform_accounts(tenant_id, platform_id, platform_uid) + WHERE deleted_at IS NULL; + +ALTER TABLE platform_accounts + DROP COLUMN IF EXISTS delete_requested_at, + DROP COLUMN IF EXISTS sync_version, + DROP COLUMN IF EXISTS tags, + DROP COLUMN IF EXISTS verified_at, + DROP COLUMN IF EXISTS health, + DROP COLUMN IF EXISTS display_name, + DROP COLUMN IF EXISTS account_fingerprint, + DROP COLUMN IF EXISTS client_id, + DROP COLUMN IF EXISTS desktop_id; diff --git a/server/migrations/20260421100010_add_desktop_fields_to_platform_accounts.up.sql b/server/migrations/20260421100010_add_desktop_fields_to_platform_accounts.up.sql new file mode 100644 index 0000000..40eec4f --- /dev/null +++ b/server/migrations/20260421100010_add_desktop_fields_to_platform_accounts.up.sql @@ -0,0 +1,61 @@ +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +ALTER TABLE platform_accounts + ADD COLUMN IF NOT EXISTS desktop_id UUID, + ADD COLUMN IF NOT EXISTS client_id UUID REFERENCES desktop_clients(id), + ADD COLUMN IF NOT EXISTS account_fingerprint TEXT, + ADD COLUMN IF NOT EXISTS display_name TEXT, + ADD COLUMN IF NOT EXISTS health TEXT, + ADD COLUMN IF NOT EXISTS verified_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS tags JSONB, + ADD COLUMN IF NOT EXISTS sync_version BIGINT DEFAULT 1, + ADD COLUMN IF NOT EXISTS delete_requested_at TIMESTAMPTZ; + +UPDATE platform_accounts +SET desktop_id = COALESCE(desktop_id, gen_random_uuid()), + display_name = COALESCE(display_name, nickname), + health = COALESCE( + health, + CASE + WHEN status = 'active' THEN 'live' + WHEN status = 'expired' THEN 'expired' + WHEN status = 'risk' THEN 'risk' + WHEN status = 'captcha' THEN 'captcha' + ELSE 'expired' + END + ), + verified_at = COALESCE(verified_at, last_check_at), + tags = COALESCE( + tags, + CASE + WHEN metadata_json IS NOT NULL AND jsonb_typeof(metadata_json -> 'tags') = 'array' + THEN metadata_json -> 'tags' + ELSE NULL + END + ), + sync_version = COALESCE(sync_version, 1) +WHERE desktop_id IS NULL + OR display_name IS NULL + OR health IS NULL + OR sync_version IS NULL + OR verified_at IS NULL + OR tags IS NULL; + +ALTER TABLE platform_accounts + ALTER COLUMN desktop_id SET NOT NULL, + ALTER COLUMN display_name SET NOT NULL, + ALTER COLUMN health SET NOT NULL, + ALTER COLUMN sync_version SET NOT NULL; + +DROP INDEX IF EXISTS uk_platform_accounts_identity_active; + +CREATE UNIQUE INDEX IF NOT EXISTS uniq_platform_accounts_desktop_id + ON platform_accounts (desktop_id); + +CREATE UNIQUE INDEX IF NOT EXISTS uniq_platform_accounts_workspace_identity_active + ON platform_accounts (workspace_id, platform_id, platform_uid) + WHERE deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_platform_accounts_client_id + ON platform_accounts (client_id) + WHERE deleted_at IS NULL; diff --git a/server/migrations/20260421100020_create_desktop_tasks.down.sql b/server/migrations/20260421100020_create_desktop_tasks.down.sql new file mode 100644 index 0000000..2eb9de8 --- /dev/null +++ b/server/migrations/20260421100020_create_desktop_tasks.down.sql @@ -0,0 +1,4 @@ +DROP TABLE IF EXISTS desktop_task_traces; +DROP TABLE IF EXISTS desktop_task_attempts; +DROP TABLE IF EXISTS desktop_tasks; +DROP TABLE IF EXISTS desktop_publish_jobs; diff --git a/server/migrations/20260421100020_create_desktop_tasks.up.sql b/server/migrations/20260421100020_create_desktop_tasks.up.sql new file mode 100644 index 0000000..eaeaf23 --- /dev/null +++ b/server/migrations/20260421100020_create_desktop_tasks.up.sql @@ -0,0 +1,82 @@ +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE IF NOT EXISTS desktop_publish_jobs ( + id BIGSERIAL PRIMARY KEY, + desktop_id UUID NOT NULL DEFAULT gen_random_uuid(), + tenant_id BIGINT NOT NULL REFERENCES tenants(id), + workspace_id BIGINT NOT NULL REFERENCES workspaces(id), + created_by_user_id BIGINT NOT NULL REFERENCES users(id), + title TEXT NOT NULL, + content_ref JSONB NOT NULL, + scheduled_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (desktop_id) +); + +CREATE INDEX IF NOT EXISTS idx_desktop_publish_jobs_workspace_created + ON desktop_publish_jobs (workspace_id, created_at DESC); + +CREATE TABLE IF NOT EXISTS desktop_tasks ( + id BIGSERIAL PRIMARY KEY, + desktop_id UUID NOT NULL DEFAULT gen_random_uuid(), + job_id UUID NOT NULL, + tenant_id BIGINT NOT NULL REFERENCES tenants(id), + workspace_id BIGINT NOT NULL REFERENCES workspaces(id), + target_account_id UUID NOT NULL REFERENCES platform_accounts(desktop_id), + target_client_id UUID NOT NULL REFERENCES desktop_clients(id), + platform_id VARCHAR(64) NOT NULL REFERENCES media_platforms(platform_id), + kind TEXT NOT NULL, + payload JSONB NOT NULL, + status TEXT NOT NULL, + dedup_key TEXT, + active_attempt_id UUID, + lease_token_hash BYTEA, + lease_expires_at TIMESTAMPTZ, + attempts INT NOT NULL DEFAULT 0, + parked_reason TEXT, + result JSONB, + error JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (desktop_id) +); + +CREATE INDEX IF NOT EXISTS idx_desktop_tasks_job + ON desktop_tasks (job_id); + +CREATE INDEX IF NOT EXISTS idx_desktop_tasks_target_client_status + ON desktop_tasks (target_client_id, status) + WHERE status IN ('queued', 'in_progress', 'waiting_user'); + +CREATE INDEX IF NOT EXISTS idx_desktop_tasks_workspace_status_created + ON desktop_tasks (workspace_id, status, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_desktop_tasks_target_account + ON desktop_tasks (target_account_id); + +CREATE TABLE IF NOT EXISTS desktop_task_attempts ( + id BIGSERIAL PRIMARY KEY, + desktop_id UUID NOT NULL DEFAULT gen_random_uuid(), + task_id UUID NOT NULL REFERENCES desktop_tasks(desktop_id) ON DELETE CASCADE, + client_id UUID NOT NULL REFERENCES desktop_clients(id), + lease_token_hash BYTEA, + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + ended_at TIMESTAMPTZ, + final_status TEXT, + error JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (desktop_id) +); + +CREATE INDEX IF NOT EXISTS idx_desktop_task_attempts_task_started + ON desktop_task_attempts (task_id, started_at DESC); + +CREATE TABLE IF NOT EXISTS desktop_task_traces ( + task_id UUID NOT NULL REFERENCES desktop_tasks(desktop_id) ON DELETE CASCADE, + attempt_id UUID, + size BIGINT, + uploaded_at TIMESTAMPTZ, + object_key TEXT, + PRIMARY KEY (task_id, attempt_id) +);