From 1e71d7a6dbe3e04f18bc767d3c8a3070e41efb6f Mon Sep 17 00:00:00 2001 From: WavyCat Date: Thu, 15 Oct 2026 18:00:39 +0500 Subject: [PATCH 1/2] chore: add junie extensions config --- .junie/extensions.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .junie/extensions.json diff --git a/.junie/extensions.json b/.junie/extensions.json new file mode 100644 index 0000000..119f90d --- /dev/null +++ b/.junie/extensions.json @@ -0,0 +1,13 @@ +{ + "github-JetBrains-junie-extensions": { + "url": "https://github.com/JetBrains/junie-extensions", + "extensions": [ + "context7", + "kotlin-engineer" + ], + "source": { + "type": "git", + "url": "https://github.com/JetBrains/junie-extensions" + } + } +} \ No newline at end of file From dcc9fb19a440195e77bd4f2c2a848fcc301c4276 Mon Sep 17 00:00:00 2001 From: WavyCat Date: Thu, 15 Oct 2026 18:00:43 +0500 Subject: [PATCH 2/2] feat: add custom application configuration to settings Add IDE setting custom application options and validation Add on application activity rendering after custom config inputs Add test cases for parsing and validating custom application IDs Add new localization strings for custom application settings Ensure new custom application config fields maintain compatibility --- .junie/plans/custom-application.md | 89 +++++++++++++++++++ .../wavy/catactivity/service/TimeService.kt | 31 +++---- .../setting/DefaultsConfigurable.kt | 65 +++++++++++++- .../wavy/catactivity/setting/DefaultsState.kt | 30 ++++++- .../setting/ProjectConfigurable.kt | 68 +++++++++++++- .../wavy/catactivity/setting/ProjectState.kt | 5 ++ .../wavy/catactivity/setting/UnitedState.kt | 66 +++++++++++++- .../messages/ConfigBundle.properties | 12 ++- .../messages/ConfigBundle_en.properties | 12 ++- .../CustomApplicationValidationTest.kt | 60 +++++++++++++ 10 files changed, 414 insertions(+), 24 deletions(-) create mode 100644 .junie/plans/custom-application.md create mode 100644 src/test/kotlin/cat/wavy/catactivity/setting/CustomApplicationValidationTest.kt diff --git a/.junie/plans/custom-application.md b/.junie/plans/custom-application.md new file mode 100644 index 0000000..58ea709 --- /dev/null +++ b/.junie/plans/custom-application.md @@ -0,0 +1,89 @@ +# Requirements + +### Behavior +- The **IDE icon** selector gains a **Custom** option in both settings pages: the main project page ("Cat Activity") and **Override Defaults**. +- Selecting **Custom** reveals: **Application ID** field; a hint link below it (create an application in the Discord Developer Portal, `https://discord.com/developers/applications`); required **Application title** field; **Icon variant** selector with the same entries as `IDEIcon.entries` (New / Old / JetBrains / Cat Activity / Custom); when variant = Custom, a **Custom icon URL** field. +- **Override Defaults**: Application ID, title, and (for Custom variant) URL are mandatory; Apply is blocked with inline errors otherwise. +- **Project page**: fields are optional; blank values and `Default` variant inherit from Override Defaults. Non-blank values must be valid; effective (resolved) values must satisfy the Custom requirements, otherwise Apply is blocked. +- **Application ID validation (strict)**: digits only, 17-20 characters, must fit into `Long` (Discord snowflake). +- **Custom icon URL validation**: absolute HTTP(S) URL with a host; credentials and fragments rejected; query parameters allowed. +- **Runtime**: resolved Application ID drives the Discord SDK connection (project reinit on change); resolved title is the large-image tooltip (`largeImageText`); resolved variant/URL is used for the large icon and for the small icon in File mode. +- **Apply in Override Defaults re-renders all open non-default projects** so a new Application ID reconnects immediately. +- Invalid values persisted manually in XML never break rendering: fall back to the current IDE's Application ID/title and the standard New icon. + +### Scope +- Only the existing bundles are updated (`ConfigBundle.properties` and `ConfigBundle_en.properties`). +- Out of scope: checking that the Application ID exists on Discord, managing app assets on the portal, README/docs updates, new locales. + +# Technical Design + +### Existing Pattern +- `IDEIcon` / `IDEIconDefaultable` enums pair an icon with an `IDEType` (app ID + title); `UnitedState` is a fresh merge snapshot of project + app defaults per render; `TimeService.render()` reinits `ActivityRender` when `clientID` changes; the custom-icon-server feature (commit `490acf3`) is the reference for Custom UI + `validationOnApply` + conditional rows. +- UI: Kotlin UI DSL v2; conditions from `com.intellij.ui.layout` (`visibleIf`, `selectedValueMatches`, combinable with `and`). Persistence: `XmlSerializerUtil.copyBean`, so new fields with defaults are backward/forward compatible. + +### Changes and Contracts +1. **Enums** (`setting/DefaultsState.kt`, `setting/ProjectState.kt`): + - `IDEIcon.app: IDEType` -> `IDEType?`; add `Custom("Custom", null)`. + - `IDEIconDefaultable`: add `Custom("Custom", null)`. + - These same enums are reused for the icon variant selector (project variant additionally has `Default` = inherit). +2. **State fields** (defaults keep old behavior): + - `DefaultsState`: `customApplicationId = ""`, `customApplicationTitle = ""`, `customIconVariant: IDEIcon = IDEIcon.New`, `customIconUrl = ""`. + - `ProjectState`: same strings; `customIconVariant: IDEIconDefaultable = IDEIconDefaultable.Default`. +3. **Validation helpers** (top-level `internal` in `DefaultsState.kt`, next to `normalizeUrl`): + - `parseApplicationId(String): Long?` / `isValidApplicationId(String): Boolean` - trim, `^\d{17,20}$`, `toLongOrNull()`. + - `normalizeIconUrl(String): String?` - trim; absolute HTTP(S), host required, no userinfo/fragment; **query allowed**. `normalizeUrl` (server) stays unchanged. +4. **`UnitedState`** - replace `ideIcon: IDEIcon` with resolved fields: `applicationId: Long`, `applicationTitle: String`, `iconVariant: IDEIcon`, `customIconUrl: String?`: + - Not Custom -> ID/title from `icon.app` (fallback `currentIDEType`), variant = icon, URL = null. + - Custom -> project value `ifBlank` -> default value; variant `Default` -> defaults' variant; URL only when effective variant = Custom; invalid/missing values fall back to `currentIDEType` ID/title and New icon. + - `resolveIDEIcon` exhaustive `when` gains `Custom`. +5. **`TimeService`**: + - `buildIDEIconUrl(iconVariant, customIconUrl, theme, iconsUrl)`; `IDEIcon.Custom` -> custom URL (New URL as a safety fallback). + - `applyIDEInfo`/`applyFileInfo` accept `UnitedState` (title = `applicationTitle`); all four call sites updated. + - Reinit check uses `configState.applicationId`; `ActivityRender` keeps its default `clientID = currentIDEType.applicationId` and switches on first render. +6. **UI**: + - `DefaultsConfigurable`: rows after IDE icon, `visibleIf(ideIcon == Custom)` - Application ID (validationOnApply), link row (`BrowserUtil.browse`), Application title (validationOnApply non-blank), Icon variant combo; URL row visibility = combined condition (`selectedValueMatches` on both combos joined with `and`), validationOnApply only when effective variant Custom. `apply()` additionally renders open non-default projects via `ProjectManager`. + - `ProjectConfigurable`: same rows using `IDEIconDefaultable`; `emptyText = leaveBlankDefaultValue` on text fields; validation resolves effective values from `CatActivitySettingAppState` (project `ifBlank` -> defaults); variant `Default` inherits. +7. **Bundles** (both files): `customApplicationId`, `customApplicationTitle`, `customIconVariant`, `customIconUrl`, `createApplicationLink`, `invalidApplicationId`, `emptyApplicationTitle`, `invalidCustomIconUrl`, plus a short tooltip help for title. + +### File Structure +- Modify: `src/main/kotlin/cat/wavy/catactivity/setting/{DefaultsState,ProjectState,UnitedState,DefaultsConfigurable,ProjectConfigurable}.kt`, `service/TimeService.kt`, `src/main/resources/messages/ConfigBundle.properties`, `ConfigBundle_en.properties`. +- Add: `src/test/kotlin/cat/wavy/catactivity/setting/CustomApplicationValidationTest.kt`. +- Unchanged: `render/ActivityRender.kt` (only its default clientID is used as a fallback), `CatSettingState.kt`, `types/IDEType.kt`, `plugin.xml`. + +### Settings Flow +```mermaid +graph LR + UI[Configurables] --> States[DefaultsState / ProjectState] + States --> United[UnitedState resolution] + United --> Time[TimeService: URL, title, clientID] + Time --> Render[ActivityRender reinit / updateActivity] +``` + +# Testing + +### Automated Checks +- Plain JUnit 4 tests (no platform deps) for the pure helpers: snowflake acceptance (17-20 digits) and rejection (blank, non-digit, too short/long, Long overflow); `normalizeIconUrl` acceptance (HTTP/HTTPS, port, base path, query) and rejection (relative, ftp, userinfo, fragment, blank); `normalizeUrl` regression unchanged. `UnitedState`/`IDEIcon` tests are intentionally not added - enum init touches `currentIDEType`, which requires the platform runtime. +- `.\gradlew.bat check` (compile + tests) and `.\gradlew.bat verifyPlugin`. +- Sandbox smoke test via `.\gradlew.bat runIde`: open both settings pages, exercise validation, Apply re-render, inheritance, and File-mode icon swap. + +# Delivery Steps + +### Step 1: Settings model and validators +Enums and both state classes support custom app values; validation helpers exist and are unit-tested. +- Extend `IDEIcon`/`IDEIconDefaultable`; add state fields; add `parseApplicationId`/`isValidApplicationId`/`normalizeIconUrl`; add `CustomApplicationValidationTest`. + +### Step 2: Resolution and runtime wiring +Custom values reach Discord: app ID/title/icon are resolved and applied per render. +- Extend `UnitedState` resolution (inheritance + fallbacks); rework `buildIDEIconUrl`, `applyIDEInfo`, `applyFileInfo`, and the `clientID` reinit check in `TimeService`. + +### Step 3: Override Defaults UI +Custom controls with validation and an immediate effect. +- Add conditional rows + link + validation to `DefaultsConfigurable`; re-render open projects on apply; add bundle keys to both files. + +### Step 4: Project settings UI +Overrides with blank/`Default` inheritance. +- Add the same rows to `ProjectConfigurable` using `IDEIconDefaultable`, `leaveBlankDefaultValue`, and effective-value validation. + +### Step 5: Verification +Build, tests, and manual smoke checks pass; bundle keys resolve. +- Run `.\gradlew.bat check` and `.\gradlew.bat verifyPlugin`; smoke-test in `runIde`. diff --git a/src/main/kotlin/cat/wavy/catactivity/service/TimeService.kt b/src/main/kotlin/cat/wavy/catactivity/service/TimeService.kt index 8a0cd5d..e78a3df 100644 --- a/src/main/kotlin/cat/wavy/catactivity/service/TimeService.kt +++ b/src/main/kotlin/cat/wavy/catactivity/service/TimeService.kt @@ -148,8 +148,8 @@ class TimeService : Disposable { state = configState.idleStateFormat.replaceVariables(variables), details = configState.idleDetailFormat.ifBlank { null }?.replaceVariables(variables), startTimestamp = startIdleTimestamp - ).applyIDEInfo(configState.ideIcon, configState.theme, configState.iconsUrl) - .applyFileInfo(configState.ideIcon, configState.theme, configState.iconsUrl) + ).applyIDEInfo(configState) + .applyFileInfo(configState) activityWrapper.let { activityRender.updateActivity(it) @@ -179,8 +179,8 @@ class TimeService : Disposable { state = configState.fileStateFormat.replaceVariables(variables), details = configState.fileDetailFormat.ifBlank { null }?.replaceVariables(variables), startTimestamp = editingFile?.key?.let { timeTracker.getIfPresent(it) }, - ).applyIDEInfo(configState.ideIcon, configState.theme, configState.iconsUrl) - .applyFileInfo(configState.ideIcon, configState.theme, configState.iconsUrl) + ).applyIDEInfo(configState) + .applyFileInfo(configState) if (configState.showRepositoryButton && !gitRemotes.isNullOrEmpty()) { activityWrapper.applyRepoButton(gitRemotes[0]) @@ -192,7 +192,7 @@ class TimeService : Disposable { state = configState.projectStateFormat.replaceVariables(variables), details = configState.projectDetailFormat.ifBlank { null }?.replaceVariables(variables), startTimestamp = editingProject?.key?.let { timeTracker.getIfPresent(it) }, - ).applyIDEInfo(configState.ideIcon, configState.theme, configState.iconsUrl) + ).applyIDEInfo(configState) if (configState.showRepositoryButton && !gitRemotes.isNullOrEmpty()) { activityWrapper.applyRepoButton(gitRemotes[0]) @@ -203,14 +203,14 @@ class TimeService : Disposable { activityWrapper = ActivityWrapper( state = ApplicationInfoEx.getInstanceEx().fullApplicationName, startTimestamp = startTime, - ).applyIDEInfo(configState.ideIcon, configState.theme, configState.iconsUrl) + ).applyIDEInfo(configState) } } thisLogger().info("Rendering file: ${activityWrapper.details}") - if (activityRender.clientID != configState.ideIcon.app.applicationId) activityRender.apply { - clientID = configState.ideIcon.app.applicationId + if (activityRender.clientID != configState.applicationId) activityRender.apply { + clientID = configState.applicationId project.service().ignoreReloadAlert.set(false) reinit(false) } @@ -261,18 +261,18 @@ class TimeService : Disposable { return this } - private fun ActivityWrapper.applyIDEInfo(ideIcon: IDEIcon, theme: Theme, iconsUrl: String): ActivityWrapper { - largeImageKey = buildIDEIconUrl(ideIcon, theme, iconsUrl) - largeImageText = ideIcon.app.title + private fun ActivityWrapper.applyIDEInfo(configState: UnitedState): ActivityWrapper { + largeImageKey = buildIDEIconUrl(configState.iconVariant, configState.customIconUrl, configState.theme, configState.iconsUrl) + largeImageText = configState.applicationTitle return this } - private fun ActivityWrapper.applyFileInfo(ideIcon: IDEIcon, theme: Theme, iconsUrl: String): ActivityWrapper { + private fun ActivityWrapper.applyFileInfo(configState: UnitedState): ActivityWrapper { editingFile?.let { val type = getFileTypeByName(it.type, it.fileName, it.extension) - smallImageKey = buildIDEIconUrl(ideIcon, theme, iconsUrl) + smallImageKey = buildIDEIconUrl(configState.iconVariant, configState.customIconUrl, configState.theme, configState.iconsUrl) smallImageText = largeImageText // swap - largeImageKey = buildLangIconUrl(type.icon, theme, iconsUrl) + largeImageKey = buildLangIconUrl(type.icon, configState.theme, configState.iconsUrl) largeImageText = type.typeName } return this @@ -284,11 +284,12 @@ class TimeService : Disposable { internal fun buildLangIconUrl(icon: String, theme: Theme, iconsUrl: String): String = "$iconsUrl/${theme.name}/$icon.webp" -internal fun buildIDEIconUrl(ideIcon: IDEIcon, theme: Theme, iconsUrl: String): String = when (ideIcon) { +internal fun buildIDEIconUrl(iconVariant: IDEIcon, customIconUrl: String?, theme: Theme, iconsUrl: String): String = when (iconVariant) { IDEIcon.New -> "$iconsUrl/IDE/new/${theme.name}/${currentIDEType.icon}.webp" IDEIcon.Old -> "$iconsUrl/IDE/${theme.name}/${currentIDEType.icon}.webp" IDEIcon.JetBrains -> "$iconsUrl/IDE/${theme.name}/jetbrains.webp" IDEIcon.CatActivity -> "$iconsUrl/IDE/${theme.name}/cat_activity.webp" + IDEIcon.Custom -> customIconUrl ?: "$iconsUrl/IDE/${theme.name}/cat_activity.webp" } sealed class TimedItem( diff --git a/src/main/kotlin/cat/wavy/catactivity/setting/DefaultsConfigurable.kt b/src/main/kotlin/cat/wavy/catactivity/setting/DefaultsConfigurable.kt index 83d8f86..ec210fd 100644 --- a/src/main/kotlin/cat/wavy/catactivity/setting/DefaultsConfigurable.kt +++ b/src/main/kotlin/cat/wavy/catactivity/setting/DefaultsConfigurable.kt @@ -1,13 +1,16 @@ package cat.wavy.catactivity.setting import cat.wavy.catactivity.bundle.ConfigBundle +import cat.wavy.catactivity.service.TimeService import com.intellij.ide.BrowserUtil import com.intellij.openapi.components.service import com.intellij.openapi.options.Configurable +import com.intellij.openapi.project.ProjectManager import com.intellij.openapi.ui.ComboBox import com.intellij.ui.JBColor import com.intellij.ui.components.JBTextField import com.intellij.ui.dsl.builder.* +import com.intellij.ui.layout.and import com.intellij.ui.layout.enteredTextSatisfies import com.intellij.ui.layout.selectedValueMatches import com.intellij.ui.dsl.listCellRenderer.textListCellRenderer @@ -17,6 +20,8 @@ class DefaultsConfigurable : Configurable { private val panel = panel { val state = service().state var customServerSelector: ComboBox? = null + var ideIconSelector: ComboBox? = null + var iconVariantSelector: ComboBox? = null group(ConfigBundle.message("display")) { row(ConfigBundle.message("theme")) { @@ -26,11 +31,65 @@ class DefaultsConfigurable : Configurable { row(ConfigBundle.message("ideIcons")) { comboBox(items = IDEIcon.entries) .applyToComponent { + ideIconSelector = this renderer = textListCellRenderer("") { it.displayName } } .bindItem(state::ideIcon.toNullableProperty()) } + val isCustomApplication = ideIconSelector!!.selectedValueMatches { it == IDEIcon.Custom } + + row(ConfigBundle.message("customApplicationId")) { + textField().columns(COLUMNS_LARGE) + .bindText(state::customApplicationId) + .validationOnApply { + if (state.ideIcon == IDEIcon.Custom && !isValidApplicationId(state.customApplicationId)) { + error(ConfigBundle.message("invalidApplicationId")) + } else null + } + + contextHelp(ConfigBundle.message("customApplicationHelp")) + }.visibleIf(isCustomApplication) + + row { + link(ConfigBundle.message("createApplicationLink")) { + BrowserUtil.browse("https://discord.com/developers/applications") + } + }.visibleIf(isCustomApplication) + + row(ConfigBundle.message("customApplicationTitle")) { + textField().columns(COLUMNS_LARGE) + .bindText(state::customApplicationTitle) + .validationOnApply { + if (state.ideIcon == IDEIcon.Custom && state.customApplicationTitle.isBlank()) { + error(ConfigBundle.message("emptyApplicationTitle")) + } else null + } + + contextHelp(ConfigBundle.message("customApplicationTitleHelp")) + }.visibleIf(isCustomApplication) + + row(ConfigBundle.message("customIconVariant")) { + comboBox(items = IDEIcon.entries) + .applyToComponent { + iconVariantSelector = this + renderer = textListCellRenderer("") { it.displayName } + } + .bindItem(state::customIconVariant.toNullableProperty()) + }.visibleIf(isCustomApplication) + + row(ConfigBundle.message("customIconUrl")) { + textField().columns(COLUMNS_LARGE) + .bindText(state::customIconUrl) + .validationOnApply { + if (state.ideIcon == IDEIcon.Custom && state.customIconVariant == IDEIcon.Custom && + normalizeIconUrl(state.customIconUrl) == null + ) { + error(ConfigBundle.message("invalidCustomIconUrl")) + } else null + } + }.visibleIf(isCustomApplication and iconVariantSelector!!.selectedValueMatches { it == IDEIcon.Custom }) + row(ConfigBundle.message("iconsServer")) { comboBox(items = listOf(false, true)) .applyToComponent { @@ -151,7 +210,11 @@ class DefaultsConfigurable : Configurable { override fun isModified(): Boolean = panel.isModified() - override fun apply() = panel.apply() + override fun apply() = panel.apply().also { + ProjectManager.getInstance().openProjects + .filter { !it.isDefault } + .forEach { project -> project.service().render(project) } + } override fun reset() = panel.reset() diff --git a/src/main/kotlin/cat/wavy/catactivity/setting/DefaultsState.kt b/src/main/kotlin/cat/wavy/catactivity/setting/DefaultsState.kt index e877008..d80d9ea 100644 --- a/src/main/kotlin/cat/wavy/catactivity/setting/DefaultsState.kt +++ b/src/main/kotlin/cat/wavy/catactivity/setting/DefaultsState.kt @@ -8,6 +8,10 @@ import java.net.URI data class DefaultsState( var theme: Theme = Theme.Macchiato, var ideIcon: IDEIcon = IDEIcon.New, + var customApplicationId: String = "", + var customApplicationTitle: String = "", + var customIconVariant: IDEIcon = IDEIcon.New, + var customIconUrl: String = "", var useCustomIconsServer: Boolean = false, var customIconsUrl: String = "", @@ -38,6 +42,28 @@ internal fun normalizeUrl(value: String): String? { }.getOrNull() } +internal fun parseApplicationId(value: String): Long? { + val trimmed = value.trim() + if (!trimmed.matches(Regex("^\\d{17,20}$"))) return null + return trimmed.toLongOrNull() +} + +internal fun isValidApplicationId(value: String): Boolean = parseApplicationId(value) != null + +internal fun normalizeIconUrl(value: String): String? { + val trimmed = value.trim() + if (trimmed.isEmpty()) return null + + return runCatching { + val uri = URI(trimmed) + if (!uri.isAbsolute || uri.host.isNullOrBlank() || uri.rawUserInfo != null || + uri.rawFragment != null || + !uri.scheme.equals("http", ignoreCase = true) && !uri.scheme.equals("https", ignoreCase = true) + ) return null + uri.toASCIIString() + }.getOrNull() +} + enum class Theme { Frappe, Latte, @@ -45,10 +71,10 @@ enum class Theme { Mocha, } -enum class IDEIcon(val displayName: String, val app: IDEType) { +enum class IDEIcon(val displayName: String, val app: IDEType?) { New("${currentIDEType.title} (New)", currentIDEType), Old("${currentIDEType.title} (Old)", currentIDEType), JetBrains("JetBrains", IDEType.JETBRAINS), CatActivity("Cat Activity", IDEType.CATACTIVITY), + Custom("Custom", null), } - diff --git a/src/main/kotlin/cat/wavy/catactivity/setting/ProjectConfigurable.kt b/src/main/kotlin/cat/wavy/catactivity/setting/ProjectConfigurable.kt index 82647b6..393c81d 100644 --- a/src/main/kotlin/cat/wavy/catactivity/setting/ProjectConfigurable.kt +++ b/src/main/kotlin/cat/wavy/catactivity/setting/ProjectConfigurable.kt @@ -1,6 +1,7 @@ package cat.wavy.catactivity.setting import cat.wavy.catactivity.bundle.ConfigBundle +import com.intellij.ide.BrowserUtil import com.intellij.openapi.components.service import com.intellij.openapi.options.Configurable import com.intellij.openapi.project.Project @@ -9,6 +10,7 @@ import cat.wavy.catactivity.service.TimeService import com.intellij.openapi.ui.ComboBox import com.intellij.ui.components.JBCheckBox import com.intellij.ui.dsl.listCellRenderer.textListCellRenderer +import com.intellij.ui.layout.and import com.intellij.ui.layout.selected import com.intellij.ui.layout.selectedValueIs import com.intellij.ui.layout.selectedValueMatches @@ -19,9 +21,12 @@ class ProjectConfigurable( ) : Configurable { private val panel = panel { val state = project.service().state + val defaults = service().state var enableCheck: JBCheckBox? = null var displayCombo: ComboBox
? = null + var ideIconSelector: ComboBox? = null + var iconVariantSelector: ComboBox? = null row { checkBox(ConfigBundle.message("enablePlugin")).bindSelected(state::isEnabled) @@ -53,10 +58,71 @@ class ProjectConfigurable( row(ConfigBundle.message("ideIcons")) { comboBox(items = IDEIconDefaultable.entries) .applyToComponent { + ideIconSelector = this renderer = textListCellRenderer("") { it.displayName } } .bindItem(state::ideIcon.toNullableProperty()) } + + val isCustomApplication = ideIconSelector!!.selectedValueMatches { it == IDEIconDefaultable.Custom } + + row(ConfigBundle.message("customApplicationId")) { + textField().columns(COLUMNS_LARGE) + .bindText(state::customApplicationId) + .applyToComponent { emptyText.text = ConfigBundle.message("leaveBlankDefaultValue") } + .validationOnApply { + val effectiveId = state.customApplicationId.ifBlank { defaults.customApplicationId } + if (state.ideIcon == IDEIconDefaultable.Custom && !isValidApplicationId(effectiveId)) { + error(ConfigBundle.message("invalidApplicationId")) + } else null + } + + contextHelp(ConfigBundle.message("customApplicationHelp")) + }.visibleIf(isCustomApplication) + + row { + link(ConfigBundle.message("createApplicationLink")) { + BrowserUtil.browse("https://discord.com/developers/applications") + } + }.visibleIf(isCustomApplication) + + row(ConfigBundle.message("customApplicationTitle")) { + textField().columns(COLUMNS_LARGE) + .bindText(state::customApplicationTitle) + .applyToComponent { emptyText.text = ConfigBundle.message("leaveBlankDefaultValue") } + .validationOnApply { + val effectiveTitle = state.customApplicationTitle.ifBlank { defaults.customApplicationTitle } + if (state.ideIcon == IDEIconDefaultable.Custom && effectiveTitle.isBlank()) { + error(ConfigBundle.message("emptyApplicationTitle")) + } else null + } + + contextHelp(ConfigBundle.message("customApplicationTitleHelp")) + }.visibleIf(isCustomApplication) + + row(ConfigBundle.message("customIconVariant")) { + comboBox(items = IDEIconDefaultable.entries) + .applyToComponent { + iconVariantSelector = this + renderer = textListCellRenderer("") { it.displayName } + } + .bindItem(state::customIconVariant.toNullableProperty()) + }.visibleIf(isCustomApplication) + + row(ConfigBundle.message("customIconUrl")) { + textField().columns(COLUMNS_LARGE) + .bindText(state::customIconUrl) + .applyToComponent { emptyText.text = ConfigBundle.message("leaveBlankDefaultValue") } + .validationOnApply { + val effectiveVariant = UnitedState.resolveIconVariant(state.customIconVariant, defaults.customIconVariant) + val effectiveUrl = state.customIconUrl.ifBlank { defaults.customIconUrl } + if (state.ideIcon == IDEIconDefaultable.Custom && effectiveVariant == IDEIcon.Custom && + normalizeIconUrl(effectiveUrl) == null + ) { + error(ConfigBundle.message("invalidCustomIconUrl")) + } else null + } + }.visibleIf(isCustomApplication and iconVariantSelector!!.selectedValueMatches { it == IDEIconDefaultable.Custom }) }.enabledIf(enableCheck.selected) group(ConfigBundle.message("formatProject")) { @@ -133,4 +199,4 @@ class ProjectConfigurable( override fun reset() = panel.reset() override fun getDisplayName(): String = ConfigBundle.message("projectTitle") -} \ No newline at end of file +} diff --git a/src/main/kotlin/cat/wavy/catactivity/setting/ProjectState.kt b/src/main/kotlin/cat/wavy/catactivity/setting/ProjectState.kt index d240774..52411ec 100644 --- a/src/main/kotlin/cat/wavy/catactivity/setting/ProjectState.kt +++ b/src/main/kotlin/cat/wavy/catactivity/setting/ProjectState.kt @@ -8,6 +8,10 @@ data class ProjectState( var details: Details = Details.IDE, var theme: ThemeDefaultable = ThemeDefaultable.Default, var ideIcon: IDEIconDefaultable = IDEIconDefaultable.Default, + var customApplicationId: String = "", + var customApplicationTitle: String = "", + var customIconVariant: IDEIconDefaultable = IDEIconDefaultable.Default, + var customIconUrl: String = "", var showRepositoryButton: Boolean = false, var projectStateFormat: String = "", @@ -42,4 +46,5 @@ enum class IDEIconDefaultable(val displayName: String, val app: IDEType?) { Old("${currentIDEType.title} (Old)", currentIDEType), JetBrains("JetBrains", IDEType.JETBRAINS), CatActivity("Cat Activity", IDEType.CATACTIVITY), + Custom("Custom", null), } diff --git a/src/main/kotlin/cat/wavy/catactivity/setting/UnitedState.kt b/src/main/kotlin/cat/wavy/catactivity/setting/UnitedState.kt index 9450673..c235269 100644 --- a/src/main/kotlin/cat/wavy/catactivity/setting/UnitedState.kt +++ b/src/main/kotlin/cat/wavy/catactivity/setting/UnitedState.kt @@ -1,10 +1,15 @@ package cat.wavy.catactivity.setting +import cat.wavy.catactivity.types.currentIDEType + data class UnitedState( var isEnabled: Boolean, var details: Details, var theme: Theme, - var ideIcon: IDEIcon, + var applicationId: Long, + var applicationTitle: String, + var iconVariant: IDEIcon, + var customIconUrl: String?, var iconsUrl: String, var showRepositoryButton: Boolean, @@ -23,7 +28,10 @@ data class UnitedState( isEnabled = projectState.isEnabled, details = projectState.details, theme = resolveTheme(projectState.theme, defaultsState.theme), - ideIcon = resolveIDEIcon(projectState.ideIcon, defaultsState.ideIcon), + applicationId = resolveApplicationId(projectState, defaultsState), + applicationTitle = resolveApplicationTitle(projectState, defaultsState), + iconVariant = resolveIconVariant(projectState, defaultsState), + customIconUrl = resolveCustomIconUrl(projectState, defaultsState), iconsUrl = defaultsState.iconsUrl, showRepositoryButton = projectState.showRepositoryButton, projectStateFormat = projectState.projectStateFormat.ifBlank { defaultsState.projectStateFormat }, @@ -46,14 +54,66 @@ data class UnitedState( } } - private fun resolveIDEIcon(settingIcon: IDEIconDefaultable, defaultIcon: IDEIcon): IDEIcon { + internal fun resolveIDEIcon(settingIcon: IDEIconDefaultable, defaultIcon: IDEIcon): IDEIcon { return when (settingIcon) { IDEIconDefaultable.Default -> defaultIcon IDEIconDefaultable.New -> IDEIcon.New IDEIconDefaultable.Old -> IDEIcon.Old IDEIconDefaultable.JetBrains -> IDEIcon.JetBrains IDEIconDefaultable.CatActivity -> IDEIcon.CatActivity + IDEIconDefaultable.Custom -> IDEIcon.Custom + } + } + + internal fun resolveIconVariant(settingVariant: IDEIconDefaultable, defaultVariant: IDEIcon): IDEIcon { + return when (settingVariant) { + IDEIconDefaultable.Default -> defaultVariant + IDEIconDefaultable.New -> IDEIcon.New + IDEIconDefaultable.Old -> IDEIcon.Old + IDEIconDefaultable.JetBrains -> IDEIcon.JetBrains + IDEIconDefaultable.CatActivity -> IDEIcon.CatActivity + IDEIconDefaultable.Custom -> IDEIcon.Custom + } + } + + internal fun resolveApplicationId(projectState: ProjectState, defaultsState: DefaultsState): Long { + val ideIcon = resolveIDEIcon(projectState.ideIcon, defaultsState.ideIcon) + if (ideIcon != IDEIcon.Custom) { + return ideIcon.app?.applicationId ?: currentIDEType.applicationId } + val raw = projectState.customApplicationId.ifBlank { defaultsState.customApplicationId } + return parseApplicationId(raw) ?: currentIDEType.applicationId + } + + internal fun resolveApplicationTitle(projectState: ProjectState, defaultsState: DefaultsState): String { + val ideIcon = resolveIDEIcon(projectState.ideIcon, defaultsState.ideIcon) + if (ideIcon != IDEIcon.Custom) { + return ideIcon.app?.title ?: currentIDEType.title + } + return projectState.customApplicationTitle.ifBlank { defaultsState.customApplicationTitle } + .ifBlank { currentIDEType.title } + } + + internal fun resolveIconVariant(projectState: ProjectState, defaultsState: DefaultsState): IDEIcon { + val ideIcon = resolveIDEIcon(projectState.ideIcon, defaultsState.ideIcon) + if (ideIcon != IDEIcon.Custom) return ideIcon + + val variant = resolveIconVariant(projectState.customIconVariant, defaultsState.customIconVariant) + if (variant == IDEIcon.Custom && resolveCustomIconUrl(projectState, defaultsState) == null) { + return IDEIcon.New + } + return variant + } + + internal fun resolveCustomIconUrl(projectState: ProjectState, defaultsState: DefaultsState): String? { + val ideIcon = resolveIDEIcon(projectState.ideIcon, defaultsState.ideIcon) + if (ideIcon != IDEIcon.Custom) return null + + val variant = resolveIconVariant(projectState.customIconVariant, defaultsState.customIconVariant) + if (variant != IDEIcon.Custom) return null + + val raw = projectState.customIconUrl.ifBlank { defaultsState.customIconUrl } + return normalizeIconUrl(raw) } } } diff --git a/src/main/resources/messages/ConfigBundle.properties b/src/main/resources/messages/ConfigBundle.properties index 4cc8254..9e28549 100644 --- a/src/main/resources/messages/ConfigBundle.properties +++ b/src/main/resources/messages/ConfigBundle.properties @@ -11,7 +11,17 @@ detailsModeHelp=
    \
  • File: display project and file information
  • \
enablePlugin=Enable Rich Presence -ideIcons=IDE icon +ideIcons=App Icon +customApplicationId=Application ID +customApplicationHelp=The Discord application used for the presence. You can create your own application in the Discord Developer Portal. +createApplicationLink=Create an application in the Discord Developer Portal +customApplicationTitle=Application title +customApplicationTitleHelp=Shown as the tooltip of the large icon. +customIconVariant=Icon variant +customIconUrl=Custom icon URL +invalidApplicationId=Enter a numeric Discord Application ID (17-20 digits). +emptyApplicationTitle=Application title can't be empty +invalidCustomIconUrl=Enter an absolute HTTP(S) URL with a host and no credentials or fragment. iconsServer=Icon server defaultServer=Default server customServer=Custom server diff --git a/src/main/resources/messages/ConfigBundle_en.properties b/src/main/resources/messages/ConfigBundle_en.properties index 4cc8254..9e28549 100644 --- a/src/main/resources/messages/ConfigBundle_en.properties +++ b/src/main/resources/messages/ConfigBundle_en.properties @@ -11,7 +11,17 @@ detailsModeHelp=
    \
  • File: display project and file information
  • \
enablePlugin=Enable Rich Presence -ideIcons=IDE icon +ideIcons=App Icon +customApplicationId=Application ID +customApplicationHelp=The Discord application used for the presence. You can create your own application in the Discord Developer Portal. +createApplicationLink=Create an application in the Discord Developer Portal +customApplicationTitle=Application title +customApplicationTitleHelp=Shown as the tooltip of the large icon. +customIconVariant=Icon variant +customIconUrl=Custom icon URL +invalidApplicationId=Enter a numeric Discord Application ID (17-20 digits). +emptyApplicationTitle=Application title can't be empty +invalidCustomIconUrl=Enter an absolute HTTP(S) URL with a host and no credentials or fragment. iconsServer=Icon server defaultServer=Default server customServer=Custom server diff --git a/src/test/kotlin/cat/wavy/catactivity/setting/CustomApplicationValidationTest.kt b/src/test/kotlin/cat/wavy/catactivity/setting/CustomApplicationValidationTest.kt new file mode 100644 index 0000000..ed7b2bf --- /dev/null +++ b/src/test/kotlin/cat/wavy/catactivity/setting/CustomApplicationValidationTest.kt @@ -0,0 +1,60 @@ +package cat.wavy.catactivity.setting + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class CustomApplicationValidationTest { + @Test + fun `application id accepts valid snowflake`() { + assertEquals(1226060730273497098L, parseApplicationId("1226060730273497098")) + assertTrue(isValidApplicationId("1226060730273497098")) + } + + @Test + fun `application id is trimmed`() { + assertTrue(isValidApplicationId(" 1226060730273497098 ")) + } + + @Test + fun `application id rejects invalid values`() { + assertFalse(isValidApplicationId("")) + assertFalse(isValidApplicationId(" ")) + assertFalse(isValidApplicationId("1226060730273497")) + assertFalse(isValidApplicationId("123456789012345678901")) + assertFalse(isValidApplicationId("12260607302734970a")) + assertFalse(isValidApplicationId("1226060730273497098L")) + assertFalse(isValidApplicationId("99999999999999999999")) + } + + @Test + fun `icon url accepts http https and query`() { + assertEquals("https://cdn.example.com/icon.webp", normalizeIconUrl("https://cdn.example.com/icon.webp")) + assertEquals("http://example.com/a/b.webp", normalizeIconUrl("http://example.com/a/b.webp")) + assertEquals( + "https://cdn.example.com/icon.webp?token=abc", + normalizeIconUrl("https://cdn.example.com/icon.webp?token=abc") + ) + assertEquals("https://example.com:8443/icon.webp", normalizeIconUrl("https://example.com:8443/icon.webp")) + } + + @Test + fun `icon url rejects invalid values`() { + assertNull(normalizeIconUrl("")) + assertNull(normalizeIconUrl(" ")) + assertNull(normalizeIconUrl("/icon.webp")) + assertNull(normalizeIconUrl("ftp://example.com/icon.webp")) + assertNull(normalizeIconUrl("https://user:pass@example.com/icon.webp")) + assertNull(normalizeIconUrl("https://example.com/icon.webp#frag")) + assertNull(normalizeIconUrl("https:///icon.webp")) + } + + @Test + fun `server url still rejects query and fragment`() { + assertEquals("https://example.com/base", normalizeUrl("https://example.com/base/")) + assertNull(normalizeUrl("https://example.com/base?x=1")) + assertNull(normalizeUrl("https://example.com/base#frag")) + } +}