diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..b701951 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + # Maintain GitHub Actions dependencies + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + + # Maintain Gradle dependencies and plugins + - package-ecosystem: "gradle" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index f43118d..7d58dc6 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -38,6 +38,9 @@ jobs: artifact-path: 'app-desktop/build/compose/binaries/main*/**/*.deb' platform: linux + env: + JAVA_VERSION: ${{ vars.JAVA_VERSION || '21' }} + defaults: run: shell: bash @@ -46,11 +49,11 @@ jobs: - name: Checkout Code uses: actions/checkout@v6 - - name: Setup JDK 17 + - name: Setup JDK uses: actions/setup-java@v5 with: distribution: 'zulu' - java-version: '17' + java-version: ${{ env.JAVA_VERSION }} - name: Setup Gradle uses: gradle/actions/setup-gradle@v6 @@ -213,6 +216,24 @@ jobs: echo "Production-level version detected ($APP_VERSION). No filename mapping required." fi + - name: Rename pre-release package files superficially + run: | + if [[ "$APP_VERSION" =~ ^0\. ]]; then + # Under the hood, Gradle maps 0.x.y to 1.x.y to satisfy macOS CFBundleVersion rules. + # Here we rename the generated file names back to 0.x.y so the user sees a pre-release version name. + MAPPED_VERSION="1.${APP_VERSION#0.}" + echo "Pre-release version detected ($APP_VERSION). Searching for generated files matching mapped version $MAPPED_VERSION..." + + # Find files containing the mapped version and rename them to use the actual version + find app-desktop/build/compose/binaries/main* -type f \( -name "*${MAPPED_VERSION}*" \) | while read -r file; do + new_file=$(echo "$file" | sed "s/${MAPPED_VERSION}/${APP_VERSION}/g") + echo "Renaming $file to $new_file" + mv "$file" "$new_file" + done + else + echo "Production-level version detected ($APP_VERSION). No filename mapping required." + fi + - name: Upload Main Package Artifact uses: actions/upload-artifact@v7 with: @@ -221,7 +242,7 @@ jobs: retention-days: 7 - name: Upload PKG Package Artifact (macOS only) - if: matrix.platform == 'macos' + if: matrix.platform == 'macos' && env.MACOS_INSTALLER_PRESENT == 'true' uses: actions/upload-artifact@v7 with: name: Codeoba-macos-pkg diff --git a/AGENTS.md b/AGENTS.md index 0e868ab..5966973 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -190,7 +190,7 @@ When modifying the Compose UI under `app-desktop`, adhere to these style guideli - Free local lexical and semantic searches are enabled by default for all users, while AI-powered summarization is a premium subscription feature. - Paid subscription entitlements are enforced strictly on the backend to gate access to the Device Sync Hub and remote command relay APIs. - Implements secure, browser-delegated OAuth flow utilizing a temporary JDK-native HTTP loopback server (listening on a random port for `/callback` parameters) and a unified Web Console SPA (running on Firebase Hosting) to prevent in-app credential handling. - - Stores all sensitive authentication credentials (ID and refresh tokens, licensing JWT, decryption key) in the OS-native keyring (Keychain on macOS, Credential Manager on Windows, Secret Service on Linux) using a secure utility with automatic self-healing migration and a Java Preferences fallback. Keychain/Keyring prompts can be bypassed completely during development or troubleshooting by setting the JVM system property `codeoba.no.keyring=true`. + - Stores all sensitive authentication credentials (ID and refresh tokens, licensing JWT, decryption key) in the OS-native keyring (Keychain on macOS, Credential Manager on Windows, Secret Service on Linux) using a secure utility with automatic self-healing migration and a Java Preferences fallback. Keychain/Keyring prompts are bypassed by default in non-production configurations to simplify testing, and can be explicitly controlled via the JVM system property `codeoba.no.keyring=`. - Billing webhooks query subscriptions using the user's immutable Firebase `uid` mapped in checkout custom metadata rather than mutable emails to support profile email updates. - Implements challenge-response authentication utilizing 90-second single-use nonces and device public/private key pairs (stored securely in the OS-native keyring via `SecureStorage` with a fallback to Preferences). - Integrates a best-effort local regex secrets scanner to redact sensitive credentials on the client side before synchronizing data. @@ -200,10 +200,19 @@ When modifying the Compose UI under `app-desktop`, adhere to these style guideli - The Firebase Web API key used for token refresh operations is resolved dynamically via the JVM system property `codeoba.firebase.api_key` or environment variable `CODEOBA_FIREBASE_API_KEY`. - If running in production (non-emulator) mode and the API key matches the default placeholder or is blank, a fail-fast validation check throws an `IllegalArgumentException` with clear configuration instructions. +27. **App Signature Configuration**: + - The client app injects an app signature token resolved at build-time (from environment `CODEOBA_APP_SIGNATURE_HASH` or build properties) into `BuildConfig.APP_SIGNATURE`. + - This token is sent as the `X-App-Signature` header in all requests to backend functions. The backend compares it to `CODEOBA_APP_SIGNATURE_HASH` to verify that the request originates from the official signed distribution. + +28. **Store Screenshot Generator & Mock Mode**: + - Restricted to debug builds only (`BuildConfig.DEBUG == true`). Activated using the JVM system property `-Dcodeoba.store=apple|microsoft` (with optional custom JSON path via `-Dcodeoba.canned_data=PATH`). + - Sized and centered to specific default dimensions (Apple: 1280x800, Microsoft: 1920x1080) unless overridden via the program argument `--size=WIDTHxHEIGHT`. + - Intercepts directory/database scanning and loads faked, high-quality session lists from `store/canned_apple.json` or `store/canned_microsoft.json` relative to the current working directory, preventing local data updates to settings. + --- ## ๐Ÿ› ๏ธ Common Gradle Development Commands - Compile Desktop Client: `./gradlew :app-desktop:compileKotlinDesktop` - Run Unit Tests: `./gradlew :core:desktopTest` -- Launch Application in Dev Mode: `./gradlew :app-desktop:run` +- Launch Application in Dev Mode: `./gradlew :app-desktop:run -Dcodeoba.base_url=localhost:5000` diff --git a/README.md b/README.md index 135f657..1fd492a 100644 --- a/README.md +++ b/README.md @@ -60,11 +60,73 @@ graph TD - **Test**: `./gradlew :core:desktopTest` - **Launch Application**: `./gradlew :app-desktop:run` -### Environment & Configuration Properties - -The application resolves dynamic parameters at startup from JVM system properties or environment variables: -- `codeoba.firebase.api_key` (JVM property) or `CODEOBA_FIREBASE_API_KEY` (env variable): Firebase Web API Key required for token refresh operations in non-emulator (production) mode. If missing in production, token refreshes fail fast. -- `codeoba.base_url` (JVM property): The active environment base URL. Defaults to `codeoba.com`. If configured to `localhost` or `127.0.0.1`, the app targets local Firebase emulators. +### โš™๏ธ Developer Runtime Modes & Configuration + +By default, the application runs in **Free/Local-First Mode** (zero cloud dependencies, subscription features compiled out). For developers working on subscription, syncing, or remote-control features, the app supports three environments: + +#### 1. Free/Local-First Mode (Default) +Runs 100% locally on your machine with no external credentials, internet, or databases required. +* **Compile-Time Setup**: No properties file or key configuration is needed. +* **Run**: + ```bash + ./gradlew :app-desktop:run + ``` + +#### 2. Local Emulator Development Mode +Enables paid subscription and multi-device sync UI features locally by connecting to a local offline Firebase Emulator Suite. +* **Compile-Time Setup**: + Create `local.properties` in the root folder of this repository (it is git-ignored by default) and add: + ```properties + # Enable subscription and sync features in the UI + # (Note: This is a temporary developer toggle to A/B test the client with/without subscription + # features. Upon subscription release, this option will be removed and permanently enabled). + codeoba.enable_subscription=true + + # The native OS keyring is automatically bypassed by default in local/staging environments + # to prevent repeated keychain authorization prompts when unsigned developer builds are run. + ``` + *(Note: When pointing to the local emulator, the client defaults to `EMULATOR_ONLY` for the Firebase API key. However, because subscription features are enabled, you must configure a public verification key (`codeoba.premium.public_key=...`) in `local.properties` to verify the premium module signature. Developers with access to the premium module can generate and pair these key properties automatically using the developer key setup tasks).* +* **Run**: Start the app by passing the local emulator base URL as a JVM System Property: + ```bash + ./gradlew :app-desktop:run -Dcodeoba.base_url=localhost:5000 + ``` + +#### 3. Deployed Dev Server Mode (Staging sandbox pointing to dev.codeoba.com) +Connects the local client app directly to the public staging cloud backend (Firebase project `codeoba-dev`). +* **Compile-Time Setup**: + Add the following compile-time variables to `local.properties` (or set them as environment variables in your build shell): + ```properties + codeoba.enable_subscription=true + + # Paste your staging Firebase Web API Key: + codeoba.firebase.api_key=YOUR_STAGING_FIREBASE_WEB_API_KEY + + # Paste your staging Public Key (pairs with premium private key): + codeoba.premium.public_key=YOUR_STAGING_PREMIUM_PUBLIC_KEY + + # Paste your staging app signature attestation token: + codeoba.app_signature_hash=YOUR_STAGING_APP_SIGNATURE_TOKEN + ``` + *(If using environment variables, export them in your terminal before building: `CODEOBA_FIREBASE_API_KEY`, `CODEOBA_PREMIUM_PUBLIC_KEY`, and `CODEOBA_APP_SIGNATURE_HASH`).* +* **Run**: Run the client pointing to the staging base URL: + ```bash + ./gradlew :app-desktop:run -Dcodeoba.base_url=dev.codeoba.com + ``` + +#### 4. Deployed Production Mode (Pointing to codeoba.com) +Connects the client app to the live production database and backend services. +* **Compile-Time Setup**: + Add the production variables to `local.properties` (or export as environment variables): + ```properties + codeoba.enable_subscription=true + codeoba.firebase.api_key=YOUR_PRODUCTION_FIREBASE_WEB_API_KEY + codeoba.premium.public_key=YOUR_PRODUCTION_PREMIUM_PUBLIC_KEY + codeoba.app_signature_hash=YOUR_PRODUCTION_APP_SIGNATURE_TOKEN + ``` +* **Run**: Run the client pointing to the production base URL: + ```bash + ./gradlew :app-desktop:run -Dcodeoba.base_url=codeoba.com + ``` --- @@ -72,3 +134,11 @@ The application resolves dynamic parameters at startup from JVM system propertie * 100% local-first: no remote accounts, telemetry, trackers, or cloud storage syncing. * All parser steps, SQL queries, and semantic embeddings are executed directly on your local machine. + +--- + +## ๐Ÿ“ธ Developer Guides + +* [Adding a New Log Adapter Guide](file:///Users/pv/Dev/GitHub/LookAtWhatAiCanDo/Codeoba/docs/ADD_NEW_SOURCE.md) +* [Developer Setup & Build Guide](file:///Users/pv/Dev/GitHub/LookAtWhatAiCanDo/Codeoba/docs/DEVELOPMENT.md) +* [Marketing Screenshot Mock Generator Guide](file:///Users/pv/Dev/GitHub/LookAtWhatAiCanDo/Codeoba/docs/SCREENSHOT_GENERATOR.md) diff --git a/app-desktop/build.gradle.kts b/app-desktop/build.gradle.kts index 2168418..f4add26 100644 --- a/app-desktop/build.gradle.kts +++ b/app-desktop/build.gradle.kts @@ -40,6 +40,7 @@ kotlin { implementation(libs.compose.material3) implementation(libs.compose.runtime) implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.cli) implementation(libs.compose.ui) implementation(libs.slf4j.simple) } @@ -72,6 +73,20 @@ compose { includeAllModules = true macOS { iconFile.set(project.file("src/desktopMain/resources/icon.icns")) + bundleID = "com.whataicando.codeoba" + val signingIdentity = System.getenv("MACOS_SIGNING_IDENTITY") + if (!signingIdentity.isNullOrBlank()) { + signing { + sign.set(true) + identity.set(signingIdentity) + entitlementsFile.set(project.file("src/desktopMain/resources/entitlements.plist")) + } + notarization { + appleID.set(System.getenv("APPLE_ID")) + password.set(System.getenv("APPLE_ID_PASSWORD")) + teamID.set(System.getenv("APPLE_TEAM_ID")) + } + } } windows { iconFile.set(project.file("src/desktopMain/resources/icon.ico")) diff --git a/app-desktop/src/desktopMain/kotlin/com/whataicando/codeoba/desktop/DetailPane.kt b/app-desktop/src/desktopMain/kotlin/com/whataicando/codeoba/desktop/DetailPane.kt index 247e9c5..7cb4fb0 100644 --- a/app-desktop/src/desktopMain/kotlin/com/whataicando/codeoba/desktop/DetailPane.kt +++ b/app-desktop/src/desktopMain/kotlin/com/whataicando/codeoba/desktop/DetailPane.kt @@ -152,6 +152,7 @@ import com.whataicando.codeoba.core.domain.parser.ParserMode import com.whataicando.codeoba.core.domain.parser.SessionSummary import com.whataicando.codeoba.core.domain.search.SearchResult import com.whataicando.codeoba.core.domain.search.buildFindRegex +import com.whataicando.codeoba.desktop.provider.* import java.awt.Desktop import java.io.File import java.text.SimpleDateFormat @@ -1043,6 +1044,7 @@ fun DetailPane( onGroupUpdate: (ConversationGroup) -> Unit, onGroupDelete: (String) -> Unit, onToggleGroupPin: (String, Boolean) -> Unit, + statsProvider: WorkspaceStatsProvider = rememberWorkspaceStatsProvider(searchResults), dragDropState: DragDropState = remember { DragDropState() }, onUrlClick: (String) -> Unit = {}, modifier: Modifier = Modifier @@ -1172,69 +1174,23 @@ fun DetailPane( ) } } else { - // Compute statistics - val totalConversations = searchResults.size - val totalTurns = searchResults.sumOf { it.session.turns.size } - val totalUserChars = searchResults.sumOf { it.session.turns.sumOf { turn -> turn.userMessage.length } } - val totalAssistantChars = searchResults.sumOf { it.session.turns.sumOf { turn -> turn.assistantMessage.length } } - val promptTokens = (totalUserChars + 3) / 4 - val responseTokens = (totalAssistantChars + 3) / 4 - val totalEstTokens = promptTokens + responseTokens - val avgTurns = if (totalConversations > 0) totalTurns.toFloat() / totalConversations else 0f - val totalDurationMs = searchResults.sumOf { getSessionComputeTimeMs(it.session) } - val avgDurationMs = if (totalConversations > 0) totalDurationMs / totalConversations else 0L - val avgSpeedText = formatSpeed(totalEstTokens.toLong(), totalDurationMs) - - val totalCompactions = searchResults.sumOf { res -> - res.session.turns.count { it.extraData["isCompaction"] == "true" } - } - val totalCompactionTimeMs = searchResults.sumOf { res -> - res.session.turns.sumOf { it.extraData["compactionTimeMs"]?.toLongOrNull() ?: 0L } - } - - val modelStatsList = remember(searchResults) { - class ModelStats( - var turnCount: Int = 0, - var promptChars: Long = 0, - var responseChars: Long = 0, - var computeTimeMs: Long = 0 - ) - val modelStatsMap = mutableMapOf() - for (res in searchResults) { - for (turn in res.session.turns) { - val mName = turn.extraData["model"] ?: "Unknown Model" - val stats = modelStatsMap.getOrPut(mName) { ModelStats() } - stats.turnCount++ - stats.promptChars += turn.userMessage.length - stats.responseChars += turn.assistantMessage.length - val ms = turn.extraData["computeTimeMs"]?.toLongOrNull() - if (ms != null && ms > 0) { - stats.computeTimeMs += ms.coerceAtMost(900_000L) - } else if (turn.assistantMessage.isNotEmpty()) { - val estMs = (turn.assistantMessage.length / 120.0 * 1000.0).toLong() - stats.computeTimeMs += estMs.coerceIn(2000L, 60000L) - } - } - } - - modelStatsMap.entries.map { (modelName, stats) -> - val modelPromptTokens = (stats.promptChars + 3) / 4 - val modelResponseTokens = (stats.responseChars + 3) / 4 - val modelTotalTokens = modelPromptTokens + modelResponseTokens - val speedTps = if (stats.computeTimeMs > 0) { - (modelTotalTokens.toDouble() * 1000.0) / stats.computeTimeMs - } else 0.0 - ModelItemStats( - modelName = modelName, - turnCount = stats.turnCount, - promptChars = stats.promptChars, - responseChars = stats.responseChars, - computeTimeMs = stats.computeTimeMs, - totalTokens = modelTotalTokens, - speedTps = speedTps - ) - } - } + // Retrieve statistics from the provider + val totalConversations = statsProvider.totalConversations + val totalTurns = statsProvider.totalTurns + val promptTokens = statsProvider.promptTokens + val responseTokens = statsProvider.responseTokens + val totalEstTokens = statsProvider.totalEstTokens + val avgTurns = statsProvider.avgTurns + val totalDurationMs = statsProvider.totalDurationMs + val avgDurationMs = statsProvider.avgDurationMs + val avgSpeedText = statsProvider.avgSpeedText + + val totalCompactions = statsProvider.totalCompactions + val totalCompactionTimeMs = statsProvider.totalCompactionTimeMs + + val modelStatsList = remember(statsProvider) { + statsProvider.modelStatsList + } val sortedModelStats = remember(modelStatsList, sortBy, sortAscending) { val comparator = when (sortBy) { @@ -1502,10 +1458,7 @@ fun DetailPane( fontWeight = FontWeight.Bold ) - val sourceGroups = searchResults.groupBy { it.session.sourceId } - .mapValues { it.value.size } - .toList() - .sortedByDescending { it.second } + val sourceGroups = statsProvider.sourceGroups Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { sourceGroups.forEach { (sourceId, count) -> @@ -2631,15 +2584,7 @@ enum class ModelSortDimension(val displayName: String) { NAME("Model Name") } -data class ModelItemStats( - val modelName: String, - val turnCount: Int, - val promptChars: Long, - val responseChars: Long, - val computeTimeMs: Long, - val totalTokens: Long, - val speedTps: Double -) + @Composable fun ClickableMarkdownText( diff --git a/app-desktop/src/desktopMain/kotlin/com/whataicando/codeoba/desktop/Main.kt b/app-desktop/src/desktopMain/kotlin/com/whataicando/codeoba/desktop/Main.kt index 179f2c9..9e024c6 100644 --- a/app-desktop/src/desktopMain/kotlin/com/whataicando/codeoba/desktop/Main.kt +++ b/app-desktop/src/desktopMain/kotlin/com/whataicando/codeoba/desktop/Main.kt @@ -105,6 +105,8 @@ import com.whataicando.codeoba.core.util.Logger.log import com.whataicando.codeoba.core.util.ModelDownloader import com.whataicando.codeoba.core.util.PlatformUtils import com.whataicando.codeoba.core.util.BuildConfig +import com.whataicando.codeoba.core.util.DebugStoreConfig +import kotlinx.cli.* import java.awt.Cursor import java.nio.file.Path import java.nio.file.Paths @@ -128,23 +130,100 @@ class DynamicSemanticEmbedder(fallback: SemanticEmbedder) : SemanticEmbedder { var cacheOverride: Boolean? = null fun main(args: Array) { - if (args.contains("--no-cache")) { + val parser = ArgParser("codeoba") + + val sizeOpt by parser.option( + ArgType.String, + fullName = "size", + description = "Set the initial application window size (e.g. 1920x1080)" + ) + + val cacheOpt by parser.option( + ArgType.Boolean, + fullName = "cache", + description = "Force enable persistent startup caching" + ) + + val noCacheOpt by parser.option( + ArgType.Boolean, + fullName = "no-cache", + description = "Disable persistent startup caching" + ) + + val updateIgnoreThrottlingOpt by parser.option( + ArgType.Boolean, + fullName = "update-ignore-throttling", + description = "Bypasses update check time throttling (debug only)" + ) + + val updateForceOpt by parser.option( + ArgType.Boolean, + fullName = "update-force", + description = "Force fake an update being available (debug only)" + ) + + val updateMockNotesOpt by parser.option( + ArgType.Boolean, + fullName = "update-mock-notes", + description = "Show fake mock release notes (debug only)" + ) + + val processedArgs = mutableListOf() + for (arg in args) { + if (arg.startsWith("--") && arg.contains("=")) { + processedArgs.add(arg.substringBefore("=")) + processedArgs.add(arg.substringAfter("=")) + } else { + processedArgs.add(arg) + } + } + + try { + parser.parse(processedArgs.toTypedArray()) + } catch (e: Exception) { + kotlin.system.exitProcess(0) + } + + if (noCacheOpt == true) { cacheOverride = false log("Main: Caching disabled via command-line option --no-cache.") - } else if (args.contains("--cache")) { + } else if (cacheOpt == true) { cacheOverride = true log("Main: Caching enabled via command-line option --cache.") } - if (args.contains("--update-ignore-throttling")) { + if (BuildConfig.DEBUG) { + System.getProperty("codeoba.store")?.lowercase()?.let { + DebugStoreConfig.storeMode = it + log("Main: Store screenshot mode enabled via system property: $it") + } + System.getProperty("codeoba.canned_data")?.let { + DebugStoreConfig.cannedDataPath = it + log("Main: Canned data path set via system property to $it") + } + + sizeOpt?.let { sizeStr -> + val parts = sizeStr.split('x', 'X', 'ร—') + if (parts.size == 2) { + val w = parts[0].toIntOrNull() + val h = parts[1].toIntOrNull() + if (w != null && h != null) { + DebugStoreConfig.sizeOverride = Pair(w, h) + log("Main: Size override set via argument: ${w}x${h}") + } + } + } + } + + if (updateIgnoreThrottlingOpt == true) { UpdateManager.ignoreUpdateThrottling = true log("Main: Update throttling disabled via command-line option --update-ignore-throttling.") } - if (args.contains("--update-force")) { + if (updateForceOpt == true) { UpdateManager.forceUpdateAvailable = true log("Main: Forced update check availability via command-line option --update-force.") } - if (args.contains("--update-mock-notes")) { + if (updateMockNotesOpt == true) { UpdateManager.mockUpdateNotes = true log("Main: Mock hostile changelog notes enabled via command-line option --update-mock-notes.") } @@ -178,16 +257,32 @@ fun main(args: Array) { } fun mainEntry() = application { - val initialX = SettingsManager.getWindowX() - val initialY = SettingsManager.getWindowY() - val initialWidth = SettingsManager.getWindowWidth() ?: 1280 - val initialHeight = SettingsManager.getWindowHeight() ?: 800 - val initialMaximized = SettingsManager.getWindowMaximized() ?: false + val isStoreMode = DebugStoreConfig.isStoreMode + val targetSize = if (isStoreMode) { + val override = DebugStoreConfig.sizeOverride + if (override != null) { + override + } else { + if (DebugStoreConfig.storeMode == "apple") { + Pair(1280, 800) + } else { + Pair(1920, 1080) + } + } + } else { + null + } + + val initialWidth = targetSize?.first ?: SettingsManager.getWindowWidth() ?: 1280 + val initialHeight = targetSize?.second ?: SettingsManager.getWindowHeight() ?: 800 + val initialMaximized = if (isStoreMode) false else (SettingsManager.getWindowMaximized() ?: false) // Validate position coordinates: make sure the window intersects at least one active screen var validatedBounds: java.awt.Rectangle? = null - if (initialX != null && initialY != null) { - val savedRect = java.awt.Rectangle(initialX, initialY, initialWidth, initialHeight) + if (!isStoreMode && SettingsManager.getWindowX() != null && SettingsManager.getWindowY() != null) { + val initialX = SettingsManager.getWindowX() + val initialY = SettingsManager.getWindowY() + val savedRect = java.awt.Rectangle(initialX!!, initialY!!, initialWidth, initialHeight) val ge = java.awt.GraphicsEnvironment.getLocalGraphicsEnvironment() for (screen in ge.screenDevices) { val bounds = screen.defaultConfiguration.bounds @@ -205,7 +300,7 @@ fun mainEntry() = application { val windowState = rememberWindowState( placement = if (initialMaximized) WindowPlacement.Maximized else WindowPlacement.Floating, - position = if (finalX != null && finalY != null) { + position = if (!isStoreMode && finalX != null && finalY != null) { WindowPosition.Absolute(finalX.dp, finalY.dp) } else { WindowPosition(Alignment.Center) @@ -314,6 +409,7 @@ fun mainEntry() = application { LaunchedEffect(refreshTrigger) { semanticEngine.similarityThreshold = SettingsManager.getSimilarityThreshold() + com.whataicando.codeoba.core.premium.PremiumLoader.sync(SettingsManager.getEcosystemActive()) } val onDownloadModel = { @@ -540,26 +636,31 @@ fun mainEntry() = application { } // 2. Perform background device registration/sync in the Hub - val deviceId = SettingsManager.getDeviceId() - val host = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { try { java.net.InetAddress.getLocalHost().hostName } catch (_: Exception) { "Unknown" } } - val deviceName = "${if (PlatformUtils.isMac()) "macOS" else if (PlatformUtils.isWindows()) "Windows" else "Linux"} ($host)" - val publicKey = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { com.whataicando.codeoba.core.security.DeviceKeyManager.getPublicKeyPem() } - - val nonce = com.whataicando.codeoba.core.domain.auth.FirebaseAuthClient.getRegistrationChallenge( - refreshedAuth.idToken, deviceId - ) - val signature = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { com.whataicando.codeoba.core.security.DeviceKeyManager.signPayload(nonce) } - - val success = com.whataicando.codeoba.core.domain.auth.FirebaseAuthClient.registerEcosystemDevice( - refreshedAuth.idToken, deviceId, deviceName, publicKey, nonce, signature - ) - if (success) { - log("Main Background Loop: Ecosystem device successfully synced with Hub.") - } else { - log("Main Background Loop: Sync Hub returned failure status for device registration.") + if (SettingsManager.getEcosystemActive()) { + val deviceId = SettingsManager.getDeviceId() + val host = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { try { java.net.InetAddress.getLocalHost().hostName } catch (_: Exception) { "Unknown" } } + val deviceName = "${if (PlatformUtils.isMac()) "macOS" else if (PlatformUtils.isWindows()) "Windows" else "Linux"} ($host)" + val publicKey = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { com.whataicando.codeoba.core.security.DeviceKeyManager.getPublicKeyPem() } + + val nonce = com.whataicando.codeoba.core.domain.auth.FirebaseAuthClient.getRegistrationChallenge( + refreshedAuth.idToken, deviceId + ) + val signature = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { com.whataicando.codeoba.core.security.DeviceKeyManager.signPayload(nonce) } + + val success = com.whataicando.codeoba.core.domain.auth.FirebaseAuthClient.registerEcosystemDevice( + refreshedAuth.idToken, deviceId, deviceName, publicKey, nonce, signature + ) + if (success) { + log("Main Background Loop: Ecosystem device successfully synced with Hub.") + } else { + log("Main Background Loop: Sync Hub returned failure status for device registration.") + } } } catch (e: FirebaseAuthException) { - if (e.status == 402 || e.status == 403) { + if (e.status == 400) { + log("Main Background Loop: Session expired or invalid on server (Status 400). Signing out.") + SettingsManager.signOut() + } else if (e.status == 402 || e.status == 403) { log("Main Background Loop: Subscription expired or permission denied (Status ${e.status}). Deactivating ecosystem features.") SettingsManager.setEcosystemActive(false) LogParserFactory.setParserMode(SettingsManager.getEffectiveParserMode()) @@ -681,6 +782,7 @@ fun mainEntry() = application { } fun saveWindowState() { + if (DebugStoreConfig.isStoreMode) return // Do not save settings in store screenshot mode windowInstance?.let { win -> try { val state = win.extendedState diff --git a/app-desktop/src/desktopMain/kotlin/com/whataicando/codeoba/desktop/SettingsDialog.kt b/app-desktop/src/desktopMain/kotlin/com/whataicando/codeoba/desktop/SettingsDialog.kt index 7ed5bc8..a10fc58 100644 --- a/app-desktop/src/desktopMain/kotlin/com/whataicando/codeoba/desktop/SettingsDialog.kt +++ b/app-desktop/src/desktopMain/kotlin/com/whataicando/codeoba/desktop/SettingsDialog.kt @@ -82,6 +82,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import com.whataicando.codeoba.core.auth.LocalAuthServer import com.whataicando.codeoba.core.domain.auth.FirebaseAuthClient +import com.whataicando.codeoba.core.domain.auth.FirebaseAuthException import com.whataicando.codeoba.core.domain.parser.LogParserFactory import com.whataicando.codeoba.core.domain.parser.ParserMode import com.whataicando.codeoba.core.domain.source.SourceAdapter @@ -1495,6 +1496,8 @@ fun AccountSettingsSection(onSettingsChanged: () -> Unit) { var errorMessage by remember { mutableStateOf(null) } var isLoading by remember { mutableStateOf(false) } var isLoadingPortal by remember { mutableStateOf(false) } + var isVerifyingStatus by remember { mutableStateOf(false) } + var cooldownSeconds by remember { mutableStateOf(0) } val savedEmail = SettingsManager.getFirebaseUserEmail() val savedUid = SettingsManager.getFirebaseUserUid() @@ -1784,18 +1787,22 @@ fun AccountSettingsSection(onSettingsChanged: () -> Unit) { } } + errorMessage?.let { + Text( + text = it, + color = Color(0xFFD32F2F), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(vertical = 4.dp) + ) + } + Row( modifier = Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(12.dp) ) { Button( onClick = { - SettingsManager.setFirebaseUserEmail(null) - SettingsManager.setFirebaseUserUid(null) - SettingsManager.setFirebaseAuthIdToken(null) - SettingsManager.setFirebaseAuthRefreshToken(null) - SettingsManager.setEcosystemActive(false) - LogParserFactory.setParserMode(SettingsManager.getEffectiveParserMode()) + SettingsManager.signOut() errorMessage = null isLoading = false LocalAuthServer.stop() @@ -1808,6 +1815,105 @@ fun AccountSettingsSection(onSettingsChanged: () -> Unit) { Text("Sign Out", style = MaterialTheme.typography.labelLarge) } + if (!isSubscribed) { + Button( + onClick = { + isVerifyingStatus = true + errorMessage = null + scope.launch { + try { + val idToken = SettingsManager.getFirebaseAuthIdToken() + if (idToken != null) { + var tokenToUse = idToken + val active = try { + FirebaseAuthClient.checkSubscriptionStatus(tokenToUse) + } catch (e: Exception) { + val isAuthError = e.message?.contains("auth", ignoreCase = true) == true || + e.message?.contains("token", ignoreCase = true) == true || + e.message?.contains("expired", ignoreCase = true) == true + if (isAuthError) { + val refreshToken = SettingsManager.getFirebaseAuthRefreshToken() + if (refreshToken != null) { + val refreshedAuth = FirebaseAuthClient.refreshIdToken(refreshToken) + if (refreshedAuth.idToken.isNotBlank()) { + SettingsManager.setFirebaseAuthIdToken(refreshedAuth.idToken) + tokenToUse = refreshedAuth.idToken + } + if (refreshedAuth.refreshToken.isNotBlank()) { + SettingsManager.setFirebaseAuthRefreshToken(refreshedAuth.refreshToken) + } + FirebaseAuthClient.checkSubscriptionStatus(tokenToUse) + } else { + throw e + } + } else { + throw e + } + } + + if (active) { + val deviceId = SettingsManager.getDeviceId() + val host = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { try { java.net.InetAddress.getLocalHost().hostName } catch (_: Exception) { "Unknown" } } + val deviceName = "${when { PlatformUtils.isMac() -> "macOS"; PlatformUtils.isWindows() -> "Windows"; else -> "Linux" }} ($host)" + val publicKeyPem = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { DeviceKeyManager.getPublicKeyPem() } + val nonce = FirebaseAuthClient.getRegistrationChallenge(tokenToUse, deviceId) + val signature = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { DeviceKeyManager.signPayload(nonce) } + + val registered = FirebaseAuthClient.registerEcosystemDevice(tokenToUse, deviceId, deviceName, publicKeyPem, nonce, signature) + require(registered) { "Device registration failed." } + SettingsManager.setEcosystemActive(true) + SettingsManager.setPreferredParserMode(ParserMode.SUMMARIZING) + LogParserFactory.setParserMode(SettingsManager.getEffectiveParserMode()) + errorMessage = null + onSettingsChanged() + } else { + errorMessage = "No active subscription found. Upgrade your subscription to enable sync." + } + } else { + errorMessage = "Authentication token not found. Please sign in again." + } + } catch (e: Exception) { + log("Failed to verify status", e) + errorMessage = e.message ?: "Failed to verify status." + } finally { + isVerifyingStatus = false + if (!SettingsManager.getEcosystemActive()) { + cooldownSeconds = 60 + scope.launch { + while (cooldownSeconds > 0) { + kotlinx.coroutines.delay(1000L) + cooldownSeconds-- + } + } + } + } + } + }, + enabled = !isVerifyingStatus && cooldownSeconds == 0, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors(containerColor = SlateSurface, contentColor = TextPrimary), + shape = RoundedCornerShape(8.dp) + ) { + if (isVerifyingStatus) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + color = TextPrimary, + strokeWidth = 2.dp + ) + Text("Verifying...", style = MaterialTheme.typography.labelLarge) + } + } else if (cooldownSeconds > 0) { + Text("Wait ${cooldownSeconds}s", style = MaterialTheme.typography.labelLarge) + } else { + Text("Verify Status", style = MaterialTheme.typography.labelLarge) + } + } + } + val polarButtonText = if (!isSubscribed) "Subscribe" else "Manage Subscription" val polarButtonColors = ButtonDefaults.buttonColors( containerColor = if (!isSubscribed) AccentPurple else AccentCyan, @@ -1849,7 +1955,7 @@ fun AccountSettingsSection(onSettingsChanged: () -> Unit) { val portalUrl = FirebaseAuthClient.getCustomerPortalUrl(refreshedAuth.idToken) java.awt.Desktop.getDesktop().browse(java.net.URI(portalUrl)) } catch (refreshEx: Exception) { - log("Failed to refresh token after auth error: ${refreshEx.message}") + log("Failed to refresh token after auth error", refreshEx) throw e } } else { @@ -1936,7 +2042,7 @@ fun AccountSettingsSection(onSettingsChanged: () -> Unit) { errorMessage = null scope.launch { try { - val port = LocalAuthServer.start { idToken, refreshToken, email, uid -> + val port = LocalAuthServer.start { idToken, refreshToken, email, uid, isSubscribed -> scope.launch { try { SettingsManager.setFirebaseUserEmail(email) @@ -1944,29 +2050,46 @@ fun AccountSettingsSection(onSettingsChanged: () -> Unit) { SettingsManager.setFirebaseAuthIdToken(idToken) SettingsManager.setFirebaseAuthRefreshToken(refreshToken) - val deviceId = SettingsManager.getDeviceId() - val host = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { try { java.net.InetAddress.getLocalHost().hostName } catch (_: Exception) { "Unknown" } } - val deviceName = "${when { PlatformUtils.isMac() -> "macOS"; PlatformUtils.isWindows() -> "Windows"; else -> "Linux" }} ($host)" - val publicKeyPem = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { DeviceKeyManager.getPublicKeyPem() } - val nonce = FirebaseAuthClient.getRegistrationChallenge(idToken, deviceId) - val signature = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { DeviceKeyManager.signPayload(nonce) } - - val registered = FirebaseAuthClient.registerEcosystemDevice(idToken, deviceId, deviceName, publicKeyPem, nonce, signature) - require(registered) { "Device registration failed." } - SettingsManager.setEcosystemActive(true) - SettingsManager.setPreferredParserMode(ParserMode.SUMMARIZING) + if (isSubscribed) { + val deviceId = SettingsManager.getDeviceId() + val host = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { try { java.net.InetAddress.getLocalHost().hostName } catch (_: Exception) { "Unknown" } } + val deviceName = "${when { PlatformUtils.isMac() -> "macOS"; PlatformUtils.isWindows() -> "Windows"; else -> "Linux" }} ($host)" + val publicKeyPem = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { DeviceKeyManager.getPublicKeyPem() } + val nonce = FirebaseAuthClient.getRegistrationChallenge(idToken, deviceId) + val signature = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { DeviceKeyManager.signPayload(nonce) } + + val registered = FirebaseAuthClient.registerEcosystemDevice(idToken, deviceId, deviceName, publicKeyPem, nonce, signature) + require(registered) { "Device registration failed." } + SettingsManager.setEcosystemActive(true) + SettingsManager.setPreferredParserMode(ParserMode.SUMMARIZING) + } else { + log("Connection successful, but subscription is inactive. Client started in Local Mode.") + SettingsManager.setEcosystemActive(false) + } + LogParserFactory.setParserMode(SettingsManager.getEffectiveParserMode()) errorMessage = null isLoading = false onSettingsChanged() + } catch (e: FirebaseAuthException) { + if (e.status == 402 || e.status == 403) { + log("Connection successful, but subscription is inactive. Sync disabled.", e) + SettingsManager.setEcosystemActive(false) + LogParserFactory.setParserMode(SettingsManager.getEffectiveParserMode()) + errorMessage = null + isLoading = false + onSettingsChanged() + } else { + log("Error configuring device keys (AuthException)", e) + SettingsManager.signOut() + onSettingsChanged() + errorMessage = e.message ?: "Error configuring device credentials." + isLoading = false + } } catch (e: Exception) { - log("Error configuring device keys: ${e.message}") + log("Error configuring device keys", e) // Roll back partial sign-in state so the user can retry cleanly. - SettingsManager.setFirebaseUserEmail(null) - SettingsManager.setFirebaseUserUid(null) - SettingsManager.setFirebaseAuthIdToken(null) - SettingsManager.setFirebaseAuthRefreshToken(null) - SettingsManager.setEcosystemActive(false) + SettingsManager.signOut() onSettingsChanged() errorMessage = e.message ?: "Error configuring device credentials." isLoading = false diff --git a/app-desktop/src/desktopMain/kotlin/com/whataicando/codeoba/desktop/SettingsManager.kt b/app-desktop/src/desktopMain/kotlin/com/whataicando/codeoba/desktop/SettingsManager.kt index 0b1b604..92ecb22 100644 --- a/app-desktop/src/desktopMain/kotlin/com/whataicando/codeoba/desktop/SettingsManager.kt +++ b/app-desktop/src/desktopMain/kotlin/com/whataicando/codeoba/desktop/SettingsManager.kt @@ -1,12 +1,22 @@ package com.whataicando.codeoba.desktop +import com.whataicando.codeoba.core.domain.parser.LogParserFactory import com.whataicando.codeoba.core.domain.parser.ParserMode import com.whataicando.codeoba.core.domain.search.ArchivalFilter import com.whataicando.codeoba.core.domain.source.SourceAdapter +import com.whataicando.codeoba.core.util.AppConfig import com.whataicando.codeoba.core.util.JsonUtils import com.whataicando.codeoba.core.util.SecureStorage import java.util.prefs.Preferences +/** + * Annotates settings, properties, or functions in [SettingsManager] whose stored values + * are keyed off and isolated by the active target server base URL. + */ +@Target(AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.SOURCE) +annotation class ServerDependent + object SettingsManager { private val prefs: Preferences = Preferences.userNodeForPackage(SettingsManager::class.java) @@ -154,36 +164,130 @@ object SettingsManager { prefs.putBoolean("sidebar_sort_ascending", value) } - fun getFirebaseUserEmail(): String? = prefs.get("firebase_user_email", null) - fun setFirebaseUserEmail(value: String?) = putOrRemove("firebase_user_email", value) + //region Server Dependent settings + + private fun serverKey(key: String): String { + return "${AppConfig.getBaseUrl()}:$key" + } + + @ServerDependent + fun getFirebaseUserEmail(): String? { + val sKey = serverKey("firebase_user_email") + var value = prefs.get(sKey, null) + if (value == null && AppConfig.getBaseUrl() == "codeoba.com") { + value = prefs.get("firebase_user_email", null) + if (value != null) { + prefs.put(sKey, value) + prefs.remove("firebase_user_email") + } + } + return value + } + + @ServerDependent + fun setFirebaseUserEmail(value: String?) { + val sKey = serverKey("firebase_user_email") + putOrRemove(sKey, value) + if (AppConfig.getBaseUrl() == "codeoba.com") { + prefs.remove("firebase_user_email") + } + } + + @ServerDependent + fun getFirebaseUserUid(): String? { + val sKey = serverKey("firebase_user_uid") + var value = prefs.get(sKey, null) + if (value == null && AppConfig.getBaseUrl() == "codeoba.com") { + value = prefs.get("firebase_user_uid", null) + if (value != null) { + prefs.put(sKey, value) + prefs.remove("firebase_user_uid") + } + } + return value + } + + @ServerDependent + fun setFirebaseUserUid(value: String?) { + val sKey = serverKey("firebase_user_uid") + putOrRemove(sKey, value) + if (AppConfig.getBaseUrl() == "codeoba.com") { + prefs.remove("firebase_user_uid") + } + } + + @ServerDependent + fun getFirebaseAuthIdToken(): String? { + val sKey = serverKey("firebase_auth_id_token") + var value = SecureStorage.get(sKey) + if (value == null && AppConfig.getBaseUrl() == "codeoba.com") { + value = SecureStorage.get("firebase_auth_id_token") + if (value != null) { + SecureStorage.put(sKey, value) + SecureStorage.delete("firebase_auth_id_token") + } + } + return value + } - fun getFirebaseUserUid(): String? = prefs.get("firebase_user_uid", null) - fun setFirebaseUserUid(value: String?) = putOrRemove("firebase_user_uid", value) + @ServerDependent + fun setFirebaseAuthIdToken(value: String?) { + val sKey = serverKey("firebase_auth_id_token") + SecureStorage.put(sKey, value) + if (AppConfig.getBaseUrl() == "codeoba.com") { + SecureStorage.delete("firebase_auth_id_token") + } + } - fun getFirebaseAuthIdToken(): String? = SecureStorage.get("firebase_auth_id_token") - fun setFirebaseAuthIdToken(value: String?) = SecureStorage.put("firebase_auth_id_token", value) + @ServerDependent + fun getFirebaseAuthRefreshToken(): String? { + val sKey = serverKey("firebase_auth_refresh_token") + var value = SecureStorage.get(sKey) + if (value == null && AppConfig.getBaseUrl() == "codeoba.com") { + value = SecureStorage.get("firebase_auth_refresh_token") + if (value != null) { + SecureStorage.put(sKey, value) + SecureStorage.delete("firebase_auth_refresh_token") + } + } + return value + } - fun getFirebaseAuthRefreshToken(): String? = SecureStorage.get("firebase_auth_refresh_token") - fun setFirebaseAuthRefreshToken(value: String?) = SecureStorage.put("firebase_auth_refresh_token", value) + @ServerDependent + fun setFirebaseAuthRefreshToken(value: String?) { + val sKey = serverKey("firebase_auth_refresh_token") + SecureStorage.put(sKey, value) + if (AppConfig.getBaseUrl() == "codeoba.com") { + SecureStorage.delete("firebase_auth_refresh_token") + } + } + @ServerDependent fun getDeviceId(): String { - var deviceId = prefs.get("device_id", null) + val sKey = serverKey("device_id") + var deviceId = prefs.get(sKey, null) if (deviceId.isNullOrEmpty()) { - // For existing logged-in users, migrate legacy device ID to preserve backend pairing. - // For new users, generate a clean, random, non-PII UUID. - val hasLegacyAccount = !prefs.get("firebase_user_uid", null).isNullOrEmpty() || - !prefs.get("firebase_user_email", null).isNullOrEmpty() + if (AppConfig.getBaseUrl() == "codeoba.com") { + val legacyId = prefs.get("device_id", null) + if (!legacyId.isNullOrEmpty()) { + prefs.put(sKey, legacyId) + prefs.remove("device_id") + return legacyId + } + } + val hasLegacyAccount = !getFirebaseUserUid().isNullOrEmpty() || + !getFirebaseUserEmail().isNullOrEmpty() if (hasLegacyAccount) { val os = System.getProperty("os.name") ?: "Unknown" - val uid = prefs.get("firebase_user_uid", null) - val email = prefs.get("firebase_user_email", null) + val uid = getFirebaseUserUid() + val email = getFirebaseUserEmail() val stableAccountId = uid ?: email ?: "Unknown" val rawId = "$os:$stableAccountId" deviceId = java.util.UUID.nameUUIDFromBytes(rawId.toByteArray()).toString() } else { deviceId = java.util.UUID.randomUUID().toString() } - prefs.put("device_id", deviceId) + prefs.put(sKey, deviceId) } return deviceId } @@ -195,8 +299,18 @@ object SettingsManager { FULL_SYNC } + @ServerDependent fun getSyncMode(): SyncMode { - val value = prefs.get("ecosystem_sync_mode", SyncMode.METADATA_ONLY.name) + val sKey = serverKey("ecosystem_sync_mode") + val hasKey = prefs.get(sKey, null) != null + if (!hasKey && AppConfig.getBaseUrl() == "codeoba.com") { + val legacyVal = prefs.get("ecosystem_sync_mode", null) + if (legacyVal != null) { + prefs.put(sKey, legacyVal) + prefs.remove("ecosystem_sync_mode") + } + } + val value = prefs.get(sKey, SyncMode.METADATA_ONLY.name) return try { SyncMode.valueOf(value) } catch (e: Exception) { @@ -204,18 +318,40 @@ object SettingsManager { } } + @ServerDependent fun setSyncMode(mode: SyncMode) { - prefs.put("ecosystem_sync_mode", mode.name) + val sKey = serverKey("ecosystem_sync_mode") + prefs.put(sKey, mode.name) + if (AppConfig.getBaseUrl() == "codeoba.com") { + prefs.remove("ecosystem_sync_mode") + } } + @ServerDependent fun getEcosystemActive(): Boolean { - return prefs.getBoolean("ecosystem_active", false) + val sKey = serverKey("ecosystem_active") + val hasKey = prefs.get(sKey, null) != null + if (!hasKey && AppConfig.getBaseUrl() == "codeoba.com") { + val legacyVal = prefs.get("ecosystem_active", null) + if (legacyVal != null) { + prefs.putBoolean(sKey, legacyVal.toBoolean()) + prefs.remove("ecosystem_active") + } + } + return prefs.getBoolean(sKey, false) } + @ServerDependent fun setEcosystemActive(value: Boolean) { - prefs.putBoolean("ecosystem_active", value) + val sKey = serverKey("ecosystem_active") + prefs.putBoolean(sKey, value) + if (AppConfig.getBaseUrl() == "codeoba.com") { + prefs.remove("ecosystem_active") + } } + //endregion + fun getPreferredParserMode(): ParserMode { val name = prefs.get("preferred_parser_mode", ParserMode.SUMMARIZING.name) return try { @@ -237,6 +373,15 @@ object SettingsManager { } } + fun signOut() { + setFirebaseUserEmail(null) + setFirebaseUserUid(null) + setFirebaseAuthIdToken(null) + setFirebaseAuthRefreshToken(null) + setEcosystemActive(false) + LogParserFactory.setParserMode(getEffectiveParserMode()) + } + enum class RemoteControlPolicy { ALLOW_ALL, ALLOW_PAIRED_ONLY, @@ -379,5 +524,3 @@ fun SourceAdapter.isEffectiveEnabled(): Boolean { SettingsManager.Decision.UNDECIDED -> this.isAppInstalled() || this.isAvailable() } } - - diff --git a/app-desktop/src/desktopMain/kotlin/com/whataicando/codeoba/desktop/Sidebar.kt b/app-desktop/src/desktopMain/kotlin/com/whataicando/codeoba/desktop/Sidebar.kt index 14a1985..dea7505 100644 --- a/app-desktop/src/desktopMain/kotlin/com/whataicando/codeoba/desktop/Sidebar.kt +++ b/app-desktop/src/desktopMain/kotlin/com/whataicando/codeoba/desktop/Sidebar.kt @@ -123,6 +123,7 @@ import com.whataicando.codeoba.core.domain.search.ArchivalFilter import com.whataicando.codeoba.core.domain.search.SearchResult import com.whataicando.codeoba.core.domain.source.SourceRegistry import com.whataicando.codeoba.core.util.PlatformUtils +import com.whataicando.codeoba.desktop.provider.* @Composable fun Sidebar( @@ -161,6 +162,7 @@ fun Sidebar( onGroupRemove: (Session, String) -> Unit = { _, _ -> }, dragDropState: DragDropState = remember { DragDropState() }, unassignedSessionCount: Int = 0, + statsProvider: WorkspaceStatsProvider = rememberWorkspaceStatsProvider(searchResults), isModelDownloaded: Boolean = true, isModelDownloading: Boolean = false, modelDownloadProgress: Float = 0f, @@ -679,7 +681,7 @@ fun Sidebar( .padding(horizontal = 5.dp, vertical = 1.dp) ) { Text( - text = unassignedSessionCount.toString(), + text = formatNumber(statsProvider.getGroupSessionCount("_none_", unassignedSessionCount).toLong()), color = AccentCyan, fontSize = 9.sp, fontWeight = FontWeight.Bold, @@ -702,7 +704,8 @@ fun Sidebar( groupInputText = old showRenameGroupDialog = old }, - dragDropState = dragDropState + dragDropState = dragDropState, + statsProvider = statsProvider ) } } @@ -815,7 +818,7 @@ fun Sidebar( .padding(horizontal = 6.dp, vertical = 2.dp) ) { Text( - text = searchResults.size.toString(), + text = formatNumber(statsProvider.totalConversations.toLong()), color = AccentCyan, fontSize = 10.sp, fontWeight = FontWeight.Bold, @@ -2002,7 +2005,8 @@ fun GroupTreeItem( onDelete: (String) -> Unit, onTogglePin: (String, Boolean) -> Unit, onShowRenameDialog: (String) -> Unit, - dragDropState: DragDropState + dragDropState: DragDropState, + statsProvider: WorkspaceStatsProvider ) { var isExpanded by remember { mutableStateOf(true) } val isSelected = activeGroupFilter != null && activeGroupFilter.lowercase() == node.fullName.lowercase() @@ -2126,7 +2130,7 @@ fun GroupTreeItem( .padding(horizontal = 5.dp, vertical = 1.dp) ) { Text( - text = node.recursiveSessionCount.toString(), + text = formatNumber(statsProvider.getGroupSessionCount(node.segment, node.recursiveSessionCount).toLong()), color = AccentCyan, fontSize = 9.sp, fontWeight = FontWeight.Bold, @@ -2150,7 +2154,8 @@ fun GroupTreeItem( onDelete = onDelete, onTogglePin = onTogglePin, onShowRenameDialog = onShowRenameDialog, - dragDropState = dragDropState + dragDropState = dragDropState, + statsProvider = statsProvider ) } } diff --git a/app-desktop/src/desktopMain/kotlin/com/whataicando/codeoba/desktop/provider/CannedWorkspaceStatsProvider.kt b/app-desktop/src/desktopMain/kotlin/com/whataicando/codeoba/desktop/provider/CannedWorkspaceStatsProvider.kt new file mode 100644 index 0000000..4edbb94 --- /dev/null +++ b/app-desktop/src/desktopMain/kotlin/com/whataicando/codeoba/desktop/provider/CannedWorkspaceStatsProvider.kt @@ -0,0 +1,125 @@ +package com.whataicando.codeoba.desktop.provider + +import com.whataicando.codeoba.core.domain.model.Turn +import com.whataicando.codeoba.core.domain.search.SearchResult +import java.util.Random + +class CannedWorkspaceStatsProvider(baseResults: List) : WorkspaceStatsProvider { + private val delegate = RealWorkspaceStatsProvider(rememberGeneratedResults(baseResults)) + + override val totalConversations: Int get() = delegate.totalConversations + override val totalTurns: Int get() = delegate.totalTurns + override val promptTokens: Long get() = delegate.promptTokens + override val responseTokens: Long get() = delegate.responseTokens + override val totalEstTokens: Long get() = delegate.totalEstTokens + override val avgTurns: Float get() = delegate.avgTurns + override val totalDurationMs: Long get() = delegate.totalDurationMs + override val avgDurationMs: Long get() = delegate.avgDurationMs + override val avgSpeedText: String get() = delegate.avgSpeedText + override val totalCompactions: Int get() = delegate.totalCompactions + override val totalCompactionTimeMs: Long get() = delegate.totalCompactionTimeMs + override val modelStatsList: List get() = delegate.modelStatsList + override val sourceGroups: List> get() = delegate.sourceGroups + + override fun getGroupSessionCount(groupName: String, defaultValue: Int): Int { + return when (groupName.lowercase()) { + "_none_" -> 130 + "backend service" -> 115 + "frontend spa" -> 55 + "mobile clients" -> 42 + "devops & deploy" -> 28 + "documentation" -> 12 + else -> defaultValue + } + } +} + +private fun rememberGeneratedResults(baseResults: List): List { + if (baseResults.isEmpty()) return emptyList() + val random = Random(42) + val result = mutableListOf() + val totalCount = 382 + + for (i in 0 until totalCount) { + val base = baseResults[i % baseResults.size] + val sourceId = when { + i < 132 -> "claude" + i < 132 + 110 -> "cursor" + i < 132 + 110 + 85 -> "antigravity" + else -> "copilot" + } + + val turnsCount = when (sourceId) { + "claude" -> random.nextInt(10) + 12 + "cursor" -> random.nextInt(8) + 10 + "antigravity" -> random.nextInt(6) + 8 + else -> random.nextInt(4) + 4 + } + + val turns = mutableListOf() + var currentTimestamp = base.session.timestamp + var firstTurnTimestamp: Long? = null + for (j in 0 until turnsCount) { + val modelName = when (sourceId) { + "claude" -> "Claude 3.5 Sonnet" + "cursor" -> if (j % 3 == 0) "GPT-4o" else "Claude 3.5 Sonnet" + "antigravity" -> "Gemini 1.5 Pro" + else -> "Claude 3.5 Haiku" + } + + val promptLength = random.nextInt(2000) + 500 + val assistantLength = random.nextInt(4000) + 1000 + + val isCompaction = (random.nextFloat() < 0.05).toString() + val compactionTime = if (isCompaction == "true") (random.nextInt(165000) + 15000).toString() else "0" + + val promptTokens = (promptLength + 3) / 4 + val responseTokens = (assistantLength + 3) / 4 + val totalTokens = promptTokens + responseTokens + + val speed = when (modelName) { + "Claude 3.5 Sonnet" -> 38.5 + "GPT-4o" -> 46.2 + "Gemini 1.5 Pro" -> 48.5 + else -> 62.4 + } + val computeTime = ((totalTokens.toDouble() / speed) * 1000).toLong() + + currentTimestamp += random.nextInt(15 * 60000) + 5 * 60000L + if (firstTurnTimestamp == null) { + firstTurnTimestamp = currentTimestamp + } + turns.add( + Turn( + turnId = "t$j", + userMessage = "a".repeat(promptLength), + assistantMessage = "b".repeat(assistantLength), + timestamp = currentTimestamp, + extraData = mapOf( + "model" to modelName, + "computeTimeMs" to computeTime.toString(), + "isCompaction" to isCompaction, + "compactionTimeMs" to compactionTime + ) + ) + ) + } + + val mockSession = base.session.copy( + id = "mock-session-$i", + sourceId = sourceId, + timestamp = firstTurnTimestamp ?: base.session.timestamp, + updatedAt = currentTimestamp, + turns = turns + ) + + result.add( + SearchResult( + session = mockSession, + matchedTurnIndexes = emptyList(), + score = 1.0f + ) + ) + } + return result +} diff --git a/app-desktop/src/desktopMain/kotlin/com/whataicando/codeoba/desktop/provider/ModelItemStats.kt b/app-desktop/src/desktopMain/kotlin/com/whataicando/codeoba/desktop/provider/ModelItemStats.kt new file mode 100644 index 0000000..9cdbe1a --- /dev/null +++ b/app-desktop/src/desktopMain/kotlin/com/whataicando/codeoba/desktop/provider/ModelItemStats.kt @@ -0,0 +1,11 @@ +package com.whataicando.codeoba.desktop.provider + +data class ModelItemStats( + val modelName: String, + val turnCount: Int, + val promptChars: Long, + val responseChars: Long, + val computeTimeMs: Long, + val totalTokens: Long, + val speedTps: Double +) diff --git a/app-desktop/src/desktopMain/kotlin/com/whataicando/codeoba/desktop/provider/RealWorkspaceStatsProvider.kt b/app-desktop/src/desktopMain/kotlin/com/whataicando/codeoba/desktop/provider/RealWorkspaceStatsProvider.kt new file mode 100644 index 0000000..1f20bad --- /dev/null +++ b/app-desktop/src/desktopMain/kotlin/com/whataicando/codeoba/desktop/provider/RealWorkspaceStatsProvider.kt @@ -0,0 +1,108 @@ +package com.whataicando.codeoba.desktop.provider + +import com.whataicando.codeoba.core.domain.search.SearchResult +import com.whataicando.codeoba.desktop.formatSpeed +import com.whataicando.codeoba.desktop.getSessionComputeTimeMs + +class RealWorkspaceStatsProvider(private val searchResults: List) : WorkspaceStatsProvider { + override val totalConversations: Int + get() = searchResults.size + + override val totalTurns: Int + get() = searchResults.sumOf { it.session.turns.size } + + private val totalUserChars = searchResults.sumOf { it.session.turns.sumOf { turn -> turn.userMessage.length } } + private val totalAssistantChars = searchResults.sumOf { it.session.turns.sumOf { turn -> turn.assistantMessage.length } } + + override val promptTokens: Long + get() = ((totalUserChars + 3) / 4).toLong() + + override val responseTokens: Long + get() = ((totalAssistantChars + 3) / 4).toLong() + + override val totalEstTokens: Long + get() = promptTokens + responseTokens + + override val avgTurns: Float + get() = if (totalConversations > 0) totalTurns.toFloat() / totalConversations else 0f + + override val totalDurationMs: Long + get() = searchResults.sumOf { getSessionComputeTimeMs(it.session) } + + override val avgDurationMs: Long + get() { + if (totalConversations <= 0) return 0L + val totalElapsedMs = searchResults.sumOf { + (it.session.updatedAt - it.session.timestamp).coerceAtLeast(0L) + } + return totalElapsedMs / totalConversations + } + + override val avgSpeedText: String + get() = formatSpeed(totalEstTokens, totalDurationMs) + + override val totalCompactions: Int + get() = searchResults.sumOf { res -> + res.session.turns.count { it.extraData["isCompaction"] == "true" } + } + + override val totalCompactionTimeMs: Long + get() = searchResults.sumOf { res -> + res.session.turns.sumOf { it.extraData["compactionTimeMs"]?.toLongOrNull() ?: 0L } + } + + override val modelStatsList: List + get() { + class ModelStats( + var turnCount: Int = 0, + var promptChars: Long = 0, + var responseChars: Long = 0, + var computeTimeMs: Long = 0 + ) + val modelStatsMap = mutableMapOf() + for (res in searchResults) { + for (turn in res.session.turns) { + val mName = turn.extraData["model"] ?: "Unknown Model" + val stats = modelStatsMap.getOrPut(mName) { ModelStats() } + stats.turnCount++ + stats.promptChars += turn.userMessage.length + stats.responseChars += turn.assistantMessage.length + val ms = turn.extraData["computeTimeMs"]?.toLongOrNull() + if (ms != null && ms > 0) { + stats.computeTimeMs += ms.coerceAtMost(900_000L) + } else if (turn.assistantMessage.isNotEmpty()) { + val estMs = (turn.assistantMessage.length / 120.0 * 1000.0).toLong() + stats.computeTimeMs += estMs.coerceIn(2000L, 60000L) + } + } + } + + return modelStatsMap.entries.map { (modelName, stats) -> + val modelPromptTokens = (stats.promptChars + 3) / 4 + val modelResponseTokens = (stats.responseChars + 3) / 4 + val modelTotalTokens = modelPromptTokens + modelResponseTokens + val speedTps = if (stats.computeTimeMs > 0) { + (modelTotalTokens.toDouble() * 1000.0) / stats.computeTimeMs + } else 0.0 + ModelItemStats( + modelName = modelName, + turnCount = stats.turnCount, + promptChars = stats.promptChars, + responseChars = stats.responseChars, + computeTimeMs = stats.computeTimeMs, + totalTokens = modelTotalTokens, + speedTps = speedTps + ) + } + } + + override val sourceGroups: List> + get() = searchResults.groupBy { it.session.sourceId } + .mapValues { it.value.size } + .toList() + .sortedByDescending { it.second } + + override fun getGroupSessionCount(groupName: String, defaultValue: Int): Int { + return defaultValue + } +} diff --git a/app-desktop/src/desktopMain/kotlin/com/whataicando/codeoba/desktop/provider/WorkspaceStatsProvider.kt b/app-desktop/src/desktopMain/kotlin/com/whataicando/codeoba/desktop/provider/WorkspaceStatsProvider.kt new file mode 100644 index 0000000..6095e8d --- /dev/null +++ b/app-desktop/src/desktopMain/kotlin/com/whataicando/codeoba/desktop/provider/WorkspaceStatsProvider.kt @@ -0,0 +1,31 @@ +package com.whataicando.codeoba.desktop.provider + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.whataicando.codeoba.core.domain.search.SearchResult +import com.whataicando.codeoba.core.util.DebugStoreConfig + +interface WorkspaceStatsProvider { + val totalConversations: Int + val totalTurns: Int + val promptTokens: Long + val responseTokens: Long + val totalEstTokens: Long + val avgTurns: Float + val totalDurationMs: Long + val avgDurationMs: Long + val avgSpeedText: String + val totalCompactions: Int + val totalCompactionTimeMs: Long + val modelStatsList: List + val sourceGroups: List> + + fun getGroupSessionCount(groupName: String, defaultValue: Int): Int +} + +@Composable +fun rememberWorkspaceStatsProvider(searchResults: List): WorkspaceStatsProvider { + return remember(searchResults) { + if (DebugStoreConfig.isStoreMode) CannedWorkspaceStatsProvider(searchResults) else RealWorkspaceStatsProvider(searchResults) + } +} diff --git a/app-desktop/src/desktopMain/resources/entitlements.plist b/app-desktop/src/desktopMain/resources/entitlements.plist new file mode 100644 index 0000000..46f6756 --- /dev/null +++ b/app-desktop/src/desktopMain/resources/entitlements.plist @@ -0,0 +1,12 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + + diff --git a/core/build.gradle.kts b/core/build.gradle.kts index b0f483f..d7d5b30 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -76,15 +76,44 @@ val generateBuildConfig = tasks.register("generateBuildConfig") { val outputDir = file("${layout.buildDirectory.get().asFile}/generated-sources/buildconfig") outputs.dir(outputDir) doLast { - val enableSub = if (localPropsFile.exists()) { - val props = Properties() + val props = Properties() + if (localPropsFile.exists()) { localPropsFile.inputStream().use { props.load(it) } - props.getProperty("codeoba.enable_subscription")?.toBoolean() ?: false - } else { - false } - + val enableSub = System.getenv("CODEOBA_ENABLE_SUBSCRIPTION")?.toBoolean() + ?: project.findProperty("codeoba.enable_subscription")?.toString()?.toBoolean() + ?: props.getProperty("codeoba.enable_subscription")?.toBoolean() + ?: false + + val premiumPublicKey = System.getenv("CODEOBA_PREMIUM_PUBLIC_KEY") + ?: project.findProperty("codeoba.premium.public_key")?.toString() + ?: props.getProperty("codeoba.premium.public_key") + ?: "" + + if (enableSub && premiumPublicKey.isBlank()) { + throw GradleException( + "Error: Subscription features are enabled (codeoba.enable_subscription=true), but the premium public verification key is missing.\n" + + "Please configure 'codeoba.premium.public_key' in local.properties, or run developer key generation tasks to set up verification keys." + ) + } + val defaultFirebaseApiKey = "EMULATOR_ONLY" + val firebaseApiKey = System.getenv("CODEOBA_FIREBASE_API_KEY") + ?: project.findProperty("codeoba.firebase.api_key")?.toString() + ?: props.getProperty("codeoba.firebase.api_key") + ?: defaultFirebaseApiKey + + val defaultAppSignature = "DEVELOPMENT_ONLY" + val appSignature = System.getenv("CODEOBA_APP_SIGNATURE_HASH") + ?: project.findProperty("codeoba.app_signature_hash")?.toString() + ?: props.getProperty("codeoba.app_signature_hash") + ?: defaultAppSignature + + val isDebug = System.getenv("CODEOBA_DEBUG")?.toBoolean() + ?: project.findProperty("codeoba.debug")?.toString()?.toBoolean() + ?: props.getProperty("codeoba.debug")?.toBoolean() + ?: !project.gradle.startParameter.taskNames.any { it.contains("Release", ignoreCase = true) } + val buildConfigFile = file("$outputDir/com/whataicando/codeoba/core/util/BuildConfig.kt") buildConfigFile.parentFile.mkdirs() buildConfigFile.writeText(""" @@ -92,6 +121,10 @@ val generateBuildConfig = tasks.register("generateBuildConfig") { object BuildConfig { const val ENABLE_SUBSCRIPTION = $enableSub + const val PREMIUM_PUBLIC_KEY = "$premiumPublicKey" + const val FIREBASE_API_KEY = "$firebaseApiKey" + const val APP_SIGNATURE = "$appSignature" + const val DEBUG = $isDebug } """.trimIndent()) } diff --git a/core/src/commonMain/kotlin/com/whataicando/codeoba/core/domain/auth/FirebaseAuthClient.kt b/core/src/commonMain/kotlin/com/whataicando/codeoba/core/domain/auth/FirebaseAuthClient.kt index 6c34cf9..0ae713e 100644 --- a/core/src/commonMain/kotlin/com/whataicando/codeoba/core/domain/auth/FirebaseAuthClient.kt +++ b/core/src/commonMain/kotlin/com/whataicando/codeoba/core/domain/auth/FirebaseAuthClient.kt @@ -28,6 +28,7 @@ object FirebaseAuthClient { val env = System.getenv("CODEOBA_FIREBASE_API_KEY")?.trim() prop.takeUnless { it.isNullOrBlank() } ?: env.takeUnless { it.isNullOrBlank() } + ?: com.whataicando.codeoba.core.util.BuildConfig.FIREBASE_API_KEY.takeUnless { it == "EMULATOR_ONLY" } ?: DEFAULT_FIREBASE_API_KEY } catch (_: Throwable) { DEFAULT_FIREBASE_API_KEY @@ -51,7 +52,25 @@ object FirebaseAuthClient { private val useEmulator: Boolean get() = AppConfig.useEmulator() - + private fun extractErrorMessage(responseText: String, defaultMsg: String): String { + if (responseText.isBlank()) return defaultMsg + return try { + val root = json.parseToJsonElement(responseText).jsonObject + val errorObj = root["error"]?.jsonObject + val message = errorObj?.get("message")?.jsonPrimitive?.content + val status = errorObj?.get("status")?.jsonPrimitive?.content + if (message != null && status != null) { + "$message (Status: $status)" + } else if (message != null) { + message + } else { + val errorDetails = root["error"]?.jsonPrimitive?.content + errorDetails ?: responseText.take(500) + } + } catch (_: Exception) { + responseText.take(500) + } + } suspend fun refreshIdToken(refreshToken: String): AuthResponse { return withContext(Dispatchers.IO) { @@ -112,13 +131,15 @@ object FirebaseAuthClient { val response: HttpResponse = client.post(url) { header("Authorization", "Bearer $idToken") + header("X-App-Signature", com.whataicando.codeoba.core.util.BuildConfig.APP_SIGNATURE) contentType(ContentType.Application.Json) setBody(bodyStr) } val responseText = response.bodyAsText() if (response.status.value != 200) { - throw FirebaseAuthException(response.status.value, "Failed to get registration challenge. Status: ${response.status.value}") + val errorMsg = extractErrorMessage(responseText, "Failed to get registration challenge. Status: ${response.status.value}") + throw FirebaseAuthException(response.status.value, errorMsg) } val root = json.parseToJsonElement(responseText).jsonObject @@ -146,13 +167,15 @@ object FirebaseAuthClient { val response: HttpResponse = client.post(url) { header("Authorization", "Bearer $idToken") + header("X-App-Signature", com.whataicando.codeoba.core.util.BuildConfig.APP_SIGNATURE) contentType(ContentType.Application.Json) setBody(bodyStr) } val responseText = response.bodyAsText() if (response.status.value != 200) { - throw FirebaseAuthException(response.status.value, "Failed to register device in Sync Hub. Status: ${response.status.value}") + val errorMsg = extractErrorMessage(responseText, "Failed to register device in Sync Hub. Status: ${response.status.value}") + throw FirebaseAuthException(response.status.value, errorMsg) } val root = json.parseToJsonElement(responseText).jsonObject @@ -174,17 +197,15 @@ object FirebaseAuthClient { val response: HttpResponse = client.post(url) { header("Authorization", "Bearer $idToken") + header("X-App-Signature", com.whataicando.codeoba.core.util.BuildConfig.APP_SIGNATURE) contentType(ContentType.Application.Json) setBody(bodyStr) } val responseText = response.bodyAsText() if (response.status.value != 200) { - val errorMsg = try { - val root = json.parseToJsonElement(responseText).jsonObject - root["error"]?.jsonObject?.get("message")?.jsonPrimitive?.content - } catch (_: Exception) { null } - throw FirebaseAuthException(response.status.value, errorMsg ?: "Failed to get customer portal URL. Status: ${response.status.value}") + val errorMsg = extractErrorMessage(responseText, "Failed to get customer portal URL. Status: ${response.status.value}") + throw FirebaseAuthException(response.status.value, errorMsg) } val root = json.parseToJsonElement(responseText).jsonObject @@ -192,6 +213,36 @@ object FirebaseAuthClient { result["url"]?.jsonPrimitive?.content ?: throw Exception("URL missing in response") } } + + suspend fun checkSubscriptionStatus(idToken: String): Boolean { + return withContext(Dispatchers.IO) { + val url = if (useEmulator) { + "http://127.0.0.1:5001/$firebaseProjectId/$CLOUD_FUNCTION_REGION/checkSubscriptionStatus" + } else { + "https://$CLOUD_FUNCTION_REGION-$firebaseProjectId.cloudfunctions.net/checkSubscriptionStatus" + } + val bodyStr = buildJsonObject { + put("data", buildJsonObject {}) + }.toString() + + val response: HttpResponse = client.post(url) { + header("Authorization", "Bearer $idToken") + header("X-App-Signature", com.whataicando.codeoba.core.util.BuildConfig.APP_SIGNATURE) + contentType(ContentType.Application.Json) + setBody(bodyStr) + } + + val responseText = response.bodyAsText() + if (response.status.value != 200) { + val errorMsg = extractErrorMessage(responseText, "Failed to check subscription status. Status: ${response.status.value}") + throw FirebaseAuthException(response.status.value, errorMsg) + } + + val root = json.parseToJsonElement(responseText).jsonObject + val result = root["result"]?.jsonObject ?: throw Exception("Invalid subscription response format") + result["active"]?.jsonPrimitive?.content == "true" + } + } } data class AuthResponse( diff --git a/core/src/commonMain/kotlin/com/whataicando/codeoba/core/domain/model/PremiumManifest.kt b/core/src/commonMain/kotlin/com/whataicando/codeoba/core/domain/model/PremiumManifest.kt new file mode 100644 index 0000000..c348f4a --- /dev/null +++ b/core/src/commonMain/kotlin/com/whataicando/codeoba/core/domain/model/PremiumManifest.kt @@ -0,0 +1,12 @@ +package com.whataicando.codeoba.core.domain.model + +import kotlinx.serialization.Serializable + +@Serializable +data class PremiumManifest( + val version: String, + val jarHash: String, // SHA-256 + val signature: String, // Base64 encoded Ed25519 signature + val entrypointClass: String, + val watermarkId: String? = null +) diff --git a/core/src/commonMain/kotlin/com/whataicando/codeoba/core/domain/parser/LogParser.kt b/core/src/commonMain/kotlin/com/whataicando/codeoba/core/domain/parser/LogParser.kt index 1a85f0c..b24f070 100644 --- a/core/src/commonMain/kotlin/com/whataicando/codeoba/core/domain/parser/LogParser.kt +++ b/core/src/commonMain/kotlin/com/whataicando/codeoba/core/domain/parser/LogParser.kt @@ -21,9 +21,23 @@ class SummarizingLogParser(private val parserConfigJson: String?) : LogParser { val baseSession = delegateParse() ?: return null val summary = try { - // Local model stub simulation using parser config configuration parameters - val summaryText = LocalModelRunner.runLocalInference(baseSession, parserConfigJson) - parseSummaryJson(summaryText) + when (val result = SummarizerProvider.current().summarize(baseSession, parserConfigJson)) { + is SummaryResult.Ok -> result.summary + is SummaryResult.Unavailable -> { + SessionSummary( + keyActions = listOf("AI-powered summarization requires an active subscription."), + errors = listOf(result.reason), + performanceCharts = emptyList() + ) + } + is SummaryResult.Failed -> { + SessionSummary( + keyActions = listOf("AI summarization failed"), + errors = listOf(result.reason + (result.cause?.let { " Cause: $it" } ?: "")), + performanceCharts = emptyList() + ) + } + } } catch (e: Exception) { SessionSummary( keyActions = listOf("AI summarization failed"), @@ -34,20 +48,6 @@ class SummarizingLogParser(private val parserConfigJson: String?) : LogParser { return baseSession.copy(summary = summary) } - - private fun parseSummaryJson(jsonStr: String): SessionSummary { - return try { - val trimmed = jsonStr.trim() - val json = kotlinx.serialization.json.Json { ignoreUnknownKeys = true } - json.decodeFromString(SessionSummary.serializer(), trimmed) - } catch (e: Exception) { - SessionSummary( - keyActions = listOf("Unable to parse AI summary outputs"), - errors = listOf("Format exception: ${e.message}"), - performanceCharts = emptyList() - ) - } - } } object LogParserFactory { @@ -78,83 +78,3 @@ object LogParserFactory { } } -object LocalModelRunner { - fun runLocalInference(session: Session, parserConfigJson: String?): String { - // In a real production environment, this is where we load the model weights - // (e.g. weights.bin / model.onnx) and run them via llama.cpp or ONNX Runtime JVM bindings. - // The parserConfigJson contains configuration parameters retrieved from the server. - // If parserConfigJson is missing or invalid, we fail inference. - if (parserConfigJson.isNullOrBlank()) { - throw IllegalStateException("Parser configuration is missing or invalid.") - } - - // Simulating structured output based on the session turns - val actions = mutableListOf() - val errors = mutableListOf() - - val turnCount = session.turns.size - actions.add("Parsed $turnCount dialogue exchanges from source '${session.sourceId}'") - - val uniqueModels = session.turns.mapNotNull { it.extraData["model"] }.distinct() - if (uniqueModels.isNotEmpty()) { - actions.add("Identified active agent models: ${uniqueModels.joinToString(", ")}") - } - - var compactionCount = 0 - var totalComputeMs = 0L - for (turn in session.turns) { - val userMsg = turn.userMessage.lowercase() - val asstMsg = turn.assistantMessage.lowercase() - - if (turn.extraData["isCompaction"] == "true") { - compactionCount++ - } - val ms = turn.extraData["computeTimeMs"]?.toLongOrNull() ?: 0L - totalComputeMs += ms - - if (userMsg.contains("error") || asstMsg.contains("error") || asstMsg.contains("exception") || asstMsg.contains("fail")) { - val excerpt = if (turn.userMessage.length > 40) turn.userMessage.take(40) + "..." else turn.userMessage - errors.add("Detected execution failure or warning in turn user query: '$excerpt'") - } - } - - if (compactionCount > 0) { - actions.add("Analyzed $compactionCount context compaction events during session execution") - } - if (totalComputeMs > 0) { - actions.add("Measured total active compute work duration: ${totalComputeMs}ms") - } - - if (errors.isEmpty()) { - errors.add("No critical runtime or execution exceptions detected in this thread.") - } - - val lexicalSpeed = (300.0 + (turnCount * 12.5)).coerceAtMost(600.0) - val semanticSpeed = (100.0 + (turnCount * 5.2)).coerceAtMost(250.0) - val watcherLatency = (10.0 + (turnCount * 0.8)).coerceAtMost(50.0) - - val jsonObject = buildJsonObject { - put("keyActions", buildJsonArray { - actions.forEach { add(it) } - }) - put("errors", buildJsonArray { - errors.forEach { add(it) } - }) - put("performanceCharts", buildJsonArray { - add(buildJsonObject { - put("label", "Lexical Search Speed") - put("value", lexicalSpeed) - }) - add(buildJsonObject { - put("label", "Semantic Search Speed") - put("value", semanticSpeed) - }) - add(buildJsonObject { - put("label", "Directory Watcher Latency") - put("value", watcherLatency) - }) - }) - } - return jsonObject.toString() - } -} diff --git a/core/src/commonMain/kotlin/com/whataicando/codeoba/core/domain/parser/StubSummarizer.kt b/core/src/commonMain/kotlin/com/whataicando/codeoba/core/domain/parser/StubSummarizer.kt new file mode 100644 index 0000000..738d67a --- /dev/null +++ b/core/src/commonMain/kotlin/com/whataicando/codeoba/core/domain/parser/StubSummarizer.kt @@ -0,0 +1,9 @@ +package com.whataicando.codeoba.core.domain.parser + +import com.whataicando.codeoba.core.domain.model.Session + +class StubSummarizer : Summarizer { + override fun summarize(session: Session, parserConfigJson: String?): SummaryResult { + return SummaryResult.Unavailable("AI-powered summarization requires an active Codeoba subscription.") + } +} diff --git a/core/src/commonMain/kotlin/com/whataicando/codeoba/core/domain/parser/Summarizer.kt b/core/src/commonMain/kotlin/com/whataicando/codeoba/core/domain/parser/Summarizer.kt new file mode 100644 index 0000000..202955e --- /dev/null +++ b/core/src/commonMain/kotlin/com/whataicando/codeoba/core/domain/parser/Summarizer.kt @@ -0,0 +1,13 @@ +package com.whataicando.codeoba.core.domain.parser + +import com.whataicando.codeoba.core.domain.model.Session + +interface Summarizer { + fun summarize(session: Session, parserConfigJson: String?): SummaryResult +} + +sealed interface SummaryResult { + data class Ok(val summary: SessionSummary) : SummaryResult + data class Unavailable(val reason: String) : SummaryResult + data class Failed(val reason: String, val cause: String? = null) : SummaryResult +} diff --git a/core/src/commonMain/kotlin/com/whataicando/codeoba/core/domain/parser/SummarizerProvider.kt b/core/src/commonMain/kotlin/com/whataicando/codeoba/core/domain/parser/SummarizerProvider.kt new file mode 100644 index 0000000..bc2731f --- /dev/null +++ b/core/src/commonMain/kotlin/com/whataicando/codeoba/core/domain/parser/SummarizerProvider.kt @@ -0,0 +1,17 @@ +package com.whataicando.codeoba.core.domain.parser + +import java.util.concurrent.atomic.AtomicReference + +object SummarizerProvider { + private val currentRef = AtomicReference(StubSummarizer()) + + fun current(): Summarizer = currentRef.get() + + fun install(real: Summarizer) { + currentRef.set(real) + } + + fun revertToStub() { + currentRef.set(StubSummarizer()) + } +} diff --git a/core/src/commonMain/kotlin/com/whataicando/codeoba/core/util/AppConfig.kt b/core/src/commonMain/kotlin/com/whataicando/codeoba/core/util/AppConfig.kt index f8c1228..95c1d4d 100644 --- a/core/src/commonMain/kotlin/com/whataicando/codeoba/core/util/AppConfig.kt +++ b/core/src/commonMain/kotlin/com/whataicando/codeoba/core/util/AppConfig.kt @@ -1,10 +1,18 @@ package com.whataicando.codeoba.core.util +/** + * Centralized configuration utility for the Codeoba application environment. + * + * Resolves environmental variables, host domains, base URLs, and determines + * target Firebase project environments and local emulator states. + */ object AppConfig { /** - * Determines the active environment host/base URL. - * Can be specified via JVM system property `-Dcodeoba.base_url=...`. - * If not specified, defaults to "codeoba.com". + * Determines the active base URL / host domain of the environment (e.g. `"codeoba.com"`, `"localhost:5000"`, `"dev.codeoba.com"`). + * Resolves from the JVM system property `-Dcodeoba.base_url=...`. + * Defaults to `"codeoba.com"` if the property is missing or empty. + * + * @return The parsed base URL. */ fun getBaseUrl(): String { val raw = System.getProperty("codeoba.base_url")?.trim().orEmpty() @@ -12,66 +20,70 @@ object AppConfig { val normalized = raw.trimEnd('/') return try { - val uri = if (normalized.startsWith("http://") || normalized.startsWith("https://")) { - java.net.URI(normalized) - } else { - // Add a scheme purely for parsing; we only return host[:port] - java.net.URI("https://$normalized") - } - val host = uri.host ?: normalized.replace(Regex("^https?://"), "") - val portPart = if (uri.port != -1) ":${uri.port}" else "" - (host + portPart).trimEnd('/') + val uri = java.net.URI(if (normalized.startsWith("http://") || normalized.startsWith("https://")) normalized else "https://$normalized") + val port = if (uri.port != -1) ":${uri.port}" else "" + ((uri.host ?: normalized.replace(Regex("^https?://"), "")) + port).trimEnd('/') } catch (_: Exception) { normalized.replace(Regex("^https?://"), "").trimEnd('/') } } /** - * Determines if a given host points to a local environment (localhost or 127.0.0.1, with or without a port). + * Checks if the given [host] represents a local loopback domain (e.g. `"localhost"` or `"127.0.0.1"`), + * with or without an explicit port configuration. + * + * @param host The host string to evaluate. + * @return `true` if the host represents a local address. */ - fun isLocalHost(host: String): Boolean { - return isRawLocalHost(host) || host.startsWith("localhost:") || - host.startsWith("127.0.0.1:") - } + fun isLocalHost(host: String) = + host == "localhost" || host == "127.0.0.1" || + host.startsWith("localhost:") || host.startsWith("127.0.0.1:") /** - * Determines if a given host is exactly "localhost" or "127.0.0.1" without an explicit port. + * Checks if the given [host] is exactly `"localhost"` or `"127.0.0.1"` without an explicit port suffix. + * + * @param host The host string to evaluate. + * @return `true` if the host matches exactly. */ - fun isRawLocalHost(host: String): Boolean { - return host == "localhost" || host == "127.0.0.1" - } + fun isRawLocalHost(host: String) = host == "localhost" || host == "127.0.0.1" /** - * Determines if the application is running in the local emulator environment. - * This is true if the base URL points to localhost or 127.0.0.1. + * Determines if the application is configured to run against the local Firebase emulator suite. + * + * @return `true` if the active base URL points to a local address. */ - fun useEmulator(): Boolean { - return isLocalHost(getBaseUrl()) - } + fun useEmulator() = isLocalHost(getBaseUrl()) /** - * Resolves the web console URL based on the specified base URL. + * Resolves the URL for the web console / dashboard based on the active environment configuration. + * Maps local hostnames to emulator port 5000 and secure HTTPS schemes for remote domains. + * + * @return The fully formatted web console URL string. */ fun getWebConsoleUrl(): String { - val host = getBaseUrl() - return if (useEmulator()) { - val emulatorHost = if (isRawLocalHost(host)) "$host:5000" else host - "http://$emulatorHost" - } else { - "https://$host" - } + val baseUrl = getBaseUrl() + if (!isLocalHost(baseUrl)) return "https://$baseUrl" + val emulatorHost = if (isRawLocalHost(baseUrl)) "$baseUrl:5000" else baseUrl + return "http://$emulatorHost" } /** - * Resolves the Firebase Project ID based on the specified base URL. + * Determines whether the active environment is local development (emulator) or a staging environment. + * This environment targets the `"codeoba-dev"` Firebase project. + * + * @return `true` if the active environment is an emulator or development configuration. */ - fun getFirebaseProjectId(): String { - val host = getBaseUrl() - val isDev = useEmulator() || Regex("(^|\\.)codeoba-dev(\\.|$)").containsMatchIn(host) - return if (isDev) { - "codeoba-dev" - } else { - "codeoba-prod" + fun isEmulatorOrDev() = + useEmulator() || + getBaseUrl().substringBefore(':').let { domain -> + domain == "dev.codeoba.com" || + Regex("(^|\\.)codeoba-dev(\\.|$)").containsMatchIn(domain) } - } + + /** + * Resolves the active Firebase Project ID target string based on [isEmulatorOrDev]. + * + * @return `"codeoba-dev"` for developer/staging environments, otherwise `"codeoba-prod"`. + */ + fun getFirebaseProjectId() = if (isEmulatorOrDev()) "codeoba-dev" else "codeoba-prod" } diff --git a/core/src/desktopMain/kotlin/com/whataicando/codeoba/core/auth/LocalAuthServer.kt b/core/src/desktopMain/kotlin/com/whataicando/codeoba/core/auth/LocalAuthServer.kt index b5d8f32..ffc1a2a 100644 --- a/core/src/desktopMain/kotlin/com/whataicando/codeoba/core/auth/LocalAuthServer.kt +++ b/core/src/desktopMain/kotlin/com/whataicando/codeoba/core/auth/LocalAuthServer.kt @@ -24,7 +24,7 @@ object LocalAuthServer { "https://codeoba-dev.firebaseapp.com" ) - fun start(onSuccess: (idToken: String, refreshToken: String, email: String, uid: String) -> Unit): Int = synchronized(this) { + fun start(onSuccess: (idToken: String, refreshToken: String, email: String, uid: String, isSubscribed: Boolean) -> Unit): Int = synchronized(this) { // Stop any running instance first stop() @@ -146,8 +146,10 @@ object LocalAuthServer { val refreshToken = if (isPost) params["refreshToken"] else null val email = if (isPost) (params["email"] ?: "") else "" val uid = if (isPost) (params["uid"] ?: "") else "" + val subscriptionStatus = if (isPost) (params["subscriptionStatus"] ?: "inactive") else "inactive" + val isSubscribed = subscriptionStatus == "active" val responseBody = if (!idToken.isNullOrBlank() && !refreshToken.isNullOrBlank() && uid.isNotBlank()) { - onSuccess(idToken, refreshToken, email, uid) + onSuccess(idToken, refreshToken, email, uid, isSubscribed) wasSuccessful = true if (isPost) { """ diff --git a/core/src/desktopMain/kotlin/com/whataicando/codeoba/core/manager/GroupManager.kt b/core/src/desktopMain/kotlin/com/whataicando/codeoba/core/manager/GroupManager.kt index 5bce459..88ea72d 100644 --- a/core/src/desktopMain/kotlin/com/whataicando/codeoba/core/manager/GroupManager.kt +++ b/core/src/desktopMain/kotlin/com/whataicando/codeoba/core/manager/GroupManager.kt @@ -3,6 +3,7 @@ package com.whataicando.codeoba.core.manager import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import com.whataicando.codeoba.core.domain.model.ConversationGroup +import com.whataicando.codeoba.core.util.DebugStoreConfig import com.whataicando.codeoba.core.util.Logger.log import java.io.File @@ -62,6 +63,9 @@ object GroupManager { } fun getGroups(): List = synchronized(lock) { + if (DebugStoreConfig.isStoreMode) { + return DebugStoreConfig.cannedGroups + } if (inMemoryGroups.isEmpty()) { loadGroups() } diff --git a/core/src/desktopMain/kotlin/com/whataicando/codeoba/core/manager/IndexManager.kt b/core/src/desktopMain/kotlin/com/whataicando/codeoba/core/manager/IndexManager.kt index de656b9..ce6a66b 100644 --- a/core/src/desktopMain/kotlin/com/whataicando/codeoba/core/manager/IndexManager.kt +++ b/core/src/desktopMain/kotlin/com/whataicando/codeoba/core/manager/IndexManager.kt @@ -9,6 +9,8 @@ import com.whataicando.codeoba.core.domain.search.SearchEngine import com.whataicando.codeoba.core.domain.source.SourceRegistry import com.whataicando.codeoba.core.util.Logger.log import com.whataicando.codeoba.core.watcher.DirectoryWatcher +import com.whataicando.codeoba.core.util.BuildConfig +import com.whataicando.codeoba.core.util.DebugStoreConfig import java.io.File class IndexManager( @@ -40,6 +42,27 @@ class IndexManager( log("IndexManager: Beginning initial scan and watch...") try { + val isCannedMode = DebugStoreConfig.isCannedDataMode + if (isCannedMode) { + onProgressCallback?.invoke("Loading canned screenshot data...") + log("IndexManager: Loading canned data for store mode...") + try { + val cannedSessions = loadCannedSessions() + log("IndexManager: Loaded ${cannedSessions.size} canned sessions.") + + onProgressCallback?.invoke("Updating search index...") + searchEngine.updateIndex(cannedSessions) { processed, total -> + onProgressCallback?.invoke("Indexing: $processed / $total...") + } + log("IndexManager: Canned index updated successfully. Notifying listeners...") + notifyListeners() + return@withContext + } catch (e: Exception) { + log("IndexManager: Failed to load canned sessions:", e) + onProgressCallback?.invoke("Error loading canned sessions: ${e.message}") + } + } + SessionCacheManager.isCacheEnabled = cacheEnabled val allSessions = mutableListOf() val activeAdapters = sourceRegistry.getActiveAdapters() @@ -189,4 +212,39 @@ class IndexManager( onIndexUpdatedListeners.forEach { it() } } } + + private fun loadCannedSessions(): List { + val path = DebugStoreConfig.cannedDataPath ?: run { + val store = DebugStoreConfig.storeMode ?: "apple" + "store/canned_$store.json" + } + val file = File(path) + val resolvedFile = if (file.isAbsolute) { + file + } else { + val cwdFile = File(System.getProperty("user.dir"), path) + if (cwdFile.exists()) { + cwdFile + } else { + val parentFile = File(System.getProperty("user.dir"), "../$path") + if (parentFile.exists()) { + parentFile + } else { + cwdFile + } + } + } + + if (!resolvedFile.exists()) { + throw java.io.FileNotFoundException("Canned data file not found at: ${resolvedFile.absolutePath}") + } + + val jsonParser = kotlinx.serialization.json.Json { + ignoreUnknownKeys = true + prettyPrint = true + } + + val content = resolvedFile.readText() + return jsonParser.decodeFromString>(content) + } } diff --git a/core/src/desktopMain/kotlin/com/whataicando/codeoba/core/premium/ClassLoaderInstaller.kt b/core/src/desktopMain/kotlin/com/whataicando/codeoba/core/premium/ClassLoaderInstaller.kt new file mode 100644 index 0000000..6d8ee9e --- /dev/null +++ b/core/src/desktopMain/kotlin/com/whataicando/codeoba/core/premium/ClassLoaderInstaller.kt @@ -0,0 +1,50 @@ +package com.whataicando.codeoba.core.premium + +import com.whataicando.codeoba.core.domain.parser.Summarizer +import java.io.File +import java.net.URLClassLoader + +private class FilteredParentClassLoader(private val delegate: ClassLoader) : ClassLoader(null) { + override fun loadClass(name: String, resolve: Boolean): Class<*> { + // 1. Allow JDK/Standard classes (java.*, javax.*, sun.*, etc.) + if (name.startsWith("java.") || + name.startsWith("javax.") || + name.startsWith("sun.") || + name.startsWith("com.sun.") || + name.startsWith("jdk.") || + name.startsWith("org.w3c.dom.") || + name.startsWith("org.xml.sax.")) { + return super.loadClass(name, resolve) + } + + // 2. Allow only classes from the API contract package and basic Kotlin runtime / serialization classes + if (name.startsWith("com.whataicando.codeoba.core.domain.parser.") || + name.startsWith("com.whataicando.codeoba.core.domain.model.") || + name.startsWith("kotlin.") || + name.startsWith("kotlinx.serialization.")) { + val clazz = delegate.loadClass(name) + if (resolve) { + resolveClass(clazz) + } + return clazz + } + + // 3. Deny access to anything else (SettingsManager, SecureStorage, etc.) + throw ClassNotFoundException("Access violation: Class $name is outside the API contract.") + } +} + +object ClassLoaderInstaller { + fun install(jarFile: File, entrypointClass: String): Summarizer { + require(entrypointClass.startsWith("com.whataicando.codeoba.premium.") || + entrypointClass.startsWith("com.whataicando.codeoba.core.premium.")) { + "Security violation: entrypoint class must reside in the com.whataicando.codeoba.premium package." + } + val contractLoader = Summarizer::class.java.classLoader + val parent = FilteredParentClassLoader(contractLoader) + val classLoader = URLClassLoader(arrayOf(jarFile.toURI().toURL()), parent) + val clazz = Class.forName(entrypointClass, true, classLoader) + val instance = clazz.getDeclaredConstructor().newInstance() + return instance as Summarizer + } +} diff --git a/core/src/desktopMain/kotlin/com/whataicando/codeoba/core/premium/PremiumCache.kt b/core/src/desktopMain/kotlin/com/whataicando/codeoba/core/premium/PremiumCache.kt new file mode 100644 index 0000000..d774b79 --- /dev/null +++ b/core/src/desktopMain/kotlin/com/whataicando/codeoba/core/premium/PremiumCache.kt @@ -0,0 +1,84 @@ +package com.whataicando.codeoba.core.premium + +import com.whataicando.codeoba.core.domain.model.PremiumManifest +import com.whataicando.codeoba.core.security.PayloadVerifier +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.io.File +import java.security.MessageDigest +import java.util.Base64 + +object PremiumCache { + private val json = Json { ignoreUnknownKeys = true } + + fun getPremiumDir(): File { + val userHome = System.getProperty("user.home") + val dir = File(userHome, ".codeoba/premium") + if (!dir.exists()) { + dir.mkdirs() + } + return dir + } + + fun getJarFile(): File = File(getPremiumDir(), "premium.jar") + fun getManifestFile(): File = File(getPremiumDir(), "premium-manifest.json") + + fun getCachedManifest(): PremiumManifest? { + val file = getManifestFile() + if (!file.exists()) return null + return try { + json.decodeFromString(file.readText()) + } catch (e: Exception) { + null + } + } + + fun saveManifest(manifest: PremiumManifest) { + getManifestFile().writeText(json.encodeToString(PremiumManifest.serializer(), manifest)) + } + + fun verifyCachedPayload(): Boolean { + val jar = getJarFile() + val manifest = getCachedManifest() ?: return false + if (!jar.exists()) return false + + val jarBytes = jar.readBytes() + + // 1. Verify Hash + val computedHash = sha256(jarBytes) + if (computedHash != manifest.jarHash) { + return false + } + + // 2. Verify Signature + val signatureBytes = Base64.getDecoder().decode(manifest.signature) + return PayloadVerifier.verify(jarBytes, signatureBytes) + } + + fun clearCache() { + val jar = getJarFile() + val manifest = getManifestFile() + if (jar.exists()) jar.delete() + if (manifest.exists()) manifest.delete() + } + + fun sha256(bytes: ByteArray): String { + val digest = MessageDigest.getInstance("SHA-256") + val hash = digest.digest(bytes) + return hash.joinToString("") { "%02x".format(it) } + } + + fun saveLastSyncTimestamp() { + com.whataicando.codeoba.core.util.SecureStorage.put("premium_last_sync_ms", System.currentTimeMillis().toString()) + } + + fun getLastSyncTimestamp(): Long? { + return com.whataicando.codeoba.core.util.SecureStorage.get("premium_last_sync_ms")?.toLongOrNull() + } + + fun isWithinGracePeriod(graceMs: Long = 24 * 60 * 60 * 1000L): Boolean { + val lastSync = getLastSyncTimestamp() ?: return false + val elapsed = System.currentTimeMillis() - lastSync + return elapsed in 0 until graceMs + } +} diff --git a/core/src/desktopMain/kotlin/com/whataicando/codeoba/core/premium/PremiumLoader.kt b/core/src/desktopMain/kotlin/com/whataicando/codeoba/core/premium/PremiumLoader.kt new file mode 100644 index 0000000..979e8fe --- /dev/null +++ b/core/src/desktopMain/kotlin/com/whataicando/codeoba/core/premium/PremiumLoader.kt @@ -0,0 +1,162 @@ +package com.whataicando.codeoba.core.premium + +import com.whataicando.codeoba.core.domain.model.PremiumManifest +import com.whataicando.codeoba.core.domain.parser.SummarizerProvider +import com.whataicando.codeoba.core.util.AppConfig +import com.whataicando.codeoba.core.util.Logger.log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import java.io.BufferedInputStream +import java.io.File +import java.io.FileOutputStream +import java.net.HttpURLConnection +import java.net.URI + +object PremiumLoader { + private val json = Json { ignoreUnknownKeys = true } + + /** + * Synchronizes the premium module. If subscription is active, it checks/downloads/loads the JAR. + * If not active, it reverts to the StubSummarizer and cleans up. + */ + suspend fun sync(isSubscribed: Boolean) { + withContext(Dispatchers.IO) { + if (!isSubscribed) { + if (SummarizerProvider.current().javaClass.simpleName != "StubSummarizer") { + log("PremiumLoader: Reverting to StubSummarizer (Ecosystem Sync disabled).") + } + SummarizerProvider.revertToStub() + return@withContext + } + + try { + // 1. Check for local developer override first (extremely useful for tests/dev) + val localOverrideDir = System.getProperty("codeoba.premium.local.dir") + if (!localOverrideDir.isNullOrBlank()) { + val dir = File(localOverrideDir) + val jar = File(dir, "premium.jar") + val manifestFile = File(dir, "premium-manifest.json") + if (jar.exists() && manifestFile.exists()) { + val manifest = json.decodeFromString(manifestFile.readText()) + + // Copy/Save to cache and verify + val jarBytes = jar.readBytes() + if (PremiumCache.sha256(jarBytes) == manifest.jarHash) { + PremiumCache.getJarFile().writeBytes(jarBytes) + PremiumCache.saveManifest(manifest) + if (PremiumCache.verifyCachedPayload()) { + val instance = ClassLoaderInstaller.install( + PremiumCache.getJarFile(), + manifest.entrypointClass + ) + SummarizerProvider.install(instance) + log("PremiumLoader: Successfully loaded local developer override module: ${manifest.entrypointClass}") + return@withContext + } + } + } + } + + // 2. Authenticated download path from Cloud Function via rewrite endpoint + val consoleUrl = AppConfig.getWebConsoleUrl() + + // Get ID token and Device ID + val idToken = com.whataicando.codeoba.core.util.SecureStorage.get("firebase_auth_id_token") + val deviceId = try { + java.util.prefs.Preferences.userRoot().node("com/whataicando/codeoba/desktop").get("device_id", null) + } catch (_: Exception) { + null + } + + val deviceQuery = if (deviceId != null) "&deviceId=${java.net.URLEncoder.encode(deviceId, "UTF-8")}" else "" + val manifestUrl = "$consoleUrl/api/premium-download?file=manifest$deviceQuery" + val jarUrl = "$consoleUrl/api/premium-download?file=jar" + + log("PremiumLoader: Fetching manifest from $manifestUrl") + val manifestBytes = downloadBytes(manifestUrl, idToken) + val serverManifest = json.decodeFromString(manifestBytes.decodeToString()) + + val cachedManifest = PremiumCache.getCachedManifest() + val cacheValid = PremiumCache.verifyCachedPayload() + + if (cacheValid && cachedManifest != null && cachedManifest.jarHash == serverManifest.jarHash) { + // Cache is up to date and valid, update the manifest in cache (keeps watermark fresh) + PremiumCache.saveManifest(serverManifest) + val instance = ClassLoaderInstaller.install( + PremiumCache.getJarFile(), + serverManifest.entrypointClass + ) + SummarizerProvider.install(instance) + PremiumCache.saveLastSyncTimestamp() + log("PremiumLoader: Successfully loaded premium module from cache: ${serverManifest.entrypointClass}") + } else { + // Cache is invalid or outdated, download new jar + log("PremiumLoader: Cache missing or outdated. Syncing module from $jarUrl...") + val jarBytes = downloadBytes(jarUrl, idToken) + + // Verify hash and signature before writing to disk + if (PremiumCache.sha256(jarBytes) != serverManifest.jarHash) { + throw Exception("Downloaded premium JAR hash mismatch.") + } + + val sigBytes = java.util.Base64.getDecoder().decode(serverManifest.signature) + if (!com.whataicando.codeoba.core.security.PayloadVerifier.verify(jarBytes, sigBytes)) { + throw Exception("Downloaded premium JAR signature verification failed.") + } + + // Write to cache + PremiumCache.getJarFile().writeBytes(jarBytes) + PremiumCache.saveManifest(serverManifest) + + val instance = ClassLoaderInstaller.install( + PremiumCache.getJarFile(), + serverManifest.entrypointClass + ) + SummarizerProvider.install(instance) + PremiumCache.saveLastSyncTimestamp() + log("PremiumLoader: Successfully downloaded, verified, and loaded premium module: ${serverManifest.entrypointClass}") + } + } catch (e: Exception) { + log("PremiumLoader: Sync failed: ${e.message}") + // Fall back to stub or try loading cached version as offline grace period if valid + if (PremiumCache.verifyCachedPayload() && PremiumCache.isWithinGracePeriod()) { + try { + val cachedManifest = PremiumCache.getCachedManifest()!! + val instance = ClassLoaderInstaller.install( + PremiumCache.getJarFile(), + cachedManifest.entrypointClass + ) + SummarizerProvider.install(instance) + log("PremiumLoader: Offline fallback to cached premium module succeeded: ${cachedManifest.entrypointClass}") + } catch (inner: Exception) { + log("PremiumLoader: Reverting to StubSummarizer after fallback exception.") + SummarizerProvider.revertToStub() + } + } else { + log("PremiumLoader: Cache invalid or offline grace period expired. Reverting to StubSummarizer.") + SummarizerProvider.revertToStub() + } + } + } + } + + private fun downloadBytes(urlStr: String, idToken: String?): ByteArray { + val url = java.net.URI(urlStr).toURL() + val connection = url.openConnection() as HttpURLConnection + connection.connectTimeout = 5000 + connection.readTimeout = 10000 + connection.requestMethod = "GET" + connection.setRequestProperty("X-App-Signature", com.whataicando.codeoba.core.util.BuildConfig.APP_SIGNATURE) + if (idToken != null) { + connection.setRequestProperty("Authorization", "Bearer $idToken") + } + + val responseCode = connection.responseCode + if (responseCode !in 200..299) { + throw Exception("HTTP Error $responseCode: ${connection.responseMessage} when fetching $urlStr") + } + + return connection.inputStream.use { it.readBytes() } + } +} diff --git a/core/src/desktopMain/kotlin/com/whataicando/codeoba/core/security/DeviceKeyManager.kt b/core/src/desktopMain/kotlin/com/whataicando/codeoba/core/security/DeviceKeyManager.kt index d7a8684..03ef4cb 100644 --- a/core/src/desktopMain/kotlin/com/whataicando/codeoba/core/security/DeviceKeyManager.kt +++ b/core/src/desktopMain/kotlin/com/whataicando/codeoba/core/security/DeviceKeyManager.kt @@ -26,13 +26,15 @@ object DeviceKeyManager { val privBytes = Base64.getDecoder().decode(securePriv) val pubBytes = Base64.getDecoder().decode(securePub) - val kf = KeyFactory.getInstance("RSA") + val kf = KeyFactory.getInstance("EC") val privateKey = kf.generatePrivate(PKCS8EncodedKeySpec(privBytes)) val publicKey = kf.generatePublic(X509EncodedKeySpec(pubBytes)) return KeyPair(publicKey, privateKey) } catch (e: Exception) { - log("DeviceKeyManager: Failed to load keys from SecureStorage: ${e.message}") + log("DeviceKeyManager: Failed to load EC keys from SecureStorage. Migrating/regenerating: ${e.message}") + SecureStorage.delete("device_private_key") + SecureStorage.delete("device_public_key") } } @@ -41,8 +43,8 @@ object DeviceKeyManager { } private fun generateAndSaveKeyPair(): KeyPair { - val kpg = KeyPairGenerator.getInstance("RSA") - kpg.initialize(2048) + val kpg = KeyPairGenerator.getInstance("EC") + kpg.initialize(java.security.spec.ECGenParameterSpec("secp256r1")) val keyPair = kpg.genKeyPair() val privBase64 = Base64.getEncoder().encodeToString(keyPair.private.encoded) @@ -63,7 +65,7 @@ object DeviceKeyManager { fun signPayload(payload: String): String { val kp = getOrGenerateKeyPair() - val sig = java.security.Signature.getInstance("SHA256withRSA") + val sig = java.security.Signature.getInstance("SHA256withECDSA") sig.initSign(kp.private) sig.update(payload.toByteArray(Charsets.UTF_8)) return Base64.getEncoder().encodeToString(sig.sign()) diff --git a/core/src/desktopMain/kotlin/com/whataicando/codeoba/core/security/PayloadVerifier.kt b/core/src/desktopMain/kotlin/com/whataicando/codeoba/core/security/PayloadVerifier.kt new file mode 100644 index 0000000..99ca083 --- /dev/null +++ b/core/src/desktopMain/kotlin/com/whataicando/codeoba/core/security/PayloadVerifier.kt @@ -0,0 +1,34 @@ +package com.whataicando.codeoba.core.security + +import com.whataicando.codeoba.core.util.BuildConfig +import java.security.GeneralSecurityException +import java.security.KeyFactory +import java.security.Signature +import java.security.spec.X509EncodedKeySpec +import java.util.Base64 + +object PayloadVerifier { + @Volatile + private var testPublicKey: java.security.PublicKey? = null + + fun setTestPublicKey(key: java.security.PublicKey?) { + testPublicKey = key + } + + private val publicKey by lazy { + val keyBytes = Base64.getDecoder().decode(BuildConfig.PREMIUM_PUBLIC_KEY) + val spec = X509EncodedKeySpec(keyBytes) + KeyFactory.getInstance("Ed25519").generatePublic(spec) + } + + fun verify(data: ByteArray, signatureBytes: ByteArray): Boolean { + return try { + val sig = Signature.getInstance("Ed25519") + sig.initVerify(testPublicKey ?: publicKey) + sig.update(data) + sig.verify(signatureBytes) + } catch (_: GeneralSecurityException) { + false + } + } +} diff --git a/core/src/desktopMain/kotlin/com/whataicando/codeoba/core/util/DebugStoreConfig.kt b/core/src/desktopMain/kotlin/com/whataicando/codeoba/core/util/DebugStoreConfig.kt new file mode 100644 index 0000000..8329c8e --- /dev/null +++ b/core/src/desktopMain/kotlin/com/whataicando/codeoba/core/util/DebugStoreConfig.kt @@ -0,0 +1,32 @@ +package com.whataicando.codeoba.core.util + +import com.whataicando.codeoba.core.domain.model.ConversationGroup + +/** + * Thread-safe configuration object to hold store screenshot options. + * Honored only when BuildConfig.DEBUG is true. + */ +object DebugStoreConfig { + @Volatile + var storeMode: String? = null // "apple" or "microsoft" + + @Volatile + var sizeOverride: Pair? = null // parsed from --size=WIDTHxHEIGHT + + @Volatile + var cannedDataPath: String? = null // parsed from --canned-data=PATH + + val isStoreMode: Boolean + get() = BuildConfig.DEBUG && storeMode != null + + val isCannedDataMode: Boolean + get() = BuildConfig.DEBUG && (storeMode != null || cannedDataPath != null) + + val cannedGroups: List = listOf( + ConversationGroup(name = "Backend Service", isPinned = true), + ConversationGroup(name = "Frontend SPA"), + ConversationGroup(name = "Mobile Clients"), + ConversationGroup(name = "DevOps & Deploy"), + ConversationGroup(name = "Documentation") + ) +} diff --git a/core/src/desktopMain/kotlin/com/whataicando/codeoba/core/util/SecureStorage.kt b/core/src/desktopMain/kotlin/com/whataicando/codeoba/core/util/SecureStorage.kt index 2363ae9..a04170c 100644 --- a/core/src/desktopMain/kotlin/com/whataicando/codeoba/core/util/SecureStorage.kt +++ b/core/src/desktopMain/kotlin/com/whataicando/codeoba/core/util/SecureStorage.kt @@ -7,9 +7,17 @@ object SecureStorage { private const val SERVICE_NAME = "Codeoba" private val keyring: Keyring? = try { - if (System.getProperty("codeoba.no.keyring") == "true") { - // This is used for development when the app is not signed to prevent keyring access popups. - Logger.log("SecureStorage: Native Keyring disabled via system property. Falling back to Java Preferences.") + val noKeyringProp = System.getProperty("codeoba.no.keyring") + val disableKeyring = if (AppConfig.isEmulatorOrDev()) { + // In non-production (dev or emulator), native keyring is disabled by default to prevent access prompts on unsigned builds, unless explicitly set to "false". + noKeyringProp != "false" + } else { + // In production, native keyring is always enabled. + false + } + + if (disableKeyring) { + Logger.log("SecureStorage: Native Keyring disabled by default in non-production. Falling back to Java Preferences.") null } else { Keyring.create() diff --git a/core/src/desktopTest/kotlin/com/whataicando/codeoba/core/auth/LocalAuthServerTest.kt b/core/src/desktopTest/kotlin/com/whataicando/codeoba/core/auth/LocalAuthServerTest.kt index 8d6cb8a..ba9230e 100644 --- a/core/src/desktopTest/kotlin/com/whataicando/codeoba/core/auth/LocalAuthServerTest.kt +++ b/core/src/desktopTest/kotlin/com/whataicando/codeoba/core/auth/LocalAuthServerTest.kt @@ -25,7 +25,7 @@ class LocalAuthServerTest { var receivedEmail: String? = null var receivedUid: String? = null - val port = LocalAuthServer.start { idToken, refreshToken, email, uid -> + val port = LocalAuthServer.start { idToken, refreshToken, email, uid, _ -> receivedIdToken = idToken receivedRefreshToken = refreshToken receivedEmail = email @@ -66,7 +66,7 @@ class LocalAuthServerTest { var receivedEmail: String? = null var receivedUid: String? = null - val port = LocalAuthServer.start { idToken, refreshToken, email, uid -> + val port = LocalAuthServer.start { idToken, refreshToken, email, uid, _ -> receivedIdToken = idToken receivedRefreshToken = refreshToken receivedEmail = email @@ -113,7 +113,7 @@ class LocalAuthServerTest { var receivedEmail: String? = null var receivedUid: String? = null - val port = LocalAuthServer.start { idToken, refreshToken, email, uid -> + val port = LocalAuthServer.start { idToken, refreshToken, email, uid, _ -> receivedIdToken = idToken receivedRefreshToken = refreshToken receivedEmail = email @@ -146,7 +146,7 @@ class LocalAuthServerTest { @Test fun testCallbackOptionsCors() { runBlocking { - val port = LocalAuthServer.start { _, _, _, _ -> } + val port = LocalAuthServer.start { _, _, _, _, _ -> } val client = HttpClient(CIO) try { @@ -169,7 +169,7 @@ class LocalAuthServerTest { @Test fun testCallbackUnauthorizedOrigin() { runBlocking { - val port = LocalAuthServer.start { _, _, _, _ -> } + val port = LocalAuthServer.start { _, _, _, _, _ -> } val client = HttpClient(CIO) try { @@ -189,7 +189,7 @@ class LocalAuthServerTest { @Test fun testCallbackMissingOriginOnPost() { runBlocking { - val port = LocalAuthServer.start { _, _, _, _ -> } + val port = LocalAuthServer.start { _, _, _, _, _ -> } val client = HttpClient(CIO) try { @@ -210,7 +210,7 @@ class LocalAuthServerTest { @Test fun testCallbackInvalidState() { runBlocking { - val port = LocalAuthServer.start { _, _, _, _ -> } + val port = LocalAuthServer.start { _, _, _, _, _ -> } val client = HttpClient(CIO) try { diff --git a/core/src/desktopTest/kotlin/com/whataicando/codeoba/core/domain/parser/LogParserSelectionTest.kt b/core/src/desktopTest/kotlin/com/whataicando/codeoba/core/domain/parser/LogParserSelectionTest.kt index 39d84e3..54fb67d 100644 --- a/core/src/desktopTest/kotlin/com/whataicando/codeoba/core/domain/parser/LogParserSelectionTest.kt +++ b/core/src/desktopTest/kotlin/com/whataicando/codeoba/core/domain/parser/LogParserSelectionTest.kt @@ -35,23 +35,63 @@ class LogParserSelectionTest { } @Test - fun testSummarizingParserExceptionSafety() { + fun testSummarizingParserDefaultStub() { runBlocking { - val parser = SummarizingLogParser(null) // This will cause runLocalInference to throw Exception - val dummySession = Session( - id = "test-id", - sourceId = "test-source", - filePath = "test-path", - timestamp = 0L, - updatedAt = 0L, - cwd = null, - threadName = null, - turns = emptyList(), - isArchived = false, - isPinned = false, - summary = null + // Revert/reset to default StubSummarizer + SummarizerProvider.revertToStub() + + val parser = SummarizingLogParser(null) + val dummySession = createDummySession() + val file = File("dummy-file") + val resultSession = parser.parse(file) { dummySession } + + assertNotNull(resultSession) + val summary = resultSession.summary + assertNotNull(summary) + assertTrue(summary.keyActions.contains("AI-powered summarization requires an active subscription.")) + assertTrue(summary.errors.first().contains("requires an active Codeoba subscription")) + } + } + + @Test + fun testSummarizingParserWithCustomSummarizer() { + runBlocking { + val expectedSummary = SessionSummary( + keyActions = listOf("Custom action"), + errors = emptyList(), + performanceCharts = emptyList() ) + SummarizerProvider.install(object : Summarizer { + override fun summarize(session: Session, parserConfigJson: String?): SummaryResult { + return SummaryResult.Ok(expectedSummary) + } + }) + + val parser = SummarizingLogParser(null) + val dummySession = createDummySession() + val file = File("dummy-file") + val resultSession = parser.parse(file) { dummySession } + + assertNotNull(resultSession) + assertEquals(expectedSummary, resultSession.summary) + + // Clean up + SummarizerProvider.revertToStub() + } + } + + @Test + fun testSummarizingParserExceptionSafety() { + runBlocking { + SummarizerProvider.install(object : Summarizer { + override fun summarize(session: Session, parserConfigJson: String?): SummaryResult { + throw RuntimeException("Simulated inference failure") + } + }) + + val parser = SummarizingLogParser(null) + val dummySession = createDummySession() val file = File("dummy-file") val resultSession = parser.parse(file) { dummySession } @@ -59,7 +99,26 @@ class LogParserSelectionTest { val summary = resultSession.summary assertNotNull(summary) assertTrue(summary.keyActions.contains("AI summarization failed"), "Should contain fallback action message") - assertTrue(summary.errors.first().contains("Inference exception:"), "Should capture the inference failure error message") + assertTrue(summary.errors.first().contains("Inference exception: Simulated inference failure"), "Should capture the exception message") + + // Clean up + SummarizerProvider.revertToStub() } } + + private fun createDummySession(): Session { + return Session( + id = "test-id", + sourceId = "test-source", + filePath = "test-path", + timestamp = 0L, + updatedAt = 0L, + cwd = null, + threadName = null, + turns = emptyList(), + isArchived = false, + isPinned = false, + summary = null + ) + } } diff --git a/core/src/desktopTest/kotlin/com/whataicando/codeoba/core/manager/CacheOptimizationTest.kt b/core/src/desktopTest/kotlin/com/whataicando/codeoba/core/manager/CacheOptimizationTest.kt index 9ed3577..989c3f7 100644 --- a/core/src/desktopTest/kotlin/com/whataicando/codeoba/core/manager/CacheOptimizationTest.kt +++ b/core/src/desktopTest/kotlin/com/whataicando/codeoba/core/manager/CacheOptimizationTest.kt @@ -3,6 +3,7 @@ package com.whataicando.codeoba.core.manager import kotlinx.coroutines.runBlocking import com.whataicando.codeoba.core.domain.model.Session import com.whataicando.codeoba.core.domain.model.Turn +import com.whataicando.codeoba.core.domain.parser.SessionSummary import java.io.File import kotlin.test.Test import kotlin.test.assertEquals @@ -195,6 +196,20 @@ class CacheOptimizationTest { com.whataicando.codeoba.core.domain.parser.LogParserFactory.setParserMode( com.whataicando.codeoba.core.domain.parser.ParserMode.SUMMARIZING ) + val mockSummarizer = object : com.whataicando.codeoba.core.domain.parser.Summarizer { + override fun summarize( + session: Session, + parserConfigJson: String? + ): com.whataicando.codeoba.core.domain.parser.SummaryResult { + val summary = SessionSummary( + keyActions = listOf("Parsed ${session.turns.size} dialogue exchanges from source '${session.sourceId}'"), + errors = emptyList(), + performanceCharts = emptyList() + ) + return com.whataicando.codeoba.core.domain.parser.SummaryResult.Ok(summary) + } + } + com.whataicando.codeoba.core.domain.parser.SummarizerProvider.install(mockSummarizer) SessionCacheManager.isCacheEnabled = true SessionCacheManager.startScan(source.id) @@ -233,6 +248,7 @@ class CacheOptimizationTest { com.whataicando.codeoba.core.domain.parser.LogParserFactory.setParserMode( com.whataicando.codeoba.core.domain.parser.ParserMode.STANDARD ) + com.whataicando.codeoba.core.domain.parser.SummarizerProvider.revertToStub() SessionCacheManager.endScan(source.id) } } diff --git a/core/src/desktopTest/kotlin/com/whataicando/codeoba/core/premium/PremiumLoaderTest.kt b/core/src/desktopTest/kotlin/com/whataicando/codeoba/core/premium/PremiumLoaderTest.kt new file mode 100644 index 0000000..d878e49 --- /dev/null +++ b/core/src/desktopTest/kotlin/com/whataicando/codeoba/core/premium/PremiumLoaderTest.kt @@ -0,0 +1,334 @@ +package com.whataicando.codeoba.core.premium + +import com.whataicando.codeoba.core.domain.model.PremiumManifest +import com.whataicando.codeoba.core.domain.model.Session +import com.whataicando.codeoba.core.domain.parser.SummarizerProvider +import com.whataicando.codeoba.core.domain.parser.SummaryResult +import com.whataicando.codeoba.core.security.PayloadVerifier +import kotlinx.coroutines.runBlocking +import java.io.File +import java.io.FileOutputStream +import java.security.KeyPairGenerator +import java.security.Signature +import java.util.Base64 +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import kotlin.test.* + +class PremiumLoaderTest { + private val json = kotlinx.serialization.json.Json { ignoreUnknownKeys = true } + + private val classBase64 = "yv66vgAAAEEAKwoAAgADBwAEDAAFAAYBABBqYXZhL2xhbmcvT2JqZWN0AQAGPGluaXQ+AQADKClWBwAIAQA5Y29tL3doYXRhaWNhbmRvL2NvZGVvYmEvY29yZS9kb21haW4vcGFyc2VyL1Nlc3Npb25TdW1tYXJ5CAAKAQAfRHluYW1pYyBwcmVtaXVtIHN1bW1hcnkgc3VjY2VzcwoADAANBwAODAAPABABABVqYXZhL3V0aWwvQ29sbGVjdGlvbnMBAA1zaW5nbGV0b25MaXN0AQAkKExqYXZhL2xhbmcvT2JqZWN0OylMamF2YS91dGlsL0xpc3Q7CgAMABIMABMAFAEACWVtcHR5TGlzdAEAEigpTGphdmEvdXRpbC9MaXN0OwoABwAWDAAFABcBADMoTGphdmEvdXRpbC9MaXN0O0xqYXZhL3V0aWwvTGlzdDtMamF2YS91dGlsL0xpc3Q7KVYHABkBADtjb20vd2hhdGFpY2FuZG8vY29kZW9iYS9jb3JlL2RvbWFpbi9wYXJzZXIvU3VtbWFyeVJlc3VsdCRPawoAGAAbDAAFABwBAD4oTGNvbS93aGF0YWljYW5kby9jb2Rlb2JhL2NvcmUvZG9tYWluL3BhcnNlci9TZXNzaW9uU3VtbWFyeTspVgcAHgEAQGNvbS93aGF0YWljYW5kby9jb2Rlb2JhL2NvcmUvcHJlbWl1bS9maXh0dXJlL0R1bW15UmVhbFN1bW1hcml6ZXIHACABADVjb20vd2hhdGFpY2FuZG8vY29kZW9iYS9jb3JlL2RvbWFpbi9wYXJzZXIvU3VtbWFyaXplcgEABENvZGUBAA9MaW5lTnVtYmVyVGFibGUBAAlzdW1tYXJpemUBAIEoTGNvbS93aGF0YWljYW5kby9jb2Rlb2JhL2NvcmUvZG9tYWluL21vZGVsL1Nlc3Npb247TGphdmEvbGFuZy9TdHJpbmc7KUxjb20vd2hhdGFpY2FuZG8vY29kZW9iYS9jb3JlL2RvbWFpbi9wYXJzZXIvU3VtbWFyeVJlc3VsdDsBAApTb3VyY2VGaWxlAQAYRHVtbXlSZWFsU3VtbWFyaXplci5qYXZhAQAMSW5uZXJDbGFzc2VzBwApAQA4Y29tL3doYXRhaWNhbmRvL2NvZGVvYmEvY29yZS9kb21haW4vcGFyc2VyL1N1bW1hcnlSZXN1bHQBAAJPawAhAB0AAgABAB8AAAACAAEABQAGAAEAIQAAAB0AAQABAAAABSq3AAGxAAAAAQAiAAAABgABAAAACQABACMAJAABACEAAABEAAUABAAAABy7AAdZEgm4AAu4ABG4ABG3ABVOuwAYWS23ABqwAAAAAQAiAAAAFgAFAAAADAAGAA0ACQAOAAwADwATABEAAgAlAAAAAgAmACcAAAAKAAEAGAAoACoAGQ==" + + private lateinit var tempJar: File + private lateinit var tempManifestFile: File + private lateinit var tempDir: File + + private val keyPair by lazy { + val g = KeyPairGenerator.getInstance("Ed25519") + g.generateKeyPair() + } + + @BeforeTest + fun setUp() { + tempDir = File.createTempFile("premium_test_", "_dir") + tempDir.delete() + tempDir.mkdirs() + + tempJar = File(tempDir, "premium.jar") + tempManifestFile = File(tempDir, "premium-manifest.json") + + // Set test public key override in verifier + PayloadVerifier.setTestPublicKey(keyPair.public) + } + + @AfterTest + fun tearDown() { + PayloadVerifier.setTestPublicKey(null) + tempDir.deleteRecursively() + SummarizerProvider.revertToStub() + } + + @Test + fun testPayloadVerifierCorrectSignature() { + val data = "Hello World".toByteArray() + val sig = Signature.getInstance("Ed25519") + sig.initSign(keyPair.private) + sig.update(data) + val signatureBytes = sig.sign() + + assertTrue(PayloadVerifier.verify(data, signatureBytes)) + } + + @Test + fun testPayloadVerifierMismatchedSignature() { + val data = "Hello World".toByteArray() + val sig = Signature.getInstance("Ed25519") + sig.initSign(keyPair.private) + sig.update(data) + val signatureBytes = sig.sign() + + // Tamper data + val tampered = "Hello World!".toByteArray() + assertFalse(PayloadVerifier.verify(tampered, signatureBytes)) + } + + @Test + fun testClassLoaderInstallerAndVerification() { + // Build the dynamic mock premium JAR + val classBytes = Base64.getDecoder().decode(classBase64) + ZipOutputStream(FileOutputStream(tempJar)).use { zos -> + zos.putNextEntry(ZipEntry("com/whataicando/codeoba/core/premium/fixture/DummyRealSummarizer.class")) + zos.write(classBytes) + zos.closeEntry() + } + + // Verify ClassLoaderInstaller can successfully classload it + val summarizer = ClassLoaderInstaller.install( + tempJar, + "com.whataicando.codeoba.core.premium.fixture.DummyRealSummarizer" + ) + assertNotNull(summarizer) + + val dummySession = Session( + id = "test", + sourceId = "test-src", + filePath = "path", + timestamp = 0L, + updatedAt = 0L, + cwd = null, + threadName = null, + turns = emptyList(), + isArchived = false, + isPinned = false, + summary = null + ) + + val result = summarizer.summarize(dummySession, null) + assertTrue(result is SummaryResult.Ok) + assertEquals("Dynamic premium summary success", result.summary.keyActions.first()) + } + + @Test + fun testClassLoaderInstallerSecurityViolation() { + assertFailsWith { + ClassLoaderInstaller.install( + tempJar, + "java.lang.System" + ) + } + } + + @Test + fun testPremiumLoaderLocalOverrideSync() = runBlocking { + // 1. Build and Sign JAR + val classBytes = Base64.getDecoder().decode(classBase64) + ZipOutputStream(FileOutputStream(tempJar)).use { zos -> + zos.putNextEntry(ZipEntry("com/whataicando/codeoba/core/premium/fixture/DummyRealSummarizer.class")) + zos.write(classBytes) + zos.closeEntry() + } + + val jarBytes = tempJar.readBytes() + val sig = Signature.getInstance("Ed25519") + sig.initSign(keyPair.private) + sig.update(jarBytes) + val signatureBase64 = Base64.getEncoder().encodeToString(sig.sign()) + val jarHash = PremiumCache.sha256(jarBytes) + + val manifestContent = """ + { + "version": "1.0.0", + "jarHash": "$jarHash", + "signature": "$signatureBase64", + "entrypointClass": "com.whataicando.codeoba.core.premium.fixture.DummyRealSummarizer" + } + """.trimIndent() + tempManifestFile.writeText(manifestContent) + + // Clean cache first + PremiumCache.clearCache() + + // 2. Set system property local override directory + System.setProperty("codeoba.premium.local.dir", tempDir.absolutePath) + + try { + // Sync loader with active subscription + PremiumLoader.sync(isSubscribed = true) + + // Confirm custom loader was loaded + val activeSummarizer = SummarizerProvider.current() + assertNotEquals("StubSummarizer", activeSummarizer::class.java.simpleName) + + val dummySession = Session( + id = "test", + sourceId = "test-src", + filePath = "path", + timestamp = 0L, + updatedAt = 0L, + cwd = null, + threadName = null, + turns = emptyList(), + isArchived = false, + isPinned = false, + summary = null + ) + val result = activeSummarizer.summarize(dummySession, null) + assertTrue(result is SummaryResult.Ok) + assertEquals("Dynamic premium summary success", result.summary.keyActions.first()) + + // Sync loader with INACTIVE subscription + PremiumLoader.sync(isSubscribed = false) + assertEquals("StubSummarizer", SummarizerProvider.current()::class.java.simpleName) + + } finally { + System.clearProperty("codeoba.premium.local.dir") + PremiumCache.clearCache() + } + } + + @Test + fun testPremiumLoaderGracePeriodExpiry() = runBlocking { + // 1. Build and Sign JAR + val classBytes = Base64.getDecoder().decode(classBase64) + ZipOutputStream(FileOutputStream(tempJar)).use { zos -> + zos.putNextEntry(ZipEntry("com/whataicando/codeoba/core/premium/fixture/DummyRealSummarizer.class")) + zos.write(classBytes) + zos.closeEntry() + } + + val jarBytes = tempJar.readBytes() + val sig = Signature.getInstance("Ed25519") + sig.initSign(keyPair.private) + sig.update(jarBytes) + val signatureBase64 = Base64.getEncoder().encodeToString(sig.sign()) + val jarHash = PremiumCache.sha256(jarBytes) + + val manifestContent = """ + { + "version": "1.0.0", + "jarHash": "$jarHash", + "signature": "$signatureBase64", + "entrypointClass": "com.whataicando.codeoba.core.premium.fixture.DummyRealSummarizer" + } + """.trimIndent() + + // Write directly to cache + PremiumCache.getJarFile().writeBytes(jarBytes) + PremiumCache.saveManifest(json.decodeFromString(manifestContent)) + + // Seed last sync timestamp to be expired (> 24 hours ago) + val expiredTime = System.currentTimeMillis() - (25 * 60 * 60 * 1000L) + com.whataicando.codeoba.core.util.SecureStorage.put("premium_last_sync_ms", expiredTime.toString()) + + // Set console url to a non-existent URL so online fetch fails + System.setProperty("codeoba.base_url", "invalid-domain-should-fail-12345.com") + + try { + // Confirm the cached payload is valid but grace period is expired + assertTrue(PremiumCache.verifyCachedPayload()) + assertFalse(PremiumCache.isWithinGracePeriod()) + + // Sync loader - should fail fetch and fall back to stub because grace period is expired + PremiumLoader.sync(isSubscribed = true) + + // Confirm active summarizer is reverted to StubSummarizer + assertEquals("StubSummarizer", SummarizerProvider.current()::class.java.simpleName) + } finally { + System.clearProperty("codeoba.base_url") + PremiumCache.clearCache() + com.whataicando.codeoba.core.util.SecureStorage.delete("premium_last_sync_ms") + } + } + + @Test + fun testPremiumLoaderClockRollback() = runBlocking { + // 1. Build and Sign JAR + val classBytes = Base64.getDecoder().decode(classBase64) + ZipOutputStream(FileOutputStream(tempJar)).use { zos -> + zos.putNextEntry(ZipEntry("com/whataicando/codeoba/core/premium/fixture/DummyRealSummarizer.class")) + zos.write(classBytes) + zos.closeEntry() + } + + val jarBytes = tempJar.readBytes() + val sig = Signature.getInstance("Ed25519") + sig.initSign(keyPair.private) + sig.update(jarBytes) + val signatureBase64 = Base64.getEncoder().encodeToString(sig.sign()) + val jarHash = PremiumCache.sha256(jarBytes) + + val manifestContent = """ + { + "version": "1.0.0", + "jarHash": "$jarHash", + "signature": "$signatureBase64", + "entrypointClass": "com.whataicando.codeoba.core.premium.fixture.DummyRealSummarizer" + } + """.trimIndent() + + // Write directly to cache + PremiumCache.getJarFile().writeBytes(jarBytes) + PremiumCache.saveManifest(json.decodeFromString(manifestContent)) + + // Seed last sync timestamp to be in the future (clock rollback scenario) + val futureTime = System.currentTimeMillis() + (10 * 60 * 1000L) // 10 minutes in the future + com.whataicando.codeoba.core.util.SecureStorage.put("premium_last_sync_ms", futureTime.toString()) + + // Set console url to a non-existent URL so online fetch fails + System.setProperty("codeoba.base_url", "invalid-domain-should-fail-12345.com") + + try { + // Confirm the cached payload is valid but grace period is expired due to clock rollback + assertTrue(PremiumCache.verifyCachedPayload()) + assertFalse(PremiumCache.isWithinGracePeriod()) + + // Sync loader - should fail fetch and fall back to stub because clock was rolled back + PremiumLoader.sync(isSubscribed = true) + + // Confirm active summarizer is reverted to StubSummarizer + assertEquals("StubSummarizer", SummarizerProvider.current()::class.java.simpleName) + } finally { + System.clearProperty("codeoba.base_url") + PremiumCache.clearCache() + com.whataicando.codeoba.core.util.SecureStorage.delete("premium_last_sync_ms") + } + } + + @Test + fun testClassLoaderIsolation() { + // Build the dynamic mock premium JAR + val classBytes = Base64.getDecoder().decode(classBase64) + ZipOutputStream(FileOutputStream(tempJar)).use { zos -> + zos.putNextEntry(ZipEntry("com/whataicando/codeoba/core/premium/fixture/DummyRealSummarizer.class")) + zos.write(classBytes) + zos.closeEntry() + } + + // Verify ClassLoaderInstaller can successfully classload it + val summarizer = ClassLoaderInstaller.install( + tempJar, + "com.whataicando.codeoba.core.premium.fixture.DummyRealSummarizer" + ) + assertNotNull(summarizer) + + // Confirm the classloader is our URLClassLoader with FilteredParentClassLoader + val classLoader = summarizer.javaClass.classLoader + assertNotNull(classLoader) + + // Try loading a core class that is NOT in the whitelisted packages (e.g. SecureStorage) + // It should fail with ClassNotFoundException due to the FilteredParentClassLoader + assertFailsWith { + classLoader.loadClass("com.whataicando.codeoba.core.util.SecureStorage") + } + + // Also try loading app-desktop classes (e.g. SettingsManager) which should fail too + assertFailsWith { + classLoader.loadClass("com.whataicando.codeoba.desktop.SettingsManager") + } + + // Whitelisted classes like java.lang.String or domain contract classes should succeed + assertNotNull(classLoader.loadClass("java.lang.String")) + assertNotNull(classLoader.loadClass("com.whataicando.codeoba.core.domain.parser.Summarizer")) + } +} diff --git a/core/src/desktopTest/kotlin/com/whataicando/codeoba/core/security/DeviceKeyManagerTest.kt b/core/src/desktopTest/kotlin/com/whataicando/codeoba/core/security/DeviceKeyManagerTest.kt index fb0d0c2..42d6f96 100644 --- a/core/src/desktopTest/kotlin/com/whataicando/codeoba/core/security/DeviceKeyManagerTest.kt +++ b/core/src/desktopTest/kotlin/com/whataicando/codeoba/core/security/DeviceKeyManagerTest.kt @@ -31,6 +31,7 @@ class DeviceKeyManagerTest { // 4. Load KeyPair again โ€” should return the same generated one val loadedKeyPair = DeviceKeyManager.getOrGenerateKeyPair() assertNotNull(loadedKeyPair) + assertEquals("EC", loadedKeyPair.public.algorithm) assertEquals( generatedKeyPair.public.encoded.toList(), loadedKeyPair.public.encoded.toList() @@ -48,4 +49,47 @@ class DeviceKeyManagerTest { } } } + + @Test + fun testRSAMigrationToEC() { + if (System.getProperty("codeoba.run.keyring.integration.tests") != "true") return + + // 1. Backup any existing real production keys + val backupPrivate = SecureStorage.get("device_private_key") + val backupPublic = SecureStorage.get("device_public_key") + + try { + // 2. Generate and store RSA keys using raw Java crypto + val kpg = java.security.KeyPairGenerator.getInstance("RSA") + kpg.initialize(2048) + val rsaKp = kpg.generateKeyPair() + + SecureStorage.put("device_private_key", java.util.Base64.getEncoder().encodeToString(rsaKp.private.encoded)) + SecureStorage.put("device_public_key", java.util.Base64.getEncoder().encodeToString(rsaKp.public.encoded)) + + // 3. Load KeyPair โ€” should detect RSA, catch error, delete them, and generate fresh EC keys + val loadedKeyPair = DeviceKeyManager.getOrGenerateKeyPair() + assertNotNull(loadedKeyPair) + assertEquals("EC", loadedKeyPair.public.algorithm) + + // Confirm they are saved back as EC + val newPubBase64 = SecureStorage.get("device_public_key") + assertNotNull(newPubBase64) + val newPubBytes = java.util.Base64.getDecoder().decode(newPubBase64) + val kf = java.security.KeyFactory.getInstance("EC") + val parsedPub = kf.generatePublic(java.security.spec.X509EncodedKeySpec(newPubBytes)) + assertEquals("EC", parsedPub.algorithm) + } finally { + // 4. Clean up test keys and restore backup + SecureStorage.delete("device_private_key") + SecureStorage.delete("device_public_key") + + if (backupPrivate != null) { + SecureStorage.put("device_private_key", backupPrivate) + } + if (backupPublic != null) { + SecureStorage.put("device_public_key", backupPublic) + } + } + } } diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 21d53fd..336d7c0 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1,56 +1,142 @@ -# Development Guide โ€” Codeoba +# Developer Setup & Development Guide โ€” Codeoba Client + +Welcome to the **Codeoba Client** development team! This guide will walk you through setting up your local environment, building the application, and developing new features. + +--- ## ๐Ÿ› ๏ธ Environment Setup -- **Prerequisites**: JDK 17+ and IntelliJ IDEA with **Kotlin** and **Compose Multiplatform** plugins. -- **Root Directory**: `LookAtWhatAiCanDo/Codeoba` +Follow these steps to set up your local machine for developing Codeoba: + +### 1. Install Java Development Kit (JDK) +Codeoba compiles against JDK 17 (or newer JVM version). +* **Mac (Homebrew)**: + ```bash + brew install openjdk@17 + ``` +* Ensure your `JAVA_HOME` environment variable is set and points to your JDK 17 installation: + ```bash + java -version # Verify it prints 17.x.x + ``` + +### 2. Import into IntelliJ IDEA +We recommend using **IntelliJ IDEA (Community or Ultimate Edition)**: +1. Open IntelliJ. +2. Select **Open** and target the root of this repository: `LookAtWhatAiCanDo/Codeoba`. +3. Wait for IntelliJ to sync the Gradle build files automatically. +4. Install the following plugins from the Marketplace: + * **Kotlin** (bundled by default) + * **Compose Multiplatform IDE Support** (from JetBrains) + +### 3. Repository Directory Layout ``` Codeoba/ -โ”œโ”€โ”€ app-desktop/ # Desktop UI -โ”œโ”€โ”€ core/ # Models, parsers, search logic -โ””โ”€โ”€ docs/ # Documentation +โ”œโ”€โ”€ build.gradle.kts # Root Gradle settings and plugin configuration +โ”œโ”€โ”€ settings.gradle.kts # Module project linkage (:core, :app-desktop) +โ”œโ”€โ”€ local.properties # Git-ignored local developer overrides +โ”œโ”€โ”€ core/ # Kotlin Multiplatform library module +โ”‚ โ””โ”€โ”€ src/ +โ”‚ โ”œโ”€โ”€ commonMain/ # Platform-agnostic data models, interface definitions +โ”‚ โ””โ”€โ”€ desktopMain/ # JVM-specific SQLite database, NIO directory watchers +โ”œโ”€โ”€ app-desktop/ # Jetpack Compose Multiplatform UI module +โ”‚ โ””โ”€โ”€ src/ +โ”‚ โ””โ”€โ”€ desktopMain/ # Compose UI views, entrypoint, settings, theme +โ””โ”€โ”€ docs/ # Feature guides and architecture documentation ``` --- -## ๐Ÿ’ป Commands -Run in monorepo root: -- **Build**: `./gradlew :app-desktop:compileKotlinDesktop` -- **Test**: `./gradlew :core:desktopTest` -- **Run**: `./gradlew :app-desktop:run` +## โš™๏ธ Build-Time Properties (`local.properties`) vs. Runtime JVM Arguments + +When building and running the client app, configuration properties are split into two categories: + +### 1. Build-Time Properties (in `local.properties` or environment variables) +These properties are evaluated by Gradle during compilation to generate `BuildConfig.kt` and are baked directly into the binary. +* **`codeoba.enable_subscription`**: Set to `true` to compile paid subscription features, device sync views, and remote control options into the UI. *(Note: This is a temporary developer toggle to A/B test the application with and without subscription capabilities. It will eventually be removed and permanently enabled once the subscription features are officially released).* +* **`codeoba.premium.public_key`**: The Ed25519 public key used to verify the premium summarizer JAR signature. If `codeoba.enable_subscription=true`, this key is **required** and the build will fail if it is missing. +* **`codeoba.firebase.api_key`**: Overrides the Firebase Web API Key for auth token refreshes in staging or production. Defaults to `"EMULATOR_ONLY"`. +* **`codeoba.app_signature_hash`**: Overrides the client app attestation token sent in HTTP headers. Defaults to `"DEVELOPMENT_ONLY"`. + +> [!WARNING] +> `codeoba.no.keyring` is **not** a build-time property and has no effect if specified in `local.properties`. It is evaluated at runtime and must be passed as a JVM system property. + +### 2. Runtime JVM Arguments (passed as `-Dargument=value` at execution time) +These arguments configure the compiled binary dynamically during execution. +* **`-Dcodeoba.base_url=`**: Configures the base URL/domain for API requests. Defaults to `codeoba.com` (Production). Set to `localhost:5000` to target the local emulator, or `dev.codeoba.com` to target staging. +* **`-Dcodeoba.no.keyring=`**: Defaults to `true` in dev/emulator environments (to automatically bypass native Keychain/Credential Manager access prompts on unsigned developer builds) and is ignored/forced to `false` in production. Set to `false` in staging/dev if you want to explicitly test native OS keyring integration. --- -## ๐Ÿ”Œ Adding a Log Source Adapter +## ๐Ÿ”‘ Zero-Configuration Emulator Onboarding -For a detailed step-by-step walkthrough, refer to the [Adding a New Source Adapter Guide](file:///Users/pv/Dev/GitHub/LookAtWhatAiCanDo/Codeoba/docs/ADD_NEW_SOURCE.md). -1. **Implement `SourceAdapter`**: Create a parser class under `core/src/desktopMain/kotlin/com/whataicando/codeoba/core/source/` subclassing `DesktopSourceAdapter`. -2. **Register**: Add it to the main sources list and register it in `SourceRegistry` in `Main.kt`. -3. **Configure UI mapping**: Add custom brand accent colors, formatted name mapping, and the product URL in [FormatUtils.kt](../app-desktop/src/desktopMain/kotlin/com/whataicando/codeoba/desktop/FormatUtils.kt). -4. **Test**: Add tests in `core/src/desktopTest/.../SourceCapabilitiesTest.kt` and `SourceParsersTest.kt`. +To run the application locally in the default Free/Local mode, you do not need to configure any keys or `local.properties` files. + +If you want to test the full subscription and premium summarizer flow using the local Firebase emulator suite: +1. In the companion premium module workspace, run `./gradlew generateDevKeys`. This dynamically generates a developer Ed25519 keypair and writes: + * The private key (`codeoba.premium.private_key`) to its local properties file. + * The public key (`codeoba.premium.public_key`) directly to `Codeoba/local.properties`. +2. Open `Codeoba/local.properties` and add: + ```properties + codeoba.enable_subscription=true + ``` +3. Run the client pointing to the emulator: + ```bash + ./gradlew :app-desktop:run -Dcodeoba.base_url=localhost:5000 + ``` --- -## ๐ŸŽจ Customizing Theme & UI +## ๐Ÿ’ป Common Terminal Commands + +Always run these commands from the root directory of the repository: -Views reside in `:app-desktop` desktop main: -- `Main.kt`: Main window state coordinates, breadcrumbs toolbar, detail header card, and overlay dialogs. -- `Sidebar.kt`: Log lists, sorting filters, status chips, search query input, and context menus. -- `DetailPane.kt`: Rendered message panels, expandable tool work blocks, and file viewer overlays. -- `Components.kt`: Drag-to-scroll wrappers, scrollbar templates, and orphaned alert indicators. +| Command | Action | +|---|---| +| `./gradlew :app-desktop:compileKotlinDesktop` | Compiles the desktop application code. | +| `./gradlew :core:desktopTest` | Runs all unit and parser tests in the core library. | +| `./gradlew :app-desktop:run` | Launches the desktop client interface locally in **Free/Local Mode**. | +| `./gradlew :app-desktop:run -Dcodeoba.base_url=localhost:5000` | Launches the app pointing to the local emulator. | -### Dynamic Theme System: -Theme parameters are resolved dynamically based on the user's active settings selection. The 8 premium theme color definitions (Obsidian, Nordic Frost, Emerald Forest, Dracula, Dracula, Cyberpunk Neon, etc.) reside in `Theme.kt`. Standard color properties include: -- `ObsidianBg` (Background) | `SlateSurface` (Surface panels) | `CardSurface` (Cards/Items) -- `BorderColor` (Borders/Dividers) | `AccentCyan` (Primary Accents/Pins) | `AccentPurple` (Secondary Accents/Tags) -- `TextPrimary` (Primary labels) | `TextSecondary` (Sub-text descriptions) +--- +## ๐Ÿ”Œ Developing Log Source Adapters -### UI Rule: -- **Mixed-Case Casing:** Actor labels must be formatted as "User" and "Assistant" (never uppercase-only like "USER" or "ASSISTANT"). +If you need to add support for indexing logs from a new coding agent, follow [Adding a New Source Adapter Guide](file:///Users/pv/Dev/GitHub/LookAtWhatAiCanDo/Codeoba/docs/ADD_NEW_SOURCE.md): +1. **Implement Adapter**: Subclass `DesktopSourceAdapter` under `core/src/desktopMain/kotlin/com/whataicando/codeoba/core/source/`. +2. **Register Adapter**: Add your adapter instance inside `SourceRegistry` in `Main.kt`. +3. **Accent Colors & UI Formatting**: Add brand colors, formatted name mapping, and the developer/product URL in `FormatUtils.kt`. +4. **Test**: Write parser and watch-filtering assertions inside `SourceCapabilitiesTest.kt` and `SourceParsersTest.kt`. + +--- + +## ๐ŸŽจ UI & Theme Customization + +The user interface resides entirely inside the `:app-desktop` module under `desktopMain`: +* **`Main.kt`**: Coordinates window state lifecycle, breadcrumbs, detail cards, and modal dialogs. +* **`Sidebar.kt`**: Handles search query inputs, relevance sorting, status/source filters, and context menus. +* **`DetailPane.kt`**: Renders message dialogue panels, collapsible tool blocks, and markdown text. +* **`Components.kt`**: Provides reusable composables such as drag-to-scroll lists and theme scrollbars. + +### Theming System (`Theme.kt`) +The app UI loads colors dynamically based on the active theme selected in Settings (e.g. Obsidian, Nordic Frost, Dracula, Emerald Forest). Ensure all UI components reference the dynamic theme properties rather than static values: +* `ObsidianBg` (Background) | `SlateSurface` (Panels) | `CardSurface` (Cards) +* `BorderColor` (Dividers) | `AccentCyan` (Pins/Primary) | `AccentPurple` (Tags/Secondary) +* `TextPrimary` (Primary Labels) | `TextSecondary` (Details) + +### UI Typography / Casing Constraints +* **Casing Constraint**: Never display capitalized actor labels like "USER" or "ASSISTANT". Always format them as **"User"** and **"Assistant"**. --- ## ๐Ÿงช Testing Guidelines -- Use temporary folders for test environments (`File.createTempFile` + `tempFile.deleteOnExit()`). -- Keep SQLite tests lightweight using memory or temporary database instances. + +* **Platform Isolation**: Ensure unit tests run offline and do not rely on local developer files. +* **Temporary Storage**: Always allocate test storage dynamically using JUnit's temporary folder rules or standard Java temp file creation (`File.createTempFile` + `deleteOnExit()`). +* **Database Tests**: Use in-memory SQLite instances (`jdbc:sqlite::memory:`) or write to temporary databases to prevent side-effects. + +--- + +## ๐Ÿ“ธ Marketing Screenshot Generator + +For debug builds, Codeoba features a built-in marketing screenshot generation tool that sizes the application window to store guidelines and loads realistic faked mock data. See the detailed guide at [SCREENSHOT_GENERATOR.md](file:///Users/pv/Dev/GitHub/LookAtWhatAiCanDo/Codeoba/docs/SCREENSHOT_GENERATOR.md). + diff --git a/docs/SCREENSHOT_GENERATOR.md b/docs/SCREENSHOT_GENERATOR.md new file mode 100644 index 0000000..ebee493 --- /dev/null +++ b/docs/SCREENSHOT_GENERATOR.md @@ -0,0 +1,62 @@ +# Marketing Screenshot Generator + +Codeoba contains a built-in marketing screenshot configuration tool **for debug builds only**. This tool automatically sizes the application window to store-compliant dimensions, centers the window, loads high-quality canned mock data, and bypasses local folder scanning. + +--- + +## ๐Ÿš€ Configuration Options + +The screenshot generator uses a clean separation between **developer-only debug hooks** (configured via JVM System Properties) and **user-facing layout configurations** (configured via command-line arguments). They are ignored entirely in production/release builds: + +### 1. Developer Debug configurations (JVM System Properties `-D`) +* `-Dcodeoba.store=apple|microsoft`: Activates screenshot mock mode and loads the corresponding canned dataset (`store/canned_apple.json` or `store/canned_microsoft.json`). +* `-Dcodeoba.canned_data=PATH`: Loads mock session data from a custom JSON file path instead of the store defaults. + +### 2. User/layout configurations (Command-Line Arguments `--`) +* `--size=WIDTHxHEIGHT`: Sets/overrides the startup window dimensions (e.g. `--size=2880x1800` or `--size=3840x2160`). +* `-h`, `--help`: Prints all available command-line options and description details to the console standard output and exits immediately without launching the application GUI. + +--- + +## ๐Ÿ’ป Example Usage + +To compile, load macOS mock data, and launch centered at `2880x1800` resolution: +```bash +./gradlew :app-desktop:run -Dcodeoba.store=apple --args="--size=2880x1800" +``` + +To load Windows mock data and launch centered at `3840x2160` (4k) resolution: +```bash +./gradlew :app-desktop:run -Dcodeoba.store=microsoft --args="--size=3840x2160" +``` + +--- + +## ๐Ÿ› ๏ธ Run/Launch Configurations in Android Studio + +To create a dedicated launch configuration in Android Studio for generating screenshots: + +### Option A: Gradle Run Configuration +1. Open **Edit Configurations...** +2. Click **+** and select **Gradle**. +3. Configure the settings: + * **Name**: `Run App (Store - Apple)` + * **Tasks and Arguments** (or **Command line**): `:app-desktop:run -Dcodeoba.store=apple --args="--size=2880x1800"` +4. Click **Apply** and then **Run**! + +### Option B: Native JVM Application Run Configuration (Bypasses Gradle) +1. Open **Edit Configurations...** +2. Click **+** and select **Application**. +3. Configure the settings: + * **Name**: `Codeoba App (Store - Microsoft)` + * **Main Class**: `com.whataicando.codeoba.desktop.MainKt` + * **VM Options**: `-Dcodeoba.store=microsoft` + * **Program Arguments**: `--size=3840x2160` + * **Use classpath of module**: `Codeoba.app-desktop.desktopMain` +4. Click **Apply** and then **Run**! + +--- + +## ๐Ÿ“‚ Customizing Mock Data + +Mock data is loaded from `store/canned_apple.json` and `store/canned_microsoft.json` relative to the root directory. You can edit these files directly to customize the lists, summaries, performance chart points, and conversation turns shown in the screenshot. diff --git a/docs/SUBSCRIPTION.md b/docs/SUBSCRIPTION.md index 34e70c2..d7fe6d4 100644 --- a/docs/SUBSCRIPTION.md +++ b/docs/SUBSCRIPTION.md @@ -166,9 +166,12 @@ If a critical vulnerability or remote-control bypass is detected, the backend ma --- -## ๐Ÿ› ๏ธ Compile-Time Subscription Gating (Developer Toggle) +## ๐Ÿ› ๏ธ Compile-Time Subscription Gating (Temporary Developer Toggle) -To enable merging of ongoing subscription development changes to the `main` branch without exposing half-finished or confusing features to production users, the project uses a compile-time static configuration constant (`BuildConfig.ENABLE_SUBSCRIPTION`). +To enable merging of ongoing subscription development changes to the `main` branch without exposing half-finished or confusing features to production users, and to allow developers to A/B test the application's behavior with and without subscription capabilities, the project uses a compile-time static configuration constant (`BuildConfig.ENABLE_SUBSCRIPTION`). + +> [!IMPORTANT] +> **Temporary Transition Control:** This compile-time setting and the `codeoba.enable_subscription` toggle are temporary transition mechanisms. Once the subscription features are officially released, this toggle and gating logic will be removed, leaving the subscription and sync integration permanently enabled in all builds. ### 1. How Gating Works When building for production, `BuildConfig.ENABLE_SUBSCRIPTION` is statically set to `false`. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 29d001b..6085b40 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,6 +5,7 @@ compose-material-icons = "1.7.3" compose-material3 = "1.9.0" java-keyring = "1.0.4" kotlin = "2.2.10" +kotlinx-cli = "0.3.6" kotlinx-coroutines = "1.8.1" kotlinx-serialization = "1.6.3" ktor = "2.3.12" @@ -22,6 +23,7 @@ compose-material3 = { group = "org.jetbrains.compose.material3", name = "materia compose-runtime = { group = "org.jetbrains.compose.runtime", name = "runtime", version.ref = "compose" } compose-ui = { group = "org.jetbrains.compose.ui", name = "ui", version.ref = "compose" } java-keyring = { group = "com.github.javakeyring", name = "java-keyring", version.ref = "java-keyring" } +kotlinx-cli = { group = "org.jetbrains.kotlinx", name = "kotlinx-cli", version.ref = "kotlinx-cli" } kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization" } diff --git a/store/canned_apple.json b/store/canned_apple.json new file mode 100644 index 0000000..ab7314f --- /dev/null +++ b/store/canned_apple.json @@ -0,0 +1,116 @@ +[ + { + "id": "antigravity-macos-lcp", + "sourceId": "antigravity", + "filePath": "/Users/pv/.gemini/antigravity/conversations/antigravity-macos-lcp.jsonl", + "timestamp": 1781949600000, + "updatedAt": 1781951400000, + "cwd": "/Users/pv/Dev/GitHub/LookAtWhatAiCanDo/Codeoba", + "threadName": "Optimizing Largest Contentful Paint (LCP)", + "isArchived": false, + "isPinned": true, + "summary": { + "keyActions": [ + "Analyzed slow LCP page-load timings", + "Identified unoptimized hero image assets", + "Optimized WebP conversion script", + "Added fetchpriority='high' priority directive" + ], + "errors": [], + "performanceCharts": [ + { "label": "Initial LCP", "value": 4.2 }, + { "label": "Optimized LCP", "value": 1.1 }, + { "label": "Savings (%)", "value": 73.8 } + ] + }, + "turns": [ + { + "turnId": "t1", + "userMessage": "Analyze the LCP (Largest Contentful Paint) of our main application. It takes 4.2 seconds to load, which feels sluggish. Show me what changes are needed.", + "assistantMessage": "I will analyze the LCP performance. Let's start by inspecting the image sizing and formatting directives on the main layout page.\n\n[[[TOOL:COMMAND|๐Ÿ’ป Running Command: git grep \"hero.jpg\"|1781949660000]]]\napp-desktop/src/desktopMain/resources/index.html: \n[[[/TOOL]]]\n\nThe main hero image `hero.jpg` is a raw uncompressed JPEG that loads without any priority flags or modern image formats. Let's write an optimization script to compress this asset to WebP and add the `fetchpriority=\"high\"` attribute to ensure it is loaded immediately during page rendering.\n\n[[[TOOL:COMMAND|๐Ÿ’ป Running Command: git diff|1781949720000]]]\n- \n+ \n[[[/TOOL]]]\n\nI have successfully optimized the layout structure. The page now uses the modern, compressed WebP format and tells the browser to prioritize fetching the hero asset first.", + "timestamp": 1781949660000, + "extraData": { + "isCompaction": "false", + "compactionTimeMs": "0" + } + }, + { + "turnId": "t2", + "userMessage": "Great! Can you check if we need to purge soft-deleted users in the backend as well? Does that run automatically?", + "assistantMessage": "Let's check the database rules and backend scheduled functions.\n\n[[[TOOL:SYSTEM_MESSAGE|โš™๏ธ System Message|1781951200000]]]\nLoading database rule definitions...\n[[[/TOOL]]]\n\nYes, the soft-deleted users grace period is configured for 30 days. The scheduled function `purgeSoftDeletedUsers` runs daily to perform a recursive delete on profiles that have been soft-deleted for more than 30 days.\n\nHere is the cleanup job schedule:\n\n```typescript\nexport const purgeSoftDeletedUsers = onSchedule(\"0 0 * * *\", async (event) => {\n const cutOff = Date.now() - 30 * 24 * 60 * 60 * 1000;\n // Recursive deletion query runs here\n});\n```", + "timestamp": 1781951200000, + "extraData": { + "isCompaction": "true", + "compactionTimeMs": "1250" + } + } + ] + }, + { + "id": "claude-macos-db-pool", + "sourceId": "claude", + "filePath": "/Users/pv/.config/claude/sessions/claude-macos-db-pool.json", + "timestamp": 1781879400000, + "updatedAt": 1781881200000, + "cwd": "/Users/pv/Dev/GitHub/LookAtWhatAiCanDo/Codeoba", + "threadName": "SQLite Connection Pool Leak Check", + "isArchived": false, + "isPinned": false, + "summary": { + "keyActions": [ + "Audited SQLite connection handlers", + "Discovered missing read-write lock releases", + "Refactored with custom ResourceOwner safety wrappers" + ], + "errors": [ + "SQLiteException: Database is locked" + ], + "performanceCharts": [ + { "label": "Active Connections", "value": 3.0 }, + { "label": "Peak Connections", "value": 8.0 } + ] + }, + "turns": [ + { + "turnId": "t1", + "userMessage": "We are randomly seeing `SQLiteException: Database is locked` errors during heavy parallel reads and writes. Can you inspect our connection pooling code?", + "assistantMessage": "I will examine the current database adapter connections to check how they are locked and closed. Let's inspect `DatabaseConnectionPool.kt`:\n\n```kotlin\nclass DatabaseConnectionPool(private val dbPath: String) {\n private val pool = ArrayDeque()\n \n fun getConnection(): Connection {\n // Missing synchronization lock wrapper\n return pool.removeFirstOrNull() ?: DriverManager.getConnection(dbPath)\n }\n}\n```\n\nThe issue is that connections are retrieved from the pool without proper synchronization locks. Under heavy concurrent execution, multiple threads can access the same connection, causing read-write collision locks. We need to implement a synchronized check and wrap operations in a thread-safe connection allocator.", + "timestamp": 1781879460000, + "extraData": {} + } + ] + }, + { + "id": "cursor-macos-onnx", + "sourceId": "cursor", + "filePath": "/Users/pv/Library/Application Support/Cursor/User/workspaceStorage/cursor-macos-onnx.sqlite", + "timestamp": 1781774100000, + "updatedAt": 1781775900000, + "cwd": "/Users/pv/Dev/GitHub/LookAtWhatAiCanDo/Codeoba-Premium", + "threadName": "Local Embedding Cache Integration", + "isArchived": false, + "isPinned": true, + "summary": { + "keyActions": [ + "Configured ONNX Runtime bindings", + "Created localized EmbeddingCacheManager", + "Optimized vector search with cache hits" + ], + "errors": [], + "performanceCharts": [ + { "label": "Cold Query (ms)", "value": 850.0 }, + { "label": "Cached Query (ms)", "value": 15.0 }, + { "label": "ONNX Cache Hit Ratio", "value": 98.2 } + ] + }, + "turns": [ + { + "turnId": "t1", + "userMessage": "Let's make sure our ONNX semantic search is extremely fast on startup. We shouldn't compute embeddings for files we have already analyzed.", + "assistantMessage": "Absolutely. We can create an `EmbeddingCacheManager` that serializes text embedding vectors alongside file hashes to a local JSON database file. Before calling the ONNX model, we calculate the MD5 hash of the snippet and query the cache first.\n\nHere is the proposed design:\n\n```kotlin\nobject EmbeddingCacheManager {\n private val cache = mutableMapOf() // MD5 -> Embeddings\n \n fun get(text: String): FloatArray? {\n val hash = calculateMd5(text)\n return cache[hash]\n }\n}\n```", + "timestamp": 1781774200000, + "extraData": {} + } + ] + } +] diff --git a/store/canned_microsoft.json b/store/canned_microsoft.json new file mode 100644 index 0000000..1100dd6 --- /dev/null +++ b/store/canned_microsoft.json @@ -0,0 +1,106 @@ +[ + { + "id": "antigravity-windows-billing", + "sourceId": "antigravity", + "filePath": "C:\\Users\\pv\\AppData\\Roaming\\gemini\\antigravity\\conversations\\antigravity-windows-billing.jsonl", + "timestamp": 1781949600000, + "updatedAt": 1781951400000, + "cwd": "C:\\Users\\pv\\source\\repos\\Codeoba-Backend", + "threadName": "Securing Billing Webhooks & API Keys", + "isArchived": false, + "isPinned": true, + "summary": { + "keyActions": [ + "Audited webhook verification logic", + "Configured GCP Secrets Manager variables", + "Implemented webhook HMAC payload validation" + ], + "errors": [ + "SignatureVerificationException: Invalid webhook signature" + ], + "performanceCharts": [ + { "label": "Verification Time (ms)", "value": 85.0 }, + { "label": "API Overhead (ms)", "value": 140.0 }, + { "label": "Memory Footprint (MB)", "value": 24.5 } + ] + }, + "turns": [ + { + "turnId": "t1", + "userMessage": "Stripe is complaining that our webhook endpoint is failing to verify signatures. Can you inspect the token exchange logic in functions/src/index.ts?", + "assistantMessage": "Let's review the webhook signature verification logic in the backend code.\n\n[[[TOOL:COMMAND|๐Ÿ’ป Running Command: findstr /S \"POLAR_WEBHOOK_SECRET\" *.ts|1781949660000]]]\nfunctions/src/index.ts: const webhookSecret = process.env.POLAR_WEBHOOK_SECRET;\n[[[/TOOL]]]\n\nThe webhook signature verification fails because the local secret was not configured inside Secret Manager for the emulator run. Let's add the override key inside our local git-ignored `functions/.secret.local` file:\n\n```env\nPOLAR_WEBHOOK_SECRET=whsec_local_dev_test_signature_key\n```\n\nAnd let's verify how the signature is checked using standard HMAC-SHA256 comparison:\n\n[[[TOOL:COMMAND|๐Ÿ’ป Running Command: git diff functions/src/index.ts|1781949720000]]]\n- const isValid = verifySignature(payload, signature);\n+ const isValid = crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(signature));\n[[[/TOOL]]]\n\nI have replaced the loose string equality check with a timing-safe buffer comparison to guard against timing attacks.", + "timestamp": 1781949660000, + "extraData": { + "isCompaction": "false", + "compactionTimeMs": "0" + } + } + ] + }, + { + "id": "claude-windows-ps", + "sourceId": "claude", + "filePath": "C:\\Users\\pv\\.config\\claude\\sessions\\claude-windows-ps.json", + "timestamp": 1781879400000, + "updatedAt": 1781881200000, + "cwd": "C:\\Users\\pv\\source\\repos\\CodeobaServer", + "threadName": "Safe PowerShell Deployment Automation", + "isArchived": false, + "isPinned": false, + "summary": { + "keyActions": [ + "Created PowerShell build pipeline script", + "Integrated environment credentials loading", + "Configured secure error action preferences" + ], + "errors": [], + "performanceCharts": [ + { "label": "Build Pipeline Execution", "value": 310.0 }, + { "label": "Deploy Overhead (s)", "value": 45.0 } + ] + }, + "turns": [ + { + "turnId": "t1", + "userMessage": "Create a PowerShell script to compile our C# codebase and package it into a docker container.", + "assistantMessage": "Here is the PowerShell script to build and containerize the app. We configure `$ErrorActionPreference = 'Stop'` to fail fast if any build step fails:\n\n```powershell\n# deploy.ps1\n$ErrorActionPreference = 'Stop'\n\nWrite-Host \"Starting MSBuild compilation...\" -ForegroundColor Cyan\ndotnet publish -c Release -o ./publish\n\nWrite-Host \"Packaging docker image...\" -ForegroundColor Cyan\ndocker build -t codeoba-server:latest .\n```", + "timestamp": 1781879460000, + "extraData": {} + } + ] + }, + { + "id": "cursor-windows-msbuild", + "sourceId": "cursor", + "filePath": "C:\\Users\\pv\\AppData\\Roaming\\Cursor\\User\\workspaceStorage\\cursor-windows-msbuild.sqlite", + "timestamp": 1781774100000, + "updatedAt": 1781775900000, + "cwd": "C:\\Users\\pv\\source\\repos\\Codeoba", + "threadName": "WPF / MSBuild Target Troubleshooting", + "isArchived": false, + "isPinned": true, + "summary": { + "keyActions": [ + "Audited MSBuild configuration", + "Resolved duplicate target definitions", + "Fixed platform target configurations" + ], + "errors": [ + "MSB4006: Duplicate target found" + ], + "performanceCharts": [ + { "label": "MSBuild Peak Mem (MB)", "value": 420.0 }, + { "label": "Parallel Threads", "value": 16.0 } + ] + }, + "turns": [ + { + "turnId": "t1", + "userMessage": "Our build target is failing with `MSB4006: Duplicate target found`. Can you check the build configuration files?", + "assistantMessage": "Let's inspect the `.csproj` file lines matching the target overrides. The target `AfterBuild` is defined twice: once near the top of the file and once near the bottom. I will merge these two target files into a single sequence.\n\n```xml\n\n\n \n \n\n```", + "timestamp": 1781774200000, + "extraData": {} + } + ] + } +] diff --git a/store/microsoft/Screenshot 1.png b/store/microsoft/Screenshot 1.png new file mode 100644 index 0000000..55da584 Binary files /dev/null and b/store/microsoft/Screenshot 1.png differ diff --git a/store/microsoft/codeoba_box_art.png b/store/microsoft/codeoba_box_art.png new file mode 100644 index 0000000..738679e Binary files /dev/null and b/store/microsoft/codeoba_box_art.png differ diff --git a/store/microsoft/codeoba_poster_art.png b/store/microsoft/codeoba_poster_art.png new file mode 100644 index 0000000..55bbeb7 Binary files /dev/null and b/store/microsoft/codeoba_poster_art.png differ diff --git a/store/microsoft/microsoft_store_listing.md b/store/microsoft/microsoft_store_listing.md new file mode 100644 index 0000000..649722f --- /dev/null +++ b/store/microsoft/microsoft_store_listing.md @@ -0,0 +1,177 @@ +# Codeoba Microsoft Store Listing Assets (Grounded Ground Truth) + +This document contains updated, verified marketing copy, product features, and release notes based strictly on features currently implemented and active in the Codeoba desktop application. + +--- + +## ๐Ÿ“ 1. Default Description (Main Listing Copy) +* **Character Limit:** Up to 10,000 characters. +* **Formatting Note:** Emojis are supported and help break up text. Clean bullet points (`โ€ข`) and caps headers are used since the Store does not render Markdown. + +*** + +**Codeoba โ€” Cross-Tool AI Chat Search & Indexer** + +Stop wasting time digging through hidden directories, SQLite database files, and markdown logs to find that one brilliant code block or design discussion. Codeoba is a platform-agnostic, local-first search application that aggregates and indexes conversation transcripts from all major AI coding assistants into a single unified desktop dashboard. + +Whether you are using Claude Code, Google Antigravity, Cursor, OpenAI Codex, Aider, or GitHub Copilot, Codeoba watches your log directories in real-time, indexing your programming history so you can find exactly what you need in milliseconds. + +--- + +### ๐Ÿ” DUAL SEARCH ENGINES: FIND EXACTLY WHAT YOU NEED + +Codeoba provides two distinct search modes designed for developers: + +โ€ข LEXICAL SEARCH (Keyword Match): Locate exact character sequences, specific variables, functions, TODO comments, or regex patterns (e.g., "onCloseRequest", "TODO:", "git merge"). +โ€ข SEMANTIC SEARCH (Conceptual Match): Find ideas using natural language queries (e.g., "how did I handle database transaction locks last week?"). Codeoba automatically downloads a lightweight, quantized "all-MiniLM-L6-v2" transformer model (~23 MB) to run local vector embeddings completely offline on your own machine. + +--- + +### โšก HIGH-PERFORMANCE DESKTOP FEATURES + +โ€ข Live Incremental Watchers: Background directory watchers instantly update your search index as you chat with your AI. +โ€ข Startup Caching & Profiling: A thread-safe file caching system minimizes startup overhead, reducing log scanning times from seconds to milliseconds. It includes an integrated execution time profiler to monitor indexing speeds. +โ€ข In-App Markdown & Code Viewer: Inspect your conversation turns and preview files safely using a secure, memory-bounded (max 5MB), symlink-aware reader that respects your workspace boundaries and prompts for authorization. +โ€ข 8 Dark Themes: Personalize your command center with dynamic, professionally designed dark modes including Obsidian, Nordic Frost, Emerald Forest, Sunset Copper, Royal Amethyst, Dracula, Cyberpunk Neon, and Monochrome Slate. +โ€ข Session Pinning & Tagging: Pin critical conversations to the top of your workspace and organize sessions with custom tags. +โ€ข Model Performance Auditing: Sort and analyze AI agent usage by turns, tokens, compute duration, and speed. + +--- + +### ๐Ÿ’Ž PREMIUM LOCAL SUMMARIES + +Unlock the Premium tier ($5/month) to run advanced local AI diagnostics over your index: + +โ€ข Local Summaries: Automatically generate structured reports detailing key activities, used models, exception/error diagnostics, and search performance charts. +โ€ข Context Compaction Tracking: Monitor and estimate the duration of Google Antigravity context compaction events to keep your token usage and execution times optimized. + +--- + +### ๐Ÿ”’ PRIVACY AND SECURITY BY DESIGN + +Your code is your intellectual property. Codeoba protects it with strict privacy controls: + +โ€ข 100% Local-First: All parser steps, database operations, and semantic embeddings are stored locally with zero remote tracking or developer telemetry. +โ€ข Safe Authentication: Secure Firebase-based browser authentication integration to verify your Polar licensing state. +โ€ข Secure Key Storage: User credentials and licensing validation parameters are stored securely in your system's native OS credential store (Windows CNG, macOS Keychain). + +Bring order to your AI development workflow. Try Codeoba today! + +*** + +## ๐Ÿ“‹ 2. Product Features +* **Character Limit:** Max 120 characters per feature. +* **Usage:** Enter these one-by-one by clicking "Add more" under "Product features" in Partner Center. + +1. **Multi-Agent Aggregation:** Merges chat logs from Claude Code, Google Antigravity, Cursor, Codex, Aider, and Copilot. +2. **Lexical Keyword Search:** Instantly locate exact code snippets, functions, variables, regex patterns, or TODO tags. +3. **Offline Semantic Search:** Search conceptually using natural language via a local, quantized machine learning model. +4. **Real-Time Directory Watchers:** Instantly updates your conversation index in the background as you write code and chat. +5. **AI-Powered Session Summaries:** Review concise summaries of what your AI agent accomplished, turn-by-turn. +6. **In-App Code & Markdown Viewer:** Safely preview workspace files using a secure, memory-bounded, symlink-aware reader. +7. **8 Desktop Dark Themes:** Customize your developer dashboard with Obsidian, Nordic Frost, Emerald Forest, or Dracula themes. +8. **Context Compaction Analytics:** Track Google Antigravity context compaction events and measure estimated time overhead. +9. **Startup Cache & Profiler:** Optimizes startup scanning down to milliseconds with an integrated performance profiler. +10. **Session Pinning:** Keep critical conversation threads floating at the top of your sidebar list regardless of sorting. +11. **Inline Session Tagging:** Organize indexed conversations into custom categories with a filterable checklist. +12. **Model Performance Audits:** Sort and analyze agent usage by turns, speed (tokens/sec), duration, and model names. +13. **Secure OS Keychains:** Stores credentials in secure hardware elements (Windows CNG/TPM, macOS Keychain). +14. **Local-First Privacy:** 100% offline indexing and search. No telemetry, tracker logs, or cloud tracking of your private code. +15. **Interactive Metrics Charts:** View custom-plotted charts showing query speeds and background indexing latency. +16. **Seamless Auto-Updates:** Instantly checks for signed releases, downloading and applying native MSI or PKG installers. + +*** + +## ๐Ÿ†• 3. What's New in this Version? +* **Character Limit:** Up to 1,500 characters. + +*** + +What's new in Codeoba v3.5: + +โ€ข LOCAL ONNX SEMANTIC SEARCH: Replaces word hashing with a real local neural text embedding pipeline using the quantized all-MiniLM-L6-v2 transformer model. +โ€ข PERSISTENT STARTUP CACHE & PROFILER: Accelerates startup scanning time by ~90% (from ~2.5s down to 0.25s) using a new thread-safe file serialization system and prints detailed profiling reports. +โ€ข DYNAMIC ACCENT THEMES: Users can now switch between 8 custom-styled developer themes including Nordic Frost, Royal Amethyst, Sunset Copper, and Cyberpunk Neon. +โ€ข SECURE IN-APP FILE VIEWER: Resolves local links within markdown transcripts securely, prompting for explicit user permissions when opening files outside the active workspace. +โ€ข DUAL SEARCH STATUS FILTERING: Added dedicated status chips to filter by Active and Archived conversations. +โ€ข MODEL PERFORMANCE BREAKDOWN: A new dashboard component allows sorting active AI models by turns, tokens, compute duration, and speed. +โ€ข SEAMLESS OS AUTO-UPDATES: Checks for new releases and downloads signed platform-native installers (MSI, PKG, DEB) automatically. + +*** + +## ๐Ÿท๏ธ 4. Supplemental & Additional Fields + +### Short Description +* **Recommended Length:** 270 characters or fewer (Actual: 247 characters). +* **Value:** +A local-first search dashboard that indexes and aggregates conversation logs from Claude Code, Google Antigravity, Cursor, OpenAI Codex, Aider, and GitHub Copilot with offline lexical and concept-matching semantic search. Private, fast, and 100% offline. + +--- + +### Additional System Requirements + +#### Minimum Hardware: +โ€ข OS: Windows 10 (Version 1809) or Windows 11 +โ€ข Architecture: x64 or ARM64 +โ€ข Memory: 4 GB RAM +โ€ข Storage: 100 MB free space (excluding logs and models) + +#### Recommended Hardware: +โ€ข OS: Windows 11 +โ€ข Memory: 8 GB RAM +โ€ข Storage: 500 MB free space (for indexes, model caching, and embeddings) + +--- + +### Additional Information + +#### Keywords +* **Limit:** Up to 7 keywords. No more than 21 separate words across all keywords. +* **Value:** +1. AI search +2. Code search +3. Cursor search +4. Claude Code +5. Aider search +6. Developer tool +7. Semantic search + +#### Copyright and Trademark Info +* **Value:** +Copyright ยฉ 2026 Look At What AI Can Do. All rights reserved. + +*** + +## โš–๏ธ 5. Applicable License Terms (EULA) +* **Usage:** Copy and paste the text block below into the "Applicable license terms" text box in Partner Center. + +*** + +CODEOBA END USER LICENSE AGREEMENT (EULA) + +This License Agreement governs your use of the Codeoba desktop application (the "Software"). + +1. LICENSE GRANTS +โ€ข Free/Local-First Features: The core, single-device offline features of Codeoba (including local directory indexing, lexical keyword search, and local semantic search) are licensed to you free of charge under open-source terms. +โ€ข Premium Features: Access to the proprietary premium components (such as local conversation summaries, metrics auditing, and cross-device sync) is granted under a limited, non-exclusive, non-transferable license, subject to an active paid subscription to the Codeoba Ecosystem. + +2. RESTRICTIONS ON PREMIUM & PROPRIETARY COMPONENTS +For any proprietary components (including but not limited to the premium analyzer module and backend services): +โ€ข You may not copy, modify, distribute, sell, or lease any part of these proprietary components. +โ€ข You may not reverse engineer, decompile, or attempt to extract the source code of these components, except to the extent permitted by law. +โ€ข You may not attempt to bypass, disable, or modify any license validation checkpoints or security controls. + +3. PRIVACY AND LOCAL-FIRST DESIGN +โ€ข By default, all conversation indexing and search embeddings run 100% locally on your machine. +โ€ข Synced ecosystem data (if enabled by you) is subject to the Codeoba Privacy Policy. + +4. WARRANTY AND LIMITATION OF LIABILITY +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE. + +*** + +## ๐Ÿ–ผ๏ธ 6. Generated Store Art Assets +The following visual assets have been generated and saved directly to the project's store assets directory for upload: + +* **1:1 Box Art Logo (1080 x 1080px):** [codeoba_box_art.png](./codeoba_box_art.png) +* **2:3 Poster Art (720 x 1080px portrait):** [codeoba_poster_art.png](./codeoba_poster_art.png)