diff --git a/.github/github-repo-workflow.json b/.github/github-repo-workflow.json index c6f556b..e647b9c 100644 --- a/.github/github-repo-workflow.json +++ b/.github/github-repo-workflow.json @@ -6,6 +6,9 @@ "index": "docs/README.md", "productGoals": "docs/product-goals.md", "architecture": "docs/architecture.md", + "designDirection": "docs/design-direction.md", + "localLimitProbe": "docs/local-limit-probe.md", + "providerUsageAccess": "docs/provider-usage-access.md", "repoSettings": "docs/repo-settings.md" }, "qualityGate": { diff --git a/.gitignore b/.gitignore index 96556d8..ccb9d4a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ DerivedData/ *.xcworkspace/xcuserdata/ .swiftpm/ .idea/ +dist/ diff --git a/AGENTS.md b/AGENTS.md index 7767de5..e02c37c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,8 @@ history, and settings. - The widget should be beautiful, dense, and calm: compact charts, rings, bars, sparklines, reset countdowns, and provider/account grouping where useful. +- For weekly or rolling account limits, include forecast language that helps the + user decide whether higher-burn modes are safe before reset. - Avoid making the widget a dashboard crammed into a rectangle. Put setup, troubleshooting, raw details, and long histories in the app. - Clicking the widget should open the app to the most relevant provider/account diff --git a/Config/ContextPanel.entitlements b/Config/ContextPanel.entitlements new file mode 100644 index 0000000..5816106 --- /dev/null +++ b/Config/ContextPanel.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + group.com.shinycomputers.contextpanel + + + diff --git a/Config/ContextPanelWidget-Info.plist b/Config/ContextPanelWidget-Info.plist new file mode 100644 index 0000000..6ef3ac5 --- /dev/null +++ b/Config/ContextPanelWidget-Info.plist @@ -0,0 +1,27 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/Config/ContextPanelWidget.entitlements b/Config/ContextPanelWidget.entitlements new file mode 100644 index 0000000..7320a3d --- /dev/null +++ b/Config/ContextPanelWidget.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + group.com.shinycomputers.contextpanel + + + diff --git a/ContextPanel.xcodeproj/project.pbxproj b/ContextPanel.xcodeproj/project.pbxproj new file mode 100644 index 0000000..459cb4a --- /dev/null +++ b/ContextPanel.xcodeproj/project.pbxproj @@ -0,0 +1,651 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 0BBD1DF433F345E62C6A26EF /* LimitProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60F09D3CAB56AE663B9F7ECA /* LimitProbe.swift */; }; + 10A2940E7E3FE6E253ECFEDE /* GeminiCodeAssistQuota.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AC2F76895EE0EFC7F463447 /* GeminiCodeAssistQuota.swift */; }; + 1DD89F5634070B75C09BDE99 /* ClaudeLocalStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E426A482568BBE85357D7E /* ClaudeLocalStatus.swift */; }; + 36A035769881D4A450BA3BD8 /* libContextPanelCore.a in Frameworks */ = {isa = PBXBuildFile; fileRef = F09182E1FD96DAEEC38F3909 /* libContextPanelCore.a */; }; + 38E9E0A22491522FF4E0A8F4 /* ContextPanelPreviewApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A46D6040EB6F68FC91EC477D /* ContextPanelPreviewApp.swift */; }; + 459C8EC82194CE1A950FE1B5 /* UsageLimit.swift in Sources */ = {isa = PBXBuildFile; fileRef = D459054C90410A784EBD308B /* UsageLimit.swift */; }; + 47414B1A42BE473BF1644296 /* CodexRateLimits.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FA8FE4CC9BE058D8F46FBA2 /* CodexRateLimits.swift */; }; + 64BA3F57751ADAB181558D58 /* SampleUsageData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2361E9EED490737FB8306CC7 /* SampleUsageData.swift */; }; + 85EBA57F3F1633F2D6E54103 /* ContextPanelWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 07D959D621E594C0CFC2CEF5 /* ContextPanelWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 9C74713AEFFAC6D85E7ACF3F /* FastModeForecast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BD985B0213AB5987D3B3170 /* FastModeForecast.swift */; }; + B6C35EBBE9327327BDCA324F /* SnapshotStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32D8515D047F5A8117DB762C /* SnapshotStore.swift */; }; + B83302BDC1EAC3EE45DBE8B8 /* WidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5233BBC86AE8057D749F71CD /* WidgetSnapshot.swift */; }; + BE7F8E276624C8E6D93BC329 /* libContextPanelCore.a in Frameworks */ = {isa = PBXBuildFile; fileRef = F09182E1FD96DAEEC38F3909 /* libContextPanelCore.a */; }; + BF04DB8945D058C7A63BEAB3 /* ClaudeWebUsage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53715ACF982CE590F00C3A75 /* ClaudeWebUsage.swift */; }; + C1DF7B59257060AC0A6AF44D /* ContextPanelLocations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23E9C71457F4E1F101132FCF /* ContextPanelLocations.swift */; }; + C6CA96F5E75E32FCCD329736 /* ContextPanelWidgetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407B0E4C039FFF22FA8F8542 /* ContextPanelWidgetViews.swift */; }; + E77C3B3D8985239A004E8CDC /* AccountConfigurationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 186D607FE2EEFDFFE2E70446 /* AccountConfigurationStore.swift */; }; + F4B9B4FB75A285E38AE52B1D /* ProviderConnector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 350ED5589FB23C42ED32704A /* ProviderConnector.swift */; }; + FE093A1996BA546A10A800DA /* ContextPanelWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = E195C6E906DA9C0BF082622F /* ContextPanelWidget.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 32954FE71C65F1FD1E590272 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 194387DBD3D0F10E17F284F9 /* Project object */; + proxyType = 1; + remoteGlobalIDString = EE4B8035BB7A9BD03B668BB8; + remoteInfo = ContextPanelCore; + }; + 3C3392E33451B979623C2996 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 194387DBD3D0F10E17F284F9 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 92EF616FEBA46FCB02E887BE; + remoteInfo = ContextPanelWidgetExtension; + }; + A81BFB53BF2A07F0375FCF7E /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 194387DBD3D0F10E17F284F9 /* Project object */; + proxyType = 1; + remoteGlobalIDString = EE4B8035BB7A9BD03B668BB8; + remoteInfo = ContextPanelCore; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 09DAA1F320B84A007C14B253 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 85EBA57F3F1633F2D6E54103 /* ContextPanelWidgetExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 07D959D621E594C0CFC2CEF5 /* ContextPanelWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ContextPanelWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 0AC2F76895EE0EFC7F463447 /* GeminiCodeAssistQuota.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeminiCodeAssistQuota.swift; sourceTree = ""; }; + 186D607FE2EEFDFFE2E70446 /* AccountConfigurationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountConfigurationStore.swift; sourceTree = ""; }; + 2361E9EED490737FB8306CC7 /* SampleUsageData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleUsageData.swift; sourceTree = ""; }; + 23E9C71457F4E1F101132FCF /* ContextPanelLocations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextPanelLocations.swift; sourceTree = ""; }; + 32D8515D047F5A8117DB762C /* SnapshotStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotStore.swift; sourceTree = ""; }; + 350ED5589FB23C42ED32704A /* ProviderConnector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderConnector.swift; sourceTree = ""; }; + 407B0E4C039FFF22FA8F8542 /* ContextPanelWidgetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextPanelWidgetViews.swift; sourceTree = ""; }; + 5233BBC86AE8057D749F71CD /* WidgetSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetSnapshot.swift; sourceTree = ""; }; + 53715ACF982CE590F00C3A75 /* ClaudeWebUsage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeWebUsage.swift; sourceTree = ""; }; + 60F09D3CAB56AE663B9F7ECA /* LimitProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LimitProbe.swift; sourceTree = ""; }; + 7BD985B0213AB5987D3B3170 /* FastModeForecast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FastModeForecast.swift; sourceTree = ""; }; + 8D6350007C73BEF27D871AB0 /* ContextPanel.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ContextPanel.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 9FA8FE4CC9BE058D8F46FBA2 /* CodexRateLimits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexRateLimits.swift; sourceTree = ""; }; + A46D6040EB6F68FC91EC477D /* ContextPanelPreviewApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextPanelPreviewApp.swift; sourceTree = ""; }; + D459054C90410A784EBD308B /* UsageLimit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsageLimit.swift; sourceTree = ""; }; + E195C6E906DA9C0BF082622F /* ContextPanelWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextPanelWidget.swift; sourceTree = ""; }; + E2E426A482568BBE85357D7E /* ClaudeLocalStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeLocalStatus.swift; sourceTree = ""; }; + F09182E1FD96DAEEC38F3909 /* libContextPanelCore.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libContextPanelCore.a; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 0A536561059AF7F95E2D3FD7 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 36A035769881D4A450BA3BD8 /* libContextPanelCore.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7BFE158EBBBAE213DE50F249 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + BE7F8E276624C8E6D93BC329 /* libContextPanelCore.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 45E9D2C527D1EF15E9811358 /* Products */ = { + isa = PBXGroup; + children = ( + 8D6350007C73BEF27D871AB0 /* ContextPanel.app */, + 07D959D621E594C0CFC2CEF5 /* ContextPanelWidgetExtension.appex */, + F09182E1FD96DAEEC38F3909 /* libContextPanelCore.a */, + ); + name = Products; + sourceTree = ""; + }; + 537271134D87C7167DEBA0C1 /* ContextPanelCore */ = { + isa = PBXGroup; + children = ( + 186D607FE2EEFDFFE2E70446 /* AccountConfigurationStore.swift */, + E2E426A482568BBE85357D7E /* ClaudeLocalStatus.swift */, + 53715ACF982CE590F00C3A75 /* ClaudeWebUsage.swift */, + 9FA8FE4CC9BE058D8F46FBA2 /* CodexRateLimits.swift */, + 23E9C71457F4E1F101132FCF /* ContextPanelLocations.swift */, + 7BD985B0213AB5987D3B3170 /* FastModeForecast.swift */, + 0AC2F76895EE0EFC7F463447 /* GeminiCodeAssistQuota.swift */, + 60F09D3CAB56AE663B9F7ECA /* LimitProbe.swift */, + 350ED5589FB23C42ED32704A /* ProviderConnector.swift */, + 32D8515D047F5A8117DB762C /* SnapshotStore.swift */, + D459054C90410A784EBD308B /* UsageLimit.swift */, + 5233BBC86AE8057D749F71CD /* WidgetSnapshot.swift */, + ); + name = ContextPanelCore; + path = Sources/ContextPanelCore; + sourceTree = ""; + }; + 77BADA15D2FF6ABB6F666F43 = { + isa = PBXGroup; + children = ( + 537271134D87C7167DEBA0C1 /* ContextPanelCore */, + F71E88199DC89A225C8F07FC /* ContextPanelPreview */, + A538E84B22D9D75DE0FD30D4 /* ContextPanelWidget */, + 45E9D2C527D1EF15E9811358 /* Products */, + ); + sourceTree = ""; + }; + A538E84B22D9D75DE0FD30D4 /* ContextPanelWidget */ = { + isa = PBXGroup; + children = ( + E195C6E906DA9C0BF082622F /* ContextPanelWidget.swift */, + 407B0E4C039FFF22FA8F8542 /* ContextPanelWidgetViews.swift */, + ); + name = ContextPanelWidget; + path = Sources/ContextPanelWidget; + sourceTree = ""; + }; + F71E88199DC89A225C8F07FC /* ContextPanelPreview */ = { + isa = PBXGroup; + children = ( + A46D6040EB6F68FC91EC477D /* ContextPanelPreviewApp.swift */, + 2361E9EED490737FB8306CC7 /* SampleUsageData.swift */, + ); + name = ContextPanelPreview; + path = Sources/ContextPanelPreview; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 92EF616FEBA46FCB02E887BE /* ContextPanelWidgetExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 6CB2601991081CFCCB5C6E04 /* Build configuration list for PBXNativeTarget "ContextPanelWidgetExtension" */; + buildPhases = ( + 255CF9DC3E13CC5E897143A3 /* Sources */, + 7BFE158EBBBAE213DE50F249 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 8361FD30418655D673073A5A /* PBXTargetDependency */, + ); + name = ContextPanelWidgetExtension; + packageProductDependencies = ( + ); + productName = ContextPanelWidgetExtension; + productReference = 07D959D621E594C0CFC2CEF5 /* ContextPanelWidgetExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; + EE4B8035BB7A9BD03B668BB8 /* ContextPanelCore */ = { + isa = PBXNativeTarget; + buildConfigurationList = 9CBD94C5FBD8FC3E4201DB61 /* Build configuration list for PBXNativeTarget "ContextPanelCore" */; + buildPhases = ( + 5CBBB511F795B6E330EACF99 /* Sources */, + A6FD12E22FB2385AD98885D1 /* Copy Swift Objective-C Interface Header */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = ContextPanelCore; + packageProductDependencies = ( + ); + productName = ContextPanelCore; + productReference = F09182E1FD96DAEEC38F3909 /* libContextPanelCore.a */; + productType = "com.apple.product-type.library.static"; + }; + FF80548BA14604BB9B1E9115 /* ContextPanel */ = { + isa = PBXNativeTarget; + buildConfigurationList = 54E2B892F7409618FAE8F040 /* Build configuration list for PBXNativeTarget "ContextPanel" */; + buildPhases = ( + 8BE7305E45C25AEF658A9E47 /* Sources */, + 0A536561059AF7F95E2D3FD7 /* Frameworks */, + 09DAA1F320B84A007C14B253 /* Embed Foundation Extensions */, + ); + buildRules = ( + ); + dependencies = ( + D29DE5B6DB7D5B324359BCA0 /* PBXTargetDependency */, + 4A468DA4B7F0B61AAE457FF9 /* PBXTargetDependency */, + ); + name = ContextPanel; + packageProductDependencies = ( + ); + productName = ContextPanel; + productReference = 8D6350007C73BEF27D871AB0 /* ContextPanel.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 194387DBD3D0F10E17F284F9 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + TargetAttributes = { + 92EF616FEBA46FCB02E887BE = { + DevelopmentTeam = MM5YXC7T6E; + ProvisioningStyle = Automatic; + }; + EE4B8035BB7A9BD03B668BB8 = { + DevelopmentTeam = MM5YXC7T6E; + ProvisioningStyle = Automatic; + }; + FF80548BA14604BB9B1E9115 = { + DevelopmentTeam = MM5YXC7T6E; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 9B6F6DC10F1BF3A02285845F /* Build configuration list for PBXProject "ContextPanel" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 77BADA15D2FF6ABB6F666F43; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 45E9D2C527D1EF15E9811358 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + FF80548BA14604BB9B1E9115 /* ContextPanel */, + EE4B8035BB7A9BD03B668BB8 /* ContextPanelCore */, + 92EF616FEBA46FCB02E887BE /* ContextPanelWidgetExtension */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXShellScriptBuildPhase section */ + A6FD12E22FB2385AD98885D1 /* Copy Swift Objective-C Interface Header */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "$(DERIVED_SOURCES_DIR)/$(SWIFT_OBJC_INTERFACE_HEADER_NAME)", + ); + name = "Copy Swift Objective-C Interface Header"; + outputPaths = ( + "$(BUILT_PRODUCTS_DIR)/include/$(PRODUCT_MODULE_NAME)/$(SWIFT_OBJC_INTERFACE_HEADER_NAME)", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "ditto \"${SCRIPT_INPUT_FILE_0}\" \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 255CF9DC3E13CC5E897143A3 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FE093A1996BA546A10A800DA /* ContextPanelWidget.swift in Sources */, + C6CA96F5E75E32FCCD329736 /* ContextPanelWidgetViews.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5CBBB511F795B6E330EACF99 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E77C3B3D8985239A004E8CDC /* AccountConfigurationStore.swift in Sources */, + 1DD89F5634070B75C09BDE99 /* ClaudeLocalStatus.swift in Sources */, + BF04DB8945D058C7A63BEAB3 /* ClaudeWebUsage.swift in Sources */, + 47414B1A42BE473BF1644296 /* CodexRateLimits.swift in Sources */, + C1DF7B59257060AC0A6AF44D /* ContextPanelLocations.swift in Sources */, + 9C74713AEFFAC6D85E7ACF3F /* FastModeForecast.swift in Sources */, + 10A2940E7E3FE6E253ECFEDE /* GeminiCodeAssistQuota.swift in Sources */, + 0BBD1DF433F345E62C6A26EF /* LimitProbe.swift in Sources */, + F4B9B4FB75A285E38AE52B1D /* ProviderConnector.swift in Sources */, + B6C35EBBE9327327BDCA324F /* SnapshotStore.swift in Sources */, + 459C8EC82194CE1A950FE1B5 /* UsageLimit.swift in Sources */, + B83302BDC1EAC3EE45DBE8B8 /* WidgetSnapshot.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 8BE7305E45C25AEF658A9E47 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 38E9E0A22491522FF4E0A8F4 /* ContextPanelPreviewApp.swift in Sources */, + 64BA3F57751ADAB181558D58 /* SampleUsageData.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 4A468DA4B7F0B61AAE457FF9 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 92EF616FEBA46FCB02E887BE /* ContextPanelWidgetExtension */; + targetProxy = 3C3392E33451B979623C2996 /* PBXContainerItemProxy */; + }; + 8361FD30418655D673073A5A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = EE4B8035BB7A9BD03B668BB8 /* ContextPanelCore */; + targetProxy = 32954FE71C65F1FD1E590272 /* PBXContainerItemProxy */; + }; + D29DE5B6DB7D5B324359BCA0 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = EE4B8035BB7A9BD03B668BB8 /* ContextPanelCore */; + targetProxy = A81BFB53BF2A07F0375FCF7E /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 04634A97AA8175852ED3CA9D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = Config/ContextPanel.entitlements; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Context Panel"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.shinycomputers.contextpanel; + PRODUCT_NAME = "Context Panel"; + SDKROOT = macosx; + }; + name = Release; + }; + 58ADB2EF94022C1803CA1F30 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + COMBINE_HIDPI_IMAGES = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.0; + PRODUCT_BUNDLE_IDENTIFIER = com.shinycomputers.ContextPanelCore; + PRODUCT_NAME = ContextPanelCore; + SDKROOT = macosx; + SKIP_INSTALL = YES; + }; + name = Debug; + }; + 7FFB726DFDD92831614DCE30 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = Config/ContextPanel.entitlements; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Context Panel"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.shinycomputers.contextpanel; + PRODUCT_NAME = "Context Panel"; + SDKROOT = macosx; + }; + name = Debug; + }; + 8A5FA37DF23FC4E3C4201B12 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + COMBINE_HIDPI_IMAGES = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.0; + PRODUCT_BUNDLE_IDENTIFIER = com.shinycomputers.ContextPanelCore; + PRODUCT_NAME = ContextPanelCore; + SDKROOT = macosx; + SKIP_INSTALL = YES; + }; + name = Release; + }; + 8FBB69A1251FDC3C8BC250F6 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = Config/ContextPanelWidget.entitlements; + COMBINE_HIDPI_IMAGES = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = "Config/ContextPanelWidget-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.shinycomputers.contextpanel.widget; + PRODUCT_NAME = ContextPanelWidgetExtension; + SDKROOT = macosx; + SKIP_INSTALL = YES; + }; + name = Debug; + }; + AF229A6A5953793A6011AFFA /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = MM5YXC7T6E; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; + C37F715DCB05E50AB0896150 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = Config/ContextPanelWidget.entitlements; + COMBINE_HIDPI_IMAGES = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = "Config/ContextPanelWidget-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.shinycomputers.contextpanel.widget; + PRODUCT_NAME = ContextPanelWidgetExtension; + SDKROOT = macosx; + SKIP_INSTALL = YES; + }; + name = Release; + }; + D8D2A31D229BB9A52739BE36 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = MM5YXC7T6E; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 6.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 54E2B892F7409618FAE8F040 /* Build configuration list for PBXNativeTarget "ContextPanel" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7FFB726DFDD92831614DCE30 /* Debug */, + 04634A97AA8175852ED3CA9D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 6CB2601991081CFCCB5C6E04 /* Build configuration list for PBXNativeTarget "ContextPanelWidgetExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8FBB69A1251FDC3C8BC250F6 /* Debug */, + C37F715DCB05E50AB0896150 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 9B6F6DC10F1BF3A02285845F /* Build configuration list for PBXProject "ContextPanel" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AF229A6A5953793A6011AFFA /* Debug */, + D8D2A31D229BB9A52739BE36 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 9CBD94C5FBD8FC3E4201DB61 /* Build configuration list for PBXNativeTarget "ContextPanelCore" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 58ADB2EF94022C1803CA1F30 /* Debug */, + 8A5FA37DF23FC4E3C4201B12 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = 194387DBD3D0F10E17F284F9 /* Project object */; +} diff --git a/ContextPanel.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ContextPanel.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ContextPanel.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ContextPanel.xcodeproj/xcshareddata/xcschemes/ContextPanel.xcscheme b/ContextPanel.xcodeproj/xcshareddata/xcschemes/ContextPanel.xcscheme new file mode 100644 index 0000000..1d06c79 --- /dev/null +++ b/ContextPanel.xcodeproj/xcshareddata/xcschemes/ContextPanel.xcscheme @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Package.swift b/Package.swift index c9da4ef..38c6cff 100644 --- a/Package.swift +++ b/Package.swift @@ -11,10 +11,74 @@ let package = Package( .library( name: "ContextPanelCore", targets: ["ContextPanelCore"] + ), + .executable( + name: "ContextPanelPreview", + targets: ["ContextPanelPreview"] + ), + .executable( + name: "OpenAILimitProbe", + targets: ["OpenAILimitProbe"] + ), + .executable( + name: "CodexRateLimitProbe", + targets: ["CodexRateLimitProbe"] + ), + .executable( + name: "GeminiQuotaProbe", + targets: ["GeminiQuotaProbe"] + ), + .executable( + name: "ClaudeLimitProbe", + targets: ["ClaudeLimitProbe"] + ), + .executable( + name: "ClaudeWebUsageProbe", + targets: ["ClaudeWebUsageProbe"] + ), + .executable( + name: "SnapshotStoreProbe", + targets: ["SnapshotStoreProbe"] + ), + .executable( + name: "ContextPanelWidget", + targets: ["ContextPanelWidget"] ) ], targets: [ .target(name: "ContextPanelCore"), + .executableTarget( + name: "ContextPanelPreview", + dependencies: ["ContextPanelCore"] + ), + .executableTarget( + name: "OpenAILimitProbe", + dependencies: ["ContextPanelCore"] + ), + .executableTarget( + name: "CodexRateLimitProbe", + dependencies: ["ContextPanelCore"] + ), + .executableTarget( + name: "GeminiQuotaProbe", + dependencies: ["ContextPanelCore"] + ), + .executableTarget( + name: "ClaudeLimitProbe", + dependencies: ["ContextPanelCore"] + ), + .executableTarget( + name: "ClaudeWebUsageProbe", + dependencies: ["ContextPanelCore"] + ), + .executableTarget( + name: "SnapshotStoreProbe", + dependencies: ["ContextPanelCore"] + ), + .executableTarget( + name: "ContextPanelWidget", + dependencies: ["ContextPanelCore"] + ), .testTarget( name: "ContextPanelCoreTests", dependencies: ["ContextPanelCore"] diff --git a/README.md b/README.md index a85a7d6..e1dabde 100644 --- a/README.md +++ b/README.md @@ -50,4 +50,82 @@ Useful entry points: - [Product Goals](docs/product-goals.md) - [Architecture](docs/architecture.md) +- [macOS Release Path](docs/release.md) - [Repository Settings](docs/repo-settings.md) + +## Local App Bundle + +To build the native macOS app with the embedded WidgetKit extension: + +```sh +xcodegen generate --spec project.yml +xcodebuild \ + -project ContextPanel.xcodeproj \ + -scheme ContextPanel \ + -configuration Debug \ + -destination 'platform=macOS' \ + -allowProvisioningUpdates \ + build +``` + +To build a quick launchable macOS app bundle from the SwiftPM app shell: + +```sh +scripts/package-macos-app.sh --output dist --identity auto +open "dist/Context Panel.app" +``` + +When a Developer ID Application identity is available in Keychain, the script +uses it through `codesign`; otherwise it falls back to ad-hoc signing. This is +the interim friend-installable path for the app shell only; use the Xcode build +when testing the widget extension. + +## Local Provider Probes + +The package includes development probes for validating provider limit signals +without printing secrets or raw provider responses: + +```sh +swift run CodexRateLimitProbe --auth ~/.codex/auth.json +GEMINI_OAUTH_CLIENT_ID=... GEMINI_OAUTH_CLIENT_SECRET=... \ + swift run GeminiQuotaProbe --auth ~/.gemini/oauth_creds.json +swift run ClaudeLimitProbe +swift run SnapshotStoreProbe --codex-auth ~/.codex/auth.json --include-claude +``` + +The Codex and Gemini probes can return live percent-window quota buckets for +their respective CLI-backed accounts. The Claude probe intentionally reports +local auth/subscription metadata and local stats-cache freshness until a Claude +Code status-line cache has been populated. + +To capture Claude subscription usage percentages, configure Claude Code's +status line to call the helper in this repo. Claude Code sends the helper a JSON +payload after session responses; the helper stores only five-hour and weekly +used percentages plus reset timestamps under Context Panel's Application +Support directory. + +```json +{ + "statusLine": { + "type": "command", + "command": "/absolute/path/to/context-panel/scripts/claude-statusline-cache.sh" + } +} +``` + +The helper does not store auth tokens, prompts, transcript contents, emails, +organization IDs, or raw Claude session JSON. Claude Code's non-interactive +`claude -p` path does not appear to run the status-line hook, so Context Panel +marks old Claude subscription readings stale instead of treating them as live. +For Every Code-driven Claude usage, Context Panel also reads `ccusage` aggregate +block output when available and shows a clearly marked estimated 5-hour token +window; that estimate is useful for "am I likely to run out soon?" but is not +Anthropic's official subscription percentage. + +For Gemini, use the OAuth client values from the locally installed Gemini CLI; +they are intentionally not checked into this repository. + +The probes call the same `ContextPanelCore` connectors the app will use, so +passing probe output is also a smoke test for the production connector runtime. +`SnapshotStoreProbe` additionally writes and reloads the local JSON cache shape +that the app and widget will consume. diff --git a/Sources/ClaudeLimitProbe/main.swift b/Sources/ClaudeLimitProbe/main.swift new file mode 100644 index 0000000..b471d46 --- /dev/null +++ b/Sources/ClaudeLimitProbe/main.swift @@ -0,0 +1,104 @@ +import ContextPanelCore +import Foundation + +struct ClaudeProbeError: LocalizedError { + let message: String + + var errorDescription: String? { message } +} + +struct ProbeConfiguration { + let claudeBinary: String + let statsPath: String + + static func fromArguments(_ arguments: [String]) throws -> ProbeConfiguration { + var claudeBinary = "claude" + var statsPath: String? + var iterator = arguments.dropFirst().makeIterator() + + while let argument = iterator.next() { + switch argument { + case "--claude-bin": + guard let value = iterator.next() else { + throw ClaudeProbeError(message: "--claude-bin requires a path or executable name") + } + claudeBinary = value + case "--stats": + guard let value = iterator.next() else { + throw ClaudeProbeError(message: "--stats requires a path") + } + statsPath = value + case "--help", "-h": + printHelp() + Foundation.exit(0) + default: + throw ClaudeProbeError(message: "unknown argument: \(argument)") + } + } + + return ProbeConfiguration( + claudeBinary: claudeBinary, + statsPath: statsPath ?? defaultStatsPath() + ) + } + + private static func defaultStatsPath() -> String { + let environment = ProcessInfo.processInfo.environment + let home = environment["HOME"] ?? FileManager.default.homeDirectoryForCurrentUser.path + return "\(home)/.claude/stats-cache.json" + } + + private static func printHelp() { + print(""" + Usage: swift run ClaudeLimitProbe [--claude-bin claude] [--stats ~/.claude/stats-cache.json] + + Prints a redacted Claude local status summary. This probe intentionally + does not read Keychain secrets, token files, raw transcripts, emails, + account IDs, org IDs, or provider response bodies. + """) + } +} + +@main +struct ClaudeLimitProbe { + static func main() async { + do { + let configuration = try ProbeConfiguration.fromArguments(CommandLine.arguments) + let connector = ClaudeLocalStatusConnector(accounts: [ + ClaudeAccountConfiguration( + claudeBinary: configuration.claudeBinary, + statsPath: configuration.statsPath + ) + ]) + let result = await connector.refresh(now: Date()) + printSummary(result: result, statsPath: configuration.statsPath) + } catch { + fputs("ClaudeLimitProbe failed: \(error.localizedDescription)\n", stderr) + Foundation.exit(1) + } + } + + private static func printSummary(result: ConnectorRefreshResult, statsPath: String) { + print("Claude local status probe") + print("stats cache: \(ConnectorRedactor.redactedPath(statsPath))") + print("accounts: \(result.reports.count)") + print("limits: \(result.snapshot.limits.count)") + print("redacted: tokens, Keychain secrets, account identifiers, org identifiers, emails, raw transcripts, raw provider responses") + print("") + + for report in result.reports { + print("- \(report.accountName): \(report.status.rawValue)") + if let errorMessage = report.errorMessage { + print(" error: \(errorMessage)") + } + for limit in report.limits { + print(" - \(limit.label): \(limit.status.rawValue)") + if let note = limit.note { + print(" \(note)") + } + } + } + print("official non-interactive subscription percentage: not exposed by Claude Code") + print("Every Code estimate: shown when ccusage aggregate block data is available") + } +} diff --git a/Sources/ClaudeWebUsageProbe/ClaudeWebUsageProbeApp.swift b/Sources/ClaudeWebUsageProbe/ClaudeWebUsageProbeApp.swift new file mode 100644 index 0000000..aa91d20 --- /dev/null +++ b/Sources/ClaudeWebUsageProbe/ClaudeWebUsageProbeApp.swift @@ -0,0 +1,583 @@ +import ContextPanelCore +import AppKit +import SwiftUI +import UniformTypeIdentifiers +import WebKit + +@main +struct ClaudeWebUsageProbeApp: App { + init() { + NSApplication.shared.setActivationPolicy(.regular) + DispatchQueue.main.async { + NSApplication.shared.activate(ignoringOtherApps: true) + } + } + + var body: some Scene { + WindowGroup { + ClaudeUsageProbeRootView() + .frame(minWidth: 1180, minHeight: 760) + } + } +} + +struct ClaudeUsageProbeRootView: View { + @StateObject private var model = ClaudeUsageProbeModel() + + var body: some View { + HStack(spacing: 0) { + sidebar + .frame(width: 390) + .padding(18) + .background(Color(nsColor: .controlBackgroundColor)) + + Divider() + + ClaudeProbeWebView(model: model) + } + .fileExporter( + isPresented: $model.isExportingReport, + document: ProbeReportDocument(report: model.reportMarkdown), + contentType: .plainText, + defaultFilename: "claude-web-usage-probe-report.md" + ) { _ in } + } + + private var sidebar: some View { + VStack(alignment: .leading, spacing: 14) { + header + controls + status + Divider() + capturedLimits + sanitizedFields + Spacer() + safetyFooter + } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Claude Usage Probe") + .font(.system(size: 24, weight: .semibold)) + Text("Log in to Claude in this window, open Usage, then capture official subscription windows from the authenticated page context.") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + } + } + + private var controls: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Button("Open Usage") { model.openUsagePage() } + Button("Reload") { model.reload() } + Button("Export") { model.exportReport() } + } + + HStack { + Button("Save Snapshot") { model.saveSnapshot() } + .disabled(model.limits.isEmpty) + Button("Clear") { model.clear() } + } + } + } + + private var status: some View { + VStack(alignment: .leading, spacing: 6) { + Label(model.statusText, systemImage: model.statusIcon) + .font(.system(size: 12, weight: .medium)) + Text(model.currentURLText) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(.tertiary) + .lineLimit(2) + } + .foregroundStyle(model.hasCapturedUsage ? .primary : .secondary) + } + + private var capturedLimits: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Captured windows") + .font(.system(size: 12, weight: .semibold)) + .textCase(.uppercase) + .foregroundStyle(.secondary) + Spacer() + Text("\(model.limits.count)") + .font(.system(.caption, design: .monospaced, weight: .medium)) + .foregroundStyle(.secondary) + } + + if model.limits.isEmpty { + ContentUnavailableView( + "No usage windows yet", + systemImage: "gauge.with.dots.needle.67percent", + description: Text("Complete Claude login or verification, then wait for the Usage page to load.") + ) + .frame(maxHeight: 220) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + ForEach(model.limits) { limit in + ClaudeUsageLimitRow(limit: limit) + } + } + } + .frame(maxHeight: 280) + } + } + } + + private var sanitizedFields: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Sanitized fields") + .font(.system(size: 12, weight: .semibold)) + .textCase(.uppercase) + .foregroundStyle(.secondary) + Spacer() + Text("\(model.fieldPaths.count)") + .font(.system(.caption, design: .monospaced, weight: .medium)) + .foregroundStyle(.secondary) + } + + if model.fieldPaths.isEmpty { + Text("No Claude usage response fields captured yet.") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 4) { + ForEach(model.fieldPaths.prefix(18), id: \.self) { field in + Text(field) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: 160) + } + } + } + + private var safetyFooter: some View { + VStack(alignment: .leading, spacing: 6) { + Label("Login stays inside the visible web session.", systemImage: "person.crop.circle.badge.checkmark") + Label("Only percent windows, reset times, and field paths leave the page.", systemImage: "lock.shield") + Label("No cookies, auth headers, tokens, local storage, emails, org IDs, or raw bodies are stored.", systemImage: "eye.slash") + } + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } +} + +struct ClaudeUsageLimitRow: View { + let limit: UsageLimit + + var body: some View { + VStack(alignment: .leading, spacing: 7) { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 2) { + Text(limit.displayLabel) + .font(.system(size: 13, weight: .semibold)) + Text(limit.contextLabel) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + Spacer() + Text(percentText) + .font(.system(size: 18, weight: .semibold, design: .rounded)) + } + + ProgressView(value: limit.usageRatio ?? 0) + .tint(tint) + + Text(resetText) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(.tertiary) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(nsColor: .textBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + + private var percentText: String { + guard let ratio = limit.usageRatio else { return "?" } + return "\(Int((ratio * 100).rounded()))%" + } + + private var resetText: String { + guard let resetsAt = limit.resetsAt else { return "reset unknown" } + return "resets " + resetsAt.formatted(date: .abbreviated, time: .shortened) + } + + private var tint: Color { + switch limit.status { + case .limited: + .red + case .close: + .orange + case .healthy: + .green + default: + .secondary + } + } +} + +@MainActor +final class ClaudeUsageProbeModel: ObservableObject { + @Published var limits: [UsageLimit] = [] + @Published var fieldPaths: [String] = [] + @Published var statusText = "Waiting for Claude usage response" + @Published var statusIcon = "clock" + @Published var currentURLText = "" + @Published var isExportingReport = false + + private let snapshotStore = JSONSnapshotStore(rootDirectory: ContextPanelLocations.snapshotDirectory()) + + private lazy var navigationDelegate = ClaudeUsageNavigationDelegate(owner: self) + + lazy var webView: WKWebView = { + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .default() + configuration.userContentController.add(ClaudeUsageScriptHandler(owner: self), name: "claudeUsageProbe") + configuration.userContentController.addUserScript( + WKUserScript(source: Self.networkProbeScript, injectionTime: .atDocumentStart, forMainFrameOnly: false) + ) + + let view = WKWebView(frame: .zero, configuration: configuration) + view.navigationDelegate = navigationDelegate + return view + }() + + init() { + openUsagePage() + } + + var hasCapturedUsage: Bool { !limits.isEmpty } + + var reportMarkdown: String { + var lines = [ + "# Claude Web Usage Probe Report", + "", + "- Captured: \(Date().ISO8601Format())", + "- Windows: \(limits.count)", + "- Sanitized fields: \(fieldPaths.count)", + "", + "## Captured Windows", + ] + + if limits.isEmpty { + lines.append("- No usage windows captured.") + } else { + for limit in limits { + let percent = limit.used.map { "\($0)%" } ?? "unknown" + let reset = limit.resetsAt?.ISO8601Format() ?? "unknown reset" + lines.append("- \(limit.displayLabel) / \(limit.contextLabel): \(percent), resets \(reset)") + } + } + + lines.append(contentsOf: [ + "", + "## Sanitized Fields", + ]) + + if fieldPaths.isEmpty { + lines.append("- No field paths captured.") + } else { + lines.append(contentsOf: fieldPaths.map { "- `\($0)`" }) + } + + lines.append(contentsOf: [ + "", + "## Redactions", + "- cookies", + "- authorization headers", + "- bearer tokens", + "- Keychain credentials", + "- OAuth tokens", + "- local storage", + "- account and organization identifiers", + "- emails", + "- raw response bodies", + "- transcripts and prompt/response content", + ]) + + return lines.joined(separator: "\n") + } + + func openUsagePage() { + load("https://claude.ai/settings/usage") + } + + func reload() { + statusText = "Reloading Claude usage page" + statusIcon = "arrow.clockwise" + webView.reload() + } + + func clear() { + limits = [] + fieldPaths = [] + statusText = "Waiting for Claude usage response" + statusIcon = "clock" + } + + func exportReport() { + isExportingReport = true + } + + func saveSnapshot() { + guard !limits.isEmpty else { return } + do { + try saveCurrentSnapshot() + statusText = "Saved sanitized Claude usage snapshot" + statusIcon = "checkmark.circle" + } catch { + statusText = "Save failed: \(error.localizedDescription)" + statusIcon = "exclamationmark.triangle" + } + } + + fileprivate func record(payload: [String: Any]) { + let windows = payload["windows"] as? [String: Any] ?? [:] + let fields = payload["fields"] as? [String] ?? [] + let wrapped = ["rate_limits": windows] + + do { + let data = try JSONSerialization.data(withJSONObject: wrapped) + let parsedLimits = try ClaudeWebUsageParser.usageLimits( + from: data, + accountID: "claude-web", + accountName: "Claude Web", + observedAt: Date() + ) + guard !parsedLimits.isEmpty else { + statusText = "Usage response found, but no percent windows were present" + statusIcon = "questionmark.circle" + return + } + + limits = parsedLimits + fieldPaths = Array(Set(fields)).sorted() + saveSnapshotAfterCapture() + } catch { + statusText = "Capture failed: \(error.localizedDescription)" + statusIcon = "exclamationmark.triangle" + } + } + + private func saveSnapshotAfterCapture() { + do { + try saveCurrentSnapshot() + statusText = "Captured and saved Claude subscription usage" + statusIcon = "checkmark.circle.fill" + } catch { + statusText = "Captured Claude usage; save failed: \(error.localizedDescription)" + statusIcon = "exclamationmark.triangle" + } + } + + private func saveCurrentSnapshot() throws { + let report = ProviderConnectorReport( + provider: .anthropic, + accountID: "claude-web", + accountName: "Claude Web", + generatedAt: Date(), + limits: limits, + status: .healthy + ) + try snapshotStore.saveMerged( + refreshResult: ConnectorRefreshResult(generatedAt: Date(), reports: [report]), + savedAt: Date() + ) + } + + fileprivate func updateCurrentURL(_ url: URL?) { + currentURLText = url?.absoluteString ?? "" + if let host = url?.host, host.contains("claude.ai"), limits.isEmpty { + statusText = "Claude page loaded; waiting for usage API" + statusIcon = "network" + } + } + + private func load(_ rawURL: String) { + guard let url = URL(string: rawURL) else { return } + statusText = "Opening Claude usage page" + statusIcon = "safari" + webView.load(URLRequest(url: url)) + } + + private static let networkProbeScript = #""" + (() => { + if (window.__contextPanelClaudeUsageProbeInstalled) return; + window.__contextPanelClaudeUsageProbeInstalled = true; + + const windowKeys = new Set([ + 'five_hour', + 'seven_day', + 'seven_day_opus', + 'seven_day_sonnet', + 'seven_day_oauth_apps' + ]); + const fieldKeys = new Set([ + 'used_percentage', + 'remaining_percentage', + 'utilization', + 'resets_at', + 'reset_at' + ]); + + function isUsageURL(rawUrl) { + try { + const url = new URL(rawUrl, window.location.href); + return /^\/api\/organizations\/[^/]+\/usage$/.test(url.pathname); + } catch (_) { + return false; + } + } + + function sanitizeWindow(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const sanitized = {}; + for (const key of fieldKeys) { + const raw = value[key]; + if (typeof raw === 'number' || typeof raw === 'string') sanitized[key] = raw; + } + return Object.keys(sanitized).length ? sanitized : null; + } + + function collectWindows(value, out = {}) { + if (!value || typeof value !== 'object') return out; + if (Array.isArray(value)) { + value.slice(0, 3).forEach(item => collectWindows(item, out)); + return out; + } + for (const [key, child] of Object.entries(value)) { + if (windowKeys.has(key)) { + const sanitized = sanitizeWindow(child); + if (sanitized) out[key] = sanitized; + } + collectWindows(child, out); + } + return out; + } + + function collectFields(value, prefix = '', out = new Set()) { + if (!value || typeof value !== 'object' || out.size > 80) return out; + if (Array.isArray(value)) { + value.slice(0, 3).forEach(item => collectFields(item, prefix, out)); + return out; + } + for (const [key, child] of Object.entries(value)) { + const path = prefix ? `${prefix}.${key}` : key; + if (windowKeys.has(key) || fieldKeys.has(key) || key === 'rate_limits' || key === 'usage') out.add(path); + collectFields(child, path, out); + } + return out; + } + + function post(payload) { + try { window.webkit.messageHandlers.claudeUsageProbe.postMessage(payload); } + catch (_) {} + } + + function inspect(url, contentType, text) { + if (!isUsageURL(url) || !/json/i.test(contentType || '')) return; + try { + const parsed = JSON.parse(String(text || '')); + const windows = collectWindows(parsed); + if (!Object.keys(windows).length) return; + post({ windows, fields: Array.from(collectFields(parsed)).slice(0, 80) }); + } catch (_) {} + } + + const originalFetch = window.fetch; + if (originalFetch) { + window.fetch = async function(input, init) { + const response = await originalFetch.apply(this, arguments); + try { + const clone = response.clone(); + const url = typeof input === 'string' ? input : (input && input.url) || ''; + clone.text().then(text => inspect(url, clone.headers.get('content-type') || '', text)).catch(() => {}); + } catch (_) {} + return response; + }; + } + + const originalOpen = XMLHttpRequest.prototype.open; + const originalSend = XMLHttpRequest.prototype.send; + XMLHttpRequest.prototype.open = function(method, url) { + this.__cpClaudeUsageUrl = url; + return originalOpen.apply(this, arguments); + }; + XMLHttpRequest.prototype.send = function() { + this.addEventListener('load', function() { + try { inspect(this.__cpClaudeUsageUrl || '', this.getResponseHeader('content-type') || '', this.responseText || ''); } + catch (_) {} + }); + return originalSend.apply(this, arguments); + }; + })(); + """# +} + +final class ClaudeUsageScriptHandler: NSObject, WKScriptMessageHandler { + weak var owner: ClaudeUsageProbeModel? + + init(owner: ClaudeUsageProbeModel) { + self.owner = owner + } + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + guard let payload = message.body as? [String: Any] else { return } + Task { @MainActor [weak owner = self.owner] in + owner?.record(payload: payload) + } + } +} + +final class ClaudeUsageNavigationDelegate: NSObject, WKNavigationDelegate { + weak var owner: ClaudeUsageProbeModel? + + init(owner: ClaudeUsageProbeModel) { + self.owner = owner + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + Task { @MainActor [weak webView, weak owner] in + owner?.updateCurrentURL(webView?.url) + } + } +} + +struct ClaudeProbeWebView: NSViewRepresentable { + @ObservedObject var model: ClaudeUsageProbeModel + + func makeNSView(context: Context) -> WKWebView { + model.webView + } + + func updateNSView(_ nsView: WKWebView, context: Context) {} +} + +struct ProbeReportDocument: FileDocument { + static var readableContentTypes: [UTType] { [.plainText] } + + var report: String + + init(report: String) { + self.report = report + } + + init(configuration: ReadConfiguration) throws { + report = "" + } + + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + FileWrapper(regularFileWithContents: Data(report.utf8)) + } +} diff --git a/Sources/CodexRateLimitProbe/main.swift b/Sources/CodexRateLimitProbe/main.swift new file mode 100644 index 0000000..f82d9df --- /dev/null +++ b/Sources/CodexRateLimitProbe/main.swift @@ -0,0 +1,114 @@ +import ContextPanelCore +import Foundation + +struct CodexProbeError: LocalizedError { + let message: String + + var errorDescription: String? { message } +} + +struct ProbeConfiguration { + let authPath: String + let endpoint: URL + + static func fromArguments(_ arguments: [String]) throws -> ProbeConfiguration { + var authPath: String? + var endpoint = URL(string: "https://chatgpt.com/backend-api/wham/usage")! + var iterator = arguments.dropFirst().makeIterator() + + while let argument = iterator.next() { + switch argument { + case "--auth": + guard let value = iterator.next() else { + throw CodexProbeError(message: "--auth requires a path") + } + authPath = value + case "--endpoint": + guard let value = iterator.next(), let url = URL(string: value) else { + throw CodexProbeError(message: "--endpoint requires an absolute URL") + } + endpoint = url + case "--help", "-h": + printHelp() + Foundation.exit(0) + default: + throw CodexProbeError(message: "unknown argument: \(argument)") + } + } + + return ProbeConfiguration( + authPath: authPath ?? defaultAuthPath(), + endpoint: endpoint + ) + } + + private static func defaultAuthPath() -> String { + let environment = ProcessInfo.processInfo.environment + let home = environment["HOME"] ?? FileManager.default.homeDirectoryForCurrentUser.path + let codexHome = environment["CODEX_HOME"] ?? "\(home)/.codex" + return "\(codexHome)/auth.json" + } + + private static func printHelp() { + print(""" + Usage: swift run CodexRateLimitProbe [--auth /path/to/auth.json] [--endpoint URL] + + Calls the live Codex usage endpoint directly and prints only a redacted + summary of limit buckets, windows, plan type, and credits. Tokens, + account identifiers, emails, headers, and raw response bodies are never + printed. + """) + } +} + +@main +struct CodexRateLimitProbe { + static func main() async { + do { + let configuration = try ProbeConfiguration.fromArguments(CommandLine.arguments) + let connector = CodexRateLimitConnector(accounts: [ + CodexAccountConfiguration(authPath: configuration.authPath, endpoint: configuration.endpoint) + ]) + let result = await connector.refresh(now: Date()) + printSummary(result: result, endpoint: configuration.endpoint, authPath: configuration.authPath) + } catch { + fputs("CodexRateLimitProbe failed: \(error.localizedDescription)\n", stderr) + Foundation.exit(1) + } + } + + private static func printSummary(result: ConnectorRefreshResult, endpoint: URL, authPath: String) { + print("Codex live usage endpoint probe") + print("endpoint: \(endpoint.absoluteString)") + print("auth: \(redactedAuthPath(authPath))") + print("accounts: \(result.reports.count)") + print("limits: \(result.snapshot.limits.count)") + print("redacted: tokens, account identifiers, emails, headers, raw response bodies") + print("") + + for report in result.reports { + print("- \(report.accountName): \(report.status.rawValue)") + if let errorMessage = report.errorMessage { + print(" error: \(errorMessage)") + } + for limit in report.limits { + print(" - \(limit.label): \(format(limit: limit))") + } + } + } + + private static func format(limit: UsageLimit) -> String { + let used = limit.used.map { "\($0)% used" } ?? "unknown used" + let reset = limit.resetsAt.map { ContextPanelDateFormatting.string(from: $0) } ?? "unknown reset" + return "\(used) / resets \(reset)" + } + + private static func redactedAuthPath(_ path: String) -> String { + let expanded = NSString(string: path).expandingTildeInPath + let home = FileManager.default.homeDirectoryForCurrentUser.path + if expanded.hasPrefix(home) { + return "~" + expanded.dropFirst(home.count) + } + return URL(fileURLWithPath: expanded).lastPathComponent + } +} diff --git a/Sources/ContextPanelCore/AccountConfigurationStore.swift b/Sources/ContextPanelCore/AccountConfigurationStore.swift new file mode 100644 index 0000000..45e0ba1 --- /dev/null +++ b/Sources/ContextPanelCore/AccountConfigurationStore.swift @@ -0,0 +1,214 @@ +import Foundation + +public enum AccountConnectorKind: String, Codable, Equatable, Sendable { + case codexRateLimits + case geminiCodeAssist + case claudeLocalStatus +} + +public struct LocalProviderAccountConfiguration: Codable, Equatable, Identifiable, Sendable { + public let id: String + public let provider: Provider + public let connectorKind: AccountConnectorKind + public var displayName: String + public var isEnabled: Bool + public var authPath: String? + public var commandPath: String? + public var statsPath: String? + public var oauthClientIDEnvironmentName: String? + public var oauthClientSecretEnvironmentName: String? + + public init( + id: String, + provider: Provider, + connectorKind: AccountConnectorKind, + displayName: String, + isEnabled: Bool = true, + authPath: String? = nil, + commandPath: String? = nil, + statsPath: String? = nil, + oauthClientIDEnvironmentName: String? = nil, + oauthClientSecretEnvironmentName: String? = nil + ) { + self.id = id + self.provider = provider + self.connectorKind = connectorKind + self.displayName = displayName + self.isEnabled = isEnabled + self.authPath = authPath + self.commandPath = commandPath + self.statsPath = statsPath + self.oauthClientIDEnvironmentName = oauthClientIDEnvironmentName + self.oauthClientSecretEnvironmentName = oauthClientSecretEnvironmentName + } +} + +public struct AccountConfigurationDocument: Codable, Equatable, Sendable { + public let schemaVersion: Int + public var updatedAt: Date + public var accounts: [LocalProviderAccountConfiguration] + + public init(updatedAt: Date, accounts: [LocalProviderAccountConfiguration]) { + schemaVersion = 1 + self.updatedAt = updatedAt + self.accounts = accounts + } +} + +public struct AccountConfigurationLoadResult: Equatable, Sendable { + public let document: AccountConfigurationDocument + public let status: UsageStatus + public let errorMessage: String? + + public init(document: AccountConfigurationDocument, status: UsageStatus, errorMessage: String? = nil) { + self.document = document + self.status = status + self.errorMessage = errorMessage.map(ConnectorRedactor.redact) + } +} + +public struct AccountConfigurationStore: Sendable { + public let configurationURL: URL + + public init(configurationURL: URL) { + self.configurationURL = configurationURL + } + + public func load(now: Date = Date()) -> AccountConfigurationLoadResult { + guard FileManager.default.fileExists(atPath: configurationURL.path) else { + return AccountConfigurationLoadResult(document: Self.defaultDocument(now: now), status: .unknown) + } + + do { + let document = try Self.makeDecoder().decode( + AccountConfigurationDocument.self, + from: try Data(contentsOf: configurationURL) + ) + guard document.schemaVersion == 1 else { + throw SnapshotStoreError.unsupportedSchema(version: document.schemaVersion) + } + return AccountConfigurationLoadResult(document: document, status: .healthy) + } catch { + return AccountConfigurationLoadResult( + document: Self.defaultDocument(now: now), + status: .failure, + errorMessage: error.localizedDescription + ) + } + } + + public func save(_ document: AccountConfigurationDocument) throws { + let directory = configurationURL.deletingLastPathComponent() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let data = try Self.makeEncoder().encode(document) + try data.write(to: configurationURL, options: [.atomic]) + } + + public static func defaultDocument(now: Date = Date()) -> AccountConfigurationDocument { + let home = FileManager.default.homeDirectoryForCurrentUser.path + return AccountConfigurationDocument(updatedAt: now, accounts: [ + LocalProviderAccountConfiguration( + id: "openai-codex-default", + provider: .openAI, + connectorKind: .codexRateLimits, + displayName: "Codex", + authPath: "\(home)/.codex/auth.json" + ), + LocalProviderAccountConfiguration( + id: "claude-local-default", + provider: .anthropic, + connectorKind: .claudeLocalStatus, + displayName: "Claude", + commandPath: "claude", + statsPath: "\(home)/.claude/stats-cache.json" + ), + LocalProviderAccountConfiguration( + id: "gemini-code-assist-default", + provider: .google, + connectorKind: .geminiCodeAssist, + displayName: "Gemini", + isEnabled: false, + authPath: "\(home)/.gemini/oauth_creds.json", + oauthClientIDEnvironmentName: "GEMINI_OAUTH_CLIENT_ID", + oauthClientSecretEnvironmentName: "GEMINI_OAUTH_CLIENT_SECRET" + ), + ]) + } + + private static func makeEncoder() -> JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + encoder.dateEncodingStrategy = .iso8601 + return encoder + } + + private static func makeDecoder() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + } +} + +public enum AccountConnectorFactory { + public static func connectors( + from document: AccountConfigurationDocument, + environment: [String: String] = ProcessInfo.processInfo.environment, + geminiMetadataFileLoader: @escaping @Sendable (String) throws -> String = { path in + try String(contentsOfFile: NSString(string: path).expandingTildeInPath, encoding: .utf8) + }, + geminiMetadataFileExists: @escaping @Sendable (String) -> Bool = { path in + FileManager.default.fileExists(atPath: NSString(string: path).expandingTildeInPath) + }, + geminiMetadataDirectoryLister: @escaping @Sendable (String) -> [String] = { path in + let expanded = NSString(string: path).expandingTildeInPath + return (try? FileManager.default.contentsOfDirectory(atPath: expanded).map { "\(expanded)/\($0)" }) ?? [] + } + ) -> [any ProviderConnector] { + document.accounts.compactMap { account in + guard account.isEnabled else { return nil } + switch account.connectorKind { + case .codexRateLimits: + guard let authPath = account.authPath else { return nil } + return CodexRateLimitConnector(accounts: [CodexAccountConfiguration( + authPath: authPath, + accountName: account.displayName + )]) + case .geminiCodeAssist: + guard let authPath = account.authPath else { return nil } + let configuredMetadata = geminiMetadata(account: account, environment: environment) + let discoveredMetadata = GeminiOAuthClientMetadataDiscovery.discover( + environment: environment, + fileLoader: geminiMetadataFileLoader, + fileExists: geminiMetadataFileExists, + directoryLister: geminiMetadataDirectoryLister + ) + guard let metadata = configuredMetadata ?? discoveredMetadata else { return nil } + return GeminiCodeAssistConnector(accounts: [GeminiAccountConfiguration( + authPath: authPath, + accountName: account.displayName, + clientID: metadata.clientID, + clientSecret: metadata.clientSecret + )]) + case .claudeLocalStatus: + return ClaudeLocalStatusConnector(accounts: [ClaudeAccountConfiguration( + accountName: account.displayName, + claudeBinary: account.commandPath ?? "claude", + statsPath: account.statsPath + )]) + } + } + } + + private static func geminiMetadata( + account: LocalProviderAccountConfiguration, + environment: [String: String] + ) -> GeminiOAuthClientMetadata? { + guard + let clientIDName = account.oauthClientIDEnvironmentName, + let clientSecretName = account.oauthClientSecretEnvironmentName, + let clientID = environment[clientIDName], !clientID.isEmpty, + let clientSecret = environment[clientSecretName], !clientSecret.isEmpty + else { return nil } + return GeminiOAuthClientMetadata(clientID: clientID, clientSecret: clientSecret) + } +} diff --git a/Sources/ContextPanelCore/ClaudeLocalStatus.swift b/Sources/ContextPanelCore/ClaudeLocalStatus.swift new file mode 100644 index 0000000..4d484ce --- /dev/null +++ b/Sources/ContextPanelCore/ClaudeLocalStatus.swift @@ -0,0 +1,567 @@ +import Foundation + +public struct ClaudeAuthStatus: Codable, Equatable, Sendable { + public let loggedIn: Bool + public let authMethod: String + public let apiProvider: String? + public let subscriptionType: String? + + public init(loggedIn: Bool, authMethod: String, apiProvider: String?, subscriptionType: String?) { + self.loggedIn = loggedIn + self.authMethod = authMethod + self.apiProvider = apiProvider + self.subscriptionType = subscriptionType + } + + public var subscriptionDisplayName: String { + guard let subscriptionType, !subscriptionType.isEmpty else { return "Claude" } + return "Claude \(subscriptionType.capitalized)" + } +} + +public struct ClaudeStatsCacheSummary: Codable, Equatable, Sendable { + public let version: Int? + public let lastComputedDate: Date? + public let totalSessions: Int? + public let totalMessages: Int? + public let firstSessionDate: Date? + public let modelUsageCount: Int + public let dailyActivityCount: Int + public let rateLimitSnapshot: ClaudeSubscriptionRateLimitSnapshot? + + public init( + version: Int?, + lastComputedDate: Date?, + totalSessions: Int?, + totalMessages: Int?, + firstSessionDate: Date?, + modelUsageCount: Int, + dailyActivityCount: Int, + rateLimitSnapshot: ClaudeSubscriptionRateLimitSnapshot? = nil + ) { + self.version = version + self.lastComputedDate = lastComputedDate + self.totalSessions = totalSessions + self.totalMessages = totalMessages + self.firstSessionDate = firstSessionDate + self.modelUsageCount = modelUsageCount + self.dailyActivityCount = dailyActivityCount + self.rateLimitSnapshot = rateLimitSnapshot + } +} + +public struct ClaudeUsageBlock: Codable, Equatable, Sendable { + public let isActive: Bool + public let totalTokens: Int? + public let endTime: Date? + public let projectedTotalTokens: Int? + public let remainingMinutes: Int? + public let modelCount: Int + + public init( + isActive: Bool, + totalTokens: Int?, + endTime: Date?, + projectedTotalTokens: Int?, + remainingMinutes: Int?, + modelCount: Int + ) { + self.isActive = isActive + self.totalTokens = totalTokens + self.endTime = endTime + self.projectedTotalTokens = projectedTotalTokens + self.remainingMinutes = remainingMinutes + self.modelCount = modelCount + } +} + +public struct ClaudeUsageBlocksSummary: Codable, Equatable, Sendable { + public let activeBlock: ClaudeUsageBlock? + public let completedBlockTokenLimitEstimate: Int? + + public init(activeBlock: ClaudeUsageBlock?, completedBlockTokenLimitEstimate: Int?) { + self.activeBlock = activeBlock + self.completedBlockTokenLimitEstimate = completedBlockTokenLimitEstimate + } +} + +public enum ClaudeUsageBlocksParser { + public static func summary(from data: Data) throws -> ClaudeUsageBlocksSummary { + let payload = try JSONDecoder.contextPanelFlexibleDates.decode(ClaudeUsageBlocksPayload.self, from: data) + let active = payload.blocks.first { $0.isActive }?.usageBlock + let completedTokens = payload.blocks + .filter { !$0.isActive } + .compactMap(\.totalTokens) + .filter { $0 > 0 } + .sorted() + let estimate = completedTokens.isEmpty ? nil : completedTokens[Int(Double(completedTokens.count - 1) * 0.95)] + return ClaudeUsageBlocksSummary(activeBlock: active, completedBlockTokenLimitEstimate: estimate) + } +} + +public struct ClaudeSubscriptionRateLimitWindow: Codable, Equatable, Sendable { + public let label: String + public let usedPercent: Double + public let resetsAt: Date? + + public init(label: String, usedPercent: Double, resetsAt: Date?) { + self.label = label + self.usedPercent = max(0, min(usedPercent, 100)) + self.resetsAt = resetsAt + } +} + +public struct ClaudeSubscriptionRateLimitSnapshot: Codable, Equatable, Sendable { + public let observedAt: Date + public let windows: [ClaudeSubscriptionRateLimitWindow] + + public init(observedAt: Date, windows: [ClaudeSubscriptionRateLimitWindow]) { + self.observedAt = observedAt + self.windows = windows + } +} + +public enum ClaudeAuthStatusParser { + public static func status(from data: Data) throws -> ClaudeAuthStatus { + let payload = try JSONDecoder().decode(ClaudeAuthStatusPayload.self, from: data) + return ClaudeAuthStatus( + loggedIn: payload.loggedIn, + authMethod: payload.authMethod, + apiProvider: payload.apiProvider, + subscriptionType: payload.subscriptionType + ) + } +} + +public enum ClaudeStatsCacheParser { + public static func summary(from data: Data) throws -> ClaudeStatsCacheSummary { + let payload = try JSONDecoder.contextPanelFlexibleDates.decode(ClaudeStatsCachePayload.self, from: data) + return ClaudeStatsCacheSummary( + version: payload.version, + lastComputedDate: payload.lastComputedDate, + totalSessions: payload.totalSessions, + totalMessages: payload.totalMessages, + firstSessionDate: payload.firstSessionDate, + modelUsageCount: payload.modelUsage?.count ?? 0, + dailyActivityCount: payload.dailyActivity?.count ?? 0, + rateLimitSnapshot: payload.rateLimits?.snapshot( + observedAt: payload.lastComputedDate ?? Date(timeIntervalSince1970: 0) + ) + ) + } +} + +public enum ClaudeSubscriptionRateLimitCacheParser { + public static func snapshot(from data: Data) throws -> ClaudeSubscriptionRateLimitSnapshot { + let payload = try JSONDecoder().decode(ClaudeStatuslineRateLimitPayload.self, from: data) + var windows: [ClaudeSubscriptionRateLimitWindow] = [] + if let fiveHour = payload.rateLimits.fiveHour { + windows.append(fiveHour.window(label: "5-hour")) + } + if let sevenDay = payload.rateLimits.sevenDay { + windows.append(sevenDay.window(label: "Weekly")) + } + return ClaudeSubscriptionRateLimitSnapshot( + observedAt: Date(timeIntervalSince1970: TimeInterval(payload.observedAt)), + windows: windows + ) + } +} + +public struct ClaudeAccountConfiguration: Equatable, Sendable { + public let accountName: String + public let claudeBinary: String + public let statsPath: String + public let rateLimitSnapshotPath: String + public let rateLimitSnapshotMaximumAge: TimeInterval + public let usageBlocksPath: String? + + public init( + accountName: String = "Claude", + claudeBinary: String = "claude", + statsPath: String? = nil, + rateLimitSnapshotPath: String? = nil, + rateLimitSnapshotMaximumAge: TimeInterval = 30 * 60, + usageBlocksPath: String? = nil + ) { + self.accountName = accountName + self.claudeBinary = claudeBinary + let home = FileManager.default.homeDirectoryForCurrentUser.path + self.statsPath = statsPath ?? "\(home)/.claude/stats-cache.json" + self.rateLimitSnapshotPath = rateLimitSnapshotPath + ?? "\(home)/Library/Application Support/Context Panel/ClaudeRateLimits/statusline-cache.json" + self.rateLimitSnapshotMaximumAge = rateLimitSnapshotMaximumAge + self.usageBlocksPath = usageBlocksPath + ?? "\(home)/Library/Application Support/Context Panel/ClaudeRateLimits/ccusage-blocks-cache.json" + } +} + +public struct ClaudeLocalStatusConnector: ProviderConnector { + public let provider: Provider = .anthropic + + private let accounts: [ClaudeAccountConfiguration] + private let processClient: any ConnectorProcessClient + private let fileLoader: @Sendable (String) throws -> Data + private let fileExists: @Sendable (String) -> Bool + + public init( + accounts: [ClaudeAccountConfiguration], + processClient: any ConnectorProcessClient = DefaultConnectorProcessClient(), + fileLoader: @escaping @Sendable (String) throws -> Data = { path in + try Data(contentsOf: URL(fileURLWithPath: NSString(string: path).expandingTildeInPath)) + }, + fileExists: @escaping @Sendable (String) -> Bool = { path in + FileManager.default.fileExists(atPath: NSString(string: path).expandingTildeInPath) + } + ) { + self.accounts = accounts + self.processClient = processClient + self.fileLoader = fileLoader + self.fileExists = fileExists + } + + public func refresh(now: Date) async -> ConnectorRefreshResult { + var reports: [ProviderConnectorReport] = [] + reports.reserveCapacity(accounts.count) + for account in accounts { + reports.append(refresh(account: account, now: now)) + } + return ConnectorRefreshResult(generatedAt: now, reports: reports) + } + + private func refresh(account: ClaudeAccountConfiguration, now: Date) -> ProviderConnectorReport { + let localAccountID = ConnectorRedactor.localAccountID(provider: provider, path: account.rateLimitSnapshotPath) + + do { + let authStatus = try loadAuthStatus(claudeBinary: account.claudeBinary) + let statsSummary = try loadStatsSummary(path: account.statsPath) + let rateLimitSnapshot = try loadRateLimitSnapshot(path: account.rateLimitSnapshotPath) + ?? statsSummary?.rateLimitSnapshot + let usageBlocksSummary = try loadUsageBlocksSummary(path: account.usageBlocksPath) + let limits = claudeLocalStatusLimits( + authStatus: authStatus, + statsSummary: statsSummary, + rateLimitSnapshot: rateLimitSnapshot, + rateLimitSnapshotMaximumAge: account.rateLimitSnapshotMaximumAge, + usageBlocksSummary: usageBlocksSummary, + accountID: localAccountID, + accountName: account.accountName, + observedAt: now + ) + return ProviderConnectorReport( + provider: provider, + accountID: localAccountID, + accountName: account.accountName, + generatedAt: now, + limits: limits, + status: authStatus.loggedIn ? limits.map(\.status).contextPanelWorstStatus : .failure, + errorMessage: authStatus.loggedIn ? nil : "Claude CLI is not logged in" + ) + } catch { + return ProviderConnectorReport( + provider: provider, + accountID: localAccountID, + accountName: account.accountName, + generatedAt: now, + limits: [], + status: .failure, + errorMessage: error.localizedDescription + ) + } + } + + private func loadAuthStatus(claudeBinary: String) throws -> ClaudeAuthStatus { + let result = try processClient.run(executable: claudeBinary, arguments: ["auth", "status", "--json"]) + guard result.exitCode == 0 else { + throw ConnectorError.processFailure(operation: "claude auth status", exitCode: result.exitCode) + } + return try ClaudeAuthStatusParser.status(from: result.stdout) + } + + private func loadStatsSummary(path: String) throws -> ClaudeStatsCacheSummary? { + guard fileExists(path) else { return nil } + return try ClaudeStatsCacheParser.summary(from: try fileLoader(path)) + } + + private func loadRateLimitSnapshot(path: String) throws -> ClaudeSubscriptionRateLimitSnapshot? { + guard fileExists(path) else { return nil } + return try ClaudeSubscriptionRateLimitCacheParser.snapshot(from: try fileLoader(path)) + } + + private func loadUsageBlocksSummary(path: String?) throws -> ClaudeUsageBlocksSummary? { + guard let path, fileExists(path) else { return nil } + return try ClaudeUsageBlocksParser.summary(from: try fileLoader(path)) + } +} + +public func claudeLocalStatusLimits( + authStatus: ClaudeAuthStatus, + statsSummary: ClaudeStatsCacheSummary?, + rateLimitSnapshot: ClaudeSubscriptionRateLimitSnapshot? = nil, + rateLimitSnapshotMaximumAge: TimeInterval = 30 * 60, + usageBlocksSummary: ClaudeUsageBlocksSummary? = nil, + accountID: String, + accountName: String, + observedAt: Date +) -> [UsageLimit] { + if let estimatedLimit = claudeUsageBlockEstimate( + usageBlocksSummary: usageBlocksSummary, + authStatus: authStatus, + accountID: accountID, + accountName: accountName, + observedAt: observedAt + ) { + return [estimatedLimit] + } + + if authStatus.loggedIn, let rateLimitSnapshot, !rateLimitSnapshot.windows.isEmpty { + let isStale = observedAt.timeIntervalSince(rateLimitSnapshot.observedAt) > rateLimitSnapshotMaximumAge + let sourceNote = isStale ? "source: stale Claude Code statusline" : "source: Claude Code statusline" + return rateLimitSnapshot.windows.map { window in + UsageLimit( + provider: .anthropic, + accountID: accountID, + accountName: accountName, + label: "Claude \(window.label)", + windowLabel: window.label, + modelLabel: authStatus.subscriptionDisplayName, + unit: .percent, + used: Int(window.usedPercent.rounded()), + limit: 100, + resetsAt: window.resetsAt, + lastUpdatedAt: rateLimitSnapshot.observedAt, + confidence: .observed, + statusOverride: isStale ? .stale : nil, + note: "\(sourceNote); subscription: \(authStatus.subscriptionType ?? "unknown")" + ) + } + } + + var noteParts = [ + "auth: \(authStatus.authMethod)", + "provider: \(authStatus.apiProvider ?? "unknown")", + "subscription: \(authStatus.subscriptionType ?? "unknown")", + "allowance: not exposed by Claude Code", + ] + if let statsSummary { + noteParts.append("sessions: \(statsSummary.totalSessions.map(String.init) ?? "unknown")") + noteParts.append("messages: \(statsSummary.totalMessages.map(String.init) ?? "unknown")") + } else { + noteParts.append("stats cache: absent") + } + + return [UsageLimit( + provider: .anthropic, + accountID: accountID, + accountName: accountName, + label: "\(authStatus.subscriptionDisplayName) status", + modelLabel: "Claude Code", + unit: .unknown, + used: nil, + limit: nil, + resetsAt: nil, + lastUpdatedAt: statsSummary?.lastComputedDate ?? observedAt, + confidence: .observed, + statusOverride: authStatus.loggedIn ? .unknown : .failure, + note: noteParts.joined(separator: "; ") + )] +} + +private func claudeUsageBlockEstimate( + usageBlocksSummary: ClaudeUsageBlocksSummary?, + authStatus: ClaudeAuthStatus, + accountID: String, + accountName: String, + observedAt: Date +) -> UsageLimit? { + guard + authStatus.loggedIn, + let activeBlock = usageBlocksSummary?.activeBlock, + activeBlock.isActive, + let totalTokens = activeBlock.totalTokens, + totalTokens > 0 + else { return nil } + + let estimatedLimit = usageBlocksSummary?.completedBlockTokenLimitEstimate + ?? activeBlock.projectedTotalTokens + let used: Int? + let limit: Int? + if let estimatedLimit, estimatedLimit > 0 { + used = min(totalTokens, estimatedLimit) + limit = estimatedLimit + } else { + used = nil + limit = nil + } + + let resetsAt = activeBlock.remainingMinutes.map { + observedAt.addingTimeInterval(TimeInterval($0 * 60)) + } ?? activeBlock.endTime + let modelMode = activeBlock.modelCount > 1 ? "mixed models" : "single model" + + return UsageLimit( + provider: .anthropic, + accountID: accountID, + accountName: accountName, + label: "Claude 5-hour estimate", + windowLabel: "5-hour estimated", + modelLabel: authStatus.subscriptionDisplayName, + unit: .tokens, + used: used, + limit: limit, + resetsAt: resetsAt, + lastUpdatedAt: observedAt, + confidence: .estimated, + statusOverride: limit == nil ? .unknown : nil, + note: "source: ccusage local block estimate from Every Code/Claude sessions; \(modelMode); official subscription percentage unavailable in claude -p" + ) +} + +private struct ClaudeStatuslineRateLimitPayload: Decodable { + let observedAt: Int + let rateLimits: ClaudeStatuslineRateLimits + + enum CodingKeys: String, CodingKey { + case observedAt = "observed_at" + case rateLimits = "rate_limits" + } +} + +private struct ClaudeStatuslineRateLimits: Decodable { + let fiveHour: ClaudeStatuslineRateLimitWindow? + let sevenDay: ClaudeStatuslineRateLimitWindow? + + enum CodingKeys: String, CodingKey { + case fiveHour = "five_hour" + case sevenDay = "seven_day" + } + + func snapshot(observedAt: Date) -> ClaudeSubscriptionRateLimitSnapshot? { + var windows: [ClaudeSubscriptionRateLimitWindow] = [] + if let fiveHour { + windows.append(fiveHour.window(label: "5-hour")) + } + if let sevenDay { + windows.append(sevenDay.window(label: "Weekly")) + } + guard !windows.isEmpty else { return nil } + return ClaudeSubscriptionRateLimitSnapshot(observedAt: observedAt, windows: windows) + } +} + +private struct ClaudeStatuslineRateLimitWindow: Decodable { + let usedPercentage: Double + let resetsAt: Int? + + enum CodingKeys: String, CodingKey { + case usedPercentage = "used_percentage" + case resetsAt = "resets_at" + } + + func window(label: String) -> ClaudeSubscriptionRateLimitWindow { + ClaudeSubscriptionRateLimitWindow( + label: label, + usedPercent: usedPercentage, + resetsAt: resetsAt.map { Date(timeIntervalSince1970: TimeInterval($0)) } + ) + } +} + +private struct ClaudeAuthStatusPayload: Decodable { + let loggedIn: Bool + let authMethod: String + let apiProvider: String? + let subscriptionType: String? +} + +private struct ClaudeStatsCachePayload: Decodable { + let version: Int? + let lastComputedDate: Date? + let dailyActivity: ClaudeCountedCollection? + let modelUsage: [String: ClaudeDiscardedValue]? + let totalSessions: Int? + let totalMessages: Int? + let firstSessionDate: Date? + let rateLimits: ClaudeStatuslineRateLimits? + + enum CodingKeys: String, CodingKey { + case version + case lastComputedDate + case dailyActivity + case modelUsage + case totalSessions + case totalMessages + case firstSessionDate + case rateLimits = "rate_limits" + } +} + +private struct ClaudeUsageBlocksPayload: Decodable { + let blocks: [ClaudeUsageBlockPayload] +} + +private struct ClaudeUsageBlockPayload: Decodable { + let isActive: Bool + let totalTokens: Int? + let endTime: Date? + let projection: ClaudeUsageBlockProjection? + let models: [String]? + + var usageBlock: ClaudeUsageBlock { + ClaudeUsageBlock( + isActive: isActive, + totalTokens: totalTokens, + endTime: endTime, + projectedTotalTokens: projection?.totalTokens, + remainingMinutes: projection?.remainingMinutes, + modelCount: models?.filter { $0 != "" }.count ?? 0 + ) + } +} + +private struct ClaudeUsageBlockProjection: Decodable { + let totalTokens: Int? + let remainingMinutes: Int? +} + +private enum ClaudeCountedCollection: Decodable { + case dictionary([String: ClaudeDiscardedValue]) + case array([ClaudeDiscardedValue]) + + var count: Int { + switch self { + case let .dictionary(values): + values.count + case let .array(values): + values.count + } + } + + init(from decoder: Decoder) throws { + if let dictionary = try? [String: ClaudeDiscardedValue](from: decoder) { + self = .dictionary(dictionary) + return + } + self = .array(try [ClaudeDiscardedValue](from: decoder)) + } +} + +private struct ClaudeDiscardedValue: Decodable {} + +extension JSONDecoder { + static var contextPanelFlexibleDates: JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let value = try container.decode(String.self) + if let date = ContextPanelDateFormatting.date(from: value) { + return date + } + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Expected ISO 8601 date string" + ) + } + return decoder + } +} diff --git a/Sources/ContextPanelCore/ClaudeWebUsage.swift b/Sources/ContextPanelCore/ClaudeWebUsage.swift new file mode 100644 index 0000000..6a46f04 --- /dev/null +++ b/Sources/ContextPanelCore/ClaudeWebUsage.swift @@ -0,0 +1,140 @@ +import Foundation + +public enum ClaudeWebUsageParser { + public static func usageLimits( + from data: Data, + accountID: String, + accountName: String, + observedAt: Date + ) throws -> [UsageLimit] { + let payload = try JSONSerialization.jsonObject(with: data) + guard let root = payload as? [String: Any] else { return [] } + + let windows: [(key: String, label: String, model: String?)] = [ + ("five_hour", "5-hour", nil), + ("seven_day", "7-day", nil), + ("seven_day_opus", "7-day", "Opus"), + ("seven_day_sonnet", "7-day", "Sonnet"), + ("seven_day_oauth_apps", "7-day", "OAuth apps"), + ] + + return windows.compactMap { window in + guard let object = findObject(named: window.key, in: root) else { return nil } + let usedPercentage = percentValue(for: ["used_percentage", "utilization"], in: object) + let remainingPercentage = percentValue(for: ["remaining_percentage"], in: object) + let used = usedPercentage ?? remainingPercentage.map { max(0, 100 - $0) } + guard let used else { return nil } + + let roundedUsed = min(max(Int(used.rounded()), 0), 100) + return UsageLimit( + id: "anthropic:\(accountID):claude-web:\(window.key)", + provider: .anthropic, + accountID: accountID, + accountName: accountName, + label: "Claude \(window.label)", + windowLabel: window.label, + modelLabel: window.model ?? "Claude subscription", + unit: .percent, + used: roundedUsed, + limit: 100, + resetsAt: resetDate(in: object), + lastUpdatedAt: observedAt, + confidence: .observed, + note: "source: Claude web usage endpoint; authenticated web session required" + ) + } + } + + public static func sanitizedUsageFields(from data: Data) throws -> [String] { + let payload = try JSONSerialization.jsonObject(with: data) + return Array(collectUsageFields(payload).sorted()) + } + + private static func findObject(named key: String, in value: Any) -> [String: Any]? { + if let dictionary = value as? [String: Any] { + if let found = dictionary[key] as? [String: Any] { + return found + } + for child in dictionary.values { + if let found = findObject(named: key, in: child) { + return found + } + } + } else if let array = value as? [Any] { + for child in array { + if let found = findObject(named: key, in: child) { + return found + } + } + } + return nil + } + + private static func percentValue(for keys: [String], in object: [String: Any]) -> Double? { + for key in keys { + guard let raw = numericValue(object[key]) else { continue } + return raw <= 1 ? raw * 100 : raw + } + return nil + } + + private static func resetDate(in object: [String: Any]) -> Date? { + guard let raw = object["resets_at"] ?? object["reset_at"] else { return nil } + if let number = numericValue(raw) { + return Date(timeIntervalSince1970: number > 10_000_000_000 ? number / 1000 : number) + } + if let string = raw as? String { + return ISO8601DateFormatter().date(from: string) + } + return nil + } + + private static func numericValue(_ value: Any?) -> Double? { + switch value { + case let value as Double: + value + case let value as Int: + Double(value) + case let value as NSNumber: + value.doubleValue + case let value as String: + Double(value) + default: + nil + } + } + + private static func collectUsageFields(_ value: Any, prefix: String = "", output: Set = []) -> Set { + var output = output + let allowed = [ + "five_hour", + "seven_day", + "seven_day_opus", + "seven_day_sonnet", + "seven_day_oauth_apps", + "used_percentage", + "remaining_percentage", + "utilization", + "resets_at", + "reset_at", + "rate_limits", + "usage", + ] + + if let dictionary = value as? [String: Any] { + for (key, child) in dictionary { + let path = prefix.isEmpty ? key : "\(prefix).\(key)" + if allowed.contains(key) { + output.insert(path) + } + output = collectUsageFields(child, prefix: path, output: output) + } + } else if let array = value as? [Any] { + for child in array.prefix(3) { + output = collectUsageFields(child, prefix: prefix, output: output) + } + } + + return output + } +} diff --git a/Sources/ContextPanelCore/CodexRateLimits.swift b/Sources/ContextPanelCore/CodexRateLimits.swift new file mode 100644 index 0000000..5c7b06e --- /dev/null +++ b/Sources/ContextPanelCore/CodexRateLimits.swift @@ -0,0 +1,436 @@ +import Foundation + +public enum CodexRateLimitReachedType: String, Codable, Equatable, Sendable { + case rateLimitReached = "rate_limit_reached" + case workspaceOwnerCreditsDepleted = "workspace_owner_credits_depleted" + case workspaceMemberCreditsDepleted = "workspace_member_credits_depleted" + case workspaceOwnerUsageLimitReached = "workspace_owner_usage_limit_reached" + case workspaceMemberUsageLimitReached = "workspace_member_usage_limit_reached" + case unknown +} + +public struct CodexRateLimitWindow: Codable, Equatable, Sendable { + public let usedPercent: Double + public let windowMinutes: Int? + public let resetsAt: Date? + + public init(usedPercent: Double, windowMinutes: Int?, resetsAt: Date?) { + self.usedPercent = max(0, min(usedPercent, 100)) + self.windowMinutes = windowMinutes + self.resetsAt = resetsAt + } +} + +public struct CodexCreditsSnapshot: Codable, Equatable, Sendable { + public let hasCredits: Bool + public let unlimited: Bool + public let balance: String? + + public init(hasCredits: Bool, unlimited: Bool, balance: String?) { + self.hasCredits = hasCredits + self.unlimited = unlimited + self.balance = balance + } +} + +public struct CodexRateLimitSnapshot: Codable, Equatable, Identifiable, Sendable { + public let id: String + public let limitName: String? + public let planType: String + public let primary: CodexRateLimitWindow? + public let secondary: CodexRateLimitWindow? + public let credits: CodexCreditsSnapshot? + public let rateLimitReachedType: CodexRateLimitReachedType? + + public init( + id: String, + limitName: String?, + planType: String, + primary: CodexRateLimitWindow?, + secondary: CodexRateLimitWindow?, + credits: CodexCreditsSnapshot?, + rateLimitReachedType: CodexRateLimitReachedType? + ) { + self.id = id + self.limitName = limitName + self.planType = planType + self.primary = primary + self.secondary = secondary + self.credits = credits + self.rateLimitReachedType = rateLimitReachedType + } + + public var displayName: String { + limitName ?? (id == "codex" ? "Codex" : id) + } +} + +public struct CodexAuthTokens: Codable, Equatable, Sendable { + public let accessToken: String + public let accountID: String? + public let idToken: String? + + public init(accessToken: String, accountID: String?, idToken: String?) { + self.accessToken = accessToken + self.accountID = accountID + self.idToken = idToken + } +} + +public enum CodexAuthFileParser { + public static func tokens(from data: Data) throws -> CodexAuthTokens { + let payload = try JSONDecoder().decode(CodexAuthFilePayload.self, from: data) + guard let tokens = payload.tokens, !tokens.accessToken.isEmpty else { + throw ConnectorError.invalidAuth("auth file does not contain ChatGPT token auth") + } + return CodexAuthTokens( + accessToken: tokens.accessToken, + accountID: tokens.accountID, + idToken: tokens.idToken + ) + } +} + +public enum CodexAccountIDExtractor { + public static func accountID(fromIDToken token: String?) -> String? { + guard let token else { return nil } + let parts = token.split(separator: ".") + guard parts.count >= 2 else { return nil } + var payload = String(parts[1]).replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + while payload.count % 4 != 0 { + payload.append("=") + } + guard + let data = Data(base64Encoded: payload), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let auth = object["https://api.openai.com/auth"] as? [String: Any] + else { + return nil + } + return auth["chatgpt_account_id"] as? String + } +} + +public struct CodexAccountConfiguration: Equatable, Sendable { + public let authPath: String + public let accountName: String + public let endpoint: URL + + public init( + authPath: String, + accountName: String? = nil, + endpoint: URL = URL(string: "https://chatgpt.com/backend-api/wham/usage")! + ) { + self.authPath = authPath + self.accountName = accountName ?? ConnectorRedactor.redactedPath(authPath) + self.endpoint = endpoint + } +} + +public struct CodexRateLimitConnector: ProviderConnector { + public let provider: Provider = .openAI + + private let accounts: [CodexAccountConfiguration] + private let httpClient: any ConnectorHTTPClient + private let fileLoader: @Sendable (String) throws -> Data + + public init( + accounts: [CodexAccountConfiguration], + httpClient: any ConnectorHTTPClient = URLSessionConnectorHTTPClient(), + fileLoader: @escaping @Sendable (String) throws -> Data = { path in + try Data(contentsOf: URL(fileURLWithPath: NSString(string: path).expandingTildeInPath)) + } + ) { + self.accounts = accounts + self.httpClient = httpClient + self.fileLoader = fileLoader + } + + public func refresh(now: Date) async -> ConnectorRefreshResult { + var reports: [ProviderConnectorReport] = [] + reports.reserveCapacity(accounts.count) + for account in accounts { + reports.append(await refresh(account: account, now: now)) + } + return ConnectorRefreshResult(generatedAt: now, reports: reports) + } + + private func refresh(account: CodexAccountConfiguration, now: Date) async -> ProviderConnectorReport { + let localAccountID = ConnectorRedactor.localAccountID(provider: provider, path: account.authPath) + + do { + let auth = try CodexAuthFileParser.tokens(from: try fileLoader(account.authPath)) + let data = try await fetchUsage(endpoint: account.endpoint, auth: auth) + let snapshots = try CodexUsagePayloadParser.snapshots(from: data) + let limits = snapshots.flatMap { snapshot in + codexUsageLimits( + from: snapshot, + accountID: localAccountID, + accountName: account.accountName, + observedAt: now + ) + } + return ProviderConnectorReport( + provider: provider, + accountID: localAccountID, + accountName: account.accountName, + generatedAt: now, + limits: limits + ) + } catch { + return ProviderConnectorReport( + provider: provider, + accountID: localAccountID, + accountName: account.accountName, + generatedAt: now, + limits: [], + status: .failure, + errorMessage: error.localizedDescription + ) + } + } + + private func fetchUsage(endpoint: URL, auth: CodexAuthTokens) async throws -> Data { + var headers = [ + "Authorization": "Bearer \(auth.accessToken)", + "User-Agent": "context-panel", + "Accept": "application/json", + ] + if let accountID = auth.accountID ?? CodexAccountIDExtractor.accountID(fromIDToken: auth.idToken) { + headers["ChatGPT-Account-Id"] = accountID + } + + let response = try await httpClient.data(for: ConnectorHTTPRequest(url: endpoint, method: "GET", headers: headers)) + guard (200..<300).contains(response.statusCode) else { + throw ConnectorError.httpFailure(operation: "Codex usage endpoint", statusCode: response.statusCode) + } + return response.data + } +} + +public func codexUsageLimits( + from snapshot: CodexRateLimitSnapshot, + accountID: String, + accountName: String, + observedAt: Date +) -> [UsageLimit] { + var limits: [UsageLimit] = [] + if let primary = snapshot.primary { + limits.append(codexUsageLimit( + snapshot: snapshot, + window: primary, + accountID: accountID, + accountName: accountName, + observedAt: observedAt + )) + } + if let secondary = snapshot.secondary { + limits.append(codexUsageLimit( + snapshot: snapshot, + window: secondary, + accountID: accountID, + accountName: accountName, + observedAt: observedAt + )) + } + return limits +} + +public enum CodexUsagePayloadParser { + public static func snapshots(from data: Data) throws -> [CodexRateLimitSnapshot] { + let payload = try JSONDecoder().decode(CodexUsagePayload.self, from: data) + return snapshots(from: payload) + } + + private static func snapshots(from payload: CodexUsagePayload) -> [CodexRateLimitSnapshot] { + var snapshots = [ + CodexRateLimitSnapshot( + id: "codex", + limitName: nil, + planType: payload.planType, + primary: payload.rateLimit?.primaryWindow?.normalizedWindow, + secondary: payload.rateLimit?.secondaryWindow?.normalizedWindow, + credits: payload.credits?.normalizedCredits, + rateLimitReachedType: payload.rateLimitReachedType?.normalizedKind + ) + ] + + snapshots.append(contentsOf: payload.additionalRateLimits.map { additional in + CodexRateLimitSnapshot( + id: additional.meteredFeature, + limitName: additional.limitName, + planType: payload.planType, + primary: additional.rateLimit?.primaryWindow?.normalizedWindow, + secondary: additional.rateLimit?.secondaryWindow?.normalizedWindow, + credits: nil, + rateLimitReachedType: nil + ) + }) + + return snapshots + } +} + +private struct CodexUsagePayload: Decodable { + let planType: String + let rateLimit: CodexRateLimitDetails? + let credits: CodexCreditsDetails? + let additionalRateLimits: [CodexAdditionalRateLimitDetails] + let rateLimitReachedType: CodexReachedType? + + enum CodingKeys: String, CodingKey { + case planType = "plan_type" + case rateLimit = "rate_limit" + case credits + case additionalRateLimits = "additional_rate_limits" + case rateLimitReachedType = "rate_limit_reached_type" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + planType = try container.decodeIfPresent(String.self, forKey: .planType) ?? "unknown" + rateLimit = try container.decodeIfPresent(CodexRateLimitDetails.self, forKey: .rateLimit) + credits = try container.decodeIfPresent(CodexCreditsDetails.self, forKey: .credits) + additionalRateLimits = try container.decodeIfPresent([CodexAdditionalRateLimitDetails].self, forKey: .additionalRateLimits) ?? [] + rateLimitReachedType = try container.decodeIfPresent(CodexReachedType.self, forKey: .rateLimitReachedType) + } +} + +private struct CodexAuthFilePayload: Decodable { + let tokens: CodexAuthTokenPayload? +} + +private struct CodexAuthTokenPayload: Decodable { + let accessToken: String + let accountID: String? + let idToken: String? + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case accountID = "account_id" + case idToken = "id_token" + } +} + +private struct CodexRateLimitDetails: Decodable { + let primaryWindow: CodexWindowSnapshot? + let secondaryWindow: CodexWindowSnapshot? + + enum CodingKeys: String, CodingKey { + case primaryWindow = "primary_window" + case secondaryWindow = "secondary_window" + } +} + +private struct CodexWindowSnapshot: Decodable { + let usedPercent: Double + let limitWindowSeconds: Int? + let resetAt: Int? + + enum CodingKeys: String, CodingKey { + case usedPercent = "used_percent" + case limitWindowSeconds = "limit_window_seconds" + case resetAt = "reset_at" + } + + var normalizedWindow: CodexRateLimitWindow { + CodexRateLimitWindow( + usedPercent: usedPercent, + windowMinutes: limitWindowSeconds.map { max(($0 + 59) / 60, 0) }, + resetsAt: resetAt.map { Date(timeIntervalSince1970: TimeInterval($0)) } + ) + } +} + +private struct CodexCreditsDetails: Decodable { + let hasCredits: Bool + let unlimited: Bool + let balance: String? + + enum CodingKeys: String, CodingKey { + case hasCredits = "has_credits" + case unlimited + case balance + } + + var normalizedCredits: CodexCreditsSnapshot { + CodexCreditsSnapshot(hasCredits: hasCredits, unlimited: unlimited, balance: balance) + } +} + +private struct CodexAdditionalRateLimitDetails: Decodable { + let limitName: String + let meteredFeature: String + let rateLimit: CodexRateLimitDetails? + + enum CodingKeys: String, CodingKey { + case limitName = "limit_name" + case meteredFeature = "metered_feature" + case rateLimit = "rate_limit" + } +} + +private struct CodexReachedType: Decodable { + let kind: CodexRateLimitReachedType + + enum CodingKeys: String, CodingKey { + case kind = "type" + } + + var normalizedKind: CodexRateLimitReachedType { + kind + } +} + +private func codexUsageLimit( + snapshot: CodexRateLimitSnapshot, + window: CodexRateLimitWindow, + accountID: String, + accountName: String, + observedAt: Date +) -> UsageLimit { + let windowLabel = window.windowMinutes.map(codexWindowLabel(minutes:)) ?? "Rolling" + return UsageLimit( + provider: .openAI, + accountID: accountID, + accountName: accountName, + label: "\(snapshot.displayName) \(windowLabel)", + windowLabel: windowLabel, + modelLabel: snapshot.displayName, + unit: .percent, + used: Int(window.usedPercent.rounded()), + limit: 100, + resetsAt: window.resetsAt, + lastUpdatedAt: observedAt, + confidence: .observed, + note: "plan: \(snapshot.planType)" + ) +} + +private func codexWindowLabel(minutes: Int) -> String { + switch minutes { + case 0..<60: + return "\(minutes)m" + case 60: + return "Hourly" + case 300: + return "5-hour" + case 1_440: + return "Daily" + case 7_200: + return "5-day" + case 10_080: + return "Weekly" + default: + if minutes.isMultiple(of: 10_080) { + return "\(minutes / 10_080)-week" + } + if minutes.isMultiple(of: 1_440) { + return "\(minutes / 1_440)-day" + } + if minutes.isMultiple(of: 60) { + return "\(minutes / 60)-hour" + } + return "\(minutes)m" + } +} diff --git a/Sources/ContextPanelCore/ContextPanelLocations.swift b/Sources/ContextPanelCore/ContextPanelLocations.swift new file mode 100644 index 0000000..85709f3 --- /dev/null +++ b/Sources/ContextPanelCore/ContextPanelLocations.swift @@ -0,0 +1,29 @@ +import Foundation + +public enum ContextPanelLocations { + public static let appGroupID = "group.com.shinycomputers.contextpanel" + + public static func applicationSupportDirectory() -> URL { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? FileManager.default.homeDirectoryForCurrentUser.appending(path: "Library/Application Support") + return base.appending(path: "Context Panel", directoryHint: .isDirectory) + } + + public static func snapshotDirectory(appGroupID: String? = nil) -> URL { + if + let appGroupID, + let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupID) + { + return containerURL + .appending(path: "Context Panel", directoryHint: .isDirectory) + .appending(path: "Snapshots", directoryHint: .isDirectory) + } + + return applicationSupportDirectory() + .appending(path: "Snapshots", directoryHint: .isDirectory) + } + + public static func accountConfigurationURL() -> URL { + applicationSupportDirectory().appending(path: "accounts.json") + } +} diff --git a/Sources/ContextPanelCore/FastModeForecast.swift b/Sources/ContextPanelCore/FastModeForecast.swift new file mode 100644 index 0000000..1b52901 --- /dev/null +++ b/Sources/ContextPanelCore/FastModeForecast.swift @@ -0,0 +1,187 @@ +import Foundation + +public enum UsageMode: String, Codable, Equatable, Sendable { + case standard + case fast +} + +public struct BurnRate: Codable, Equatable, Sendable { + public let mode: UsageMode + public let unitsPerHour: Double + + public init(mode: UsageMode, unitsPerHour: Double) { + precondition(unitsPerHour >= 0, "unitsPerHour must not be negative") + + self.mode = mode + self.unitsPerHour = unitsPerHour + } +} + +public struct FastModeForecastInput: Codable, Equatable, Sendable { + public let limit: UsageLimit + public let now: Date + public let standardBurnRate: BurnRate? + public let fastBurnRate: BurnRate? + public let reserveUnits: Double + public let minimumSafeHours: Double + + public init( + limit: UsageLimit, + now: Date, + standardBurnRate: BurnRate?, + fastBurnRate: BurnRate?, + reserveUnits: Double = 5, + minimumSafeHours: Double = 1 + ) { + precondition(reserveUnits >= 0, "reserveUnits must not be negative") + precondition(minimumSafeHours >= 0, "minimumSafeHours must not be negative") + + self.limit = limit + self.now = now + self.standardBurnRate = standardBurnRate + self.fastBurnRate = fastBurnRate + self.reserveUnits = reserveUnits + self.minimumSafeHours = minimumSafeHours + } +} + +public enum FastModeRecommendation: String, Codable, Equatable, Sendable { + case safeThroughReset + case safeForLimitedTime + case saveFastMode + case needsCalibration + case limited +} + +public struct FastModeForecast: Codable, Equatable, Sendable { + public let limitID: UsageLimit.ID + public let accountName: String + public let recommendation: FastModeRecommendation + public let confidence: UsageConfidence + public let remainingUnits: Double? + public let hoursUntilReset: Double? + public let fastModeRunwayHours: Double? + public let projectedFastUseUntilReset: Double? + public let reserveUnits: Double + + public var copy: String { + switch recommendation { + case .safeThroughReset: + "Fast mode looks safe through reset." + case .safeForLimitedTime: + if let fastModeRunwayHours { + "Fast mode safe for about \(Self.format(hours: fastModeRunwayHours))." + } else { + "Fast mode safe for a limited time." + } + case .saveFastMode: + "Save fast mode before reset." + case .needsCalibration: + "Needs calibration before fast mode." + case .limited: + "Limited until reset." + } + } + + public init(input: FastModeForecastInput) { + limitID = input.limit.id + accountName = input.limit.accountName + confidence = input.limit.confidence + reserveUnits = input.reserveUnits + + let remaining = input.limit.remaining.map(Double.init) + remainingUnits = remaining + if let resetsAt = input.limit.resetsAt { + hoursUntilReset = max(resetsAt.timeIntervalSince(input.now) / 3_600, 0) + } else { + hoursUntilReset = nil + } + + guard input.limit.status != .limited else { + recommendation = .limited + fastModeRunwayHours = 0 + projectedFastUseUntilReset = 0 + return + } + + guard + let remaining, + let fastRate = input.fastBurnRate?.unitsPerHour, + fastRate > 0, + let hoursUntilReset + else { + recommendation = .needsCalibration + fastModeRunwayHours = nil + projectedFastUseUntilReset = nil + return + } + + let usableRemaining = max(remaining - input.reserveUnits, 0) + let runway = usableRemaining / fastRate + let projected = fastRate * hoursUntilReset + + fastModeRunwayHours = runway + projectedFastUseUntilReset = projected + + if usableRemaining <= 0 { + recommendation = .saveFastMode + } else if projected <= usableRemaining { + recommendation = .safeThroughReset + } else if runway >= input.minimumSafeHours { + recommendation = .safeForLimitedTime + } else { + recommendation = .saveFastMode + } + } + + private static func format(hours: Double) -> String { + if hours < 1 { + let minutes = max(Int((hours * 60).rounded()), 1) + return "\(minutes)m" + } + if hours < 10 { + let rounded = (hours * 2).rounded() / 2 + if rounded.rounded() == rounded { + return "\(Int(rounded))h" + } + return "\(rounded)h" + } + return "\(Int(hours.rounded()))h" + } +} + +public struct FastModePortfolioForecast: Codable, Equatable, Sendable { + public let forecasts: [FastModeForecast] + + public init(forecasts: [FastModeForecast]) { + self.forecasts = forecasts + } + + public var bestForecast: FastModeForecast? { + forecasts.sorted { lhs, rhs in + lhs.rank > rhs.rank + }.first + } + + public var copy: String { + bestForecast?.copy ?? "Add an OpenAI account to forecast fast mode." + } +} + +extension FastModeForecast { + fileprivate var rank: Double { + let runway = fastModeRunwayHours ?? -1 + switch recommendation { + case .safeThroughReset: + return 1_000 + runway + case .safeForLimitedTime: + return 500 + runway + case .saveFastMode: + return 100 + runway + case .needsCalibration: + return 10 + case .limited: + return 0 + } + } +} diff --git a/Sources/ContextPanelCore/GeminiCodeAssistQuota.swift b/Sources/ContextPanelCore/GeminiCodeAssistQuota.swift new file mode 100644 index 0000000..e134c98 --- /dev/null +++ b/Sources/ContextPanelCore/GeminiCodeAssistQuota.swift @@ -0,0 +1,446 @@ +import Foundation + +public struct GeminiQuotaBucket: Codable, Equatable, Identifiable, Sendable { + public let id: String + public let modelID: String + public let remainingFraction: Double? + public let remainingAmount: Int? + public let resetsAt: Date? + + public init(modelID: String, remainingFraction: Double?, remainingAmount: Int?, resetsAt: Date?) { + self.id = modelID + self.modelID = modelID + self.remainingFraction = remainingFraction.map { max(0, min($0, 1)) } + self.remainingAmount = remainingAmount + self.resetsAt = resetsAt + } + + public var usedPercent: Double? { + remainingFraction.map { max(0, min((1 - $0) * 100, 100)) } + } + + public func usageLimit(accountID: String, accountName: String, observedAt: Date) -> UsageLimit { + UsageLimit( + provider: .google, + accountID: accountID, + accountName: accountName, + label: modelID, + windowLabel: resetWindowLabel, + modelLabel: modelID, + unit: .percent, + used: usedPercent.map { Int($0.rounded()) }, + limit: usedPercent == nil ? nil : 100, + resetsAt: resetsAt, + lastUpdatedAt: observedAt, + confidence: .observed, + note: remainingAmount.map { "remaining amount: \($0)" } + ) + } + + private var resetWindowLabel: String? { + guard let resetsAt else { return nil } + let seconds = Int(resetsAt.timeIntervalSince(Date())) + if seconds <= 0 { return nil } + let hours = max(Int((Double(seconds) / 3_600).rounded()), 1) + if hours <= 2 { return "Hourly" } + if hours <= 7 { return "5-hour" } + if hours <= 30 { return "Daily" } + if hours <= 132 { return "5-day" } + if hours <= 180 { return "Weekly" } + return nil + } +} + +public enum GeminiQuotaPayloadParser { + public static func buckets(from data: Data) throws -> [GeminiQuotaBucket] { + let payload = try JSONDecoder.contextPanelISO8601.decode(GeminiQuotaPayload.self, from: data) + return payload.buckets.map(\.normalizedBucket) + } +} + +public struct GeminiOAuthCredentials: Codable, Equatable, Sendable { + public let accessToken: String? + public let refreshToken: String? + + public init(accessToken: String?, refreshToken: String?) { + self.accessToken = accessToken + self.refreshToken = refreshToken + } + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case refreshToken = "refresh_token" + } +} + +public struct GeminiRefreshResponse: Decodable, Equatable, Sendable { + public let accessToken: String + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + } +} + +public struct GeminiCodeAssistTier: Decodable, Equatable, Sendable { + public let name: String? +} + +public struct GeminiLoadCodeAssistResponse: Decodable, Equatable, Sendable { + public let cloudaicompanionProject: String? + public let currentTier: GeminiCodeAssistTier? + public let paidTier: GeminiCodeAssistTier? +} + +public struct GeminiAccountConfiguration: Equatable, Sendable { + public let authPath: String + public let accountName: String + public let tokenEndpoint: URL + public let codeAssistEndpoint: URL + public let clientID: String + public let clientSecret: String + + public init( + authPath: String, + accountName: String? = nil, + tokenEndpoint: URL = URL(string: "https://oauth2.googleapis.com/token")!, + codeAssistEndpoint: URL = URL(string: "https://cloudcode-pa.googleapis.com/v1internal")!, + clientID: String, + clientSecret: String + ) { + self.authPath = authPath + self.accountName = accountName ?? ConnectorRedactor.redactedPath(authPath) + self.tokenEndpoint = tokenEndpoint + self.codeAssistEndpoint = codeAssistEndpoint + self.clientID = clientID + self.clientSecret = clientSecret + } +} + +public struct GeminiOAuthClientMetadata: Equatable, Sendable { + public let clientID: String + public let clientSecret: String + + public init(clientID: String, clientSecret: String) { + self.clientID = clientID + self.clientSecret = clientSecret + } +} + +public enum GeminiOAuthClientMetadataDiscovery { + public static func discover( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileLoader: @escaping @Sendable (String) throws -> String = { path in + try String(contentsOfFile: NSString(string: path).expandingTildeInPath, encoding: .utf8) + }, + fileExists: @escaping @Sendable (String) -> Bool = { path in + FileManager.default.fileExists(atPath: NSString(string: path).expandingTildeInPath) + }, + directoryLister: @escaping @Sendable (String) -> [String] = { path in + let expanded = NSString(string: path).expandingTildeInPath + return (try? FileManager.default.contentsOfDirectory(atPath: expanded).map { "\(expanded)/\($0)" }) ?? [] + } + ) -> GeminiOAuthClientMetadata? { + if + let clientID = environment["GEMINI_OAUTH_CLIENT_ID"], !clientID.isEmpty, + let clientSecret = environment["GEMINI_OAUTH_CLIENT_SECRET"], !clientSecret.isEmpty + { + return GeminiOAuthClientMetadata(clientID: clientID, clientSecret: clientSecret) + } + + for path in candidateBundlePaths( + environment: environment, + directoryLister: directoryLister + ) where fileExists(path) { + guard + let source = try? fileLoader(path), + let metadata = parseClientMetadata(from: source) + else { continue } + return metadata + } + return nil + } + + static func parseClientMetadata(from source: String) -> GeminiOAuthClientMetadata? { + guard + let clientID = stringLiteral(named: "OAUTH_CLIENT_ID", in: source), + let clientSecret = stringLiteral(named: "OAUTH_CLIENT_SECRET", in: source) + else { return nil } + return GeminiOAuthClientMetadata(clientID: clientID, clientSecret: clientSecret) + } + + private static func stringLiteral(named variableName: String, in source: String) -> String? { + let pattern = #"var\s+\#(variableName)\s*=\s*\"([^\"]+)\""# + guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } + let range = NSRange(source.startIndex..= 2, + let valueRange = Range(match.range(at: 1), in: source) + else { return nil } + return String(source[valueRange]) + } + + private static func candidateBundlePaths( + environment: [String: String], + directoryLister: @Sendable (String) -> [String] + ) -> [String] { + var paths: [String] = [] + if let path = environment["GEMINI_CLI_BUNDLE_PATH"], !path.isEmpty { + paths.append(path) + } + paths.append(contentsOf: bundleChunkPaths( + root: "/opt/homebrew/lib/node_modules/@google/gemini-cli/bundle", + directoryLister: directoryLister + )) + paths.append(contentsOf: bundleChunkPaths( + root: "/usr/local/lib/node_modules/@google/gemini-cli/bundle", + directoryLister: directoryLister + )) + return paths + } + + private static func bundleChunkPaths( + root: String, + directoryLister: @Sendable (String) -> [String] + ) -> [String] { + directoryLister(root) + .filter { $0.hasSuffix(".js") } + .sorted() + } +} + +public struct GeminiCodeAssistConnector: ProviderConnector { + public let provider: Provider = .google + + private let accounts: [GeminiAccountConfiguration] + private let httpClient: any ConnectorHTTPClient + private let fileLoader: @Sendable (String) throws -> Data + + public init( + accounts: [GeminiAccountConfiguration], + httpClient: any ConnectorHTTPClient = URLSessionConnectorHTTPClient(), + fileLoader: @escaping @Sendable (String) throws -> Data = { path in + try Data(contentsOf: URL(fileURLWithPath: NSString(string: path).expandingTildeInPath)) + } + ) { + self.accounts = accounts + self.httpClient = httpClient + self.fileLoader = fileLoader + } + + public func refresh(now: Date) async -> ConnectorRefreshResult { + var reports: [ProviderConnectorReport] = [] + reports.reserveCapacity(accounts.count) + for account in accounts { + reports.append(await refresh(account: account, now: now)) + } + return ConnectorRefreshResult(generatedAt: now, reports: reports) + } + + private func refresh(account: GeminiAccountConfiguration, now: Date) async -> ProviderConnectorReport { + let localAccountID = ConnectorRedactor.localAccountID(provider: provider, path: account.authPath) + + do { + let credentials = try JSONDecoder().decode(GeminiOAuthCredentials.self, from: try fileLoader(account.authPath)) + let accessToken = try await refreshedAccessToken(credentials: credentials, account: account) + let loadResponse = try await loadCodeAssist(accessToken: accessToken, endpoint: account.codeAssistEndpoint) + guard let project = loadResponse.cloudaicompanionProject, !project.isEmpty else { + throw ConnectorError.decodingFailure("Code Assist did not return an active project; raw body redacted") + } + let quotaData = try await retrieveUserQuota( + accessToken: accessToken, + project: project, + endpoint: account.codeAssistEndpoint + ) + let buckets = try GeminiQuotaPayloadParser.buckets(from: quotaData) + let limits = buckets.map { + $0.usageLimit(accountID: localAccountID, accountName: account.accountName, observedAt: now) + } + return ProviderConnectorReport( + provider: provider, + accountID: localAccountID, + accountName: account.accountName, + generatedAt: now, + limits: limits + ) + } catch { + return ProviderConnectorReport( + provider: provider, + accountID: localAccountID, + accountName: account.accountName, + generatedAt: now, + limits: [], + status: .failure, + errorMessage: error.localizedDescription + ) + } + } + + private func refreshedAccessToken(credentials: GeminiOAuthCredentials, account: GeminiAccountConfiguration) async throws -> String { + guard let refreshToken = credentials.refreshToken, !refreshToken.isEmpty else { + throw ConnectorError.invalidAuth("Gemini OAuth file does not contain a refresh token") + } + let body = formEncoded([ + "client_id": account.clientID, + "client_secret": account.clientSecret, + "refresh_token": refreshToken, + "grant_type": "refresh_token", + ]) + let response = try await httpClient.data(for: ConnectorHTTPRequest( + url: account.tokenEndpoint, + method: "POST", + headers: [ + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + ], + body: body + )) + guard (200..<300).contains(response.statusCode) else { + throw ConnectorError.httpFailure(operation: "Gemini OAuth refresh", statusCode: response.statusCode) + } + return try JSONDecoder().decode(GeminiRefreshResponse.self, from: response.data).accessToken + } + + private func loadCodeAssist(accessToken: String, endpoint: URL) async throws -> GeminiLoadCodeAssistResponse { + let body = try JSONSerialization.data(withJSONObject: [ + "cloudaicompanionProject": NSNull(), + "metadata": [ + "ideType": "IDE_UNSPECIFIED", + "platform": "PLATFORM_UNSPECIFIED", + "pluginType": "GEMINI", + "duetProject": NSNull(), + ], + ]) + let response = try await httpClient.data(for: ConnectorHTTPRequest( + url: endpoint.appending(path: ":loadCodeAssist"), + method: "POST", + headers: jsonHeaders(accessToken: accessToken), + body: body + )) + guard (200..<300).contains(response.statusCode) else { + throw ConnectorError.httpFailure(operation: "Gemini Code Assist load", statusCode: response.statusCode) + } + return try JSONDecoder().decode(GeminiLoadCodeAssistResponse.self, from: response.data) + } + + private func retrieveUserQuota(accessToken: String, project: String, endpoint: URL) async throws -> Data { + let body = try JSONSerialization.data(withJSONObject: ["project": project]) + let response = try await httpClient.data(for: ConnectorHTTPRequest( + url: endpoint.appending(path: ":retrieveUserQuota"), + method: "POST", + headers: jsonHeaders(accessToken: accessToken), + body: body + )) + guard (200..<300).contains(response.statusCode) else { + throw ConnectorError.httpFailure(operation: "Gemini Code Assist quota", statusCode: response.statusCode) + } + return response.data + } +} + +private struct GeminiQuotaPayload: Decodable { + let buckets: [GeminiQuotaBucketPayload] + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + buckets = try container.decodeIfPresent([GeminiQuotaBucketPayload].self, forKey: .buckets) ?? [] + } + + enum CodingKeys: String, CodingKey { + case buckets + } +} + +private struct GeminiQuotaBucketPayload: Decodable { + let modelID: String + let remainingFraction: Double? + let remainingAmount: Int? + let resetTime: Date? + + enum CodingKeys: String, CodingKey { + case modelID = "modelId" + case remainingFraction + case remainingAmount + case resetTime + } + + var normalizedBucket: GeminiQuotaBucket { + GeminiQuotaBucket( + modelID: modelID, + remainingFraction: remainingFraction, + remainingAmount: remainingAmount, + resetsAt: resetTime + ) + } +} + +extension JSONDecoder { + static var contextPanelISO8601: JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let value = try container.decode(String.self) + if let date = ContextPanelDateFormatting.date(from: value) { + return date + } + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Expected ISO 8601 date string" + ) + } + return decoder + } +} + +public enum ContextPanelDateFormatting { + public static func string(from date: Date) -> String { + internetDateFormatter().string(from: date) + } + + public static func date(from value: String) -> Date? { + internetDateFormatterWithFractionalSeconds().date(from: value) + ?? internetDateFormatter().date(from: value) + ?? dateOnlyFormatter().date(from: value) + } + + private static func internetDateFormatter() -> ISO8601DateFormatter { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + } + + private static func internetDateFormatterWithFractionalSeconds() -> ISO8601DateFormatter { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + } + + private static func dateOnlyFormatter() -> ISO8601DateFormatter { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withFullDate] + return formatter + } +} + +func formEncoded(_ values: [String: String]) -> Data { + values + .map { key, value in + "\(urlFormEscape(key))=\(urlFormEscape(value))" + } + .joined(separator: "&") + .data(using: .utf8) ?? Data() +} + +func urlFormEscape(_ value: String) -> String { + var allowed = CharacterSet.urlQueryAllowed + allowed.remove(charactersIn: "&+=") + return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value +} + +private func jsonHeaders(accessToken: String) -> [String: String] { + [ + "Authorization": "Bearer \(accessToken)", + "Content-Type": "application/json", + "Accept": "application/json", + ] +} diff --git a/Sources/ContextPanelCore/LimitProbe.swift b/Sources/ContextPanelCore/LimitProbe.swift new file mode 100644 index 0000000..144c9e0 --- /dev/null +++ b/Sources/ContextPanelCore/LimitProbe.swift @@ -0,0 +1,260 @@ +import Foundation + +public enum ProbeSource: String, Codable, Equatable, Sendable { + case visibleText + case networkMetadata + case redactedResponseShape + case manualUserEntry +} + +public enum ProbeSignalKind: String, Codable, Equatable, Sendable { + case resetLanguage + case relativeDuration + case usagePressure + case limitReached + case modelAvailability + case planLanguage + case candidateFieldName +} + +public struct LimitProbeObservation: Codable, Equatable, Identifiable, Sendable { + public let id: UUID + public let provider: Provider + public let observedAt: Date + public let source: ProbeSource + public let signalKind: ProbeSignalKind + public let confidence: UsageConfidence + public let sanitizedEvidence: String + + public init( + id: UUID = UUID(), + provider: Provider, + observedAt: Date, + source: ProbeSource, + signalKind: ProbeSignalKind, + confidence: UsageConfidence, + sanitizedEvidence: String + ) { + self.id = id + self.provider = provider + self.observedAt = observedAt + self.source = source + self.signalKind = signalKind + self.confidence = confidence + self.sanitizedEvidence = EvidenceRedactor.redact(sanitizedEvidence) + } +} + +public struct LimitProbeReport: Codable, Equatable, Sendable { + public let schemaVersion: Int + public let provider: Provider + public let capturedAt: Date + public let observations: [LimitProbeObservation] + public let networkEvents: [NetworkProbeEvent] + public let redactions: [String] + + public init( + provider: Provider, + capturedAt: Date, + observations: [LimitProbeObservation], + networkEvents: [NetworkProbeEvent] = [] + ) { + self.schemaVersion = 1 + self.provider = provider + self.capturedAt = capturedAt + self.observations = observations + self.networkEvents = networkEvents + self.redactions = [ + "cookies", + "authorization headers", + "bearer tokens", + "session identifiers", + "emails", + "account identifiers", + "raw response bodies" + ] + } + + public var markdownSummary: String { + var lines = [ + "# Limit Probe Report", + "", + "- Provider: \(provider.displayName)", + "- Captured: \(capturedAt.ISO8601Format())", + "- Observations: \(observations.count)", + "", + "## Observations" + ] + + if observations.isEmpty { + lines.append("- No candidate limit signals found.") + } else { + for observation in observations { + lines.append("- `\(observation.signalKind.rawValue)` from `\(observation.source.rawValue)`: \(observation.sanitizedEvidence)") + } + } + + lines.append(contentsOf: [ + "", + "## Network Candidates" + ]) + + if networkEvents.isEmpty { + lines.append("- No candidate response shapes found.") + } else { + for event in networkEvents { + let status = event.status.map(String.init) ?? "?" + let bodySize = event.bodySize.map { "\($0)b" } ?? "unknown size" + let fields = event.matchedFields.joined(separator: ", ") + lines.append("- `\(event.method) \(event.pathHint)` status `\(status)`, `\(bodySize)`: \(fields)") + } + } + + lines.append(contentsOf: [ + "", + "## Redactions", + redactions.map { "- \($0)" }.joined(separator: "\n") + ]) + + return lines.joined(separator: "\n") + } +} + +public struct NetworkProbeEvent: Codable, Equatable, Identifiable, Sendable { + public let id: UUID + public let observedAt: Date + public let method: String + public let pathHint: String + public let status: Int? + public let contentType: String? + public let bodySize: Int? + public let matchedFields: [String] + + public init( + id: UUID = UUID(), + observedAt: Date, + method: String, + pathHint: String, + status: Int?, + contentType: String?, + bodySize: Int?, + matchedFields: [String] + ) { + self.id = id + self.observedAt = observedAt + self.method = EvidenceRedactor.redact(method) + self.pathHint = EvidenceRedactor.redactPath(pathHint) + self.status = status + self.contentType = EvidenceRedactor.redact(contentType ?? "") + self.bodySize = bodySize + self.matchedFields = matchedFields.map(EvidenceRedactor.redact).sorted() + } +} + +public enum LimitProbeScanner { + private static let patterns: [(ProbeSignalKind, NSRegularExpression)] = [ + (.resetLanguage, regex(#"(?i)\b(reset|resets|refresh|refreshes|available again)\b.{0,80}"#)), + (.relativeDuration, regex(#"(?i)\b(in\s+)?\d+\s*(m|min|mins|minutes|h|hr|hrs|hour|hours|day|days|week|weeks)\b"#)), + (.usagePressure, regex(#"(?i)\b\d+[\d,]*\s*(%|percent|tokens?)\s*(used|every|per|/)?\s*\d*\s*(hours?|days?|weeks?)?\b"#)), + (.limitReached, regex(#"(?i)\b(limit reached|reached your limit|you.ve reached|unavailable|try again)\b.{0,80}"#)), + (.modelAvailability, regex(#"(?i)\b(GPT|Thinking|fast mode|model picker|available|unavailable)\b.{0,80}"#)), + (.planLanguage, regex(#"(?i)\b(Free|Plus|Pro|Team|Business|Enterprise|Go)\b.{0,80}"#)) + ] + + public static func scanVisibleText( + _ text: String, + provider: Provider, + observedAt: Date = Date() + ) -> [LimitProbeObservation] { + let normalized = text.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + let range = NSRange(normalized.startIndex..() + + for (kind, pattern) in patterns { + for match in pattern.matches(in: normalized, range: range).prefix(8) { + guard let matchRange = Range(match.range, in: normalized) else { continue } + let evidence = String(normalized[matchRange]).trimmingCharacters(in: .whitespacesAndNewlines) + let key = "\(kind.rawValue):\(evidence.lowercased())" + guard !seen.contains(key) else { continue } + seen.insert(key) + observations.append( + LimitProbeObservation( + provider: provider, + observedAt: observedAt, + source: .visibleText, + signalKind: kind, + confidence: .observed, + sanitizedEvidence: evidence + ) + ) + } + } + + return observations + } + + public static func scanResponseShape( + fieldNames: [String], + provider: Provider, + observedAt: Date = Date() + ) -> [LimitProbeObservation] { + let candidates = fieldNames.filter { field in + let lower = field.lowercased() + return ["limit", "usage", "percent", "token", "remaining", "reset", "quota", "model", "plan", "cap"].contains { lower.contains($0) } + } + + return Array(Set(candidates)).sorted().map { field in + LimitProbeObservation( + provider: provider, + observedAt: observedAt, + source: .redactedResponseShape, + signalKind: .candidateFieldName, + confidence: .observed, + sanitizedEvidence: field + ) + } + } + + private static func regex(_ pattern: String) -> NSRegularExpression { + do { + return try NSRegularExpression(pattern: pattern) + } catch { + preconditionFailure("Invalid probe regex: \(pattern)") + } + } +} + +public enum EvidenceRedactor { + private static let redactionPatterns: [(String, String)] = [ + (#"(?i)bearer\s+[a-z0-9._\-]+"#, "bearer [redacted]"), + (#"(?i)(authorization|cookie|set-cookie|csrf|session|token)[:=]\s*[^\s,;]+"#, "$1=[redacted]"), + (#"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}"#, "[email redacted]"), + (#"\bsk-[A-Za-z0-9_\-]{12,}\b"#, "[api key redacted]"), + (#"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b"#, "[id]"), + (#"(?<=\.)[A-Za-z0-9_-]{20,}(?=\.)"#, "[id]") + ] + + public static func redact(_ value: String) -> String { + var redacted = value + for (pattern, replacement) in redactionPatterns { + redacted = redacted.replacingOccurrences( + of: pattern, + with: replacement, + options: [.regularExpression, .caseInsensitive] + ) + } + return redacted + } + + public static func redactPath(_ value: String) -> String { + guard let components = URLComponents(string: value) else { + return redact(value) + } + let path = components.path + .replacingOccurrences(of: #"/[0-9a-fA-F-]{16,}"#, with: "/[id]", options: .regularExpression) + .replacingOccurrences(of: #"/[A-Za-z0-9_-]{24,}"#, with: "/[id]", options: .regularExpression) + return redact(path.isEmpty ? value : path) + } +} diff --git a/Sources/ContextPanelCore/ProviderConnector.swift b/Sources/ContextPanelCore/ProviderConnector.swift new file mode 100644 index 0000000..b8b8978 --- /dev/null +++ b/Sources/ContextPanelCore/ProviderConnector.swift @@ -0,0 +1,198 @@ +import Foundation + +public enum ConnectorError: LocalizedError, Equatable, Sendable { + case missingAuth(String) + case invalidAuth(String) + case httpFailure(operation: String, statusCode: Int) + case nonHTTPResponse(String) + case processFailure(operation: String, exitCode: Int32) + case decodingFailure(String) + + public var errorDescription: String? { + switch self { + case let .missingAuth(message), let .invalidAuth(message), let .nonHTTPResponse(message), let .decodingFailure(message): + message + case let .httpFailure(operation, statusCode): + "\(operation) returned HTTP \(statusCode); raw body redacted" + case let .processFailure(operation, exitCode): + "\(operation) failed with exit code \(exitCode); stderr redacted" + } + } +} + +public struct ProviderConnectorReport: Equatable, Sendable { + public let provider: Provider + public let accountID: String + public let accountName: String + public let generatedAt: Date + public let limits: [UsageLimit] + public let status: UsageStatus + public let errorMessage: String? + + public init( + provider: Provider, + accountID: String, + accountName: String, + generatedAt: Date, + limits: [UsageLimit], + status: UsageStatus? = nil, + errorMessage: String? = nil + ) { + self.provider = provider + self.accountID = accountID + self.accountName = accountName + self.generatedAt = generatedAt + self.limits = limits + self.status = status ?? UsageSnapshot(generatedAt: generatedAt, limits: limits).aggregateStatus + self.errorMessage = errorMessage.map(ConnectorRedactor.redact) + } +} + +public struct ConnectorRefreshResult: Equatable, Sendable { + public let generatedAt: Date + public let reports: [ProviderConnectorReport] + + public init(generatedAt: Date, reports: [ProviderConnectorReport]) { + self.generatedAt = generatedAt + self.reports = reports + } + + public var snapshot: UsageSnapshot { + UsageSnapshot(generatedAt: generatedAt, limits: reports.flatMap(\.limits)) + } +} + +public protocol ProviderConnector: Sendable { + var provider: Provider { get } + + func refresh(now: Date) async -> ConnectorRefreshResult +} + +public struct ProviderConnectorRuntime: Sendable { + private let connectors: [any ProviderConnector] + + public init(connectors: [any ProviderConnector]) { + self.connectors = connectors + } + + public func refreshAll(now: Date = Date()) async -> ConnectorRefreshResult { + var reports: [ProviderConnectorReport] = [] + for connector in connectors { + let result = await connector.refresh(now: now) + reports.append(contentsOf: result.reports) + } + return ConnectorRefreshResult(generatedAt: now, reports: reports) + } +} + +public struct ConnectorHTTPRequest: Sendable { + public let url: URL + public let method: String + public let headers: [String: String] + public let body: Data? + + public init(url: URL, method: String, headers: [String: String] = [:], body: Data? = nil) { + self.url = url + self.method = method + self.headers = headers + self.body = body + } +} + +public struct ConnectorHTTPResponse: Sendable { + public let statusCode: Int + public let data: Data + + public init(statusCode: Int, data: Data) { + self.statusCode = statusCode + self.data = data + } +} + +public protocol ConnectorHTTPClient: Sendable { + func data(for request: ConnectorHTTPRequest) async throws -> ConnectorHTTPResponse +} + +public struct URLSessionConnectorHTTPClient: ConnectorHTTPClient { + public init() {} + + public func data(for request: ConnectorHTTPRequest) async throws -> ConnectorHTTPResponse { + var urlRequest = URLRequest(url: request.url) + urlRequest.httpMethod = request.method + urlRequest.httpBody = request.body + for (key, value) in request.headers { + urlRequest.setValue(value, forHTTPHeaderField: key) + } + + let (data, response) = try await URLSession.shared.data(for: urlRequest) + guard let http = response as? HTTPURLResponse else { + throw ConnectorError.nonHTTPResponse("provider request returned a non-HTTP response") + } + return ConnectorHTTPResponse(statusCode: http.statusCode, data: data) + } +} + +public struct ConnectorProcessResult: Sendable { + public let exitCode: Int32 + public let stdout: Data + + public init(exitCode: Int32, stdout: Data) { + self.exitCode = exitCode + self.stdout = stdout + } +} + +public protocol ConnectorProcessClient: Sendable { + func run(executable: String, arguments: [String]) throws -> ConnectorProcessResult +} + +public struct DefaultConnectorProcessClient: ConnectorProcessClient { + public init() {} + + public func run(executable: String, arguments: [String]) throws -> ConnectorProcessResult { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = [executable] + arguments + + let output = Pipe() + let error = Pipe() + process.standardOutput = output + process.standardError = error + + try process.run() + process.waitUntilExit() + + return ConnectorProcessResult( + exitCode: process.terminationStatus, + stdout: output.fileHandleForReading.readDataToEndOfFile() + ) + } +} + +public enum ConnectorRedactor { + public static func redact(_ value: String) -> String { + EvidenceRedactor.redact(value) + } + + public static func redactedPath(_ path: String) -> String { + let expanded = NSString(string: path).expandingTildeInPath + let home = FileManager.default.homeDirectoryForCurrentUser.path + if expanded.hasPrefix(home) { + return "~" + expanded.dropFirst(home.count) + } + return URL(fileURLWithPath: expanded).lastPathComponent + } + + public static func localAccountID(provider: Provider, path: String) -> String { + "\(provider.rawValue)-\(fnv1a(path))" + } + + private static func fnv1a(_ value: String) -> String { + var hash: UInt64 = 14_695_981_039_346_656_037 + for byte in value.utf8 { + hash ^= UInt64(byte) + hash &*= 1_099_511_628_211 + } + return String(hash, radix: 16) + } +} diff --git a/Sources/ContextPanelCore/SnapshotStore.swift b/Sources/ContextPanelCore/SnapshotStore.swift new file mode 100644 index 0000000..d76dd06 --- /dev/null +++ b/Sources/ContextPanelCore/SnapshotStore.swift @@ -0,0 +1,263 @@ +import Foundation + +public struct StoredUsageSnapshot: Codable, Equatable, Sendable { + public let schemaVersion: Int + public let savedAt: Date + public let snapshot: UsageSnapshot + public let reports: [StoredProviderReport] + + public init(savedAt: Date, snapshot: UsageSnapshot, reports: [StoredProviderReport] = []) { + self.schemaVersion = 1 + self.savedAt = savedAt + self.snapshot = snapshot + self.reports = reports + } + + public init(savedAt: Date, refreshResult: ConnectorRefreshResult) { + self.init( + savedAt: savedAt, + snapshot: refreshResult.snapshot, + reports: refreshResult.reports.map(StoredProviderReport.init(report:)) + ) + } +} + +public struct StoredProviderReport: Codable, Equatable, Sendable { + public let provider: Provider + public let accountID: String + public let accountName: String + public let generatedAt: Date + public let status: UsageStatus + public let errorMessage: String? + + public init( + provider: Provider, + accountID: String, + accountName: String, + generatedAt: Date, + status: UsageStatus, + errorMessage: String? + ) { + self.provider = provider + self.accountID = accountID + self.accountName = accountName + self.generatedAt = generatedAt + self.status = status + self.errorMessage = errorMessage.map(ConnectorRedactor.redact) + } + + public init(report: ProviderConnectorReport) { + self.init( + provider: report.provider, + accountID: report.accountID, + accountName: report.accountName, + generatedAt: report.generatedAt, + status: report.status, + errorMessage: report.errorMessage + ) + } +} + +public enum SnapshotStoreError: LocalizedError, Equatable, Sendable { + case unsupportedSchema(version: Int) + case corruptStore(String) + + public var errorDescription: String? { + switch self { + case let .unsupportedSchema(version): + "Unsupported snapshot schema version \(version)" + case let .corruptStore(message): + message + } + } +} + +public struct SnapshotStoreLoadResult: Equatable, Sendable { + public let snapshot: StoredUsageSnapshot? + public let status: UsageStatus + public let errorMessage: String? + + public init(snapshot: StoredUsageSnapshot?, status: UsageStatus, errorMessage: String? = nil) { + self.snapshot = snapshot + self.status = status + self.errorMessage = errorMessage.map(ConnectorRedactor.redact) + } +} + +public struct SnapshotStoreQuery: Equatable, Sendable { + public let provider: Provider? + public let accountID: String? + public let since: Date? + public let limit: Int? + + public init(provider: Provider? = nil, accountID: String? = nil, since: Date? = nil, limit: Int? = nil) { + self.provider = provider + self.accountID = accountID + self.since = since + self.limit = limit + } +} + +public struct SnapshotStoreStalenessPolicy: Equatable, Sendable { + public let maximumAge: TimeInterval + + public init(maximumAge: TimeInterval = 15 * 60) { + precondition(maximumAge >= 0, "maximumAge must not be negative") + self.maximumAge = maximumAge + } + + public func status(for storedSnapshot: StoredUsageSnapshot?, now: Date) -> UsageStatus { + guard let storedSnapshot else { return .unknown } + if now.timeIntervalSince(storedSnapshot.savedAt) > maximumAge { + return .stale + } + return storedSnapshot.snapshot.aggregateStatus + } +} + +public struct JSONSnapshotStore: Sendable { + public let rootDirectory: URL + + public init(rootDirectory: URL) { + self.rootDirectory = rootDirectory + } + + public var currentSnapshotURL: URL { + rootDirectory.appending(path: "current-snapshot.json") + } + + public var historyDirectoryURL: URL { + rootDirectory.appending(path: "history", directoryHint: .isDirectory) + } + + public func save(_ storedSnapshot: StoredUsageSnapshot) throws { + try ensureDirectories() + let data = try Self.makeEncoder().encode(storedSnapshot) + try data.write(to: currentSnapshotURL, options: [.atomic]) + let historyURL = historyURL(for: storedSnapshot.savedAt) + try data.write(to: historyURL, options: [.atomic]) + } + + public func saveMerged(refreshResult: ConnectorRefreshResult, savedAt: Date) throws { + let replacementAccounts = Set( + refreshResult.reports.map { ProviderAccountKey(provider: $0.provider, accountID: $0.accountID) } + ) + let current = loadCurrent().snapshot + let preservedLimits = current?.snapshot.limits.filter { limit in + !replacementAccounts.contains(ProviderAccountKey(provider: limit.provider, accountID: limit.accountID)) + } ?? [] + let preservedReports = current?.reports.filter { report in + !replacementAccounts.contains(ProviderAccountKey(provider: report.provider, accountID: report.accountID)) + } ?? [] + + let mergedSnapshot = UsageSnapshot( + generatedAt: refreshResult.generatedAt, + limits: preservedLimits + refreshResult.snapshot.limits + ) + let mergedReports = preservedReports + refreshResult.reports.map(StoredProviderReport.init(report:)) + + try save(StoredUsageSnapshot(savedAt: savedAt, snapshot: mergedSnapshot, reports: mergedReports)) + } + + public func loadCurrent() -> SnapshotStoreLoadResult { + guard FileManager.default.fileExists(atPath: currentSnapshotURL.path) else { + return SnapshotStoreLoadResult(snapshot: nil, status: .unknown) + } + + do { + let snapshot = try loadSnapshot(from: currentSnapshotURL) + return SnapshotStoreLoadResult(snapshot: snapshot, status: snapshot.snapshot.aggregateStatus) + } catch { + return SnapshotStoreLoadResult( + snapshot: nil, + status: .failure, + errorMessage: error.localizedDescription + ) + } + } + + public func loadCurrent(policy: SnapshotStoreStalenessPolicy, now: Date = Date()) -> SnapshotStoreLoadResult { + let result = loadCurrent() + guard result.status != .failure else { return result } + return SnapshotStoreLoadResult( + snapshot: result.snapshot, + status: policy.status(for: result.snapshot, now: now), + errorMessage: result.errorMessage + ) + } + + public func loadHistory(query: SnapshotStoreQuery = SnapshotStoreQuery()) -> [StoredUsageSnapshot] { + guard let urls = try? FileManager.default.contentsOfDirectory( + at: historyDirectoryURL, + includingPropertiesForKeys: nil + ) else { + return [] + } + + let snapshots = urls + .filter { $0.pathExtension == "json" } + .compactMap { try? loadSnapshot(from: $0) } + .filter { snapshot in + if let since = query.since, snapshot.savedAt < since { return false } + if let provider = query.provider, !snapshot.snapshot.limits.contains(where: { $0.provider == provider }) { + return false + } + if let accountID = query.accountID, !snapshot.snapshot.limits.contains(where: { $0.accountID == accountID }) { + return false + } + return true + } + .sorted { $0.savedAt > $1.savedAt } + + if let limit = query.limit { + return Array(snapshots.prefix(max(limit, 0))) + } + return snapshots + } + + private func ensureDirectories() throws { + try FileManager.default.createDirectory(at: rootDirectory, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: historyDirectoryURL, withIntermediateDirectories: true) + } + + private func loadSnapshot(from url: URL) throws -> StoredUsageSnapshot { + let data = try Data(contentsOf: url) + let snapshot = try Self.makeDecoder().decode(StoredUsageSnapshot.self, from: data) + guard snapshot.schemaVersion == 1 else { + throw SnapshotStoreError.unsupportedSchema(version: snapshot.schemaVersion) + } + return snapshot + } + + private func historyURL(for date: Date) -> URL { + let timestamp = ContextPanelDateFormatting.historyFileTimestamp(from: date) + return historyDirectoryURL.appending(path: "\(timestamp).json") + } + + private static func makeEncoder() -> JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + encoder.dateEncodingStrategy = .iso8601 + return encoder + } + + private static func makeDecoder() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + } +} + +private struct ProviderAccountKey: Hashable { + let provider: Provider + let accountID: String +} + +extension ContextPanelDateFormatting { + static func historyFileTimestamp(from date: Date) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter.string(from: date) + .replacingOccurrences(of: ":", with: "-") + } +} diff --git a/Sources/ContextPanelCore/UsageLimit.swift b/Sources/ContextPanelCore/UsageLimit.swift index a59c634..21cc432 100644 --- a/Sources/ContextPanelCore/UsageLimit.swift +++ b/Sources/ContextPanelCore/UsageLimit.swift @@ -1,34 +1,279 @@ import Foundation -public enum Provider: String, CaseIterable, Codable, Equatable, Sendable { +public enum Provider: String, CaseIterable, Codable, Equatable, Identifiable, Sendable { case openAI = "openai" case anthropic case google + + public var id: String { rawValue } + + public var displayName: String { + switch self { + case .openAI: + "OpenAI" + case .anthropic: + "Anthropic" + case .google: + "Google" + } + } + + public var shortName: String { + switch self { + case .openAI: + "OAI" + case .anthropic: + "ANT" + case .google: + "GOO" + } + } +} + +public enum UsageStatus: String, Codable, Equatable, Sendable { + case healthy + case close + case limited + case stale + case unknown + case failure + case loading +} + +public enum UsageConfidence: String, Codable, Equatable, Sendable { + case official + case observed + case manual + case estimated + case unknown +} + +public enum UsageUnit: String, Codable, Equatable, Sendable { + case percent + case tokens + case requests + case credits + case units + case unknown +} + +public struct ProviderAccount: Codable, Equatable, Identifiable, Sendable { + public let id: String + public let provider: Provider + public let name: String + public let isEnabled: Bool + + public init(id: String, provider: Provider, name: String, isEnabled: Bool = true) { + self.id = id + self.provider = provider + self.name = name + self.isEnabled = isEnabled + } } -public struct UsageLimit: Codable, Equatable, Sendable { +public struct UsageLimit: Codable, Equatable, Identifiable, Sendable { + public let id: String public let provider: Provider + public let accountID: String + public let accountName: String public let label: String - public let used: Int - public let limit: Int + public let windowLabel: String? + public let modelLabel: String? + public let unit: UsageUnit + public let used: Int? + public let limit: Int? public let resetsAt: Date? + public let lastUpdatedAt: Date? + public let confidence: UsageConfidence + public let statusOverride: UsageStatus? + public let note: String? - public init(provider: Provider, label: String, used: Int, limit: Int, resetsAt: Date? = nil) { - precondition(used >= 0, "used must not be negative") - precondition(limit > 0, "limit must be positive") + enum CodingKeys: String, CodingKey { + case id + case provider + case accountID + case accountName + case label + case windowLabel + case modelLabel + case unit + case used + case limit + case resetsAt + case lastUpdatedAt + case confidence + case statusOverride + case note + } + + public init( + id: String? = nil, + provider: Provider, + accountID: String, + accountName: String, + label: String, + windowLabel: String? = nil, + modelLabel: String? = nil, + unit: UsageUnit = .units, + used: Int?, + limit: Int?, + resetsAt: Date? = nil, + lastUpdatedAt: Date? = nil, + confidence: UsageConfidence = .official, + statusOverride: UsageStatus? = nil, + note: String? = nil + ) { + if let used { + precondition(used >= 0, "used must not be negative") + } + if let limit { + precondition(limit > 0, "limit must be positive") + } + self.id = id ?? "\(provider.rawValue):\(accountID):\(label)" self.provider = provider + self.accountID = accountID + self.accountName = accountName self.label = label + self.windowLabel = windowLabel + self.modelLabel = modelLabel + self.unit = unit self.used = used self.limit = limit self.resetsAt = resetsAt + self.lastUpdatedAt = lastUpdatedAt + self.confidence = confidence + self.statusOverride = statusOverride + self.note = note + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + provider = try container.decode(Provider.self, forKey: .provider) + accountID = try container.decode(String.self, forKey: .accountID) + accountName = try container.decode(String.self, forKey: .accountName) + label = try container.decode(String.self, forKey: .label) + windowLabel = try container.decodeIfPresent(String.self, forKey: .windowLabel) + modelLabel = try container.decodeIfPresent(String.self, forKey: .modelLabel) + unit = try container.decode(UsageUnit.self, forKey: .unit) + used = try container.decodeIfPresent(Int.self, forKey: .used) + limit = try container.decodeIfPresent(Int.self, forKey: .limit) + resetsAt = try container.decodeIfPresent(Date.self, forKey: .resetsAt) + lastUpdatedAt = try container.decodeIfPresent(Date.self, forKey: .lastUpdatedAt) + confidence = try container.decode(UsageConfidence.self, forKey: .confidence) + statusOverride = try container.decodeIfPresent(UsageStatus.self, forKey: .statusOverride) + note = try container.decodeIfPresent(String.self, forKey: .note) } - public var remaining: Int { - max(limit - used, 0) + public init(provider: Provider, label: String, used: Int, limit: Int, resetsAt: Date? = nil) { + self.init( + provider: provider, + accountID: "default", + accountName: provider.displayName, + label: label, + unit: .units, + used: used, + limit: limit, + resetsAt: resetsAt, + confidence: .official + ) } - public var usageRatio: Double { - min(Double(used) / Double(limit), 1) + public var remaining: Int? { + guard let used, let limit else { return nil } + return max(limit - used, 0) + } + + public var usageRatio: Double? { + guard let used, let limit else { return nil } + return min(Double(used) / Double(limit), 1) + } + + public var displayLabel: String { + windowLabel ?? label + } + + public var contextLabel: String { + [modelLabel, accountName] + .compactMap { value in + guard let value, !value.isEmpty else { return nil } + return value + } + .joined(separator: " ยท ") + } + + public var status: UsageStatus { + if let statusOverride { + return statusOverride + } + guard let ratio = usageRatio else { + return .unknown + } + if ratio >= 1 { + return .limited + } + if ratio >= 0.8 { + return .close + } + return .healthy + } +} + +public struct UsageSnapshot: Codable, Equatable, Sendable { + public let generatedAt: Date + public let limits: [UsageLimit] + + public init(generatedAt: Date, limits: [UsageLimit]) { + self.generatedAt = generatedAt + self.limits = limits + } + + public var mostConstrainedLimits: [UsageLimit] { + limits.sorted { lhs, rhs in + let lhsScore = lhs.constraintScore + let rhsScore = rhs.constraintScore + if lhsScore != rhsScore { + return lhsScore > rhsScore + } + return (lhs.usageRatio ?? -1) > (rhs.usageRatio ?? -1) + } + } + + public var aggregateCapacityRatio: Double { + let ratios = limits.compactMap(\.usageRatio) + guard !ratios.isEmpty else { return 0 } + return max(1 - (ratios.max() ?? 0), 0) + } + + public var aggregateStatus: UsageStatus { + mostConstrainedLimits.first?.status ?? .unknown + } +} + +extension UsageStatus { + fileprivate var sortRank: Double { + switch self { + case .limited: + 2 + case .failure: + 1.3 + case .close: + 1 + case .stale: + 0.4 + case .unknown: + 0.3 + case .loading: + 0.2 + case .healthy: + 0 + } + } +} + +extension UsageLimit { + fileprivate var constraintScore: Double { + status.sortRank + (usageRatio ?? 0) } } diff --git a/Sources/ContextPanelCore/WidgetSnapshot.swift b/Sources/ContextPanelCore/WidgetSnapshot.swift new file mode 100644 index 0000000..5f0cd8d --- /dev/null +++ b/Sources/ContextPanelCore/WidgetSnapshot.swift @@ -0,0 +1,139 @@ +import Foundation + +public enum WidgetSnapshotState: String, Codable, Equatable, Sendable { + case ready + case setupNeeded + case stale + case failure +} + +public struct WidgetSnapshot: Codable, Equatable, Sendable { + public let state: WidgetSnapshotState + public let generatedAt: Date + public let limits: [UsageLimit] + public let reports: [StoredProviderReport] + public let status: UsageStatus + public let message: String + + public init( + state: WidgetSnapshotState, + generatedAt: Date, + limits: [UsageLimit], + reports: [StoredProviderReport] = [], + status: UsageStatus, + message: String + ) { + self.state = state + self.generatedAt = generatedAt + self.limits = limits + self.reports = reports + self.status = status + self.message = message + } + + public var usageSnapshot: UsageSnapshot { + UsageSnapshot(generatedAt: generatedAt, limits: limits) + } + + public var mostConstrainedLimits: [UsageLimit] { + usageSnapshot.mostConstrainedLimits + } + + public var aggregateCapacityRatio: Double { + usageSnapshot.aggregateCapacityRatio + } + + public var providerSummaries: [ProviderSummary] { + Provider.allCases.map { provider in + let providerLimits = limits.filter { $0.provider == provider } + return ProviderSummary( + provider: provider, + limitCount: providerLimits.count, + status: providerLimits.map(\.status).contextPanelWorstStatus, + capacityRatio: capacityRatio(for: providerLimits) + ) + } + } + + public static func fromStore( + _ result: SnapshotStoreLoadResult, + now: Date = Date() + ) -> WidgetSnapshot { + guard let stored = result.snapshot else { + return WidgetSnapshot( + state: result.status == .failure ? .failure : .setupNeeded, + generatedAt: now, + limits: [], + status: result.status, + message: result.errorMessage ?? "Set up Context Panel in the app." + ) + } + + let state: WidgetSnapshotState = switch result.status { + case .failure: + .failure + case .stale: + .stale + default: + .ready + } + + return WidgetSnapshot( + state: state, + generatedAt: stored.snapshot.generatedAt, + limits: stored.snapshot.limits, + reports: stored.reports, + status: result.status, + message: message(state: state, stored: stored) + ) + } + + private static func message(state: WidgetSnapshotState, stored: StoredUsageSnapshot) -> String { + switch state { + case .ready: + let limitedCount = stored.snapshot.limits.filter { $0.status == .limited }.count + if limitedCount > 0 { + return "\(limitedCount) limit needs attention." + } + return "You're good to keep working." + case .setupNeeded: + return "Set up Context Panel in the app." + case .stale: + return "Last snapshot is stale." + case .failure: + return "Refresh failed." + } + } + + private func capacityRatio(for limits: [UsageLimit]) -> Double { + let ratios = limits.compactMap(\.usageRatio) + guard !ratios.isEmpty else { return 0 } + return max(1 - (ratios.max() ?? 0), 0) + } +} + +public struct ProviderSummary: Codable, Equatable, Sendable { + public let provider: Provider + public let limitCount: Int + public let status: UsageStatus + public let capacityRatio: Double + + public init(provider: Provider, limitCount: Int, status: UsageStatus, capacityRatio: Double) { + self.provider = provider + self.limitCount = limitCount + self.status = status + self.capacityRatio = capacityRatio + } +} + +extension Array where Element == UsageStatus { + public var contextPanelWorstStatus: UsageStatus { + if contains(.limited) { return .limited } + if contains(.failure) { return .failure } + if contains(.close) { return .close } + if contains(.stale) { return .stale } + if contains(.unknown) { return .unknown } + if contains(.loading) { return .loading } + return .healthy + } +} diff --git a/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift b/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift new file mode 100644 index 0000000..5ea5bb5 --- /dev/null +++ b/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift @@ -0,0 +1,1374 @@ +import ContextPanelCore +import SwiftUI +import WebKit + +@main +struct ContextPanelPreviewApp: App { + var body: some Scene { + WindowGroup { + AppRoot() + .frame(minWidth: 1280, minHeight: 720) + } + } +} + +struct AppRoot: View { + @StateObject private var model = ContextPanelAppModel() + @State private var selectedID: UsageLimit.ID? + + private var snapshot: UsageSnapshot { + model.currentSnapshot + } + + private var selectedLimit: UsageLimit { + if let selectedID, let match = snapshot.limits.first(where: { $0.id == selectedID }) { + return match + } + return snapshot.mostConstrainedLimits.first ?? SampleUsageData.snapshot.mostConstrainedLimits[0] + } + + var body: some View { + HStack(spacing: 0) { + AccountsSidebar(model: model, snapshot: snapshot, selectedID: $selectedID) + .frame(width: 210) + Divider() + InstrumentDashboard(model: model, snapshot: snapshot) + .frame(minWidth: 740) + Divider() + AccountDetail(model: model, limit: selectedLimit, generatedAt: snapshot.generatedAt) + .frame(width: 320) + } + .tint(CPTheme.accent) + .onAppear { + model.loadSnapshot() + selectedID = selectedID ?? snapshot.mostConstrainedLimits.first?.id + } + .sheet(isPresented: $model.isClaudeWebCapturePresented) { + ClaudeWebCaptureSheet(model: model) + .frame(minWidth: 980, minHeight: 680) + } + } +} + +struct AccountsSidebar: View { + @ObservedObject var model: ContextPanelAppModel + let snapshot: UsageSnapshot + @Binding var selectedID: UsageLimit.ID? + + var body: some View { + List(selection: $selectedID) { + Section("Accounts") { + ForEach(Provider.allCases) { provider in + let limits = snapshot.limits.filter { $0.provider == provider } + if !limits.isEmpty { + ProviderSidebarRow(provider: provider, limits: limits) + ForEach(limits) { limit in + SidebarLimitRow(limit: limit) + .tag(limit.id) + } + } + } + } + } + .navigationTitle("Context Panel") + .safeAreaInset(edge: .bottom) { + VStack(spacing: 8) { + Button { + Task { await model.refreshLocalConnectors() } + } label: { + Label(model.isRefreshing ? "Refreshing" : "Refresh", systemImage: "arrow.clockwise") + .frame(maxWidth: .infinity) + } + .disabled(model.isRefreshing) + + Button { + model.openClaudeWebCapture() + } label: { + Label("Claude Web", systemImage: "gauge.with.dots.needle.67percent") + .frame(maxWidth: .infinity) + } + } + .buttonStyle(.bordered) + .controlSize(.large) + .padding(12) + } + } +} + +struct ProviderSidebarRow: View { + let provider: Provider + let limits: [UsageLimit] + + var body: some View { + HStack(spacing: 8) { + ProviderBadge(provider: provider) + Text(provider.displayName) + .font(.system(size: 12, weight: .semibold)) + .textCase(.uppercase) + .foregroundStyle(.secondary) + Spacer() + Text("\(limits.count)") + .font(.system(.caption, design: .monospaced, weight: .medium)) + .foregroundStyle(.tertiary) + } + } +} + +struct SidebarLimitRow: View { + let limit: UsageLimit + + var body: some View { + HStack(spacing: 10) { + StatusMark(status: limit.status, size: 7) + VStack(alignment: .leading, spacing: 2) { + Text(limit.accountName) + .font(.system(size: 13, weight: .medium)) + Text(limit.displayLabel) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + Spacer() + Text(limit.compactUsageText) + .font(.system(.caption2, design: .monospaced, weight: .medium)) + .foregroundStyle(.secondary) + } + .padding(.vertical, 3) + } +} + +struct InstrumentDashboard: View { + @ObservedObject var model: ContextPanelAppModel + let snapshot: UsageSnapshot + + private var constrained: [UsageLimit] { + Array(snapshot.mostConstrainedLimits.prefix(4)) + } + + var body: some View { + ScrollView([.vertical, .horizontal]) { + VStack(alignment: .leading, spacing: 18) { + HeaderCard(model: model, snapshot: snapshot) + SetupStatusStrip(model: model) + WidgetPreviewGrid(snapshot: snapshot) + SectionHeader(title: "Most Constrained", trailing: "\(snapshot.limits.count) accounts") + VStack(spacing: 10) { + ForEach(constrained) { limit in + AccountRow(limit: limit) + } + } + SectionHeader(title: "Provider Groups", trailing: "Last update 2m ago") + ProviderGroupGrid(snapshot: snapshot) + } + .padding(24) + .frame(minWidth: 720, alignment: .topLeading) + } + .background(CPTheme.background) + .navigationTitle("Glance") + } +} + +struct HeaderCard: View { + @ObservedObject var model: ContextPanelAppModel + let snapshot: UsageSnapshot + + var body: some View { + HStack(alignment: .center, spacing: 22) { + VStack(alignment: .leading, spacing: 10) { + CPLabel("Context Panel") + Text(snapshot.headline) + .font(.system(size: 28, weight: .semibold)) + .foregroundStyle(CPTheme.primaryText) + .lineLimit(2) + Text(snapshot.subheadline) + .font(.system(size: 13)) + .foregroundStyle(CPTheme.secondaryText) + Text(model.fastModeForecast.copy) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(CPTheme.accent) + HStack(spacing: 8) { + TagLabel("SwiftUI") + TagLabel("WidgetKit") + TagLabel(model.storeStatus.rawValue) + } + } + Spacer(minLength: 16) + CapacityDial( + value: snapshot.tightestCapacityRatio, + status: snapshot.aggregateStatus, + label: "\(Int(snapshot.tightestCapacityRatio * 100))", + sublabel: "tightest", + size: 116 + ) + } + .padding(22) + .background(CPTheme.surface) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay(CPTheme.stroke(cornerRadius: 12)) + .shadow(color: .black.opacity(0.05), radius: 14, x: 0, y: 8) + } +} + +struct SetupStatusStrip: View { + @ObservedObject var model: ContextPanelAppModel + + var body: some View { + HStack(spacing: 12) { + SetupStatusItem( + title: "Snapshot cache", + value: model.storeStatus == .healthy ? "Ready" : model.storeStatus.rawValue, + status: model.storeStatus + ) + SetupStatusItem( + title: "History", + value: "\(model.historyCount) entries", + status: model.historyCount > 0 ? .healthy : .unknown + ) + SetupStatusItem( + title: "Last refresh", + value: model.lastRefreshText, + status: model.isRefreshing ? .loading : .healthy + ) + Spacer(minLength: 12) + if let errorMessage = model.errorMessage { + Text(errorMessage) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(CPTheme.statusColor(.failure)) + .lineLimit(1) + } + } + .padding(14) + .background(CPTheme.surface) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay(CPTheme.stroke(cornerRadius: 10)) + } +} + +struct SetupStatusItem: View { + let title: String + let value: String + let status: UsageStatus + + var body: some View { + HStack(spacing: 8) { + StatusMark(status: status, size: 8) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(CPTheme.tertiaryText) + .textCase(.uppercase) + Text(value) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(CPTheme.secondaryText) + .lineLimit(1) + } + } + } +} + +struct WidgetPreviewGrid: View { + let snapshot: UsageSnapshot + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + SectionHeader(title: "Concept A ยท Instrument", trailing: "Native preview") + HStack(alignment: .top, spacing: 12) { + SmallWidgetPreview(snapshot: snapshot) + MediumWidgetPreview(snapshot: snapshot) + } + LargeWidgetPreview(snapshot: snapshot) + } + } +} + +struct SmallWidgetPreview: View { + let snapshot: UsageSnapshot + + var body: some View { + WidgetShell(width: 220, height: 220) { + VStack(alignment: .leading, spacing: 10) { + WidgetHeader(status: snapshot.aggregateStatus) + Spacer() + Text(snapshot.fastModeForecast.copy) + .font(.system(size: 26, weight: .semibold)) + .foregroundStyle(CPTheme.primaryText) + .lineLimit(2) + .minimumScaleFactor(0.75) + Text(snapshot.tightestSupportText) + .font(.system(size: 12)) + .foregroundStyle(CPTheme.secondaryText) + .lineLimit(2) + Spacer() + ProviderMiniStatus(snapshot: snapshot) + } + } + } +} + +struct MediumWidgetPreview: View { + let snapshot: UsageSnapshot + + var body: some View { + WidgetShell(width: 460, height: 220) { + HStack(spacing: 18) { + VStack(alignment: .leading) { + WidgetHeader(status: snapshot.aggregateStatus) + Spacer() + CapacityDial( + value: snapshot.tightestCapacityRatio, + status: snapshot.aggregateStatus, + label: "\(Int(snapshot.tightestCapacityRatio * 100))", + sublabel: "tightest", + size: 94 + ) + VStack(alignment: .leading, spacing: 2) { + Text(snapshot.fastModeForecast.copy) + .font(.system(size: 18, weight: .semibold)) + Text(snapshot.providerPressureText) + .font(.system(size: 11)) + .foregroundStyle(CPTheme.tertiaryText) + } + Spacer() + Text(snapshot.nearestResetText) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(CPTheme.tertiaryText) + } + .frame(width: 150, alignment: .leading) + + Divider() + + VStack(alignment: .leading, spacing: 8) { + SectionHeader(title: "Most Constrained", trailing: "4 accounts") + ForEach(snapshot.mostConstrainedLimits.prefix(4)) { limit in + AccountRow(limit: limit, compact: true) + } + } + } + } + } +} + +struct LargeWidgetPreview: View { + let snapshot: UsageSnapshot + + var body: some View { + WidgetShell(width: 460, height: 460) { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 6) { + CPLabel("Context Panel") + Text(snapshot.fastModeForecast.copy) + .font(.system(size: 25, weight: .semibold)) + .foregroundStyle(CPTheme.primaryText) + Text(snapshot.tightestSupportText) + .font(.system(size: 12)) + .foregroundStyle(CPTheme.secondaryText) + } + Spacer() + CapacityDial( + value: snapshot.tightestCapacityRatio, + status: snapshot.aggregateStatus, + label: "\(Int(snapshot.tightestCapacityRatio * 100))", + sublabel: "tightest", + size: 84 + ) + } + + ProviderGroupGrid(snapshot: snapshot, compact: true) + + Spacer(minLength: 0) + Divider() + HStack { + Sparkline(values: [0.72, 0.68, 0.7, 0.64, 0.62, 0.58, 0.64]) + .frame(width: 120, height: 20) + Text("pressure trend") + .font(.system(size: 10)) + .foregroundStyle(CPTheme.tertiaryText) + Spacer() + Text(snapshot.nearestResetText) + .font(.system(size: 10)) + .foregroundStyle(CPTheme.tertiaryText) + } + } + } + } +} + +struct ProviderGroupGrid: View { + let snapshot: UsageSnapshot + var compact = false + + var body: some View { + HStack(alignment: .top, spacing: 14) { + ForEach(Provider.allCases) { provider in + let limits = snapshot.limits.filter { $0.provider == provider } + if !limits.isEmpty { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + ProviderBadge(provider: provider, compact: true) + Text(provider.displayName) + .font(.system(size: 11, weight: .semibold)) + .textCase(.uppercase) + Spacer() + StatusMark(status: limits.map(\.status).worstStatus, size: 7) + } + .foregroundStyle(CPTheme.secondaryText) + Divider() + ForEach(limits.prefix(compact ? 3 : 4)) { limit in + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline) { + Text(limit.accountName) + .font(.system(size: compact ? 11 : 12, weight: .medium)) + .lineLimit(1) + Spacer() + Text(limit.percentText) + .font(.system(size: 10, weight: .medium, design: .monospaced)) + .foregroundStyle(CPTheme.secondaryText) + } + Text(limit.displayLabel) + .font(.system(size: 10)) + .foregroundStyle(CPTheme.tertiaryText) + .lineLimit(1) + CapacityBar(value: limit.usageRatio ?? 0, status: limit.status) + } + } + } + .frame(maxWidth: .infinity, alignment: .topLeading) + } + } + } + } +} + +struct AccountDetail: View { + @ObservedObject var model: ContextPanelAppModel + let limit: UsageLimit + let generatedAt: Date + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + HStack(spacing: 10) { + ProviderBadge(provider: limit.provider) + VStack(alignment: .leading, spacing: 2) { + Text(limit.accountName) + .font(.system(size: 22, weight: .semibold)) + Text("\(limit.provider.displayName) ยท \(limit.displayLabel) ยท \(limit.contextLabel)") + .font(.system(size: 13)) + .foregroundStyle(CPTheme.secondaryText) + } + Spacer() + StatusMark(status: limit.status, size: 10) + } + + CapacityDial( + value: 1 - (limit.usageRatio ?? 0), + status: limit.status, + label: limit.remaining.map(String.init) ?? "?", + sublabel: "left", + size: 140 + ) + .frame(maxWidth: .infinity) + + DetailCard(title: "Forecast") { + Text(forecastCopy) + .font(.system(size: 15, weight: .medium)) + Text("Confidence: \(limit.confidence.rawValue)") + .font(.system(size: 12)) + .foregroundStyle(CPTheme.secondaryText) + } + + DetailCard(title: "Normalized limit") { + DetailRow(label: "Used", value: limit.used.map(String.init) ?? "unknown") + DetailRow(label: "Limit", value: limit.limit.map(String.init) ?? "unknown") + DetailRow(label: "Remaining", value: limit.remaining.map(String.init) ?? "unknown") + DetailRow(label: "Status", value: limit.status.rawValue) + DetailRow(label: "Updated", value: limit.lastUpdatedAt.map(model.relativeTime) ?? "unknown") + } + + DetailCard(title: "Refresh history") { + Sparkline(values: [0.72, 0.68, 0.70, 0.64, 0.62, 0.58, 0.64]) + .frame(height: 42) + Text("\(model.historyCount) cached snapshots. Last good snapshot is preserved for stale and failure states.") + .font(.system(size: 12)) + .foregroundStyle(CPTheme.secondaryText) + } + + DetailCard(title: "Setup") { + DetailRow(label: "Store", value: ConnectorRedactor.redactedPath(model.store.rootDirectory.path)) + DetailRow(label: "Accounts", value: "\(model.configuredAccounts.filter(\.isEnabled).count) enabled") + ForEach(model.configuredAccounts) { account in + DetailRow( + label: account.displayName, + value: account.isEnabled ? account.connectorKind.rawValue : "disabled" + ) + } + } + } + .padding(22) + } + .background(CPTheme.background) + .navigationTitle("Details") + } + + private var forecastCopy: String { + if limit.provider == .openAI, limit.unit == .percent { + return FastModeForecast( + input: FastModeForecastInput( + limit: limit, + now: model.now, + standardBurnRate: BurnRate(mode: .standard, unitsPerHour: 2), + fastBurnRate: BurnRate(mode: .fast, unitsPerHour: 12), + reserveUnits: 6, + minimumSafeHours: 1 + ) + ).copy + } + return limit.note ?? "No fast-mode forecast for this limit yet." + } +} + +struct DetailCard: View { + let title: String + @ViewBuilder let content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + CPLabel(title) + content + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(CPTheme.surface) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay(CPTheme.stroke(cornerRadius: 10)) + } +} + +struct DetailRow: View { + let label: String + let value: String + + var body: some View { + HStack { + Text(label) + .foregroundStyle(CPTheme.secondaryText) + Spacer() + Text(value) + .font(.system(.caption, design: .monospaced, weight: .medium)) + } + .font(.system(size: 13)) + } +} + +struct WidgetShell: View { + let width: CGFloat + let height: CGFloat + @ViewBuilder let content: Content + + var body: some View { + content + .padding(16) + .frame(width: width, height: height) + .background(CPTheme.surface) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .overlay(CPTheme.stroke(cornerRadius: 20)) + .shadow(color: .black.opacity(0.05), radius: 12, x: 0, y: 6) + } +} + +struct WidgetHeader: View { + let status: UsageStatus + + var body: some View { + HStack { + CPLabel("Context Panel") + Spacer() + StatusMark(status: status, size: 9) + } + } +} + +struct ProviderMiniStatus: View { + let snapshot: UsageSnapshot + + var body: some View { + HStack(spacing: 14) { + ForEach(Provider.allCases) { provider in + let limits = snapshot.limits.filter { $0.provider == provider } + HStack(spacing: 5) { + ProviderBadge(provider: provider, compact: true) + } + .opacity(limits.isEmpty ? 0.35 : 1) + } + } + } +} + +struct AccountRow: View { + let limit: UsageLimit + var compact = false + + var body: some View { + HStack(spacing: 10) { + ProviderBadge(provider: limit.provider, compact: true) + .frame(width: 16) + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline) { + Text(limit.displayLabel) + .font(.system(size: compact ? 12 : 13, weight: .medium)) + .lineLimit(1) + Text("ยท \(limit.contextLabel)") + .font(.system(size: compact ? 12 : 13)) + .foregroundStyle(CPTheme.tertiaryText) + .lineLimit(1) + Spacer() + Text(limit.compactUsageText) + .font(.system(size: compact ? 10 : 11, weight: .medium, design: .monospaced)) + .foregroundStyle(CPTheme.secondaryText) + } + HStack(spacing: 8) { + CapacityBar(value: limit.usageRatio ?? 0, status: limit.status) + Text(limit.resetText) + .font(.system(size: 10)) + .foregroundStyle(limit.status == .stale ? CPTheme.statusColor(.stale) : CPTheme.tertiaryText) + .lineLimit(1) + } + } + } + .padding(compact ? 0 : 10) + .background(compact ? Color.clear : CPTheme.surface) + .clipShape(RoundedRectangle(cornerRadius: compact ? 0 : 8, style: .continuous)) + .overlay { + if !compact { + CPTheme.stroke(cornerRadius: 8) + } + } + } +} + +@MainActor +final class ContextPanelAppModel: ObservableObject { + @Published private(set) var storedSnapshot: StoredUsageSnapshot? + @Published private(set) var storeStatus: UsageStatus = .unknown + @Published private(set) var historyCount: Int = 0 + @Published private(set) var configuredAccounts: [LocalProviderAccountConfiguration] = [] + @Published private(set) var isRefreshing = false + @Published var isClaudeWebCapturePresented = false + @Published private(set) var errorMessage: String? + @Published private(set) var lastRefreshAt: Date? + + let now = Date() + let store: JSONSnapshotStore + let accountStore: AccountConfigurationStore + + var currentSnapshot: UsageSnapshot { + storedSnapshot?.snapshot ?? SampleUsageData.snapshot + } + + var fastModeForecast: FastModePortfolioForecast { + let forecasts = currentSnapshot.limits + .filter { $0.provider == .openAI && $0.unit == .percent } + .map { limit in + FastModeForecast(input: FastModeForecastInput( + limit: limit, + now: Date(), + standardBurnRate: BurnRate(mode: .standard, unitsPerHour: 2), + fastBurnRate: BurnRate(mode: .fast, unitsPerHour: 12), + reserveUnits: 6, + minimumSafeHours: 1 + )) + } + return FastModePortfolioForecast(forecasts: forecasts) + } + + var lastRefreshText: String { + lastRefreshAt.map(relativeTime) ?? "not yet" + } + + init() { + store = JSONSnapshotStore( + rootDirectory: ContextPanelLocations.snapshotDirectory(appGroupID: ContextPanelLocations.appGroupID) + ) + accountStore = AccountConfigurationStore(configurationURL: ContextPanelLocations.accountConfigurationURL()) + } + + func loadSnapshot() { + let accounts = accountStore.load().document.accounts + configuredAccounts = accounts + let result = store.loadCurrent(policy: SnapshotStoreStalenessPolicy(maximumAge: 15 * 60), now: Date()) + storedSnapshot = result.snapshot + storeStatus = result.status + errorMessage = result.errorMessage + historyCount = store.loadHistory().count + } + + func refreshLocalConnectors() async { + isRefreshing = true + defer { isRefreshing = false } + + let accountDocument = accountStore.load().document + configuredAccounts = accountDocument.accounts + let connectors = AccountConnectorFactory.connectors(from: accountDocument) + let refreshResult = await ProviderConnectorRuntime(connectors: connectors).refreshAll() + let savedAt = Date() + + do { + try store.saveMerged(refreshResult: refreshResult, savedAt: savedAt) + lastRefreshAt = savedAt + loadSnapshot() + } catch { + storeStatus = .failure + errorMessage = error.localizedDescription + } + } + + func openClaudeWebCapture() { + isClaudeWebCapturePresented = true + } + + func closeClaudeWebCapture() { + isClaudeWebCapturePresented = false + } + + func saveClaudeWebLimits(_ limits: [UsageLimit]) { + guard !limits.isEmpty else { return } + let savedAt = Date() + let report = ProviderConnectorReport( + provider: .anthropic, + accountID: "claude-web", + accountName: "Claude Web", + generatedAt: savedAt, + limits: limits, + status: .healthy + ) + do { + try store.saveMerged( + refreshResult: ConnectorRefreshResult(generatedAt: savedAt, reports: [report]), + savedAt: savedAt + ) + lastRefreshAt = savedAt + loadSnapshot() + } catch { + storeStatus = .failure + errorMessage = error.localizedDescription + } + } + + func relativeTime(_ date: Date) -> String { + let seconds = max(Int(Date().timeIntervalSince(date)), 0) + if seconds < 60 { return "just now" } + let minutes = seconds / 60 + if minutes < 60 { return "\(minutes)m ago" } + let hours = minutes / 60 + if hours < 24 { return "\(hours)h ago" } + return "\(hours / 24)d ago" + } + +} + +struct CapacityDial: View { + let value: Double + let status: UsageStatus + let label: String + let sublabel: String + var size: CGFloat = 96 + var thickness: CGFloat = 6 + + var body: some View { + ZStack { + Circle() + .stroke(CPTheme.line, lineWidth: thickness) + Circle() + .trim(from: 0, to: min(max(value, 0), 1)) + .stroke( + CPTheme.statusColor(status), + style: StrokeStyle(lineWidth: thickness, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) + VStack(spacing: 0) { + Text(label) + .font(.system(size: size > 100 ? 30 : 22, weight: .semibold, design: .monospaced)) + .foregroundStyle(CPTheme.primaryText) + Text(sublabel) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(CPTheme.tertiaryText) + .textCase(.uppercase) + } + } + .frame(width: size, height: size) + } +} + +struct CapacityBar: View { + let value: Double + let status: UsageStatus + var height: CGFloat = 4 + + var body: some View { + GeometryReader { proxy in + ZStack(alignment: .leading) { + Capsule() + .fill(CPTheme.line) + Capsule() + .fill(CPTheme.statusColor(status)) + .frame(width: proxy.size.width * min(max(value, 0), 1)) + } + } + .frame(height: height) + } +} + +struct ProviderBadge: View { + let provider: Provider + var compact = false + + var body: some View { + Text(provider.shortName) + .font(.system(size: compact ? 10 : 11, weight: .semibold, design: .monospaced)) + .foregroundStyle(CPTheme.providerColor(provider)) + .lineLimit(1) + } +} + +struct StatusMark: View { + let status: UsageStatus + var size: CGFloat = 8 + + var body: some View { + Group { + switch status { + case .healthy: + Circle().fill(CPTheme.statusColor(status)) + case .close: + Circle().trim(from: 0, to: 0.75).stroke(CPTheme.statusColor(status), lineWidth: 2) + case .limited: + RoundedRectangle(cornerRadius: 1).fill(CPTheme.statusColor(status)) + case .stale: + Circle().stroke(CPTheme.statusColor(status), style: StrokeStyle(lineWidth: 1.4, dash: [2, 2])) + case .unknown: + Text("?").font(.system(size: size + 3, weight: .semibold)).foregroundStyle(CPTheme.statusColor(status)) + case .failure: + Image(systemName: "xmark").font(.system(size: size, weight: .bold)).foregroundStyle(CPTheme.statusColor(status)) + case .loading: + Circle().stroke(CPTheme.statusColor(status), lineWidth: 1.4) + } + } + .frame(width: size, height: size) + } +} + +struct ClaudeWebCaptureSheet: View { + @ObservedObject var model: ContextPanelAppModel + @StateObject private var captureModel = ClaudeWebCaptureModel() + + var body: some View { + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 8) { + Text("Claude Web") + .font(.system(size: 22, weight: .semibold)) + Text("Complete Claude verification here. The app captures only official usage windows from the Usage page.") + .font(.system(size: 13)) + .foregroundStyle(CPTheme.secondaryText) + } + + HStack { + Button("Open Usage") { captureModel.openUsagePage() } + Button("Reload") { captureModel.reload() } + Spacer() + Button("Done") { model.closeClaudeWebCapture() } + } + + Label(captureModel.statusText, systemImage: captureModel.statusIcon) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(captureModel.limits.isEmpty ? CPTheme.secondaryText : CPTheme.primaryText) + + Divider() + + Text("Captured windows") + .font(.system(size: 11, weight: .semibold)) + .textCase(.uppercase) + .foregroundStyle(CPTheme.secondaryText) + + if captureModel.limits.isEmpty { + ContentUnavailableView( + "Waiting for Claude usage", + systemImage: "network", + description: Text("The sheet auto-saves when Claude's usage endpoint returns percent windows.") + ) + .frame(maxHeight: 220) + } else { + ScrollView { + VStack(spacing: 8) { + ForEach(captureModel.limits) { limit in + ClaudeWebCaptureLimitRow(limit: limit) + } + } + } + } + + Spacer() + + VStack(alignment: .leading, spacing: 6) { + Label("No cookies, tokens, headers, IDs, emails, local storage, or raw bodies are stored.", systemImage: "lock.shield") + Label("Saved rows are merged with OpenAI and Gemini instead of replacing them.", systemImage: "square.stack.3d.up") + } + .font(.system(size: 11)) + .foregroundStyle(CPTheme.secondaryText) + } + .frame(width: 330) + .padding(18) + .background(CPTheme.surface) + + Divider() + + ClaudeWebCaptureWebView(model: captureModel) + } + .onReceive(captureModel.$limits) { limits in + guard !limits.isEmpty else { return } + model.saveClaudeWebLimits(limits) + } + } +} + +struct ClaudeWebCaptureLimitRow: View { + let limit: UsageLimit + + var body: some View { + VStack(alignment: .leading, spacing: 7) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(limit.displayLabel) + .font(.system(size: 13, weight: .semibold)) + Text(limit.contextLabel) + .font(.system(size: 11)) + .foregroundStyle(CPTheme.secondaryText) + } + Spacer() + Text(limit.compactUsageText) + .font(.system(size: 18, weight: .semibold, design: .rounded)) + } + CapacityBar(value: limit.usageRatio ?? 0, status: limit.status) + Text(limit.resetText) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(CPTheme.tertiaryText) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(CPTheme.background) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay(CPTheme.stroke(cornerRadius: 8)) + } +} + +@MainActor +final class ClaudeWebCaptureModel: ObservableObject { + @Published var limits: [UsageLimit] = [] + @Published var statusText = "Opening Claude usage page" + @Published var statusIcon = "safari" + + private lazy var navigationDelegate = ClaudeWebCaptureNavigationDelegate(owner: self) + + lazy var webView: WKWebView = { + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .default() + configuration.userContentController.add(ClaudeWebCaptureScriptHandler(owner: self), name: "claudeUsageCapture") + configuration.userContentController.addUserScript( + WKUserScript(source: Self.networkProbeScript, injectionTime: .atDocumentStart, forMainFrameOnly: false) + ) + let view = WKWebView(frame: .zero, configuration: configuration) + view.navigationDelegate = navigationDelegate + return view + }() + + init() { + openUsagePage() + } + + func openUsagePage() { + statusText = "Opening Claude usage page" + statusIcon = "safari" + webView.load(URLRequest(url: URL(string: "https://claude.ai/settings/usage")!)) + } + + func reload() { + statusText = "Reloading Claude usage page" + statusIcon = "arrow.clockwise" + webView.reload() + } + + fileprivate func record(payload: [String: Any]) { + let windows = payload["windows"] as? [String: Any] ?? [:] + let wrapped = ["rate_limits": windows] + do { + let data = try JSONSerialization.data(withJSONObject: wrapped) + let parsed = try ClaudeWebUsageParser.usageLimits( + from: data, + accountID: "claude-web", + accountName: "Claude Web", + observedAt: Date() + ) + guard !parsed.isEmpty else { return } + limits = parsed + statusText = "Captured and saved Claude web usage" + statusIcon = "checkmark.circle.fill" + } catch { + statusText = "Capture failed: \(error.localizedDescription)" + statusIcon = "exclamationmark.triangle" + } + } + + fileprivate func didFinishNavigation(url: URL?) { + if let host = url?.host, host.contains("claude.ai"), limits.isEmpty { + statusText = "Claude page loaded; waiting for usage API" + statusIcon = "network" + } + } + + private static let networkProbeScript = #""" + (() => { + if (window.__contextPanelClaudeUsageCaptureInstalled) return; + window.__contextPanelClaudeUsageCaptureInstalled = true; + + const windowKeys = new Set(['five_hour', 'seven_day', 'seven_day_opus', 'seven_day_sonnet', 'seven_day_oauth_apps']); + const fieldKeys = new Set(['used_percentage', 'remaining_percentage', 'utilization', 'resets_at', 'reset_at']); + + function isUsageURL(rawUrl) { + try { return /^\/api\/organizations\/[^/]+\/usage$/.test(new URL(rawUrl, window.location.href).pathname); } + catch (_) { return false; } + } + + function sanitizeWindow(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const sanitized = {}; + for (const key of fieldKeys) { + const raw = value[key]; + if (typeof raw === 'number' || typeof raw === 'string') sanitized[key] = raw; + } + return Object.keys(sanitized).length ? sanitized : null; + } + + function collectWindows(value, out = {}) { + if (!value || typeof value !== 'object') return out; + if (Array.isArray(value)) { + value.slice(0, 3).forEach(item => collectWindows(item, out)); + return out; + } + for (const [key, child] of Object.entries(value)) { + if (windowKeys.has(key)) { + const sanitized = sanitizeWindow(child); + if (sanitized) out[key] = sanitized; + } + collectWindows(child, out); + } + return out; + } + + function post(payload) { + try { window.webkit.messageHandlers.claudeUsageCapture.postMessage(payload); } + catch (_) {} + } + + function inspect(url, contentType, text) { + if (!isUsageURL(url) || !/json/i.test(contentType || '')) return; + try { + const windows = collectWindows(JSON.parse(String(text || ''))); + if (Object.keys(windows).length) post({ windows }); + } catch (_) {} + } + + const originalFetch = window.fetch; + if (originalFetch) { + window.fetch = async function(input, init) { + const response = await originalFetch.apply(this, arguments); + try { + const clone = response.clone(); + const url = typeof input === 'string' ? input : (input && input.url) || ''; + clone.text().then(text => inspect(url, clone.headers.get('content-type') || '', text)).catch(() => {}); + } catch (_) {} + return response; + }; + } + + const originalOpen = XMLHttpRequest.prototype.open; + const originalSend = XMLHttpRequest.prototype.send; + XMLHttpRequest.prototype.open = function(method, url) { + this.__cpClaudeUsageUrl = url; + return originalOpen.apply(this, arguments); + }; + XMLHttpRequest.prototype.send = function() { + this.addEventListener('load', function() { + try { inspect(this.__cpClaudeUsageUrl || '', this.getResponseHeader('content-type') || '', this.responseText || ''); } + catch (_) {} + }); + return originalSend.apply(this, arguments); + }; + })(); + """# +} + +final class ClaudeWebCaptureScriptHandler: NSObject, WKScriptMessageHandler { + weak var owner: ClaudeWebCaptureModel? + + init(owner: ClaudeWebCaptureModel) { + self.owner = owner + } + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + guard let payload = message.body as? [String: Any] else { return } + Task { @MainActor [weak owner] in owner?.record(payload: payload) } + } +} + +final class ClaudeWebCaptureNavigationDelegate: NSObject, WKNavigationDelegate { + weak var owner: ClaudeWebCaptureModel? + + init(owner: ClaudeWebCaptureModel) { + self.owner = owner + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + Task { @MainActor [weak webView, weak owner] in owner?.didFinishNavigation(url: webView?.url) } + } +} + +struct ClaudeWebCaptureWebView: NSViewRepresentable { + @ObservedObject var model: ClaudeWebCaptureModel + + func makeNSView(context: Context) -> WKWebView { model.webView } + + func updateNSView(_ nsView: WKWebView, context: Context) {} +} + +struct Sparkline: View { + let values: [Double] + + var body: some View { + GeometryReader { proxy in + Path { path in + guard let first = values.first else { return } + let points = values.enumerated().map { index, value in + CGPoint( + x: proxy.size.width * CGFloat(index) / CGFloat(max(values.count - 1, 1)), + y: proxy.size.height * CGFloat(1 - min(max(value, 0), 1)) + ) + } + path.move(to: CGPoint(x: 0, y: proxy.size.height * CGFloat(1 - first))) + points.dropFirst().forEach { path.addLine(to: $0) } + } + .stroke(CPTheme.accent, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round)) + } + } +} + +struct SectionHeader: View { + let title: String + var trailing: String? = nil + + var body: some View { + HStack(alignment: .firstTextBaseline) { + Text(title) + .font(.system(size: 10, weight: .semibold)) + .tracking(0.9) + .textCase(.uppercase) + .foregroundStyle(CPTheme.tertiaryText) + Spacer() + if let trailing { + Text(trailing) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(CPTheme.tertiaryText) + } + } + } +} + +struct CPLabel: View { + let text: String + + init(_ text: String) { + self.text = text + } + + var body: some View { + HStack(spacing: 6) { + RoundedRectangle(cornerRadius: 1) + .fill(CPTheme.accent) + .rotationEffect(.degrees(45)) + .frame(width: 6, height: 6) + Text(text) + .font(.system(size: 10, weight: .semibold)) + .tracking(0.8) + .textCase(.uppercase) + .foregroundStyle(CPTheme.tertiaryText) + } + } +} + +struct TagLabel: View { + let text: String + + init(_ text: String) { + self.text = text + } + + var body: some View { + Text(text) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(CPTheme.secondaryText) + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background(CPTheme.accent.opacity(0.08)) + .clipShape(Capsule()) + } +} + +enum CPTheme { + static let background = Color(red: 244 / 255, green: 244 / 255, blue: 245 / 255) + static let surface = Color.white + static let surface2 = Color(red: 250 / 255, green: 250 / 255, blue: 250 / 255) + static let line = Color.black.opacity(0.07) + static let primaryText = Color(red: 10 / 255, green: 10 / 255, blue: 11 / 255) + static let secondaryText = primaryText.opacity(0.66) + static let tertiaryText = primaryText.opacity(0.46) + static let accent = Color(red: 74 / 255, green: 91 / 255, blue: 122 / 255) + + static func providerColor(_ provider: Provider) -> Color { + switch provider { + case .openAI: + Color(red: 56 / 255, green: 92 / 255, blue: 126 / 255) + case .anthropic: + Color(red: 139 / 255, green: 102 / 255, blue: 51 / 255) + case .google: + Color(red: 35 / 255, green: 116 / 255, blue: 106 / 255) + } + } + + static func statusColor(_ status: UsageStatus) -> Color { + switch status { + case .healthy: + Color(red: 74 / 255, green: 122 / 255, blue: 91 / 255) + case .close: + Color(red: 138 / 255, green: 106 / 255, blue: 42 / 255) + case .limited, .failure: + Color(red: 138 / 255, green: 74 / 255, blue: 74 / 255) + case .stale, .unknown, .loading: + Color(red: 106 / 255, green: 106 / 255, blue: 114 / 255) + } + } + + static func stroke(cornerRadius: CGFloat) -> some View { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .stroke(line, lineWidth: 1) + } +} + +extension UsageSnapshot { + var headline: String { + "Know whether you can keep working." + } + + var subheadline: String { + "OpenAI, Anthropic, and Google capacity with local confidence." + } + + var tightestLimit: UsageLimit? { + mostConstrainedLimits.first + } + + var tightestUsageText: String { + guard let tightestLimit else { return "Set up accounts" } + return tightestLimit.compactUsageText == "?" ? "Unknown limit" : "\(tightestLimit.compactUsageText) left" + } + + var tightestSupportText: String { + guard let tightestLimit else { return "Add OpenAI, Anthropic, or Google." } + return "\(tightestLimit.provider.shortName) ยท \(tightestLimit.displayLabel) ยท \(tightestLimit.contextLabel)" + } + + var tightestCapacityRatio: Double { + guard let ratio = tightestLimit?.usageRatio else { return 0 } + return max(1 - ratio, 0) + } + + var fastModeForecast: FastModePortfolioForecast { + let forecasts = limits + .filter { $0.provider == .openAI && $0.unit == .percent } + .map { limit in + FastModeForecast(input: FastModeForecastInput( + limit: limit, + now: Date(), + standardBurnRate: BurnRate(mode: .standard, unitsPerHour: 2), + fastBurnRate: BurnRate(mode: .fast, unitsPerHour: 12), + reserveUnits: 6, + minimumSafeHours: 1 + )) + } + return FastModePortfolioForecast(forecasts: forecasts) + } + + var providerPressureText: String { + let limited = limits.filter { $0.status == .limited }.count + let close = limits.filter { $0.status == .close }.count + if limited > 0 || close > 0 { + return "\(limited) limited ยท \(close) close" + } + return "all tracked windows healthy" + } + + var nearestResetText: String { + let futureResets = limits.compactMap(\.resetsAt).filter { $0 > Date() }.sorted() + guard let reset = futureResets.first else { return "reset unknown" } + return "nearest reset \(reset.widgetRelativeText)" + } +} + +extension UsageLimit { + var compactUsageText: String { + if provider == .anthropic, unit == .unknown, status == .unknown { + return "unknown" + } + guard let used, let limit else { return status == .failure ? "โ€”" : "?" } + return "\(used)/\(limit)" + } + + var percentText: String { + if provider == .anthropic, unit == .unknown, status == .unknown { + return "unknown" + } + guard let usageRatio else { return status == .failure ? "โ€”" : "?" } + return "\(Int(usageRatio * 100))%" + } + + var resetText: String { + if status == .failure { return "refresh failed" } + guard let resetsAt else { return "unknown reset" } + if resetsAt < Date().addingTimeInterval(-60) { return "reset passed" } + return "resets \(resetsAt.widgetRelativeText)" + } +} + +extension Date { + var widgetRelativeText: String { + let seconds = Int(timeIntervalSince(Date())) + if abs(seconds) < 60 { return "now" } + if seconds >= 0 { + let minutes = seconds / 60 + if minutes < 60 { return "in \(minutes)m" } + let hours = minutes / 60 + if hours < 24 { return "in \(hours)h" } + return "in \(hours / 24)d" + } + let elapsed = abs(seconds) + let minutes = elapsed / 60 + if minutes < 60 { return "\(minutes)m ago" } + let hours = minutes / 60 + if hours < 24 { return "\(hours)h ago" } + return "\(hours / 24)d ago" + } +} + +extension [UsageStatus] { + var worstStatus: UsageStatus { + contextPanelWorstStatus + } +} diff --git a/Sources/ContextPanelPreview/SampleUsageData.swift b/Sources/ContextPanelPreview/SampleUsageData.swift new file mode 100644 index 0000000..42fd6ac --- /dev/null +++ b/Sources/ContextPanelPreview/SampleUsageData.swift @@ -0,0 +1,127 @@ +import ContextPanelCore +import Foundation + +enum SampleUsageData { + static let referenceNow = Date(timeIntervalSinceReferenceDate: 800_000_000) + + static var snapshot: UsageSnapshot { + UsageSnapshot( + generatedAt: referenceNow, + limits: [ + UsageLimit( + provider: .openAI, + accountID: "openai-personal", + accountName: "Personal", + label: "GPT-5 5-hour", + windowLabel: "5-hour", + modelLabel: "GPT-5", + unit: .percent, + used: 72, + limit: 100, + resetsAt: referenceNow.addingTimeInterval(12_000), + lastUpdatedAt: referenceNow.addingTimeInterval(-120), + confidence: .manual + ), + UsageLimit( + provider: .openAI, + accountID: "openai-work", + accountName: "Work", + label: "GPT-5 Thinking Weekly", + windowLabel: "Weekly", + modelLabel: "GPT-5 Thinking", + unit: .percent, + used: 18, + limit: 100, + resetsAt: referenceNow.addingTimeInterval(86_400), + lastUpdatedAt: referenceNow.addingTimeInterval(-120), + confidence: .estimated, + note: "Fast mode looks safe for about 2h." + ), + UsageLimit( + provider: .openAI, + accountID: "openai-team", + accountName: "Team", + label: "Image generation Hourly", + windowLabel: "Hourly", + modelLabel: "Image generation", + unit: .percent, + used: 49, + limit: 100, + resetsAt: referenceNow.addingTimeInterval(2_520), + lastUpdatedAt: referenceNow.addingTimeInterval(-120), + confidence: .observed + ), + UsageLimit( + provider: .anthropic, + accountID: "anthropic-personal", + accountName: "Personal", + label: "Claude Opus 5-hour", + windowLabel: "5-hour", + modelLabel: "Claude Opus", + used: 38, + limit: 45, + resetsAt: referenceNow.addingTimeInterval(4_500), + lastUpdatedAt: referenceNow.addingTimeInterval(-120), + confidence: .official + ), + UsageLimit( + provider: .anthropic, + accountID: "anthropic-work", + accountName: "Work", + label: "Claude Sonnet Daily", + windowLabel: "Daily", + modelLabel: "Claude Sonnet", + used: 12, + limit: 100, + resetsAt: referenceNow.addingTimeInterval(21_600), + lastUpdatedAt: referenceNow.addingTimeInterval(-120), + confidence: .official + ), + UsageLimit( + provider: .google, + accountID: "google-personal", + accountName: "Personal", + label: "Gemini Pro", + modelLabel: "Gemini Pro", + used: nil, + limit: nil, + lastUpdatedAt: referenceNow.addingTimeInterval(-120), + confidence: .unknown, + statusOverride: .unknown, + note: "Provider does not expose this limit." + ), + UsageLimit( + provider: .google, + accountID: "google-work", + accountName: "Work", + label: "Gemini Deep Research", + modelLabel: "Gemini Deep Research", + used: nil, + limit: nil, + lastUpdatedAt: referenceNow.addingTimeInterval(-21_600), + confidence: .unknown, + statusOverride: .failure, + note: "Last good snapshot 6h ago." + ) + ] + ) + } + + static var fastModeForecast: FastModePortfolioForecast { + let forecasts = snapshot.limits + .filter { $0.provider == .openAI && $0.label.contains("GPT-5") } + .map { limit in + FastModeForecast( + input: FastModeForecastInput( + limit: limit, + now: referenceNow, + standardBurnRate: BurnRate(mode: .standard, unitsPerHour: 2), + fastBurnRate: BurnRate(mode: .fast, unitsPerHour: 12), + reserveUnits: 6, + minimumSafeHours: 1 + ) + ) + } + return FastModePortfolioForecast(forecasts: forecasts) + } +} diff --git a/Sources/ContextPanelWidget/ContextPanelWidget.swift b/Sources/ContextPanelWidget/ContextPanelWidget.swift new file mode 100644 index 0000000..101a103 --- /dev/null +++ b/Sources/ContextPanelWidget/ContextPanelWidget.swift @@ -0,0 +1,112 @@ +import ContextPanelCore +import SwiftUI +import WidgetKit + +struct ContextPanelWidgetEntry: TimelineEntry { + let date: Date + let snapshot: WidgetSnapshot +} + +struct ContextPanelTimelineProvider: TimelineProvider { + let store: JSONSnapshotStore + + init( + store: JSONSnapshotStore = JSONSnapshotStore( + rootDirectory: ContextPanelLocations.snapshotDirectory(appGroupID: ContextPanelLocations.appGroupID) + ) + ) { + self.store = store + } + + func placeholder(in context: Context) -> ContextPanelWidgetEntry { + ContextPanelWidgetEntry(date: Date(), snapshot: .placeholder) + } + + func getSnapshot(in context: Context, completion: @escaping (ContextPanelWidgetEntry) -> Void) { + completion(entry(date: Date())) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let now = Date() + let nextRefresh = Calendar.current.date(byAdding: .minute, value: 10, to: now) ?? now.addingTimeInterval(600) + completion(Timeline(entries: [entry(date: now)], policy: .after(nextRefresh))) + } + + private func entry(date: Date) -> ContextPanelWidgetEntry { + let result = store.loadCurrent(policy: SnapshotStoreStalenessPolicy(maximumAge: 20 * 60), now: date) + return ContextPanelWidgetEntry(date: date, snapshot: WidgetSnapshot.fromStore(result, now: date)) + } +} + +struct ContextPanelWidgetView: View { + @Environment(\.widgetFamily) private var family + let entry: ContextPanelWidgetEntry + + var body: some View { + switch family { + case .systemSmall: + ContextPanelSmallWidget(snapshot: entry.snapshot) + case .systemLarge, .systemExtraLarge: + ContextPanelLargeWidget(snapshot: entry.snapshot) + default: + ContextPanelMediumWidget(snapshot: entry.snapshot) + } + } +} + +@main +struct ContextPanelWidget: Widget { + let kind = "ContextPanelWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: ContextPanelTimelineProvider()) { entry in + ContextPanelWidgetView(entry: entry) + .containerBackground(CPWTheme.surface, for: .widget) + } + .configurationDisplayName("Context Panel") + .description("AI account usage limits, reset timing, and fast-mode safety from local snapshots.") + .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + } +} + +extension WidgetSnapshot { + static var placeholder: WidgetSnapshot { + let now = Date() + return WidgetSnapshot( + state: .ready, + generatedAt: now, + limits: [ + UsageLimit( + provider: .openAI, + accountID: "placeholder-openai", + accountName: "OpenAI", + label: "Codex Weekly", + windowLabel: "Weekly", + modelLabel: "Codex", + unit: .percent, + used: 52, + limit: 100, + resetsAt: now.addingTimeInterval(18_000), + lastUpdatedAt: now.addingTimeInterval(-90), + confidence: .observed + ), + UsageLimit( + provider: .google, + accountID: "placeholder-google", + accountName: "Gemini", + label: "gemini-3-pro-preview", + windowLabel: "Daily", + modelLabel: "gemini-3-pro-preview", + unit: .percent, + used: 12, + limit: 100, + resetsAt: now.addingTimeInterval(86_400), + lastUpdatedAt: now.addingTimeInterval(-90), + confidence: .observed + ), + ], + status: .healthy, + message: "Fast mode looks safe." + ) + } +} diff --git a/Sources/ContextPanelWidget/ContextPanelWidgetViews.swift b/Sources/ContextPanelWidget/ContextPanelWidgetViews.swift new file mode 100644 index 0000000..01a24f8 --- /dev/null +++ b/Sources/ContextPanelWidget/ContextPanelWidgetViews.swift @@ -0,0 +1,516 @@ +import ContextPanelCore +import SwiftUI + +struct ContextPanelSmallWidget: View { + let snapshot: WidgetSnapshot + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + CPWHeader(status: snapshot.status) + Spacer(minLength: 4) + if let tightest = snapshot.mostConstrainedLimits.first { + Text(snapshot.fastModeVerdict) + .font(.system(size: 26, weight: .semibold)) + .foregroundStyle(CPWTheme.primaryText) + .minimumScaleFactor(0.75) + .lineLimit(2) + Text("\(tightest.provider.shortName) ยท \(tightest.displayLabel) ยท \(tightest.widgetUsageText)") + .font(.system(size: 11)) + .foregroundStyle(CPWTheme.secondaryText) + .lineLimit(2) + Text(tightest.widgetResetText) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(CPWTheme.tertiaryText) + .lineLimit(1) + } else { + Text("Set up accounts") + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(CPWTheme.primaryText) + .lineLimit(2) + Text(snapshot.message) + .font(.system(size: 11)) + .foregroundStyle(CPWTheme.secondaryText) + .lineLimit(2) + } + Spacer(minLength: 4) + CPWProviderMiniStatus(snapshot: snapshot) + } + .padding(16) + } +} + +struct ContextPanelMediumWidget: View { + let snapshot: WidgetSnapshot + + var body: some View { + HStack(spacing: 16) { + VStack(alignment: .leading, spacing: 10) { + CPWHeader(status: snapshot.status) + Spacer(minLength: 0) + CPWCapacityDial( + value: snapshot.tightestCapacityRatio, + status: snapshot.status, + label: "\(Int(snapshot.tightestCapacityRatio * 100))", + sublabel: "tightest", + size: 86 + ) + Text(snapshot.fastModeVerdict) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(CPWTheme.secondaryText) + .lineLimit(2) + Spacer(minLength: 0) + } + .frame(width: 142, alignment: .leading) + + Divider() + + VStack(alignment: .leading, spacing: 8) { + CPWSectionHeader(title: "Most Constrained", trailing: "\(snapshot.limits.count) limits") + ForEach(snapshot.mostConstrainedLimits.prefix(4)) { limit in + CPWLimitRow(limit: limit) + } + if snapshot.limits.isEmpty { + CPWEmptyRow(message: snapshot.message) + } + } + } + .padding(16) + } +} + +struct ContextPanelLargeWidget: View { + let snapshot: WidgetSnapshot + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 6) { + CPWLabel("Context Panel") + Text(snapshot.fastModeVerdict) + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(CPWTheme.primaryText) + .lineLimit(2) + Text(snapshot.state.rawValue) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(CPWTheme.secondaryText) + .textCase(.uppercase) + } + Spacer() + CPWCapacityDial( + value: snapshot.tightestCapacityRatio, + status: snapshot.status, + label: "\(Int(snapshot.tightestCapacityRatio * 100))", + sublabel: "tightest", + size: 82 + ) + } + + CPWProviderSummaryGrid(snapshot: snapshot) + + Divider() + + VStack(alignment: .leading, spacing: 8) { + CPWFastModeCard(snapshot: snapshot) + + CPWSectionHeader(title: "Account Limits", trailing: snapshot.generatedAt.widgetRelativeText) + ForEach(snapshot.mostConstrainedLimits.prefix(6)) { limit in + CPWLimitRow(limit: limit) + } + if snapshot.limits.isEmpty { + CPWEmptyRow(message: snapshot.message) + } + } + } + .padding(16) + } +} + +struct CPWHeader: View { + let status: UsageStatus + + var body: some View { + HStack { + CPWLabel("Context Panel") + Spacer() + CPWStatusMark(status: status, size: 9) + } + } +} + +struct CPWLimitRow: View { + let limit: UsageLimit + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 6) { + CPWProviderBadge(provider: limit.provider, compact: true) + Text(limit.displayLabel) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(CPWTheme.primaryText) + .lineLimit(1) + Text("ยท \(limit.contextLabel)") + .font(.system(size: 11)) + .foregroundStyle(CPWTheme.tertiaryText) + .lineLimit(1) + Spacer(minLength: 6) + Text(limit.widgetUsageText) + .font(.system(size: 10, weight: .medium, design: .monospaced)) + .foregroundStyle(CPWTheme.secondaryText) + } + HStack(spacing: 8) { + CPWCapacityBar(value: limit.usageRatio ?? 0, status: limit.status) + Text(limit.widgetResetText) + .font(.system(size: 10)) + .foregroundStyle(CPWTheme.tertiaryText) + .lineLimit(1) + } + } + } +} + +struct CPWProviderSummaryGrid: View { + let snapshot: WidgetSnapshot + + var body: some View { + HStack(spacing: 12) { + ForEach(snapshot.providerSummaries, id: \.provider) { summary in + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + CPWProviderBadge(provider: summary.provider, compact: true) + Spacer() + CPWStatusMark(status: summary.status, size: 7) + } + Text(summary.limitCount == 0 ? "setup" : "\(Int(summary.capacityRatio * 100))% tightest room") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(CPWTheme.secondaryText) + CPWCapacityBar(value: 1 - summary.capacityRatio, status: summary.status) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } +} + +struct CPWProviderMiniStatus: View { + let snapshot: WidgetSnapshot + + var body: some View { + HStack(spacing: 12) { + ForEach(snapshot.providerSummaries, id: \.provider) { summary in + HStack(spacing: 5) { + CPWProviderBadge(provider: summary.provider, compact: true) + } + .foregroundStyle(CPWTheme.secondaryText) + .opacity(summary.limitCount == 0 ? 0.35 : 1) + } + } + } +} + +struct CPWEmptyRow: View { + let message: String + + var body: some View { + HStack(spacing: 8) { + CPWStatusMark(status: .unknown, size: 9) + Text(message) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(CPWTheme.secondaryText) + .lineLimit(2) + } + } +} + +struct CPWFastModeCard: View { + let snapshot: WidgetSnapshot + + var body: some View { + HStack(spacing: 10) { + Image(systemName: "bolt.fill") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(CPWTheme.fastModeColor(snapshot.fastModeStatus)) + .frame(width: 18) + VStack(alignment: .leading, spacing: 2) { + Text(snapshot.fastModeVerdict) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(CPWTheme.primaryText) + .lineLimit(1) + Text(snapshot.fastModeDetail) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(CPWTheme.secondaryText) + .lineLimit(1) + } + Spacer(minLength: 4) + } + .padding(.vertical, 8) + .padding(.horizontal, 10) + .background(CPWTheme.line.opacity(0.65)) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } +} + +struct CPWCapacityDial: View { + let value: Double + let status: UsageStatus + let label: String + let sublabel: String + var size: CGFloat = 86 + + var body: some View { + ZStack { + Circle().stroke(CPWTheme.line, lineWidth: 6) + Circle() + .trim(from: 0, to: min(max(value, 0), 1)) + .stroke(CPWTheme.statusColor(status), style: StrokeStyle(lineWidth: 6, lineCap: .round)) + .rotationEffect(.degrees(-90)) + VStack(spacing: 0) { + Text(label) + .font(.system(size: 22, weight: .semibold, design: .monospaced)) + .foregroundStyle(CPWTheme.primaryText) + Text(sublabel) + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(CPWTheme.tertiaryText) + .textCase(.uppercase) + } + } + .frame(width: size, height: size) + } +} + +struct CPWCapacityBar: View { + let value: Double + let status: UsageStatus + + var body: some View { + GeometryReader { proxy in + ZStack(alignment: .leading) { + Capsule().fill(CPWTheme.line) + Capsule() + .fill(CPWTheme.statusColor(status)) + .frame(width: proxy.size.width * min(max(value, 0), 1)) + } + } + .frame(height: 4) + } +} + +struct CPWProviderBadge: View { + let provider: Provider + var compact = false + + var body: some View { + Text(provider.shortName) + .font(.system(size: compact ? 9 : 10, weight: .semibold, design: .monospaced)) + .foregroundStyle(CPWTheme.providerColor(provider)) + .lineLimit(1) + } +} + +struct CPWStatusMark: View { + let status: UsageStatus + var size: CGFloat = 8 + + var body: some View { + Group { + switch status { + case .healthy: + Circle().fill(CPWTheme.statusColor(status)) + case .close: + Circle().trim(from: 0, to: 0.75).stroke(CPWTheme.statusColor(status), lineWidth: 2) + case .limited: + RoundedRectangle(cornerRadius: 1).fill(CPWTheme.statusColor(status)) + case .stale: + Circle().stroke(CPWTheme.statusColor(status), style: StrokeStyle(lineWidth: 1.4, dash: [2, 2])) + case .unknown: + Text("?").font(.system(size: size + 3, weight: .semibold)).foregroundStyle(CPWTheme.statusColor(status)) + case .failure: + Image(systemName: "xmark").font(.system(size: size, weight: .bold)).foregroundStyle(CPWTheme.statusColor(status)) + case .loading: + Circle().stroke(CPWTheme.statusColor(status), lineWidth: 1.4) + } + } + .frame(width: size, height: size) + } +} + +struct CPWSectionHeader: View { + let title: String + var trailing: String? = nil + + var body: some View { + HStack(alignment: .firstTextBaseline) { + Text(title) + .font(.system(size: 10, weight: .semibold)) + .tracking(0.8) + .textCase(.uppercase) + .foregroundStyle(CPWTheme.tertiaryText) + Spacer() + if let trailing { + Text(trailing) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(CPWTheme.tertiaryText) + } + } + } +} + +struct CPWLabel: View { + let text: String + + init(_ text: String) { + self.text = text + } + + var body: some View { + HStack(spacing: 6) { + RoundedRectangle(cornerRadius: 1) + .fill(CPWTheme.accent) + .rotationEffect(.degrees(45)) + .frame(width: 6, height: 6) + Text(text) + .font(.system(size: 10, weight: .semibold)) + .tracking(0.8) + .textCase(.uppercase) + .foregroundStyle(CPWTheme.tertiaryText) + } + } +} + +enum CPWTheme { + static let surface = Color(red: 250 / 255, green: 250 / 255, blue: 250 / 255) + static let line = Color.black.opacity(0.08) + static let primaryText = Color(red: 10 / 255, green: 10 / 255, blue: 11 / 255) + static let secondaryText = primaryText.opacity(0.66) + static let tertiaryText = primaryText.opacity(0.46) + static let accent = Color(red: 74 / 255, green: 91 / 255, blue: 122 / 255) + + static func providerColor(_ provider: Provider) -> Color { + switch provider { + case .openAI: + Color(red: 56 / 255, green: 92 / 255, blue: 126 / 255) + case .anthropic: + Color(red: 139 / 255, green: 102 / 255, blue: 51 / 255) + case .google: + Color(red: 35 / 255, green: 116 / 255, blue: 106 / 255) + } + } + + static func fastModeColor(_ recommendation: FastModeRecommendation?) -> Color { + switch recommendation { + case .safeThroughReset: + statusColor(.healthy) + case .safeForLimitedTime: + statusColor(.close) + case .saveFastMode, .limited: + statusColor(.limited) + case .needsCalibration, nil: + statusColor(.unknown) + } + } + + static func statusColor(_ status: UsageStatus) -> Color { + switch status { + case .healthy: + Color(red: 74 / 255, green: 122 / 255, blue: 91 / 255) + case .close: + Color(red: 138 / 255, green: 106 / 255, blue: 42 / 255) + case .limited, .failure: + Color(red: 138 / 255, green: 74 / 255, blue: 74 / 255) + case .stale, .unknown, .loading: + Color(red: 106 / 255, green: 106 / 255, blue: 114 / 255) + } + } +} + +extension UsageLimit { + var widgetUsageText: String { + if provider == .anthropic, unit == .unknown, status == .unknown { + return "allowance unknown" + } + if let remaining, let limit { + return "\(remaining)/\(limit) left" + } + if status == .failure { return "refresh failed" } + return "unknown" + } + + var widgetResetText: String { + guard let resetsAt else { + return status == .failure ? "refresh failed" : "unknown reset" + } + if resetsAt < Date().addingTimeInterval(-60) { + return "reset passed" + } + return "resets \(resetsAt.widgetRelativeText)" + } +} + +extension WidgetSnapshot { + var tightestCapacityRatio: Double { + guard let ratio = mostConstrainedLimits.first?.usageRatio else { return 0 } + return max(1 - ratio, 0) + } + + var fastModeForecast: FastModeForecast? { + let forecasts = limits + .filter { $0.provider == .openAI && $0.unit == .percent } + .map { limit in + FastModeForecast(input: FastModeForecastInput( + limit: limit, + now: Date(), + standardBurnRate: BurnRate(mode: .standard, unitsPerHour: 2), + fastBurnRate: BurnRate(mode: .fast, unitsPerHour: 12), + reserveUnits: 6, + minimumSafeHours: 1 + )) + } + return FastModePortfolioForecast(forecasts: forecasts).bestForecast + } + + var fastModeStatus: FastModeRecommendation? { + fastModeForecast?.recommendation + } + + var fastModeVerdict: String { + fastModeForecast?.copy ?? message + } + + var fastModeDetail: String { + guard let forecast = fastModeForecast else { return "OpenAI account needed for fast-mode forecast" } + let runway = forecast.fastModeRunwayHours.map { "runway \(Self.format(hours: $0))" } ?? "runway unknown" + let reset = forecast.hoursUntilReset.map { "reset \(Self.format(hours: $0))" } ?? "reset unknown" + return "\(forecast.accountName) ยท \(runway) ยท \(reset)" + } + + private static func format(hours: Double) -> String { + if hours < 1 { + return "\(max(Int((hours * 60).rounded()), 1))m" + } + if hours < 10 { + let rounded = (hours * 2).rounded() / 2 + if rounded == rounded.rounded() { return "\(Int(rounded))h" } + return "\(rounded)h" + } + return "\(Int(hours.rounded()))h" + } +} + +extension Date { + var widgetRelativeText: String { + let seconds = Int(timeIntervalSince(Date())) + if abs(seconds) < 60 { return "now" } + if seconds >= 0 { + let minutes = seconds / 60 + if minutes < 60 { return "in \(minutes)m" } + let hours = minutes / 60 + if hours < 24 { return "in \(hours)h" } + return "in \(hours / 24)d" + } + let elapsed = abs(seconds) + let minutes = elapsed / 60 + if minutes < 60 { return "\(minutes)m ago" } + let hours = minutes / 60 + if hours < 24 { return "\(hours)h ago" } + return "\(hours / 24)d ago" + } +} diff --git a/Sources/GeminiQuotaProbe/main.swift b/Sources/GeminiQuotaProbe/main.swift new file mode 100644 index 0000000..b3d6e6d --- /dev/null +++ b/Sources/GeminiQuotaProbe/main.swift @@ -0,0 +1,132 @@ +import ContextPanelCore +import Foundation + +struct GeminiProbeError: LocalizedError { + let message: String + + var errorDescription: String? { message } +} +struct ProbeConfiguration { + let account: GeminiAccountConfiguration + + static func fromArguments(_ arguments: [String]) throws -> ProbeConfiguration { + var authPath: String? + var tokenEndpoint = URL(string: "https://oauth2.googleapis.com/token")! + var codeAssistEndpoint = URL(string: "https://cloudcode-pa.googleapis.com/v1internal")! + var metadata = GeminiOAuthClientMetadataDiscovery.discover() + var iterator = arguments.dropFirst().makeIterator() + + while let argument = iterator.next() { + switch argument { + case "--auth": + guard let value = iterator.next() else { + throw GeminiProbeError(message: "--auth requires a path") + } + authPath = value + case "--token-endpoint": + guard let value = iterator.next(), let url = URL(string: value) else { + throw GeminiProbeError(message: "--token-endpoint requires an absolute URL") + } + tokenEndpoint = url + case "--code-assist-endpoint": + guard let value = iterator.next(), let url = URL(string: value) else { + throw GeminiProbeError(message: "--code-assist-endpoint requires an absolute URL") + } + codeAssistEndpoint = url + case "--client-id": + guard let value = iterator.next(), !value.isEmpty else { + throw GeminiProbeError(message: "--client-id requires a value") + } + metadata = GeminiOAuthClientMetadata( + clientID: value, + clientSecret: metadata?.clientSecret ?? "" + ) + case "--client-secret": + guard let value = iterator.next(), !value.isEmpty else { + throw GeminiProbeError(message: "--client-secret requires a value") + } + metadata = GeminiOAuthClientMetadata( + clientID: metadata?.clientID ?? "", + clientSecret: value + ) + case "--help", "-h": + printHelp() + Foundation.exit(0) + default: + throw GeminiProbeError(message: "unknown argument: \(argument)") + } + } + + guard let metadata, !metadata.clientID.isEmpty, !metadata.clientSecret.isEmpty else { + throw GeminiProbeError(message: "install Gemini CLI, set GEMINI_OAUTH_CLIENT_ID/SECRET, or pass --client-id/--client-secret") + } + + return ProbeConfiguration(account: GeminiAccountConfiguration( + authPath: authPath ?? defaultAuthPath(), + tokenEndpoint: tokenEndpoint, + codeAssistEndpoint: codeAssistEndpoint, + clientID: metadata.clientID, + clientSecret: metadata.clientSecret + )) + } + + private static func defaultAuthPath() -> String { + let environment = ProcessInfo.processInfo.environment + let home = environment["HOME"] ?? FileManager.default.homeDirectoryForCurrentUser.path + let geminiHome = environment["GEMINI_CLI_HOME"] ?? "\(home)/.gemini" + return "\(geminiHome)/oauth_creds.json" + } + + private static func printHelp() { + print(""" + Usage: swift run GeminiQuotaProbe [--auth /path/to/oauth_creds.json] + + Uses the locally installed Gemini CLI OAuth client metadata when + available. You can also set GEMINI_OAUTH_CLIENT_ID and + GEMINI_OAUTH_CLIENT_SECRET, or pass --client-id and --client-secret. + Do not commit OAuth client values to this repository. + + Uses the production Gemini Code Assist connector and prints only a + redacted quota summary. Tokens, account identifiers, project IDs, + emails, headers, and raw response bodies are never printed. + """) + } +} + +@main +struct GeminiQuotaProbe { + static func main() async { + do { + let configuration = try ProbeConfiguration.fromArguments(CommandLine.arguments) + let connector = GeminiCodeAssistConnector(accounts: [configuration.account]) + let result = await connector.refresh(now: Date()) + printSummary(result: result, account: configuration.account) + } catch { + fputs("GeminiQuotaProbe failed: \(error.localizedDescription)\n", stderr) + Foundation.exit(1) + } + } + + private static func printSummary(result: ConnectorRefreshResult, account: GeminiAccountConfiguration) { + print("Gemini Code Assist quota probe") + print("endpoint: \(account.codeAssistEndpoint.absoluteString)") + print("auth: \(ConnectorRedactor.redactedPath(account.authPath))") + print("accounts: \(result.reports.count)") + print("limits: \(result.snapshot.limits.count)") + print("redacted: tokens, account identifiers, project IDs, emails, headers, raw response bodies") + print("") + + for report in result.reports { + print("- \(report.accountName): \(report.status.rawValue)") + if let errorMessage = report.errorMessage { + print(" error: \(errorMessage)") + } + for limit in report.limits.sorted(by: { $0.label < $1.label }) { + let used = limit.used.map { "\($0)% used" } ?? "unknown used" + let remaining = limit.remaining.map { "\($0)% remaining" } ?? "unknown remaining" + let reset = limit.resetsAt.map { ContextPanelDateFormatting.string(from: $0) } ?? "unknown reset" + print(" - \(limit.label): \(used), \(remaining), resets \(reset)") + } + } + } +} diff --git a/Sources/OpenAILimitProbe/OpenAILimitProbeApp.swift b/Sources/OpenAILimitProbe/OpenAILimitProbeApp.swift new file mode 100644 index 0000000..3a0c712 --- /dev/null +++ b/Sources/OpenAILimitProbe/OpenAILimitProbeApp.swift @@ -0,0 +1,426 @@ +import ContextPanelCore +import SwiftUI +import UniformTypeIdentifiers +import WebKit + +@main +struct OpenAILimitProbeApp: App { + var body: some Scene { + WindowGroup { + ProbeRootView() + .frame(minWidth: 1180, minHeight: 760) + } + } +} + +struct ProbeRootView: View { + @StateObject private var model = ProbeModel() + + var body: some View { + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 14) { + header + controls + Divider() + ObservationList(observations: model.observations) + NetworkEventList(events: model.networkEvents) + Spacer() + safetyFooter + } + .frame(width: 360) + .padding(18) + .background(Color(nsColor: .controlBackgroundColor)) + + Divider() + + ProbeWebView(model: model) + } + .fileExporter( + isPresented: $model.isExportingReport, + document: ProbeReportDocument(report: model.reportMarkdown), + contentType: .plainText, + defaultFilename: "openai-limit-probe-report.md" + ) { _ in } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 8) { + Text("OpenAI Limit Probe") + .font(.system(size: 24, weight: .semibold)) + Text("Log in directly with OpenAI, navigate to ChatGPT/model picker, then scan visible text for subscription limit signals.") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + } + } + + private var controls: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Button("Open ChatGPT") { + model.loadChatGPT() + } + Button("Scan Visible Text") { + model.scanVisibleText() + } + .keyboardShortcut("s", modifiers: [.command]) + } + + HStack { + Button("Record Manual Observation") { + model.recordManualObservation() + } + Button("Export Redacted Report") { + model.exportReport() + } + .keyboardShortcut("e", modifiers: [.command]) + } + + TextField("Manual note, e.g. resets tomorrow 9:00 AM", text: $model.manualObservation) + .textFieldStyle(.roundedBorder) + } + } + + private var safetyFooter: some View { + VStack(alignment: .leading, spacing: 6) { + Label("No passwords, cookies, auth headers, or raw response bodies are exported.", systemImage: "lock.shield") + Label("Network capture stores only method/path/status/body size and matching field names.", systemImage: "point.3.connected.trianglepath.dotted") + } + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } +} + +struct NetworkEventList: View { + let events: [NetworkProbeEvent] + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Network candidates") + .font(.system(size: 12, weight: .semibold)) + .textCase(.uppercase) + .foregroundStyle(.secondary) + Spacer() + Text("\(events.count)") + .font(.system(.caption, design: .monospaced, weight: .medium)) + .foregroundStyle(.secondary) + } + + if events.isEmpty { + Text("No candidate response fields yet. Open model menus or settings, then scan/navigate.") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + ForEach(events.prefix(12)) { event in + VStack(alignment: .leading, spacing: 4) { + Text("\(event.method) \(event.pathHint)") + .font(.system(size: 11, design: .monospaced)) + .lineLimit(2) + Text(event.matchedFields.joined(separator: ", ")) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + Text("status \(event.status.map(String.init) ?? "?") ยท \(event.bodySize ?? 0)b") + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(.tertiary) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(nsColor: .textBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + } + } + .frame(maxHeight: 220) + } + } + } +} + +struct ObservationList: View { + let observations: [LimitProbeObservation] + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Observations") + .font(.system(size: 12, weight: .semibold)) + .textCase(.uppercase) + .foregroundStyle(.secondary) + Spacer() + Text("\(observations.count)") + .font(.system(.caption, design: .monospaced, weight: .medium)) + .foregroundStyle(.secondary) + } + + if observations.isEmpty { + ContentUnavailableView( + "No signals yet", + systemImage: "waveform.path.ecg.rectangle", + description: Text("Open ChatGPT, log in, navigate to the model picker, and scan visible text.") + ) + .frame(maxHeight: 220) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + ForEach(observations) { observation in + VStack(alignment: .leading, spacing: 4) { + Text(observation.signalKind.rawValue) + .font(.system(size: 12, weight: .semibold)) + Text(observation.sanitizedEvidence) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + Text("\(observation.source.rawValue) ยท \(observation.confidence.rawValue)") + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(.tertiary) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(nsColor: .textBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + } + } + } + } + } +} + +@MainActor +final class ProbeModel: ObservableObject { + @Published var observations: [LimitProbeObservation] = [] + @Published var networkEvents: [NetworkProbeEvent] = [] + @Published var manualObservation = "" + @Published var isExportingReport = false + + lazy var webView: WKWebView = { + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .nonPersistent() + configuration.userContentController.add(ProbeScriptHandler(owner: self), name: "limitProbe") + configuration.userContentController.addUserScript( + WKUserScript(source: Self.networkProbeScript, injectionTime: .atDocumentStart, forMainFrameOnly: false) + ) + return WKWebView(frame: .zero, configuration: configuration) + }() + + init() { + loadChatGPT() + } + + var reportMarkdown: String { + LimitProbeReport( + provider: .openAI, + capturedAt: Date(), + observations: observations, + networkEvents: networkEvents + ).markdownSummary + } + + func loadChatGPT() { + webView.load(URLRequest(url: URL(string: "https://chatgpt.com/")!)) + } + + func scanVisibleText() { + let script = "document.body ? document.body.innerText : ''" + webView.evaluateJavaScript(script) { [weak self] result, error in + Task { @MainActor in + guard let self else { return } + if let text = result as? String { + let newObservations = LimitProbeScanner.scanVisibleText(text, provider: .openAI) + self.merge(newObservations) + } else if let error { + self.merge([ + LimitProbeObservation( + provider: .openAI, + observedAt: Date(), + source: .visibleText, + signalKind: .limitReached, + confidence: .unknown, + sanitizedEvidence: "Scan failed: \(error.localizedDescription)" + ) + ]) + } + } + } + } + + func recordManualObservation() { + let trimmed = manualObservation.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + merge([ + LimitProbeObservation( + provider: .openAI, + observedAt: Date(), + source: .manualUserEntry, + signalKind: .resetLanguage, + confidence: .manual, + sanitizedEvidence: trimmed + ) + ]) + manualObservation = "" + } + + func exportReport() { + isExportingReport = true + } + + private func merge(_ newObservations: [LimitProbeObservation]) { + var keys = Set(observations.map { "\($0.signalKind.rawValue):\($0.sanitizedEvidence.lowercased())" }) + for observation in newObservations { + let key = "\(observation.signalKind.rawValue):\(observation.sanitizedEvidence.lowercased())" + guard !keys.contains(key) else { continue } + keys.insert(key) + observations.append(observation) + } + } + + fileprivate func record(networkEvent event: NetworkProbeEvent) { + let key = "\(event.method):\(event.pathHint):\(event.matchedFields.joined(separator: ","))" + let existing = Set(networkEvents.map { "\($0.method):\($0.pathHint):\($0.matchedFields.joined(separator: ","))" }) + guard !existing.contains(key), !event.matchedFields.isEmpty else { return } + networkEvents.insert(event, at: 0) + } + + private static let networkProbeScript = #""" + (() => { + if (window.__contextPanelLimitProbeInstalled) return; + window.__contextPanelLimitProbeInstalled = true; + const candidate = /limit|usage|remaining|reset|quota|message|model|plan|cap/i; + + function pathHint(rawUrl) { + try { return new URL(rawUrl, window.location.href).pathname; } + catch (_) { return String(rawUrl || '').split('?')[0].slice(0, 180); } + } + + function collectFields(value, prefix = '', out = new Set(), depth = 0) { + if (!value || depth > 3 || out.size > 80) return out; + if (Array.isArray(value)) { + value.slice(0, 3).forEach(item => collectFields(item, prefix, out, depth + 1)); + return out; + } + if (typeof value !== 'object') return out; + Object.keys(value).forEach(key => { + const full = prefix ? `${prefix}.${key}` : key; + if (candidate.test(full)) out.add(full); + collectFields(value[key], full, out, depth + 1); + }); + return out; + } + + function post(event) { + try { window.webkit.messageHandlers.limitProbe.postMessage(event); } + catch (_) {} + } + + function inspect(method, url, status, contentType, text) { + const body = String(text || ''); + let fields = []; + if (/json/i.test(contentType || '') && body.length < 2_000_000) { + try { fields = Array.from(collectFields(JSON.parse(body))).slice(0, 40); } + catch (_) { fields = []; } + } + if (fields.length === 0) return; + post({ + method: method || 'GET', + pathHint: pathHint(url), + status: status || null, + contentType: contentType || null, + bodySize: body.length, + matchedFields: fields + }); + } + + const originalFetch = window.fetch; + if (originalFetch) { + window.fetch = async function(input, init) { + const response = await originalFetch.apply(this, arguments); + try { + const clone = response.clone(); + const url = typeof input === 'string' ? input : (input && input.url) || ''; + const method = (init && init.method) || (input && input.method) || 'GET'; + const contentType = clone.headers.get('content-type') || ''; + clone.text().then(text => inspect(method, url, clone.status, contentType, text)).catch(() => {}); + } catch (_) {} + return response; + }; + } + + const originalOpen = XMLHttpRequest.prototype.open; + const originalSend = XMLHttpRequest.prototype.send; + XMLHttpRequest.prototype.open = function(method, url) { + this.__cpMethod = method; + this.__cpUrl = url; + return originalOpen.apply(this, arguments); + }; + XMLHttpRequest.prototype.send = function() { + this.addEventListener('load', function() { + try { + const contentType = this.getResponseHeader('content-type') || ''; + inspect(this.__cpMethod || 'GET', this.__cpUrl || '', this.status, contentType, this.responseText || ''); + } catch (_) {} + }); + return originalSend.apply(this, arguments); + }; + })(); + """# +} + +final class ProbeScriptHandler: NSObject, WKScriptMessageHandler { + weak var owner: ProbeModel? + + init(owner: ProbeModel) { + self.owner = owner + } + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + guard let payload = message.body as? [String: Any] else { return } + let method = payload["method"] as? String ?? "GET" + let pathHint = payload["pathHint"] as? String ?? "" + let status = payload["status"] as? Int + let contentType = payload["contentType"] as? String + let bodySize = payload["bodySize"] as? Int + let matchedFields = payload["matchedFields"] as? [String] ?? [] + let event = NetworkProbeEvent( + observedAt: Date(), + method: method, + pathHint: pathHint, + status: status, + contentType: contentType, + bodySize: bodySize, + matchedFields: matchedFields + ) + Task { @MainActor [weak owner = self.owner] in + owner?.record(networkEvent: event) + } + } +} + +struct ProbeWebView: NSViewRepresentable { + @ObservedObject var model: ProbeModel + + func makeNSView(context: Context) -> WKWebView { + model.webView + } + + func updateNSView(_ nsView: WKWebView, context: Context) {} +} + +struct ProbeReportDocument: FileDocument { + static var readableContentTypes: [UTType] { [.plainText] } + + var report: String + + init(report: String) { + self.report = report + } + + init(configuration: ReadConfiguration) throws { + report = "" + } + + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + FileWrapper(regularFileWithContents: Data(report.utf8)) + } +} diff --git a/Sources/SnapshotStoreProbe/main.swift b/Sources/SnapshotStoreProbe/main.swift new file mode 100644 index 0000000..1bfd4c4 --- /dev/null +++ b/Sources/SnapshotStoreProbe/main.swift @@ -0,0 +1,128 @@ +import ContextPanelCore +import Foundation + +struct SnapshotStoreProbeError: LocalizedError { + let message: String + + var errorDescription: String? { message } +} + +struct ProbeConfiguration { + let outputDirectory: URL + let codexAccounts: [CodexAccountConfiguration] + let includeConfiguredAccounts: Bool + let includeClaude: Bool + + static func fromArguments(_ arguments: [String]) throws -> ProbeConfiguration { + var outputDirectory: URL? + var codexAccounts: [CodexAccountConfiguration] = [] + var includeConfiguredAccounts = false + var includeClaude = false + var iterator = arguments.dropFirst().makeIterator() + + while let argument = iterator.next() { + switch argument { + case "--output": + guard let value = iterator.next() else { + throw SnapshotStoreProbeError(message: "--output requires a directory path") + } + outputDirectory = URL(fileURLWithPath: NSString(string: value).expandingTildeInPath) + case "--codex-auth": + guard let value = iterator.next() else { + throw SnapshotStoreProbeError(message: "--codex-auth requires a path") + } + codexAccounts.append(CodexAccountConfiguration(authPath: value)) + case "--codex-account": + guard let value = iterator.next() else { + throw SnapshotStoreProbeError(message: "--codex-account requires label=path") + } + codexAccounts.append(try parseCodexAccount(value)) + case "--configured-accounts": + includeConfiguredAccounts = true + case "--include-claude": + includeClaude = true + case "--help", "-h": + printHelp() + Foundation.exit(0) + default: + throw SnapshotStoreProbeError(message: "unknown argument: \(argument)") + } + } + + return ProbeConfiguration( + outputDirectory: outputDirectory ?? defaultOutputDirectory(), + codexAccounts: codexAccounts, + includeConfiguredAccounts: includeConfiguredAccounts, + includeClaude: includeClaude + ) + } + + private static func parseCodexAccount(_ value: String) throws -> CodexAccountConfiguration { + let parts = value.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + guard parts.count == 2, !parts[0].isEmpty, !parts[1].isEmpty else { + throw SnapshotStoreProbeError(message: "--codex-account requires label=path") + } + return CodexAccountConfiguration(authPath: String(parts[1]), accountName: String(parts[0])) + } + + private static func defaultOutputDirectory() -> URL { + FileManager.default.temporaryDirectory + .appending(path: "context-panel-snapshot-store", directoryHint: .isDirectory) + } + + private static func printHelp() { + print(""" + Usage: swift run SnapshotStoreProbe [--output /tmp/context-panel-store] [--configured-accounts] [--codex-account Label=~/.codex/auth.json] [--include-claude] + + Refreshes selected local connectors, writes the normalized snapshot to + the JSON snapshot store, then reloads it. The store contains normalized + usage state only; tokens, account identifiers, project IDs, emails, + headers, and raw provider responses are not persisted. + """) + } +} + +@main +struct SnapshotStoreProbe { + static func main() async { + do { + let configuration = try ProbeConfiguration.fromArguments(CommandLine.arguments) + let connectors = makeConnectors(configuration: configuration) + guard !connectors.isEmpty else { + throw SnapshotStoreProbeError(message: "no connectors selected") + } + + let result = await ProviderConnectorRuntime(connectors: connectors).refreshAll() + let store = JSONSnapshotStore(rootDirectory: configuration.outputDirectory) + try store.save(StoredUsageSnapshot(savedAt: Date(), refreshResult: result)) + let loaded = store.loadCurrent(policy: SnapshotStoreStalenessPolicy()) + + print("Context Panel snapshot store probe") + print("store: \(ConnectorRedactor.redactedPath(configuration.outputDirectory.path))") + print("reports: \(result.reports.count)") + print("limits: \(result.snapshot.limits.count)") + print("load status: \(loaded.status.rawValue)") + print("history entries: \(store.loadHistory().count)") + print("redacted: tokens, account identifiers, project IDs, emails, headers, raw response bodies") + } catch { + fputs("SnapshotStoreProbe failed: \(error.localizedDescription)\n", stderr) + Foundation.exit(1) + } + } + + private static func makeConnectors(configuration: ProbeConfiguration) -> [any ProviderConnector] { + if configuration.includeConfiguredAccounts { + let store = AccountConfigurationStore(configurationURL: ContextPanelLocations.accountConfigurationURL()) + return AccountConnectorFactory.connectors(from: store.load().document) + } + + var connectors: [any ProviderConnector] = [] + if !configuration.codexAccounts.isEmpty { + connectors.append(CodexRateLimitConnector(accounts: configuration.codexAccounts)) + } + if configuration.includeClaude { + connectors.append(ClaudeLocalStatusConnector(accounts: [ClaudeAccountConfiguration()])) + } + return connectors + } +} diff --git a/Tests/ContextPanelCoreTests/AccountConfigurationStoreTests.swift b/Tests/ContextPanelCoreTests/AccountConfigurationStoreTests.swift new file mode 100644 index 0000000..54844bb --- /dev/null +++ b/Tests/ContextPanelCoreTests/AccountConfigurationStoreTests.swift @@ -0,0 +1,124 @@ +import Foundation +import Testing + +@testable import ContextPanelCore + +@Test func accountConfigurationStoreReturnsDefaultsWhenMissing() throws { + let store = AccountConfigurationStore(configurationURL: try temporaryDirectory().appending(path: "accounts.json")) + + let result = store.load(now: Date(timeIntervalSince1970: 0)) + + #expect(result.status == .unknown) + #expect(result.document.accounts.count == 3) + #expect(result.document.accounts.contains { $0.connectorKind == .codexRateLimits && $0.isEnabled }) + #expect(result.document.accounts.contains { $0.connectorKind == .geminiCodeAssist && !$0.isEnabled }) +} + +@Test func accountConfigurationStoreRoundTripsAccounts() throws { + let url = try temporaryDirectory().appending(path: "accounts.json") + let store = AccountConfigurationStore(configurationURL: url) + let document = AccountConfigurationDocument(updatedAt: Date(timeIntervalSince1970: 10), accounts: [ + LocalProviderAccountConfiguration( + id: "codex-a", + provider: .openAI, + connectorKind: .codexRateLimits, + displayName: "OpenAI A", + authPath: "/tmp/codex-a.json" + ) + ]) + + try store.save(document) + let result = store.load() + + #expect(result.status == .healthy) + #expect(result.document == document) +} + +@Test func accountConnectorFactorySkipsDisabledAndMissingSecretEnvironment() { + let document = AccountConfigurationDocument(updatedAt: Date(timeIntervalSince1970: 0), accounts: [ + LocalProviderAccountConfiguration( + id: "codex", + provider: .openAI, + connectorKind: .codexRateLimits, + displayName: "OpenAI", + authPath: "/tmp/codex.json" + ), + LocalProviderAccountConfiguration( + id: "gemini", + provider: .google, + connectorKind: .geminiCodeAssist, + displayName: "Gemini", + authPath: "/tmp/gemini.json", + oauthClientIDEnvironmentName: "GEMINI_ID", + oauthClientSecretEnvironmentName: "GEMINI_SECRET" + ), + LocalProviderAccountConfiguration( + id: "claude-disabled", + provider: .anthropic, + connectorKind: .claudeLocalStatus, + displayName: "Claude", + isEnabled: false + ), + ]) + + let withoutGeminiEnvironment = AccountConnectorFactory.connectors( + from: document, + environment: [:], + geminiMetadataFileLoader: { _ in "" }, + geminiMetadataFileExists: { _ in false } + ) + let withGeminiEnvironment = AccountConnectorFactory.connectors(from: document, environment: [ + "GEMINI_ID": "client", + "GEMINI_SECRET": "secret", + ]) + + #expect(withoutGeminiEnvironment.count == 1) + #expect(withoutGeminiEnvironment[0].provider == .openAI) + #expect(withGeminiEnvironment.count == 2) + #expect(Set(withGeminiEnvironment.map(\.provider)) == [.openAI, .google]) +} + +@Test func accountConnectorFactoryCanDiscoverGeminiMetadataFromInstalledCLI() { + let document = AccountConfigurationDocument(updatedAt: Date(timeIntervalSince1970: 0), accounts: [ + LocalProviderAccountConfiguration( + id: "gemini", + provider: .google, + connectorKind: .geminiCodeAssist, + displayName: "Gemini", + authPath: "/tmp/gemini.json" + ) + ]) + + let source = #""" + var OAUTH_CLIENT_ID = "client-id.apps.googleusercontent.com"; + var OAUTH_CLIENT_SECRET = "client-secret"; + """# + let connectors = AccountConnectorFactory.connectors( + from: document, + environment: ["GEMINI_CLI_BUNDLE_PATH": "/tmp/gemini-bundle.js"], + geminiMetadataFileLoader: { _ in source }, + geminiMetadataFileExists: { _ in true } + ) + + #expect(connectors.count == 1) + #expect(connectors[0].provider == .google) +} + +@Test func accountConfigurationStoreReportsCorruptFilesAsFailure() throws { + let url = try temporaryDirectory().appending(path: "accounts.json") + try Data("nope".utf8).write(to: url) + + let result = AccountConfigurationStore(configurationURL: url).load(now: Date(timeIntervalSince1970: 0)) + + #expect(result.status == .failure) + #expect(result.document.accounts.count == 3) + #expect(result.errorMessage?.isEmpty == false) +} + +private func temporaryDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appending(path: "context-panel-account-tests") + .appending(path: UUID().uuidString, directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url +} diff --git a/Tests/ContextPanelCoreTests/ClaudeLocalStatusTests.swift b/Tests/ContextPanelCoreTests/ClaudeLocalStatusTests.swift new file mode 100644 index 0000000..9869c89 --- /dev/null +++ b/Tests/ContextPanelCoreTests/ClaudeLocalStatusTests.swift @@ -0,0 +1,244 @@ +import Foundation +import Testing + +@testable import ContextPanelCore + +@Test func claudeAuthStatusParserReducesStatusToNonSecretFields() throws { + let json = #""" + { + "loggedIn": true, + "authMethod": "claude.ai", + "apiProvider": "firstParty", + "email": "friend@example.com", + "orgId": "org_secret", + "subscriptionType": "pro" + } + """# + + let status = try ClaudeAuthStatusParser.status(from: Data(json.utf8)) + + #expect(status.loggedIn) + #expect(status.authMethod == "claude.ai") + #expect(status.apiProvider == "firstParty") + #expect(status.subscriptionType == "pro") + #expect(status.subscriptionDisplayName == "Claude Pro") +} + +@Test func claudeStatsCacheParserSummarizesLocalActivityOnly() throws { + let json = #""" + { + "version": 2, + "lastComputedDate": "2026-05-06T14:00:00Z", + "dailyActivity": { + "2026-05-06": { "messageCount": 5 } + }, + "modelUsage": { + "claude-sonnet-4-6": { "inputTokens": 12 }, + "claude-opus-4-6": { "inputTokens": 3 } + }, + "totalSessions": 7, + "totalMessages": 42, + "firstSessionDate": "2026-05-01" + } + """# + + let summary = try ClaudeStatsCacheParser.summary(from: Data(json.utf8)) + + #expect(summary.version == 2) + #expect(ContextPanelDateFormatting.string(from: summary.lastComputedDate!) == "2026-05-06T14:00:00Z") + #expect(summary.totalSessions == 7) + #expect(summary.totalMessages == 42) + #expect(summary.modelUsageCount == 2) + #expect(summary.dailyActivityCount == 1) +} + +@Test func claudeStatsCacheParserAcceptsArrayShapedDailyActivity() throws { + let json = #""" + { + "lastComputedDate": "2026-04-26", + "dailyActivity": [ + { "date": "2026-04-25" }, + { "date": "2026-04-26" } + ], + "modelUsage": {}, + "totalSessions": 1, + "totalMessages": 2 + } + """# + + let summary = try ClaudeStatsCacheParser.summary(from: Data(json.utf8)) + + #expect(ContextPanelDateFormatting.string(from: summary.lastComputedDate!) == "2026-04-26T00:00:00Z") + #expect(summary.dailyActivityCount == 2) + #expect(summary.modelUsageCount == 0) +} + +@Test func claudeStatsCacheParserAcceptsRateLimitSnapshotWhenPresent() throws { + let json = #""" + { + "lastComputedDate": "2026-05-06T14:00:00Z", + "rate_limits": { + "five_hour": { + "used_percentage": 42.4, + "resets_at": 1788397200 + }, + "seven_day": { + "used_percentage": 51.6, + "resets_at": 1788984000 + } + } + } + """# + + let summary = try ClaudeStatsCacheParser.summary(from: Data(json.utf8)) + + #expect(summary.rateLimitSnapshot?.windows.map(\.label) == ["5-hour", "Weekly"]) + #expect(summary.rateLimitSnapshot?.windows.map { Int($0.usedPercent.rounded()) } == [42, 52]) +} + +@Test func claudeUsageBlocksParserSummarizesActiveBlockAndLimitEstimate() throws { + let json = #""" + { + "blocks": [ + { "isActive": false, "totalTokens": 1000 }, + { "isActive": false, "totalTokens": 2000 }, + { + "isActive": true, + "totalTokens": 500, + "endTime": "2026-05-06T23:00:00Z", + "projection": { "totalTokens": 1200, "remainingMinutes": 45 }, + "models": ["claude-sonnet-4-6", ""] + } + ] + } + """# + + let summary = try ClaudeUsageBlocksParser.summary(from: Data(json.utf8)) + + #expect(summary.activeBlock?.totalTokens == 500) + #expect(summary.activeBlock?.projectedTotalTokens == 1200) + #expect(summary.activeBlock?.remainingMinutes == 45) + #expect(summary.activeBlock?.modelCount == 1) + #expect(summary.completedBlockTokenLimitEstimate == 1000) +} + +@Test func claudeLocalStatusLimitMakesUnknownAllowanceExplicit() { + let limit = claudeLocalStatusLimits( + authStatus: ClaudeAuthStatus( + loggedIn: true, + authMethod: "claude.ai", + apiProvider: "firstParty", + subscriptionType: "pro" + ), + statsSummary: nil, + accountID: "local", + accountName: "Claude", + observedAt: Date(timeIntervalSince1970: 0) + ).first! + + #expect(limit.label == "Claude Pro status") + #expect(limit.modelLabel == "Claude Code") + #expect(limit.confidence == .observed) + #expect(limit.status == .unknown) + #expect(limit.note?.contains("allowance: not exposed by Claude Code") == true) +} + +@Test func claudeStatuslineRateLimitCacheParsesSubscriptionWindows() throws { + let json = #""" + { + "observed_at": 1788379200, + "rate_limits": { + "five_hour": { + "used_percentage": 42.4, + "resets_at": 1788397200 + }, + "seven_day": { + "used_percentage": 51.6, + "resets_at": 1788984000 + } + } + } + """# + + let snapshot = try ClaudeSubscriptionRateLimitCacheParser.snapshot(from: Data(json.utf8)) + + #expect(ContextPanelDateFormatting.string(from: snapshot.observedAt) == "2026-09-02T20:00:00Z") + #expect(snapshot.windows.map(\.label) == ["5-hour", "Weekly"]) + #expect(snapshot.windows.map { Int($0.usedPercent.rounded()) } == [42, 52]) +} + +@Test func claudeLocalStatusLimitsPrefersStatuslineSubscriptionWindows() { + let limits = claudeLocalStatusLimits( + authStatus: ClaudeAuthStatus( + loggedIn: true, + authMethod: "claude.ai", + apiProvider: "firstParty", + subscriptionType: "pro" + ), + statsSummary: nil, + rateLimitSnapshot: ClaudeSubscriptionRateLimitSnapshot( + observedAt: Date(timeIntervalSince1970: 10), + windows: [ + ClaudeSubscriptionRateLimitWindow( + label: "5-hour", + usedPercent: 42.4, + resetsAt: Date(timeIntervalSince1970: 100) + ), + ClaudeSubscriptionRateLimitWindow( + label: "Weekly", + usedPercent: 51.6, + resetsAt: Date(timeIntervalSince1970: 200) + ), + ] + ), + accountID: "local", + accountName: "Claude", + observedAt: Date(timeIntervalSince1970: 0) + ) + + #expect(limits.count == 2) + #expect(limits.map(\.provider) == [.anthropic, .anthropic]) + #expect(limits.map(\.windowLabel) == ["5-hour", "Weekly"]) + #expect(limits.map(\.modelLabel) == ["Claude Pro", "Claude Pro"]) + #expect(limits.map(\.used) == [42, 52]) + #expect(limits.allSatisfy { $0.unit == .percent && $0.limit == 100 && $0.confidence == .observed }) +} + +@Test func claudeLocalStatusLimitsUsesEveryCodeEstimateWhenAvailable() { + let limits = claudeLocalStatusLimits( + authStatus: ClaudeAuthStatus( + loggedIn: true, + authMethod: "claude.ai", + apiProvider: "firstParty", + subscriptionType: "pro" + ), + statsSummary: nil, + rateLimitSnapshot: ClaudeSubscriptionRateLimitSnapshot( + observedAt: Date(timeIntervalSince1970: 10), + windows: [ClaudeSubscriptionRateLimitWindow(label: "5-hour", usedPercent: 4, resetsAt: nil)] + ), + usageBlocksSummary: ClaudeUsageBlocksSummary( + activeBlock: ClaudeUsageBlock( + isActive: true, + totalTokens: 500, + endTime: Date(timeIntervalSince1970: 9_000), + projectedTotalTokens: 1_200, + remainingMinutes: 45, + modelCount: 2 + ), + completedBlockTokenLimitEstimate: 1_000 + ), + accountID: "local", + accountName: "Claude", + observedAt: Date(timeIntervalSince1970: 1_000) + ) + + #expect(limits.count == 1) + #expect(limits[0].label == "Claude 5-hour estimate") + #expect(limits[0].unit == .tokens) + #expect(limits[0].used == 500) + #expect(limits[0].limit == 1_000) + #expect(limits[0].confidence == .estimated) + #expect(limits[0].resetsAt == Date(timeIntervalSince1970: 3_700)) + #expect(limits[0].note?.contains("official subscription percentage unavailable") == true) +} diff --git a/Tests/ContextPanelCoreTests/ClaudeWebUsageTests.swift b/Tests/ContextPanelCoreTests/ClaudeWebUsageTests.swift new file mode 100644 index 0000000..3e90790 --- /dev/null +++ b/Tests/ContextPanelCoreTests/ClaudeWebUsageTests.swift @@ -0,0 +1,62 @@ +import Foundation +import Testing + +@testable import ContextPanelCore + +@Test func claudeWebUsageParserBuildsSubscriptionPercentWindows() throws { + let payload = #""" + { + "rate_limits": { + "five_hour": { + "used_percentage": 42.4, + "resets_at": 1778109600 + }, + "seven_day": { + "remaining_percentage": 30, + "resets_at": "2026-05-08T01:00:00Z" + }, + "seven_day_opus": { + "utilization": 0.91, + "resets_at": 1778192400000 + } + }, + "account_uuid": "1e13c5e0-a592-428d-a051-9fe5d6260e38" + } + """#.data(using: .utf8)! + + let limits = try ClaudeWebUsageParser.usageLimits( + from: payload, + accountID: "claude-local", + accountName: "Claude Max", + observedAt: Date(timeIntervalSince1970: 1) + ) + + #expect(limits.count == 3) + #expect(limits[0].label == "Claude 5-hour") + #expect(limits[0].windowLabel == "5-hour") + #expect(limits[0].used == 42) + #expect(limits[0].confidence == .observed) + #expect(limits[1].used == 70) + #expect(limits[2].modelLabel == "Opus") + #expect(limits[2].used == 91) +} + +@Test func claudeWebUsageSanitizerReturnsOnlyAllowedUsageFields() throws { + let payload = #""" + { + "rate_limits": { + "five_hour": { "used_percentage": 12, "resets_at": 1778109600 } + }, + "email": "chris@example.com", + "organization_uuid": "1e13c5e0-a592-428d-a051-9fe5d6260e38" + } + """#.data(using: .utf8)! + + let fields = try ClaudeWebUsageParser.sanitizedUsageFields(from: payload) + + #expect(fields.contains("rate_limits")) + #expect(fields.contains("rate_limits.five_hour")) + #expect(fields.contains("rate_limits.five_hour.used_percentage")) + #expect(!fields.contains { $0.localizedCaseInsensitiveContains("email") }) + #expect(!fields.contains { $0.localizedCaseInsensitiveContains("uuid") }) +} diff --git a/Tests/ContextPanelCoreTests/CodexRateLimitsTests.swift b/Tests/ContextPanelCoreTests/CodexRateLimitsTests.swift new file mode 100644 index 0000000..42aee55 --- /dev/null +++ b/Tests/ContextPanelCoreTests/CodexRateLimitsTests.swift @@ -0,0 +1,96 @@ +import Foundation +import Testing + +@testable import ContextPanelCore + +@Test func codexUsagePayloadParserNormalizesPrimarySecondaryAndAdditionalWindows() throws { + let json = #""" + { + "plan_type": "pro", + "rate_limit": { + "allowed": true, + "limit_reached": false, + "primary_window": { + "used_percent": 45, + "limit_window_seconds": 18000, + "reset_after_seconds": 14000, + "reset_at": 1788393600 + }, + "secondary_window": { + "used_percent": 36, + "limit_window_seconds": 604800, + "reset_after_seconds": 500000, + "reset_at": 1788998400 + } + }, + "credits": { + "has_credits": true, + "unlimited": false, + "balance": "0" + }, + "additional_rate_limits": [ + { + "limit_name": "GPT-5.3-Codex-Spark", + "metered_feature": "codex_bengalfox", + "rate_limit": { + "allowed": true, + "limit_reached": false, + "primary_window": { + "used_percent": 1, + "limit_window_seconds": 18000, + "reset_after_seconds": 1000, + "reset_at": 1788400000 + }, + "secondary_window": null + } + } + ], + "rate_limit_reached_type": { + "type": "workspace_member_usage_limit_reached" + } + } + """# + + let snapshots = try CodexUsagePayloadParser.snapshots(from: Data(json.utf8)) + + #expect(snapshots.count == 2) + #expect(snapshots[0].id == "codex") + #expect(snapshots[0].planType == "pro") + #expect(snapshots[0].primary?.usedPercent == 45) + #expect(snapshots[0].primary?.windowMinutes == 300) + #expect(snapshots[0].secondary?.windowMinutes == 10080) + #expect(snapshots[0].credits == CodexCreditsSnapshot(hasCredits: true, unlimited: false, balance: "0")) + #expect(snapshots[0].rateLimitReachedType == .workspaceMemberUsageLimitReached) + #expect(snapshots[1].id == "codex_bengalfox") + #expect(snapshots[1].limitName == "GPT-5.3-Codex-Spark") + #expect(snapshots[1].primary?.usedPercent == 1) + #expect(snapshots[1].credits == nil) + + let limits = codexUsageLimits( + from: snapshots[0], + accountID: "acct", + accountName: "Personal", + observedAt: Date(timeIntervalSince1970: 0) + ) + #expect(limits.map(\.windowLabel) == ["5-hour", "Weekly"]) + #expect(limits.first?.modelLabel == "Codex") +} + +@Test func codexUsagePayloadParserHandlesMissingLimitDetails() throws { + let json = #""" + { + "plan_type": "plus", + "rate_limit": null, + "additional_rate_limits": null, + "credits": null + } + """# + + let snapshots = try CodexUsagePayloadParser.snapshots(from: Data(json.utf8)) + + #expect(snapshots.count == 1) + #expect(snapshots[0].id == "codex") + #expect(snapshots[0].planType == "plus") + #expect(snapshots[0].primary == nil) + #expect(snapshots[0].secondary == nil) +} diff --git a/Tests/ContextPanelCoreTests/FastModeForecastTests.swift b/Tests/ContextPanelCoreTests/FastModeForecastTests.swift new file mode 100644 index 0000000..808d0dc --- /dev/null +++ b/Tests/ContextPanelCoreTests/FastModeForecastTests.swift @@ -0,0 +1,112 @@ +import Foundation +import Testing + +@testable import ContextPanelCore + +private let now = Date(timeIntervalSinceReferenceDate: 900_000_000) + +@Test func forecastReportsSafeThroughResetWhenFastBurnFitsWithReserve() { + let forecast = FastModeForecast( + input: FastModeForecastInput( + limit: openAILimit(used: 20, limit: 100, resetsInHours: 4), + now: now, + standardBurnRate: BurnRate(mode: .standard, unitsPerHour: 4), + fastBurnRate: BurnRate(mode: .fast, unitsPerHour: 10), + reserveUnits: 10 + ) + ) + + #expect(forecast.recommendation == .safeThroughReset) + #expect(forecast.fastModeRunwayHours == 7) + #expect(forecast.copy == "Fast mode looks safe through reset.") +} + +@Test func forecastReportsLimitedFastModeRunwayWhenFastBurnDoesNotReachReset() { + let forecast = FastModeForecast( + input: FastModeForecastInput( + limit: openAILimit(used: 60, limit: 100, resetsInHours: 8), + now: now, + standardBurnRate: BurnRate(mode: .standard, unitsPerHour: 2), + fastBurnRate: BurnRate(mode: .fast, unitsPerHour: 15), + reserveUnits: 10 + ) + ) + + #expect(forecast.recommendation == .safeForLimitedTime) + #expect(forecast.copy == "Fast mode safe for about 2h.") +} + +@Test func forecastSavesFastModeWhenRunwayIsBelowMinimumWindow() { + let forecast = FastModeForecast( + input: FastModeForecastInput( + limit: openAILimit(used: 88, limit: 100, resetsInHours: 6), + now: now, + standardBurnRate: BurnRate(mode: .standard, unitsPerHour: 1), + fastBurnRate: BurnRate(mode: .fast, unitsPerHour: 10), + reserveUnits: 5, + minimumSafeHours: 1 + ) + ) + + #expect(forecast.recommendation == .saveFastMode) + #expect(forecast.copy == "Save fast mode before reset.") +} + +@Test func forecastNeedsCalibrationWithoutResetOrFastBurnRate() { + let forecast = FastModeForecast( + input: FastModeForecastInput( + limit: openAILimit(used: 10, limit: 100, resetsInHours: nil), + now: now, + standardBurnRate: nil, + fastBurnRate: nil + ) + ) + + #expect(forecast.recommendation == .needsCalibration) + #expect(forecast.copy == "Needs calibration before fast mode.") +} + +@Test func portfolioChoosesBestOpenAIAccountForFastMode() { + let personal = FastModeForecast( + input: FastModeForecastInput( + limit: openAILimit(accountName: "Personal", used: 80, limit: 100, resetsInHours: 12), + now: now, + standardBurnRate: BurnRate(mode: .standard, unitsPerHour: 2), + fastBurnRate: BurnRate(mode: .fast, unitsPerHour: 12), + reserveUnits: 5 + ) + ) + let work = FastModeForecast( + input: FastModeForecastInput( + limit: openAILimit(accountName: "Work", used: 20, limit: 100, resetsInHours: 4), + now: now, + standardBurnRate: BurnRate(mode: .standard, unitsPerHour: 2), + fastBurnRate: BurnRate(mode: .fast, unitsPerHour: 12), + reserveUnits: 5 + ) + ) + + let portfolio = FastModePortfolioForecast(forecasts: [personal, work]) + + #expect(portfolio.bestForecast?.accountName == "Work") + #expect(portfolio.copy == "Fast mode looks safe through reset.") +} + +private func openAILimit( + accountName: String = "Personal", + used: Int, + limit: Int, + resetsInHours: Double? +) -> UsageLimit { + UsageLimit( + provider: .openAI, + accountID: "openai-\(accountName.lowercased())", + accountName: accountName, + label: "GPT-5 Thinking", + unit: .percent, + used: used, + limit: limit, + resetsAt: resetsInHours.map { now.addingTimeInterval($0 * 3_600) }, + confidence: .estimated + ) +} diff --git a/Tests/ContextPanelCoreTests/GeminiCodeAssistQuotaTests.swift b/Tests/ContextPanelCoreTests/GeminiCodeAssistQuotaTests.swift new file mode 100644 index 0000000..6c93b80 --- /dev/null +++ b/Tests/ContextPanelCoreTests/GeminiCodeAssistQuotaTests.swift @@ -0,0 +1,96 @@ +import Foundation +import Testing + +@testable import ContextPanelCore + +@Test func geminiQuotaPayloadParserNormalizesBucketsAsPercentPressure() throws { + let json = #""" + { + "buckets": [ + { + "modelId": "gemini-3-flash-preview", + "remainingFraction": 0.965, + "resetTime": "2026-05-06T16:04:50Z" + }, + { + "modelId": "gemini-3.1-pro-preview", + "remainingFraction": 1, + "remainingAmount": 12, + "resetTime": "2026-05-07T14:19:35Z" + } + ] + } + """# + + let buckets = try GeminiQuotaPayloadParser.buckets(from: Data(json.utf8)) + + #expect(buckets.count == 2) + #expect(buckets[0].modelID == "gemini-3-flash-preview") + #expect(buckets[0].remainingFraction == 0.965) + #expect(abs((buckets[0].usedPercent ?? 0) - 3.5) < 0.0001) + #expect(ContextPanelDateFormatting.string(from: buckets[0].resetsAt!) == "2026-05-06T16:04:50Z") + #expect(buckets[1].remainingAmount == 12) + + let limit = buckets[0].usageLimit(accountID: "local", accountName: "Gemini CLI", observedAt: Date(timeIntervalSince1970: 0)) + #expect(limit.provider == .google) + #expect(limit.unit == .percent) + #expect(limit.used == 4) + #expect(limit.limit == 100) + #expect(limit.confidence == .observed) +} + +@Test func geminiQuotaPayloadParserHandlesMissingBuckets() throws { + let json = #"{"notBuckets": true}"# + + let buckets = try GeminiQuotaPayloadParser.buckets(from: Data(json.utf8)) + + #expect(buckets.isEmpty) +} + +@Test func geminiOAuthClientMetadataDiscoveryParsesInstalledCLIBundleShape() { + let source = #""" + var OAUTH_CLIENT_ID = "client-id.apps.googleusercontent.com"; + var OAUTH_CLIENT_SECRET = "client-secret"; + """# + + let metadata = GeminiOAuthClientMetadataDiscovery.parseClientMetadata(from: source) + + #expect(metadata?.clientID == "client-id.apps.googleusercontent.com") + #expect(metadata?.clientSecret == "client-secret") +} + +@Test func geminiOAuthClientMetadataDiscoveryPrefersEnvironmentValues() { + let metadata = GeminiOAuthClientMetadataDiscovery.discover( + environment: [ + "GEMINI_OAUTH_CLIENT_ID": "env-client", + "GEMINI_OAUTH_CLIENT_SECRET": "env-secret", + ], + fileLoader: { _ in "" }, + fileExists: { _ in false }, + directoryLister: { _ in [] } + ) + + #expect(metadata == GeminiOAuthClientMetadata(clientID: "env-client", clientSecret: "env-secret")) +} + +@Test func geminiOAuthClientMetadataDiscoveryScansInstalledBundleDirectory() { + let source = #""" + var OAUTH_CLIENT_ID = "bundle-client"; + var OAUTH_CLIENT_SECRET = "bundle-secret"; + """# + + let metadata = GeminiOAuthClientMetadataDiscovery.discover( + environment: [:], + fileLoader: { path in + path.hasSuffix("chunk-with-oauth.js") ? source : "" + }, + fileExists: { _ in true }, + directoryLister: { root in + root == "/opt/homebrew/lib/node_modules/@google/gemini-cli/bundle" + ? ["\(root)/chunk-with-oauth.js"] + : [] + } + ) + + #expect(metadata == GeminiOAuthClientMetadata(clientID: "bundle-client", clientSecret: "bundle-secret")) +} diff --git a/Tests/ContextPanelCoreTests/LimitProbeTests.swift b/Tests/ContextPanelCoreTests/LimitProbeTests.swift new file mode 100644 index 0000000..dc63c59 --- /dev/null +++ b/Tests/ContextPanelCoreTests/LimitProbeTests.swift @@ -0,0 +1,112 @@ +import Foundation +import Testing + +@testable import ContextPanelCore + +@Test func visibleTextScannerFindsSubscriptionLimitSignals() { + let text = """ + GPT-5 Thinking unavailable. You have reached your weekly limit. + Try again in 3h. Pro shows 52 percent used in the 5 hour window. + """ + + let observations = LimitProbeScanner.scanVisibleText(text, provider: .openAI) + let kinds = Set(observations.map(\.signalKind)) + + #expect(kinds.contains(.limitReached)) + #expect(kinds.contains(.relativeDuration)) + #expect(kinds.contains(.usagePressure)) + #expect(kinds.contains(.modelAvailability)) + #expect(kinds.contains(.planLanguage)) +} + +@Test func responseShapeScannerOnlyReportsCandidateFieldNames() { + let observations = LimitProbeScanner.scanResponseShape( + fieldNames: ["id", "email", "used_percent", "reset_at", "avatar", "token_pressure"], + provider: .openAI + ) + + #expect(observations.map(\.sanitizedEvidence).contains("reset_at")) + #expect(!observations.map(\.sanitizedEvidence).contains("email")) + #expect(observations.map(\.sanitizedEvidence).contains("used_percent")) + #expect(observations.map(\.sanitizedEvidence).contains("token_pressure")) +} + +@Test func probeEvidenceRedactsSecretsBeforeStorage() { + let observation = LimitProbeObservation( + provider: .openAI, + observedAt: Date(), + source: .visibleText, + signalKind: .resetLanguage, + confidence: .observed, + sanitizedEvidence: "Authorization: bearer abc.def.ghi user chris@example.com token=secret" + ) + + #expect(!observation.sanitizedEvidence.localizedCaseInsensitiveContains("abc.def.ghi")) + #expect(!observation.sanitizedEvidence.localizedCaseInsensitiveContains("chris@example.com")) + #expect(!observation.sanitizedEvidence.localizedCaseInsensitiveContains("secret")) + #expect(observation.sanitizedEvidence.localizedCaseInsensitiveContains("[email redacted]")) +} + +@Test func markdownReportContainsRedactionStatement() { + let report = LimitProbeReport( + provider: .openAI, + capturedAt: Date(timeIntervalSinceReferenceDate: 1), + observations: [ + LimitProbeObservation( + provider: .openAI, + observedAt: Date(timeIntervalSinceReferenceDate: 1), + source: .visibleText, + signalKind: .relativeDuration, + confidence: .observed, + sanitizedEvidence: "resets in 3h" + ) + ], + networkEvents: [ + NetworkProbeEvent( + observedAt: Date(timeIntervalSinceReferenceDate: 1), + method: "GET", + pathHint: "/backend-api/accounts/check", + status: 200, + contentType: "application/json", + bodySize: 256, + matchedFields: ["default_account_plan_type", "accounts.[id].entitlement.subscription_plan"] + ) + ] + ) + + #expect(report.markdownSummary.contains("Limit Probe Report")) + #expect(report.markdownSummary.contains("resets in 3h")) + #expect(report.markdownSummary.contains("Network Candidates")) + #expect(report.markdownSummary.contains("/backend-api/accounts/check")) + #expect(report.markdownSummary.contains("subscription_plan")) + #expect(report.markdownSummary.contains("authorization headers")) +} + +@Test func networkProbeEventRedactsPathIdentifiers() { + let event = NetworkProbeEvent( + observedAt: Date(), + method: "GET", + pathHint: "https://chatgpt.com/backend-api/conversation/abc123def456abc123def456?token=secret", + status: 200, + contentType: "application/json", + bodySize: 120, + matchedFields: ["reset_at", "used_percent"] + ) + + #expect(event.pathHint == "/backend-api/conversation/[id]") + #expect(event.matchedFields == ["reset_at", "used_percent"]) +} + +@Test func networkProbeEventRedactsIdentifiersInsideFieldNames() { + let event = NetworkProbeEvent( + observedAt: Date(), + method: "GET", + pathHint: "/api/accounts", + status: 200, + contentType: "application/json", + bodySize: 120, + matchedFields: ["accounts.1e13c5e0-a592-428d-a051-9fe5d6260e38.entitlement.subscription_plan"] + ) + + #expect(event.matchedFields == ["accounts.[id].entitlement.subscription_plan"]) +} diff --git a/Tests/ContextPanelCoreTests/ProviderConnectorTests.swift b/Tests/ContextPanelCoreTests/ProviderConnectorTests.swift new file mode 100644 index 0000000..566fc71 --- /dev/null +++ b/Tests/ContextPanelCoreTests/ProviderConnectorTests.swift @@ -0,0 +1,240 @@ +import Foundation +import Testing + +@testable import ContextPanelCore + +@Test func codexConnectorRefreshesMultipleAccountsIntoNormalizedLimits() async throws { + let auth = #"{"tokens":{"access_token":"token-secret"}}"#.data(using: .utf8)! + let usage = #""" + { + "plan_type": "pro", + "rate_limit": { + "primary_window": { "used_percent": 50, "limit_window_seconds": 18000, "reset_at": 1788393600 }, + "secondary_window": { "used_percent": 25, "limit_window_seconds": 604800, "reset_at": 1788998400 } + } + } + """#.data(using: .utf8)! + let http = StubHTTPClient(responses: [ConnectorHTTPResponse(statusCode: 200, data: usage)]) + let connector = CodexRateLimitConnector( + accounts: [ + CodexAccountConfiguration(authPath: "/tmp/openai-a.json", accountName: "OpenAI A"), + CodexAccountConfiguration(authPath: "/tmp/openai-b.json", accountName: "OpenAI B"), + ], + httpClient: http, + fileLoader: { _ in auth } + ) + + let result = await connector.refresh(now: Date(timeIntervalSince1970: 0)) + + #expect(result.reports.count == 2) + #expect(result.snapshot.limits.count == 4) + #expect(Set(result.snapshot.limits.map(\.accountName)) == ["OpenAI A", "OpenAI B"]) + #expect(result.snapshot.limits.allSatisfy { $0.provider == .openAI && $0.unit == .percent }) + #expect(result.snapshot.limits.contains { $0.used == 50 && $0.windowLabel == "5-hour" }) + #expect(http.requests.count == 2) +} + +@Test func codexConnectorRedactsHTTPFailures() async { + let auth = #"{"tokens":{"access_token":"token-secret"}}"#.data(using: .utf8)! + let http = StubHTTPClient(responses: [ConnectorHTTPResponse(statusCode: 401, data: Data("secret body".utf8))]) + let connector = CodexRateLimitConnector( + accounts: [CodexAccountConfiguration(authPath: "/tmp/openai.json", accountName: "OpenAI")], + httpClient: http, + fileLoader: { _ in auth } + ) + + let result = await connector.refresh(now: Date()) + + #expect(result.reports.count == 1) + #expect(result.reports[0].status == .failure) + #expect(result.reports[0].errorMessage?.contains("HTTP 401") == true) + #expect(result.reports[0].errorMessage?.contains("secret body") == false) + #expect(result.snapshot.limits.isEmpty) +} + +@Test func geminiConnectorRefreshesQuotaBuckets() async throws { + let credentials = #"{"refresh_token":"refresh-secret"}"#.data(using: .utf8)! + let refresh = #"{"access_token":"access-secret"}"#.data(using: .utf8)! + let load = #"{"cloudaicompanionProject":"project-secret","currentTier":{"name":"Gemini Code Assist"}}"#.data(using: .utf8)! + let quota = #""" + { + "buckets": [ + { "modelId": "gemini-3-flash-preview", "remainingFraction": 0.75, "resetTime": "2026-05-06T16:04:50Z" } + ] + } + """#.data(using: .utf8)! + let http = StubHTTPClient(responses: [ + ConnectorHTTPResponse(statusCode: 200, data: refresh), + ConnectorHTTPResponse(statusCode: 200, data: load), + ConnectorHTTPResponse(statusCode: 200, data: quota), + ]) + let connector = GeminiCodeAssistConnector( + accounts: [GeminiAccountConfiguration(authPath: "/tmp/gemini.json", accountName: "Gemini", clientID: "client", clientSecret: "secret")], + httpClient: http, + fileLoader: { _ in credentials } + ) + + let result = await connector.refresh(now: Date(timeIntervalSince1970: 0)) + + #expect(result.reports.count == 1) + #expect(result.snapshot.limits.count == 1) + #expect(result.snapshot.limits[0].provider == .google) + #expect(result.snapshot.limits[0].label == "gemini-3-flash-preview") + #expect(result.snapshot.limits[0].used == 25) + #expect(http.requests.map(\.method) == ["POST", "POST", "POST"]) + #expect(http.requests[2].body.flatMap { String(data: $0, encoding: .utf8) }?.contains("project-secret") == true) +} + +@Test func claudeConnectorReportsUnknownLiveAllowanceFromLocalStatus() async throws { + let auth = #"{"loggedIn":true,"authMethod":"claude.ai","apiProvider":"firstParty","subscriptionType":"pro"}"#.data(using: .utf8)! + let stats = #"{"version":3,"lastComputedDate":"2026-04-26","dailyActivity":[],"modelUsage":{},"totalSessions":2,"totalMessages":3}"#.data(using: .utf8)! + let connector = ClaudeLocalStatusConnector( + accounts: [ClaudeAccountConfiguration(accountName: "Claude", claudeBinary: "claude", statsPath: "/tmp/stats.json")], + processClient: StubProcessClient(result: ConnectorProcessResult(exitCode: 0, stdout: auth)), + fileLoader: { _ in stats }, + fileExists: { path in path == "/tmp/stats.json" } + ) + + let result = await connector.refresh(now: Date(timeIntervalSince1970: 0)) + + #expect(result.reports.count == 1) + #expect(result.reports[0].status == .unknown) + #expect(result.snapshot.limits.count == 1) + #expect(result.snapshot.limits[0].provider == .anthropic) + #expect(result.snapshot.limits[0].status == .unknown) + #expect(result.snapshot.limits[0].note?.contains("subscription: pro") == true) +} + +@Test func claudeConnectorReportsHealthyWhenStatuslineLimitsExist() async throws { + let auth = #"{"loggedIn":true,"authMethod":"claude.ai","apiProvider":"firstParty","subscriptionType":"pro"}"#.data(using: .utf8)! + let stats = #"{"version":3,"lastComputedDate":"2026-04-26","dailyActivity":[],"modelUsage":{},"totalSessions":2,"totalMessages":3}"#.data(using: .utf8)! + let cache = #"{"observed_at":1788379200,"rate_limits":{"five_hour":{"used_percentage":4,"resets_at":1788397200},"seven_day":{"used_percentage":0,"resets_at":1788984000}}}"#.data(using: .utf8)! + let connector = ClaudeLocalStatusConnector( + accounts: [ClaudeAccountConfiguration( + accountName: "Claude", + claudeBinary: "claude", + statsPath: "/tmp/stats.json", + rateLimitSnapshotPath: "/tmp/claude-statusline.json", + rateLimitSnapshotMaximumAge: 60 + )], + processClient: StubProcessClient(result: ConnectorProcessResult(exitCode: 0, stdout: auth)), + fileLoader: { path in path == "/tmp/claude-statusline.json" ? cache : stats }, + fileExists: { path in path == "/tmp/stats.json" || path == "/tmp/claude-statusline.json" } + ) + + let result = await connector.refresh(now: Date(timeIntervalSince1970: 1_788_379_230)) + + #expect(result.reports[0].status == .healthy) + #expect(result.snapshot.limits.count == 2) + #expect(result.snapshot.limits.map(\.windowLabel) == ["5-hour", "Weekly"]) +} + +@Test func claudeConnectorMarksOldStatuslineLimitsStale() async throws { + let auth = #"{"loggedIn":true,"authMethod":"claude.ai","apiProvider":"firstParty","subscriptionType":"pro"}"#.data(using: .utf8)! + let stats = #"{"version":3,"lastComputedDate":"2026-04-26","dailyActivity":[],"modelUsage":{},"totalSessions":2,"totalMessages":3}"#.data(using: .utf8)! + let cache = #"{"observed_at":1788379200,"rate_limits":{"five_hour":{"used_percentage":4,"resets_at":1788397200},"seven_day":{"used_percentage":0,"resets_at":1788984000}}}"#.data(using: .utf8)! + let connector = ClaudeLocalStatusConnector( + accounts: [ClaudeAccountConfiguration( + accountName: "Claude", + claudeBinary: "claude", + statsPath: "/tmp/stats.json", + rateLimitSnapshotPath: "/tmp/claude-statusline.json", + rateLimitSnapshotMaximumAge: 60 + )], + processClient: StubProcessClient(result: ConnectorProcessResult(exitCode: 0, stdout: auth)), + fileLoader: { path in path == "/tmp/claude-statusline.json" ? cache : stats }, + fileExists: { path in path == "/tmp/stats.json" || path == "/tmp/claude-statusline.json" } + ) + + let result = await connector.refresh(now: Date(timeIntervalSince1970: 1_788_379_500)) + + #expect(result.reports[0].status == .stale) + #expect(result.snapshot.limits.map(\.status) == [.stale, .stale]) + #expect(result.snapshot.limits[0].note?.contains("stale Claude Code statusline") == true) +} + +@Test func claudeConnectorReportsEveryCodeUsageEstimate() async throws { + let auth = #"{"loggedIn":true,"authMethod":"claude.ai","apiProvider":"firstParty","subscriptionType":"pro"}"#.data(using: .utf8)! + let stats = #"{"version":3,"lastComputedDate":"2026-04-26","dailyActivity":[],"modelUsage":{},"totalSessions":2,"totalMessages":3}"#.data(using: .utf8)! + let blocks = #"{"blocks":[{"isActive":false,"totalTokens":1000},{"isActive":true,"totalTokens":500,"projection":{"totalTokens":1200,"remainingMinutes":30},"models":["claude-sonnet-4-6"]}]}"#.data(using: .utf8)! + let connector = ClaudeLocalStatusConnector( + accounts: [ClaudeAccountConfiguration( + accountName: "Claude", + claudeBinary: "claude", + statsPath: "/tmp/stats.json", + usageBlocksPath: "/tmp/ccusage-blocks.json" + )], + processClient: StubProcessClient(result: ConnectorProcessResult(exitCode: 0, stdout: auth)), + fileLoader: { path in path == "/tmp/ccusage-blocks.json" ? blocks : stats }, + fileExists: { path in path == "/tmp/stats.json" || path == "/tmp/ccusage-blocks.json" } + ) + + let result = await connector.refresh(now: Date(timeIntervalSince1970: 1_000)) + + #expect(result.reports[0].status == .healthy) + #expect(result.snapshot.limits.count == 1) + #expect(result.snapshot.limits[0].confidence == .estimated) + #expect(result.snapshot.limits[0].used == 500) + #expect(result.snapshot.limits[0].limit == 1000) + #expect(result.snapshot.limits[0].note?.contains("Every Code/Claude sessions") == true) +} + +@Test func providerConnectorRuntimeAggregatesConnectorSnapshots() async { + let connectorA = StubConnector(provider: .openAI, report: ProviderConnectorReport( + provider: .openAI, + accountID: "a", + accountName: "A", + generatedAt: Date(timeIntervalSince1970: 0), + limits: [UsageLimit(provider: .openAI, label: "A", used: 1, limit: 100)] + )) + let connectorB = StubConnector(provider: .google, report: ProviderConnectorReport( + provider: .google, + accountID: "b", + accountName: "B", + generatedAt: Date(timeIntervalSince1970: 0), + limits: [UsageLimit(provider: .google, label: "B", used: 2, limit: 100)] + )) + + let result = await ProviderConnectorRuntime(connectors: [connectorA, connectorB]).refreshAll(now: Date(timeIntervalSince1970: 10)) + + #expect(result.reports.count == 2) + #expect(result.snapshot.generatedAt == Date(timeIntervalSince1970: 10)) + #expect(Set(result.snapshot.limits.map(\.provider)) == [.openAI, .google]) +} + +private final class StubHTTPClient: ConnectorHTTPClient, @unchecked Sendable { + private var responses: [ConnectorHTTPResponse] + private(set) var requests: [ConnectorHTTPRequest] = [] + + init(responses: [ConnectorHTTPResponse]) { + self.responses = responses + } + + func data(for request: ConnectorHTTPRequest) async throws -> ConnectorHTTPResponse { + requests.append(request) + guard !responses.isEmpty else { + return ConnectorHTTPResponse(statusCode: 500, data: Data()) + } + if responses.count == 1 { + return responses[0] + } + return responses.removeFirst() + } +} + +private struct StubProcessClient: ConnectorProcessClient { + let result: ConnectorProcessResult + + func run(executable: String, arguments: [String]) throws -> ConnectorProcessResult { + result + } +} + +private struct StubConnector: ProviderConnector { + let provider: Provider + let report: ProviderConnectorReport + + func refresh(now: Date) async -> ConnectorRefreshResult { + ConnectorRefreshResult(generatedAt: now, reports: [report]) + } +} diff --git a/Tests/ContextPanelCoreTests/SnapshotStoreTests.swift b/Tests/ContextPanelCoreTests/SnapshotStoreTests.swift new file mode 100644 index 0000000..3099d0a --- /dev/null +++ b/Tests/ContextPanelCoreTests/SnapshotStoreTests.swift @@ -0,0 +1,210 @@ +import Foundation +import Testing + +@testable import ContextPanelCore + +@Test func jsonSnapshotStoreRoundTripsCurrentSnapshotAndReports() throws { + let root = try temporaryDirectory() + let store = JSONSnapshotStore(rootDirectory: root) + let savedAt = Date(timeIntervalSince1970: 100) + let refresh = ConnectorRefreshResult(generatedAt: savedAt, reports: [ProviderConnectorReport( + provider: .openAI, + accountID: "local-openai", + accountName: "OpenAI", + generatedAt: savedAt, + limits: [usageLimit(provider: .openAI, accountID: "local-openai", used: 30, savedAt: savedAt)] + )]) + + try store.save(StoredUsageSnapshot(savedAt: savedAt, refreshResult: refresh)) + + let result = store.loadCurrent() + + #expect(result.status == .healthy) + #expect(result.snapshot?.schemaVersion == 1) + #expect(result.snapshot?.savedAt == savedAt) + #expect(result.snapshot?.snapshot.limits.count == 1) + #expect(result.snapshot?.reports.count == 1) + #expect(result.snapshot?.reports[0].provider == .openAI) + #expect(FileManager.default.fileExists(atPath: store.currentSnapshotURL.path)) + #expect(store.loadHistory().count == 1) +} + +@Test func jsonSnapshotStoreFiltersHistoryByProviderAccountAndLimit() throws { + let root = try temporaryDirectory() + let store = JSONSnapshotStore(rootDirectory: root) + let first = Date(timeIntervalSince1970: 100) + let second = Date(timeIntervalSince1970: 200) + + try store.save(StoredUsageSnapshot(savedAt: first, snapshot: UsageSnapshot( + generatedAt: first, + limits: [usageLimit(provider: .openAI, accountID: "a", used: 10, savedAt: first)] + ))) + try store.save(StoredUsageSnapshot(savedAt: second, snapshot: UsageSnapshot( + generatedAt: second, + limits: [usageLimit(provider: .google, accountID: "b", used: 20, savedAt: second)] + ))) + + #expect(store.loadHistory().map(\.savedAt) == [second, first]) + #expect(store.loadHistory(query: SnapshotStoreQuery(provider: .openAI)).map(\.savedAt) == [first]) + #expect(store.loadHistory(query: SnapshotStoreQuery(accountID: "b")).map(\.savedAt) == [second]) + #expect(store.loadHistory(query: SnapshotStoreQuery(limit: 1)).count == 1) +} + +@Test func jsonSnapshotStoreMergesRefreshResultByProviderAccount() throws { + let root = try temporaryDirectory() + let store = JSONSnapshotStore(rootDirectory: root) + let first = Date(timeIntervalSince1970: 100) + let second = Date(timeIntervalSince1970: 200) + + let initial = ConnectorRefreshResult(generatedAt: first, reports: [ + ProviderConnectorReport( + provider: .openAI, + accountID: "openai-a", + accountName: "OpenAI A", + generatedAt: first, + limits: [usageLimit(provider: .openAI, accountID: "openai-a", used: 40, savedAt: first)] + ), + ProviderConnectorReport( + provider: .google, + accountID: "gemini-a", + accountName: "Gemini A", + generatedAt: first, + limits: [usageLimit(provider: .google, accountID: "gemini-a", used: 10, savedAt: first)] + ), + ]) + try store.save(StoredUsageSnapshot(savedAt: first, refreshResult: initial)) + + let claudeRefresh = ConnectorRefreshResult(generatedAt: second, reports: [ + ProviderConnectorReport( + provider: .anthropic, + accountID: "claude-web", + accountName: "Claude Web", + generatedAt: second, + limits: [usageLimit(provider: .anthropic, accountID: "claude-web", used: 3, savedAt: second)] + ) + ]) + + try store.saveMerged(refreshResult: claudeRefresh, savedAt: second) + + let limits = try #require(store.loadCurrent().snapshot?.snapshot.limits) + #expect(limits.map(\.provider).contains(.openAI)) + #expect(limits.map(\.provider).contains(.google)) + #expect(limits.map(\.provider).contains(.anthropic)) + #expect(store.loadCurrent().snapshot?.reports.count == 3) + #expect(store.loadHistory().count == 2) + + let replacement = ConnectorRefreshResult(generatedAt: second, reports: [ + ProviderConnectorReport( + provider: .openAI, + accountID: "openai-a", + accountName: "OpenAI A", + generatedAt: second, + limits: [usageLimit(provider: .openAI, accountID: "openai-a", used: 80, savedAt: second)] + ) + ]) + try store.saveMerged(refreshResult: replacement, savedAt: second.addingTimeInterval(1)) + + let replaced = try #require(store.loadCurrent().snapshot?.snapshot.limits) + let openAILimit = try #require(replaced.first { $0.provider == .openAI && $0.accountID == "openai-a" }) + #expect(openAILimit.used == 80) + #expect(replaced.filter { $0.provider == .openAI && $0.accountID == "openai-a" }.count == 1) + #expect(replaced.map(\.provider).contains(.google)) + #expect(replaced.map(\.provider).contains(.anthropic)) +} + +@Test func jsonSnapshotStoreReportsMissingCurrentAsUnknown() throws { + let store = JSONSnapshotStore(rootDirectory: try temporaryDirectory()) + + let result = store.loadCurrent() + + #expect(result.snapshot == nil) + #expect(result.status == .unknown) +} + +@Test func jsonSnapshotStoreReportsCorruptCurrentAsFailureWithoutThrowing() throws { + let root = try temporaryDirectory() + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try Data("not json".utf8).write(to: root.appending(path: "current-snapshot.json")) + + let result = JSONSnapshotStore(rootDirectory: root).loadCurrent() + + #expect(result.snapshot == nil) + #expect(result.status == .failure) + #expect(result.errorMessage?.isEmpty == false) +} + +@Test func jsonSnapshotStoreAppliesStalenessPolicy() throws { + let root = try temporaryDirectory() + let store = JSONSnapshotStore(rootDirectory: root) + let savedAt = Date(timeIntervalSince1970: 100) + try store.save(StoredUsageSnapshot(savedAt: savedAt, snapshot: UsageSnapshot( + generatedAt: savedAt, + limits: [usageLimit(provider: .google, accountID: "g", used: 20, savedAt: savedAt)] + ))) + + let fresh = store.loadCurrent(policy: SnapshotStoreStalenessPolicy(maximumAge: 60), now: Date(timeIntervalSince1970: 120)) + let stale = store.loadCurrent(policy: SnapshotStoreStalenessPolicy(maximumAge: 60), now: Date(timeIntervalSince1970: 200)) + + #expect(fresh.status == .healthy) + #expect(stale.status == .stale) +} + +@Test func jsonSnapshotStoreRejectsUnsupportedSchema() throws { + let root = try temporaryDirectory() + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let current = root.appending(path: "current-snapshot.json") + let json = #""" + { + "schemaVersion": 99, + "savedAt": "1970-01-01T00:00:00Z", + "snapshot": { "generatedAt": "1970-01-01T00:00:00Z", "limits": [] }, + "reports": [] + } + """# + try Data(json.utf8).write(to: current) + + let result = JSONSnapshotStore(rootDirectory: root).loadCurrent() + + #expect(result.status == .failure) + #expect(result.errorMessage?.contains("Unsupported snapshot schema") == true) +} + +@Test func storedProviderReportRedactsErrorMessages() { + let report = ProviderConnectorReport( + provider: .openAI, + accountID: "local", + accountName: "OpenAI", + generatedAt: Date(timeIntervalSince1970: 0), + limits: [], + status: .failure, + errorMessage: "failed for user@example.com with bearer sk-secret" + ) + + let stored = StoredProviderReport(report: report) + + #expect(stored.errorMessage?.contains("user@example.com") == false) + #expect(stored.errorMessage?.contains("sk-secret") == false) +} + +private func usageLimit(provider: Provider, accountID: String, used: Int, savedAt: Date) -> UsageLimit { + UsageLimit( + provider: provider, + accountID: accountID, + accountName: accountID, + label: "usage", + unit: .percent, + used: used, + limit: 100, + resetsAt: savedAt.addingTimeInterval(3_600), + lastUpdatedAt: savedAt, + confidence: .observed + ) +} + +private func temporaryDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appending(path: "context-panel-tests") + .appending(path: UUID().uuidString, directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url +} diff --git a/Tests/ContextPanelCoreTests/UsageLimitTests.swift b/Tests/ContextPanelCoreTests/UsageLimitTests.swift index ec05f1f..fa3f369 100644 --- a/Tests/ContextPanelCoreTests/UsageLimitTests.swift +++ b/Tests/ContextPanelCoreTests/UsageLimitTests.swift @@ -15,6 +15,98 @@ import Testing #expect(limit.usageRatio == 1) } +@Test func unknownLimitsHaveUnknownStatus() { + let limit = UsageLimit( + provider: .google, + accountID: "google-personal", + accountName: "Personal", + label: "Gemini Pro", + used: nil, + limit: nil, + confidence: .unknown + ) + + #expect(limit.remaining == nil) + #expect(limit.usageRatio == nil) + #expect(limit.status == .unknown) +} + +@Test func snapshotSortsMostConstrainedFirst() { + let snapshot = UsageSnapshot( + generatedAt: Date(), + limits: [ + UsageLimit( + provider: .google, + accountID: "google-work", + accountName: "Work", + label: "Gemini Deep Research", + used: nil, + limit: nil, + confidence: .unknown, + statusOverride: .failure + ), + UsageLimit( + provider: .openAI, + accountID: "openai-team", + accountName: "Team", + label: "Image generation", + used: 49, + limit: 50, + confidence: .observed + ) + ] + ) + let first = snapshot.mostConstrainedLimits.first + + #expect(first?.label == "Image generation") + #expect(abs(snapshot.aggregateCapacityRatio - 0.02) < 0.0001) +} + +@Test func aggregateCapacityUsesTightestTrackedWindow() { + let snapshot = UsageSnapshot( + generatedAt: Date(), + limits: [ + UsageLimit(provider: .openAI, label: "Weekly", used: 95, limit: 100), + UsageLimit(provider: .openAI, label: "5-hour", used: 5, limit: 100), + ] + ) + + #expect(abs(snapshot.aggregateCapacityRatio - 0.05) < 0.0001) +} + +@Test func usageLimitSeparatesWindowAndModelLabels() { + let limit = UsageLimit( + provider: .openAI, + accountID: "openai-personal", + accountName: "Personal", + label: "Codex 5-hour", + windowLabel: "5-hour", + modelLabel: "Codex", + used: 42, + limit: 100 + ) + + #expect(limit.displayLabel == "5-hour") + #expect(limit.contextLabel == "Codex ยท Personal") +} + @Test func providersCoverInitialScope() { #expect(Provider.allCases == [.openAI, .anthropic, .google]) } + +@Test func usageLimitCanRepresentPercentPressure() { + let limit = UsageLimit( + provider: .openAI, + accountID: "openai-personal", + accountName: "Personal", + label: "Codex weekly", + unit: .percent, + used: 38, + limit: 100, + confidence: .official + ) + + #expect(limit.unit == .percent) + #expect(limit.remaining == 62) + #expect(limit.usageRatio == 0.38) +} diff --git a/Tests/ContextPanelCoreTests/WidgetSnapshotTests.swift b/Tests/ContextPanelCoreTests/WidgetSnapshotTests.swift new file mode 100644 index 0000000..53be3a9 --- /dev/null +++ b/Tests/ContextPanelCoreTests/WidgetSnapshotTests.swift @@ -0,0 +1,69 @@ +import Foundation +import Testing + +@testable import ContextPanelCore + +@Test func widgetSnapshotUsesSetupNeededForMissingStore() { + let widget = WidgetSnapshot.fromStore( + SnapshotStoreLoadResult(snapshot: nil, status: .unknown), + now: Date(timeIntervalSince1970: 0) + ) + + #expect(widget.state == .setupNeeded) + #expect(widget.status == .unknown) + #expect(widget.limits.isEmpty) + #expect(widget.message.contains("Set up")) +} + +@Test func widgetSnapshotPreservesStaleCachedLimits() { + let savedAt = Date(timeIntervalSince1970: 100) + let stored = StoredUsageSnapshot(savedAt: savedAt, snapshot: UsageSnapshot( + generatedAt: savedAt, + limits: [UsageLimit(provider: .openAI, label: "Codex", used: 20, limit: 100)] + )) + + let widget = WidgetSnapshot.fromStore( + SnapshotStoreLoadResult(snapshot: stored, status: .stale), + now: Date(timeIntervalSince1970: 1_000) + ) + + #expect(widget.state == .stale) + #expect(widget.limits.count == 1) + #expect(widget.message == "Last snapshot is stale.") +} + +@Test func widgetSnapshotBuildsProviderSummaries() { + let savedAt = Date(timeIntervalSince1970: 100) + let stored = StoredUsageSnapshot(savedAt: savedAt, snapshot: UsageSnapshot( + generatedAt: savedAt, + limits: [ + UsageLimit(provider: .openAI, label: "Codex", used: 85, limit: 100), + UsageLimit(provider: .google, label: "Gemini", used: 10, limit: 100), + ] + )) + + let widget = WidgetSnapshot.fromStore(SnapshotStoreLoadResult(snapshot: stored, status: .healthy)) + let summaries = Dictionary(uniqueKeysWithValues: widget.providerSummaries.map { ($0.provider, $0) }) + + #expect(widget.state == .ready) + #expect(summaries[.openAI]?.status == .close) + #expect(summaries[.openAI]?.limitCount == 1) + #expect(summaries[.google]?.status == .healthy) + #expect(summaries[.anthropic]?.limitCount == 0) +} + +@Test func providerSummariesUseTheTightestWindowInsteadOfAverageCapacity() { + let savedAt = Date(timeIntervalSince1970: 100) + let stored = StoredUsageSnapshot(savedAt: savedAt, snapshot: UsageSnapshot( + generatedAt: savedAt, + limits: [ + UsageLimit(provider: .openAI, label: "Weekly", used: 95, limit: 100), + UsageLimit(provider: .openAI, label: "5-hour", used: 5, limit: 100), + ] + )) + + let widget = WidgetSnapshot.fromStore(SnapshotStoreLoadResult(snapshot: stored, status: .healthy)) + let openAI = widget.providerSummaries.first { $0.provider == .openAI } + + #expect(abs((openAI?.capacityRatio ?? 0) - 0.05) < 0.0001) +} diff --git a/docs/README.md b/docs/README.md index 15ea28f..3fb5baf 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,4 +3,8 @@ - [Agent Notes](../AGENTS.md) - [Product Goals](product-goals.md) - [Architecture](architecture.md) +- [Design Direction](design-direction.md) +- [Local Limit Probe Design](local-limit-probe.md) +- [Provider Usage Access Research](provider-usage-access.md) +- [macOS Release Path](release.md) - [Repository Settings](repo-settings.md) diff --git a/docs/architecture.md b/docs/architecture.md index c262388..4da0aa9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -18,6 +18,54 @@ Context Panel is expected to split into a few native boundaries: The first committed code lives in `ContextPanelCore` so provider, account, and UI work can share the same vocabulary from the start. +## Connector Runtime + +`ContextPanelCore` owns the provider connector contract. A connector refreshes +one or more configured local accounts and returns a `ConnectorRefreshResult`, +which carries provider/account reports plus a normalized `UsageSnapshot` for UI +and storage code. + +MVP connectors: + +- `CodexRateLimitConnector`: reads Codex-style auth roots such as `~/.code` or + `~/.codex`, calls the live Codex usage endpoint, and normalizes primary, + secondary, and additional percent-window buckets. +- `GeminiCodeAssistConnector`: reads Gemini CLI OAuth credentials, uses + explicitly supplied OAuth client inputs, resolves the active Code Assist + project internally, and normalizes model quota buckets as percent pressure. +- `ClaudeLocalStatusConnector`: runs `claude auth status --json` and summarizes + `~/.claude/stats-cache.json`; live personal subscription allowance remains + unknown unless a clean provider signal appears. + +Connector implementations must keep secrets out of normalized state. Do not +persist or print tokens, account IDs, project IDs, organization IDs, emails, +headers, or raw response bodies. Errors should mention status and operation but +not provider response content. + +## Snapshot Store + +The MVP cache is a local JSON store. It writes one current snapshot file plus a +history directory of timestamped snapshots. The schema is intentionally simple: +`StoredUsageSnapshot` includes a schema version, save time, normalized +`UsageSnapshot`, and redacted provider refresh reports. + +The widget should read `current-snapshot.json` and apply a staleness policy. It +must not read provider credential files or make provider network calls. The app +owns connector refreshes, account setup, diagnostics, and future migration from +JSON to a richer store if history queries become more complex. + +The WidgetKit implementation uses a `WidgetSnapshot` projection from the stored +snapshot. That projection owns setup-needed, stale, failure, provider-summary, +and most-constrained row selection so the widget view stays read-only and small. + +## Account Configuration + +The MVP account configuration is also local JSON. It stores account labels, +enabled/disabled state, connector kind, and local paths or command names needed +to locate provider CLI auth. It does not store provider secrets. Gemini OAuth +client inputs are referenced by environment variable names so the values can +remain outside the repository and outside the account config file. + Widget interactions should keep the widget simple. Tapping the widget should open the app to the relevant provider or account detail; mutation and setup stay inside the app. diff --git a/docs/design-direction.md b/docs/design-direction.md new file mode 100644 index 0000000..b3cec85 --- /dev/null +++ b/docs/design-direction.md @@ -0,0 +1,100 @@ +# Design Direction + +Last updated: 2026-05-05. + +## Accepted Direction + +Use **Quiet Instrument** as the default Context Panel visual direction. + +The widget should feel like a calm Mac status instrument, not a billing +dashboard. It should answer the user's immediate question first: can I keep +working, and which account is most constrained? + +Adopt **Concept A - Instrument** as the primary widget direction: + +- Small widget: answer-first verdict, tightest account/model, capacity indicator, + provider mini-status, and nearest reset. +- Medium widget: overall verdict/dial plus three or four most constrained rows. +- Large widget: provider groups, six to eight account/model rows, compact trend, + refresh/stale state, and reset summary. + +Use **Concept B - Ledger** as the dense-list treatment inside the app and as a +fallback large-widget direction if the Instrument layout cannot fit realistic +multi-account data. + +## Visual System + +- True neutral gray surfaces, tuned separately for light and dark appearances. +- One swappable accent color; default accent is a restrained slate blue. +- Subtle status tints. Avoid alarm-heavy red as the dominant state language. +- Provider identity should use short text badges plus labels, not abstract + shapes or provider logos as the only hierarchy. +- Status must be communicated by color plus nearby text for color-vision safety. +- Widgets are read-only and deep-link into the app for setup and detail. +- Failure and stale states isolate to the affected account or provider; never + blank the whole widget when neighboring data is still valid. + +## Native App Shape + +The first app window should use a native macOS split-view structure: + +- Sidebar: provider/account groups, account status, and setup entry points. +- Detail: selected account/provider limits, reset timing, trend, forecast, and + refresh history. +- Inspector: normalized raw limits, confidence, provider connection state, and + troubleshooting. + +The app is where mutation lives: adding logins, naming accounts, disabling or +removing accounts, refreshing, calibration, and credential/privacy messaging. + +## Component Map + +Translate the design artifact into native SwiftUI/WidgetKit components instead +of copying the React implementation: + +- `CapacityDial`: ring/dial for overall or account capacity. +- `CapacityBar`: compact account/model capacity bar. +- `ProviderBadge`: provider short-name text badge. +- `StatusMark`: compact status marker paired with text. +- `AccountRow`: reusable account/model row for widgets and app detail. +- `ContextWidget`: WidgetKit configuration for small, medium, and large layouts. +- `WidgetTimelineProvider`: timeline backed by cached local snapshots and + last-good stale state. +- `AppRoot`: SwiftUI app shell with `NavigationSplitView`. + +Keep colors, spacing, radius, typography, material, and status semantics in a +native theme layer rather than scattering raw values through views. + +## State Coverage + +Required states for design and implementation: + +- Healthy/default. +- Close to limit. +- Limited or exceeded. +- Stale data with last-good timestamp. +- Unknown limit without implying zero capacity. +- Provider or account refresh failure. +- Loading/refreshing with last-known values preserved. +- Empty/first run setup state. +- Dense multi-account data. + +## Implementation Notes + +- Use native `.system` typography and SF Mono for numeric/tabular data. Web fonts + in the design artifact are preview stand-ins only. +- Tune dark-mode contrast in SwiftUI; do not blindly trust exported CSS values. +- Verify large-widget row density with realistic data before committing to six to + eight visible rows. +- Prefer answer-first small widget copy, with the tightest account as supporting + text. +- Default provider ordering can be by constraint/tightness for the widget, while + the app can support stable user/provider grouping. + +## Design Artifact + +The external design export was delivered as `/Users/cbusillo/Downloads/Context Panel.zip`. +It includes React/HTML files for review only. The local browser export did not +render under the current helper because the CDN-backed local HTML stayed blank, +so visual implementation should use the handoff notes and source structure, then +be validated again once native SwiftUI/WidgetKit views exist. diff --git a/docs/local-limit-probe.md b/docs/local-limit-probe.md new file mode 100644 index 0000000..2a3753e --- /dev/null +++ b/docs/local-limit-probe.md @@ -0,0 +1,197 @@ +# Local Limit Probe Design + +Last updated: 2026-05-05. + +## Goal + +Context Panel needs to know whether subscription limits are exposed anywhere a +logged-in user can legitimately see them. The first target is OpenAI ChatGPT +subscription usage: weekly limits, short rolling windows, reset times, model +availability, and any percent or token pressure signals. If the approach works +for OpenAI, the same diagnostic shape can be reused for Claude and Gemini. + +The probe is a local diagnostic tool, not a production data integration. It +should help answer: + +- Does the provider expose subscription limits in visible UI text? +- Does the provider expose subscription limits in browser-accessible structured + responses after a normal login? +- Which observations are safe and reliable enough to become Context Panel + signals? +- Which observations should stay manual/calibrated because the provider hides or + changes them? + +## Safety Rules + +- The user logs in directly with the provider. Context Panel never asks for or + stores the provider password. +- The probe never prints, commits, uploads, or logs cookies, bearer tokens, + session IDs, full response bodies, or account identifiers. +- Captured artifacts are local and gitignored by default. +- Raw network/body capture is opt-in and redacted before display. +- The default probe reports only route/method/status/content-type/body-size plus + detected field names or text snippets that match usage-limit patterns. +- Automated message sending is out of scope. The probe observes login/account + pages and model picker state; it does not burn subscription allowance. + +## Recommended Implementation + +Build a local macOS diagnostic surface called **Limit Probe** inside the +companion app or as a development-only target. + +For the first implementation, prefer a native `WKWebView`-based probe because it +lets the user log in normally while keeping the session isolated from Safari or +Chrome. Later, a browser-extension or browser-control probe can be considered if +WKWebView cannot observe enough. + +### Flow + +1. User opens `Limit Probe`. +2. User selects provider: OpenAI first, then Anthropic, then Google. +3. App opens an isolated `WKWebView` at the provider's normal product URL. +4. User logs in normally. +5. Probe shows a checklist: + - Login detected. + - Model picker/account UI reachable. + - Limit/reset text detected. + - Candidate structured responses detected. + - Manual observation needed. +6. User navigates to the relevant UI, such as ChatGPT model picker. +7. Probe scans visible text and sanitized network metadata for usage/reset + signals. +8. User can press `Record Observation` to save a sanitized event. +9. User can press `Export Redacted Report` for a local Markdown/JSON report. + +### OpenAI Targets + +Initial OpenAI pages and states to inspect: + +- ChatGPT app after login. +- Model picker when GPT-5/GPT-5.5/Thinking modes are available. +- Provider UI when a model is close to its limit. +- Provider UI when a model is unavailable or limit-reached. +- Account or plan surfaces that mention current plan and reset. + +Detection patterns: + +- `reset`, `resets`, `refresh`, `available`, `limit`, `usage`, `percent`, + `tokens`, `weekly`, `every 3 hours`, `every 5 hours`, `Thinking`, `fast`, + `temporary`. +- Dates and relative durations such as `tomorrow`, `in 42m`, `3h`, `5 hours`, + `7 days`, `weekly`. +- JSON field names containing `limit`, `usage`, `used_percent`, `token`, + `remaining`, `reset`, `cap`, `quota`, `model`, or `plan`. + +## Capture Model + +```swift +struct LimitProbeObservation: Codable, Sendable { + var provider: Provider + var accountLabel: String? + var surface: String + var observedAt: Date + var source: ProbeSource + var signal: ProbeSignal + var confidence: UsageConfidence + var sanitizedEvidence: String +} + +enum ProbeSource: String, Codable, Sendable { + case visibleText + case networkMetadata + case redactedResponseShape + case manualUserEntry +} + +enum ProbeSignal: Codable, Sendable { + case resetTime(Date) + case relativeReset(seconds: TimeInterval) + case knownLimit(used: Int?, limit: Int, unit: String) + case modelAvailable(model: String) + case modelUnavailable(model: String, reason: String?) + case plan(name: String) + case unknownLimit +} +``` + +## Sanitized Network Probe + +`WKWebView` does not expose every network body through public APIs. There are +three possible tiers: + +1. **Visible text only**: safest and easiest. JavaScript reads `document.body` + text after login and extracts matching snippets. +2. **JavaScript fetch instrumentation**: inject a user script that wraps + `window.fetch` and `XMLHttpRequest` to record sanitized URL path, method, + status, content type, body size, and matching field names from JSON responses. + Do not store headers or raw bodies. +3. **External browser-control/devtools probe**: use a separate development-only + probe with browser automation/DevTools if WKWebView cannot see enough. This + remains local and diagnostic-only. + +Start with tier 1 and tier 2. Escalate only if OpenAI hides the useful signal +from visible text and simple response-shape inspection. + +## Report Shape + +The exported report should be local and safe to share in a PR or issue after +review: + +```json +{ + "schema_version": 1, + "provider": "openai", + "captured_at": "2026-05-05T00:00:00Z", + "surfaces": [ + { + "surface": "chatgpt-model-picker", + "signals": [ + { + "source": "visibleText", + "signal": "relativeReset", + "evidence": "resets in 3h", + "confidence": "observed" + } + ] + } + ], + "candidate_network_shapes": [ + { + "method": "GET", + "path_hint": "/.../models/...", + "status": 200, + "content_type": "application/json", + "matched_fields": ["model", "limit", "reset"] + } + ], + "redactions": [ + "cookies", + "authorization headers", + "account ids", + "emails", + "raw response bodies" + ] +} +``` + +## Acceptance Criteria For A Prototype + +- User can log in to OpenAI in an isolated local web view. +- Probe can scan visible UI text for limit/reset signals without storing + secrets. +- Probe can record a manual observation when the UI exposes reset or limit state. +- Probe can show whether any structured candidate responses appear, without + revealing raw response bodies or tokens. +- Probe writes only redacted local artifacts under a gitignored directory. +- Findings can update `docs/provider-usage-access.md` with evidence and + confidence. + +## Open Questions + +- Does ChatGPT's login flow work reliably in `WKWebView`, or does it require the + user's default browser? +- Does the model picker expose useful reset text before a limit is reached? +- Can visible text reveal enough to calibrate weekly Thinking limits without + network inspection? +- Are subscription limit signals account-specific enough to distinguish multiple + OpenAI accounts cleanly? diff --git a/docs/provider-usage-access.md b/docs/provider-usage-access.md new file mode 100644 index 0000000..3ebb7aa --- /dev/null +++ b/docs/provider-usage-access.md @@ -0,0 +1,308 @@ +# Provider Usage Access Research + +Last verified: 2026-05-06. + +## Summary + +Context Panel should model two different worlds: + +- API/provider-console usage, where official usage APIs, cost APIs, quota APIs, + rate-limit headers, or Cloud Monitoring data can provide real measurements. +- Consumer chat subscription usage, where providers often expose limits and reset + timing in product UI but do not expose a stable public API for the underlying + percent or token pressure. + +The OpenAI account use case needs special treatment. For ChatGPT-style weekly +subscription limits, current product surfaces have moved away from a visible +message counter. Context Panel should model OpenAI usage as percent or token +pressure over reset windows: multiple OpenAI accounts, reset windows, observed +usage pressure, burn-rate history, and a clear answer to "am I safe to turn on +fast mode?" + +## Forecast Requirement + +The app should answer these questions for each account and across all enabled +accounts: + +- How much usable allowance remains before reset? +- At my current pace, will I run out before the weekly reset? +- If I turn on fast mode now, how long can I leave it on safely? +- Which account should I use next if one account is close to its limit? + +Recommended model: + +- `LimitWindow`: provider, account, bucket, unit, limit, used or remaining, + reset time, reset policy, and confidence. +- `UsageObservation`: sampled value, source, timestamp, and confidence. +- `BurnProfile`: observed standard-mode and fast-mode usage rate over recent + windows. +- `Forecast`: projected use through reset, estimated runway, recommended mode, + and confidence. + +Recommended safe-mode rule: + +```text +safe_fast_mode = projected_fast_usage_until_reset + reserve <= remaining +``` + +Where `reserve` is a user-configurable safety buffer. The widget should say +"safe", "safe for about N hours", "save fast mode", or "needs calibration" +instead of pretending an estimate is exact. + +## Provider Matrix + +| Provider surface | Official data available | Reset/limit signal | Multi-login shape | V1 recommendation | Confidence | +| --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| OpenAI API organizations | Usage API, Costs API, and rate-limit headers. Usage can be grouped by project, user, API key, model, batch, and service tier depending on endpoint. | API rate limits expose remaining requests/tokens and reset headers; monthly usage limits are organization/project concerns. | One connected API organization/project per credential. Multiple credentials/accounts should be supported. | Support API org usage as an official adapter using admin or sufficiently privileged API keys. | High | +| OpenAI ChatGPT accounts | No stable public API found for general personal ChatGPT subscription pressure outside Codex. Current product surfaces no longer present a simple message counter; the useful automated signal found so far is percent-used pressure for Codex/Fast Mode. | Weekly and short rolling reset windows matter. Codex/Fast Mode exposes live percent-used windows through the Codex backend usage endpoint. | Multiple ChatGPT accounts are core. Each account needs its own reset window, plan, mode, percent/token pressure, and local observation history. | For Codex/Fast Mode, use the live Codex usage endpoint. For non-Codex ChatGPT surfaces, keep manual/assisted observations and forecast confidence until a clean provider signal exists. | High for Codex percent windows; medium for visible reset clues; low for non-Codex automation | +| Anthropic API organizations | Usage and Cost API can report message usage and costs by time bucket, model, workspace, API key, service tier, context window, geo, and beta fast-mode speed. API responses include rate-limit headers with remaining and reset values. | API rate limits use token bucket behavior; monthly spend limits exist by tier. | Organization/workspace/API-key credentials. Multiple organizations and workspaces should be supported. | Support official API usage/cost adapter. Capture fast-mode dimensions where available. | High | +| Claude subscriptions and Claude Code seats | Claude Code status-line JSON can include `rate_limits.five_hour` and `rate_limits.seven_day` for Claude.ai Pro and Max subscribers after a session receives an API response. Claude Code auth status exposes login method and subscription type; local stats cache exposes historical local usage only. Non-interactive `claude -p --output-format stream-json --verbose` can emit `rate_limit_info` with status, active window type, and reset time, but no used percentage was observed. `ccusage blocks --json --offline` can derive active 5-hour block token use and runway from local aggregate session data used by Every Code. | Status-line rate-limit windows include used percentage and reset epochs. Pro/Max/Team usage has session-based reset behavior. `ccusage` estimates token pressure and reset/runway rather than official server percent. | Multiple Claude accounts/seats are possible, but account connection should be conservative. | Support a local Claude status connector for account metadata, local activity freshness, optional status-line rate-limit cache, and an explicit Every Code-compatible `ccusage` estimate. Mark old status-line readings stale, and label `ccusage` rows as estimated. Do not read auth, Keychain, raw transcripts, prompts, or conversation JSONL directly. | High for fresh status-line subscription windows when configured; medium for non-interactive reset/status metadata; medium for `ccusage` estimated runway; medium for auth/subscription metadata and local history | +| Google Gemini CLI / Code Assist | Gemini CLI OAuth credentials can be refreshed locally, then the Code Assist backend returns live quota buckets with model IDs, remaining fractions, optional remaining amounts, and reset times. | Quota buckets are percent-style remaining fractions per model with provider reset timestamps. | Google account plus Code Assist project is the natural boundary; multiple `GEMINI_CLI_HOME` roots can represent multiple logins. | Support a Gemini Code Assist live quota connector using Gemini CLI auth. Store only normalized percent pressure and reset times. | High for Gemini CLI/Code Assist buckets observed locally | +| Google Gemini API / Google AI Studio projects | AI Studio and Cloud Billing show usage. Gemini API rate limits are project-scoped, not API-key-scoped. Service Usage API lists quota limits; Cloud Monitoring exposes quota usage metrics; Cloud Billing export to BigQuery provides detailed cost/usage data. | Rate limits are RPM, input TPM, and RPD, with model/tier variation. RPD quotas reset at midnight Pacific time. | Google project is the natural account boundary. Multiple Google accounts/projects should be supported. | Support Google API projects after OAuth/service-account design. Use Service Usage for limits, Cloud Monitoring for quota usage, and optional Billing export for cost history. | Medium-high, but setup is heavier | +| Google consumer Gemini app subscriptions | No stable public API for personal Gemini app subscription allowance was found in this pass. | Provider UI likely remains source of truth. | Multiple Google accounts may matter, but automation risk is high. | Defer for v1 unless a supported API emerges. | Low | + +## OpenAI Fast-Mode Forecasting + +For the user's immediate OpenAI need, the best product path is a live Codex +percent-window connector backed by an honest local predictor: + +1. Add each OpenAI account separately. +2. Record plan and relevant buckets, such as Codex/Fast Mode weekly and + five-hour windows. +3. Fetch current percent-used pressure and reset times when the Codex endpoint + is available. +4. Capture reset time from the UI when no endpoint signal exists, or let the + user enter it. +5. Start a local usage ledger from the moment Context Panel is installed. +6. Estimate standard and fast-mode burn rates in percent or tokens per hour + from local usage history. +7. Recommend when to enable fast mode only when the forecast has enough margin. + +The widget should make confidence visible. Good copy examples: + +- `Fast mode looks safe through reset.` +- `Fast mode safe for about 2h, then switch back.` +- `Save fast mode: projected to run out 18h before reset.` +- `Needs calibration: open ChatGPT and set reset time.` + +## Local Probe And Every Code Evidence + +The first OpenAI Limit Probe run confirmed the uncomfortable but useful shape of +the problem: + +- ChatGPT visible text exposed plan/model language such as model names, `Pro`, + `Instant`, and `Thinking`, but did not expose a percent/token counter or a + reset time before exhaustion. +- Sanitized network response-shape scanning found account entitlement and plan + fields, including subscription-plan style field names, but no obvious + `used_percent`, token pressure, `reset_at`, weekly allowance, or five-hour + allowance fields outside the Codex usage surface. +- The probe should remain useful as a diagnostic harness because it can detect + if OpenAI later starts exposing cleaner fields, and it can produce redacted + evidence across multiple accounts. + +Codex-family tooling exposes a stronger path for Codex/Fast Mode. Upstream +Codex CLI has an app-server method, `account/rateLimits/read`, backed by a +backend client call that fetches live snapshots from the ChatGPT Codex backend: +`GET /backend-api/wham/usage` for ChatGPT-backed auth, or `/api/codex/usage` for +Codex API-style deployments. The payload maps into rate-limit snapshots with +provider window buckets, reset times, plan type, credits, reached-limit +classification, and additional buckets keyed by `limit_id`. + +Every Code is useful as a fallback and validation source. It does not derive +Codex rate-limit snapshots from local token counts. It sends authenticated +requests to the ChatGPT Codex backend, parses server-reported `x-codex-*` +response headers into percentage and reset-window snapshots, and persists the +latest server snapshot under local usage files. The local files are a cache of +server state plus local token history, which explains why displayed limit +pressure reflects cloud and other-machine usage for the same account. + +Every Code also has a deliberate refresh path: it sends a tiny `"ok"` prompt via +the selected account, waits for a `RateLimits` event from response headers, then +persists the snapshot and updates the `/limits` UI. Separately, when the backend +returns `usage_limit_reached`, it records `plan_type`, `resets_in_seconds`, and +the reached-limit type as a hint. + +That is stronger evidence than visible ChatGPT UI scraping for Codex-style +limits, but it is still product-surface-specific. Context Panel should separate +`OpenAI ChatGPT product UI hints` from `OpenAI Codex backend percent windows`. +The latter looks viable as an automated adapter if Context Panel can reuse the +same authenticated account flow safely. + +Implication: v1 should not promise exact general ChatGPT subscription counters. +For Codex/Fast Mode, though, the preferred path is a live OpenAI Codex limits +connector using the same shape as Codex CLI's +`account/rateLimits/read`/`get_rate_limits_many()` flow. If that cannot be made +stable or safely testable, fall back to Every Code's local `usage/*.json` cache +or Codex CLI's app-server request. + +### Codex Limits Connector + +Preferred v1 connector scope: + +- Fetch live Codex limits directly from the Codex backend usage endpoint shape: + `GET https://chatgpt.com/backend-api/wham/usage` for ChatGPT-backed auth. +- Support provider window buckets, reset times, plan type, credits, + reached-limit classification, and additional `limit_id` buckets. +- Keep auth handling isolated and redacted; never log tokens, cookies, + authorization headers, account IDs, emails, or raw response bodies. +- Expose a diagnostic probe that reports only sanitized structure, percentages, + reset timing, bucket labels, and staleness. +- Mark this as an OpenAI Codex/Fast Mode percent-window source, not a general + ChatGPT subscription counter. +- Do not require the Codex CLI binary or app server at runtime. The local + `CodexRateLimitProbe` executable exists to prove the direct call path against + an existing Codex `auth.json` while printing only redacted summaries. + +### Gemini Code Assist Connector + +The local Gemini CLI path gives Context Panel a second viable live connector. +The CLI stores OAuth credentials under `~/.gemini/oauth_creds.json`, while the +active account metadata lives separately under `~/.gemini/google_accounts.json`. +The quota values are not persisted as a durable local cache; Gemini CLI keeps +quota state in memory and refreshes it from the Code Assist backend. + +Preferred v1 connector scope: + +- Resolve `GEMINI_CLI_HOME`, then default to `~/.gemini`. +- Read `oauth_creds.json` only to refresh an access token locally; never print, + store, or upload token values. +- Call the Gemini Code Assist load path to resolve the active project internally; + never print or persist the raw project identifier. +- Call the Gemini Code Assist quota path and normalize buckets by model ID, + remaining fraction, optional remaining amount, and reset time. +- Represent each bucket as percent pressure: `used = round((1 - remaining) * +100)`, `limit = 100`, `unit = percent`. +- Mark confidence as observed because this is a product backend surface rather + than a public quota API contract. + +The local `GeminiQuotaProbe` executable proves this path with redacted output. +On 2026-05-06 it returned seven live model buckets for the local Gemini CLI +account, including Gemini 2.5 and Gemini 3 preview models, with percent +remaining and reset timestamps. + +### Claude Subscription Connector + +Claude subscription pressure should use Claude Code's supported status-line JSON +surface, not Anthropic API organization usage. Claude Code's status-line input +can contain `rate_limits.five_hour.used_percentage`, +`rate_limits.five_hour.resets_at`, `rate_limits.seven_day.used_percentage`, and +`rate_limits.seven_day.resets_at` for Claude.ai Pro and Max subscribers after a +Claude Code session receives an API response. + +This status-line surface is currently interactive-session scoped. On +2026-05-06, a local non-interactive probe using `claude -p "Reply with exactly +OK." --output-format json --verbose` emitted a `rate_limit_event` with +`rate_limit_info.status`, `rateLimitType`, and `resetsAt`, but did not emit +`used_percentage` and did not refresh the configured status-line cache. A local +`claude -p "/usage" --output-format json --verbose` probe returned only that the +Claude Code subscription was in use, not the five-hour or weekly usage +percentages. This means Every Code's current external `claude -p` agent path +does not by itself provide official subscription percent pressure. + +Local binary/bundle inspection found runtime strings for +`anthropic-ratelimit-unified-*`, `utilization`, `five_hour`, and `seven_day` in +Claude Code/Desktop surfaces. It also found the Claude web/desktop usage hook: +the installed Claude app calls +`GET /api/organizations/{active_organization_uuid}/usage` and refreshes it on a +five-minute interval from the settings usage page. The usage page component is +what renders "Claude subscription usage", current-session/five-hour usage, and +weekly limit rows. + +That endpoint is the strongest direct subscription API candidate found so far, +but it is authenticated through the Claude web/app session. A local automated +browser probe against `https://claude.ai/settings/usage` on 2026-05-06 was +blocked by Cloudflare before login/session reuse, so Context Panel has not yet +proven it can call this endpoint without a user-visible web login context. We +should not extract browser cookies, Keychain credentials, OAuth tokens, local +storage, raw response bodies, transcripts, account UUIDs, or emails to force the +call. The next safe implementation path is a Claude web usage probe that runs in +a user-visible embedded web session and records only sanitized fields such as +`five_hour`, `seven_day`, `used_percentage`, `remaining_percentage`, +`utilization`, and `resets_at`. + +The local `ClaudeWebUsageProbe` executable implements that path. It opens +Claude's usage page in a visible WebKit session, lets the user complete login or +Cloudflare verification normally, observes only `/api/organizations/*/usage` +responses, and reduces the page response to whitelisted usage windows before +Swift receives anything. Saving from the probe writes normalized percent/reset +rows to Context Panel's snapshot store; it does not persist cookies, +authorization headers, tokens, local storage, account UUIDs, organization UUIDs, +emails, or raw response bodies. + +No safe persisted local Claude Desktop file/cache containing official +subscription percentages was found. The remaining research target is an +explicit, privacy-safe metadata capture path for Every Code/non-interactive +usage, tracked separately in issue #19. + +The official Claude Code authentication docs say macOS credentials are stored in +the encrypted macOS Keychain. Context Panel must not read Keychain secrets or +try to extract subscription OAuth tokens. + +Preferred v1 connector scope: + +- Call `claude auth status --json` and keep only non-secret fields such as + `loggedIn`, `authMethod`, `apiProvider`, and `subscriptionType`. +- Offer a tiny status-line helper that receives Claude Code status-line JSON on + stdin and writes only observed timestamp, five-hour percentage/reset, and + weekly percentage/reset to a Context Panel cache file. +- Read that sanitized status-line cache and normalize Claude five-hour and + weekly windows as percent limits when present. +- Read `~/.claude/stats-cache.json` only as local historical activity, not live + subscription allowance. +- Summarize local stats by freshness and counts; do not read raw transcript + JSONL files, prompts, account UUIDs, emails, organization IDs, or token blobs. +- Show Claude subscription allowance as unknown until the status-line cache has + been populated by a live Claude Code response. +- Treat `ccusage` and local token aggregates as estimated pressure only; never + present them as official Claude subscription percent used. + +The local `ClaudeLimitProbe` executable proves the conservative fallback path. +On 2026-05-06 it confirmed the local Claude CLI is logged in with subscription +metadata and has a local stats cache. The follow-up subscription path is the +sanitized status-line cache, not raw Claude auth/session data. + +### Every Code Cache Fallback + +Fallback connector scope: + +- Resolve `CODE_HOME`, then `CODEX_HOME`, then default to `~/.code`. +- Read only `$CODE_HOME/usage/*.json`. +- Never read auth files, token files, debug logs, history files, or config files + for the connector. +- Parse `rate_limit.snapshot`, `observed_at`, `primary_next_reset_at`, + `secondary_next_reset_at`, `last_usage_limit_hit_at`, and `plan`. +- Normalize Codex provider window buckets as limits with observed + confidence and freshness state. +- Mark the connector stale when `observed_at` or `last_updated` is older than a + conservative threshold; do not trigger refreshes. +- Show account IDs only as short local labels unless the user assigns names. + +## Product Decisions + +- Treat `unknown`, `manual`, `observed`, and `official` as distinct confidence + levels in the data model and UI. +- Do not block the whole widget when one provider cannot expose usage. Show stale + or estimated state for that account and keep official data for other accounts. +- Prioritize OpenAI ChatGPT forecasting in the UX even if the first automated + data source is manual/local, because it directly answers the fast-mode problem. +- Keep provider terms and account safety ahead of automation convenience. + +## Sources + +- [OpenAI Usage API reference](https://developers.openai.com/api/reference/resources/admin/subresources/organization/subresources/usage) +- [OpenAI API rate limits](https://developers.openai.com/api/docs/guides/rate-limits#usage-tiers) +- [GPT-5.3 and GPT-5.5 in ChatGPT](https://help.openai.com/en/articles/11909943-gpt-5-in-chatgpt) +- [OpenAI o3 and o4-mini usage limits](https://help.openai.com/en/articles/9824962-openai-o1and-o1-mini-usage-limits-on-chatgpt-and-the-api) +- [Anthropic Usage and Cost API](https://platform.claude.com/docs/en/build-with-claude/usage-cost-api) +- [Anthropic API rate limits](https://docs.anthropic.com/en/api/rate-limits) +- [Claude Code authentication](https://code.claude.com/docs/en/authentication) +- [Claude usage and length limits](https://support.claude.com/en/articles/11647753-how-do-usage-and-length-limits-work) +- [Models, usage, and limits in Claude Code](https://support.claude.com/en/articles/14552983-models-usage-and-limits-in-claude-code) +- [Gemini CLI authentication](https://google-gemini.github.io/gemini-cli/docs/get-started/authentication.html) +- [Gemini CLI quotas and pricing](https://google-gemini.github.io/gemini-cli/docs/quota-and-pricing.html) +- [Gemini API billing](https://ai.google.dev/gemini-api/docs/billing/) +- [Gemini API rate limits](https://ai.google.dev/gemini-api/docs/rate-limits) +- [Google Service Usage consumer quota metrics](https://cloud.google.com/service-usage/docs/reference/rest/v1beta1/services.consumerQuotaMetrics/list) +- [Google Cloud quota usage metrics](https://docs.cloud.google.com/monitoring/alerts/using-quota-metrics) +- [Cloud Billing export to BigQuery](https://cloud.google.com/billing/docs/how-to/export-data-bigquery) diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 0000000..bd1e890 --- /dev/null +++ b/docs/release.md @@ -0,0 +1,80 @@ +# macOS Release Path + +Last verified: 2026-05-06. + +Context Panel has two local packaging paths: + +- a native Xcode app target with an embedded WidgetKit extension, generated by + XcodeGen +- an interim SwiftPM wrapper script for quickly sharing the preview app without + the widget extension + +The native Xcode path is the one to use for widget installability. + +## Build The Native App And Widget + +```sh +xcodegen generate --spec project.yml +xcodebuild \ + -project ContextPanel.xcodeproj \ + -scheme ContextPanel \ + -configuration Debug \ + -destination 'platform=macOS' \ + -allowProvisioningUpdates \ + build +``` + +This builds `Context Panel.app` and embeds +`ContextPanelWidgetExtension.appex` under `Contents/PlugIns`. The app and widget +both carry the `group.com.shinycomputers.contextpanel` App Group entitlement and +share snapshots through the App Group container when it is available. + +The Xcode project is generated from `project.yml`; update the spec and +regenerate rather than editing the project by hand. + +On 2026-05-06, the Debug Xcode build succeeded locally with automatic signing, +embedded the WidgetKit extension, included the WidgetKit extension point in the +extension Info.plist, and passed `codesign --verify --deep --strict`. + +## Build A Signed App + +```sh +scripts/package-macos-app.sh --output dist --identity auto +``` + +The script builds `ContextPanelPreview` in release mode, wraps it as +`dist/Context Panel.app`, writes app metadata, signs the bundle, verifies the +signature, and runs a local Gatekeeper assessment. + +`--identity auto` asks Keychain for available code-signing identities and +prefers `Developer ID Application`, then `Apple Development`, then ad-hoc +signing. The script does not read private keys or credentials; signing is +performed by macOS Keychain through `codesign`. + +Useful variants: + +```sh +scripts/package-macos-app.sh --debug +scripts/package-macos-app.sh --identity - +scripts/package-macos-app.sh \ + --product ClaudeWebUsageProbe \ + --display-name "Claude Usage Probe" \ + --bundle-id com.shinycomputers.contextpanel.claudeprobe +``` + +## Current Constraints + +- The package is signed but not notarized by this script. +- The script's app bundle contains the SwiftPM preview app, not the native Xcode + app target. +- The script does not embed the WidgetKit extension; use the Xcode build for + widget testing. +- Full friend-installable distribution still needs archive/export and + notarization polish. + +## Validation + +On 2026-05-06, `scripts/package-macos-app.sh --output dist --identity auto` +produced `dist/Context Panel.app`, signed with Developer ID Application: +Shiny Computers Leasing LLC, and `spctl --assess --type execute` accepted the +bundle as Developer ID signed. diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..f9c3967 --- /dev/null +++ b/project.yml @@ -0,0 +1,78 @@ +--- +name: ContextPanel +options: + bundleIdPrefix: com.shinycomputers + deploymentTarget: + macOS: "14.0" +settings: + base: + DEVELOPMENT_TEAM: MM5YXC7T6E + CODE_SIGN_STYLE: Automatic + SWIFT_VERSION: "6.0" + MACOSX_DEPLOYMENT_TARGET: "14.0" +packages: {} +targets: + ContextPanelCore: + type: library.static + platform: macOS + deploymentTarget: "14.0" + sources: + - path: Sources/ContextPanelCore + settings: + base: + PRODUCT_NAME: ContextPanelCore + SKIP_INSTALL: true + + ContextPanel: + type: application + platform: macOS + deploymentTarget: "14.0" + sources: + - path: Sources/ContextPanelPreview + dependencies: + - target: ContextPanelCore + - target: ContextPanelWidgetExtension + embed: true + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.shinycomputers.contextpanel + PRODUCT_NAME: Context Panel + GENERATE_INFOPLIST_FILE: true + INFOPLIST_KEY_CFBundleDisplayName: Context Panel + MARKETING_VERSION: "1.0" + CURRENT_PROJECT_VERSION: "1" + INFOPLIST_KEY_LSApplicationCategoryType: >- + public.app-category.productivity + CODE_SIGN_ENTITLEMENTS: Config/ContextPanel.entitlements + + ContextPanelWidgetExtension: + type: app-extension + platform: macOS + deploymentTarget: "14.0" + sources: + - path: Sources/ContextPanelWidget + dependencies: + - target: ContextPanelCore + info: + path: Config/ContextPanelWidget-Info.plist + properties: + NSExtension: + NSExtensionPointIdentifier: com.apple.widgetkit-extension + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.shinycomputers.contextpanel.widget + PRODUCT_NAME: ContextPanelWidgetExtension + GENERATE_INFOPLIST_FILE: false + CODE_SIGN_ENTITLEMENTS: Config/ContextPanelWidget.entitlements + SKIP_INSTALL: true + +schemes: + ContextPanel: + build: + targets: + ContextPanel: all + ContextPanelWidgetExtension: all + run: + config: Debug + archive: + config: Release diff --git a/scripts/claude-ccusage-cache.sh b/scripts/claude-ccusage-cache.sh new file mode 100755 index 0000000..1b85566 --- /dev/null +++ b/scripts/claude-ccusage-cache.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +cache_dir="${CONTEXT_PANEL_CLAUDE_RATE_LIMIT_DIR:-$HOME/Library/Application Support/Context Panel/ClaudeRateLimits}" +cache_file="$cache_dir/ccusage-blocks-cache.json" + +mkdir -p "$cache_dir" + +if command -v ccusage >/dev/null 2>&1; then + ccusage blocks --json --offline >"$cache_file.tmp" +elif command -v bunx >/dev/null 2>&1; then + bunx ccusage@latest blocks --json --offline >"$cache_file.tmp" +else + echo "ccusage or bunx is required" >&2 + exit 127 +fi + +mv "$cache_file.tmp" "$cache_file" +echo "$cache_file" diff --git a/scripts/claude-statusline-cache.sh b/scripts/claude-statusline-cache.sh new file mode 100755 index 0000000..03348be --- /dev/null +++ b/scripts/claude-statusline-cache.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail + +input="$(cat)" +out_dir="${CONTEXT_PANEL_CLAUDE_RATE_LIMIT_DIR:-$HOME/Library/Application Support/Context Panel/ClaudeRateLimits}" +out_file="$out_dir/statusline-cache.json" +tmp_file="${out_file}.tmp" + +mkdir -p "$out_dir" + +cache_json="$(jq -c --argjson observed_at "$(date +%s)" ' + def clean_window: + select(type == "object") + | { + used_percentage: (.used_percentage // empty), + resets_at: (.resets_at // null) + }; + + { + observed_at: $observed_at, + rate_limits: { + five_hour: (.rate_limits.five_hour? | clean_window), + seven_day: (.rate_limits.seven_day? | clean_window) + } + } + | .rate_limits |= with_entries(select(.value != null)) + | select((.rate_limits | length) > 0) +' <<<"$input")" + +if [ -n "$cache_json" ]; then + printf '%s\n' "$cache_json" >"$tmp_file" + mv "$tmp_file" "$out_file" +fi + +model="$(jq -r '.model.display_name // "Claude"' <<<"$input")" +five_hour="$(jq -r '.rate_limits.five_hour.used_percentage // empty' <<<"$input")" +weekly="$(jq -r '.rate_limits.seven_day.used_percentage // empty' <<<"$input")" + +limits="" +if [ -n "$five_hour" ]; then + limits="5h: $(printf '%.0f' "$five_hour")%" +fi +if [ -n "$weekly" ]; then + limits="${limits:+$limits }7d: $(printf '%.0f' "$weekly")%" +fi + +if [ -n "$limits" ]; then + printf '[%s] | %s\n' "$model" "$limits" +else + printf '[%s]\n' "$model" +fi diff --git a/scripts/package-macos-app.sh b/scripts/package-macos-app.sh new file mode 100755 index 0000000..8e09c15 --- /dev/null +++ b/scripts/package-macos-app.sh @@ -0,0 +1,175 @@ +#!/usr/bin/env bash +set -euo pipefail + +product="ContextPanelPreview" +display_name="Context Panel" +bundle_id="com.shinycomputers.contextpanel" +configuration="release" +output_dir="dist" +signing_identity="auto" + +usage() { + cat <<'USAGE' +Usage: scripts/package-macos-app.sh [options] + +Builds a SwiftPM executable and wraps it in a launchable macOS .app bundle. + +Options: + --product NAME SwiftPM executable product. Default: ContextPanelPreview + --display-name NAME App display name. Default: Context Panel + --bundle-id ID CFBundleIdentifier. Default: com.shinycomputers.contextpanel + --debug Build debug instead of release + --output DIR Output directory. Default: dist + --identity VALUE codesign identity, "auto", or "-" for ad-hoc. Default: auto + -h, --help Show this help + +The script never reads private keys or credentials. When --identity auto is +used, it asks Keychain for signing identities and prefers Developer ID +Application, then Apple Development, then ad-hoc signing. +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --product) + product="${2:?--product requires a value}" + shift 2 + ;; + --display-name) + display_name="${2:?--display-name requires a value}" + shift 2 + ;; + --bundle-id) + bundle_id="${2:?--bundle-id requires a value}" + shift 2 + ;; + --debug) + configuration="debug" + shift + ;; + --output) + output_dir="${2:?--output requires a value}" + shift 2 + ;; + --identity) + signing_identity="${2:?--identity requires a value}" + shift 2 + ;; + -h | --help) + usage + exit 0 + ;; + *) + echo "unknown option: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +build_args=(--product "$product") +if [[ "$configuration" == "release" ]]; then + build_args+=(--configuration release) +fi + +swift build "${build_args[@]}" + +bin_path_args=(--show-bin-path) +if [[ "$configuration" == "release" ]]; then + bin_path_args+=(--configuration release) +fi +triple=$(swift build "${bin_path_args[@]}" 2>/dev/null || true) +if [[ -z "$triple" ]]; then + if [[ "$configuration" == "release" ]]; then + triple=".build/release" + else + triple=".build/debug" + fi +fi + +executable_path="$triple/$product" +if [[ ! -x "$executable_path" ]]; then + echo "built executable not found: $executable_path" >&2 + exit 1 +fi + +app_path="$output_dir/$display_name.app" +contents="$app_path/Contents" +macos="$contents/MacOS" +resources="$contents/Resources" + +rm -rf "$app_path" +mkdir -p "$macos" "$resources" +cp "$executable_path" "$macos/$product" + +cat >"$contents/Info.plist" < + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $product + CFBundleIdentifier + $bundle_id + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $display_name + CFBundleDisplayName + $display_name + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 14.0 + NSHighResolutionCapable + + NSPrincipalClass + NSApplication + + +PLIST + +printf 'APPL????' >"$contents/PkgInfo" + +resolve_identity() { + if [[ "$signing_identity" != "auto" ]]; then + printf '%s\n' "$signing_identity" + return + fi + + local identity + identity=$(security find-identity -v -p codesigning 2>/dev/null | + sed -n 's/^.*"\(Developer ID Application:[^"]*\)".*$/\1/p' | + head -n 1) + if [[ -z "$identity" ]]; then + identity=$(security find-identity -v -p codesigning 2>/dev/null | + sed -n 's/^.*"\(Apple Development:[^"]*\)".*$/\1/p' | + head -n 1) + fi + printf '%s\n' "${identity:--}" +} + +resolved_identity=$(resolve_identity) +if [[ "$resolved_identity" == "-" ]]; then + codesign --force --sign - "$app_path" +else + if ! codesign --force --options runtime --timestamp --sign "$resolved_identity" "$app_path"; then + echo "Developer signing failed; retrying ad-hoc signing." >&2 + codesign --force --sign - "$app_path" + resolved_identity="-" + fi +fi + +codesign --verify --deep --strict --verbose=2 "$app_path" +spctl --assess --type execute --verbose=2 "$app_path" || true + +echo "Packaged: $app_path" +echo "Executable: $product" +echo "Bundle ID: $bundle_id" +echo "Signing identity: $resolved_identity"