From 2e7e931c3babb25712080bbc554013d0988a42e5 Mon Sep 17 00:00:00 2001 From: Sven Date: Fri, 10 Apr 2026 22:30:46 +0200 Subject: [PATCH] first commit --- dock-g/.DS_Store | Bin 0 -> 6148 bytes dock-g/dock-g.xcodeproj/project.pbxproj | 337 ++++++++++++ .../contents.xcworkspacedata | 7 + .../UserInterfaceState.xcuserstate | Bin 0 -> 15758 bytes .../xcschemes/xcschememanagement.plist | 14 + .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/AppIcon.png | Bin 0 -> 20498 bytes .../AppIcon.appiconset/Contents.json | 38 ++ dock-g/dock-g/Assets.xcassets/Contents.json | 6 + dock-g/dock-g/ContentView.swift | 42 ++ dock-g/dock-g/Models/DockgeServer.swift | 22 + dock-g/dock-g/Models/ServiceStatus.swift | 18 + dock-g/dock-g/Models/Stack.swift | 45 ++ dock-g/dock-g/Models/StackStatus.swift | 31 ++ dock-g/dock-g/Services/DockgeService.swift | 488 ++++++++++++++++++ dock-g/dock-g/Services/KeychainService.swift | 40 ++ dock-g/dock-g/Services/SocketIOClient.swift | 247 +++++++++ dock-g/dock-g/Utilities/ANSIParser.swift | 120 +++++ dock-g/dock-g/Utilities/ANSIStripper.swift | 12 + dock-g/dock-g/Utilities/Theme.swift | 80 +++ dock-g/dock-g/ViewModels/AppState.swift | 158 ++++++ .../dock-g/Views/Servers/AddServerView.swift | 81 +++ dock-g/dock-g/Views/Servers/LoginView.swift | 150 ++++++ dock-g/dock-g/Views/Servers/ServersView.swift | 166 ++++++ .../dock-g/Views/Settings/SettingsView.swift | 46 ++ .../Views/Stacks/ActionTerminalSheet.swift | 75 +++ .../dock-g/Views/Stacks/ComposeTabView.swift | 28 + .../dock-g/Views/Stacks/CreateStackView.swift | 289 +++++++++++ dock-g/dock-g/Views/Stacks/EnvTabView.swift | 27 + dock-g/dock-g/Views/Stacks/LogsTabView.swift | 102 ++++ .../dock-g/Views/Stacks/ServicesTabView.swift | 69 +++ .../dock-g/Views/Stacks/StackDetailView.swift | 345 +++++++++++++ dock-g/dock-g/Views/Stacks/StackRowView.swift | 57 ++ dock-g/dock-g/Views/Stacks/StacksView.swift | 173 +++++++ dock-g/dock-g/dock_gApp.swift | 20 + dock-g_ios_icons/dockge_AppIcon_1024.png | Bin 0 -> 20498 bytes dock-g_ios_icons/dockge_AppIcon_20@3x.png | Bin 0 -> 1137 bytes dock-g_ios_icons/dockge_AppIcon_29@3x.png | Bin 0 -> 1656 bytes dock-g_ios_icons/dockge_AppIcon_40@2x.png | Bin 0 -> 1517 bytes dock-g_ios_icons/dockge_AppIcon_60@2x.png | Bin 0 -> 2161 bytes dock-g_ios_icons/dockge_AppIcon_60@3x.png | Bin 0 -> 3216 bytes dock-g_ios_icons/dockge_AppIcon_76@2x.png | Bin 0 -> 2685 bytes dock-g_ios_icons/dockge_AppIcon_83.5@2x.png | Bin 0 -> 3027 bytes original-source/dockge | 1 + 44 files changed, 3345 insertions(+) create mode 100644 dock-g/.DS_Store create mode 100644 dock-g/dock-g.xcodeproj/project.pbxproj create mode 100644 dock-g/dock-g.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 dock-g/dock-g.xcodeproj/project.xcworkspace/xcuserdata/sven.xcuserdatad/UserInterfaceState.xcuserstate create mode 100644 dock-g/dock-g.xcodeproj/xcuserdata/sven.xcuserdatad/xcschemes/xcschememanagement.plist create mode 100644 dock-g/dock-g/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 dock-g/dock-g/Assets.xcassets/AppIcon.appiconset/AppIcon.png create mode 100644 dock-g/dock-g/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 dock-g/dock-g/Assets.xcassets/Contents.json create mode 100644 dock-g/dock-g/ContentView.swift create mode 100644 dock-g/dock-g/Models/DockgeServer.swift create mode 100644 dock-g/dock-g/Models/ServiceStatus.swift create mode 100644 dock-g/dock-g/Models/Stack.swift create mode 100644 dock-g/dock-g/Models/StackStatus.swift create mode 100644 dock-g/dock-g/Services/DockgeService.swift create mode 100644 dock-g/dock-g/Services/KeychainService.swift create mode 100644 dock-g/dock-g/Services/SocketIOClient.swift create mode 100644 dock-g/dock-g/Utilities/ANSIParser.swift create mode 100644 dock-g/dock-g/Utilities/ANSIStripper.swift create mode 100644 dock-g/dock-g/Utilities/Theme.swift create mode 100644 dock-g/dock-g/ViewModels/AppState.swift create mode 100644 dock-g/dock-g/Views/Servers/AddServerView.swift create mode 100644 dock-g/dock-g/Views/Servers/LoginView.swift create mode 100644 dock-g/dock-g/Views/Servers/ServersView.swift create mode 100644 dock-g/dock-g/Views/Settings/SettingsView.swift create mode 100644 dock-g/dock-g/Views/Stacks/ActionTerminalSheet.swift create mode 100644 dock-g/dock-g/Views/Stacks/ComposeTabView.swift create mode 100644 dock-g/dock-g/Views/Stacks/CreateStackView.swift create mode 100644 dock-g/dock-g/Views/Stacks/EnvTabView.swift create mode 100644 dock-g/dock-g/Views/Stacks/LogsTabView.swift create mode 100644 dock-g/dock-g/Views/Stacks/ServicesTabView.swift create mode 100644 dock-g/dock-g/Views/Stacks/StackDetailView.swift create mode 100644 dock-g/dock-g/Views/Stacks/StackRowView.swift create mode 100644 dock-g/dock-g/Views/Stacks/StacksView.swift create mode 100644 dock-g/dock-g/dock_gApp.swift create mode 100644 dock-g_ios_icons/dockge_AppIcon_1024.png create mode 100644 dock-g_ios_icons/dockge_AppIcon_20@3x.png create mode 100644 dock-g_ios_icons/dockge_AppIcon_29@3x.png create mode 100644 dock-g_ios_icons/dockge_AppIcon_40@2x.png create mode 100644 dock-g_ios_icons/dockge_AppIcon_60@2x.png create mode 100644 dock-g_ios_icons/dockge_AppIcon_60@3x.png create mode 100644 dock-g_ios_icons/dockge_AppIcon_76@2x.png create mode 100644 dock-g_ios_icons/dockge_AppIcon_83.5@2x.png create mode 160000 original-source/dockge diff --git a/dock-g/.DS_Store b/dock-g/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..4da5c6b3fb1656ad4ac60427a48118194be77db6 GIT binary patch literal 6148 zcmeHK%}T^D5T4Oh3SN2>JdjUcBxDC~aj$yQC}&~ z29j?wKl%BfNr#Adao^2}N+K%I1X+{`5%Z*L%Yt`+oaZ>ARd?6+O^p4-M8DA_d*7vw zBDHiw@AhAAI_Q`l5ENm%q=9asUH7Z`vW@FOPj-F={4{4B>$>SI0OHi0le8F#h#+~&VV!E47@QQ_d`GvtPGQ4zB#&sf{}i9hWRX8k@sTs&4E!+$cvdg#1rB9z>xbjXTN}_W&_pCIi2{K>`U${5 h?vaD+RDTd1ag||Glv%`{(}DgYkO}e58TbJPUI3^%KqUYG literal 0 HcmV?d00001 diff --git a/dock-g/dock-g.xcodeproj/project.pbxproj b/dock-g/dock-g.xcodeproj/project.pbxproj new file mode 100644 index 0000000..0e5c609 --- /dev/null +++ b/dock-g/dock-g.xcodeproj/project.pbxproj @@ -0,0 +1,337 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXFileReference section */ + 2617D1562F89084500DEE247 /* dock-g.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "dock-g.app"; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 2617D1582F89084500DEE247 /* dock-g */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = "dock-g"; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 2617D1532F89084400DEE247 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 2617D14D2F89084400DEE247 = { + isa = PBXGroup; + children = ( + 2617D1582F89084500DEE247 /* dock-g */, + 2617D1572F89084500DEE247 /* Products */, + ); + sourceTree = ""; + }; + 2617D1572F89084500DEE247 /* Products */ = { + isa = PBXGroup; + children = ( + 2617D1562F89084500DEE247 /* dock-g.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 2617D1552F89084400DEE247 /* dock-g */ = { + isa = PBXNativeTarget; + buildConfigurationList = 2617D1612F89084500DEE247 /* Build configuration list for PBXNativeTarget "dock-g" */; + buildPhases = ( + 2617D1522F89084400DEE247 /* Sources */, + 2617D1532F89084400DEE247 /* Frameworks */, + 2617D1542F89084400DEE247 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 2617D1582F89084500DEE247 /* dock-g */, + ); + name = "dock-g"; + packageProductDependencies = ( + ); + productName = "dock-g"; + productReference = 2617D1562F89084500DEE247 /* dock-g.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 2617D14E2F89084400DEE247 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2640; + LastUpgradeCheck = 2640; + TargetAttributes = { + 2617D1552F89084400DEE247 = { + CreatedOnToolsVersion = 26.4; + }; + }; + }; + buildConfigurationList = 2617D1512F89084400DEE247 /* Build configuration list for PBXProject "dock-g" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 2617D14D2F89084400DEE247; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 2617D1572F89084500DEE247 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 2617D1552F89084400DEE247 /* dock-g */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 2617D1542F89084400DEE247 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 2617D1522F89084400DEE247 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 2617D15F2F89084500DEE247 /* 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; + DEVELOPMENT_TEAM = EKFHUHT63T; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + 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 = 26.4; + 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; + }; + 2617D1602F89084500DEE247 /* 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"; + DEVELOPMENT_TEAM = EKFHUHT63T; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + 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 = 26.4; + 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; + }; + 2617D1622F89084500DEE247 /* 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 = EKFHUHT63T; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + 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; + PRODUCT_BUNDLE_IDENTIFIER = "Team.dock-g"; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 2617D1632F89084500DEE247 /* 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 = EKFHUHT63T; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + 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; + PRODUCT_BUNDLE_IDENTIFIER = "Team.dock-g"; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 2617D1512F89084400DEE247 /* Build configuration list for PBXProject "dock-g" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2617D15F2F89084500DEE247 /* Debug */, + 2617D1602F89084500DEE247 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 2617D1612F89084500DEE247 /* Build configuration list for PBXNativeTarget "dock-g" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2617D1622F89084500DEE247 /* Debug */, + 2617D1632F89084500DEE247 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 2617D14E2F89084400DEE247 /* Project object */; +} diff --git a/dock-g/dock-g.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/dock-g/dock-g.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/dock-g/dock-g.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/dock-g/dock-g.xcodeproj/project.xcworkspace/xcuserdata/sven.xcuserdatad/UserInterfaceState.xcuserstate b/dock-g/dock-g.xcodeproj/project.xcworkspace/xcuserdata/sven.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000000000000000000000000000000000000..42005427173e83528aa44fa1c91e7b27a21f683f GIT binary patch literal 15758 zcmeHud0CYptsQ48WxD{4bt zqEFDL=otD89YK}Tz)GycdThidoQcgi8{2R`9)l<1Nw^G8 z##8V#JOejk9=GB)+>V`i5nhZta3@}hm*F0~9AAR}iEqF+<6e9l-iEj12k?VQIS{@ zN8(8aF_BDSCRxNntR$Nhk};%+j3whp2`MFIWGbm9(?|`eBaOsCnn*KgAw0Q&c!-zy zNH@8VTtxijO0tgpgWN!FBpb<12HhT1Lxh1+Ao$ z=@eQ;&!bamHJwIlXdRtH9khuy)5WxdE}={5GTK8grha-2y_T-0*U=61dU^xBk#3>A zw2$6Nx6$qNA-an`LH|Yf(F61#eTg2TZ_#(@G5Q%jPCuvL(C_FE^j8jX92d?-aB?n% zOXbqIbS{H4ahaT%%i=7YmCNRGxLhueo5)S#in$W5lq=)Pxe9J7*T~J}W^uE*Iow>X zh2yzaZV~6=F5y;ijPr9VxmDb1?()VIS9eFpVHAnvNP(0n8pY14skbiXd%Sbudq5** zrZjgryxu+(g;b0%+K1FAhH>nDmTFg~m!+E$*dN-bxr-eY1z> zT?>Pyl^#bYZ&zBNV2Z;#&DrJwg>ISY=;-FXl^%CzO?@5j^|?KcrVd_I_Hu{MVOORF z8)$Gel{!4L9Nw8uuM>!7IeqQ*PFGt84>fk>M1e3cPADmJyL=vZM+fh*7nPNjRa6$* zECqRGg_iuhvOG&keo3jNs3@qI_-RU_<5oo%!sH_woppOa%Kf*;|Nn_mbIdHIHsfwd??WhiSK8jHrE@n`~? zh$f+8RDw!b6jQNire-lLmc_Anmaq-hpaNB*$!H3yLg&G{RKqH0*l1S3Ca{U@2&~9E z!kQ?7srq*J(i+DSXPW~iS1JlVmUwuW;wYeM^f^16J}2*;ce*`2lRb{E_5fjE3TC4PC~GU4 zgXW@nXgThT&fN9Q94OJrFrm*ojVGt6#wxA0bAeX7%E4f16TuANlEQ3o@yB$mvKTagR7Q5SNt6qd%);WzNBu5@=}+D} zzox$234-o+IXpdmK*eU))K3@pU`dXL@l)>x!2rTQAFI%1D7F{5Sz0ez%`$|(6zB@H z7F}sqYG6$S{jC)sKpo%Z;k{sN970ztlbNPR;i9GhJivrUKJ?BfpPiJbFqXcNVcX0nu^lo2=)5Qy1Nq9+^{sHmmNB>0EAojQE?aC&5gKu)#%-n+V znGJ(|fP&=}3_cF&vZ1Go_ZALxXP;Sbx8)AfiBnt;mdDjr)dDl;Y;}T3kcP1yT?dw+ zrhZztPhfaZ9Q)CFK_COdc0JmNvii^s=tgE?);@F-+QhP1j-XI+&4MC3M1u`$BVbEW z(CkW;SR)8km8+}U*T63WwNA;+FD)-9$j!A>=I54L^2g-mSW5D8Y?ku8yo!o~g3`+T zg7Vt`&S(Uf8fLCUyJD^PdAgf@-5${Wq9EPh zw^>%P6=QK%u_!D7Ztg|*3*78L_pw4YrVs5z53nLOPT(fJ+sk`uJ$x(g@$fA{J1@3V zu?)mqP?4%~uU(lmjIs=rvki>*P?|#`f_WJ2*}}$3i~A^g46saz$KwF&@+e{w#S`es zEo{6*@ih9^!rsO%1*>F}*_6A`tLP9qj9x>pqc_l-=q+>vz0IoFd2A}1#bz@ndzih>-e7Mr z^TKJ4WzJ6La=t~BQ@b*=ly7x=_%e5Am&4=qx?Q3%ZiI;%z#Gy&Czn_4;F>gew9iQP!suVJ7AD0OxL zL=rl#mAa{R^tii0#tnh3>x_o01bbKumJvt>8m<*wF|T0IhI8dr0vHWU{^#fml+}w) zutbvUWG;kXIjAHxI59mkq6PwOz*^GYl z3xJnjSsehDdX~BXnj16@^M$8DNTq~F!LUtpw7HzV?iPLyOpG)%WAQ4qIXvRb zp72PNh9g)b^fXhz%L;K?C1fH@DVXRSQP`B|4IG74I2tLi8pq&R9Eam^0@k2IusH=F zbG5r!v@Z(>`IV+LCI~s|+-@Ii(Y#a+Z+1GIu0e7;07zjarnd IRtaY%b$jJDbDK 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 Iul%|H2b$~%BLDyZ literal 0 HcmV?d00001 diff --git a/dock-g/dock-g.xcodeproj/xcuserdata/sven.xcuserdatad/xcschemes/xcschememanagement.plist b/dock-g/dock-g.xcodeproj/xcuserdata/sven.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..1c47d09 --- /dev/null +++ b/dock-g/dock-g.xcodeproj/xcuserdata/sven.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + dock-g.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/dock-g/dock-g/Assets.xcassets/AccentColor.colorset/Contents.json b/dock-g/dock-g/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/dock-g/dock-g/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/dock-g/dock-g/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/dock-g/dock-g/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..a4b877d4ea4c7d519d665b229153bfa7421b6768 GIT binary patch literal 20498 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 literal 0 HcmV?d00001 diff --git a/dock-g/dock-g/Assets.xcassets/AppIcon.appiconset/Contents.json b/dock-g/dock-g/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..87d4015 --- /dev/null +++ b/dock-g/dock-g/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,38 @@ +{ + "images" : [ + { + "filename" : "AppIcon.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "AppIcon.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "filename" : "AppIcon.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/dock-g/dock-g/Assets.xcassets/Contents.json b/dock-g/dock-g/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/dock-g/dock-g/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/dock-g/dock-g/ContentView.swift b/dock-g/dock-g/ContentView.swift new file mode 100644 index 0000000..3fd9c36 --- /dev/null +++ b/dock-g/dock-g/ContentView.swift @@ -0,0 +1,42 @@ +// +// ContentView.swift +// dock-g +// +// Created by Sven Hanold on 10.04.26. +// + +import SwiftUI + +struct ContentView: View { + @Environment(AppState.self) private var appState + + var body: some View { + @Bindable var state = appState + TabView(selection: $state.selectedTab) { + StacksView() + .tabItem { + Label("Stacks", systemImage: "shippingbox.fill") + } + .tag(AppState.Tab.stacks) + + ServersView() + .tabItem { + Label("Servers", systemImage: "server.rack") + } + .tag(AppState.Tab.servers) + + SettingsView() + .tabItem { + Label("Settings", systemImage: "gearshape.fill") + } + .tag(AppState.Tab.settings) + } + .tint(.appAccent) + .preferredColorScheme(appState.appearanceMode.colorScheme) + } +} + +#Preview { + ContentView() + .environment(AppState()) +} diff --git a/dock-g/dock-g/Models/DockgeServer.swift b/dock-g/dock-g/Models/DockgeServer.swift new file mode 100644 index 0000000..ccc1561 --- /dev/null +++ b/dock-g/dock-g/Models/DockgeServer.swift @@ -0,0 +1,22 @@ +import Foundation + +struct DockgeServer: Identifiable, Codable { + var id: UUID + var name: String // Friendly label, e.g. "Home Server" + var host: String // e.g. "myserver.home:5001" + var useSSL: Bool + + init(id: UUID = UUID(), name: String, host: String, useSSL: Bool = false) { + self.id = id + self.name = name + self.host = host + self.useSSL = useSSL + } + + var baseURL: URL? { + let scheme = useSSL ? "https" : "http" + return URL(string: "\(scheme)://\(host)") + } + + var displayHost: String { host } +} diff --git a/dock-g/dock-g/Models/ServiceStatus.swift b/dock-g/dock-g/Models/ServiceStatus.swift new file mode 100644 index 0000000..46a03d9 --- /dev/null +++ b/dock-g/dock-g/Models/ServiceStatus.swift @@ -0,0 +1,18 @@ +import SwiftUI + +struct ServiceStatus: Identifiable, Codable { + var id: String { name } + var name: String + var state: String + var ports: [String] + + var stateColor: Color { + switch state { + case "running": return Color(hex: "#22c55e") + case "exited": return Color(hex: "#ef4444") + case "starting": return Color(hex: "#f59e0b") + case "healthy": return Color(hex: "#22c55e") + default: return Color(hex: "#6b7280") + } + } +} diff --git a/dock-g/dock-g/Models/Stack.swift b/dock-g/dock-g/Models/Stack.swift new file mode 100644 index 0000000..f4de5db --- /dev/null +++ b/dock-g/dock-g/Models/Stack.swift @@ -0,0 +1,45 @@ +import Foundation + +struct Stack: Identifiable, Codable, Equatable { + var id: String { "\(endpoint)/\(name)" } + var name: String + var status: StackStatus + var isManagedByDockge: Bool + var composeFileName: String + var endpoint: String + var primaryHostname: String + var composeYAML: String? + var composeENV: String? + var tags: [String] + + init( + name: String, + status: StackStatus = .unknown, + isManagedByDockge: Bool = true, + composeFileName: String = "compose.yaml", + endpoint: String = "", + primaryHostname: String = "", + composeYAML: String? = nil, + composeENV: String? = nil, + tags: [String] = [] + ) { + self.name = name + self.status = status + self.isManagedByDockge = isManagedByDockge + self.composeFileName = composeFileName + self.endpoint = endpoint + self.primaryHostname = primaryHostname + self.composeYAML = composeYAML + self.composeENV = composeENV + self.tags = tags + } + + // Merges simple info (from stackList event) into an existing detailed stack + func withSimpleInfo(status: StackStatus, isManagedByDockge: Bool, tags: [String]) -> Stack { + var copy = self + copy.status = status + copy.isManagedByDockge = isManagedByDockge + copy.tags = tags + return copy + } +} diff --git a/dock-g/dock-g/Models/StackStatus.swift b/dock-g/dock-g/Models/StackStatus.swift new file mode 100644 index 0000000..5c5f9e3 --- /dev/null +++ b/dock-g/dock-g/Models/StackStatus.swift @@ -0,0 +1,31 @@ +import SwiftUI + +enum StackStatus: Int, Codable { + case unknown = 0 + case createdFile = 1 + case createdStack = 2 + case running = 3 + case exited = 4 + + var label: String { + switch self { + case .unknown: return "Unknown" + case .createdFile: return "Draft" + case .createdStack: return "Inactive" + case .running: return "Running" + case .exited: return "Exited" + } + } + + var color: Color { + switch self { + case .unknown: return Color(hex: "#374151") + case .createdFile: return Color(hex: "#6b7280") + case .createdStack: return Color(hex: "#6b7280") + case .running: return Color(hex: "#22c55e") + case .exited: return Color(hex: "#ef4444") + } + } + + var isActive: Bool { self == .running } +} diff --git a/dock-g/dock-g/Services/DockgeService.swift b/dock-g/dock-g/Services/DockgeService.swift new file mode 100644 index 0000000..3b13375 --- /dev/null +++ b/dock-g/dock-g/Services/DockgeService.swift @@ -0,0 +1,488 @@ +import Foundation + +// All Dockge-specific Socket.IO operations for a single server connection. +@Observable +@MainActor +final class DockgeService { + + enum AuthState: Equatable { + case disconnected + case connecting + case needsLogin + case needsSetup + case twoFactorRequired + case authenticated + case error(String) + } + + var authState: AuthState = .disconnected + var serverInfo: ServerInfo? + /// Stack list — updated directly from the "stackList" socket event. + var stacks: [Stack] = [] + + struct ServerInfo { + var version: String + var primaryHostname: String + } + + private let socket: SocketIOClient + private let server: DockgeServer + + // Terminal buffers keyed by terminalName + private(set) var terminalBuffers: [String: String] = [:] + private var terminalListeners: [String: [(String) -> Void]] = [:] + /// Stacks indexed by agent endpoint so partial updates don't wipe other agents. + private var stacksByEndpoint: [String: [Stack]] = [:] + + // Reconnect state + private var intentionalDisconnect = false + private var reconnectDelay: TimeInterval = 1 + private var reconnectTask: Task? + + /// All known agent endpoints (empty string = local agent). + var availableEndpoints: [String] { + Array(stacksByEndpoint.keys).sorted() + } + + init(server: DockgeServer) { + self.server = server + self.socket = SocketIOClient(allowSelfSigned: true) + } + + // MARK: - Connection + + func connect() { + intentionalDisconnect = false + reconnectDelay = 1 + reconnectTask?.cancel() + reconnectTask = nil + setupHandlers() + doConnect() + } + + /// Reconnect after an unexpected drop (e.g. from pull-to-refresh or explicit retry). + func reconnect() { + guard !intentionalDisconnect else { return } + reconnectDelay = 1 + reconnectTask?.cancel() + reconnectTask = nil + doConnect() + } + + func disconnect() { + intentionalDisconnect = true + reconnectTask?.cancel() + reconnectTask = nil + socket.clearHandlers() + socket.disconnect() + authState = .disconnected + stacks = [] + stacksByEndpoint = [:] + } + + // MARK: - Private connection helpers + + private func setupHandlers() { + socket.clearHandlers() + + socket.onConnect { [weak self] in + Task { @MainActor [weak self] in + print("[DockgeService] Socket connected") + self?.reconnectDelay = 1 // reset backoff on success + await self?.onSocketConnected() + } + } + socket.onDisconnect { [weak self] in + Task { @MainActor [weak self] in + guard let self else { return } + print("[DockgeService] Socket disconnected") + self.authState = .disconnected + if !self.intentionalDisconnect { + self.scheduleReconnect() + } + } + } + socket.onError { error in + print("[DockgeService] Socket error: \(error)") + } + socket.on("agent") { [weak self] data in + self?.handleAgentEvent(data) + } + socket.on("stackList") { [weak self] data in + print("[DockgeService] Received legacy stackList event") + self?.handleLegacyStackList(data) + } + socket.on("terminalWrite") { [weak self] data in + self?.handleTerminalWrite(data) + } + socket.on("info") { [weak self] data in + self?.handleServerInfo(data) + } + } + + private func doConnect() { + guard let url = server.baseURL else { + authState = .error("Invalid server URL") + return + } + authState = .connecting + print("[DockgeService] Connecting to \(url) (reconnectDelay was \(reconnectDelay)s)") + socket.connect(to: url) + } + + private func scheduleReconnect() { + let delay = reconnectDelay + reconnectDelay = min(reconnectDelay * 2, 30) + print("[DockgeService] Scheduling reconnect in \(delay)s") + reconnectTask?.cancel() + reconnectTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(delay)) + guard !Task.isCancelled else { return } + await MainActor.run { [weak self] in + guard let self, !self.intentionalDisconnect else { return } + self.doConnect() + } + } + } + + // MARK: - Auth + + private func onSocketConnected() async { + if let token = KeychainService.load(forKey: KeychainService.tokenKey(for: server.id)) { + print("[DockgeService] Trying loginByToken") + let result = await socket.emitWithAck("loginByToken", token) + print("[DockgeService] loginByToken result: \(result)") + if let dict = result.first as? [String: Any], dict["ok"] as? Bool == true { + authState = .authenticated + requestStackList() + return + } + KeychainService.delete(forKey: KeychainService.tokenKey(for: server.id)) + } + authState = .needsLogin + } + + /// Returns nil on success, error message on failure. + func login(username: String, password: String, twoFAToken: String? = nil) async -> String? { + var payload: [String: Any] = ["username": username, "password": password] + if let t = twoFAToken { payload["token"] = t } + let result = await socket.emitWithAck("login", payload) + print("[DockgeService] login result: \(result)") + guard let dict = result.first as? [String: Any] else { return "No response from server" } + + if dict["tokenRequired"] as? Bool == true { + authState = .twoFactorRequired + return "2fa" + } + if dict["ok"] as? Bool == true, let token = dict["token"] as? String { + KeychainService.save(token, forKey: KeychainService.tokenKey(for: server.id)) + authState = .authenticated + requestStackList() + return nil + } + return localizedError(dict["msg"] as? String ?? "Login failed") + } + + func setup(username: String, password: String) async -> String? { + let result = await socket.emitWithAck("setup", username, password) + guard let dict = result.first as? [String: Any] else { return "No response" } + if dict["ok"] as? Bool == true { + return await login(username: username, password: password) + } + return dict["msg"] as? String ?? "Setup failed" + } + + func logout() { + KeychainService.delete(forKey: KeychainService.tokenKey(for: server.id)) + authState = .needsLogin + } + + // MARK: - Stack list + + /// Fire-and-forget: Dockge responds via a "stackList" broadcast event, not an ack. + func requestStackList() { + print("[DockgeService] Emitting requestStackList") + socket.emit("requestStackList") + } + + // MARK: - Stack operations (return nil on success, error string on failure) + + func getStack(name: String, endpoint: String) async -> Stack? { + print("[DockgeService] getStack '\(name)' endpoint='\(endpoint)'") + // All stack ops go through the agent proxy: emit("agent", endpoint, event, ...args) + let result = await socket.emitWithAck("agent", endpoint, "getStack", name) + print("[DockgeService] getStack result: \(result)") + guard let dict = result.first as? [String: Any], + dict["ok"] as? Bool == true, + let stackDict = dict["stack"] as? [String: Any] else { return nil } + return parseDetailedStack(stackDict) + } + + @discardableResult + func startStack(_ name: String, endpoint: String) async -> String? { + return await stackAction("startStack", stackName: name, endpoint: endpoint) + } + + @discardableResult + func stopStack(_ name: String, endpoint: String) async -> String? { + return await stackAction("stopStack", stackName: name, endpoint: endpoint) + } + + @discardableResult + func restartStack(_ name: String, endpoint: String) async -> String? { + return await stackAction("restartStack", stackName: name, endpoint: endpoint) + } + + @discardableResult + func updateStack(_ name: String, endpoint: String) async -> String? { + return await stackAction("updateStack", stackName: name, endpoint: endpoint) + } + + @discardableResult + func downStack(_ name: String, endpoint: String) async -> String? { + return await stackAction("downStack", stackName: name, endpoint: endpoint) + } + + @discardableResult + func deleteStack(_ name: String, endpoint: String) async -> String? { + return await stackAction("deleteStack", stackName: name, endpoint: endpoint) + } + + @discardableResult + func deployStack(name: String, yaml: String, env: String, isAdd: Bool, endpoint: String) async -> String? { + let result = await socket.emitWithAck("agent", endpoint, "deployStack", name, yaml, env, isAdd) + return parseOkResult(result) + } + + @discardableResult + func saveStack(name: String, yaml: String, env: String, isAdd: Bool, endpoint: String) async -> String? { + let result = await socket.emitWithAck("agent", endpoint, "saveStack", name, yaml, env, isAdd) + return parseOkResult(result) + } + + /// Convert a `docker run` command to a compose.yaml snippet. + /// Handled by the main server (not agent proxy). + func composerize(_ dockerRunCommand: String) async -> String? { + let result = await socket.emitWithAck("composerize", dockerRunCommand) + guard let dict = result.first as? [String: Any], + dict["ok"] as? Bool == true, + let content = dict["content"] as? String else { return nil } + return content + } + + func serviceStatusList(stackName: String, endpoint: String) async -> [ServiceStatus] { + print("[DockgeService] serviceStatusList '\(stackName)' endpoint='\(endpoint)'") + let result = await socket.emitWithAck("agent", endpoint, "serviceStatusList", stackName) + print("[DockgeService] serviceStatusList result: \(result)") + guard let dict = result.first as? [String: Any], + dict["ok"] as? Bool == true, + let statusDict = dict["serviceStatusList"] as? [String: Any] else { return [] } + return statusDict.map { (svcName, value) -> ServiceStatus in + let info = value as? [String: Any] ?? [:] + return ServiceStatus( + name: svcName, + state: info["state"] as? String ?? "unknown", + ports: info["ports"] as? [String] ?? [] + ) + }.sorted { $0.name < $1.name } + } + + // MARK: - Terminal + + func onTerminalWrite(name: String, handler: @escaping (String) -> Void) { + terminalListeners[name, default: []].append(handler) + if let existing = terminalBuffers[name], !existing.isEmpty { + handler(existing) + } + } + + func removeTerminalListeners(name: String) { + terminalListeners.removeValue(forKey: name) + } + + func clearTerminalBuffer(name: String) { + terminalBuffers.removeValue(forKey: name) + } + + func terminalName(for stackName: String, endpoint: String = "") -> String { + "compose-\(endpoint)-\(stackName)" + } + + func combinedTerminalName(for stackName: String, endpoint: String = "") -> String { + "combined-\(endpoint)-\(stackName)" + } + + /// Fetch the action terminal buffer (compose/start/stop/restart/update/down) via terminalJoin. + func joinActionTerminal(stackName: String, endpoint: String) async { + let termName = terminalName(for: stackName, endpoint: endpoint) + print("[DockgeService] terminalJoin action '\(termName)'") + let result = await socket.emitWithAck("agent", endpoint, "terminalJoin", termName) + let buffer: String? + if let dict = result.first as? [String: Any] { + buffer = dict["buffer"] as? String + } else { + buffer = result.first as? String + } + guard let buf = buffer, !buf.isEmpty else { return } + terminalBuffers[termName] = buf + terminalListeners[termName]?.forEach { $0(buf) } + } + + /// Fetch (or re-fetch) combined logs for a stack via terminalJoin. + /// Replaces the local buffer with the server's authoritative full buffer, + /// then calls all registered listeners with the full content. + /// Safe to call repeatedly for polling — each call gives a fresh snapshot. + func joinTerminal(stackName: String, endpoint: String) async { + let termName = combinedTerminalName(for: stackName, endpoint: endpoint) + print("[DockgeService] terminalJoin '\(termName)'") + let result = await socket.emitWithAck("agent", endpoint, "terminalJoin", termName) + let buffer: String? + if let dict = result.first as? [String: Any] { + buffer = dict["buffer"] as? String + } else { + buffer = result.first as? String + } + guard let buf = buffer else { return } + // Replace (not append) so repeated polls give the correct full snapshot + terminalBuffers[termName] = buf + terminalListeners[termName]?.forEach { $0(buf) } + } + + func leaveTerminal(stackName: String, endpoint: String) { + socket.emit("agent", endpoint, "leaveCombinedTerminal", stackName) + } + + // MARK: - Private event handlers + + /// Handles Dockge 1.5+ multi-agent "agent" events. + /// Format: args = ["subCommand", {"ok": true, ...}] + private func handleAgentEvent(_ data: [Any]) { + guard let subCommand = data.first as? String else { return } + let rest = Array(data.dropFirst()) + print("[DockgeService] agent sub-command: '\(subCommand)'") + + switch subCommand { + case "stackList": + guard let payload = rest.first as? [String: Any], + payload["ok"] as? Bool == true, + let stackDict = payload["stackList"] as? [String: Any] else { + print("[DockgeService] agent stackList: bad payload: \(rest.first ?? "nil")") + return + } + var result: [Stack] = [] + var endpointKey = "" + for (stackName, stackValue) in stackDict { + guard let info = stackValue as? [String: Any] else { continue } + let statusRaw = info["status"] as? Int ?? 0 + let ep = info["endpoint"] as? String ?? "" + endpointKey = ep + let stack = Stack( + name: stackName, + status: StackStatus(rawValue: statusRaw) ?? .unknown, + isManagedByDockge: info["isManagedByDockge"] as? Bool ?? true, + composeFileName: info["composeFileName"] as? String ?? "compose.yaml", + endpoint: ep, + primaryHostname: info["primaryHostname"] as? String ?? "", + tags: info["tags"] as? [String] ?? [] + ) + result.append(stack) + } + // Update only this agent's stacks, preserve other agents + stacksByEndpoint[endpointKey] = result + rebuildStacks() + #if DEBUG + print("[DockgeService] agent stackList for endpoint '\(endpointKey)', count: \(result.count), total: \(stacks.count)") + #endif + + default: + print("[DockgeService] Unhandled agent sub-command: '\(subCommand)', data: \(rest)") + } + } + + /// Legacy Dockge format: outer dict keyed by endpoint → inner dict keyed by stackName. + private func handleLegacyStackList(_ data: [Any]) { + guard let outerDict = data.first as? [String: Any] else { return } + for (endpoint, endpointValue) in outerDict { + guard let stacksDict = endpointValue as? [String: Any] else { continue } + var result: [Stack] = [] + for (stackName, stackValue) in stacksDict { + guard let info = stackValue as? [String: Any] else { continue } + let statusRaw = info["status"] as? Int ?? 0 + result.append(Stack( + name: stackName, + status: StackStatus(rawValue: statusRaw) ?? .unknown, + isManagedByDockge: info["isManagedByDockge"] as? Bool ?? true, + composeFileName: info["composeFileName"] as? String ?? "compose.yaml", + endpoint: info["endpoint"] as? String ?? "", + primaryHostname: info["primaryHostname"] as? String ?? "", + tags: info["tags"] as? [String] ?? [] + )) + } + stacksByEndpoint[endpoint] = result + } + rebuildStacks() + print("[DockgeService] legacy stackList, total: \(stacks.count)") + } + + private func rebuildStacks() { + let rebuilt = stacksByEndpoint.values.flatMap { $0 }.sorted { $0.name < $1.name } + guard rebuilt != stacks else { return } + stacks = rebuilt + } + + private func handleTerminalWrite(_ data: [Any]) { + guard let dict = data.first as? [String: Any], + let termName = dict["terminalName"] as? String, + let chunk = dict["data"] as? String else { return } + terminalBuffers[termName, default: ""] += chunk + terminalListeners[termName]?.forEach { $0(chunk) } + } + + private func handleServerInfo(_ data: [Any]) { + guard let dict = data.first as? [String: Any] else { return } + serverInfo = ServerInfo( + version: dict["version"] as? String ?? "", + primaryHostname: dict["primaryHostname"] as? String ?? "" + ) + print("[DockgeService] Server info: v\(serverInfo?.version ?? "")") + } + + // MARK: - Helpers + + private func stackAction(_ event: String, stackName: String, endpoint: String) async -> String? { + let result = await socket.emitWithAck("agent", endpoint, event, stackName) + return parseOkResult(result) + } + + private func parseOkResult(_ result: [Any]) -> String? { + guard let dict = result.first as? [String: Any] else { return "No response from server" } + if dict["ok"] as? Bool == true { return nil } + return localizedError(dict["msg"] as? String ?? "Unknown error") + } + + private func parseDetailedStack(_ dict: [String: Any]) -> Stack { + let statusRaw = dict["status"] as? Int ?? 0 + return Stack( + name: dict["name"] as? String ?? "", + status: StackStatus(rawValue: statusRaw) ?? .unknown, + isManagedByDockge: dict["isManagedByDockge"] as? Bool ?? true, + composeFileName: dict["composeFileName"] as? String ?? "compose.yaml", + endpoint: dict["endpoint"] as? String ?? "", + primaryHostname: dict["primaryHostname"] as? String ?? "", + composeYAML: dict["composeYAML"] as? String, + composeENV: dict["composeENV"] as? String, + tags: dict["tags"] as? [String] ?? [] + ) + } + + private func localizedError(_ key: String) -> String { + switch key { + case "authIncorrectCreds": return "Incorrect username or password." + case "authInvalidToken": return "Session expired. Please log in again." + case "authUserInactiveOrDeleted": return "Account is inactive or deleted." + case "Stack not found": return "Stack not found." + case "Stack name already exists": return "A stack with that name already exists." + default: return key + } + } +} diff --git a/dock-g/dock-g/Services/KeychainService.swift b/dock-g/dock-g/Services/KeychainService.swift new file mode 100644 index 0000000..c4e0735 --- /dev/null +++ b/dock-g/dock-g/Services/KeychainService.swift @@ -0,0 +1,40 @@ +import Foundation +import Security + +enum KeychainService { + static func save(_ value: String, forKey key: String) { + let data = Data(value.utf8) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecValueData as String: data + ] + SecItemDelete(query as CFDictionary) + SecItemAdd(query as CFDictionary, nil) + } + + static func load(forKey key: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let data = result as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + static func delete(forKey key: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key + ] + SecItemDelete(query as CFDictionary) + } + + static func tokenKey(for serverID: UUID) -> String { + "dockge.token.\(serverID.uuidString)" + } +} diff --git a/dock-g/dock-g/Services/SocketIOClient.swift b/dock-g/dock-g/Services/SocketIOClient.swift new file mode 100644 index 0000000..b6bef78 --- /dev/null +++ b/dock-g/dock-g/Services/SocketIOClient.swift @@ -0,0 +1,247 @@ +import Foundation + +// Minimal Socket.IO v4 / Engine.IO v4 client over WebSocket. +// No external dependencies — uses URLSessionWebSocketTask. +@MainActor +final class SocketIOClient: NSObject { + + enum ConnectionState { case disconnected, connecting, connected } + + private(set) var connectionState: ConnectionState = .disconnected + + private var webSocketTask: URLSessionWebSocketTask? + private var urlSession: URLSession! + private var nextAckId = 0 + private var pendingAcks: [Int: ([Any]) -> Void] = [:] + private var eventHandlers: [String: [([Any]) -> Void]] = [:] + private var connectHandlers: [() -> Void] = [] + private var disconnectHandlers: [() -> Void] = [] + private var errorHandlers: [(Error) -> Void] = [] + private var allowSelfSigned: Bool + + init(allowSelfSigned: Bool = false) { + self.allowSelfSigned = allowSelfSigned + super.init() + let delegate = allowSelfSigned ? InsecureDelegate() : nil + urlSession = URLSession( + configuration: .default, + delegate: delegate, + delegateQueue: nil + ) + } + + // MARK: - Public API + + func connect(to baseURL: URL) { + guard connectionState == .disconnected else { return } + var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! + components.scheme = (baseURL.scheme == "https" || baseURL.scheme == "wss") ? "wss" : "ws" + components.path = "/socket.io/" + components.queryItems = [ + URLQueryItem(name: "EIO", value: "4"), + URLQueryItem(name: "transport", value: "websocket") + ] + guard let wsURL = components.url else { return } + connectionState = .connecting + webSocketTask = urlSession.webSocketTask(with: wsURL) + webSocketTask?.resume() + scheduleReceive() + } + + func disconnect() { + connectionState = .disconnected + webSocketTask?.cancel(with: .normalClosure, reason: nil) + webSocketTask = nil + pendingAcks.removeAll() + disconnectHandlers.forEach { $0() } + } + + func on(_ event: String, handler: @escaping ([Any]) -> Void) { + eventHandlers[event, default: []].append(handler) + } + + func onConnect(handler: @escaping () -> Void) { + connectHandlers.append(handler) + } + + func onDisconnect(handler: @escaping () -> Void) { + disconnectHandlers.append(handler) + } + + func onError(handler: @escaping (Error) -> Void) { + errorHandlers.append(handler) + } + + func clearHandlers() { + eventHandlers.removeAll() + connectHandlers.removeAll() + disconnectHandlers.removeAll() + errorHandlers.removeAll() + } + + /// Fire-and-forget emit. + func emit(_ event: String, _ args: Any...) { + sendEvent(event, args: args, ackId: nil) + } + + /// Emit with acknowledgement callback. + func emitWithAck(_ event: String, _ args: Any..., timeout: TimeInterval = 30) async -> [Any] { + await withCheckedContinuation { continuation in + let ackId = nextAckId + nextAckId += 1 + var settled = false + + pendingAcks[ackId] = { data in + guard !settled else { return } + settled = true + continuation.resume(returning: data) + } + sendEvent(event, args: args, ackId: ackId) + + Task { [weak self] in + try? await Task.sleep(for: .seconds(timeout)) + await MainActor.run { + guard let self, !settled else { return } + settled = true + self.pendingAcks.removeValue(forKey: ackId) + continuation.resume(returning: []) + } + } + } + } + + // MARK: - Private send + + private func sendEvent(_ event: String, args: [Any], ackId: Int?) { + var payload: [Any] = [event] + payload.append(contentsOf: args) + guard let jsonData = try? JSONSerialization.data(withJSONObject: payload), + let jsonStr = String(data: jsonData, encoding: .utf8) else { return } + let ackStr = ackId.map { String($0) } ?? "" + sendRaw("42\(ackStr)\(jsonStr)") + } + + private func sendRaw(_ text: String) { + #if DEBUG + print("[SocketIO] TX: \(text.prefix(200))") + #endif + webSocketTask?.send(.string(text)) { error in + if let error { + print("[SocketIO] send error: \(error.localizedDescription)") + } + } + } + + // MARK: - Receive loop + + private func scheduleReceive() { + webSocketTask?.receive { [weak self] result in + guard let self else { return } + switch result { + case .success(let message): + let text: String + switch message { + case .string(let s): text = s + case .data(let d): text = String(data: d, encoding: .utf8) ?? "" + @unknown default: text = "" + } + Task { @MainActor [weak self] in + self?.handleRaw(text) + self?.scheduleReceive() + } + case .failure(let error): + Task { @MainActor [weak self] in + guard let self else { return } + self.connectionState = .disconnected + self.errorHandlers.forEach { $0(error) } + self.disconnectHandlers.forEach { $0() } + } + } + } + } + + // MARK: - Protocol handling + + private func handleRaw(_ text: String) { + guard let first = text.first else { return } + let body = String(text.dropFirst()) + #if DEBUG + print("[SocketIO] RX: \(text.prefix(200))") + #endif + switch first { + case "0": // EIO OPEN + sendRaw("40") // SIO CONNECT to default namespace + case "2": // EIO PING → respond PONG + sendRaw("3") + case "4": // EIO MESSAGE → Socket.IO packet + handleSIOPacket(body) + case "1": // EIO CLOSE + disconnect() + default: + break + } + } + + private func handleSIOPacket(_ text: String) { + guard let first = text.first else { return } + let body = String(text.dropFirst()) + switch first { + case "0": // SIO CONNECT + connectionState = .connected + connectHandlers.forEach { $0() } + case "2": // SIO EVENT + dispatchEvent(body) + case "3": // SIO ACK + dispatchAck(body) + case "4": // SIO CONNECT_ERROR + print("[SocketIO] connect error: \(body)") + default: + break + } + } + + private func dispatchEvent(_ text: String) { + // Optional ack ID before the '[' bracket + let (jsonStr, _) = splitAckPrefix(text) + guard let data = jsonStr.data(using: .utf8), + let array = try? JSONSerialization.jsonObject(with: data) as? [Any], + let name = array.first as? String else { return } + let args = Array(array.dropFirst()) + eventHandlers[name]?.forEach { $0(args) } + } + + private func dispatchAck(_ text: String) { + let (jsonStr, ackId) = splitAckPrefix(text) + guard let id = ackId, + let data = jsonStr.data(using: .utf8), + let array = try? JSONSerialization.jsonObject(with: data) as? [Any], + let handler = pendingAcks[id] else { return } + pendingAcks.removeValue(forKey: id) + handler(array) + } + + /// Splits "N[…]" into ("[…]", N), or text that starts with "[" into (text, nil). + private func splitAckPrefix(_ text: String) -> (String, Int?) { + guard let bracketIdx = text.firstIndex(of: "[") else { return (text, nil) } + let prefix = String(text[text.startIndex.. Void + ) { + if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, + let trust = challenge.protectionSpace.serverTrust { + completionHandler(.useCredential, URLCredential(trust: trust)) + } else { + completionHandler(.performDefaultHandling, nil) + } + } +} diff --git a/dock-g/dock-g/Utilities/ANSIParser.swift b/dock-g/dock-g/Utilities/ANSIParser.swift new file mode 100644 index 0000000..9312f98 --- /dev/null +++ b/dock-g/dock-g/Utilities/ANSIParser.swift @@ -0,0 +1,120 @@ +import Foundation +import SwiftUI + +/// Parses ANSI/VT100 escape sequences and returns a styled AttributedString. +/// SGR color/bold/dim codes are applied as SwiftUI attributes. +/// All other escape sequences (cursor movement, erase, etc.) are silently stripped. +enum ANSIParser { + + // Matches any ANSI/VT100 escape sequence + private static let escapeRegex = try! NSRegularExpression( + pattern: #"\x1B(?:[@-Z\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*\x07)"# + ) + // Checks if a matched sequence is an SGR sequence (ends with 'm') + private static let sgrRegex = try! NSRegularExpression( + pattern: #"^\x1B\[([0-9;]*)m$"# + ) + + // Current SGR render state + private struct Style { + var foreground: Color? = nil + var bold: Bool = false + var dim: Bool = false + + mutating func reset() { self = Style() } + + mutating func apply(_ code: Int) { + switch code { + case 0: reset() + case 1: bold = true + case 2: dim = true + case 22: bold = false; dim = false + case 39: foreground = nil + // Standard foreground colors (dark-theme optimized) + case 30: foreground = Color(white: 0.35) + case 31: foreground = Color(hex: "#f87171") // red + case 32: foreground = Color(hex: "#4ade80") // green + case 33: foreground = Color(hex: "#facc15") // yellow + case 34: foreground = Color(hex: "#60a5fa") // blue + case 35: foreground = Color(hex: "#e879f9") // magenta + case 36: foreground = Color(hex: "#22d3ee") // cyan + case 37: foreground = Color(white: 0.88) // white + // Bright foreground colors + case 90: foreground = Color(white: 0.50) + case 91: foreground = Color(hex: "#fca5a5") + case 92: foreground = Color(hex: "#86efac") + case 93: foreground = Color(hex: "#fde68a") + case 94: foreground = Color(hex: "#93c5fd") + case 95: foreground = Color(hex: "#f5d0fe") + case 96: foreground = Color(hex: "#a5f3fc") + case 97: foreground = Color(white: 0.97) + default: break // background colors and other codes ignored + } + } + } + + // MARK: - Public API + + static func attributedString(from input: String) -> AttributedString { + // Normalise line endings: \r\n → \n, stray \r → \n + let text = input + .replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") + + var result = AttributedString() + var style = Style() + + let nsText = text as NSString + let fullRange = NSRange(location: 0, length: nsText.length) + let matches = escapeRegex.matches(in: text, range: fullRange) + + var lastIdx = text.startIndex + + for match in matches { + guard let matchRange = Range(match.range, in: text) else { continue } + + // Append plain text before this escape sequence + if lastIdx < matchRange.lowerBound { + result += segment(String(text[lastIdx.. AttributedString { + var s = AttributedString(text) + s.font = .system( + size: 12, + weight: style.bold ? .semibold : .regular, + design: .monospaced + ) + let base: Color = style.foreground ?? Color(hex: "#cbd5e1") // default: slate-300 + s.foregroundColor = style.dim ? base.opacity(0.5) : base + return s + } +} diff --git a/dock-g/dock-g/Utilities/ANSIStripper.swift b/dock-g/dock-g/Utilities/ANSIStripper.swift new file mode 100644 index 0000000..9356cfe --- /dev/null +++ b/dock-g/dock-g/Utilities/ANSIStripper.swift @@ -0,0 +1,12 @@ +import Foundation + +enum ANSIStripper { + // Strips common ANSI/VT100 escape codes from terminal output. + private static let pattern = #"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*\x07)"# + private static let regex = try! NSRegularExpression(pattern: pattern) + + static func strip(_ input: String) -> String { + let range = NSRange(input.startIndex..., in: input) + return regex.stringByReplacingMatches(in: input, range: range, withTemplate: "") + } +} diff --git a/dock-g/dock-g/Utilities/Theme.swift b/dock-g/dock-g/Utilities/Theme.swift new file mode 100644 index 0000000..e313eda --- /dev/null +++ b/dock-g/dock-g/Utilities/Theme.swift @@ -0,0 +1,80 @@ +import SwiftUI +import UIKit + +// MARK: - Adaptive UIColor palette + +extension UIColor { + static let appBackground = UIColor { + $0.userInterfaceStyle == .dark ? UIColor(hex: "#0f0f0f") : UIColor(hex: "#f2f2f7") + } + static let appSurface = UIColor { + $0.userInterfaceStyle == .dark ? UIColor(hex: "#1e1e1e") : UIColor(hex: "#ffffff") + } + static let appSurface2 = UIColor { + $0.userInterfaceStyle == .dark ? UIColor(hex: "#252525") : UIColor(hex: "#f0f0f5") + } + static let appDarkGray = UIColor { + $0.userInterfaceStyle == .dark ? UIColor(hex: "#374151") : UIColor(hex: "#d1d5db") + } + static let appGray = UIColor { + $0.userInterfaceStyle == .dark ? UIColor(hex: "#6b7280") : UIColor(hex: "#4b5563") + } + + convenience init(hex: String) { + let h = hex.trimmingCharacters(in: .init(charactersIn: "#")) + let v = UInt64(h, radix: 16) ?? 0 + self.init( + red: CGFloat((v >> 16) & 0xff) / 255, + green: CGFloat((v >> 8) & 0xff) / 255, + blue: CGFloat( v & 0xff) / 255, + alpha: 1 + ) + } +} + +// MARK: - Color palette (concrete Color values for explicit use) + +extension Color { + static let appBackground = Color(uiColor: .appBackground) + static let appSurface = Color(uiColor: .appSurface) + static let appSurface2 = Color(uiColor: .appSurface2) + static let appAccent = Color(hex: "#3b82f6") + static let appGreen = Color(hex: "#22c55e") + static let appRed = Color(hex: "#ef4444") + static let appGray = Color(uiColor: .appGray) + static let appDarkGray = Color(uiColor: .appDarkGray) + static let terminalBg = Color(hex: "#0d0d0d") + static let terminalText = Color(hex: "#d4d4d4") + + /// Initialise from a CSS hex string like "#3b82f6". + init(hex: String) { + let h = hex.trimmingCharacters(in: .init(charactersIn: "#")) + let value = UInt64(h, radix: 16) ?? 0 + let r = Double((value >> 16) & 0xff) / 255 + let g = Double((value >> 8) & 0xff) / 255 + let b = Double( value & 0xff) / 255 + self.init(red: r, green: g, blue: b) + } +} + +// MARK: - ShapeStyle extensions so dot-syntax works in foregroundStyle() + +extension ShapeStyle where Self == Color { + static var appBackground: Color { .appBackground } + static var appSurface: Color { .appSurface } + static var appAccent: Color { .appAccent } + static var appGreen: Color { .appGreen } + static var appRed: Color { .appRed } + static var appGray: Color { .appGray } + static var appDarkGray: Color { .appDarkGray } + static var terminalBg: Color { .terminalBg } + static var terminalText: Color { .terminalText } +} + +// MARK: - Font helpers + +extension Font { + static let monoSmall = Font.system(size: 12, design: .monospaced) + static let monoBody = Font.system(size: 14, design: .monospaced) + static let monoBold = Font.system(size: 14, weight: .bold, design: .monospaced) +} diff --git a/dock-g/dock-g/ViewModels/AppState.swift b/dock-g/dock-g/ViewModels/AppState.swift new file mode 100644 index 0000000..c47b0f7 --- /dev/null +++ b/dock-g/dock-g/ViewModels/AppState.swift @@ -0,0 +1,158 @@ +import SwiftUI + +enum AppearanceMode: String, CaseIterable { + case system, light, dark + + var label: String { + switch self { + case .system: return "System" + case .light: return "Light" + case .dark: return "Dark" + } + } + + var colorScheme: ColorScheme? { + switch self { + case .system: return nil + case .light: return .light + case .dark: return .dark + } + } +} + +// Central application state — inject via .environment(appState) +@Observable +final class AppState { + + // MARK: - Servers + var servers: [DockgeServer] = [] { + didSet { persistServers() } + } + + // MARK: - Active connection + var activeServer: DockgeServer? + var dockgeService: DockgeService? + + /// Stacks are owned by DockgeService; this computed property bridges to SwiftUI observation. + var stacks: [Stack] { dockgeService?.stacks ?? [] } + + // MARK: - Navigation + var selectedTab: Tab = .stacks + + enum Tab { case stacks, servers, settings } + + // MARK: - Init + + init() { + loadServers() + loadAppearance() + loadLogRefreshInterval() + } + + // MARK: - Server management + + func addServer(_ server: DockgeServer) { + servers.append(server) + } + + func removeServer(at offsets: IndexSet) { + for index in offsets { + let server = servers[index] + KeychainService.delete(forKey: KeychainService.tokenKey(for: server.id)) + if activeServer?.id == server.id { disconnectFromServer() } + } + servers.remove(atOffsets: offsets) + } + + func updateServer(_ server: DockgeServer) { + if let index = servers.firstIndex(where: { $0.id == server.id }) { + servers[index] = server + } + } + + // MARK: - Connection + + func connectToServer(_ server: DockgeServer) { + if dockgeService != nil { disconnectFromServer() } + + activeServer = server + let service = DockgeService(server: server) + dockgeService = service + service.connect() + } + + func disconnectFromServer() { + dockgeService?.disconnect() + dockgeService = nil + activeServer = nil + } + + // MARK: - Persistence (servers stored in UserDefaults as JSON; tokens in Keychain) + + // MARK: - Log refresh interval + + private let logRefreshKey = "dockge.logRefreshInterval" + + var logRefreshInterval: Int = 10 { + didSet { UserDefaults.standard.set(logRefreshInterval, forKey: logRefreshKey) } + } + + private func loadLogRefreshInterval() { + let stored = UserDefaults.standard.integer(forKey: logRefreshKey) + if [10, 30, 60].contains(stored) { logRefreshInterval = stored } + } + + // MARK: - Appearance + + private let appearanceKey = "dockge.appearanceMode" + + var appearanceMode: AppearanceMode = .dark { + didSet { UserDefaults.standard.set(appearanceMode.rawValue, forKey: appearanceKey) } + } + + private func loadAppearance() { + guard let raw = UserDefaults.standard.string(forKey: appearanceKey), + let mode = AppearanceMode(rawValue: raw) else { return } + appearanceMode = mode + } + + // MARK: - Auto-connect + + private let autoConnectKey = "dockge.autoConnectServerID" + + var autoConnectServerID: UUID? { + didSet { + if let id = autoConnectServerID { + UserDefaults.standard.set(id.uuidString, forKey: autoConnectKey) + } else { + UserDefaults.standard.removeObject(forKey: autoConnectKey) + } + } + } + + func setAutoConnect(for server: DockgeServer?) { + autoConnectServerID = server?.id + } + + // MARK: - Persistence (servers stored in UserDefaults as JSON; tokens in Keychain) + + private let serversKey = "dockge.servers" + + private func persistServers() { + guard let data = try? JSONEncoder().encode(servers) else { return } + UserDefaults.standard.set(data, forKey: serversKey) + } + + private func loadServers() { + if let str = UserDefaults.standard.string(forKey: autoConnectKey), + let id = UUID(uuidString: str) { + autoConnectServerID = id + } + guard let data = UserDefaults.standard.data(forKey: serversKey), + let decoded = try? JSONDecoder().decode([DockgeServer].self, from: data) else { return } + servers = decoded + if let id = autoConnectServerID, let server = servers.first(where: { $0.id == id }) { + connectToServer(server) + } + } +} diff --git a/dock-g/dock-g/Views/Servers/AddServerView.swift b/dock-g/dock-g/Views/Servers/AddServerView.swift new file mode 100644 index 0000000..72d7433 --- /dev/null +++ b/dock-g/dock-g/Views/Servers/AddServerView.swift @@ -0,0 +1,81 @@ +import SwiftUI + +struct AddServerView: View { + @Environment(\.dismiss) private var dismiss + @Environment(AppState.self) private var appState + + @State private var name = "" + @State private var host = "" + @State private var useSSL = false + @State private var editingServer: DockgeServer? + + // Pass an existing server to edit + init(server: DockgeServer? = nil) { + if let server { + _name = State(initialValue: server.name) + _host = State(initialValue: server.host) + _useSSL = State(initialValue: server.useSSL) + _editingServer = State(initialValue: server) + } + } + + private var isValid: Bool { + !name.trimmingCharacters(in: .whitespaces).isEmpty && + !host.trimmingCharacters(in: .whitespaces).isEmpty + } + + var body: some View { + NavigationStack { + Form { + Section("Server") { + TextField("Friendly name (e.g. Home Server)", text: $name) + .autocorrectionDisabled() + TextField("Host (e.g. 192.168.1.10:5001)", text: $host) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .keyboardType(.URL) + } + .listRowBackground(Color.appSurface) + + Section { + Toggle("Use HTTPS / WSS", isOn: $useSSL) + } footer: { + Text("Enable for TLS-secured Dockge instances. Self-signed certificates are accepted.") + } + .listRowBackground(Color.appSurface) + } + .scrollContentBackground(.hidden) + .background(Color.appBackground) + .navigationTitle(editingServer == nil ? "Add Server" : "Edit Server") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { save() } + .disabled(!isValid) + } + } + } + + } + + private func save() { + let trimmedHost = host.trimmingCharacters(in: .whitespaces) + if var existing = editingServer { + existing.name = name.trimmingCharacters(in: .whitespaces) + existing.host = trimmedHost + existing.useSSL = useSSL + appState.updateServer(existing) + } else { + let server = DockgeServer( + name: name.trimmingCharacters(in: .whitespaces), + host: trimmedHost, + useSSL: useSSL + ) + appState.addServer(server) + } + dismiss() + } +} diff --git a/dock-g/dock-g/Views/Servers/LoginView.swift b/dock-g/dock-g/Views/Servers/LoginView.swift new file mode 100644 index 0000000..b348a67 --- /dev/null +++ b/dock-g/dock-g/Views/Servers/LoginView.swift @@ -0,0 +1,150 @@ +import SwiftUI + +struct LoginView: View { + let service: DockgeService + let server: DockgeServer + + @Environment(\.dismiss) private var dismiss + @State private var username = "" + @State private var password = "" + @State private var twoFAToken = "" + @State private var showTwoFA = false + @State private var showSetup = false + @State private var isLoading = false + @State private var errorMessage: String? + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 24) { + // Logo / header + VStack(spacing: 8) { + Image(systemName: "server.rack") + .font(.system(size: 48)) + .foregroundStyle(.appAccent) + Text(server.name) + .font(.title2.bold()) + Text(server.displayHost) + .font(.subheadline) + .foregroundStyle(.appGray) + } + .padding(.top, 32) + + if let error = errorMessage { + Text(error) + .font(.system(size: 14)) + .foregroundStyle(.primary) + .padding(12) + .frame(maxWidth: .infinity) + .background(Color.appRed.opacity(0.8), in: RoundedRectangle(cornerRadius: 8)) + } + + VStack(spacing: 12) { + if showSetup { + Text("First-time Setup") + .font(.headline) + .foregroundStyle(.appGray) + } + + TextField("Username", text: $username) + .textFieldStyle(.plain) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .padding() + .background(Color.appSurface, in: RoundedRectangle(cornerRadius: 10)) + + SecureField("Password", text: $password) + .textFieldStyle(.plain) + .padding() + .background(Color.appSurface, in: RoundedRectangle(cornerRadius: 10)) + + if showTwoFA { + TextField("2FA Code", text: $twoFAToken) + .textFieldStyle(.plain) + .keyboardType(.numberPad) + .padding() + .background(Color.appSurface, in: RoundedRectangle(cornerRadius: 10)) + } + } + + Button { + Task { await submit() } + } label: { + Group { + if isLoading { + ProgressView() + .tint(.white) + } else { + Text(showSetup ? "Create Account" : "Login") + .font(.system(size: 16, weight: .semibold)) + } + } + .frame(maxWidth: .infinity) + .padding() + .background(Color.appAccent, in: RoundedRectangle(cornerRadius: 10)) + .foregroundStyle(.primary) + } + .disabled(isLoading || username.isEmpty || password.isEmpty) + + if showSetup { + Button("Back to Login") { + showSetup = false + showTwoFA = false + errorMessage = nil + } + .foregroundStyle(.appAccent) + } else { + Button("First-time Setup") { + showSetup = true + showTwoFA = false + errorMessage = nil + } + .foregroundStyle(.appGray) + .font(.system(size: 13)) + } + } + .padding(.horizontal, 24) + .padding(.bottom, 32) + } + .background(Color.appBackground) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + } + + .onChange(of: service.authState) { _, newState in + if case .authenticated = newState { dismiss() } + if case .needsSetup = newState { showSetup = true } + } + } + + private func submit() async { + isLoading = true + errorMessage = nil + + let error: String? + if showSetup { + error = await service.setup(username: username, password: password) + } else { + error = await service.login( + username: username, + password: password, + twoFAToken: showTwoFA && !twoFAToken.isEmpty ? twoFAToken : nil + ) + } + isLoading = false + + if let msg = error { + if msg == "2fa" { + showTwoFA = true + errorMessage = "Enter your two-factor authentication code." + } else { + errorMessage = msg + } + } + // On success, onChange(of: service.authState) will dismiss + } +} diff --git a/dock-g/dock-g/Views/Servers/ServersView.swift b/dock-g/dock-g/Views/Servers/ServersView.swift new file mode 100644 index 0000000..66f26ee --- /dev/null +++ b/dock-g/dock-g/Views/Servers/ServersView.swift @@ -0,0 +1,166 @@ +import SwiftUI + +struct ServersView: View { + @Environment(AppState.self) private var appState + @State private var showAddServer = false + @State private var serverToEdit: DockgeServer? + + var body: some View { + NavigationStack { + Group { + if appState.servers.isEmpty { + ContentUnavailableView { + Label("No Servers", systemImage: "server.rack") + } description: { + Text("Tap + to add a Dockge server.") + } actions: { + Button("Add Server") { showAddServer = true } + .buttonStyle(.borderedProminent) + .tint(.appAccent) + } + } else { + List { + ForEach(appState.servers) { server in + serverRow(server) + .listRowBackground(Color.appSurface) + } + .onDelete(perform: appState.removeServer) + } + .listStyle(.insetGrouped) + .scrollContentBackground(.hidden) + } + } + .background(Color.appBackground) + .navigationTitle("Servers") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { showAddServer = true } label: { + Image(systemName: "plus") + } + } + } + .sheet(isPresented: $showAddServer) { + AddServerView() + } + .sheet(item: $serverToEdit) { server in + AddServerView(server: server) + } + } + + } + + @ViewBuilder + private func serverRow(_ server: DockgeServer) -> some View { + HStack(spacing: 12) { + // Connection indicator + Circle() + .fill(appState.activeServer?.id == server.id ? Color.appGreen : Color.appGray) + .frame(width: 8, height: 8) + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(server.name) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.primary) + if appState.autoConnectServerID == server.id { + Text("Auto") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(Color.appAccent) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(Color.appAccent.opacity(0.2), in: Capsule()) + .overlay(Capsule().strokeBorder(Color.appAccent.opacity(0.4), lineWidth: 0.5)) + } + } + HStack(spacing: 4) { + Text(server.displayHost) + .font(.system(size: 12)) + .foregroundStyle(.appGray) + if server.useSSL { + Image(systemName: "lock.fill") + .font(.system(size: 10)) + .foregroundStyle(.appAccent) + } + } + if let active = appState.activeServer, active.id == server.id, + let info = appState.dockgeService?.serverInfo { + Text("v\(info.version)") + .font(.system(size: 11)) + .foregroundStyle(.appGray) + } + } + + Spacer() + + // Connect / Disconnect button + if appState.activeServer?.id == server.id { + Button { + appState.disconnectFromServer() + } label: { + Text("Disconnect") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.appRed) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(Color.appRed.opacity(0.15), in: Capsule()) + } + .buttonStyle(.plain) + } else { + Button { + appState.connectToServer(server) + } label: { + Text("Connect") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.appAccent) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(Color.appAccent.opacity(0.15), in: Capsule()) + } + .buttonStyle(.plain) + } + } + .padding(.vertical, 4) + .swipeActions(edge: .trailing) { + Button(role: .destructive) { + if let index = appState.servers.firstIndex(where: { $0.id == server.id }) { + appState.removeServer(at: IndexSet([index])) + } + } label: { + Label("Delete", systemImage: "trash") + } + + Button { + serverToEdit = server + } label: { + Label("Edit", systemImage: "pencil") + } + .tint(.appAccent) + + if appState.activeServer?.id == server.id { + Button { + appState.dockgeService?.logout() + } label: { + Label("Logout", systemImage: "rectangle.portrait.and.arrow.right") + } + .tint(.appRed) + } + } + .swipeActions(edge: .leading) { + if appState.autoConnectServerID == server.id { + Button { + appState.setAutoConnect(for: nil) + } label: { + Label("Remove Auto", systemImage: "bolt.slash") + } + .tint(.appGray) + } else { + Button { + appState.setAutoConnect(for: server) + } label: { + Label("Auto Connect", systemImage: "bolt") + } + .tint(.appAccent) + } + } + } +} diff --git a/dock-g/dock-g/Views/Settings/SettingsView.swift b/dock-g/dock-g/Views/Settings/SettingsView.swift new file mode 100644 index 0000000..97533b7 --- /dev/null +++ b/dock-g/dock-g/Views/Settings/SettingsView.swift @@ -0,0 +1,46 @@ +import SwiftUI + +struct SettingsView: View { + @Environment(AppState.self) private var appState + + var body: some View { + NavigationStack { + Form { + Section("Appearance") { + @Bindable var state = appState + Picker("Theme", selection: $state.appearanceMode) { + ForEach(AppearanceMode.allCases, id: \.self) { mode in + Text(mode.label).tag(mode) + } + } + .pickerStyle(.segmented) + } + .listRowBackground(Color.appSurface) + + Section("Logs") { + @Bindable var state = appState + Picker("Refresh Rate", selection: $state.logRefreshInterval) { + Text("10 s").tag(10) + Text("30 s").tag(30) + Text("60 s").tag(60) + } + .pickerStyle(.segmented) + } + .listRowBackground(Color.appSurface) + + Section("About") { + LabeledContent("App", value: "dock-g") + if let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String { + LabeledContent("Version", value: version) + } + LabeledContent("Platform", value: "Dockge Mobile Client") + } + .listRowBackground(Color.appSurface) + } + .scrollContentBackground(.hidden) + .background(Color.appBackground) + .navigationTitle("Settings") + } + + } +} diff --git a/dock-g/dock-g/Views/Stacks/ActionTerminalSheet.swift b/dock-g/dock-g/Views/Stacks/ActionTerminalSheet.swift new file mode 100644 index 0000000..aaacde4 --- /dev/null +++ b/dock-g/dock-g/Views/Stacks/ActionTerminalSheet.swift @@ -0,0 +1,75 @@ +import SwiftUI + +// Shows terminal output after a stack action (start/stop/restart/update/down/deploy etc.) +struct ActionTerminalSheet: View { + let title: String + let terminalName: String + let stackName: String + let endpoint: String + let service: DockgeService + + @Environment(\.dismiss) private var dismiss + @State private var displayAttr = AttributedString() + @State private var isLoading = true + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + ScrollViewReader { proxy in + ScrollView { + if isLoading { + ProgressView() + .tint(.appAccent) + .frame(maxWidth: .infinity, alignment: .center) + .padding(24) + } else if displayAttr == AttributedString() { + Text("No output.") + .font(.monoSmall) + .foregroundStyle(Color.appGray) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + } else { + Text(displayAttr) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + } + Color.clear.frame(height: 1).id("end") + } + .background(Color.terminalBg) + .onChange(of: displayAttr) { _, _ in + withAnimation(.none) { proxy.scrollTo("end") } + } + } + + Button("Done") { dismiss() } + .buttonStyle(.borderedProminent) + .tint(.appAccent) + .padding() + } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close") { dismiss() } + } + } + } + .presentationDetents([.medium, .large]) + .onAppear { + // Replay local buffer immediately (synchronous if buffer exists) + service.onTerminalWrite(name: terminalName) { buf in + displayAttr = ANSIParser.attributedString(from: buf) + } + // Show whatever we have right away — don't block on a slow/no-op server call + isLoading = false + // Background fallback: ask the server for its buffer in case local events + // were missed (fire-and-forget, updates displayAttr via the listener above) + Task { + await service.joinActionTerminal(stackName: stackName, endpoint: endpoint) + } + } + .onDisappear { + service.removeTerminalListeners(name: terminalName) + } + } +} diff --git a/dock-g/dock-g/Views/Stacks/ComposeTabView.swift b/dock-g/dock-g/Views/Stacks/ComposeTabView.swift new file mode 100644 index 0000000..9ec73f8 --- /dev/null +++ b/dock-g/dock-g/Views/Stacks/ComposeTabView.swift @@ -0,0 +1,28 @@ +import SwiftUI + +struct ComposeTabView: View { + @Binding var yaml: String + var isEditing: Bool + var onSave: () async -> Void + + var body: some View { + ScrollView { + if isEditing { + TextEditor(text: $yaml) + .font(.monoBody) + .foregroundStyle(Color.terminalText) + .scrollContentBackground(.hidden) + .frame(minHeight: 400) + .padding(8) + } else { + Text(yaml.isEmpty ? " " : yaml) + .font(.monoBody) + .foregroundStyle(Color.terminalText) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + } + } + .background(Color.terminalBg) + } +} diff --git a/dock-g/dock-g/Views/Stacks/CreateStackView.swift b/dock-g/dock-g/Views/Stacks/CreateStackView.swift new file mode 100644 index 0000000..25a2af8 --- /dev/null +++ b/dock-g/dock-g/Views/Stacks/CreateStackView.swift @@ -0,0 +1,289 @@ +import SwiftUI + +struct CreateStackView: View { + let service: DockgeService + + @Environment(\.dismiss) private var dismiss + + @State private var stackName = "" + @State private var selectedEndpoint = "" + @State private var composeYAML = Self.defaultYAML + @State private var envContent = "" + + // Converter + @State private var dockerRunCommand = "" + @State private var isConverting = false + @State private var converterExpanded = false + + // Actions + @State private var isWorking = false + @State private var errorMessage: String? + @State private var showTerminal = false + @State private var deployTerminalName = "" + + private static let defaultYAML = """ + services: + app: + image: + restart: unless-stopped + """ + + private var nameIsValid: Bool { + let trimmed = stackName.trimmingCharacters(in: .whitespaces) + return !trimmed.isEmpty && trimmed.range(of: #"^[a-z0-9_-]+$"#, options: .regularExpression) != nil + } + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 16) { + stackInfoSection + converterSection + composeSection + envSection + if let error = errorMessage { + Text(error) + .font(.system(size: 13)) + .foregroundStyle(.primary) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.appRed.opacity(0.8), in: RoundedRectangle(cornerRadius: 8)) + } + } + .padding() + } + .background(Color.appBackground) + .navigationTitle("New Stack") + .navigationBarTitleDisplayMode(.inline) + .toolbar { toolbarItems } + } + + .sheet(isPresented: $showTerminal) { + ActionTerminalSheet( + title: "Deploying \(stackName)", + terminalName: deployTerminalName, + stackName: stackName, + endpoint: selectedEndpoint, + service: service + ) + } + } + + // MARK: - Sections + + private var stackInfoSection: some View { + VStack(alignment: .leading, spacing: 12) { + sectionHeader("Stack") + VStack(spacing: 10) { + VStack(alignment: .leading, spacing: 4) { + TextField("stack-name", text: $stackName) + .font(.system(size: 15, design: .monospaced)) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .padding(10) + .background(Color.appSurface, in: RoundedRectangle(cornerRadius: 8)) + if !stackName.isEmpty && !nameIsValid { + Text("Only lowercase letters, numbers, - and _ allowed") + .font(.system(size: 11)) + .foregroundStyle(Color.appRed) + .padding(.leading, 4) + } + } + + if service.availableEndpoints.count > 1 { + Picker("Agent", selection: $selectedEndpoint) { + ForEach(service.availableEndpoints, id: \.self) { ep in + Text(ep.isEmpty ? "Local" : ep).tag(ep) + } + } + .pickerStyle(.menu) + .padding(10) + .background(Color.appSurface, in: RoundedRectangle(cornerRadius: 8)) + } + } + } + } + + private var converterSection: some View { + VStack(alignment: .leading, spacing: 12) { + Button { + withAnimation(.easeInOut(duration: 0.2)) { converterExpanded.toggle() } + } label: { + HStack { + sectionHeader("Convert from docker run") + Spacer() + Image(systemName: converterExpanded ? "chevron.up" : "chevron.down") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Color.appGray) + } + } + .buttonStyle(.plain) + + if converterExpanded { + VStack(spacing: 8) { + TextEditor(text: $dockerRunCommand) + .font(.monoSmall) + .foregroundStyle(Color.terminalText) + .scrollContentBackground(.hidden) + .frame(minHeight: 80) + .padding(8) + .background(Color.terminalBg, in: RoundedRectangle(cornerRadius: 8)) + .overlay( + Group { + if dockerRunCommand.isEmpty { + Text("docker run -p 8080:80 nginx") + .font(.monoSmall) + .foregroundStyle(Color.appGray.opacity(0.6)) + .allowsHitTesting(false) + .padding(12) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + } + ) + + Button { + Task { await runConverter() } + } label: { + HStack(spacing: 6) { + if isConverting { + ProgressView().scaleEffect(0.7) + } else { + Image(systemName: "arrow.triangle.2.circlepath") + } + Text(isConverting ? "Converting…" : "Convert") + } + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(.primary) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Color.appAccent, in: RoundedRectangle(cornerRadius: 8)) + } + .disabled(dockerRunCommand.trimmingCharacters(in: .whitespaces).isEmpty || isConverting) + .frame(maxWidth: .infinity, alignment: .trailing) + } + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + } + + private var composeSection: some View { + VStack(alignment: .leading, spacing: 12) { + sectionHeader("compose.yaml") + TextEditor(text: $composeYAML) + .font(.monoBody) + .foregroundStyle(Color.terminalText) + .scrollContentBackground(.hidden) + .frame(minHeight: 200) + .padding(8) + .background(Color.terminalBg, in: RoundedRectangle(cornerRadius: 8)) + } + } + + private var envSection: some View { + VStack(alignment: .leading, spacing: 12) { + sectionHeader(".env (optional)") + TextEditor(text: $envContent) + .font(.monoBody) + .foregroundStyle(Color.terminalText) + .scrollContentBackground(.hidden) + .frame(minHeight: 80) + .padding(8) + .background(Color.terminalBg, in: RoundedRectangle(cornerRadius: 8)) + } + } + + private func sectionHeader(_ title: String) -> some View { + Text(title) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Color.appGray) + .textCase(.uppercase) + } + + // MARK: - Toolbar + + @ToolbarContentBuilder + private var toolbarItems: some ToolbarContent { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .primaryAction) { + Menu { + Button { + Task { await deploy() } + } label: { + Label("Save & Deploy", systemImage: "bolt.fill") + } + Button { + Task { await save() } + } label: { + Label("Save Draft", systemImage: "square.and.arrow.down") + } + } label: { + if isWorking { + ProgressView().scaleEffect(0.8) + } else { + Text("Create").fontWeight(.semibold) + } + } + .disabled(!nameIsValid || isWorking) + } + } + + // MARK: - Actions + + private func runConverter() async { + let cmd = dockerRunCommand.trimmingCharacters(in: .whitespaces) + guard !cmd.isEmpty else { return } + isConverting = true + errorMessage = nil + if let result = await service.composerize(cmd) { + composeYAML = result + withAnimation { converterExpanded = false } + } else { + errorMessage = "Conversion failed. Check your docker run command." + } + isConverting = false + } + + private func save() async { + guard nameIsValid else { return } + isWorking = true + errorMessage = nil + let error = await service.saveStack( + name: stackName.trimmingCharacters(in: .whitespaces), + yaml: composeYAML, + env: envContent, + isAdd: true, + endpoint: selectedEndpoint + ) + isWorking = false + if let msg = error { + errorMessage = msg + } else { + dismiss() + } + } + + private func deploy() async { + guard nameIsValid else { return } + isWorking = true + errorMessage = nil + let name = stackName.trimmingCharacters(in: .whitespaces) + deployTerminalName = service.terminalName(for: name, endpoint: selectedEndpoint) + service.clearTerminalBuffer(name: deployTerminalName) + let error = await service.deployStack( + name: name, + yaml: composeYAML, + env: envContent, + isAdd: true, + endpoint: selectedEndpoint + ) + isWorking = false + if let msg = error { + errorMessage = msg + } else { + showTerminal = true + dismiss() + } + } +} diff --git a/dock-g/dock-g/Views/Stacks/EnvTabView.swift b/dock-g/dock-g/Views/Stacks/EnvTabView.swift new file mode 100644 index 0000000..93f2cc7 --- /dev/null +++ b/dock-g/dock-g/Views/Stacks/EnvTabView.swift @@ -0,0 +1,27 @@ +import SwiftUI + +struct EnvTabView: View { + @Binding var env: String + var isEditing: Bool + + var body: some View { + ScrollView { + if isEditing { + TextEditor(text: $env) + .font(.monoBody) + .foregroundStyle(Color.terminalText) + .scrollContentBackground(.hidden) + .frame(minHeight: 400) + .padding(8) + } else { + Text(env.isEmpty ? " " : env) + .font(.monoBody) + .foregroundStyle(Color.terminalText) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + } + } + .background(Color.terminalBg) + } +} diff --git a/dock-g/dock-g/Views/Stacks/LogsTabView.swift b/dock-g/dock-g/Views/Stacks/LogsTabView.swift new file mode 100644 index 0000000..5a28936 --- /dev/null +++ b/dock-g/dock-g/Views/Stacks/LogsTabView.swift @@ -0,0 +1,102 @@ +import SwiftUI + +struct LogsTabView: View { + let stackName: String + let endpoint: String + let service: DockgeService + + @Environment(AppState.self) private var appState + + @State private var displayAttr = AttributedString() + @State private var hasContent = false + @State private var autoScroll = true + @State private var refreshTask: Task? + @State private var lastUpdated: Date? + + private static let timeFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "HH:mm:ss" + return f + }() + + private var terminalName: String { + service.combinedTerminalName(for: stackName, endpoint: endpoint) + } + + var body: some View { + VStack(spacing: 0) { + HStack { + if let date = lastUpdated { + Text("Updated \(Self.timeFormatter.string(from: date))") + .font(.system(size: 11)) + .foregroundStyle(Color.appGray) + } + Spacer() + Toggle("Auto-scroll", isOn: $autoScroll) + .toggleStyle(.switch) + .tint(.appAccent) + .font(.system(size: 12)) + .foregroundStyle(Color.appGray) + + Button { + displayAttr = AttributedString() + hasContent = false + service.clearTerminalBuffer(name: terminalName) + } label: { + Image(systemName: "trash") + .font(.system(size: 12)) + .foregroundStyle(Color.appGray) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.appSurface) + + ScrollViewReader { proxy in + ScrollView { + if hasContent { + Text(displayAttr) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + } else { + Text("Waiting for logs\u{2026}") + .font(.monoSmall) + .foregroundStyle(Color.appGray) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + } + Color.clear.frame(height: 1).id("logEnd") + } + .background(Color.terminalBg) + .onChange(of: displayAttr) { _, _ in + if autoScroll { + withAnimation(.none) { + proxy.scrollTo("logEnd") + } + } + } + } + } + .onAppear { + // Each joinTerminal call delivers the full buffer; re-parse the whole thing. + service.onTerminalWrite(name: terminalName) { fullBuffer in + displayAttr = ANSIParser.attributedString(from: fullBuffer) + hasContent = !fullBuffer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + // Initial load + 10-second polling loop + refreshTask = Task { + while !Task.isCancelled { + await service.joinTerminal(stackName: stackName, endpoint: endpoint) + lastUpdated = Date() + try? await Task.sleep(for: .seconds(appState.logRefreshInterval)) + } + } + } + .onDisappear { + refreshTask?.cancel() + refreshTask = nil + service.removeTerminalListeners(name: terminalName) + service.leaveTerminal(stackName: stackName, endpoint: endpoint) + } + } +} diff --git a/dock-g/dock-g/Views/Stacks/ServicesTabView.swift b/dock-g/dock-g/Views/Stacks/ServicesTabView.swift new file mode 100644 index 0000000..69692b8 --- /dev/null +++ b/dock-g/dock-g/Views/Stacks/ServicesTabView.swift @@ -0,0 +1,69 @@ +import SwiftUI + +struct ServicesTabView: View { + let stackName: String + let endpoint: String + let service: DockgeService + + @State private var services: [ServiceStatus] = [] + @State private var isLoading = true + + var body: some View { + Group { + if isLoading { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if services.isEmpty { + ContentUnavailableView("No Services", systemImage: "cube") + } else { + List(services) { svc in + ServiceRowView(service: svc) + .listRowBackground(Color.appSurface) + } + .listStyle(.insetGrouped) + .scrollContentBackground(.hidden) + } + } + .task { + await reload() + } + } + + private func reload() async { + isLoading = true + services = await service.serviceStatusList(stackName: stackName, endpoint: endpoint) + isLoading = false + } +} + +private struct ServiceRowView: View { + let service: ServiceStatus + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Circle() + .fill(service.stateColor) + .frame(width: 8, height: 8) + Text(service.name) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.primary) + Spacer() + Text(service.state) + .font(.system(size: 12)) + .foregroundStyle(service.stateColor) + } + if !service.ports.isEmpty { + VStack(alignment: .leading, spacing: 2) { + ForEach(service.ports, id: \.self) { port in + Text(port) + .font(.monoSmall) + .foregroundStyle(.appGray) + } + } + .padding(.leading, 16) + } + } + .padding(.vertical, 4) + } +} diff --git a/dock-g/dock-g/Views/Stacks/StackDetailView.swift b/dock-g/dock-g/Views/Stacks/StackDetailView.swift new file mode 100644 index 0000000..4ea7d24 --- /dev/null +++ b/dock-g/dock-g/Views/Stacks/StackDetailView.swift @@ -0,0 +1,345 @@ +import SwiftUI + +struct StackDetailView: View { + let initialStack: Stack + let service: DockgeService + + @Environment(\.dismiss) private var dismiss + @State private var stack: Stack + @State private var selectedTab = 0 + @State private var isEditing = false + @State private var editYAML = "" + @State private var editENV = "" + @State private var isLoading = true + @State private var actionInProgress: String? + @State private var actionError: String? + @State private var showDeleteConfirmation = false + + init(initialStack: Stack, service: DockgeService) { + self.initialStack = initialStack + self.service = service + _stack = State(initialValue: initialStack) + } + +// MARK: - Custom floppy disk icon (not in SF Symbols) + +private struct FloppyDiskShape: Shape { + func path(in rect: CGRect) -> Path { + let w = rect.width, h = rect.height + let cr = min(w, h) * 0.12 // corner radius (3 rounded corners) + let cut = min(w, h) * 0.20 // top-right diagonal cut + + var p = Path() + + // Outer body: rounded rect with clipped top-right corner + p.move(to: CGPoint(x: cr, y: 0)) + p.addLine(to: CGPoint(x: w - cut, y: 0)) + p.addLine(to: CGPoint(x: w, y: cut)) + p.addLine(to: CGPoint(x: w, y: h - cr)) + p.addArc(center: CGPoint(x: w - cr, y: h - cr), radius: cr, + startAngle: .degrees(0), endAngle: .degrees(90), clockwise: false) + p.addLine(to: CGPoint(x: cr, y: h)) + p.addArc(center: CGPoint(x: cr, y: h - cr), radius: cr, + startAngle: .degrees(90), endAngle: .degrees(180), clockwise: false) + p.addLine(to: CGPoint(x: 0, y: cr)) + p.addArc(center: CGPoint(x: cr, y: cr), radius: cr, + startAngle: .degrees(180), endAngle: .degrees(270), clockwise: false) + p.closeSubpath() + + // Shutter slot — cutout at the top (even-odd → hole) + let slotX = w * 0.15 + let slotW = w - slotX - cut * 0.5 + p.addRect(CGRect(x: slotX, y: 0, width: slotW, height: h * 0.42)) + + // Hub inside shutter — small rectangle (3rd level → solid again) + let hubW = w * 0.22, hubH = h * 0.20 + p.addRect(CGRect(x: (w - hubW) * 0.5, y: h * 0.06, width: hubW, height: hubH)) + + // Label area at bottom — cutout (even-odd → hole) + let labInset = w * 0.10 + p.addRect(CGRect(x: labInset, y: h * 0.54, width: w - labInset * 2, height: h * 0.34)) + + return p + } +} + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + // Header + headerSection + + // Error banner + if let error = actionError { + Text(error) + .font(.system(size: 13)) + .foregroundStyle(.primary) + .padding(10) + .frame(maxWidth: .infinity) + .background(Color.appRed.opacity(0.8)) + } + + // Tabs + Picker("Tab", selection: $selectedTab) { + Text("Services").tag(0) + Text("Compose").tag(1) + Text("Env").tag(2) + Text("Logs").tag(3) + } + .pickerStyle(.segmented) + .padding(.horizontal) + .padding(.vertical, 8) + .background(Color.appSurface) + + // Tab content + tabContent + } + .background(Color.appBackground) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + HStack(spacing: 6) { + Circle() + .fill(stack.status.color) + .frame(width: 8, height: 8) + Text(stack.name) + .font(.system(size: 16, weight: .bold)) + } + } + ToolbarItem(placement: .navigationBarLeading) { + Button { dismiss() } label: { + Image(systemName: "xmark") + } + } + ToolbarItemGroup(placement: .navigationBarTrailing) { + if selectedTab == 1 || selectedTab == 2 { + if isEditing { + Button { + Task { await deployChanges() } + } label: { + Image(systemName: "hammer.fill") + } + .disabled(actionInProgress != nil) + Button { + Task { await saveChanges() } + } label: { + FloppyDiskShape() + .fill(style: FillStyle(eoFill: true)) + .frame(width: 20, height: 20) + } + .disabled(actionInProgress != nil) + Button { + editYAML = stack.composeYAML ?? "" + editENV = stack.composeENV ?? "" + isEditing = false + } label: { + Image(systemName: "xmark") + } + } else { + Button { isEditing = true } label: { + Image(systemName: "pencil") + } + } + } + } + } + } + + .confirmationDialog( + "Delete stack?", + isPresented: $showDeleteConfirmation, + titleVisibility: .visible + ) { + Button("Delete Stack", role: .destructive) { + Task { + let error = await service.deleteStack(stack.name, endpoint: stack.endpoint) + if error == nil { dismiss() } + if let msg = error { actionError = msg } + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This will run docker compose down and remove all stack files. This cannot be undone.") + } + .task { await loadDetails() } + } + + // MARK: - Header + + private var headerSection: some View { + VStack(spacing: 12) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(stack.status.label) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(stack.status.color) + if !stack.primaryHostname.isEmpty { + Text(stack.primaryHostname) + .font(.system(size: 12)) + .foregroundStyle(Color.appGray) + } + } + Spacer() + if let action = actionInProgress { + HStack(spacing: 6) { + ProgressView().tint(.appAccent).scaleEffect(0.8) + Text(action + "…") + .font(.system(size: 12)) + .foregroundStyle(Color.appGray) + } + } + } + + // Action buttons + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + if stack.status != .running { + actionButton("Start", icon: "play.fill", color: .appGreen) { + await performAction("startStack", display: "Starting") + } + } + if stack.status == .running || stack.status == .unknown || stack.status == .exited { + actionButton("Stop", icon: "stop.fill", color: .appRed) { + await performAction("stopStack", display: "Stopping") + } + actionButton("Restart", icon: "arrow.counterclockwise", color: .appAccent) { + await performAction("restartStack", display: "Restarting") + } + actionButton("Update", icon: "arrow.down.circle", color: .appAccent) { + await performAction("updateStack", display: "Updating") + } + } + actionButton("Down", icon: "arrow.down.to.line", color: .appGray) { + await performAction("downStack", display: "Taking down") + } + actionButton("Delete", icon: "trash", color: .appRed) { + showDeleteConfirmation = true + } + + + } + .padding(.horizontal, 2) + } + } + .padding() + .background(Color.appSurface) + } + + @ViewBuilder + private func actionButton(_ label: String, icon: String, color: Color, action: @escaping () async -> Void) -> some View { + Button { + Task { await action() } + } label: { + Label(label, systemImage: icon) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(color) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(color.opacity(0.15), in: RoundedRectangle(cornerRadius: 8)) + } + .disabled(actionInProgress != nil) + } + + // MARK: - Tab content + + @ViewBuilder + private var tabContent: some View { + switch selectedTab { + case 0: + ServicesTabView(stackName: stack.name, endpoint: stack.endpoint, service: service) + case 1: + ComposeTabView(yaml: $editYAML, isEditing: isEditing, onSave: saveChanges) + case 2: + EnvTabView(env: $editENV, isEditing: isEditing) + case 3: + LogsTabView(stackName: stack.name, endpoint: stack.endpoint, service: service) + default: + EmptyView() + } + } + + // MARK: - Actions + + private func loadDetails() async { + isLoading = true + if let detailed = await service.getStack(name: stack.name, endpoint: stack.endpoint) { + // getStack often returns status=0 (unknown) because the agent hasn't + // polled docker compose ps yet. The stackList already has the real status, + // so keep it unless getStack gives us something more specific. + let resolvedStatus = detailed.status == .unknown ? stack.status : detailed.status + stack = Stack( + name: detailed.name, + status: resolvedStatus, + isManagedByDockge: detailed.isManagedByDockge, + composeFileName: detailed.composeFileName, + endpoint: detailed.endpoint, + primaryHostname: detailed.primaryHostname, + composeYAML: detailed.composeYAML, + composeENV: detailed.composeENV, + tags: detailed.tags + ) + editYAML = detailed.composeYAML ?? "" + editENV = detailed.composeENV ?? "" + } + isLoading = false + } + + private func performAction(_ event: String, display: String) async { + actionInProgress = display + actionError = nil + + let error: String? + switch event { + case "startStack": error = await service.startStack(stack.name, endpoint: stack.endpoint) + case "stopStack": error = await service.stopStack(stack.name, endpoint: stack.endpoint) + case "restartStack": error = await service.restartStack(stack.name, endpoint: stack.endpoint) + case "updateStack": error = await service.updateStack(stack.name, endpoint: stack.endpoint) + case "downStack": error = await service.downStack(stack.name, endpoint: stack.endpoint) + default: error = "Unknown action" + } + + actionInProgress = nil + if let msg = error { actionError = msg } + await loadDetails() + } + + private func saveChanges() async { + actionInProgress = "Saving" + actionError = nil + let error = await service.saveStack( + name: stack.name, + yaml: editYAML, + env: editENV, + isAdd: false, + endpoint: stack.endpoint + ) + actionInProgress = nil + if let msg = error { + actionError = msg + } else { + isEditing = false + await loadDetails() + } + } + + private func deployChanges() async { + actionInProgress = "Deploying" + actionError = nil + + let error = await service.deployStack( + name: stack.name, + yaml: editYAML, + env: editENV, + isAdd: false, + endpoint: stack.endpoint + ) + actionInProgress = nil + + if let msg = error { + actionError = msg + } else { + isEditing = false + await loadDetails() + } + } +} diff --git a/dock-g/dock-g/Views/Stacks/StackRowView.swift b/dock-g/dock-g/Views/Stacks/StackRowView.swift new file mode 100644 index 0000000..f0a6f53 --- /dev/null +++ b/dock-g/dock-g/Views/Stacks/StackRowView.swift @@ -0,0 +1,57 @@ +import SwiftUI + +struct StackRowView: View, Equatable { + let stack: Stack + + private var machineLabel: String { + if stack.endpoint.isEmpty { + return "local" + } + // endpoint is "host:port" — show only the host + return stack.endpoint.components(separatedBy: ":").first ?? stack.endpoint + } + + var body: some View { + HStack(spacing: 12) { + Circle() + .fill(stack.status.color) + .frame(width: 10, height: 10) + + VStack(alignment: .leading, spacing: 2) { + Text(stack.name) + .font(.system(size: 16, weight: .bold)) + .foregroundStyle(.primary) + Text(stack.status.label) + .font(.system(size: 12)) + .foregroundStyle(.appGray) + } + + Spacer() + + if !stack.tags.isEmpty { + ForEach(stack.tags.prefix(2), id: \.self) { tag in + Text(tag) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(Color.appAccent) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(Color.appAccent.opacity(0.2), in: Capsule()) + .overlay(Capsule().strokeBorder(Color.appAccent.opacity(0.4), lineWidth: 0.5)) + } + } + + Text(machineLabel) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(Color.appGray) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(Color.appGray.opacity(0.15), in: Capsule()) + .overlay(Capsule().strokeBorder(Color.appGray.opacity(0.35), lineWidth: 0.5)) + + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.appGray) + } + .padding(.vertical, 4) + } +} diff --git a/dock-g/dock-g/Views/Stacks/StacksView.swift b/dock-g/dock-g/Views/Stacks/StacksView.swift new file mode 100644 index 0000000..e14f3ef --- /dev/null +++ b/dock-g/dock-g/Views/Stacks/StacksView.swift @@ -0,0 +1,173 @@ +import SwiftUI + +struct StacksView: View { + @Environment(AppState.self) private var appState + @State private var selectedStack: Stack? + @State private var searchText = "" + @State private var showLoginSheet = false + @State private var showCreateSheet = false + + /// Sorts and partitions stacks in a single pass — called once per render. + private var partitionedStacks: (running: [Stack], inactive: [Stack]) { + let all = searchText.isEmpty + ? appState.stacks + : appState.stacks.filter { $0.name.localizedCaseInsensitiveContains(searchText) } + let sorted = all.sorted { a, b in + let ao = sortKey(a.status), bo = sortKey(b.status) + return ao != bo ? ao < bo : a.name < b.name + } + var running: [Stack] = [] + var inactive: [Stack] = [] + for stack in sorted { + if stack.status == .running { running.append(stack) } else { inactive.append(stack) } + } + return (running, inactive) + } + + private func sortKey(_ status: StackStatus) -> Int { + switch status { + case .running: return 0 + case .exited: return 1 + case .unknown: return 2 + case .createdStack: return 3 + case .createdFile: return 4 + } + } + + var body: some View { + NavigationStack { + Group { + if appState.activeServer == nil { + noServerPlaceholder + } else if let service = appState.dockgeService { + switch service.authState { + case .disconnected, .connecting: + connectingView + case .needsLogin, .twoFactorRequired: + Color.clear + .onAppear { showLoginSheet = true } + case .needsSetup: + Color.clear + .onAppear { showLoginSheet = true } + case .authenticated: + stackList + case .error(let msg): + errorView(msg) + } + } + } + .navigationTitle(appState.activeServer?.name ?? "Dockge") + .navigationBarTitleDisplayMode(.large) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + if appState.activeServer != nil, appState.dockgeService?.authState == .authenticated { + Button { + showCreateSheet = true + } label: { + Image(systemName: "plus") + } + } + } + } + .sheet(isPresented: $showLoginSheet) { + if let service = appState.dockgeService { + LoginView(service: service, server: appState.activeServer!) + } + } + .sheet(item: $selectedStack) { stack in + if let service = appState.dockgeService { + StackDetailView(initialStack: stack, service: service) + } + } + .sheet(isPresented: $showCreateSheet) { + if let service = appState.dockgeService { + CreateStackView(service: service) + } + } + } + + } + + // MARK: - Subviews + + private func refreshStacks() async { + guard let service = appState.dockgeService else { return } + if service.authState == .disconnected { + service.reconnect() + try? await Task.sleep(for: .seconds(2)) + } else { + service.requestStackList() + try? await Task.sleep(for: .seconds(1)) + } + } + + private var stackList: some View { + let (running, inactive) = partitionedStacks + return List { + if appState.stacks.isEmpty { + ContentUnavailableView( + "No Stacks", + systemImage: "shippingbox", + description: Text("No stacks found on this server.") + ) + } else { + if !running.isEmpty { + Section { + ForEach(running) { stack in + Button { selectedStack = stack } label: { + StackRowView(stack: stack).equatable() + } + .listRowBackground(Color.appSurface) + } + } header: { + Label("\(running.count) Running", systemImage: "circle.fill") + .foregroundStyle(Color.appGreen) + .font(.system(size: 12, weight: .semibold)) + } + } + if !inactive.isEmpty { + Section { + ForEach(inactive) { stack in + Button { selectedStack = stack } label: { + StackRowView(stack: stack).equatable() + } + .listRowBackground(Color.appSurface) + } + } header: { + Label("\(inactive.count) Inactive", systemImage: "circle") + .foregroundStyle(Color.appGray) + .font(.system(size: 12, weight: .semibold)) + } + } + } + } + .listStyle(.insetGrouped) + .scrollContentBackground(.hidden) + .searchable(text: $searchText, prompt: "Search stacks") + .refreshable { await refreshStacks() } + } + + private var noServerPlaceholder: some View { + ContentUnavailableView { + Label("No Server Selected", systemImage: "server.rack") + } description: { + Text("Go to Servers to add and connect to a Dockge instance.") + } + } + + private var connectingView: some View { + VStack(spacing: 16) { + ProgressView() + Text("Connecting…") + .foregroundStyle(.appGray) + } + } + + private func errorView(_ message: String) -> some View { + ContentUnavailableView { + Label("Connection Error", systemImage: "wifi.exclamationmark") + } description: { + Text(message) + } + } +} diff --git a/dock-g/dock-g/dock_gApp.swift b/dock-g/dock-g/dock_gApp.swift new file mode 100644 index 0000000..9fda18b --- /dev/null +++ b/dock-g/dock-g/dock_gApp.swift @@ -0,0 +1,20 @@ +// +// dock_gApp.swift +// dock-g +// +// Created by Sven Hanold on 10.04.26. +// + +import SwiftUI + +@main +struct dock_gApp: App { + @State private var appState = AppState() + + var body: some Scene { + WindowGroup { + ContentView() + .environment(appState) + } + } +} diff --git a/dock-g_ios_icons/dockge_AppIcon_1024.png b/dock-g_ios_icons/dockge_AppIcon_1024.png new file mode 100644 index 0000000000000000000000000000000000000000..a4b877d4ea4c7d519d665b229153bfa7421b6768 GIT binary patch literal 20498 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 literal 0 HcmV?d00001 diff --git a/dock-g_ios_icons/dockge_AppIcon_20@3x.png b/dock-g_ios_icons/dockge_AppIcon_20@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..077aa05f9a0d4475cd64a95c9e6bfd4a8fcdcaf0 GIT binary patch literal 1137 zcmV-%1djWOP)DP+(a10pLfRN{f?TbRTY|+3J@Yq4;3ImFI3{|g2aIX z0`Y%v;l>|9Z`_b7jvOjcP6!E5dn&X|P}-`5P^H?Z>v+7*#!=Be z+RKg~`~7)lJnuV0YUw?I@GOZvfJk`9%`t}9YH{2Qc{GJCN>k{fG=(loQ|O{Jg)T}{ z=%O@*E=ul7T9jvGb+!PMp-gwv4uW)#1G+S~aQ34wg0Q$s1get4KbLk#O7YZqcA?Ii0XP0UR*_ipOUjri4 z{DfZ6hTEZa;<9ef46X*a0@rJ19rsq(Uc24h*BBW*3Cz>KHUDrMTlj$mRxxm_%_|?fbMpJPQU;xBN(`2YYQL~ zVv+>vZxF!Nr28Du>o3<%_8_Ve)QcLe2#kohxR{#c4Jn<*9)lOXU zPhAw1xqJc2t+;hJ-Q$3+ox3pq_Q%bYo$;gRS2ybD&R;182Xy`H!s&~R=E_c^ zEH9mJBzo?yciTY>Knkk*f<{)^erzJ0@$0mJAa!6u$OQdVLQu6NlOM60e^4*WvPwuU zB_*1j$Qgp{qs3RQt^N5z&o!;GLJwm5sM$}vG+S4*bv5+t^z~?h>BsJLI230akF3!B z*#7Z&e@2|FSe##8{TvX$cj-K+$06259`suqbrZ_G0*Vk8-9L+80^-U0FnJ3os9~wPB zu}#26JN)K{2Z@)M*;r-F0ikStim-7#s#Y zKQws?wK-?@O1ROlLRHXhv(C9b=AF|dP@By`{NL?#nA`J0s}e}S#612eGh3J#B;b|u z1$ZGDk5k~-6dXS2Qg!Gss39+XOnb}%m1Ebj*m>5Pg0Oga-d4iS`de3w;dY5B( zDbYXL6i0_R^Lqw0g)T}{=%O@*E=nH2Z1r(tj3xdT?hTukj!>g^00000NkvXXu0mjf Dre`jC literal 0 HcmV?d00001 diff --git a/dock-g_ios_icons/dockge_AppIcon_29@3x.png b/dock-g_ios_icons/dockge_AppIcon_29@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..686791071f629d29269df012ad93d7029c9e4b32 GIT binary patch literal 1656 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 U-Ym1vI{GI8jF~m6!o(xyAK&5bL;wH) literal 0 HcmV?d00001 diff --git a/dock-g_ios_icons/dockge_AppIcon_83.5@2x.png b/dock-g_ios_icons/dockge_AppIcon_83.5@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..f36e2649c63d342b4a2c94e693f13d430f8bd819 GIT binary patch literal 3027 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 Z99fD-3+zT$rJpYyU}xiqXteTA`4?g8#c%)s literal 0 HcmV?d00001 diff --git a/original-source/dockge b/original-source/dockge new file mode 160000 index 0000000..cc18056 --- /dev/null +++ b/original-source/dockge @@ -0,0 +1 @@ +Subproject commit cc180562fcd534de7c0890633494cde2c9658d97