diff --git a/Makefile b/Makefile
index f1bef40..5a6aae3 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: android desktop android-usb android-emulator
+.PHONY: android desktop desktop-install android-usb android-emulator
ADB := $(shell command -v adb 2>/dev/null || echo "$(HOME)/Library/Android/sdk/platform-tools/adb")
EMULATOR := $(shell command -v emulator 2>/dev/null || echo "$(HOME)/Library/Android/sdk/emulator/emulator")
@@ -11,6 +11,10 @@ android:
desktop:
./gradlew composeApp:run
+desktop-install:
+ ./gradlew composeApp:packageDistributionForCurrentOS
+ cp -R composeApp/build/compose/binaries/main/app/myicek.app /Applications/myicek.app
+
android-usb: android
$(ADB) -d install -r $(APK)
$(ADB) -d shell am start -n ch.parano.myicek/.MainActivity
diff --git a/composeApp/src/commonMain/kotlin/ch/parano/myicek/WindowStateStorage.kt b/composeApp/src/commonMain/kotlin/ch/parano/myicek/WindowStateStorage.kt
new file mode 100644
index 0000000..27abb83
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/ch/parano/myicek/WindowStateStorage.kt
@@ -0,0 +1,52 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// MyIce Kotlin Multiplatform — schedule/convocation viewer
+// Copyright (C) 2026 parano.ch
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+package ch.parano.myicek
+
+import com.russhwolf.settings.Settings
+
+data class SavedWindowState(
+ val width: Int,
+ val height: Int,
+ val x: Int,
+ val y: Int,
+)
+
+class WindowStateStorage(private val settings: Settings) {
+
+ companion object {
+ private const val WIDTH_KEY = "window_width"
+ private const val HEIGHT_KEY = "window_height"
+ private const val X_KEY = "window_x"
+ private const val Y_KEY = "window_y"
+ }
+
+ fun save(width: Int, height: Int, x: Int, y: Int) {
+ settings.putInt(WIDTH_KEY, width)
+ settings.putInt(HEIGHT_KEY, height)
+ settings.putInt(X_KEY, x)
+ settings.putInt(Y_KEY, y)
+ }
+
+ fun load(): SavedWindowState? {
+ val width = settings.getIntOrNull(WIDTH_KEY) ?: return null
+ val height = settings.getIntOrNull(HEIGHT_KEY) ?: return null
+ val x = settings.getIntOrNull(X_KEY) ?: return null
+ val y = settings.getIntOrNull(Y_KEY) ?: return null
+ return SavedWindowState(width, height, x, y)
+ }
+}
diff --git a/composeApp/src/commonTest/kotlin/ch/parano/myicek/WindowStateStorageTest.kt b/composeApp/src/commonTest/kotlin/ch/parano/myicek/WindowStateStorageTest.kt
new file mode 100644
index 0000000..c9d4c80
--- /dev/null
+++ b/composeApp/src/commonTest/kotlin/ch/parano/myicek/WindowStateStorageTest.kt
@@ -0,0 +1,63 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// MyIce Kotlin Multiplatform — schedule/convocation viewer
+// Copyright (C) 2026 parano.ch
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+package ch.parano.myicek
+
+import com.russhwolf.settings.MapSettings
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+class WindowStateStorageTest {
+
+ @Test
+ fun saveAndLoadWindowState() {
+ val settings = MapSettings()
+ val storage = WindowStateStorage(settings)
+
+ storage.save(width = 1200, height = 800, x = 100, y = 50)
+
+ val state = storage.load()
+ assertEquals(1200, state?.width)
+ assertEquals(800, state?.height)
+ assertEquals(100, state?.x)
+ assertEquals(50, state?.y)
+ }
+
+ @Test
+ fun loadReturnsNullWhenNothingSaved() {
+ val settings = MapSettings()
+ val storage = WindowStateStorage(settings)
+
+ assertNull(storage.load())
+ }
+
+ @Test
+ fun saveOverwritesPreviousState() {
+ val settings = MapSettings()
+ val storage = WindowStateStorage(settings)
+
+ storage.save(width = 1200, height = 800, x = 100, y = 50)
+ storage.save(width = 1024, height = 768, x = 200, y = 75)
+
+ val state = storage.load()
+ assertEquals(1024, state?.width)
+ assertEquals(768, state?.height)
+ assertEquals(200, state?.x)
+ assertEquals(75, state?.y)
+ }
+}
diff --git a/composeApp/src/desktopMain/kotlin/ch/parano/myicek/Main.kt b/composeApp/src/desktopMain/kotlin/ch/parano/myicek/Main.kt
index 0ce13b1..7d86ea2 100644
--- a/composeApp/src/desktopMain/kotlin/ch/parano/myicek/Main.kt
+++ b/composeApp/src/desktopMain/kotlin/ch/parano/myicek/Main.kt
@@ -17,12 +17,43 @@
package ch.parano.myicek
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window
+import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.application
+import androidx.compose.ui.window.rememberWindowState
import ch.parano.myicek.auth.DesktopOAuthClient
+import com.russhwolf.settings.Settings
+import java.util.prefs.Preferences
fun main() = application {
- Window(onCloseRequest = ::exitApplication, title = "MyIce") {
+ val storage = WindowStateStorage(Settings())
+ val saved = storage.load()
+ val windowState = rememberWindowState(
+ width = saved?.width?.dp ?: 1024.dp,
+ height = saved?.height?.dp ?: 768.dp,
+ position = if (saved != null) WindowPosition(saved.x.dp, saved.y.dp) else WindowPosition(0.dp, 0.dp),
+ )
+
+ Window(
+ onCloseRequest = ::exitApplication,
+ title = "MyIce",
+ state = windowState,
+ ) {
+ LaunchedEffect(Unit) {
+ snapshotFlow { windowState.size to windowState.position }
+ .collect { (size, position) ->
+ storage.save(
+ width = size.width.value.toInt(),
+ height = size.height.value.toInt(),
+ x = position.x.value.toInt(),
+ y = position.y.value.toInt(),
+ )
+ Preferences.userRoot().flush()
+ }
+ }
App(oauthClient = DesktopOAuthClient())
}
}