@sriinnu/runic 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.swiftformat +50 -0
- package/.swiftlint.yml +160 -0
- package/CHANGELOG.md +307 -0
- package/CONTRIBUTING.md +456 -0
- package/Icon.icns +0 -0
- package/Icon.icon/Assets/runic.png +0 -0
- package/Icon.icon/icon.json +36 -0
- package/LICENSE +21 -0
- package/Package.resolved +51 -0
- package/Package.swift +136 -0
- package/Packages/Helix/LICENSE +21 -0
- package/Packages/Helix/Package.swift +29 -0
- package/Packages/Helix/README.md +976 -0
- package/Packages/Helix/Sources/Helix/Binding.swift +111 -0
- package/Packages/Helix/Sources/Helix/CommandParser.swift +160 -0
- package/Packages/Helix/Sources/Helix/CommandSignature.swift +117 -0
- package/Packages/Helix/Sources/Helix/ExpressibleFromArgument.swift +72 -0
- package/Packages/Helix/Sources/Helix/HelpGenerator.swift +175 -0
- package/Packages/Helix/Sources/Helix/NameSpecification.swift +98 -0
- package/Packages/Helix/Sources/Helix/ParsableCommand.swift +204 -0
- package/Packages/Helix/Sources/Helix/Platform/DarwinPlatformContext.swift +44 -0
- package/Packages/Helix/Sources/Helix/Platform/LinuxPlatformContext.swift +34 -0
- package/Packages/Helix/Sources/Helix/Platform/PlatformContext+Default.swift +91 -0
- package/Packages/Helix/Sources/Helix/Platform/PlatformContext.swift +94 -0
- package/Packages/Helix/Sources/Helix/Platform/PlatformPath.swift +167 -0
- package/Packages/Helix/Sources/Helix/Platform/Stdio.swift +99 -0
- package/Packages/Helix/Sources/Helix/Platform/WebPlatformContext.swift +146 -0
- package/Packages/Helix/Sources/Helix/Platform/WindowsPlatformContext.swift +97 -0
- package/Packages/Helix/Sources/Helix/Program.swift +204 -0
- package/Packages/Helix/Sources/Helix/PropertyWrappers.swift +367 -0
- package/Packages/Silo/LICENSE +21 -0
- package/Packages/Silo/Package.swift +34 -0
- package/Packages/Silo/README.md +466 -0
- package/Packages/Silo/Sources/Silo/BinaryCookiesReader.swift +169 -0
- package/Packages/Silo/Sources/Silo/BrowserCookieAnalytics.swift +365 -0
- package/Packages/Silo/Sources/Silo/BrowserCookieBackends.swift +594 -0
- package/Packages/Silo/Sources/Silo/BrowserCookieClient.swift +326 -0
- package/Packages/Silo/Sources/Silo/BrowserCookieExport.swift +154 -0
- package/Packages/Silo/Sources/Silo/BrowserCookieImport.swift +119 -0
- package/Packages/Silo/Sources/Silo/BrowserCookieMockStore.swift +121 -0
- package/Packages/Silo/Sources/Silo/BrowserCookieModels.swift +391 -0
- package/Packages/Silo/Sources/Silo/ChromiumCookieReader.swift +240 -0
- package/Packages/Silo/Sources/Silo/CryptoSupport.swift +86 -0
- package/Packages/Silo/Sources/Silo/FirefoxCookieReader.swift +172 -0
- package/Packages/Silo/Sources/Silo/LinuxChromiumCookieReader.swift +172 -0
- package/Packages/Silo/Sources/Silo/LinuxSecretService.swift +105 -0
- package/Packages/Silo/Sources/Silo/MacOSChromiumCookieReader.swift +191 -0
- package/Packages/Silo/Sources/Silo/SQLiteSnapshot.swift +41 -0
- package/Packages/Silo/Sources/Silo/WindowsChromiumCookieReader.swift +110 -0
- package/README.md +109 -0
- package/RuniciOS/App/RuniciOSApp.swift +105 -0
- package/RuniciOS/Core/iOSUsageStore.swift +192 -0
- package/RuniciOS/Views/ProviderDetailView.swift +312 -0
- package/RuniciOS/Views/ProviderListView.swift +228 -0
- package/RuniciOS/Widgets/LargeWidget.swift +367 -0
- package/RuniciOS/Widgets/LiveActivity.swift +342 -0
- package/RuniciOS/Widgets/LockScreenWidget.swift +273 -0
- package/RuniciOS/Widgets/MediumWidget.swift +370 -0
- package/RuniciOS/Widgets/RunicWidget.swift +274 -0
- package/RuniciOS/Widgets/SmallWidget.swift +281 -0
- package/RuniciOS/Widgets/WidgetModels.swift +285 -0
- package/RuniciOS/Widgets/WidgetTimeline.swift +270 -0
- package/Scripts/CREATION-SUMMARY.md +367 -0
- package/Scripts/DIRECTORY-STRUCTURE.md +358 -0
- package/Scripts/GETTING-STARTED-CHECKLIST.md +364 -0
- package/Scripts/INDEX.md +365 -0
- package/Scripts/QUICK-REFERENCE.md +164 -0
- package/Scripts/README-SCRIPTS.md +926 -0
- package/Scripts/build-android.sh +526 -0
- package/Scripts/build-ios.sh +529 -0
- package/Scripts/build-macos.sh +603 -0
- package/Scripts/build-windows.sh +504 -0
- package/Scripts/build_icon.sh +49 -0
- package/Scripts/changelog-to-html.sh +89 -0
- package/Scripts/check-release-assets.sh +10 -0
- package/Scripts/ci-config.yml +474 -0
- package/Scripts/compile_and_run.sh +219 -0
- package/Scripts/compile_and_run_adhoc.sh +4 -0
- package/Scripts/docs-list.mjs +119 -0
- package/Scripts/make_appcast.sh +58 -0
- package/Scripts/package_app.sh +409 -0
- package/Scripts/release-lib.sh +214 -0
- package/Scripts/release.sh +76 -0
- package/Scripts/setup-all.sh +387 -0
- package/Scripts/setup-ios.sh +404 -0
- package/Scripts/setup-react-native.sh +523 -0
- package/Scripts/sign-and-notarize.sh +122 -0
- package/Scripts/test_live_update.sh +38 -0
- package/Scripts/validate_changelog.sh +23 -0
- package/Scripts/verify_appcast.sh +92 -0
- package/Sources/Runic/About.swift +93 -0
- package/Sources/Runic/AppNotifications.swift +115 -0
- package/Sources/Runic/Controllers/StatusItemController+Actions.swift +657 -0
- package/Sources/Runic/Controllers/StatusItemController+Animation.swift +518 -0
- package/Sources/Runic/Controllers/StatusItemController+ChartSubmenus.swift +810 -0
- package/Sources/Runic/Controllers/StatusItemController+CustomProviders.swift +87 -0
- package/Sources/Runic/Controllers/StatusItemController+Menu.swift +1379 -0
- package/Sources/Runic/Controllers/StatusItemController+MenuCards.swift +337 -0
- package/Sources/Runic/Controllers/StatusItemController+MenuHelpers.swift +395 -0
- package/Sources/Runic/Controllers/StatusItemController.swift +360 -0
- package/Sources/Runic/Core/BudgetNotificationManager.swift +80 -0
- package/Sources/Runic/Core/Login/ClaudeLoginRunner.swift +103 -0
- package/Sources/Runic/Core/Login/CodexLoginRunner.swift +154 -0
- package/Sources/Runic/Core/Login/CursorLoginRunner.swift +206 -0
- package/Sources/Runic/Core/Login/GeminiLoginRunner.swift +95 -0
- package/Sources/Runic/Core/Rendering/IconRenderer.swift +1216 -0
- package/Sources/Runic/Core/Rendering/IconView.swift +120 -0
- package/Sources/Runic/Core/Rendering/ProviderBrandIcon.swift +71 -0
- package/Sources/Runic/Core/RunicAnimation.swift +53 -0
- package/Sources/Runic/Core/RunicColors.swift +73 -0
- package/Sources/Runic/Core/RunicCornerRadius.swift +15 -0
- package/Sources/Runic/Core/RunicSpacing.swift +68 -0
- package/Sources/Runic/Core/Stores/AuggieTokenStore.swift +143 -0
- package/Sources/Runic/Core/Stores/AzureOpenAITokenStore.swift +143 -0
- package/Sources/Runic/Core/Stores/CerebrasTokenStore.swift +143 -0
- package/Sources/Runic/Core/Stores/CohereTokenStore.swift +143 -0
- package/Sources/Runic/Core/Stores/CopilotTokenStore.swift +143 -0
- package/Sources/Runic/Core/Stores/CustomProviderStore.swift +158 -0
- package/Sources/Runic/Core/Stores/DeepSeekTokenStore.swift +143 -0
- package/Sources/Runic/Core/Stores/FireworksTokenStore.swift +143 -0
- package/Sources/Runic/Core/Stores/GroqTokenStore.swift +143 -0
- package/Sources/Runic/Core/Stores/KimiTokenStore.swift +143 -0
- package/Sources/Runic/Core/Stores/MiniMaxCookieHeaderStore.swift +143 -0
- package/Sources/Runic/Core/Stores/MiniMaxGroupIDStore.swift +143 -0
- package/Sources/Runic/Core/Stores/MiniMaxTokenStore.swift +143 -0
- package/Sources/Runic/Core/Stores/MistralTokenStore.swift +143 -0
- package/Sources/Runic/Core/Stores/OpenRouterTokenStore.swift +143 -0
- package/Sources/Runic/Core/Stores/PerplexityTokenStore.swift +143 -0
- package/Sources/Runic/Core/Stores/ProviderToggleStore.swift +30 -0
- package/Sources/Runic/Core/Stores/QwenTokenStore.swift +143 -0
- package/Sources/Runic/Core/Stores/SambaNovaTokenStore.swift +143 -0
- package/Sources/Runic/Core/Stores/SettingsStore.swift +1702 -0
- package/Sources/Runic/Core/Stores/TogetherTokenStore.swift +143 -0
- package/Sources/Runic/Core/Stores/UsageStore+Accessors.swift +20 -0
- package/Sources/Runic/Core/Stores/UsageStore+Status.swift +189 -0
- package/Sources/Runic/Core/Stores/UsageStore+TokenCost.swift +33 -0
- package/Sources/Runic/Core/Stores/UsageStore+WidgetSnapshot.swift +67 -0
- package/Sources/Runic/Core/Stores/UsageStore.swift +3564 -0
- package/Sources/Runic/Core/Stores/XAITokenStore.swift +143 -0
- package/Sources/Runic/Core/Stores/ZaiTokenStore.swift +143 -0
- package/Sources/Runic/Core/UsageExporter.swift +193 -0
- package/Sources/Runic/Date+RelativeDescription.swift +21 -0
- package/Sources/Runic/HiddenWindowView.swift +32 -0
- package/Sources/Runic/InstallOrigin.swift +9 -0
- package/Sources/Runic/KeyboardShortcuts+Names.swift +6 -0
- package/Sources/Runic/MouseLocationReader.swift +65 -0
- package/Sources/Runic/Notifications+Runic.swift +6 -0
- package/Sources/Runic/OpenAICreditsPurchaseWindowController.swift +503 -0
- package/Sources/Runic/ProviderRegistry.swift +100 -0
- package/Sources/Runic/ProviderSwitcherButtons.swift +267 -0
- package/Sources/Runic/Providers/Antigravity/AntigravityLoginFlow.swift +11 -0
- package/Sources/Runic/Providers/Antigravity/AntigravityProviderImplementation.swift +14 -0
- package/Sources/Runic/Providers/Auggie/AuggieProviderImplementation.swift +24 -0
- package/Sources/Runic/Providers/Azure/AzureProviderImplementation.swift +52 -0
- package/Sources/Runic/Providers/Bedrock/BedrockProviderImplementation.swift +42 -0
- package/Sources/Runic/Providers/Cerebras/CerebrasProviderImplementation.swift +24 -0
- package/Sources/Runic/Providers/Claude/ClaudeLoginFlow.swift +26 -0
- package/Sources/Runic/Providers/Claude/ClaudeProviderImplementation.swift +14 -0
- package/Sources/Runic/Providers/Codex/CodexLoginFlow.swift +18 -0
- package/Sources/Runic/Providers/Codex/CodexProviderImplementation.swift +15 -0
- package/Sources/Runic/Providers/Cohere/CohereProviderImplementation.swift +24 -0
- package/Sources/Runic/Providers/Copilot/CopilotLoginFlow.swift +193 -0
- package/Sources/Runic/Providers/Copilot/CopilotProviderImplementation.swift +110 -0
- package/Sources/Runic/Providers/Cursor/CursorLoginFlow.swift +28 -0
- package/Sources/Runic/Providers/Cursor/CursorProviderImplementation.swift +15 -0
- package/Sources/Runic/Providers/DeepSeek/DeepSeekProviderImplementation.swift +24 -0
- package/Sources/Runic/Providers/Factory/FactoryLoginFlow.swift +12 -0
- package/Sources/Runic/Providers/Factory/FactoryProviderImplementation.swift +15 -0
- package/Sources/Runic/Providers/Fireworks/FireworksProviderImplementation.swift +24 -0
- package/Sources/Runic/Providers/Gemini/GeminiLoginFlow.swift +20 -0
- package/Sources/Runic/Providers/Gemini/GeminiProviderImplementation.swift +15 -0
- package/Sources/Runic/Providers/Groq/GroqProviderImplementation.swift +24 -0
- package/Sources/Runic/Providers/Kimi/KimiProviderImplementation.swift +24 -0
- package/Sources/Runic/Providers/MiniMax/MiniMaxProviderImplementation.swift +32 -0
- package/Sources/Runic/Providers/Mistral/MistralProviderImplementation.swift +24 -0
- package/Sources/Runic/Providers/OpenRouter/OpenRouterProviderImplementation.swift +24 -0
- package/Sources/Runic/Providers/Perplexity/PerplexityProviderImplementation.swift +24 -0
- package/Sources/Runic/Providers/SambaNova/SambaNovaProviderImplementation.swift +24 -0
- package/Sources/Runic/Providers/Shared/ProviderCatalog.swift +15 -0
- package/Sources/Runic/Providers/Shared/ProviderImplementation.swift +48 -0
- package/Sources/Runic/Providers/Shared/ProviderImplementationRegistry.swift +66 -0
- package/Sources/Runic/Providers/Shared/ProviderLoginFlow.swift +12 -0
- package/Sources/Runic/Providers/Shared/ProviderSettingsDescriptors.swift +99 -0
- package/Sources/Runic/Providers/Shared/SystemSettingsLinks.swift +18 -0
- package/Sources/Runic/Providers/Together/TogetherProviderImplementation.swift +24 -0
- package/Sources/Runic/Providers/XAI/XAIProviderImplementation.swift +24 -0
- package/Sources/Runic/Providers/Zai/ZaiProviderImplementation.swift +24 -0
- package/Sources/Runic/Resources/ProviderIcon-antigravity.svg +2 -0
- package/Sources/Runic/Resources/ProviderIcon-auggie.svg +3 -0
- package/Sources/Runic/Resources/ProviderIcon-azure.svg +5 -0
- package/Sources/Runic/Resources/ProviderIcon-bedrock.svg +7 -0
- package/Sources/Runic/Resources/ProviderIcon-cerebras.svg +3 -0
- package/Sources/Runic/Resources/ProviderIcon-claude.svg +1 -0
- package/Sources/Runic/Resources/ProviderIcon-codex.svg +1 -0
- package/Sources/Runic/Resources/ProviderIcon-cohere.svg +3 -0
- package/Sources/Runic/Resources/ProviderIcon-copilot.svg +1 -0
- package/Sources/Runic/Resources/ProviderIcon-cursor.svg +3 -0
- package/Sources/Runic/Resources/ProviderIcon-deepseek.svg +4 -0
- package/Sources/Runic/Resources/ProviderIcon-factory.svg +3 -0
- package/Sources/Runic/Resources/ProviderIcon-fireworks.svg +3 -0
- package/Sources/Runic/Resources/ProviderIcon-gemini.svg +1 -0
- package/Sources/Runic/Resources/ProviderIcon-groq.svg +1 -0
- package/Sources/Runic/Resources/ProviderIcon-kimi.svg +4 -0
- package/Sources/Runic/Resources/ProviderIcon-minimax.svg +1 -0
- package/Sources/Runic/Resources/ProviderIcon-mistral.svg +5 -0
- package/Sources/Runic/Resources/ProviderIcon-openrouter.svg +7 -0
- package/Sources/Runic/Resources/ProviderIcon-perplexity.svg +4 -0
- package/Sources/Runic/Resources/ProviderIcon-qwen.svg +9 -0
- package/Sources/Runic/Resources/ProviderIcon-sambanova.svg +4 -0
- package/Sources/Runic/Resources/ProviderIcon-together.svg +5 -0
- package/Sources/Runic/Resources/ProviderIcon-vertexai.svg +17 -0
- package/Sources/Runic/Resources/ProviderIcon-xai.svg +3 -0
- package/Sources/Runic/Resources/ProviderIcon-zai.svg +5 -0
- package/Sources/Runic/Resources/RunicMenubarIcon.svg +7 -0
- package/Sources/Runic/RunicApp.swift +357 -0
- package/Sources/Runic/SessionQuotaNotifications.swift +58 -0
- package/Sources/Runic/UsagePaceText.swift +52 -0
- package/Sources/Runic/UsageProgressBar.swift +224 -0
- package/Sources/Runic/Utilities/DisplayLink.swift +96 -0
- package/Sources/Runic/Utilities/LoadingPattern.swift +60 -0
- package/Sources/Runic/Utilities/PerformanceConstants.swift +85 -0
- package/Sources/Runic/Views/Menu/AlertsMenuView.swift +256 -0
- package/Sources/Runic/Views/Menu/ChartPanelStyle.swift +22 -0
- package/Sources/Runic/Views/Menu/CostComparativeChartMenuView.swift +275 -0
- package/Sources/Runic/Views/Menu/CostHistoryChartMenuView.swift +377 -0
- package/Sources/Runic/Views/Menu/CreditsHistoryChartMenuView.swift +312 -0
- package/Sources/Runic/Views/Menu/EfficiencyMetricsMenuView.swift +312 -0
- package/Sources/Runic/Views/Menu/HeroStatCardsMenuView.swift +228 -0
- package/Sources/Runic/Views/Menu/HourlyActivityChartMenuView.swift +228 -0
- package/Sources/Runic/Views/Menu/InlineUsageChartView.swift +162 -0
- package/Sources/Runic/Views/Menu/MenuCardEntranceModifier.swift +64 -0
- package/Sources/Runic/Views/Menu/MenuCardSidebarView.swift +268 -0
- package/Sources/Runic/Views/Menu/MenuCardSkeletonView.swift +104 -0
- package/Sources/Runic/Views/Menu/MenuCardView.swift +1900 -0
- package/Sources/Runic/Views/Menu/MenuContent.swift +150 -0
- package/Sources/Runic/Views/Menu/MenuDescriptor.swift +369 -0
- package/Sources/Runic/Views/Menu/MenuHighlightStyle.swift +42 -0
- package/Sources/Runic/Views/Menu/ModelBreakdownMenuView.swift +235 -0
- package/Sources/Runic/Views/Menu/OverviewMenuView.swift +141 -0
- package/Sources/Runic/Views/Menu/ProjectBreakdownMenuView.swift +209 -0
- package/Sources/Runic/Views/Menu/ProjectBudgetMenuView.swift +220 -0
- package/Sources/Runic/Views/Menu/ProviderTabBarView.swift +88 -0
- package/Sources/Runic/Views/Menu/SubscriptionUtilizationChartMenuView.swift +201 -0
- package/Sources/Runic/Views/Menu/UsageBreakdownChartMenuView.swift +398 -0
- package/Sources/Runic/Views/Menu/UsageHeatmapMenuView.swift +232 -0
- package/Sources/Runic/Views/Menu/UsageTimelineChartMenuView.swift +417 -0
- package/Sources/Runic/Views/Menu/UsageWindowComparisonChartMenuView.swift +190 -0
- package/Sources/Runic/Views/Menu/WeeklyActivityChartMenuView.swift +132 -0
- package/Sources/Runic/Views/Performance/PerformanceDashboardView.swift +618 -0
- package/Sources/Runic/Views/Preferences/LiquidComponents.swift +305 -0
- package/Sources/Runic/Views/Preferences/PreferencesAboutPane.swift +271 -0
- package/Sources/Runic/Views/Preferences/PreferencesAdvancedPane.swift +268 -0
- package/Sources/Runic/Views/Preferences/PreferencesAlertsPane.swift +422 -0
- package/Sources/Runic/Views/Preferences/PreferencesAnalyticsPane.swift +641 -0
- package/Sources/Runic/Views/Preferences/PreferencesBudgetsPane.swift +344 -0
- package/Sources/Runic/Views/Preferences/PreferencesComponents.swift +230 -0
- package/Sources/Runic/Views/Preferences/PreferencesCustomProvidersPane.swift +322 -0
- package/Sources/Runic/Views/Preferences/PreferencesDebugPane.swift +469 -0
- package/Sources/Runic/Views/Preferences/PreferencesGeneralPane.swift +231 -0
- package/Sources/Runic/Views/Preferences/PreferencesHelpPane.swift +50 -0
- package/Sources/Runic/Views/Preferences/PreferencesIntegrationsPane.swift +497 -0
- package/Sources/Runic/Views/Preferences/PreferencesPerformancePane.swift +288 -0
- package/Sources/Runic/Views/Preferences/PreferencesProvidersPane.swift +3435 -0
- package/Sources/Runic/Views/Preferences/PreferencesSelection.swift +25 -0
- package/Sources/Runic/Views/Preferences/PreferencesSyncPane.swift +59 -0
- package/Sources/Runic/Views/Preferences/PreferencesView.swift +206 -0
- package/Sources/Runic/Views/Rating/QualityRatingView.swift +238 -0
- package/Sources/Runic/Views/Team/TeamDashboardView.swift +371 -0
- package/Sources/Runic/Views/Team/TeamInviteSheet.swift +175 -0
- package/Sources/Runic/Views/Team/TeamManagementView.swift +761 -0
- package/Sources/Runic/Views/Team/TeamMemberRow.swift +181 -0
- package/Sources/RunicCLI/CLIEntry.swift +942 -0
- package/Sources/RunicCLI/CLIRenderer.swift +87 -0
- package/Sources/RunicCLI/Commands/EnhancedUsageCommand.swift +588 -0
- package/Sources/RunicCLI/Commands/ModelsCommand.swift +362 -0
- package/Sources/RunicCLI/Commands/ProjectsCommand.swift +439 -0
- package/Sources/RunicCLI/Commands/ResetCommand.swift +429 -0
- package/Sources/RunicCLI/Commands/UsageLedgerSourceFactory.swift +168 -0
- package/Sources/RunicClaudeWatchdog/main.swift +122 -0
- package/Sources/RunicClaudeWebProbe/main.swift +62 -0
- package/Sources/RunicCore/AlertRuleStore.swift +187 -0
- package/Sources/RunicCore/BrowserCookieImportOrder.swift +25 -0
- package/Sources/RunicCore/CopilotUsageModels.swift +84 -0
- package/Sources/RunicCore/CostUsageFetcher.swift +119 -0
- package/Sources/RunicCore/CostUsageModels.swift +479 -0
- package/Sources/RunicCore/CreditsModels.swift +50 -0
- package/Sources/RunicCore/CustomProviders/CustomProviderModels.swift +269 -0
- package/Sources/RunicCore/CustomProviders/GenericProviderFetcher.swift +622 -0
- package/Sources/RunicCore/CustomProviders/ProviderTemplates.swift +500 -0
- package/Sources/RunicCore/Host/PTY/TTYCommandRunner.swift +679 -0
- package/Sources/RunicCore/Host/Process/SubprocessRunner.swift +135 -0
- package/Sources/RunicCore/Integrations/GitHubIntegration.swift +173 -0
- package/Sources/RunicCore/Logging/JSONStderrLogHandler.swift +79 -0
- package/Sources/RunicCore/Logging/OSLogLogHandler.swift +86 -0
- package/Sources/RunicCore/Logging/RunicLog.swift +123 -0
- package/Sources/RunicCore/Models/EnhancedUsageModels.swift +371 -0
- package/Sources/RunicCore/OpenAIDashboardModels.swift +210 -0
- package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift +676 -0
- package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardFetcher.swift +492 -0
- package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardNavigationDelegate.swift +51 -0
- package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardParser.swift +483 -0
- package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardScrapeScript.swift +558 -0
- package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift +204 -0
- package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardWebsiteDataStore.swift +78 -0
- package/Sources/RunicCore/PathEnvironment.swift +482 -0
- package/Sources/RunicCore/Performance/PerformanceModels.swift +371 -0
- package/Sources/RunicCore/Performance/PerformanceStorageImpl.swift +409 -0
- package/Sources/RunicCore/Policy/UsagePolicyEngine.swift +178 -0
- package/Sources/RunicCore/ProjectBudgetStore.swift +99 -0
- package/Sources/RunicCore/ProviderCostSnapshot.swift +29 -0
- package/Sources/RunicCore/Providers/Antigravity/AntigravityProviderDescriptor.swift +66 -0
- package/Sources/RunicCore/Providers/Antigravity/AntigravityStatusProbe.swift +685 -0
- package/Sources/RunicCore/Providers/Auggie/AuggieProviderDescriptor.swift +83 -0
- package/Sources/RunicCore/Providers/Auggie/AuggieUsageFetcher.swift +142 -0
- package/Sources/RunicCore/Providers/Azure/AzureProviderDescriptor.swift +141 -0
- package/Sources/RunicCore/Providers/Azure/AzureUsageFetcher.swift +186 -0
- package/Sources/RunicCore/Providers/Bedrock/BedrockProviderDescriptor.swift +126 -0
- package/Sources/RunicCore/Providers/Bedrock/BedrockUsageFetcher.swift +168 -0
- package/Sources/RunicCore/Providers/Cerebras/CerebrasProviderDescriptor.swift +84 -0
- package/Sources/RunicCore/Providers/Cerebras/CerebrasUsageFetcher.swift +75 -0
- package/Sources/RunicCore/Providers/Claude/ClaudeCLISession.swift +332 -0
- package/Sources/RunicCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift +180 -0
- package/Sources/RunicCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift +137 -0
- package/Sources/RunicCore/Providers/Claude/ClaudeProviderDescriptor.swift +207 -0
- package/Sources/RunicCore/Providers/Claude/ClaudeStatusProbe.swift +654 -0
- package/Sources/RunicCore/Providers/Claude/ClaudeUsageDataSource.swift +28 -0
- package/Sources/RunicCore/Providers/Claude/ClaudeUsageFetcher.swift +480 -0
- package/Sources/RunicCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift +780 -0
- package/Sources/RunicCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift +146 -0
- package/Sources/RunicCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift +269 -0
- package/Sources/RunicCore/Providers/Codex/CodexOAuth/CodexTokenRefresher.swift +100 -0
- package/Sources/RunicCore/Providers/Codex/CodexProviderDescriptor.swift +243 -0
- package/Sources/RunicCore/Providers/Codex/CodexStatusProbe.swift +125 -0
- package/Sources/RunicCore/Providers/Codex/CodexUsageDataSource.swift +24 -0
- package/Sources/RunicCore/Providers/Codex/CodexWebDashboardStrategy.swift +181 -0
- package/Sources/RunicCore/Providers/Cohere/CohereProviderDescriptor.swift +83 -0
- package/Sources/RunicCore/Providers/Cohere/CohereUsageFetcher.swift +85 -0
- package/Sources/RunicCore/Providers/Copilot/CopilotDeviceFlow.swift +123 -0
- package/Sources/RunicCore/Providers/Copilot/CopilotGitHubCLITokenReader.swift +114 -0
- package/Sources/RunicCore/Providers/Copilot/CopilotProviderDescriptor.swift +100 -0
- package/Sources/RunicCore/Providers/Copilot/CopilotUsageFetcher.swift +291 -0
- package/Sources/RunicCore/Providers/Copilot/CopilotVSCodeTokenReader.swift +386 -0
- package/Sources/RunicCore/Providers/Cursor/CursorProviderDescriptor.swift +65 -0
- package/Sources/RunicCore/Providers/Cursor/CursorStatusProbe.swift +594 -0
- package/Sources/RunicCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift +85 -0
- package/Sources/RunicCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift +108 -0
- package/Sources/RunicCore/Providers/Factory/FactoryLocalStorageImporter.swift +398 -0
- package/Sources/RunicCore/Providers/Factory/FactoryProviderDescriptor.swift +65 -0
- package/Sources/RunicCore/Providers/Factory/FactoryStatusProbe.swift +1275 -0
- package/Sources/RunicCore/Providers/Fireworks/FireworksProviderDescriptor.swift +84 -0
- package/Sources/RunicCore/Providers/Fireworks/FireworksUsageFetcher.swift +75 -0
- package/Sources/RunicCore/Providers/Gemini/GeminiProviderDescriptor.swift +65 -0
- package/Sources/RunicCore/Providers/Gemini/GeminiStatusProbe.swift +835 -0
- package/Sources/RunicCore/Providers/Groq/GroqProviderDescriptor.swift +83 -0
- package/Sources/RunicCore/Providers/Groq/GroqUsageFetcher.swift +71 -0
- package/Sources/RunicCore/Providers/Kimi/KimiProviderDescriptor.swift +83 -0
- package/Sources/RunicCore/Providers/Kimi/KimiUsageFetcher.swift +77 -0
- package/Sources/RunicCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift +92 -0
- package/Sources/RunicCore/Providers/MiniMax/MiniMaxSettingsReader.swift +54 -0
- package/Sources/RunicCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +171 -0
- package/Sources/RunicCore/Providers/MiniMax/MiniMaxWebUsageFetcher.swift +911 -0
- package/Sources/RunicCore/Providers/Mistral/MistralProviderDescriptor.swift +84 -0
- package/Sources/RunicCore/Providers/Mistral/MistralUsageFetcher.swift +75 -0
- package/Sources/RunicCore/Providers/OpenRouter/OpenRouterProviderDescriptor.swift +87 -0
- package/Sources/RunicCore/Providers/OpenRouter/OpenRouterUsageFetcher.swift +207 -0
- package/Sources/RunicCore/Providers/Perplexity/PerplexityProviderDescriptor.swift +84 -0
- package/Sources/RunicCore/Providers/Perplexity/PerplexityUsageFetcher.swift +75 -0
- package/Sources/RunicCore/Providers/ProviderBranding.swift +25 -0
- package/Sources/RunicCore/Providers/ProviderCLIConfig.swift +17 -0
- package/Sources/RunicCore/Providers/ProviderDescriptor.swift +129 -0
- package/Sources/RunicCore/Providers/ProviderFetchPlan.swift +263 -0
- package/Sources/RunicCore/Providers/ProviderSettingsSnapshot.swift +98 -0
- package/Sources/RunicCore/Providers/ProviderTokenResolver.swift +501 -0
- package/Sources/RunicCore/Providers/ProviderVersionDetector.swift +68 -0
- package/Sources/RunicCore/Providers/Providers.swift +184 -0
- package/Sources/RunicCore/Providers/Qwen/QwenProviderDescriptor.swift +84 -0
- package/Sources/RunicCore/Providers/Qwen/QwenSettingsReader.swift +30 -0
- package/Sources/RunicCore/Providers/Qwen/QwenUsageStats.swift +317 -0
- package/Sources/RunicCore/Providers/SambaNova/SambaNovaProviderDescriptor.swift +84 -0
- package/Sources/RunicCore/Providers/SambaNova/SambaNovaUsageFetcher.swift +75 -0
- package/Sources/RunicCore/Providers/Together/TogetherProviderDescriptor.swift +83 -0
- package/Sources/RunicCore/Providers/Together/TogetherUsageFetcher.swift +75 -0
- package/Sources/RunicCore/Providers/VertexAI/VertexAIProviderDescriptor.swift +123 -0
- package/Sources/RunicCore/Providers/VertexAI/VertexAISettingsReader.swift +48 -0
- package/Sources/RunicCore/Providers/VertexAI/VertexAIUsageFetcher.swift +144 -0
- package/Sources/RunicCore/Providers/XAI/XAIProviderDescriptor.swift +83 -0
- package/Sources/RunicCore/Providers/XAI/XAIUsageFetcher.swift +75 -0
- package/Sources/RunicCore/Providers/Zai/ZaiProviderDescriptor.swift +72 -0
- package/Sources/RunicCore/Providers/Zai/ZaiSettingsReader.swift +41 -0
- package/Sources/RunicCore/Providers/Zai/ZaiUsageStats.swift +724 -0
- package/Sources/RunicCore/Sync/BackgroundSyncManager.swift +385 -0
- package/Sources/RunicCore/Sync/SyncConflictResolver.swift +310 -0
- package/Sources/RunicCore/Sync/SyncProtocol.swift +288 -0
- package/Sources/RunicCore/Sync/SyncRecord.swift +405 -0
- package/Sources/RunicCore/Sync/SyncUsageExample.swift +294 -0
- package/Sources/RunicCore/Sync/TeamSyncRecords.swift +775 -0
- package/Sources/RunicCore/Sync/iCloudSyncEngine.swift +333 -0
- package/Sources/RunicCore/Team/TeamModels.swift +583 -0
- package/Sources/RunicCore/Team/TeamShowbackExporter.swift +495 -0
- package/Sources/RunicCore/Team/TeamStore.swift +792 -0
- package/Sources/RunicCore/TextParsing.swift +55 -0
- package/Sources/RunicCore/UsageFetcher.swift +694 -0
- package/Sources/RunicCore/UsageFormatter.swift +410 -0
- package/Sources/RunicCore/UsageLedger/ClaudeUsageLogSource.swift +329 -0
- package/Sources/RunicCore/UsageLedger/CodexUsageLogSource.swift +448 -0
- package/Sources/RunicCore/UsageLedger/OTelGenAILedgerAdapter.swift +550 -0
- package/Sources/RunicCore/UsageLedger/UsageLedger.swift +29 -0
- package/Sources/RunicCore/UsageLedger/UsageLedgerAggregator.swift +660 -0
- package/Sources/RunicCore/UsageLedger/UsageLedgerInsightsAdvisor.swift +209 -0
- package/Sources/RunicCore/UsageLedger/UsageLedgerModels.swift +417 -0
- package/Sources/RunicCore/UsageLedger/UsageLedgerProjectIdentity.swift +245 -0
- package/Sources/RunicCore/UsagePace.swift +81 -0
- package/Sources/RunicCore/Vendored/CostUsage/CostUsageCache.swift +73 -0
- package/Sources/RunicCore/Vendored/CostUsage/CostUsageJsonl.swift +74 -0
- package/Sources/RunicCore/Vendored/CostUsage/CostUsagePricing.swift +208 -0
- package/Sources/RunicCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +455 -0
- package/Sources/RunicCore/Vendored/CostUsage/CostUsageScanner+Timestamp.swift +132 -0
- package/Sources/RunicCore/Vendored/CostUsage/CostUsageScanner.swift +589 -0
- package/Sources/RunicCore/WidgetSnapshot.swift +183 -0
- package/Sources/RunicMacroSupport/ProviderRegistrationMacros.swift +14 -0
- package/Sources/RunicMacros/ProviderRegistrationMacros.swift +216 -0
- package/Sources/RunicWidget/RunicWidgetBundle.swift +79 -0
- package/Sources/RunicWidget/RunicWidgetProvider.swift +283 -0
- package/Sources/RunicWidget/RunicWidgetViews.swift +680 -0
- package/Tests/RunicTests/AntigravityStatusProbeTests.swift +50 -0
- package/Tests/RunicTests/AppDelegateTests.swift +43 -0
- package/Tests/RunicTests/BrowserCookieOrderLabelTests.swift +22 -0
- package/Tests/RunicTests/CLIArgumentParsingTests.swift +70 -0
- package/Tests/RunicTests/CLICostTests.swift +65 -0
- package/Tests/RunicTests/CLIProviderSelectionTests.swift +22 -0
- package/Tests/RunicTests/CLISnapshotTests.swift +72 -0
- package/Tests/RunicTests/CLIWebFallbackTests.swift +58 -0
- package/Tests/RunicTests/ClaudeOAuthTests.swift +164 -0
- package/Tests/RunicTests/ClaudeResilienceTests.swift +30 -0
- package/Tests/RunicTests/ClaudeUsageTests.swift +370 -0
- package/Tests/RunicTests/CodexOAuthTests.swift +122 -0
- package/Tests/RunicTests/CodexUsageLogSourceTests.swift +82 -0
- package/Tests/RunicTests/CopilotTokenResolverTests.swift +75 -0
- package/Tests/RunicTests/CopilotVSCodeTokenReaderTests.swift +75 -0
- package/Tests/RunicTests/CostUsageDecodingTests.swift +394 -0
- package/Tests/RunicTests/CostUsagePricingTests.swift +50 -0
- package/Tests/RunicTests/CostUsageScannerTests.swift +700 -0
- package/Tests/RunicTests/CursorStatusProbeTests.swift +323 -0
- package/Tests/RunicTests/FactoryStatusProbeTests.swift +69 -0
- package/Tests/RunicTests/GeminiAPITestHelpers.swift +94 -0
- package/Tests/RunicTests/GeminiLoginAlertTests.swift +28 -0
- package/Tests/RunicTests/GeminiStatusProbeAPITests.swift +283 -0
- package/Tests/RunicTests/GeminiStatusProbePlanTests.swift +279 -0
- package/Tests/RunicTests/GeminiStatusProbeTests.swift +233 -0
- package/Tests/RunicTests/GeminiTestEnvironment.swift +87 -0
- package/Tests/RunicTests/GoogleWorkspaceStatusTests.swift +73 -0
- package/Tests/RunicTests/InstallOriginTests.swift +18 -0
- package/Tests/RunicTests/KeyboardShortcutsBundleTests.swift +9 -0
- package/Tests/RunicTests/LiveAccountTests.swift +24 -0
- package/Tests/RunicTests/LoadingPatternTests.swift +35 -0
- package/Tests/RunicTests/MenuCardModelTests.swift +545 -0
- package/Tests/RunicTests/MiniMaxCookieHeaderStoreTestSupport.swift +8 -0
- package/Tests/RunicTests/MiniMaxGroupIDStoreTestSupport.swift +8 -0
- package/Tests/RunicTests/MiniMaxParsingTests.swift +68 -0
- package/Tests/RunicTests/MiniMaxTokenStoreTestSupport.swift +8 -0
- package/Tests/RunicTests/OTelGenAILedgerAdapterTests.swift +112 -0
- package/Tests/RunicTests/OpenAIDashboardBrowserCookieImporterTests.swift +17 -0
- package/Tests/RunicTests/OpenAIDashboardFetcherCreditsWaitTests.swift +75 -0
- package/Tests/RunicTests/OpenAIDashboardOffscreenHostTests.swift +27 -0
- package/Tests/RunicTests/OpenAIDashboardParserTests.swift +134 -0
- package/Tests/RunicTests/OpenAIWebAccountSwitchTests.swift +53 -0
- package/Tests/RunicTests/OpenRouterTokenStoreTestSupport.swift +92 -0
- package/Tests/RunicTests/PathBuilderTests.swift +237 -0
- package/Tests/RunicTests/ProviderFetchPipelineTests.swift +160 -0
- package/Tests/RunicTests/ProviderIconResourcesTests.swift +38 -0
- package/Tests/RunicTests/ProviderMetadataStatusLinkTests.swift +16 -0
- package/Tests/RunicTests/ProviderRegistryTests.swift +29 -0
- package/Tests/RunicTests/ProviderSettingsDescriptorTests.swift +202 -0
- package/Tests/RunicTests/ProviderToggleStoreTests.swift +51 -0
- package/Tests/RunicTests/RunicTests.swift +120 -0
- package/Tests/RunicTests/SessionQuotaNotificationLogicTests.swift +36 -0
- package/Tests/RunicTests/SettingsStoreTests.swift +161 -0
- package/Tests/RunicTests/StatusItemAnimationTests.swift +49 -0
- package/Tests/RunicTests/StatusMenuTests.swift +434 -0
- package/Tests/RunicTests/StatusProbeTests.swift +377 -0
- package/Tests/RunicTests/SubprocessRunnerTests.swift +20 -0
- package/Tests/RunicTests/SubscriptionDetectionTests.swift +65 -0
- package/Tests/RunicTests/TTYCommandRunnerTests.swift +148 -0
- package/Tests/RunicTests/TTYIntegrationTests.swift +41 -0
- package/Tests/RunicTests/TeamShowbackExporterTests.swift +159 -0
- package/Tests/RunicTests/TextParsingTests.swift +12 -0
- package/Tests/RunicTests/UsageFormatterTests.swift +112 -0
- package/Tests/RunicTests/UsageLedgerAnomalyDetectionTests.swift +172 -0
- package/Tests/RunicTests/UsageLedgerForecastTests.swift +213 -0
- package/Tests/RunicTests/UsageLedgerInsightsAdvisorTests.swift +83 -0
- package/Tests/RunicTests/UsageLedgerProjectNameTests.swift +170 -0
- package/Tests/RunicTests/UsagePaceTests.swift +77 -0
- package/Tests/RunicTests/UsagePaceTextTests.swift +109 -0
- package/Tests/RunicTests/UsagePolicyEngineTests.swift +91 -0
- package/Tests/RunicTests/WidgetSnapshotTests.swift +45 -0
- package/Tests/RunicTests/ZaiProviderTests.swift +61 -0
- package/Tests/RunicTests/ZaiTokenStoreTestSupport.swift +6 -0
- package/TestsLinux/PlatformGatingTests.swift +46 -0
- package/appcast.xml +105 -0
- package/assets/logo-concept-stack.svg +24 -0
- package/assets/logo-minimal-bars.svg +15 -0
- package/assets/logo.svg +20 -0
- package/assets/logo2.svg +43 -0
- package/bin/docs-list +5 -0
- package/bin/install-runic-cli.sh +32 -0
- package/build-and-run.sh +369 -0
- package/docs/.nojekyll +1 -0
- package/docs/CNAME +1 -0
- package/docs/antigravity.md +75 -0
- package/docs/architecture.md +32 -0
- package/docs/claude.md +93 -0
- package/docs/cli-commands/integration_guide.md +371 -0
- package/docs/cli-commands/quick_reference.md +362 -0
- package/docs/cli-commands/readme.md +361 -0
- package/docs/cli.md +156 -0
- package/docs/cloudkit_sync_guide.md +495 -0
- package/docs/codebase_review.md +288 -0
- package/docs/codex-oauth.md +635 -0
- package/docs/codex.md +104 -0
- package/docs/codexbar.png +0 -0
- package/docs/copilot.md +44 -0
- package/docs/cursor.md +46 -0
- package/docs/design/designcomparisonchart.md +510 -0
- package/docs/design/implementationguide.md +792 -0
- package/docs/design/menubaricondesignconcepts.md +796 -0
- package/docs/design/mockups/hybridiconconcept.svg +62 -0
- package/docs/design/mockups/infinityiconconcept.svg +41 -0
- package/docs/design/mockups/infinityiconstates.svg +129 -0
- package/docs/design/mockups/wavelogorefined.svg +48 -0
- package/docs/design/quick_summary.md +426 -0
- package/docs/design/readme.md +478 -0
- package/docs/design/sizespecifications.md +575 -0
- package/docs/design-system.md +836 -0
- package/docs/factory.md +103 -0
- package/docs/gemini.md +73 -0
- package/docs/icon.md +37 -0
- package/docs/icon.png +0 -0
- package/docs/index.html +175 -0
- package/docs/keychain_fix.md +274 -0
- package/docs/minimax.md +71 -0
- package/docs/packaging.md +25 -0
- package/docs/progress-bar-improvements.md +240 -0
- package/docs/progress-bar-quick-reference.md +283 -0
- package/docs/progress-bar-technical-details.md +432 -0
- package/docs/progress-bar-visual-test.swift +375 -0
- package/docs/provider.md +202 -0
- package/docs/providers.md +86 -0
- package/docs/refactor/macros.md +35 -0
- package/docs/refresh-loop.md +22 -0
- package/docs/releasing-homebrew.md +35 -0
- package/docs/releasing.md +121 -0
- package/docs/research/concrete_todo_2026-02-22.md +47 -0
- package/docs/research/core-feature-research.md +248 -0
- package/docs/research/feature_uiux_roadmap_2026-02-22.md +202 -0
- package/docs/research/parity_matrix.md +49 -0
- package/docs/research/provider_expansion_candidates_2026-02-22.md +80 -0
- package/docs/research/provider_model_token_project_matrix_2026-02-23.md +46 -0
- package/docs/research/release_gate_checklist.md +57 -0
- package/docs/site.css +505 -0
- package/docs/sparkle.md +28 -0
- package/docs/status.md +28 -0
- package/docs/sync/implementation.md +393 -0
- package/docs/sync/readme.md +317 -0
- package/docs/sync_files_summary.md +391 -0
- package/docs/sync_quick_reference.md +225 -0
- package/docs/todo.md +11 -0
- package/docs/ui.md +29 -0
- package/docs/web-integration.md +9 -0
- package/docs/widgets.md +85 -0
- package/docs/workflows/error-handling-improvements.md +434 -0
- package/docs/workflows/folder_structure.md +299 -0
- package/docs/workflows/loading_states.md +446 -0
- package/docs/workflows/loading_states_examples.md +565 -0
- package/docs/workflows/loading_states_flow.md +430 -0
- package/docs/workflows/performance_optimizations.md +457 -0
- package/docs/workflows/phase1-cli-testing-report.md +313 -0
- package/docs/workflows/quick_reference.md +228 -0
- package/docs/workflows/readme.md +59 -0
- package/docs/workflows/recent_improvements.md +275 -0
- package/docs/workflows/sync_enablement_summary.md +400 -0
- package/docs/zai.md +43 -0
- package/package.json +36 -0
- package/runic.png +0 -0
- package/version.env +2 -0
|
@@ -0,0 +1,1275 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Silo
|
|
3
|
+
|
|
4
|
+
#if os(macOS)
|
|
5
|
+
|
|
6
|
+
private let factoryCookieImportOrder: BrowserCookieImportOrder =
|
|
7
|
+
ProviderDefaults.metadata[.factory]?.browserCookieOrder ?? Browser.defaultImportOrder
|
|
8
|
+
|
|
9
|
+
// MARK: - Factory Cookie Importer
|
|
10
|
+
|
|
11
|
+
/// Imports Factory session cookies from browser cookies.
|
|
12
|
+
public enum FactoryCookieImporter {
|
|
13
|
+
private static let cookieClient = BrowserCookieClient()
|
|
14
|
+
private static let sessionCookieNames: Set<String> = [
|
|
15
|
+
"wos-session",
|
|
16
|
+
"__Secure-next-auth.session-token",
|
|
17
|
+
"next-auth.session-token",
|
|
18
|
+
"__Secure-authjs.session-token",
|
|
19
|
+
"__Host-authjs.csrf-token",
|
|
20
|
+
"authjs.session-token",
|
|
21
|
+
"session",
|
|
22
|
+
"access-token",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
private static let authSessionCookieNames: Set<String> = [
|
|
26
|
+
"__Secure-next-auth.session-token",
|
|
27
|
+
"next-auth.session-token",
|
|
28
|
+
"__Secure-authjs.session-token",
|
|
29
|
+
"authjs.session-token",
|
|
30
|
+
]
|
|
31
|
+
private static let appBaseURL = URL(string: "https://app.factory.ai")!
|
|
32
|
+
private static let authBaseURL = URL(string: "https://auth.factory.ai")!
|
|
33
|
+
private static let apiBaseURL = URL(string: "https://api.factory.ai")!
|
|
34
|
+
|
|
35
|
+
public struct SessionInfo: Sendable {
|
|
36
|
+
public let cookies: [HTTPCookie]
|
|
37
|
+
public let sourceLabel: String
|
|
38
|
+
|
|
39
|
+
public init(cookies: [HTTPCookie], sourceLabel: String) {
|
|
40
|
+
self.cookies = cookies
|
|
41
|
+
self.sourceLabel = sourceLabel
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
public var cookieHeader: String {
|
|
45
|
+
self.cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ")
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/// Returns all Factory sessions across supported browsers.
|
|
50
|
+
public static func importSessions(logger: ((String) -> Void)? = nil) throws -> [SessionInfo] {
|
|
51
|
+
let log: (String) -> Void = { msg in logger?("[factory-cookie] \(msg)") }
|
|
52
|
+
var sessions: [SessionInfo] = []
|
|
53
|
+
|
|
54
|
+
for browserSource in factoryCookieImportOrder {
|
|
55
|
+
do {
|
|
56
|
+
let perSource = try self.importSessions(from: browserSource, logger: logger)
|
|
57
|
+
sessions.append(contentsOf: perSource)
|
|
58
|
+
} catch {
|
|
59
|
+
log("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)")
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
guard !sessions.isEmpty else {
|
|
64
|
+
throw FactoryStatusProbeError.noSessionCookie
|
|
65
|
+
}
|
|
66
|
+
return sessions
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
public static func importSessions(
|
|
70
|
+
from browserSource: Browser,
|
|
71
|
+
logger: ((String) -> Void)? = nil) throws -> [SessionInfo]
|
|
72
|
+
{
|
|
73
|
+
let log: (String) -> Void = { msg in logger?("[factory-cookie] \(msg)") }
|
|
74
|
+
let cookieDomains = ["factory.ai", "app.factory.ai", "auth.factory.ai"]
|
|
75
|
+
let query = BrowserCookieQuery(domains: cookieDomains)
|
|
76
|
+
let sources = try Self.cookieClient.records(
|
|
77
|
+
matching: query,
|
|
78
|
+
in: browserSource)
|
|
79
|
+
|
|
80
|
+
var sessions: [SessionInfo] = []
|
|
81
|
+
for source in sources where !source.records.isEmpty {
|
|
82
|
+
let httpCookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin)
|
|
83
|
+
if httpCookies.contains(where: { Self.sessionCookieNames.contains($0.name) }) {
|
|
84
|
+
log("Found \(httpCookies.count) Factory cookies in \(source.label)")
|
|
85
|
+
log("\(source.label) cookie names: \(self.cookieNames(from: httpCookies))")
|
|
86
|
+
if let token = httpCookies.first(where: { $0.name == "access-token" })?.value {
|
|
87
|
+
let hint = token.contains(".") ? "jwt" : "opaque"
|
|
88
|
+
log("\(source.label) access-token cookie: \(token.count) chars (\(hint))")
|
|
89
|
+
}
|
|
90
|
+
if let token = httpCookies.first(where: { self.authSessionCookieNames.contains($0.name) })?.value {
|
|
91
|
+
let hint = token.contains(".") ? "jwt" : "opaque"
|
|
92
|
+
log("\(source.label) session cookie: \(token.count) chars (\(hint))")
|
|
93
|
+
}
|
|
94
|
+
sessions.append(SessionInfo(cookies: httpCookies, sourceLabel: source.label))
|
|
95
|
+
} else {
|
|
96
|
+
log("\(source.label) cookies found, but no Factory session cookie present")
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return sessions
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/// Attempts to import Factory cookies using the standard browser import order.
|
|
103
|
+
public static func importSession(logger: ((String) -> Void)? = nil) throws -> SessionInfo {
|
|
104
|
+
let sessions = try self.importSessions(logger: logger)
|
|
105
|
+
guard let first = sessions.first else {
|
|
106
|
+
throw FactoryStatusProbeError.noSessionCookie
|
|
107
|
+
}
|
|
108
|
+
return first
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/// Check if Factory session cookies are available
|
|
112
|
+
public static func hasSession(logger: ((String) -> Void)? = nil) -> Bool {
|
|
113
|
+
do {
|
|
114
|
+
return try !(self.importSessions(logger: logger)).isEmpty
|
|
115
|
+
} catch {
|
|
116
|
+
return false
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private static func cookieNames(from cookies: [HTTPCookie]) -> String {
|
|
121
|
+
let names = Set(cookies.map { "\($0.name)@\($0.domain)" }).sorted()
|
|
122
|
+
return names.joined(separator: ", ")
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// MARK: - Factory API Models
|
|
127
|
+
|
|
128
|
+
public struct FactoryAuthResponse: Codable, Sendable {
|
|
129
|
+
public let featureFlags: FactoryFeatureFlags?
|
|
130
|
+
public let organization: FactoryOrganization?
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
public struct FactoryFeatureFlags: Codable, Sendable {
|
|
134
|
+
public let flags: [String: Bool]?
|
|
135
|
+
public let configs: [String: AnyCodable]?
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
public struct FactoryOrganization: Codable, Sendable {
|
|
139
|
+
public let id: String?
|
|
140
|
+
public let name: String?
|
|
141
|
+
public let subscription: FactorySubscription?
|
|
142
|
+
|
|
143
|
+
enum CodingKeys: String, CodingKey {
|
|
144
|
+
case id
|
|
145
|
+
case name
|
|
146
|
+
case subscription
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
public struct FactorySubscription: Codable, Sendable {
|
|
151
|
+
public let factoryTier: String?
|
|
152
|
+
public let orbSubscription: FactoryOrbSubscription?
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
public struct FactoryOrbSubscription: Codable, Sendable {
|
|
156
|
+
public let plan: FactoryPlan?
|
|
157
|
+
public let status: String?
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
public struct FactoryPlan: Codable, Sendable {
|
|
161
|
+
public let name: String?
|
|
162
|
+
public let id: String?
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
public struct FactoryUsageResponse: Codable, Sendable {
|
|
166
|
+
public let usage: FactoryUsageData?
|
|
167
|
+
public let source: String?
|
|
168
|
+
public let userId: String?
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
public struct FactoryUsageData: Codable, Sendable {
|
|
172
|
+
public let startDate: Int64?
|
|
173
|
+
public let endDate: Int64?
|
|
174
|
+
public let standard: FactoryTokenUsage?
|
|
175
|
+
public let premium: FactoryTokenUsage?
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
public struct FactoryTokenUsage: Codable, Sendable {
|
|
179
|
+
public let userTokens: Int64?
|
|
180
|
+
public let orgTotalTokensUsed: Int64?
|
|
181
|
+
public let totalAllowance: Int64?
|
|
182
|
+
public let usedRatio: Double?
|
|
183
|
+
public let orgOverageUsed: Int64?
|
|
184
|
+
public let basicAllowance: Int64?
|
|
185
|
+
public let orgOverageLimit: Int64?
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/// Helper for encoding arbitrary JSON
|
|
189
|
+
public struct AnyCodable: Codable, Sendable {
|
|
190
|
+
public init(from decoder: Decoder) throws {
|
|
191
|
+
let container = try decoder.singleValueContainer()
|
|
192
|
+
if container.decodeNil() {
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
_ = try? container.decode([String: AnyCodable].self)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
public func encode(to encoder: Encoder) throws {
|
|
199
|
+
var container = encoder.singleValueContainer()
|
|
200
|
+
try container.encodeNil()
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// MARK: - Factory Status Snapshot
|
|
205
|
+
|
|
206
|
+
public struct FactoryStatusSnapshot: Sendable {
|
|
207
|
+
/// Standard token usage (user)
|
|
208
|
+
public let standardUserTokens: Int64
|
|
209
|
+
/// Standard token usage (org total)
|
|
210
|
+
public let standardOrgTokens: Int64
|
|
211
|
+
/// Standard token allowance
|
|
212
|
+
public let standardAllowance: Int64
|
|
213
|
+
/// Premium token usage (user)
|
|
214
|
+
public let premiumUserTokens: Int64
|
|
215
|
+
/// Premium token usage (org total)
|
|
216
|
+
public let premiumOrgTokens: Int64
|
|
217
|
+
/// Premium token allowance
|
|
218
|
+
public let premiumAllowance: Int64
|
|
219
|
+
/// Billing period start
|
|
220
|
+
public let periodStart: Date?
|
|
221
|
+
/// Billing period end
|
|
222
|
+
public let periodEnd: Date?
|
|
223
|
+
/// Plan name
|
|
224
|
+
public let planName: String?
|
|
225
|
+
/// Factory tier (enterprise, team, etc.)
|
|
226
|
+
public let tier: String?
|
|
227
|
+
/// Organization name
|
|
228
|
+
public let organizationName: String?
|
|
229
|
+
/// User email
|
|
230
|
+
public let accountEmail: String?
|
|
231
|
+
/// User ID
|
|
232
|
+
public let userId: String?
|
|
233
|
+
/// Raw JSON for debugging
|
|
234
|
+
public let rawJSON: String?
|
|
235
|
+
|
|
236
|
+
public init(
|
|
237
|
+
standardUserTokens: Int64,
|
|
238
|
+
standardOrgTokens: Int64,
|
|
239
|
+
standardAllowance: Int64,
|
|
240
|
+
premiumUserTokens: Int64,
|
|
241
|
+
premiumOrgTokens: Int64,
|
|
242
|
+
premiumAllowance: Int64,
|
|
243
|
+
periodStart: Date?,
|
|
244
|
+
periodEnd: Date?,
|
|
245
|
+
planName: String?,
|
|
246
|
+
tier: String?,
|
|
247
|
+
organizationName: String?,
|
|
248
|
+
accountEmail: String?,
|
|
249
|
+
userId: String?,
|
|
250
|
+
rawJSON: String?)
|
|
251
|
+
{
|
|
252
|
+
self.standardUserTokens = standardUserTokens
|
|
253
|
+
self.standardOrgTokens = standardOrgTokens
|
|
254
|
+
self.standardAllowance = standardAllowance
|
|
255
|
+
self.premiumUserTokens = premiumUserTokens
|
|
256
|
+
self.premiumOrgTokens = premiumOrgTokens
|
|
257
|
+
self.premiumAllowance = premiumAllowance
|
|
258
|
+
self.periodStart = periodStart
|
|
259
|
+
self.periodEnd = periodEnd
|
|
260
|
+
self.planName = planName
|
|
261
|
+
self.tier = tier
|
|
262
|
+
self.organizationName = organizationName
|
|
263
|
+
self.accountEmail = accountEmail
|
|
264
|
+
self.userId = userId
|
|
265
|
+
self.rawJSON = rawJSON
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/// Convert to UsageSnapshot for the common provider interface
|
|
269
|
+
public func toUsageSnapshot() -> UsageSnapshot {
|
|
270
|
+
// Primary: Standard tokens used (as percentage of allowance, capped reasonably)
|
|
271
|
+
let standardPercent = self.calculateUsagePercent(
|
|
272
|
+
used: self.standardUserTokens,
|
|
273
|
+
allowance: self.standardAllowance)
|
|
274
|
+
|
|
275
|
+
let primary = RateWindow(
|
|
276
|
+
usedPercent: standardPercent,
|
|
277
|
+
windowMinutes: nil,
|
|
278
|
+
resetsAt: self.periodEnd,
|
|
279
|
+
resetDescription: self.periodEnd.map { Self.formatResetDate($0) })
|
|
280
|
+
|
|
281
|
+
// Secondary: Premium tokens used
|
|
282
|
+
let premiumPercent = self.calculateUsagePercent(
|
|
283
|
+
used: self.premiumUserTokens,
|
|
284
|
+
allowance: self.premiumAllowance)
|
|
285
|
+
|
|
286
|
+
let secondary = RateWindow(
|
|
287
|
+
usedPercent: premiumPercent,
|
|
288
|
+
windowMinutes: nil,
|
|
289
|
+
resetsAt: self.periodEnd,
|
|
290
|
+
resetDescription: self.periodEnd.map { Self.formatResetDate($0) })
|
|
291
|
+
|
|
292
|
+
// Format login method as tier + plan
|
|
293
|
+
let loginMethod: String? = {
|
|
294
|
+
var parts: [String] = []
|
|
295
|
+
if let tier = self.tier, !tier.isEmpty {
|
|
296
|
+
parts.append("Factory \(tier.capitalized)")
|
|
297
|
+
}
|
|
298
|
+
if let plan = self.planName, !plan.isEmpty, !plan.lowercased().contains("factory") {
|
|
299
|
+
parts.append(plan)
|
|
300
|
+
}
|
|
301
|
+
return parts.isEmpty ? nil : parts.joined(separator: " - ")
|
|
302
|
+
}()
|
|
303
|
+
|
|
304
|
+
let identity = ProviderIdentitySnapshot(
|
|
305
|
+
providerID: .factory,
|
|
306
|
+
accountEmail: self.accountEmail,
|
|
307
|
+
accountOrganization: self.organizationName,
|
|
308
|
+
loginMethod: loginMethod)
|
|
309
|
+
return UsageSnapshot(
|
|
310
|
+
primary: primary,
|
|
311
|
+
secondary: secondary,
|
|
312
|
+
tertiary: nil,
|
|
313
|
+
providerCost: nil,
|
|
314
|
+
updatedAt: Date(),
|
|
315
|
+
identity: identity)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private func calculateUsagePercent(used: Int64, allowance: Int64) -> Double {
|
|
319
|
+
// Treat very large allowances (> 1 trillion) as unlimited
|
|
320
|
+
let unlimitedThreshold: Int64 = 1_000_000_000_000
|
|
321
|
+
if allowance > unlimitedThreshold {
|
|
322
|
+
// For unlimited, show a token count-based pseudo-percentage (capped at 100%)
|
|
323
|
+
// Use 100M tokens as a reference point for "100%"
|
|
324
|
+
let referenceTokens: Double = 100_000_000
|
|
325
|
+
return min(100, Double(used) / referenceTokens * 100)
|
|
326
|
+
}
|
|
327
|
+
guard allowance > 0 else { return 0 }
|
|
328
|
+
return min(100, Double(used) / Double(allowance) * 100)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private static func formatResetDate(_ date: Date) -> String {
|
|
332
|
+
let formatter = DateFormatter()
|
|
333
|
+
formatter.dateFormat = "MMM d 'at' h:mma"
|
|
334
|
+
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
335
|
+
return "Resets " + formatter.string(from: date)
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// MARK: - Factory Status Probe Error
|
|
340
|
+
|
|
341
|
+
public enum FactoryStatusProbeError: LocalizedError, Sendable {
|
|
342
|
+
case notLoggedIn
|
|
343
|
+
case networkError(String)
|
|
344
|
+
case parseFailed(String)
|
|
345
|
+
case noSessionCookie
|
|
346
|
+
|
|
347
|
+
public var errorDescription: String? {
|
|
348
|
+
switch self {
|
|
349
|
+
case .notLoggedIn:
|
|
350
|
+
"Not logged in to Factory. Please log in via the Runic menu."
|
|
351
|
+
case let .networkError(msg):
|
|
352
|
+
"Factory API error: \(msg)"
|
|
353
|
+
case let .parseFailed(msg):
|
|
354
|
+
"Could not parse Factory usage: \(msg)"
|
|
355
|
+
case .noSessionCookie:
|
|
356
|
+
"No Factory session found. Please log in to app.factory.ai in \(factoryCookieImportOrder.loginHint)."
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// MARK: - Factory Session Store
|
|
362
|
+
|
|
363
|
+
public actor FactorySessionStore {
|
|
364
|
+
public static let shared = FactorySessionStore()
|
|
365
|
+
|
|
366
|
+
private var sessionCookies: [HTTPCookie] = []
|
|
367
|
+
private var bearerToken: String?
|
|
368
|
+
private var refreshToken: String?
|
|
369
|
+
private let fileURL: URL
|
|
370
|
+
private var didLoadFromDisk = false
|
|
371
|
+
|
|
372
|
+
private init() {
|
|
373
|
+
let fm = FileManager.default
|
|
374
|
+
let appSupport = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
|
375
|
+
?? fm.temporaryDirectory
|
|
376
|
+
let dir = appSupport.appendingPathComponent("Runic", isDirectory: true)
|
|
377
|
+
try? fm.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
378
|
+
self.fileURL = dir.appendingPathComponent("factory-session.json")
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
public func setCookies(_ cookies: [HTTPCookie]) {
|
|
382
|
+
self.didLoadFromDisk = true
|
|
383
|
+
self.sessionCookies = cookies
|
|
384
|
+
self.saveToDisk()
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
public func getCookies() -> [HTTPCookie] {
|
|
388
|
+
self.loadFromDiskIfNeeded()
|
|
389
|
+
return self.sessionCookies
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
public func setBearerToken(_ token: String?) {
|
|
393
|
+
self.didLoadFromDisk = true
|
|
394
|
+
self.bearerToken = token
|
|
395
|
+
self.saveToDisk()
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
public func getBearerToken() -> String? {
|
|
399
|
+
self.loadFromDiskIfNeeded()
|
|
400
|
+
return self.bearerToken
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
public func setRefreshToken(_ token: String?) {
|
|
404
|
+
self.didLoadFromDisk = true
|
|
405
|
+
self.refreshToken = token
|
|
406
|
+
self.saveToDisk()
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
public func getRefreshToken() -> String? {
|
|
410
|
+
self.loadFromDiskIfNeeded()
|
|
411
|
+
return self.refreshToken
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
public func clearSession() {
|
|
415
|
+
self.didLoadFromDisk = true
|
|
416
|
+
self.sessionCookies = []
|
|
417
|
+
self.bearerToken = nil
|
|
418
|
+
self.refreshToken = nil
|
|
419
|
+
try? FileManager.default.removeItem(at: self.fileURL)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
public func hasValidSession() -> Bool {
|
|
423
|
+
self.loadFromDiskIfNeeded()
|
|
424
|
+
return !self.sessionCookies.isEmpty || self.bearerToken != nil || self.refreshToken != nil
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
private func saveToDisk() {
|
|
428
|
+
let cookieData = self.sessionCookies.compactMap { cookie -> [String: Any]? in
|
|
429
|
+
guard let props = cookie.properties else { return nil }
|
|
430
|
+
var serializable: [String: Any] = [:]
|
|
431
|
+
for (key, value) in props {
|
|
432
|
+
let keyString = key.rawValue
|
|
433
|
+
if let date = value as? Date {
|
|
434
|
+
serializable[keyString] = date.timeIntervalSince1970
|
|
435
|
+
serializable[keyString + "_isDate"] = true
|
|
436
|
+
} else if let url = value as? URL {
|
|
437
|
+
serializable[keyString] = url.absoluteString
|
|
438
|
+
serializable[keyString + "_isURL"] = true
|
|
439
|
+
} else if JSONSerialization.isValidJSONObject([value]) ||
|
|
440
|
+
value is String ||
|
|
441
|
+
value is Bool ||
|
|
442
|
+
value is NSNumber
|
|
443
|
+
{
|
|
444
|
+
serializable[keyString] = value
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return serializable
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
var payload: [String: Any] = [:]
|
|
451
|
+
if !cookieData.isEmpty {
|
|
452
|
+
payload["cookies"] = cookieData
|
|
453
|
+
}
|
|
454
|
+
if let bearerToken {
|
|
455
|
+
payload["bearerToken"] = bearerToken
|
|
456
|
+
}
|
|
457
|
+
if let refreshToken {
|
|
458
|
+
payload["refreshToken"] = refreshToken
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
guard !payload.isEmpty,
|
|
462
|
+
let data = try? JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted])
|
|
463
|
+
else {
|
|
464
|
+
return
|
|
465
|
+
}
|
|
466
|
+
try? data.write(to: self.fileURL)
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
private func loadFromDisk() {
|
|
470
|
+
guard let data = try? Data(contentsOf: self.fileURL),
|
|
471
|
+
let json = try? JSONSerialization.jsonObject(with: data)
|
|
472
|
+
else { return }
|
|
473
|
+
|
|
474
|
+
var cookieArray: [[String: Any]] = []
|
|
475
|
+
if let dict = json as? [String: Any] {
|
|
476
|
+
if let stored = dict["cookies"] as? [[String: Any]] {
|
|
477
|
+
cookieArray = stored
|
|
478
|
+
}
|
|
479
|
+
self.bearerToken = dict["bearerToken"] as? String
|
|
480
|
+
self.refreshToken = dict["refreshToken"] as? String
|
|
481
|
+
} else if let stored = json as? [[String: Any]] {
|
|
482
|
+
cookieArray = stored
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
self.sessionCookies = cookieArray.compactMap { props in
|
|
486
|
+
var cookieProps: [HTTPCookiePropertyKey: Any] = [:]
|
|
487
|
+
for (key, value) in props {
|
|
488
|
+
if key.hasSuffix("_isDate") || key.hasSuffix("_isURL") { continue }
|
|
489
|
+
|
|
490
|
+
let propKey = HTTPCookiePropertyKey(key)
|
|
491
|
+
|
|
492
|
+
if props[key + "_isDate"] as? Bool == true, let interval = value as? TimeInterval {
|
|
493
|
+
cookieProps[propKey] = Date(timeIntervalSince1970: interval)
|
|
494
|
+
} else if props[key + "_isURL"] as? Bool == true, let urlString = value as? String {
|
|
495
|
+
cookieProps[propKey] = URL(string: urlString)
|
|
496
|
+
} else {
|
|
497
|
+
cookieProps[propKey] = value
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
return HTTPCookie(properties: cookieProps)
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
private func loadFromDiskIfNeeded() {
|
|
505
|
+
guard !self.didLoadFromDisk else { return }
|
|
506
|
+
self.didLoadFromDisk = true
|
|
507
|
+
self.loadFromDisk()
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// MARK: - Factory Status Probe
|
|
512
|
+
|
|
513
|
+
public struct FactoryStatusProbe: Sendable {
|
|
514
|
+
public let baseURL: URL
|
|
515
|
+
public var timeout: TimeInterval = 15.0
|
|
516
|
+
private static let staleTokenCookieNames: Set<String> = [
|
|
517
|
+
"access-token",
|
|
518
|
+
"__recent_auth",
|
|
519
|
+
]
|
|
520
|
+
private static let sessionCookieNames: Set<String> = [
|
|
521
|
+
"session",
|
|
522
|
+
"wos-session",
|
|
523
|
+
]
|
|
524
|
+
private static let authSessionCookieNames: Set<String> = [
|
|
525
|
+
"__Secure-next-auth.session-token",
|
|
526
|
+
"next-auth.session-token",
|
|
527
|
+
"__Secure-authjs.session-token",
|
|
528
|
+
"authjs.session-token",
|
|
529
|
+
]
|
|
530
|
+
static let appBaseURL = URL(string: "https://app.factory.ai")!
|
|
531
|
+
static let authBaseURL = URL(string: "https://auth.factory.ai")!
|
|
532
|
+
static let apiBaseURL = URL(string: "https://api.factory.ai")!
|
|
533
|
+
private static let workosClientIDs = [
|
|
534
|
+
"client_01HXRMBQ9BJ3E7QSTQ9X2PHVB7",
|
|
535
|
+
"client_01HNM792M5G5G1A2THWPXKFMXB",
|
|
536
|
+
]
|
|
537
|
+
|
|
538
|
+
private struct WorkOSAuthResponse: Decodable, Sendable {
|
|
539
|
+
let access_token: String
|
|
540
|
+
let refresh_token: String?
|
|
541
|
+
let organization_id: String?
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
public init(baseURL: URL = URL(string: "https://app.factory.ai")!, timeout: TimeInterval = 15.0) {
|
|
545
|
+
self.baseURL = baseURL
|
|
546
|
+
self.timeout = timeout
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/// Fetch Factory usage using browser cookies with fallback to stored session.
|
|
550
|
+
public func fetch(logger: ((String) -> Void)? = nil) async throws -> FactoryStatusSnapshot {
|
|
551
|
+
let log: (String) -> Void = { msg in logger?("[factory] \(msg)") }
|
|
552
|
+
var lastError: Error?
|
|
553
|
+
|
|
554
|
+
let attempts: [FetchAttemptResult] = await [
|
|
555
|
+
self.attemptBrowserCookies(logger: log, sources: [.safari]),
|
|
556
|
+
self.attemptStoredCookies(logger: log),
|
|
557
|
+
self.attemptStoredBearer(logger: log),
|
|
558
|
+
self.attemptStoredRefreshToken(logger: log),
|
|
559
|
+
self.attemptLocalStorageTokens(logger: log),
|
|
560
|
+
self.attemptWorkOSCookies(logger: log, sources: [.safari]),
|
|
561
|
+
self.attemptBrowserCookies(logger: log, sources: [.chrome, .firefox]),
|
|
562
|
+
self.attemptWorkOSCookies(logger: log, sources: [.chrome, .firefox]),
|
|
563
|
+
]
|
|
564
|
+
|
|
565
|
+
for result in attempts {
|
|
566
|
+
switch result {
|
|
567
|
+
case let .success(snapshot):
|
|
568
|
+
return snapshot
|
|
569
|
+
case let .failure(error):
|
|
570
|
+
lastError = error
|
|
571
|
+
case .skipped:
|
|
572
|
+
continue
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if let lastError { throw lastError }
|
|
577
|
+
throw FactoryStatusProbeError.noSessionCookie
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
private enum FetchAttemptResult: Sendable {
|
|
581
|
+
case success(FactoryStatusSnapshot)
|
|
582
|
+
case failure(Error)
|
|
583
|
+
case skipped
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
private func attemptBrowserCookies(
|
|
587
|
+
logger: @escaping (String) -> Void,
|
|
588
|
+
sources: [Browser]) async -> FetchAttemptResult
|
|
589
|
+
{
|
|
590
|
+
do {
|
|
591
|
+
var lastError: Error?
|
|
592
|
+
for browserSource in sources {
|
|
593
|
+
let sessions = try FactoryCookieImporter.importSessions(from: browserSource, logger: logger)
|
|
594
|
+
for session in sessions {
|
|
595
|
+
logger("Using cookies from \(session.sourceLabel)")
|
|
596
|
+
do {
|
|
597
|
+
let snapshot = try await self.fetchWithCookies(session.cookies, logger: logger)
|
|
598
|
+
await FactorySessionStore.shared.setCookies(session.cookies)
|
|
599
|
+
return .success(snapshot)
|
|
600
|
+
} catch {
|
|
601
|
+
lastError = error
|
|
602
|
+
logger("Browser session fetch failed for \(session.sourceLabel): \(error.localizedDescription)")
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
if let lastError { return .failure(lastError) }
|
|
607
|
+
return .skipped
|
|
608
|
+
} catch {
|
|
609
|
+
logger("Browser cookie import failed: \(error.localizedDescription)")
|
|
610
|
+
return .failure(error)
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
private func attemptStoredCookies(logger: (String) -> Void) async -> FetchAttemptResult {
|
|
615
|
+
let storedCookies = await FactorySessionStore.shared.getCookies()
|
|
616
|
+
guard !storedCookies.isEmpty else { return .skipped }
|
|
617
|
+
logger("Using stored session cookies")
|
|
618
|
+
do {
|
|
619
|
+
return try await .success(self.fetchWithCookies(storedCookies, logger: logger))
|
|
620
|
+
} catch {
|
|
621
|
+
if case FactoryStatusProbeError.notLoggedIn = error {
|
|
622
|
+
await FactorySessionStore.shared.clearSession()
|
|
623
|
+
logger("Stored session invalid, cleared")
|
|
624
|
+
} else {
|
|
625
|
+
logger("Stored session failed: \(error.localizedDescription)")
|
|
626
|
+
}
|
|
627
|
+
return .failure(error)
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
private func attemptStoredBearer(logger: (String) -> Void) async -> FetchAttemptResult {
|
|
632
|
+
guard let bearerToken = await FactorySessionStore.shared.getBearerToken() else { return .skipped }
|
|
633
|
+
logger("Using stored Factory bearer token")
|
|
634
|
+
do {
|
|
635
|
+
return try await .success(self.fetchWithBearerToken(bearerToken, logger: logger))
|
|
636
|
+
} catch {
|
|
637
|
+
return .failure(error)
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
private func attemptStoredRefreshToken(logger: (String) -> Void) async -> FetchAttemptResult {
|
|
642
|
+
guard let refreshToken = await FactorySessionStore.shared.getRefreshToken(),
|
|
643
|
+
!refreshToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
644
|
+
else {
|
|
645
|
+
return .skipped
|
|
646
|
+
}
|
|
647
|
+
logger("Using stored WorkOS refresh token")
|
|
648
|
+
do {
|
|
649
|
+
return try await .success(self.fetchWithWorkOSRefreshToken(
|
|
650
|
+
refreshToken,
|
|
651
|
+
organizationID: nil,
|
|
652
|
+
logger: logger))
|
|
653
|
+
} catch {
|
|
654
|
+
if self.isInvalidGrant(error) {
|
|
655
|
+
await FactorySessionStore.shared.setRefreshToken(nil)
|
|
656
|
+
} else if case FactoryStatusProbeError.noSessionCookie = error {
|
|
657
|
+
await FactorySessionStore.shared.setRefreshToken(nil)
|
|
658
|
+
}
|
|
659
|
+
return .failure(error)
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
private func attemptLocalStorageTokens(logger: @escaping (String) -> Void) async -> FetchAttemptResult {
|
|
664
|
+
let workosTokens = FactoryLocalStorageImporter.importWorkOSTokens(logger: logger)
|
|
665
|
+
guard !workosTokens.isEmpty else { return .skipped }
|
|
666
|
+
var lastError: Error?
|
|
667
|
+
for token in workosTokens {
|
|
668
|
+
guard !token.refreshToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
669
|
+
continue
|
|
670
|
+
}
|
|
671
|
+
logger("Using WorkOS refresh token from \(token.sourceLabel)")
|
|
672
|
+
if let accessToken = token.accessToken {
|
|
673
|
+
do {
|
|
674
|
+
await FactorySessionStore.shared.setBearerToken(accessToken)
|
|
675
|
+
return try await .success(self.fetchWithBearerToken(accessToken, logger: logger))
|
|
676
|
+
} catch {
|
|
677
|
+
lastError = error
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
do {
|
|
681
|
+
return try await .success(self.fetchWithWorkOSRefreshToken(
|
|
682
|
+
token.refreshToken,
|
|
683
|
+
organizationID: token.organizationID,
|
|
684
|
+
logger: logger))
|
|
685
|
+
} catch {
|
|
686
|
+
if self.isInvalidGrant(error) {
|
|
687
|
+
await FactorySessionStore.shared.setRefreshToken(nil)
|
|
688
|
+
}
|
|
689
|
+
lastError = error
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
if let lastError { return .failure(lastError) }
|
|
693
|
+
return .skipped
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
private func attemptWorkOSCookies(
|
|
697
|
+
logger: @escaping (String) -> Void,
|
|
698
|
+
sources: [Browser]) async -> FetchAttemptResult
|
|
699
|
+
{
|
|
700
|
+
let log: (String) -> Void = { msg in logger("[factory-workos] \(msg)") }
|
|
701
|
+
var lastError: Error?
|
|
702
|
+
|
|
703
|
+
for browserSource in sources {
|
|
704
|
+
do {
|
|
705
|
+
let query = BrowserCookieQuery(domains: ["workos.com"])
|
|
706
|
+
let sources = try BrowserCookieClient().records(
|
|
707
|
+
matching: query,
|
|
708
|
+
in: browserSource)
|
|
709
|
+
for source in sources where !source.records.isEmpty {
|
|
710
|
+
let cookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin)
|
|
711
|
+
guard !cookies.isEmpty else { continue }
|
|
712
|
+
log("Using WorkOS cookies from \(source.label)")
|
|
713
|
+
do {
|
|
714
|
+
let auth = try await self.fetchWorkOSAccessTokenWithCookies(
|
|
715
|
+
cookies: cookies,
|
|
716
|
+
logger: logger)
|
|
717
|
+
await FactorySessionStore.shared.setBearerToken(auth.access_token)
|
|
718
|
+
if let refreshToken = auth.refresh_token {
|
|
719
|
+
await FactorySessionStore.shared.setRefreshToken(refreshToken)
|
|
720
|
+
}
|
|
721
|
+
return try await .success(self.fetchWithBearerToken(auth.access_token, logger: logger))
|
|
722
|
+
} catch {
|
|
723
|
+
lastError = error
|
|
724
|
+
log("WorkOS cookie auth failed for \(source.label): \(error.localizedDescription)")
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
} catch {
|
|
728
|
+
log("\(browserSource.displayName) WorkOS cookie import failed: \(error.localizedDescription)")
|
|
729
|
+
lastError = error
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if let lastError { return .failure(lastError) }
|
|
734
|
+
return .skipped
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
private func fetchWithWorkOSRefreshToken(
|
|
738
|
+
_ refreshToken: String,
|
|
739
|
+
organizationID: String?,
|
|
740
|
+
logger: (String) -> Void) async throws -> FactoryStatusSnapshot
|
|
741
|
+
{
|
|
742
|
+
let auth = try await self.fetchWorkOSAccessToken(
|
|
743
|
+
refreshToken: refreshToken,
|
|
744
|
+
organizationID: organizationID)
|
|
745
|
+
await FactorySessionStore.shared.setBearerToken(auth.access_token)
|
|
746
|
+
if let newRefresh = auth.refresh_token {
|
|
747
|
+
await FactorySessionStore.shared.setRefreshToken(newRefresh)
|
|
748
|
+
}
|
|
749
|
+
return try await self.fetchWithBearerToken(auth.access_token, logger: logger)
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
private func fetchWithCookies(
|
|
753
|
+
_ cookies: [HTTPCookie],
|
|
754
|
+
logger: (String) -> Void) async throws -> FactoryStatusSnapshot
|
|
755
|
+
{
|
|
756
|
+
let candidates = Self.baseURLCandidates(default: self.baseURL, cookies: cookies)
|
|
757
|
+
var lastError: Error?
|
|
758
|
+
|
|
759
|
+
for baseURL in candidates {
|
|
760
|
+
if baseURL != self.baseURL {
|
|
761
|
+
logger("Trying Factory base URL: \(baseURL.host ?? baseURL.absoluteString)")
|
|
762
|
+
}
|
|
763
|
+
do {
|
|
764
|
+
return try await self.fetchWithCookies(cookies, baseURL: baseURL, logger: logger)
|
|
765
|
+
} catch {
|
|
766
|
+
lastError = error
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
if let lastError { throw lastError }
|
|
771
|
+
throw FactoryStatusProbeError.noSessionCookie
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
private func fetchWithCookies(
|
|
775
|
+
_ cookies: [HTTPCookie],
|
|
776
|
+
baseURL: URL,
|
|
777
|
+
logger: (String) -> Void) async throws -> FactoryStatusSnapshot
|
|
778
|
+
{
|
|
779
|
+
let header = Self.cookieHeader(from: cookies)
|
|
780
|
+
let bearerToken = Self.bearerToken(from: cookies)
|
|
781
|
+
do {
|
|
782
|
+
return try await self.fetchWithCookieHeader(header, bearerToken: bearerToken, baseURL: baseURL)
|
|
783
|
+
} catch let error as FactoryStatusProbeError {
|
|
784
|
+
if case .notLoggedIn = error, bearerToken != nil {
|
|
785
|
+
logger("Retrying without Authorization header")
|
|
786
|
+
return try await self.fetchWithCookieHeader(header, bearerToken: nil, baseURL: baseURL)
|
|
787
|
+
}
|
|
788
|
+
guard case let .networkError(message) = error,
|
|
789
|
+
message.contains("HTTP 409")
|
|
790
|
+
else {
|
|
791
|
+
throw error
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
var lastError: Error? = error
|
|
795
|
+
if bearerToken != nil {
|
|
796
|
+
logger("Retrying without Authorization header (HTTP 409)")
|
|
797
|
+
do {
|
|
798
|
+
return try await self.fetchWithCookieHeader(header, bearerToken: nil, baseURL: baseURL)
|
|
799
|
+
} catch {
|
|
800
|
+
lastError = error
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
let retries: [(String, (HTTPCookie) -> Bool)] = [
|
|
805
|
+
("Retrying without access-token cookies", { !Self.staleTokenCookieNames.contains($0.name) }),
|
|
806
|
+
("Retrying without session cookies", { !Self.sessionCookieNames.contains($0.name) }),
|
|
807
|
+
("Retrying without access-token/session cookies", {
|
|
808
|
+
!Self.staleTokenCookieNames.contains($0.name) && !Self.sessionCookieNames.contains($0.name)
|
|
809
|
+
}),
|
|
810
|
+
]
|
|
811
|
+
|
|
812
|
+
for (label, predicate) in retries {
|
|
813
|
+
let filtered = cookies.filter(predicate)
|
|
814
|
+
guard filtered.count < cookies.count else { continue }
|
|
815
|
+
logger(label)
|
|
816
|
+
do {
|
|
817
|
+
let filteredBearer = Self.bearerToken(from: filtered)
|
|
818
|
+
return try await self.fetchWithCookieHeader(
|
|
819
|
+
Self.cookieHeader(from: filtered),
|
|
820
|
+
bearerToken: filteredBearer,
|
|
821
|
+
baseURL: baseURL)
|
|
822
|
+
} catch let retryError as FactoryStatusProbeError {
|
|
823
|
+
switch retryError {
|
|
824
|
+
case let .networkError(retryMessage)
|
|
825
|
+
where retryMessage.contains("HTTP 409") &&
|
|
826
|
+
retryMessage.localizedCaseInsensitiveContains("stale token"):
|
|
827
|
+
lastError = retryError
|
|
828
|
+
continue
|
|
829
|
+
case .notLoggedIn:
|
|
830
|
+
lastError = retryError
|
|
831
|
+
continue
|
|
832
|
+
default:
|
|
833
|
+
throw retryError
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
let authOnly = cookies.filter {
|
|
839
|
+
Self.authSessionCookieNames.contains($0.name) || $0.name == "__Host-authjs.csrf-token"
|
|
840
|
+
}
|
|
841
|
+
if !authOnly.isEmpty, authOnly.count < cookies.count {
|
|
842
|
+
logger("Retrying with auth session cookies only")
|
|
843
|
+
do {
|
|
844
|
+
return try await self.fetchWithCookieHeader(
|
|
845
|
+
Self.cookieHeader(from: authOnly),
|
|
846
|
+
bearerToken: Self.bearerToken(from: authOnly),
|
|
847
|
+
baseURL: baseURL)
|
|
848
|
+
} catch let retryError as FactoryStatusProbeError {
|
|
849
|
+
lastError = retryError
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
if let lastError { throw lastError }
|
|
854
|
+
throw error
|
|
855
|
+
} catch {
|
|
856
|
+
throw error
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
private static func cookieHeader(from cookies: [HTTPCookie]) -> String {
|
|
861
|
+
cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ")
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
private func fetchWithCookieHeader(
|
|
865
|
+
_ cookieHeader: String,
|
|
866
|
+
bearerToken: String?,
|
|
867
|
+
baseURL: URL) async throws -> FactoryStatusSnapshot
|
|
868
|
+
{
|
|
869
|
+
// First fetch auth info to get user ID and org info
|
|
870
|
+
let authInfo = try await self.fetchAuthInfo(
|
|
871
|
+
cookieHeader: cookieHeader,
|
|
872
|
+
bearerToken: bearerToken,
|
|
873
|
+
baseURL: baseURL)
|
|
874
|
+
|
|
875
|
+
// Extract user ID from JWT in the auth response or use a default endpoint
|
|
876
|
+
let userId = self.extractUserIdFromAuth(authInfo)
|
|
877
|
+
|
|
878
|
+
// Fetch usage data
|
|
879
|
+
let usageData = try await self.fetchUsage(
|
|
880
|
+
cookieHeader: cookieHeader,
|
|
881
|
+
bearerToken: bearerToken,
|
|
882
|
+
userId: userId,
|
|
883
|
+
baseURL: baseURL)
|
|
884
|
+
|
|
885
|
+
return self.buildSnapshot(authInfo: authInfo, usageData: usageData, userId: userId)
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
private func fetchAuthInfo(
|
|
889
|
+
cookieHeader: String,
|
|
890
|
+
bearerToken: String?,
|
|
891
|
+
baseURL: URL) async throws -> FactoryAuthResponse
|
|
892
|
+
{
|
|
893
|
+
let url = baseURL.appendingPathComponent("/api/app/auth/me")
|
|
894
|
+
var request = URLRequest(url: url)
|
|
895
|
+
request.timeoutInterval = self.timeout
|
|
896
|
+
request.httpMethod = "GET"
|
|
897
|
+
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
898
|
+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
899
|
+
request.setValue("https://app.factory.ai", forHTTPHeaderField: "Origin")
|
|
900
|
+
request.setValue("https://app.factory.ai/", forHTTPHeaderField: "Referer")
|
|
901
|
+
if !cookieHeader.isEmpty {
|
|
902
|
+
request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
|
|
903
|
+
}
|
|
904
|
+
request.setValue("web-app", forHTTPHeaderField: "x-factory-client")
|
|
905
|
+
if let bearerToken {
|
|
906
|
+
request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization")
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
let (data, response) = try await URLSession.shared.data(for: request)
|
|
910
|
+
|
|
911
|
+
guard let httpResponse = response as? HTTPURLResponse else {
|
|
912
|
+
throw FactoryStatusProbeError.networkError("Invalid response")
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 {
|
|
916
|
+
throw FactoryStatusProbeError.notLoggedIn
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
guard httpResponse.statusCode == 200 else {
|
|
920
|
+
let body = String(data: data, encoding: .utf8)?
|
|
921
|
+
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "<binary>"
|
|
922
|
+
let snippet = body.isEmpty ? "" : ": \(body.prefix(200))"
|
|
923
|
+
throw FactoryStatusProbeError.networkError("HTTP \(httpResponse.statusCode)\(snippet)")
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
do {
|
|
927
|
+
let decoder = JSONDecoder()
|
|
928
|
+
return try decoder.decode(FactoryAuthResponse.self, from: data)
|
|
929
|
+
} catch {
|
|
930
|
+
let rawJSON = String(data: data, encoding: .utf8) ?? "<binary>"
|
|
931
|
+
throw FactoryStatusProbeError
|
|
932
|
+
.parseFailed("Auth decode failed: \(error.localizedDescription). Raw: \(rawJSON.prefix(200))")
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
private func fetchUsage(
|
|
937
|
+
cookieHeader: String,
|
|
938
|
+
bearerToken: String?,
|
|
939
|
+
userId: String?,
|
|
940
|
+
baseURL: URL) async throws -> FactoryUsageResponse
|
|
941
|
+
{
|
|
942
|
+
let url = baseURL.appendingPathComponent("/api/organization/subscription/usage")
|
|
943
|
+
var request = URLRequest(url: url)
|
|
944
|
+
request.timeoutInterval = self.timeout
|
|
945
|
+
request.httpMethod = "POST"
|
|
946
|
+
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
947
|
+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
948
|
+
request.setValue("https://app.factory.ai", forHTTPHeaderField: "Origin")
|
|
949
|
+
request.setValue("https://app.factory.ai/", forHTTPHeaderField: "Referer")
|
|
950
|
+
if !cookieHeader.isEmpty {
|
|
951
|
+
request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
|
|
952
|
+
}
|
|
953
|
+
request.setValue("web-app", forHTTPHeaderField: "x-factory-client")
|
|
954
|
+
if let bearerToken {
|
|
955
|
+
request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization")
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// Build request body
|
|
959
|
+
var body: [String: Any] = ["useCache": true]
|
|
960
|
+
if let userId {
|
|
961
|
+
body["userId"] = userId
|
|
962
|
+
}
|
|
963
|
+
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
|
|
964
|
+
|
|
965
|
+
let (data, response) = try await URLSession.shared.data(for: request)
|
|
966
|
+
|
|
967
|
+
guard let httpResponse = response as? HTTPURLResponse else {
|
|
968
|
+
throw FactoryStatusProbeError.networkError("Invalid response")
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 {
|
|
972
|
+
throw FactoryStatusProbeError.notLoggedIn
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
guard httpResponse.statusCode == 200 else {
|
|
976
|
+
let body = String(data: data, encoding: .utf8)?
|
|
977
|
+
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "<binary>"
|
|
978
|
+
let snippet = body.isEmpty ? "" : ": \(body.prefix(200))"
|
|
979
|
+
throw FactoryStatusProbeError.networkError("HTTP \(httpResponse.statusCode)\(snippet)")
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
do {
|
|
983
|
+
let decoder = JSONDecoder()
|
|
984
|
+
return try decoder.decode(FactoryUsageResponse.self, from: data)
|
|
985
|
+
} catch {
|
|
986
|
+
let rawJSON = String(data: data, encoding: .utf8) ?? "<binary>"
|
|
987
|
+
throw FactoryStatusProbeError
|
|
988
|
+
.parseFailed("Usage decode failed: \(error.localizedDescription). Raw: \(rawJSON.prefix(200))")
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
private static func baseURLCandidates(default baseURL: URL, cookies: [HTTPCookie]) -> [URL] {
|
|
993
|
+
let cookieDomains = Set(
|
|
994
|
+
cookies.map {
|
|
995
|
+
$0.domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
|
996
|
+
})
|
|
997
|
+
|
|
998
|
+
var candidates: [URL] = []
|
|
999
|
+
if cookieDomains.contains("auth.factory.ai") {
|
|
1000
|
+
candidates.append(Self.authBaseURL)
|
|
1001
|
+
}
|
|
1002
|
+
candidates.append(Self.apiBaseURL)
|
|
1003
|
+
candidates.append(Self.appBaseURL)
|
|
1004
|
+
candidates.append(baseURL)
|
|
1005
|
+
|
|
1006
|
+
var seen = Set<String>()
|
|
1007
|
+
return candidates.filter { url in
|
|
1008
|
+
let key = url.absoluteString
|
|
1009
|
+
if seen.contains(key) { return false }
|
|
1010
|
+
seen.insert(key)
|
|
1011
|
+
return true
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
private static func bearerToken(from cookies: [HTTPCookie]) -> String? {
|
|
1016
|
+
let accessToken = cookies.first(where: { $0.name == "access-token" })?.value
|
|
1017
|
+
let sessionToken = cookies.first(where: { Self.authSessionCookieNames.contains($0.name) })?.value
|
|
1018
|
+
let legacySession = cookies.first(where: { $0.name == "session" })?.value
|
|
1019
|
+
|
|
1020
|
+
if let accessToken, accessToken.contains(".") {
|
|
1021
|
+
return accessToken
|
|
1022
|
+
}
|
|
1023
|
+
if let sessionToken, sessionToken.contains(".") {
|
|
1024
|
+
return sessionToken
|
|
1025
|
+
}
|
|
1026
|
+
if let legacySession, legacySession.contains(".") {
|
|
1027
|
+
return legacySession
|
|
1028
|
+
}
|
|
1029
|
+
return accessToken ?? sessionToken
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
private func fetchWithBearerToken(
|
|
1033
|
+
_ bearerToken: String,
|
|
1034
|
+
logger: (String) -> Void) async throws -> FactoryStatusSnapshot
|
|
1035
|
+
{
|
|
1036
|
+
let candidates = [Self.apiBaseURL, self.baseURL]
|
|
1037
|
+
var lastError: Error?
|
|
1038
|
+
for baseURL in candidates {
|
|
1039
|
+
if baseURL != Self.apiBaseURL {
|
|
1040
|
+
logger("Trying Factory bearer base URL: \(baseURL.host ?? baseURL.absoluteString)")
|
|
1041
|
+
}
|
|
1042
|
+
do {
|
|
1043
|
+
return try await self.fetchWithCookieHeader(
|
|
1044
|
+
"",
|
|
1045
|
+
bearerToken: bearerToken,
|
|
1046
|
+
baseURL: baseURL)
|
|
1047
|
+
} catch {
|
|
1048
|
+
lastError = error
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
if let lastError { throw lastError }
|
|
1052
|
+
throw FactoryStatusProbeError.notLoggedIn
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
private func fetchWorkOSAccessToken(
|
|
1056
|
+
refreshToken: String,
|
|
1057
|
+
organizationID: String?) async throws -> WorkOSAuthResponse
|
|
1058
|
+
{
|
|
1059
|
+
var lastError: Error?
|
|
1060
|
+
for clientID in Self.workosClientIDs {
|
|
1061
|
+
do {
|
|
1062
|
+
return try await self.fetchWorkOSAccessToken(
|
|
1063
|
+
refreshToken: refreshToken,
|
|
1064
|
+
organizationID: organizationID,
|
|
1065
|
+
clientID: clientID)
|
|
1066
|
+
} catch {
|
|
1067
|
+
lastError = error
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
if let lastError { throw lastError }
|
|
1071
|
+
throw FactoryStatusProbeError.networkError("WorkOS auth failed")
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
private func fetchWorkOSAccessToken(
|
|
1075
|
+
refreshToken: String,
|
|
1076
|
+
organizationID: String?,
|
|
1077
|
+
clientID: String) async throws -> WorkOSAuthResponse
|
|
1078
|
+
{
|
|
1079
|
+
guard let url = URL(string: "https://api.workos.com/user_management/authenticate") else {
|
|
1080
|
+
throw FactoryStatusProbeError.networkError("WorkOS auth URL unavailable")
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
var request = URLRequest(url: url)
|
|
1084
|
+
request.timeoutInterval = self.timeout
|
|
1085
|
+
request.httpMethod = "POST"
|
|
1086
|
+
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
1087
|
+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
1088
|
+
|
|
1089
|
+
var body: [String: Any] = [
|
|
1090
|
+
"client_id": clientID,
|
|
1091
|
+
"grant_type": "refresh_token",
|
|
1092
|
+
"refresh_token": refreshToken,
|
|
1093
|
+
]
|
|
1094
|
+
if let organizationID {
|
|
1095
|
+
body["organization_id"] = organizationID
|
|
1096
|
+
}
|
|
1097
|
+
request.httpBody = try JSONSerialization.data(withJSONObject: body, options: [])
|
|
1098
|
+
|
|
1099
|
+
let (data, response) = try await URLSession.shared.data(for: request)
|
|
1100
|
+
guard let httpResponse = response as? HTTPURLResponse else {
|
|
1101
|
+
throw FactoryStatusProbeError.networkError("Invalid WorkOS response")
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
guard httpResponse.statusCode == 200 else {
|
|
1105
|
+
if httpResponse.statusCode == 400, Self.isMissingWorkOSRefreshToken(data) {
|
|
1106
|
+
throw FactoryStatusProbeError.noSessionCookie
|
|
1107
|
+
}
|
|
1108
|
+
let body = String(data: data, encoding: .utf8)?
|
|
1109
|
+
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "<binary>"
|
|
1110
|
+
let snippet = body.isEmpty ? "" : ": \(body.prefix(200))"
|
|
1111
|
+
throw FactoryStatusProbeError.networkError("WorkOS HTTP \(httpResponse.statusCode)\(snippet)")
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
let decoder = JSONDecoder()
|
|
1115
|
+
return try decoder.decode(WorkOSAuthResponse.self, from: data)
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
private func fetchWorkOSAccessTokenWithCookies(
|
|
1119
|
+
cookies: [HTTPCookie],
|
|
1120
|
+
logger: (String) -> Void) async throws -> WorkOSAuthResponse
|
|
1121
|
+
{
|
|
1122
|
+
let cookieHeader = Self.cookieHeader(from: cookies)
|
|
1123
|
+
guard !cookieHeader.isEmpty else {
|
|
1124
|
+
throw FactoryStatusProbeError.networkError("Missing WorkOS cookies")
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
var lastError: Error?
|
|
1128
|
+
for clientID in Self.workosClientIDs {
|
|
1129
|
+
do {
|
|
1130
|
+
return try await self.fetchWorkOSAccessTokenWithCookies(
|
|
1131
|
+
cookieHeader: cookieHeader,
|
|
1132
|
+
organizationID: nil,
|
|
1133
|
+
clientID: clientID)
|
|
1134
|
+
} catch {
|
|
1135
|
+
lastError = error
|
|
1136
|
+
logger("WorkOS cookie auth failed for client \(clientID): \(error.localizedDescription)")
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
if let lastError { throw lastError }
|
|
1140
|
+
throw FactoryStatusProbeError.networkError("WorkOS cookie auth failed")
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
private func fetchWorkOSAccessTokenWithCookies(
|
|
1144
|
+
cookieHeader: String,
|
|
1145
|
+
organizationID: String?,
|
|
1146
|
+
clientID: String) async throws -> WorkOSAuthResponse
|
|
1147
|
+
{
|
|
1148
|
+
guard let url = URL(string: "https://api.workos.com/user_management/authenticate") else {
|
|
1149
|
+
throw FactoryStatusProbeError.networkError("WorkOS auth URL unavailable")
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
var request = URLRequest(url: url)
|
|
1153
|
+
request.timeoutInterval = self.timeout
|
|
1154
|
+
request.httpMethod = "POST"
|
|
1155
|
+
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
1156
|
+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
1157
|
+
request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
|
|
1158
|
+
|
|
1159
|
+
var body: [String: Any] = [
|
|
1160
|
+
"client_id": clientID,
|
|
1161
|
+
"grant_type": "refresh_token",
|
|
1162
|
+
"useCookie": true,
|
|
1163
|
+
]
|
|
1164
|
+
if let organizationID {
|
|
1165
|
+
body["organization_id"] = organizationID
|
|
1166
|
+
}
|
|
1167
|
+
request.httpBody = try JSONSerialization.data(withJSONObject: body, options: [])
|
|
1168
|
+
|
|
1169
|
+
let (data, response) = try await URLSession.shared.data(for: request)
|
|
1170
|
+
guard let httpResponse = response as? HTTPURLResponse else {
|
|
1171
|
+
throw FactoryStatusProbeError.networkError("Invalid WorkOS response")
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
guard httpResponse.statusCode == 200 else {
|
|
1175
|
+
if httpResponse.statusCode == 400, Self.isMissingWorkOSRefreshToken(data) {
|
|
1176
|
+
throw FactoryStatusProbeError.noSessionCookie
|
|
1177
|
+
}
|
|
1178
|
+
let bodyText = String(data: data, encoding: .utf8)?
|
|
1179
|
+
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "<binary>"
|
|
1180
|
+
let snippet = bodyText.isEmpty ? "" : ": \(bodyText.prefix(200))"
|
|
1181
|
+
throw FactoryStatusProbeError.networkError("WorkOS HTTP \(httpResponse.statusCode)\(snippet)")
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
let decoder = JSONDecoder()
|
|
1185
|
+
return try decoder.decode(WorkOSAuthResponse.self, from: data)
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
private func isInvalidGrant(_ error: Error) -> Bool {
|
|
1189
|
+
guard case let FactoryStatusProbeError.networkError(message) = error else {
|
|
1190
|
+
return false
|
|
1191
|
+
}
|
|
1192
|
+
return message.localizedCaseInsensitiveContains("invalid_grant")
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
static func isMissingWorkOSRefreshToken(_ data: Data) -> Bool {
|
|
1196
|
+
guard let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
|
|
1197
|
+
return false
|
|
1198
|
+
}
|
|
1199
|
+
guard let description = json["error_description"] as? String else { return false }
|
|
1200
|
+
return description.localizedCaseInsensitiveContains("missing refresh token")
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
private func extractUserIdFromAuth(_ auth: FactoryAuthResponse) -> String? {
|
|
1204
|
+
// The user ID might be in the organization or we might need to parse JWT
|
|
1205
|
+
// For now, return nil and let the API handle it
|
|
1206
|
+
nil
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
private func buildSnapshot(
|
|
1210
|
+
authInfo: FactoryAuthResponse,
|
|
1211
|
+
usageData: FactoryUsageResponse,
|
|
1212
|
+
userId: String?) -> FactoryStatusSnapshot
|
|
1213
|
+
{
|
|
1214
|
+
let usage = usageData.usage
|
|
1215
|
+
|
|
1216
|
+
let periodStart: Date? = usage?.startDate.map { Date(timeIntervalSince1970: TimeInterval($0) / 1000) }
|
|
1217
|
+
let periodEnd: Date? = usage?.endDate.map { Date(timeIntervalSince1970: TimeInterval($0) / 1000) }
|
|
1218
|
+
|
|
1219
|
+
return FactoryStatusSnapshot(
|
|
1220
|
+
standardUserTokens: usage?.standard?.userTokens ?? 0,
|
|
1221
|
+
standardOrgTokens: usage?.standard?.orgTotalTokensUsed ?? 0,
|
|
1222
|
+
standardAllowance: usage?.standard?.totalAllowance ?? 0,
|
|
1223
|
+
premiumUserTokens: usage?.premium?.userTokens ?? 0,
|
|
1224
|
+
premiumOrgTokens: usage?.premium?.orgTotalTokensUsed ?? 0,
|
|
1225
|
+
premiumAllowance: usage?.premium?.totalAllowance ?? 0,
|
|
1226
|
+
periodStart: periodStart,
|
|
1227
|
+
periodEnd: periodEnd,
|
|
1228
|
+
planName: authInfo.organization?.subscription?.orbSubscription?.plan?.name,
|
|
1229
|
+
tier: authInfo.organization?.subscription?.factoryTier,
|
|
1230
|
+
organizationName: authInfo.organization?.name,
|
|
1231
|
+
accountEmail: nil, // Email is in JWT, not in auth response body
|
|
1232
|
+
userId: userId ?? usageData.userId,
|
|
1233
|
+
rawJSON: nil)
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
#else
|
|
1238
|
+
|
|
1239
|
+
// MARK: - Factory (Unsupported)
|
|
1240
|
+
|
|
1241
|
+
public enum FactoryStatusProbeError: LocalizedError, Sendable {
|
|
1242
|
+
case notSupported
|
|
1243
|
+
|
|
1244
|
+
public var errorDescription: String? {
|
|
1245
|
+
"Factory is only supported on macOS."
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
public struct FactoryStatusSnapshot: Sendable {
|
|
1250
|
+
public init() {}
|
|
1251
|
+
|
|
1252
|
+
public func toUsageSnapshot() -> UsageSnapshot {
|
|
1253
|
+
UsageSnapshot(
|
|
1254
|
+
primary: RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
|
|
1255
|
+
secondary: nil,
|
|
1256
|
+
tertiary: nil,
|
|
1257
|
+
providerCost: nil,
|
|
1258
|
+
updatedAt: Date(),
|
|
1259
|
+
identity: nil)
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
public struct FactoryStatusProbe: Sendable {
|
|
1264
|
+
public init(baseURL: URL = URL(string: "https://app.factory.ai")!, timeout: TimeInterval = 15.0) {
|
|
1265
|
+
_ = baseURL
|
|
1266
|
+
_ = timeout
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
public func fetch(logger: ((String) -> Void)? = nil) async throws -> FactoryStatusSnapshot {
|
|
1270
|
+
_ = logger
|
|
1271
|
+
throw FactoryStatusProbeError.notSupported
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
#endif
|