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 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")) + } +}