diff --git a/examples/androidApp/build.gradle.kts b/app/android/build.gradle.kts similarity index 78% rename from examples/androidApp/build.gradle.kts rename to app/android/build.gradle.kts index 724db5f3..35d8fd36 100644 --- a/examples/androidApp/build.gradle.kts +++ b/app/android/build.gradle.kts @@ -4,11 +4,11 @@ plugins { } android { - namespace = "com.linroid.kdown.examples.android" + namespace = "com.linroid.kdown.android" compileSdk = libs.versions.android.compileSdk.get().toInt() defaultConfig { - applicationId = "com.linroid.kdown.examples.android" + applicationId = "com.linroid.kdown.app" minSdk = libs.versions.android.minSdk.get().toInt() targetSdk = libs.versions.android.targetSdk.get().toInt() versionCode = 1 @@ -35,10 +35,16 @@ android { buildFeatures { compose = true } + + packaging { + resources { + excludes += "/META-INF/{INDEX.LIST,io.netty.versions.properties}" + } + } } dependencies { - implementation(projects.examples.app) + implementation(projects.app.shared) implementation(projects.library.sqlite) implementation(projects.server) implementation(libs.androidx.activity.compose) diff --git a/examples/androidApp/src/main/AndroidManifest.xml b/app/android/src/main/AndroidManifest.xml similarity index 91% rename from examples/androidApp/src/main/AndroidManifest.xml rename to app/android/src/main/AndroidManifest.xml index be8ce7e5..ba11c420 100644 --- a/examples/androidApp/src/main/AndroidManifest.xml +++ b/app/android/src/main/AndroidManifest.xml @@ -9,7 +9,7 @@ android:supportsRtl="true" android:theme="@android:style/Theme.Material.Light.NoActionBar"> diff --git a/examples/androidApp/src/main/kotlin/com/linroid/kdown/examples/MainActivity.kt b/app/android/src/main/kotlin/com/linroid/kdown/android/MainActivity.kt similarity index 86% rename from examples/androidApp/src/main/kotlin/com/linroid/kdown/examples/MainActivity.kt rename to app/android/src/main/kotlin/com/linroid/kdown/android/MainActivity.kt index 53f2470c..af0b1a41 100644 --- a/examples/androidApp/src/main/kotlin/com/linroid/kdown/examples/MainActivity.kt +++ b/app/android/src/main/kotlin/com/linroid/kdown/android/MainActivity.kt @@ -1,4 +1,4 @@ -package com.linroid.kdown.examples +package com.linroid.kdown.android import android.os.Bundle import androidx.activity.ComponentActivity @@ -6,9 +6,10 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember -import com.linroid.kdown.examples.backend.BackendFactory -import com.linroid.kdown.examples.backend.BackendManager -import com.linroid.kdown.examples.backend.LocalServerHandle +import com.linroid.kdown.app.App +import com.linroid.kdown.app.backend.BackendFactory +import com.linroid.kdown.app.backend.BackendManager +import com.linroid.kdown.app.backend.LocalServerHandle import com.linroid.kdown.server.KDownServer import com.linroid.kdown.server.KDownServerConfig import com.linroid.kdown.sqlite.DriverFactory diff --git a/examples/androidApp/src/main/res/values/strings.xml b/app/android/src/main/res/values/strings.xml similarity index 100% rename from examples/androidApp/src/main/res/values/strings.xml rename to app/android/src/main/res/values/strings.xml diff --git a/examples/desktopApp/build.gradle.kts b/app/desktop/build.gradle.kts similarity index 82% rename from examples/desktopApp/build.gradle.kts rename to app/desktop/build.gradle.kts index 92f13612..c8f59bdd 100644 --- a/examples/desktopApp/build.gradle.kts +++ b/app/desktop/build.gradle.kts @@ -7,7 +7,7 @@ plugins { } dependencies { - implementation(projects.examples.app) + implementation(projects.app.shared) implementation(projects.server) implementation(projects.library.ktor) implementation(projects.library.sqlite) @@ -17,11 +17,11 @@ dependencies { compose.desktop { application { - mainClass = "com.linroid.kdown.examples.MainKt" + mainClass = "com.linroid.kdown.app.MainKt" nativeDistributions { targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) - packageName = "KDown Examples" + packageName = "KDown" packageVersion = "1.0.0" } } diff --git a/examples/desktopApp/src/main/kotlin/com/linroid/kdown/examples/main.kt b/app/desktop/src/main/kotlin/com/linroid/kdown/desktop/main.kt similarity index 82% rename from examples/desktopApp/src/main/kotlin/com/linroid/kdown/examples/main.kt rename to app/desktop/src/main/kotlin/com/linroid/kdown/desktop/main.kt index cefa3ae2..6a2937c4 100644 --- a/examples/desktopApp/src/main/kotlin/com/linroid/kdown/examples/main.kt +++ b/app/desktop/src/main/kotlin/com/linroid/kdown/desktop/main.kt @@ -1,12 +1,13 @@ -package com.linroid.kdown.examples +package com.linroid.kdown.desktop import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import androidx.compose.ui.window.Window import androidx.compose.ui.window.application -import com.linroid.kdown.examples.backend.BackendFactory -import com.linroid.kdown.examples.backend.BackendManager -import com.linroid.kdown.examples.backend.LocalServerHandle +import com.linroid.kdown.app.App +import com.linroid.kdown.app.backend.BackendFactory +import com.linroid.kdown.app.backend.BackendManager +import com.linroid.kdown.app.backend.LocalServerHandle import com.linroid.kdown.server.KDownServer import com.linroid.kdown.server.KDownServerConfig import com.linroid.kdown.sqlite.DriverFactory @@ -42,7 +43,7 @@ fun main() = application { } Window( onCloseRequest = ::exitApplication, - title = "KDown Examples" + title = "KDown" ) { App(backendManager) } diff --git a/examples/iosApp/iosApp/ContentView.swift b/app/ios/ContentView.swift similarity index 95% rename from examples/iosApp/iosApp/ContentView.swift rename to app/ios/ContentView.swift index 0f57c4fa..ed6bc758 100644 --- a/examples/iosApp/iosApp/ContentView.swift +++ b/app/ios/ContentView.swift @@ -1,6 +1,6 @@ import UIKit import SwiftUI -import ExamplesApp +import KDownApp struct ComposeView: UIViewControllerRepresentable { func makeUIViewController(context: Context) -> UIViewController { diff --git a/app/ios/KDown-Info.plist b/app/ios/KDown-Info.plist new file mode 100644 index 00000000..11845e1d --- /dev/null +++ b/app/ios/KDown-Info.plist @@ -0,0 +1,8 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + + diff --git a/app/ios/KDown.xcodeproj/project.pbxproj b/app/ios/KDown.xcodeproj/project.pbxproj new file mode 100644 index 00000000..ef784798 --- /dev/null +++ b/app/ios/KDown.xcodeproj/project.pbxproj @@ -0,0 +1,379 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + A001 /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B001 /* iOSApp.swift */; }; + A002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B002 /* ContentView.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 8676F78D2F3CDCA000DE65CF /* KDown-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "KDown-Info.plist"; sourceTree = ""; }; + B001 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; }; + B002 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + B003 /* KDown.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = KDown.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + C001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + D001 = { + isa = PBXGroup; + children = ( + 8676F78D2F3CDCA000DE65CF /* KDown-Info.plist */, + D002 /* Sources */, + D003 /* Products */, + ); + sourceTree = ""; + }; + D002 /* Sources */ = { + isa = PBXGroup; + children = ( + B001 /* iOSApp.swift */, + B002 /* ContentView.swift */, + ); + name = Sources; + sourceTree = ""; + }; + D003 /* Products */ = { + isa = PBXGroup; + children = ( + B003 /* KDown.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + E001 /* KDown */ = { + isa = PBXNativeTarget; + buildConfigurationList = F003 /* Build configuration list for PBXNativeTarget "KDown" */; + buildPhases = ( + E002 /* Compile Kotlin Framework */, + E003 /* Sources */, + C001 /* Frameworks */, + E004 /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = KDown; + productName = KDown; + productReference = B003 /* KDown.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + F001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1540; + LastUpgradeCheck = 1540; + }; + buildConfigurationList = F002 /* Build configuration list for PBXProject "KDown" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = D001; + productRefGroup = D003 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + E001 /* KDown */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXShellScriptBuildPhase section */ + E002 /* Compile Kotlin Framework */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Compile Kotlin Framework"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "cd \"$SRCROOT/../..\"\n./gradlew :app:shared:embedAndSignAppleFrameworkForXcode\n"; + }; + E004 /* Embed Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Embed Frameworks"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = ""; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + E003 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A001 /* iOSApp.swift in Sources */, + A002 /* ContentView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + G001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + G002 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + G003 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = MJ7RE83LT9; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", + ); + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "KDown-Info.plist"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-framework", + KDownApp, + "-lsqlite3", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.linroid.kdown.ios; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + G004 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = WHHN7J96UQ; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", + ); + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "KDown-Info.plist"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-framework", + KDownApp, + "-lsqlite3", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.linroid.kdown.ios; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + F002 /* Build configuration list for PBXProject "KDown" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + G001 /* Debug */, + G002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + F003 /* Build configuration list for PBXNativeTarget "KDown" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + G003 /* Debug */, + G004 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = F001 /* Project object */; +} diff --git a/app/ios/KDown.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/app/ios/KDown.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/app/ios/KDown.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/examples/iosApp/iosApp/iOSApp.swift b/app/ios/iOSApp.swift similarity index 86% rename from examples/iosApp/iosApp/iOSApp.swift rename to app/ios/iOSApp.swift index b3b19723..e83eb218 100644 --- a/examples/iosApp/iosApp/iOSApp.swift +++ b/app/ios/iOSApp.swift @@ -1,5 +1,5 @@ import SwiftUI -import ExamplesApp +import KDownApp @main struct iOSApp: App { diff --git a/examples/app/build.gradle.kts b/app/shared/build.gradle.kts similarity index 96% rename from examples/app/build.gradle.kts rename to app/shared/build.gradle.kts index 6da2d6eb..0ab20822 100644 --- a/examples/app/build.gradle.kts +++ b/app/shared/build.gradle.kts @@ -10,7 +10,7 @@ plugins { kotlin { androidLibrary { - namespace = "com.linroid.kdown.examples.app" + namespace = "com.linroid.kdown.app.shared" compileSdk = libs.versions.android.compileSdk.get().toInt() minSdk = libs.versions.android.minSdk.get().toInt() @@ -24,7 +24,7 @@ kotlin { iosSimulatorArm64() ).forEach { iosTarget -> iosTarget.binaries.framework { - baseName = "ExamplesApp" + baseName = "KDownApp" isStatic = true } } diff --git a/examples/app/src/commonMain/kotlin/com/linroid/kdown/examples/App.kt b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/App.kt similarity index 99% rename from examples/app/src/commonMain/kotlin/com/linroid/kdown/examples/App.kt rename to app/shared/src/commonMain/kotlin/com/linroid/kdown/app/App.kt index 18962a2b..641fc1c7 100644 --- a/examples/app/src/commonMain/kotlin/com/linroid/kdown/examples/App.kt +++ b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/App.kt @@ -1,4 +1,4 @@ -package com.linroid.kdown.examples +package com.linroid.kdown.app import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -72,11 +72,11 @@ import com.linroid.kdown.api.DownloadState import com.linroid.kdown.api.DownloadTask import com.linroid.kdown.api.KDownApi import com.linroid.kdown.api.SpeedLimit -import com.linroid.kdown.examples.backend.BackendConfig -import com.linroid.kdown.examples.backend.BackendEntry +import com.linroid.kdown.app.backend.BackendConfig +import com.linroid.kdown.app.backend.BackendEntry import com.linroid.kdown.remote.ConnectionState -import com.linroid.kdown.examples.backend.BackendManager -import com.linroid.kdown.examples.backend.ServerState +import com.linroid.kdown.app.backend.BackendManager +import com.linroid.kdown.app.backend.ServerState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch diff --git a/examples/app/src/commonMain/kotlin/com/linroid/kdown/examples/backend/BackendConfig.kt b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/backend/BackendConfig.kt similarity index 85% rename from examples/app/src/commonMain/kotlin/com/linroid/kdown/examples/backend/BackendConfig.kt rename to app/shared/src/commonMain/kotlin/com/linroid/kdown/app/backend/BackendConfig.kt index 62a85025..e6b875a5 100644 --- a/examples/app/src/commonMain/kotlin/com/linroid/kdown/examples/backend/BackendConfig.kt +++ b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/backend/BackendConfig.kt @@ -1,4 +1,4 @@ -package com.linroid.kdown.examples.backend +package com.linroid.kdown.app.backend sealed class BackendConfig { data object Embedded : BackendConfig() diff --git a/examples/app/src/commonMain/kotlin/com/linroid/kdown/examples/backend/BackendEntry.kt b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/backend/BackendEntry.kt similarity index 87% rename from examples/app/src/commonMain/kotlin/com/linroid/kdown/examples/backend/BackendEntry.kt rename to app/shared/src/commonMain/kotlin/com/linroid/kdown/app/backend/BackendEntry.kt index d6f35b69..7517abd9 100644 --- a/examples/app/src/commonMain/kotlin/com/linroid/kdown/examples/backend/BackendEntry.kt +++ b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/backend/BackendEntry.kt @@ -1,4 +1,4 @@ -package com.linroid.kdown.examples.backend +package com.linroid.kdown.app.backend import com.linroid.kdown.remote.ConnectionState import kotlinx.coroutines.flow.StateFlow diff --git a/examples/app/src/commonMain/kotlin/com/linroid/kdown/examples/backend/BackendFactory.kt b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/backend/BackendFactory.kt similarity index 98% rename from examples/app/src/commonMain/kotlin/com/linroid/kdown/examples/backend/BackendFactory.kt rename to app/shared/src/commonMain/kotlin/com/linroid/kdown/app/backend/BackendFactory.kt index fa9e770d..40c8e0b6 100644 --- a/examples/app/src/commonMain/kotlin/com/linroid/kdown/examples/backend/BackendFactory.kt +++ b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/backend/BackendFactory.kt @@ -1,4 +1,4 @@ -package com.linroid.kdown.examples.backend +package com.linroid.kdown.app.backend import com.linroid.kdown.api.KDownApi import com.linroid.kdown.core.DownloadConfig diff --git a/examples/app/src/commonMain/kotlin/com/linroid/kdown/examples/backend/BackendManager.kt b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/backend/BackendManager.kt similarity index 99% rename from examples/app/src/commonMain/kotlin/com/linroid/kdown/examples/backend/BackendManager.kt rename to app/shared/src/commonMain/kotlin/com/linroid/kdown/app/backend/BackendManager.kt index 616b23fb..4a05db69 100644 --- a/examples/app/src/commonMain/kotlin/com/linroid/kdown/examples/backend/BackendManager.kt +++ b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/backend/BackendManager.kt @@ -1,4 +1,4 @@ -package com.linroid.kdown.examples.backend +package com.linroid.kdown.app.backend import com.linroid.kdown.api.DownloadRequest import com.linroid.kdown.api.DownloadTask diff --git a/examples/app/src/commonMain/kotlin/com/linroid/kdown/examples/backend/LocalServerHandle.kt b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/backend/LocalServerHandle.kt similarity index 92% rename from examples/app/src/commonMain/kotlin/com/linroid/kdown/examples/backend/LocalServerHandle.kt rename to app/shared/src/commonMain/kotlin/com/linroid/kdown/app/backend/LocalServerHandle.kt index 1b925601..ab42c848 100644 --- a/examples/app/src/commonMain/kotlin/com/linroid/kdown/examples/backend/LocalServerHandle.kt +++ b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/backend/LocalServerHandle.kt @@ -1,4 +1,4 @@ -package com.linroid.kdown.examples.backend +package com.linroid.kdown.app.backend /** * Represents a running local server. Defined in commonMain so diff --git a/examples/app/src/commonMain/kotlin/com/linroid/kdown/examples/backend/ServerState.kt b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/backend/ServerState.kt similarity index 81% rename from examples/app/src/commonMain/kotlin/com/linroid/kdown/examples/backend/ServerState.kt rename to app/shared/src/commonMain/kotlin/com/linroid/kdown/app/backend/ServerState.kt index 78481bf6..707fc6bc 100644 --- a/examples/app/src/commonMain/kotlin/com/linroid/kdown/examples/backend/ServerState.kt +++ b/app/shared/src/commonMain/kotlin/com/linroid/kdown/app/backend/ServerState.kt @@ -1,4 +1,4 @@ -package com.linroid.kdown.examples.backend +package com.linroid.kdown.app.backend /** State of the optional HTTP server exposing the embedded backend. */ sealed class ServerState { diff --git a/examples/app/src/commonTest/kotlin/com/linroid/kdown/examples/BackendManagerTest.kt b/app/shared/src/commonTest/kotlin/com/linroid/kdown/app/BackendManagerTest.kt similarity index 99% rename from examples/app/src/commonTest/kotlin/com/linroid/kdown/examples/BackendManagerTest.kt rename to app/shared/src/commonTest/kotlin/com/linroid/kdown/app/BackendManagerTest.kt index 5c917f9f..a4561f88 100644 --- a/examples/app/src/commonTest/kotlin/com/linroid/kdown/examples/BackendManagerTest.kt +++ b/app/shared/src/commonTest/kotlin/com/linroid/kdown/app/BackendManagerTest.kt @@ -1,8 +1,8 @@ -package com.linroid.kdown.examples +package com.linroid.kdown.app -import com.linroid.kdown.examples.backend.BackendConfig +import com.linroid.kdown.app.backend.BackendConfig import com.linroid.kdown.remote.ConnectionState -import com.linroid.kdown.examples.backend.BackendEntry +import com.linroid.kdown.app.backend.BackendEntry import kotlinx.coroutines.flow.MutableStateFlow import kotlin.test.Test import kotlin.test.assertEquals diff --git a/examples/app/src/commonTest/kotlin/com/linroid/kdown/examples/FakeBackendFactory.kt b/app/shared/src/commonTest/kotlin/com/linroid/kdown/app/FakeBackendFactory.kt similarity index 86% rename from examples/app/src/commonTest/kotlin/com/linroid/kdown/examples/FakeBackendFactory.kt rename to app/shared/src/commonTest/kotlin/com/linroid/kdown/app/FakeBackendFactory.kt index 9180a643..7953f71e 100644 --- a/examples/app/src/commonTest/kotlin/com/linroid/kdown/examples/FakeBackendFactory.kt +++ b/app/shared/src/commonTest/kotlin/com/linroid/kdown/app/FakeBackendFactory.kt @@ -1,11 +1,11 @@ -package com.linroid.kdown.examples +package com.linroid.kdown.app import com.linroid.kdown.api.KDownApi -import com.linroid.kdown.examples.backend.BackendConfig +import com.linroid.kdown.app.backend.BackendConfig /** - * A fake [com.linroid.kdown.examples.backend.BackendFactory] for testing - * [com.linroid.kdown.examples.backend.BackendManager]. + * A fake [com.linroid.kdown.app.backend.BackendFactory] for testing + * [com.linroid.kdown.app.backend.BackendManager]. * * Usage: * ``` diff --git a/examples/app/src/commonTest/kotlin/com/linroid/kdown/examples/FakeKDownApi.kt b/app/shared/src/commonTest/kotlin/com/linroid/kdown/app/FakeKDownApi.kt similarity index 97% rename from examples/app/src/commonTest/kotlin/com/linroid/kdown/examples/FakeKDownApi.kt rename to app/shared/src/commonTest/kotlin/com/linroid/kdown/app/FakeKDownApi.kt index a2009c1d..c2758702 100644 --- a/examples/app/src/commonTest/kotlin/com/linroid/kdown/examples/FakeKDownApi.kt +++ b/app/shared/src/commonTest/kotlin/com/linroid/kdown/app/FakeKDownApi.kt @@ -1,4 +1,4 @@ -package com.linroid.kdown.examples +package com.linroid.kdown.app import com.linroid.kdown.api.DownloadRequest import com.linroid.kdown.api.DownloadTask diff --git a/examples/app/src/iosMain/kotlin/com/linroid/kdown/examples/MainViewController.kt b/app/shared/src/iosMain/kotlin/com/linroid/kdown/app/MainViewController.kt similarity index 78% rename from examples/app/src/iosMain/kotlin/com/linroid/kdown/examples/MainViewController.kt rename to app/shared/src/iosMain/kotlin/com/linroid/kdown/app/MainViewController.kt index d9575fa9..a6fe8aed 100644 --- a/examples/app/src/iosMain/kotlin/com/linroid/kdown/examples/MainViewController.kt +++ b/app/shared/src/iosMain/kotlin/com/linroid/kdown/app/MainViewController.kt @@ -1,10 +1,10 @@ -package com.linroid.kdown.examples +package com.linroid.kdown.app import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import androidx.compose.ui.window.ComposeUIViewController -import com.linroid.kdown.examples.backend.BackendFactory -import com.linroid.kdown.examples.backend.BackendManager +import com.linroid.kdown.app.backend.BackendFactory +import com.linroid.kdown.app.backend.BackendManager import com.linroid.kdown.sqlite.DriverFactory import com.linroid.kdown.sqlite.createSqliteTaskStore diff --git a/examples/webApp/build.gradle.kts b/app/web/build.gradle.kts similarity index 93% rename from examples/webApp/build.gradle.kts rename to app/web/build.gradle.kts index 643571b6..9fcf244b 100644 --- a/examples/webApp/build.gradle.kts +++ b/app/web/build.gradle.kts @@ -19,7 +19,7 @@ kotlin { sourceSets { wasmJsMain.dependencies { - implementation(projects.examples.app) + implementation(projects.app.shared) implementation(libs.compose.ui) implementation(libs.compose.runtime) implementation(libs.compose.foundation) diff --git a/examples/webApp/src/wasmJsMain/kotlin/com/linroid/kdown/examples/main.kt b/app/web/src/wasmJsMain/kotlin/com/linroid/kdown/app/main.kt similarity index 78% rename from examples/webApp/src/wasmJsMain/kotlin/com/linroid/kdown/examples/main.kt rename to app/web/src/wasmJsMain/kotlin/com/linroid/kdown/app/main.kt index 271742da..a2aa505d 100644 --- a/examples/webApp/src/wasmJsMain/kotlin/com/linroid/kdown/examples/main.kt +++ b/app/web/src/wasmJsMain/kotlin/com/linroid/kdown/app/main.kt @@ -1,11 +1,11 @@ -package com.linroid.kdown.examples +package com.linroid.kdown.app import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.window.ComposeViewport -import com.linroid.kdown.examples.backend.BackendFactory -import com.linroid.kdown.examples.backend.BackendManager +import com.linroid.kdown.app.backend.BackendFactory +import com.linroid.kdown.app.backend.BackendManager import kotlinx.browser.document @OptIn(ExperimentalComposeUiApi::class) diff --git a/examples/webApp/src/wasmJsMain/resources/index.html b/app/web/src/wasmJsMain/resources/index.html similarity index 100% rename from examples/webApp/src/wasmJsMain/resources/index.html rename to app/web/src/wasmJsMain/resources/index.html diff --git a/examples/cli/build.gradle.kts b/cli/build.gradle.kts similarity index 80% rename from examples/cli/build.gradle.kts rename to cli/build.gradle.kts index f3fa4f48..b6f3a249 100644 --- a/examples/cli/build.gradle.kts +++ b/cli/build.gradle.kts @@ -4,7 +4,7 @@ plugins { } application { - mainClass.set("com.linroid.kdown.examples.MainKt") + mainClass.set("com.linroid.kdown.cli.MainKt") } dependencies { diff --git a/examples/cli/src/main/kotlin/com/linroid/kdown/examples/Main.kt b/cli/src/main/kotlin/com/linroid/kdown/cli/Main.kt similarity index 99% rename from examples/cli/src/main/kotlin/com/linroid/kdown/examples/Main.kt rename to cli/src/main/kotlin/com/linroid/kdown/cli/Main.kt index 2ff6aacd..a0e29eb2 100644 --- a/examples/cli/src/main/kotlin/com/linroid/kdown/examples/Main.kt +++ b/cli/src/main/kotlin/com/linroid/kdown/cli/Main.kt @@ -1,4 +1,4 @@ -package com.linroid.kdown.examples +package com.linroid.kdown.cli import com.linroid.kdown.api.DownloadPriority import com.linroid.kdown.api.DownloadRequest diff --git a/docs/architecture/multi-backend-example-app.md b/docs/architecture/multi-backend-example-app.md new file mode 100644 index 00000000..603a5f2f --- /dev/null +++ b/docs/architecture/multi-backend-example-app.md @@ -0,0 +1,603 @@ +# Multi-Backend Example App Architecture + +## Overview + +The example app (`app/shared`) currently creates a hardcoded `KDown` instance. This +design adds support for three backend modes, all working through the existing `KDownApi` +interface: + +1. **Embedded** (default) -- in-process `KDown` instance, works on all platforms +2. **Remote server** -- connects to an existing KDown daemon via `RemoteKDown` +3. **Local server** -- starts `KDownServer` in-process (JVM/Desktop only) + +Users can configure multiple backends (e.g., several remote servers) and switch between +them. The embedded backend is always present and cannot be removed. + +## Design Principles + +- **`KDownApi` is the only abstraction the UI needs.** No new download interfaces. +- **Backend list model.** Users manage a list of configured backends (like bookmarks). + The embedded backend is always present. Remote servers can be added/removed freely. +- **Lambda injection over expect/actual.** Local server support is JVM-only. Instead + of expect/actual declarations on every platform, use a lambda parameter injected from + the JVM entry point. CommonMain checks `lambda != null` to gate UI visibility. +- **Compose state drives everything.** The UI observes `StateFlow`s; backend switching + is a state change, not a navigation event. + +--- + +## 1. Data Model + +### BackendConfig + +Describes how to create a backend. Sealed class in `commonMain`: + +```kotlin +// app/shared/src/commonMain/.../backend/BackendConfig.kt +package com.linroid.kdown.app.backend + +sealed class BackendConfig { + /** In-process KDown instance. Default on all platforms. */ + data object Embedded : BackendConfig() + + /** Connect to an existing KDown daemon. Works on all platforms. */ + data class Remote( + val host: String, + val port: Int = 8642, + val apiToken: String? = null + ) : BackendConfig() { + val baseUrl: String get() = "http://$host:$port" + } + + /** Start a local KDownServer in-process. JVM only. */ + data class LocalServer( + val port: Int = 8642, + val apiToken: String? = null + ) : BackendConfig() +} +``` + +### BackendType + +Simple enum for UI display logic: + +```kotlin +enum class BackendType { + EMBEDDED, + REMOTE, + LOCAL_SERVER +} +``` + +### BackendEntry + +A configured backend in the list. Combines config with runtime identity and state: + +```kotlin +data class BackendEntry( + val id: String, + val type: BackendType, + val label: String, + val config: BackendConfig, + val connectionState: StateFlow +) +``` + +- `id`: Stable identifier. `"embedded"` for the built-in backend. UUID for user-added + backends. +- `label`: Human-readable display name. "Embedded" for the built-in backend. For remote, + derived from host:port (e.g., "192.168.1.5:8642"). For local server, "Local Server". +- `connectionState`: Per-backend connection status. For embedded, always `Connected`. + For remote, mirrors `RemoteKDown.connectionState`. For local server, `Connected` once + the server is listening. + +### BackendConnectionState + +UI-friendly connection status, unified across backend types: + +```kotlin +sealed class BackendConnectionState { + data object Connected : BackendConnectionState() + data object Connecting : BackendConnectionState() + data class Disconnected(val reason: String? = null) : BackendConnectionState() +} +``` + +This maps from `RemoteKDown.connectionState` for remote backends and is always +`Connected` for embedded/local-server backends. + +### Platform availability (no expect/actual needed) + +Local server support is determined at runtime via lambda injection rather than +expect/actual. See section 3 (BackendFactory) for details. The UI checks +`backendManager.isLocalServerSupported` which is simply `localServerFactory != null`. + +--- + +## 2. BackendManager + +Manages the list of configured backends, the active backend, and lifecycle transitions. +Lives in `commonMain`. + +```kotlin +class BackendManager( + private val factory: BackendFactory +) { + /** All configured backends. Embedded is always first. */ + val backends: StateFlow> + + /** The currently active backend entry. */ + val activeBackend: StateFlow + + /** The KDownApi for the active backend. UI observes this for tasks. */ + val activeApi: StateFlow + + /** + * Switch to a different configured backend by ID. + * Closes the old backend's KDownApi, creates a new one. + * Throws on failure (old backend remains active). + */ + suspend fun switchTo(id: String) + + /** + * Add a new remote server to the backend list. + * Does NOT activate it -- call switchTo() afterward. + * Returns the new BackendEntry. + */ + fun addRemote(host: String, port: Int = 8642, token: String? = null): BackendEntry + + /** + * Remove a backend by ID. Cannot remove the embedded backend. + * If the removed backend is active, switches to embedded first. + */ + suspend fun removeBackend(id: String) + + /** Whether the current platform supports starting a local server. */ + val isLocalServerSupported: Boolean // delegates to factory.isLocalServerSupported + + /** + * Start a local server and add it to the backend list. JVM only. + * Throws UnsupportedOperationException if !isLocalServerSupported. + * Returns the new BackendEntry. + */ + fun addLocalServer(port: Int = 8642, token: String? = null): BackendEntry + + /** Close the active backend and release all resources. */ + fun close() +} +``` + +### Key behaviors + +1. **Embedded is always present.** The backend list is initialized with a single embedded + entry (`id = "embedded"`). It cannot be removed. +2. **Adding backends does not activate them.** `addRemote()` and `addLocalServer()` only + add to the list. The user must explicitly `switchTo()` to activate. +3. **Removing the active backend auto-switches to embedded.** If the user removes the + currently active remote backend, the manager switches to embedded before removing it. +4. **Only one active backend at a time.** Switching closes the previous backend's + `KDownApi` and factory resources. +5. **Connection state is per-entry.** Each `BackendEntry` has its own + `connectionState` StateFlow. For inactive remote entries, the state is `Disconnected` + (connection is only established on activation). For the active entry, it reflects + actual connection status. + +### Why `addRemote` / `addLocalServer` instead of `switchTo(BackendConfig)` + +The UX design calls for a backend list where users can add servers and switch between +them later. Separating "add" from "activate" means: +- The backend selector sheet can show all configured servers with their status +- Users can remove servers they no longer need +- The add-remote dialog is separate from the switch action + +--- + +## 3. BackendFactory (lambda injection, no expect/actual) + +Per kmp-expert review: instead of expect/actual classes with 4 platform files, we use +**lambda injection** to provide the JVM-only local server capability. This keeps all +factory logic in `commonMain` with zero platform-specific files. + +### LocalServerHandle + +A simple interface representing a running local server. Defined in `commonMain` so +`BackendManager` can manage its lifecycle without referencing `KDownServer` directly: + +```kotlin +// commonMain +interface LocalServerHandle { + val api: KDownApi // The core KDown instance the server wraps + fun stop() +} +``` + +### BackendFactory + +A regular class in `commonMain` (not expect/actual). Takes an optional lambda for +local server creation: + +```kotlin +// commonMain +class BackendFactory( + private val localServerFactory: ((BackendConfig.LocalServer) -> LocalServerHandle)? = null +) { + /** Whether this platform supports starting a local server. */ + val isLocalServerSupported: Boolean + get() = localServerFactory != null + + private var localServer: LocalServerHandle? = null + + fun create(config: BackendConfig): KDownApi { + return when (config) { + is BackendConfig.Embedded -> createEmbeddedKDown() + is BackendConfig.Remote -> + RemoteKDown(config.baseUrl, config.apiToken) + is BackendConfig.LocalServer -> { + val factory = localServerFactory + ?: throw UnsupportedOperationException( + "Local server not supported on this platform" + ) + val handle = factory(config) + localServer = handle + handle.api // UI interacts with core KDown directly + } + } + } + + fun closeResources() { + localServer?.stop() + localServer = null + } + + private fun createEmbeddedKDown(): KDown { + return KDown( + httpEngine = KtorHttpEngine(), + config = DownloadConfig( + maxConnections = 4, + retryCount = 3, + queueConfig = QueueConfig(maxConcurrentDownloads = 3) + ), + logger = Logger.console() + ) + } +} +``` + +### JVM entry point provides the lambda + +Only the JVM/Desktop entry point passes the `localServerFactory`. All other platforms +pass `null` (the default): + +```kotlin +// app/desktop/src/main/kotlin/.../main.kt (JVM only) +fun main() = application { + val backendManager = remember { + BackendManager( + BackendFactory(localServerFactory = { config -> + val kdown = KDown( + httpEngine = KtorHttpEngine(), + logger = Logger.console() + ) + val server = KDownServer( + kdown, + KDownServerConfig( + port = config.port, + apiToken = config.apiToken + ) + ) + server.start(wait = false) + object : LocalServerHandle { + override val api: KDownApi = kdown + override fun stop() = server.stop() + } + }) + ) + } + // ... +} + +// iOS, Android, wasmJs entry points: +BackendManager(BackendFactory()) // no lambda = no local server +``` + +### Why lambda injection over expect/actual + +- **Eliminates 8 files.** No `BackendFactory.jvm.kt`, `BackendFactory.ios.kt`, + `BackendFactory.android.kt`, `BackendFactory.wasmJs.kt`, and no `Platform.*.kt` files. +- **All factory logic in one place.** Easier to understand and maintain. +- **Server dependency stays in jvmMain.** Only `app/desktop` (pure JVM) references + `KDownServer` and `KDownServerConfig`. The `app/shared` KMP module never touches them. +- **Runtime capability check is natural.** `isLocalServerSupported` = `lambda != null`. + +**Note on LocalServer backend:** When running a local server, the UI interacts with +the core `KDown` instance directly (same process). The server is started alongside it +so external clients (browser, other devices) can also connect. This avoids HTTP +round-trip overhead for local UI operations. + +--- + +## 4. State Flow & Switching Sequence + +### Data flow + +``` +BackendManager + | + |-- backends: StateFlow> <-- backend selector list + |-- activeBackend: StateFlow <-- current selection + |-- activeApi: StateFlow <-- UI task list + | + | BackendEntry + | |-- id, type, label, config + | |-- connectionState: StateFlow +``` + +### switchTo(id) sequence + +``` +User taps a backend in the selector sheet + --> BackendManager.switchTo(id) + 1. Find entry in backends list by id + 2. If same as active, no-op + 3. Close current activeApi (api.close()) + 4. Close factory resources (factory.closeResources()) + 5. Create new KDownApi via factory.create(entry.config) + 6. Update activeBackend, activeApi flows + 7. If embedded/local: call (api as KDown).loadTasks() + 8. If remote: start observing RemoteKDown.connectionState + --> map to entry's connectionState flow + 9. Emit Connected (or Connecting for remote) + --> UI recomposes with new activeApi.tasks +``` + +### addRemote() sequence + +``` +User fills in "Add Remote Server" dialog, taps Add + --> BackendManager.addRemote(host, port, token) + 1. Create BackendEntry with UUID id, type=REMOTE, config=Remote(...) + 2. Entry's connectionState = Disconnected (not yet connected) + 3. Add to backends list + 4. Return the entry + --> UI shows new entry in backend selector sheet + --> User can tap it to switchTo(entry.id) and activate +``` + +--- + +## 5. Compose Integration + +### App composable changes + +`App()` accepts a `BackendManager` instead of creating `KDown` directly: + +```kotlin +@Composable +fun App(backendManager: BackendManager) { + val activeApi by backendManager.activeApi.collectAsState() + val activeBackend by backendManager.activeBackend.collectAsState() + val backends by backendManager.backends.collectAsState() + val tasks by activeApi.tasks.collectAsState() + val connectionState by activeBackend.connectionState.collectAsState() + val version by activeApi.version.collectAsState() + + // ... rest of UI uses `activeApi` instead of `kdown` +} +``` + +### Platform entry points + +```kotlin +// Desktop main.kt (JVM -- provides localServerFactory lambda) +fun main() = application { + val backendManager = remember { + BackendManager( + BackendFactory(localServerFactory = { config -> + val kdown = KDown(httpEngine = KtorHttpEngine(), logger = Logger.console()) + val server = KDownServer(kdown, KDownServerConfig( + port = config.port, apiToken = config.apiToken + )) + server.start(wait = false) + object : LocalServerHandle { + override val api: KDownApi = kdown + override fun stop() = server.stop() + } + }) + ) + } + DisposableEffect(Unit) { + onDispose { backendManager.close() } + } + Window( + onCloseRequest = ::exitApplication, + title = "KDown Examples" + ) { + App(backendManager) + } +} + +// iOS, Android, wasmJs entry points -- no local server support +val backendManager = BackendManager(BackendFactory()) +App(backendManager) +``` + +### Top app bar + +Shows current backend and connection status: + +``` +[TopAppBar] + Title: "KDown" + Subtitle: clickable chip showing "v1.0.0 · Core" + or "v1.0.0 · Remote · 192.168.1.5:8642" + Trailing: Connection status dot + - Green = Connected + - Yellow/Amber = Connecting + - Red = Disconnected + Tap subtitle chip --> opens backend selector bottom sheet +``` + +### Backend selector (ModalBottomSheet) + +``` +[Backend Selector Sheet] + -- Backend List -- + [*] Embedded [Connected] + [ ] Remote · 192.168.1.5:8642 [Disconnected] [X remove] + [ ] Remote · nas.local:8642 [Disconnected] [X remove] + [ ] Local Server · :8642 [Connected] [X remove] + + -- Actions -- + [+ Add Remote Server] + [+ Start Local Server] (only shown when backendManager.isLocalServerSupported) +``` + +Tapping a backend entry calls `switchTo(entry.id)`. The [X] button calls +`removeBackend(entry.id)`. + +### Add Remote Server (AlertDialog) + +``` +[Add Remote Server] + Host: [________________] + Port: [8642____________] + Token: [________________] (optional) + + [Cancel] [Add] +``` + +Tapping "Add" calls `addRemote(host, port, token)`. The new entry appears in the +backend selector sheet but is not activated until the user taps it. + +--- + +## 6. File / Package Layout + +All new code in `app/shared/src/commonMain/` under a `backend` sub-package. +**No platform-specific source sets needed** thanks to lambda injection: + +``` +app/shared/src/ + commonMain/kotlin/com/linroid/kdown/examples/ + App.kt (modified: accept BackendManager param) + backend/ + BackendConfig.kt (sealed class: Embedded, Remote, LocalServer) + BackendConnectionState.kt (sealed class: Connected, Connecting, Disconnected) + BackendEntry.kt (data class with id, type, label, config, connectionState) + BackendType.kt (enum: EMBEDDED, REMOTE, LOCAL_SERVER) + BackendManager.kt (manages backend list, active backend, switching) + BackendFactory.kt (regular class with optional localServerFactory lambda) + LocalServerHandle.kt (interface: api + stop()) + +app/desktop/src/main/kotlin/com/linroid/kdown/examples/ + main.kt (modified: create BackendFactory with localServerFactory lambda) +``` + +Note: The `localServerFactory` lambda is only provided in the JVM desktop entry point +(`app/desktop`), which already depends on the `server` module. No changes +needed in iOS, Android, or wasmJs entry points. + +--- + +## 7. Dependency Changes + +### app/shared/build.gradle.kts + +```kotlin +commonMain.dependencies { + // existing + implementation(projects.library.ktor) + // add + implementation(projects.library.remote) // RemoteKDown +} +// No jvmMain changes -- server dependency lives in desktopApp, not here +``` + +### app/desktop/build.gradle.kts + +```kotlin +dependencies { + implementation(projects.app.shared) + implementation(compose.desktop.currentOs) + implementation(libs.kotlinx.coroutinesSwing) + // add + implementation(projects.server) // KDownServer -- only referenced in main.kt lambda +} +``` + +The `server` module dependency belongs in `desktopApp` (not `app/shared/jvmMain`) +because only the desktop entry point provides the `localServerFactory` lambda that +references `KDownServer`. The `app/shared` KMP module never imports server classes. + +No changes needed for iOS, Android, or wasmJs -- they only use `library.remote` which +is already KMP. + +### All platform entry points need updating + +Per kmp-expert review, all entry points must create `BackendManager` and pass to `App()`: +- `app/desktop/main.kt` -- with `localServerFactory` lambda (shown in section 5) +- `app/android` -- `BackendManager(BackendFactory())` +- iOS `MainViewController.kt` -- `BackendManager(BackendFactory())` +- wasmJs entry point -- `BackendManager(BackendFactory())` + +--- + +## 8. Error Handling + +### Backend switch failures + +If `switchTo()` fails (e.g., remote server unreachable, port already in use), the +manager: + +1. Keeps the old backend active (does not close it before the new one succeeds) +2. Throws the exception, which the UI catches and shows as an error snackbar + +```kotlin +scope.launch { + try { + backendManager.switchTo(entry.id) + } catch (e: Exception) { + errorMessage = "Failed to switch backend: ${e.message}" + } +} +``` + +### Remote disconnection + +When a remote backend disconnects, its `connectionState` transitions to `Disconnected`. +The UI shows a connection indicator change but does NOT auto-switch. `RemoteKDown` +handles reconnection with exponential backoff. The user can manually switch to another +backend if they prefer. + +### Removing the active backend + +If the user removes the currently active backend, the manager switches to embedded +first, then removes the entry. This ensures there is always an active backend. + +--- + +## 9. What This Design Does NOT Do + +- **No ViewModel layer.** The example app is intentionally simple. `BackendManager` + holds the StateFlows directly. A production app would wrap this in a ViewModel. +- **No persistent backend list.** The backend list is not saved to disk. The app + always starts with only the embedded backend. A production app might persist + configured remote servers. +- **No simultaneous active backends.** Only one backend is active at a time. Switching + closes the previous one. +- **No new modules.** All code lives in the existing `app/shared` and + `app/desktop` modules. No platform-specific source sets needed in + `app/shared` -- lambda injection handles the JVM-only local server case. + +--- + +## 10. Summary of Changes + +| Area | Change | +|------|--------| +| `app/shared/commonMain` | Add `backend/` package: `BackendConfig`, `BackendConnectionState`, `BackendEntry`, `BackendType`, `BackendManager`, `BackendFactory`, `LocalServerHandle` (all in commonMain, no expect/actual) | +| `app/shared/commonMain/App.kt` | Accept `BackendManager` param, use `activeApi` StateFlow, add backend indicator chip in TopAppBar, add backend selector bottom sheet | +| `app/shared/build.gradle.kts` | Add `library.remote` to commonMain dependencies | +| `app/desktop/main.kt` | Create `BackendFactory` with `localServerFactory` lambda, pass `BackendManager` to `App()` | +| `app/desktop/build.gradle.kts` | Add `projects.server` dependency | +| All platform entry points | Create `BackendManager(BackendFactory())` and pass to `App()` | diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1498d2ee..5bd4c125 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] agp = "9.0.0" android-compileSdk = "36" -android-minSdk = "24" +android-minSdk = "26" android-targetSdk = "36" androidx-activity = "1.12.3" androidx-appcompat = "1.7.1" diff --git a/settings.gradle.kts b/settings.gradle.kts index 3e27fe90..c099b08d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -41,9 +41,9 @@ include(":library:kermit") include(":library:sqlite") include(":server") -// Example modules -include(":examples:app") -include(":examples:androidApp") -include(":examples:desktopApp") -include(":examples:webApp") -include(":examples:cli") +// App modules +include(":app:shared") +include(":app:android") +include(":app:desktop") +include(":app:web") +include(":cli")