first workting version

This commit is contained in:
Thomas Boksberger committed 2026-04-03 11:33:29 +02:00
1 parent 830765cb13
commit d2048ea5b7
23 files changed
+292 -86

No files matched your search

+3
View File
@@ -13,3 +13,6 @@ out/
# macOS # macOS
.DS_Store .DS_Store
# Http Client
httpClient/http-client.private.env.json
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="externalSystemId" value="Gradle" />
<option name="version" value="2.1.0" />
</component>
</project>
+5 -1
View File
@@ -1,6 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" project-jdk-name="temurin-21" project-jdk-type="JavaSDK"> <component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="FrameworkDetectionExcludesConfiguration">
<file type="web" url="file://$PROJECT_DIR$" />
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="temurin-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" /> <output url="file://$PROJECT_DIR$/out" />
</component> </component>
</project> </project>
+1 -1
View File
@@ -2,7 +2,7 @@
<project version="4"> <project version="4">
<component name="ProjectModuleManager"> <component name="ProjectModuleManager">
<modules> <modules>
<module fileurl="file://$PROJECT_DIR$/.idea/AnyTask.iml" filepath="$PROJECT_DIR$/.idea/AnyTask.iml" /> <module fileurl="file://$PROJECT_DIR$/.idea/modules/AnyTask.main.iml" filepath="$PROJECT_DIR$/.idea/modules/AnyTask.main.iml" />
</modules> </modules>
</component> </component>
</project> </project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module version="4">
<component name="AdditionalModuleElements">
<content url="file://$MODULE_DIR$/../../src/main" dumb="true">
<sourceFolder url="file://$MODULE_DIR$/../../src/main/resources" type="java-resource" />
</content>
</component>
</module>
+24 -14
View File
@@ -2,37 +2,47 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Git
Always stage newly created files that permanently belong to the project with `git add <file>`.
## Commands ## Commands
```bash ```bash
./gradlew run # App starten ./gradlew run # Run the app
./gradlew compileKotlin # Nur kompilieren ./gradlew compileKotlin # Compile only
./gradlew packageDmg # macOS DMG bauen ./gradlew packageDmg # Build macOS DMG
``` ```
## Architecture ## Architecture
**AnyTask** ist eine Kotlin/Compose-Desktop-App, die im Hintergrund läuft und über eine globale Tastenkombination (Ctrl+Shift+Space) ein Fenster zum Erstellen von AnyType-Tasks zeigt. **AnyTask** is a Kotlin/Compose desktop app that runs in the background and shows a task creation window via a global hotkey (Ctrl+Shift+Space).
### Komponenten ### Components
- **`Main.kt`** — Einstiegspunkt. Setzt `apple.awt.UIElement=true` (kein Dock-Icon), startet die Compose-`application {}`, richtet System-Tray und HotkeyManager ein. - **`Main.kt`** — Entry point. Sets `apple.awt.UIElement=true` (no dock icon), starts the Compose `application {}`, sets up system tray and HotkeyManager.
- **`hotkey/HotkeyManager.kt`** — JNativeHook-Wrapper. Registriert Ctrl+Shift+Space als systemweiten Hotkey und dispatcht den Callback auf den AWT-EDT. - **`hotkey/HotkeyManager.kt`** — JNativeHook wrapper. Registers Ctrl+Shift+Space as a system-wide hotkey and dispatches the callback on the AWT EDT.
- **`ui/TaskWindow.kt`** — Compose-UI mit Formular (Titel, Details). Escape schliesst, ⌘↵ erstellt. - **`ui/TaskWindow.kt`** — Compose UI with a form (title, details). Escape closes, ⌘↵ creates.
- **`api/AnyTypeClient.kt`** — Ktor-HTTP-Client für die lokale AnyType-API (`localhost:31007`). Momentan Skeleton — API-Details müssen noch verifiziert werden. - **`api/AnyTypeApi.kt`** — Ktor HTTP client for the local AnyType API (`localhost:31009`). Currently a skeleton — API details still need to be verified.
### Stack ### Stack
- Kotlin 2.1.0 / Compose Multiplatform 1.7.1 - Kotlin 2.1.0 / Compose Multiplatform 1.7.1
- JNativeHook 2.2.2 (globale Hotkeys) - JNativeHook 2.2.2 (global hotkeys)
- Ktor 3.0.0 + kotlinx.serialization (AnyType API) - Ktor 3.0.0 + kotlinx.serialization (AnyType API)
- Gradle 9.2.1 - Gradle 9.2.1
## macOS-Hinweise ## Code Style
- **Accessibility-Berechtigung** für JNativeHook: System Settings → Privacy & Security → Accessibility → App hinzufügen. Ohne diese Berechtigung funktioniert der globale Hotkey nicht. - One class per file — file name matches the class name.
- Das Tray-Icon erscheint in der Menüleiste (kein Dock-Icon, da `apple.awt.UIElement=true`). - No wildcard imports (`import foo.*`).
- Code, comments, and documentation in English.
## macOS Notes
- **Accessibility permission** for JNativeHook: System Settings → Privacy & Security → Accessibility → add the app. Without this permission the global hotkey will not work.
- The tray icon appears in the menu bar (no dock icon, since `apple.awt.UIElement=true`).
## AnyType API ## AnyType API
Die lokale AnyType-API läuft via `anytype-heart` auf `http://localhost:31007`. Der `appKey` und die `spaceId` in `AnyTypeClient` müssen noch konfigurierbar gemacht werden. The local AnyType API runs via `anytype-heart` on `http://localhost:31007`. `apiKey` and `spaceId` are configured via `anytask.yaml`.
+7
View File
@@ -28,6 +28,13 @@ dependencies {
// Coroutines (Swing/AWT dispatcher) // Coroutines (Swing/AWT dispatcher)
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.9.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.9.0")
// YAML config
implementation("com.charleskorn.kaml:kaml:0.67.0")
// Logging
implementation("org.apache.logging.log4j:log4j-api:2.24.3")
implementation("org.apache.logging.log4j:log4j-core:2.24.3")
} }
compose.desktop { compose.desktop {
+14
View File
@@ -0,0 +1,14 @@
### Get Spaces
GET http://localhost:31009/v1/spaces
Authorization: Bearer {{apiKey}}
### Create Task
POST http://localhost:31009/v1/spaces/{{spaceId}}/objects
Authorization: Bearer {{apiKey}}
Content-Type: application/json
{
"name": "Test Task II",
"body": "Created via HTTP client",
"type_key": "ot-task"
}
@@ -0,0 +1,6 @@
{
"local": {
"apiKey": "<API-Key>",
"spaceId": "<Space-Id>"
}
}
+25 -6
View File
@@ -5,13 +5,18 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.* import androidx.compose.ui.window.*
import ch.ergon.anytask.api.AnyTypeClient import ch.ergon.anytask.api.AnyTypeApi
import ch.ergon.anytask.config.loadConfig
import ch.ergon.anytask.hotkey.HotkeyManager import ch.ergon.anytask.hotkey.HotkeyManager
import ch.ergon.anytask.ui.TaskWindow import ch.ergon.anytask.ui.TaskWindow
import org.apache.logging.log4j.LogManager
import java.awt.* import java.awt.*
import kotlin.system.exitProcess
private val logger = LogManager.getLogger("Main")
fun main() { fun main() {
// Kein Dock-Icon auf macOS (Agent-App) // No dock icon on macOS (agent app)
System.setProperty("apple.awt.UIElement", "true") System.setProperty("apple.awt.UIElement", "true")
application { application {
@@ -22,11 +27,25 @@ fun main() {
size = DpSize(480.dp, 320.dp) size = DpSize(480.dp, 320.dp)
) )
val client = remember { AnyTypeClient() } val config = remember { loadConfig() }
val client = remember { AnyTypeApi(apiKey = config.apiKey, spaceId = config.spaceId) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
HotkeyManager.start(onHotkey = { isVisible = !isVisible }) HotkeyManager.start(onHotkey = { isVisible = !isVisible })
setupSystemTray(onShow = { isVisible = true }, onExit = { exitApplication() }) setupSystemTray(onShow = { isVisible = true }, onExit = { exitApplication() })
client.getSpaces()
.onSuccess { spaces ->
logger.info("Available spaces:")
spaces.forEach { logger.info("Space: ${it.id} ${it.name}") }
val space = spaces.find { space -> space.id == config.spaceId }
if (space == null) {
logger.error("Selected space not found: ${config.spaceId}")
exitProcess(-1)
} else {
logger.info("Selected space: ${space.name} (${space.id})")
}
}
.onFailure { logger.error("Failed to load spaces: ${it.message}") }
} }
DisposableEffect(Unit) { DisposableEffect(Unit) {
@@ -57,14 +76,14 @@ private fun setupSystemTray(onShow: () -> Unit, onExit: () -> Unit) {
val icon = createTrayIcon(size) val icon = createTrayIcon(size)
val menu = PopupMenu().apply { val menu = PopupMenu().apply {
add(MenuItem("Öffnen").also { it.addActionListener { onShow() } }) add(MenuItem("Open").also { it.addActionListener { onShow() } })
addSeparator() addSeparator()
add(MenuItem("Beenden").also { it.addActionListener { onExit() } }) add(MenuItem("Quit").also { it.addActionListener { onExit() } })
} }
val trayIcon = TrayIcon(icon, "AnyTask", menu).apply { val trayIcon = TrayIcon(icon, "AnyTask", menu).apply {
isImageAutoSize = true isImageAutoSize = true
addActionListener { onShow() } // Doppelklick addActionListener { onShow() } // double click
} }
SystemTray.getSystemTray().add(trayIcon) SystemTray.getSystemTray().add(trayIcon)
@@ -0,0 +1,75 @@
package ch.ergon.anytask.api
import ch.ergon.anytask.api.dto.CreateObjectRequest
import ch.ergon.anytask.api.dto.CreateObjectResponse
import ch.ergon.anytask.api.dto.Space
import ch.ergon.anytask.api.dto.SpacesResponse
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
import org.apache.logging.log4j.LogManager
import kotlin.time.DurationUnit.MILLISECONDS
import kotlin.time.toDuration
/**
* Client for the local AnyType API (anytype-heart middleware).
* The API runs on http://localhost:31007 by default.
* apiKey and spaceId are loaded from anytask.yaml.
*/
class AnyTypeApi(
private val baseUrl: String = "http://localhost:31009",
private val apiKey: String = "",
private val spaceId: String = "",
) {
private val logger = LogManager.getLogger(AnyTypeApi::class.java)
private val http = HttpClient(CIO) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
}.also { client ->
client.plugin(HttpSend).intercept { request ->
val method = request.method.value
val url = request.url.buildString()
logger.info("→ $method $url")
val start = System.currentTimeMillis()
val call = execute(request)
val duration = (System.currentTimeMillis() - start).toDuration(MILLISECONDS)
val status = call.response.status
logger.info("← ${status.value} (${status.description}) $duration")
if (!status.isSuccess()) {
logger.error("Response body: ${call.response.bodyAsText()}")
}
call
}
}
suspend fun getSpaces(): Result<List<Space>> =
runCatching {
val response = http.get("$baseUrl/v1/spaces") {
header("Anytype-Version", "0.1")
bearerAuth(apiKey)
}
response.body<SpacesResponse>().data
}
suspend fun createTask(title: String, details: String = ""): Result<String> =
runCatching {
val response = http.post("$baseUrl/v1/spaces/$spaceId/objects") {
contentType(ContentType.Application.Json)
header("Anytype-Version", "0.1")
bearerAuth(apiKey)
setBody(CreateObjectRequest(name = title, body = details, typeKey = "ot-task"))
}
response.body<CreateObjectResponse>().objectId
}
fun close() = http.close()
}
@@ -1,53 +0,0 @@
package ch.ergon.anytask.api
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
/**
* Client für die lokale AnyType-API (anytype-heart Middleware).
* Die API läuft standardmässig auf http://localhost:31007.
*
* TODO: App-Key via Einstellungen konfigurierbar machen.
*/
class AnyTypeClient(
private val baseUrl: String = "http://localhost:31007",
private val appKey: String = "", // wird nach erstem Login gesetzt
) {
private val http = HttpClient(CIO) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
}
suspend fun createTask(title: String, details: String = "", spaceId: String = ""): Result<String> =
runCatching {
val response = http.post("$baseUrl/v1/spaces/$spaceId/objects") {
contentType(ContentType.Application.Json)
header("Anytype-Version", "0.1")
bearerAuth(appKey)
setBody(CreateObjectRequest(title = title, details = details, typeKey = "ot-task"))
}
response.body<CreateObjectResponse>().objectId
}
fun close() = http.close()
}
@Serializable
private data class CreateObjectRequest(
val title: String,
val details: String,
val typeKey: String,
)
@Serializable
private data class CreateObjectResponse(
val objectId: String,
)
@@ -0,0 +1,11 @@
package ch.ergon.anytask.api.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class CreateObjectRequest(
val name: String,
val body: String,
@SerialName("type_key") val typeKey: String,
)
@@ -0,0 +1,8 @@
package ch.ergon.anytask.api.dto
import kotlinx.serialization.Serializable
@Serializable
data class CreateObjectResponse(
val objectId: String,
)
@@ -0,0 +1,9 @@
package ch.ergon.anytask.api.dto
import kotlinx.serialization.Serializable
@Serializable
data class Space(
val id: String,
val name: String,
)
@@ -0,0 +1,8 @@
package ch.ergon.anytask.api.dto
import kotlinx.serialization.Serializable
@Serializable
data class SpacesResponse(
val data: List<Space>,
)
@@ -0,0 +1,32 @@
package ch.ergon.anytask.config
import com.charleskorn.kaml.Yaml
import kotlinx.serialization.Serializable
import java.io.File
@Serializable
data class AppConfig(
val apiKey: String = "",
val spaceId: String = "",
)
fun loadConfig(): AppConfig {
val yaml = Yaml.default
val serializer = AppConfig.serializer()
// External files take precedence (production use)
val externalLocations = listOf(
File(System.getProperty("user.home"), ".anytask/anytask.yaml"),
File("anytask.yaml"),
)
externalLocations.firstOrNull { it.exists() }?.let {
return yaml.decodeFromString(serializer, it.readText())
}
// Fallback: classpath resource (development)
AppConfig::class.java.getResourceAsStream("/anytask.yaml")?.let {
return yaml.decodeFromString(serializer, it.bufferedReader().readText())
}
return AppConfig()
}
@@ -4,20 +4,22 @@ import com.github.kwhat.jnativehook.GlobalScreen
import com.github.kwhat.jnativehook.NativeHookException import com.github.kwhat.jnativehook.NativeHookException
import com.github.kwhat.jnativehook.keyboard.NativeKeyEvent import com.github.kwhat.jnativehook.keyboard.NativeKeyEvent
import com.github.kwhat.jnativehook.keyboard.NativeKeyListener import com.github.kwhat.jnativehook.keyboard.NativeKeyListener
import org.apache.logging.log4j.LogManager
import java.awt.EventQueue import java.awt.EventQueue
import java.util.logging.Level import java.util.logging.Level
import java.util.logging.Logger import java.util.logging.Logger
/** /**
* Lauscht auf eine systemweite Tastenkombination: Ctrl+Shift+Space * Listens for a system-wide hotkey: Ctrl+Shift+Space.
* Erfordert auf macOS die Berechtigung "Eingabegeräte überwachen" (Accessibility). * Requires the Accessibility permission on macOS.
*/ */
object HotkeyManager { object HotkeyManager {
private val logger = LogManager.getLogger(HotkeyManager::class.java)
private var listener: NativeKeyListener? = null private var listener: NativeKeyListener? = null
fun start(onHotkey: () -> Unit) { fun start(onHotkey: () -> Unit) {
// JNativeHook-Logging unterdrücken // Suppress JNativeHook logging
Logger.getLogger(GlobalScreen::class.java.`package`.name).apply { Logger.getLogger(GlobalScreen::class.java.`package`.name).apply {
level = Level.OFF level = Level.OFF
useParentHandlers = false useParentHandlers = false
@@ -26,14 +28,14 @@ object HotkeyManager {
try { try {
GlobalScreen.registerNativeHook() GlobalScreen.registerNativeHook()
} catch (e: NativeHookException) { } catch (e: NativeHookException) {
System.err.println("JNativeHook konnte nicht registriert werden: ${e.message}") logger.error("Failed to register JNativeHook: ${e.message}")
return return
} }
listener = object : NativeKeyListener { listener = object : NativeKeyListener {
override fun nativeKeyPressed(e: NativeKeyEvent) { override fun nativeKeyPressed(e: NativeKeyEvent) {
if (isHotkey(e)) { if (isHotkey(e)) {
// Callback auf den AWT-EDT dispatchen (Compose läuft dort) // Dispatch callback on the AWT EDT (where Compose runs)
EventQueue.invokeLater(onHotkey) EventQueue.invokeLater(onHotkey)
} }
} }
@@ -9,11 +9,11 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.* import androidx.compose.ui.input.key.*
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import ch.ergon.anytask.api.AnyTypeClient import ch.ergon.anytask.api.AnyTypeApi
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @Composable
fun TaskWindow(client: AnyTypeClient, onDismiss: () -> Unit) { fun TaskWindow(client: AnyTypeApi, onDismiss: () -> Unit) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var title by remember { mutableStateOf("") } var title by remember { mutableStateOf("") }
var details by remember { mutableStateOf("") } var details by remember { mutableStateOf("") }
@@ -58,12 +58,12 @@ fun TaskWindow(client: AnyTypeClient, onDismiss: () -> Unit) {
}, },
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
Text("Neue Aufgabe", style = MaterialTheme.typography.h6) Text("New Task", style = MaterialTheme.typography.h6)
OutlinedTextField( OutlinedTextField(
value = title, value = title,
onValueChange = { title = it }, onValueChange = { title = it },
label = { Text("Titel") }, label = { Text("Title") },
singleLine = true, singleLine = true,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -86,10 +86,10 @@ fun TaskWindow(client: AnyTypeClient, onDismiss: () -> Unit) {
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
) { ) {
TextButton(onClick = onDismiss) { Text("Abbrechen") } TextButton(onClick = onDismiss) { Text("Cancel") }
Button(onClick = ::submit, enabled = title.isNotBlank() && !isLoading) { Button(onClick = ::submit, enabled = title.isNotBlank() && !isLoading) {
if (isLoading) CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) if (isLoading) CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
else Text("Erstellen ⌘↵") else Text("Create ⌘↵")
} }
} }
} }
+2
View File
@@ -0,0 +1,2 @@
apiKey: 2tkMSzFegWwzsQ+n9SpGwVjychEkCESWjV1/ftytXG4=
spaceId: bafyreiafzvfsrurvx4gvr2wwysgp5uugujzmbdg5hxnjlahfmx3ww6kxfy.172mmtcppmjtt
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
<Appenders>
<Console name="Console" target="SYSTEM_OUT">
<PatternLayout pattern="%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n"/>
</Console>
</Appenders>
<Loggers>
<Root level="info">
<AppenderRef ref="Console"/>
</Root>
</Loggers>
</Configuration>
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.