From 016adcd5ea308659e52aba081884671061bd47ff Mon Sep 17 00:00:00 2001 From: rrroyal Date: Mon, 11 Oct 2021 19:33:41 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=89=20Initial=202.0=20commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 93 +++ Harbour.xcodeproj/project.pbxproj | 755 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/swiftpm/Package.resolved | 16 + .../xcshareddata/xcschemes/Harbour.xcscheme | 78 ++ .../AccentColor.colorset/Contents.json | 20 + .../AppIcon.appiconset/1024.png | Bin 0 -> 129131 bytes .../AppIcon.appiconset/120.png | Bin 0 -> 4196 bytes .../AppIcon.appiconset/152.png | Bin 0 -> 5506 bytes .../AppIcon.appiconset/167.png | Bin 0 -> 6091 bytes .../AppIcon.appiconset/180.png | Bin 0 -> 6794 bytes .../Assets.xcassets/AppIcon.appiconset/20.png | Bin 0 -> 432 bytes .../Assets.xcassets/AppIcon.appiconset/29.png | Bin 0 -> 716 bytes .../Assets.xcassets/AppIcon.appiconset/40.png | Bin 0 -> 1117 bytes .../Assets.xcassets/AppIcon.appiconset/58.png | Bin 0 -> 1750 bytes .../Assets.xcassets/AppIcon.appiconset/60.png | Bin 0 -> 1870 bytes .../Assets.xcassets/AppIcon.appiconset/76.png | Bin 0 -> 2456 bytes .../Assets.xcassets/AppIcon.appiconset/80.png | Bin 0 -> 2586 bytes .../Assets.xcassets/AppIcon.appiconset/87.png | Bin 0 -> 2860 bytes .../AppIcon.appiconset/Contents.json | 166 ++++ Harbour/Assets.xcassets/Contents.json | 6 + Harbour/Data/AppState.swift | 100 +++ .../Portainer+AttachedContainer.swift | 82 ++ .../Portainer/Portainer+PortainerError.swift | 15 + Harbour/Data/Portainer/Portainer.swift | 320 ++++++++ Harbour/Data/Preferences.swift | 49 ++ Harbour/Extensions+Modifiers/Array+.swift | 23 + Harbour/Extensions+Modifiers/Bundle+.swift | 22 + .../Extensions+Modifiers/Notification+.swift | 12 + Harbour/Extensions+Modifiers/String+.swift | 18 + Harbour/Extensions+Modifiers/UIDevice+.swift | 54 ++ Harbour/Extensions+Modifiers/UIWindow+.swift | 16 + Harbour/Globals.swift | 27 + Harbour/Harbour.entitlements | 14 + Harbour/HarbourApp.swift | 62 ++ Harbour/Info.plist | 20 + Harbour/Localization/Localization.swift | 39 + .../Localization/en.lproj/Localizable.strings | 85 ++ .../Components/ContainerContextMenu.swift | 144 ++++ Harbour/Views/Components/CustomSection.swift | 43 + Harbour/Views/Components/Labeled.swift | 54 ++ Harbour/Views/Components/LabeledSection.swift | 47 ++ .../Components/NavigationLinkLabel.swift | 42 + Harbour/Views/Components/ToolbarTitle.swift | 34 + .../ContainerGridView+ContainerCell.swift | 67 ++ .../Grid/ContainerGridView.swift | 40 + .../ContainerListView+ContainerCell.swift | 66 ++ .../List/ContainerListView.swift | 38 + Harbour/Views/ContentView.swift | 141 ++++ Harbour/Views/DebugView.swift | 110 +++ .../ContainerConfigDetailsView.swift | 55 ++ .../Container/ContainerConsoleView.swift | 32 + .../Container/ContainerDetailView.swift | 233 ++++++ .../Details/Container/ContainerLogsView.swift | 156 ++++ .../ContainerMountsDetailsView.swift | 134 ++++ .../ContainerNetworkDetailsView.swift | 101 +++ Harbour/Views/LoginView.swift | 212 +++++ .../Settings/SettingsView+Components.swift | 75 ++ .../SettingsView+InterfaceSection.swift | 31 + .../Settings/SettingsView+OtherSection.swift | 73 ++ .../SettingsView+PortainerSection.swift | 86 ++ Harbour/Views/Settings/SettingsView.swift | 30 + Harbour/Views/SetupView.swift | 100 +++ .../Button/DecreasesOnPressButtonStyle.swift | 19 + .../Styles/Button/PrimaryButtonStyle.swift | 37 + .../Button/TransparentButtonStyle.swift | 21 + .../TextField/RoundedTextFieldStyle.swift | 27 + LICENSE | 674 ++++++++++++++++ Modules/Indicators/.gitignore | 7 + Modules/Indicators/Package.swift | 18 + .../Indicators/Indicators+Indicator.swift | 88 ++ .../Indicators/Indicators+IndicatorView.swift | 107 +++ .../Indicators+IndicatorsOverlay.swift | 70 ++ .../Sources/Indicators/Indicators.swift | 42 + .../Indicators/View+indicatorOverlay.swift | 7 + .../xcschemes/PortainerKit.xcscheme | 67 ++ Modules/PortainerKit/Package.swift | 20 + Modules/PortainerKit/README.md | 58 ++ .../PortainerKit/PortainerKit+Errors.swift | 56 ++ .../PortainerKit+RequestPath.swift | 40 + .../PortainerKit+WebSocketMessage.swift | 21 + .../Sources/PortainerKit/PortainerKit.swift | 237 ++++++ .../PortainerKit/Types/Container.swift | 73 ++ .../PortainerKit/Types/ContainerDetails.swift | 90 +++ .../Sources/PortainerKit/Types/Endpoint.swift | 67 ++ .../Sources/PortainerKit/Types/Generics.swift | 612 ++++++++++++++ README.md | 28 + .../Extensions+Modifiers/PortainerKit+.swift | 93 +++ Shared/Extensions+Modifiers/View+.swift | 36 + 90 files changed, 6866 insertions(+) create mode 100644 .gitignore create mode 100644 Harbour.xcodeproj/project.pbxproj create mode 100644 Harbour.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 Harbour.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 Harbour.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 Harbour.xcodeproj/xcshareddata/xcschemes/Harbour.xcscheme create mode 100644 Harbour/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 Harbour/Assets.xcassets/AppIcon.appiconset/1024.png create mode 100644 Harbour/Assets.xcassets/AppIcon.appiconset/120.png create mode 100644 Harbour/Assets.xcassets/AppIcon.appiconset/152.png create mode 100644 Harbour/Assets.xcassets/AppIcon.appiconset/167.png create mode 100644 Harbour/Assets.xcassets/AppIcon.appiconset/180.png create mode 100644 Harbour/Assets.xcassets/AppIcon.appiconset/20.png create mode 100644 Harbour/Assets.xcassets/AppIcon.appiconset/29.png create mode 100644 Harbour/Assets.xcassets/AppIcon.appiconset/40.png create mode 100644 Harbour/Assets.xcassets/AppIcon.appiconset/58.png create mode 100644 Harbour/Assets.xcassets/AppIcon.appiconset/60.png create mode 100644 Harbour/Assets.xcassets/AppIcon.appiconset/76.png create mode 100644 Harbour/Assets.xcassets/AppIcon.appiconset/80.png create mode 100644 Harbour/Assets.xcassets/AppIcon.appiconset/87.png create mode 100644 Harbour/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Harbour/Assets.xcassets/Contents.json create mode 100644 Harbour/Data/AppState.swift create mode 100644 Harbour/Data/Portainer/Portainer+AttachedContainer.swift create mode 100644 Harbour/Data/Portainer/Portainer+PortainerError.swift create mode 100644 Harbour/Data/Portainer/Portainer.swift create mode 100644 Harbour/Data/Preferences.swift create mode 100644 Harbour/Extensions+Modifiers/Array+.swift create mode 100644 Harbour/Extensions+Modifiers/Bundle+.swift create mode 100644 Harbour/Extensions+Modifiers/Notification+.swift create mode 100644 Harbour/Extensions+Modifiers/String+.swift create mode 100644 Harbour/Extensions+Modifiers/UIDevice+.swift create mode 100644 Harbour/Extensions+Modifiers/UIWindow+.swift create mode 100644 Harbour/Globals.swift create mode 100644 Harbour/Harbour.entitlements create mode 100644 Harbour/HarbourApp.swift create mode 100644 Harbour/Info.plist create mode 100644 Harbour/Localization/Localization.swift create mode 100644 Harbour/Localization/en.lproj/Localizable.strings create mode 100644 Harbour/Views/Components/ContainerContextMenu.swift create mode 100644 Harbour/Views/Components/CustomSection.swift create mode 100644 Harbour/Views/Components/Labeled.swift create mode 100644 Harbour/Views/Components/LabeledSection.swift create mode 100644 Harbour/Views/Components/NavigationLinkLabel.swift create mode 100644 Harbour/Views/Components/ToolbarTitle.swift create mode 100644 Harbour/Views/Containers List/Grid/ContainerGridView+ContainerCell.swift create mode 100644 Harbour/Views/Containers List/Grid/ContainerGridView.swift create mode 100644 Harbour/Views/Containers List/List/ContainerListView+ContainerCell.swift create mode 100644 Harbour/Views/Containers List/List/ContainerListView.swift create mode 100644 Harbour/Views/ContentView.swift create mode 100644 Harbour/Views/DebugView.swift create mode 100644 Harbour/Views/Details/Container/ContainerConfigDetailsView.swift create mode 100644 Harbour/Views/Details/Container/ContainerConsoleView.swift create mode 100644 Harbour/Views/Details/Container/ContainerDetailView.swift create mode 100644 Harbour/Views/Details/Container/ContainerLogsView.swift create mode 100644 Harbour/Views/Details/Container/ContainerMountsDetailsView.swift create mode 100644 Harbour/Views/Details/Container/ContainerNetworkDetailsView.swift create mode 100644 Harbour/Views/LoginView.swift create mode 100644 Harbour/Views/Settings/SettingsView+Components.swift create mode 100644 Harbour/Views/Settings/SettingsView+InterfaceSection.swift create mode 100644 Harbour/Views/Settings/SettingsView+OtherSection.swift create mode 100644 Harbour/Views/Settings/SettingsView+PortainerSection.swift create mode 100644 Harbour/Views/Settings/SettingsView.swift create mode 100644 Harbour/Views/SetupView.swift create mode 100644 Harbour/Views/Styles/Button/DecreasesOnPressButtonStyle.swift create mode 100644 Harbour/Views/Styles/Button/PrimaryButtonStyle.swift create mode 100644 Harbour/Views/Styles/Button/TransparentButtonStyle.swift create mode 100644 Harbour/Views/Styles/TextField/RoundedTextFieldStyle.swift create mode 100644 LICENSE create mode 100644 Modules/Indicators/.gitignore create mode 100644 Modules/Indicators/Package.swift create mode 100644 Modules/Indicators/Sources/Indicators/Indicators+Indicator.swift create mode 100644 Modules/Indicators/Sources/Indicators/Indicators+IndicatorView.swift create mode 100644 Modules/Indicators/Sources/Indicators/Indicators+IndicatorsOverlay.swift create mode 100644 Modules/Indicators/Sources/Indicators/Indicators.swift create mode 100644 Modules/Indicators/Sources/Indicators/View+indicatorOverlay.swift create mode 100644 Modules/PortainerKit/.swiftpm/xcode/xcshareddata/xcschemes/PortainerKit.xcscheme create mode 100644 Modules/PortainerKit/Package.swift create mode 100644 Modules/PortainerKit/README.md create mode 100644 Modules/PortainerKit/Sources/PortainerKit/PortainerKit+Errors.swift create mode 100644 Modules/PortainerKit/Sources/PortainerKit/PortainerKit+RequestPath.swift create mode 100644 Modules/PortainerKit/Sources/PortainerKit/PortainerKit+WebSocketMessage.swift create mode 100644 Modules/PortainerKit/Sources/PortainerKit/PortainerKit.swift create mode 100644 Modules/PortainerKit/Sources/PortainerKit/Types/Container.swift create mode 100644 Modules/PortainerKit/Sources/PortainerKit/Types/ContainerDetails.swift create mode 100644 Modules/PortainerKit/Sources/PortainerKit/Types/Endpoint.swift create mode 100644 Modules/PortainerKit/Sources/PortainerKit/Types/Generics.swift create mode 100644 README.md create mode 100644 Shared/Extensions+Modifiers/PortainerKit+.swift create mode 100644 Shared/Extensions+Modifiers/View+.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..49b4cff0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,93 @@ +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## MacOS +.DS_Store + +## User settings +xcuserdata/ + +## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) +*.xcscmblueprint +*.xccheckout + +## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) +build/ +DerivedData/ +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 + +## Obj-C/Swift specific +*.hmap + +## App packaging +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +# Package.resolved +# *.xcodeproj +# +# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata +# hence it is not needed unless you have added a package configuration file to your project +# .swiftpm + +.build/ + +# CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +# Pods/ +# +# Add this line if you want to avoid checking in source code from the Xcode workspace +# *.xcworkspace + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build/ + +# Accio dependency management +Dependencies/ +.accio/ + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. +# Instead, use fastlane to re-generate the screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# Code Injection +# +# After new code Injection tools there's a generated folder /iOSInjectionProject +# https://github.com/johnno1962/injectionforxcode + +iOSInjectionProject/ diff --git a/Harbour.xcodeproj/project.pbxproj b/Harbour.xcodeproj/project.pbxproj new file mode 100644 index 00000000..2bfd64f0 --- /dev/null +++ b/Harbour.xcodeproj/project.pbxproj @@ -0,0 +1,755 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 55; + objects = { + +/* Begin PBXBuildFile section */ + E72112EA267F496B00D6004D /* LabeledSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = E72112E9267F496B00D6004D /* LabeledSection.swift */; }; + E7272E8A26736CCF00228494 /* PortainerKit+.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7272E8926736CCF00228494 /* PortainerKit+.swift */; }; + E7339C562674236700A55B5C /* ContainerContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7339C552674236700A55B5C /* ContainerContextMenu.swift */; }; + E7339C582674262500A55B5C /* String+.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7339C572674262500A55B5C /* String+.swift */; }; + E74AC7F326768543003411AA /* Portainer+PortainerError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E74AC7F226768543003411AA /* Portainer+PortainerError.swift */; }; + E74AC7F526768564003411AA /* Portainer+AttachedContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E74AC7F426768564003411AA /* Portainer+AttachedContainer.swift */; }; + E751F054270B0B5000980DCA /* Indicators in Frameworks */ = {isa = PBXBuildFile; productRef = E751F053270B0B5000980DCA /* Indicators */; }; + E751F059270B314700980DCA /* ContainerListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E751F058270B314700980DCA /* ContainerListView.swift */; }; + E751F05B270B315200980DCA /* ContainerListView+ContainerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = E751F05A270B315200980DCA /* ContainerListView+ContainerCell.swift */; }; + E751F05D270B315B00980DCA /* ContainerGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E751F05C270B315B00980DCA /* ContainerGridView.swift */; }; + E751F05F270B316400980DCA /* ContainerGridView+ContainerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = E751F05E270B316400980DCA /* ContainerGridView+ContainerCell.swift */; }; + E751F063270B37DA00980DCA /* Array+.swift in Sources */ = {isa = PBXBuildFile; fileRef = E751F062270B37DA00980DCA /* Array+.swift */; }; + E75A51D8267384E100857D2B /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = E75A51D7267384E100857D2B /* AppState.swift */; }; + E75A51DE26738A2000857D2B /* ContainerDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E75A51DD26738A2000857D2B /* ContainerDetailView.swift */; }; + E75A51E026739ED100857D2B /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = E75A51DF26739ED100857D2B /* Preferences.swift */; }; + E75A51E22673A1C100857D2B /* Bundle+.swift in Sources */ = {isa = PBXBuildFile; fileRef = E75A51E12673A1C100857D2B /* Bundle+.swift */; }; + E75A51E42673A4F000857D2B /* NavigationLinkLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E75A51E32673A4F000857D2B /* NavigationLinkLabel.swift */; }; + E75A51E62673A75700857D2B /* ContainerMountsDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E75A51E52673A75700857D2B /* ContainerMountsDetailsView.swift */; }; + E75A51E82673A78300857D2B /* ContainerNetworkDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E75A51E72673A78300857D2B /* ContainerNetworkDetailsView.swift */; }; + E75A51EA2673A79700857D2B /* ContainerLogsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E75A51E92673A79700857D2B /* ContainerLogsView.swift */; }; + E75A51EC2673A7F300857D2B /* ContainerConfigDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E75A51EB2673A7F300857D2B /* ContainerConfigDetailsView.swift */; }; + E75A51EE2673B17B00857D2B /* Labeled.swift in Sources */ = {isa = PBXBuildFile; fileRef = E75A51ED2673B17B00857D2B /* Labeled.swift */; }; + E75A51F02673CE3500857D2B /* View+.swift in Sources */ = {isa = PBXBuildFile; fileRef = E75A51EF2673CE3500857D2B /* View+.swift */; }; + E7685BBF26769B2500CD592E /* PortainerKit in Frameworks */ = {isa = PBXBuildFile; productRef = E7685BBE26769B2500CD592E /* PortainerKit */; }; + E76B26ED267518AA000F437D /* RoundedTextFieldStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E76B26EC267518AA000F437D /* RoundedTextFieldStyle.swift */; }; + E7748E7D267E831B00456BFD /* UIWindow+.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7748E7C267E831B00456BFD /* UIWindow+.swift */; }; + E7748E7F267E835600456BFD /* Notification+.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7748E7E267E835600456BFD /* Notification+.swift */; }; + E77C5FB3267E9B99000B4994 /* DebugView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E77C5FB2267E9B99000B4994 /* DebugView.swift */; }; + E77C5FB5267E9D1A000B4994 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E77C5FB4267E9D1A000B4994 /* SetupView.swift */; }; + E77C5FB9267EA4E5000B4994 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E77C5FBB267EA4E5000B4994 /* Localizable.strings */; }; + E7992A4426BF3BA3007335CA /* ToolbarTitle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7992A4326BF3BA3007335CA /* ToolbarTitle.swift */; }; + E7B10B7026CD80C3005B82BC /* SettingsView+Components.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7B10B6F26CD80C3005B82BC /* SettingsView+Components.swift */; }; + E7B10B7226CD80EB005B82BC /* SettingsView+OtherSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7B10B7126CD80EB005B82BC /* SettingsView+OtherSection.swift */; }; + E7B10B7426CD82A2005B82BC /* SettingsView+PortainerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7B10B7326CD82A2005B82BC /* SettingsView+PortainerSection.swift */; }; + E7B10B7626CD82D5005B82BC /* SettingsView+InterfaceSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7B10B7526CD82D5005B82BC /* SettingsView+InterfaceSection.swift */; }; + E7BF9E5E2672B5B100AAB6A1 /* HarbourApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7BF9E502672B5B000AAB6A1 /* HarbourApp.swift */; }; + E7BF9E602672B5B100AAB6A1 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7BF9E512672B5B000AAB6A1 /* ContentView.swift */; }; + E7BF9E622672B5B100AAB6A1 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E7BF9E522672B5B100AAB6A1 /* Assets.xcassets */; }; + E7BF9E782672E17D00AAB6A1 /* Portainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7BF9E772672E17D00AAB6A1 /* Portainer.swift */; }; + E7BF9E7B2672E38900AAB6A1 /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = E7BF9E7A2672E38900AAB6A1 /* KeychainAccess */; }; + E7BF9E862672EF5600AAB6A1 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7BF9E852672EF5600AAB6A1 /* SettingsView.swift */; }; + E7BF9E882672F29200AAB6A1 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7BF9E872672F29200AAB6A1 /* LoginView.swift */; }; + E7BF9E8A2672F2F600AAB6A1 /* UIDevice+.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7BF9E892672F2F600AAB6A1 /* UIDevice+.swift */; }; + E7BF9E8D2672F4B300AAB6A1 /* DecreasesOnPressButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7BF9E8C2672F4B300AAB6A1 /* DecreasesOnPressButtonStyle.swift */; }; + E7BF9E8F2672F4C700AAB6A1 /* Globals.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7BF9E8E2672F4C700AAB6A1 /* Globals.swift */; }; + E7BF9E912672F55700AAB6A1 /* PrimaryButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7BF9E902672F55700AAB6A1 /* PrimaryButtonStyle.swift */; }; + E7C84B792708BB170071DE06 /* Localization.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7C84B782708BB170071DE06 /* Localization.swift */; }; + E7DC3BA82708FB8A00F32F8B /* TransparentButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7DC3BA72708FB8A00F32F8B /* TransparentButtonStyle.swift */; }; + E7DD4CC6267650CB002709F0 /* ContainerConsoleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7DD4CC5267650CB002709F0 /* ContainerConsoleView.swift */; }; + E7EAE5CC270B6234008CFD20 /* CustomSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7EAE5CB270B6234008CFD20 /* CustomSection.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXCopyFilesBuildPhase section */ + E75A51D3267380AE00857D2B /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + E72112E9267F496B00D6004D /* LabeledSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabeledSection.swift; sourceTree = ""; }; + E7272E8926736CCF00228494 /* PortainerKit+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PortainerKit+.swift"; sourceTree = ""; }; + E7339C552674236700A55B5C /* ContainerContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerContextMenu.swift; sourceTree = ""; }; + E7339C572674262500A55B5C /* String+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+.swift"; sourceTree = ""; }; + E74AC7F226768543003411AA /* Portainer+PortainerError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Portainer+PortainerError.swift"; sourceTree = ""; }; + E74AC7F426768564003411AA /* Portainer+AttachedContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Portainer+AttachedContainer.swift"; sourceTree = ""; }; + E751F052270B096B00980DCA /* Indicators */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Indicators; sourceTree = ""; }; + E751F058270B314700980DCA /* ContainerListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerListView.swift; sourceTree = ""; }; + E751F05A270B315200980DCA /* ContainerListView+ContainerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContainerListView+ContainerCell.swift"; sourceTree = ""; }; + E751F05C270B315B00980DCA /* ContainerGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerGridView.swift; sourceTree = ""; }; + E751F05E270B316400980DCA /* ContainerGridView+ContainerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContainerGridView+ContainerCell.swift"; sourceTree = ""; }; + E751F062270B37DA00980DCA /* Array+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+.swift"; sourceTree = ""; }; + E75A51D7267384E100857D2B /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; + E75A51DD26738A2000857D2B /* ContainerDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerDetailView.swift; sourceTree = ""; }; + E75A51DF26739ED100857D2B /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = ""; }; + E75A51E12673A1C100857D2B /* Bundle+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+.swift"; sourceTree = ""; }; + E75A51E32673A4F000857D2B /* NavigationLinkLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationLinkLabel.swift; sourceTree = ""; }; + E75A51E52673A75700857D2B /* ContainerMountsDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerMountsDetailsView.swift; sourceTree = ""; }; + E75A51E72673A78300857D2B /* ContainerNetworkDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerNetworkDetailsView.swift; sourceTree = ""; }; + E75A51E92673A79700857D2B /* ContainerLogsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerLogsView.swift; sourceTree = ""; }; + E75A51EB2673A7F300857D2B /* ContainerConfigDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerConfigDetailsView.swift; sourceTree = ""; }; + E75A51ED2673B17B00857D2B /* Labeled.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Labeled.swift; sourceTree = ""; }; + E75A51EF2673CE3500857D2B /* View+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+.swift"; sourceTree = ""; }; + E7685BB726769B0900CD592E /* PortainerKit */ = {isa = PBXFileReference; lastKnownFileType = folder; path = PortainerKit; sourceTree = ""; }; + E76B26EC267518AA000F437D /* RoundedTextFieldStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedTextFieldStyle.swift; sourceTree = ""; }; + E7748E7C267E831B00456BFD /* UIWindow+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIWindow+.swift"; sourceTree = ""; }; + E7748E7E267E835600456BFD /* Notification+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+.swift"; sourceTree = ""; }; + E77C5FB2267E9B99000B4994 /* DebugView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugView.swift; sourceTree = ""; }; + E77C5FB4267E9D1A000B4994 /* SetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupView.swift; sourceTree = ""; }; + E77C5FBA267EA4E5000B4994 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; + E7992A4326BF3BA3007335CA /* ToolbarTitle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolbarTitle.swift; sourceTree = ""; }; + E7B10B6F26CD80C3005B82BC /* SettingsView+Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SettingsView+Components.swift"; sourceTree = ""; }; + E7B10B7126CD80EB005B82BC /* SettingsView+OtherSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SettingsView+OtherSection.swift"; sourceTree = ""; }; + E7B10B7326CD82A2005B82BC /* SettingsView+PortainerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SettingsView+PortainerSection.swift"; sourceTree = ""; }; + E7B10B7526CD82D5005B82BC /* SettingsView+InterfaceSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SettingsView+InterfaceSection.swift"; sourceTree = ""; }; + E7BF9E502672B5B000AAB6A1 /* HarbourApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HarbourApp.swift; sourceTree = ""; }; + E7BF9E512672B5B000AAB6A1 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + E7BF9E522672B5B100AAB6A1 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + E7BF9E572672B5B100AAB6A1 /* Harbour.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Harbour.app; sourceTree = BUILT_PRODUCTS_DIR; }; + E7BF9E772672E17D00AAB6A1 /* Portainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Portainer.swift; sourceTree = ""; }; + E7BF9E852672EF5600AAB6A1 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + E7BF9E872672F29200AAB6A1 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; + E7BF9E892672F2F600AAB6A1 /* UIDevice+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIDevice+.swift"; sourceTree = ""; }; + E7BF9E8C2672F4B300AAB6A1 /* DecreasesOnPressButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecreasesOnPressButtonStyle.swift; sourceTree = ""; }; + E7BF9E8E2672F4C700AAB6A1 /* Globals.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Globals.swift; sourceTree = ""; }; + E7BF9E902672F55700AAB6A1 /* PrimaryButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryButtonStyle.swift; sourceTree = ""; }; + E7C84B782708BB170071DE06 /* Localization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Localization.swift; sourceTree = ""; }; + E7DC3BA72708FB8A00F32F8B /* TransparentButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransparentButtonStyle.swift; sourceTree = ""; }; + E7DD4CC5267650CB002709F0 /* ContainerConsoleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerConsoleView.swift; sourceTree = ""; }; + E7EAE5CB270B6234008CFD20 /* CustomSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomSection.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + E7BF9E542672B5B100AAB6A1 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + E751F054270B0B5000980DCA /* Indicators in Frameworks */, + E7685BBF26769B2500CD592E /* PortainerKit in Frameworks */, + E7BF9E7B2672E38900AAB6A1 /* KeychainAccess in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + E72F350626769ABD00A38910 /* Modules */ = { + isa = PBXGroup; + children = ( + E7685BB726769B0900CD592E /* PortainerKit */, + E751F052270B096B00980DCA /* Indicators */, + ); + path = Modules; + sourceTree = ""; + }; + E7339C592674366000A55B5C /* Container */ = { + isa = PBXGroup; + children = ( + E75A51DD26738A2000857D2B /* ContainerDetailView.swift */, + E75A51E52673A75700857D2B /* ContainerMountsDetailsView.swift */, + E75A51E72673A78300857D2B /* ContainerNetworkDetailsView.swift */, + E75A51EB2673A7F300857D2B /* ContainerConfigDetailsView.swift */, + E75A51E92673A79700857D2B /* ContainerLogsView.swift */, + E7DD4CC5267650CB002709F0 /* ContainerConsoleView.swift */, + ); + path = Container; + sourceTree = ""; + }; + E74AC7F126768532003411AA /* Portainer */ = { + isa = PBXGroup; + children = ( + E7BF9E772672E17D00AAB6A1 /* Portainer.swift */, + E74AC7F226768543003411AA /* Portainer+PortainerError.swift */, + E74AC7F426768564003411AA /* Portainer+AttachedContainer.swift */, + ); + path = Portainer; + sourceTree = ""; + }; + E751F055270B312900980DCA /* Containers List */ = { + isa = PBXGroup; + children = ( + E751F057270B313C00980DCA /* List */, + E751F056270B313600980DCA /* Grid */, + ); + path = "Containers List"; + sourceTree = ""; + }; + E751F056270B313600980DCA /* Grid */ = { + isa = PBXGroup; + children = ( + E751F05C270B315B00980DCA /* ContainerGridView.swift */, + E751F05E270B316400980DCA /* ContainerGridView+ContainerCell.swift */, + ); + path = Grid; + sourceTree = ""; + }; + E751F057270B313C00980DCA /* List */ = { + isa = PBXGroup; + children = ( + E751F058270B314700980DCA /* ContainerListView.swift */, + E751F05A270B315200980DCA /* ContainerListView+ContainerCell.swift */, + ); + path = List; + sourceTree = ""; + }; + E75A51DC26738A1400857D2B /* Details */ = { + isa = PBXGroup; + children = ( + E7339C592674366000A55B5C /* Container */, + ); + path = Details; + sourceTree = ""; + }; + E76B26EE267518B8000F437D /* TextField */ = { + isa = PBXGroup; + children = ( + E76B26EC267518AA000F437D /* RoundedTextFieldStyle.swift */, + ); + path = TextField; + sourceTree = ""; + }; + E76B26EF267518BE000F437D /* Button */ = { + isa = PBXGroup; + children = ( + E7BF9E902672F55700AAB6A1 /* PrimaryButtonStyle.swift */, + E7DC3BA72708FB8A00F32F8B /* TransparentButtonStyle.swift */, + E7BF9E8C2672F4B300AAB6A1 /* DecreasesOnPressButtonStyle.swift */, + ); + path = Button; + sourceTree = ""; + }; + E76F798E267304760009FE65 /* Harbour */ = { + isa = PBXGroup; + children = ( + E7BF9E502672B5B000AAB6A1 /* HarbourApp.swift */, + E7BF9E8E2672F4C700AAB6A1 /* Globals.swift */, + E7BF9E762672E15700AAB6A1 /* Data */, + E7BF9E832672EF4200AAB6A1 /* Views */, + E7BF9E7E2672E60E00AAB6A1 /* Extensions+Modifiers */, + E77C5FB6267EA454000B4994 /* Localization */, + E7BF9E522672B5B100AAB6A1 /* Assets.xcassets */, + ); + path = Harbour; + sourceTree = ""; + }; + E77C5FB6267EA454000B4994 /* Localization */ = { + isa = PBXGroup; + children = ( + E77C5FBB267EA4E5000B4994 /* Localizable.strings */, + E7C84B782708BB170071DE06 /* Localization.swift */, + ); + path = Localization; + sourceTree = ""; + }; + E7B10B6E26CD80A6005B82BC /* Settings */ = { + isa = PBXGroup; + children = ( + E7BF9E852672EF5600AAB6A1 /* SettingsView.swift */, + E7B10B7326CD82A2005B82BC /* SettingsView+PortainerSection.swift */, + E7B10B7526CD82D5005B82BC /* SettingsView+InterfaceSection.swift */, + E7B10B7126CD80EB005B82BC /* SettingsView+OtherSection.swift */, + E7B10B6F26CD80C3005B82BC /* SettingsView+Components.swift */, + ); + path = Settings; + sourceTree = ""; + }; + E7B970EF26ED37DE00358FB9 /* Shared */ = { + isa = PBXGroup; + children = ( + E7B970F026ED37EB00358FB9 /* Extensions+Modifiers */, + ); + path = Shared; + sourceTree = ""; + }; + E7B970F026ED37EB00358FB9 /* Extensions+Modifiers */ = { + isa = PBXGroup; + children = ( + E7272E8926736CCF00228494 /* PortainerKit+.swift */, + E75A51EF2673CE3500857D2B /* View+.swift */, + ); + path = "Extensions+Modifiers"; + sourceTree = ""; + }; + E7BF9E4A2672B5B000AAB6A1 = { + isa = PBXGroup; + children = ( + E76F798E267304760009FE65 /* Harbour */, + E7B970EF26ED37DE00358FB9 /* Shared */, + E72F350626769ABD00A38910 /* Modules */, + E7BF9E582672B5B100AAB6A1 /* Products */, + E7BF9E702672BC8D00AAB6A1 /* Frameworks */, + ); + sourceTree = ""; + }; + E7BF9E582672B5B100AAB6A1 /* Products */ = { + isa = PBXGroup; + children = ( + E7BF9E572672B5B100AAB6A1 /* Harbour.app */, + ); + name = Products; + sourceTree = ""; + }; + E7BF9E702672BC8D00AAB6A1 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; + E7BF9E762672E15700AAB6A1 /* Data */ = { + isa = PBXGroup; + children = ( + E74AC7F126768532003411AA /* Portainer */, + E75A51D7267384E100857D2B /* AppState.swift */, + E75A51DF26739ED100857D2B /* Preferences.swift */, + ); + path = Data; + sourceTree = ""; + }; + E7BF9E7E2672E60E00AAB6A1 /* Extensions+Modifiers */ = { + isa = PBXGroup; + children = ( + E751F062270B37DA00980DCA /* Array+.swift */, + E75A51E12673A1C100857D2B /* Bundle+.swift */, + E7748E7E267E835600456BFD /* Notification+.swift */, + E7339C572674262500A55B5C /* String+.swift */, + E7BF9E892672F2F600AAB6A1 /* UIDevice+.swift */, + E7748E7C267E831B00456BFD /* UIWindow+.swift */, + ); + path = "Extensions+Modifiers"; + sourceTree = ""; + }; + E7BF9E832672EF4200AAB6A1 /* Views */ = { + isa = PBXGroup; + children = ( + E7BF9E8B2672F48700AAB6A1 /* Styles */, + E7BF9E922672FB7A00AAB6A1 /* Components */, + E751F055270B312900980DCA /* Containers List */, + E75A51DC26738A1400857D2B /* Details */, + E7B10B6E26CD80A6005B82BC /* Settings */, + E7BF9E512672B5B000AAB6A1 /* ContentView.swift */, + E77C5FB4267E9D1A000B4994 /* SetupView.swift */, + E7BF9E872672F29200AAB6A1 /* LoginView.swift */, + E77C5FB2267E9B99000B4994 /* DebugView.swift */, + ); + path = Views; + sourceTree = ""; + }; + E7BF9E8B2672F48700AAB6A1 /* Styles */ = { + isa = PBXGroup; + children = ( + E76B26EF267518BE000F437D /* Button */, + E76B26EE267518B8000F437D /* TextField */, + ); + path = Styles; + sourceTree = ""; + }; + E7BF9E922672FB7A00AAB6A1 /* Components */ = { + isa = PBXGroup; + children = ( + E75A51ED2673B17B00857D2B /* Labeled.swift */, + E72112E9267F496B00D6004D /* LabeledSection.swift */, + E7EAE5CB270B6234008CFD20 /* CustomSection.swift */, + E75A51E32673A4F000857D2B /* NavigationLinkLabel.swift */, + E7339C552674236700A55B5C /* ContainerContextMenu.swift */, + E7992A4326BF3BA3007335CA /* ToolbarTitle.swift */, + ); + path = Components; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + E7BF9E562672B5B100AAB6A1 /* Harbour */ = { + isa = PBXNativeTarget; + buildConfigurationList = E7BF9E662672B5B100AAB6A1 /* Build configuration list for PBXNativeTarget "Harbour" */; + buildPhases = ( + E7BF9E532672B5B100AAB6A1 /* Sources */, + E7BF9E542672B5B100AAB6A1 /* Frameworks */, + E7BF9E552672B5B100AAB6A1 /* Resources */, + E75A51D3267380AE00857D2B /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Harbour; + packageProductDependencies = ( + E7BF9E7A2672E38900AAB6A1 /* KeychainAccess */, + E7685BBE26769B2500CD592E /* PortainerKit */, + E751F053270B0B5000980DCA /* Indicators */, + ); + productName = "Harbour (iOS)"; + productReference = E7BF9E572672B5B100AAB6A1 /* Harbour.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + E7BF9E4B2672B5B000AAB6A1 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1300; + LastUpgradeCheck = 1300; + TargetAttributes = { + E7BF9E562672B5B100AAB6A1 = { + CreatedOnToolsVersion = 13.0; + }; + }; + }; + buildConfigurationList = E7BF9E4E2672B5B000AAB6A1 /* Build configuration list for PBXProject "Harbour" */; + compatibilityVersion = "Xcode 13.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + pl, + ); + mainGroup = E7BF9E4A2672B5B000AAB6A1; + packageReferences = ( + E7BF9E792672E38900AAB6A1 /* XCRemoteSwiftPackageReference "KeychainAccess" */, + ); + productRefGroup = E7BF9E582672B5B100AAB6A1 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + E7BF9E562672B5B100AAB6A1 /* Harbour */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + E7BF9E552672B5B100AAB6A1 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E7BF9E622672B5B100AAB6A1 /* Assets.xcassets in Resources */, + E77C5FB9267EA4E5000B4994 /* Localizable.strings in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + E7BF9E532672B5B100AAB6A1 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E7748E7F267E835600456BFD /* Notification+.swift in Sources */, + E751F063270B37DA00980DCA /* Array+.swift in Sources */, + E7BF9E882672F29200AAB6A1 /* LoginView.swift in Sources */, + E751F05F270B316400980DCA /* ContainerGridView+ContainerCell.swift in Sources */, + E7992A4426BF3BA3007335CA /* ToolbarTitle.swift in Sources */, + E76B26ED267518AA000F437D /* RoundedTextFieldStyle.swift in Sources */, + E75A51EA2673A79700857D2B /* ContainerLogsView.swift in Sources */, + E7C84B792708BB170071DE06 /* Localization.swift in Sources */, + E751F059270B314700980DCA /* ContainerListView.swift in Sources */, + E7BF9E862672EF5600AAB6A1 /* SettingsView.swift in Sources */, + E7EAE5CC270B6234008CFD20 /* CustomSection.swift in Sources */, + E75A51F02673CE3500857D2B /* View+.swift in Sources */, + E75A51E62673A75700857D2B /* ContainerMountsDetailsView.swift in Sources */, + E74AC7F526768564003411AA /* Portainer+AttachedContainer.swift in Sources */, + E7BF9E602672B5B100AAB6A1 /* ContentView.swift in Sources */, + E7BF9E782672E17D00AAB6A1 /* Portainer.swift in Sources */, + E7DD4CC6267650CB002709F0 /* ContainerConsoleView.swift in Sources */, + E75A51E22673A1C100857D2B /* Bundle+.swift in Sources */, + E7339C582674262500A55B5C /* String+.swift in Sources */, + E7BF9E8A2672F2F600AAB6A1 /* UIDevice+.swift in Sources */, + E7BF9E912672F55700AAB6A1 /* PrimaryButtonStyle.swift in Sources */, + E7748E7D267E831B00456BFD /* UIWindow+.swift in Sources */, + E7BF9E8D2672F4B300AAB6A1 /* DecreasesOnPressButtonStyle.swift in Sources */, + E7B10B7026CD80C3005B82BC /* SettingsView+Components.swift in Sources */, + E7B10B7226CD80EB005B82BC /* SettingsView+OtherSection.swift in Sources */, + E7DC3BA82708FB8A00F32F8B /* TransparentButtonStyle.swift in Sources */, + E7272E8A26736CCF00228494 /* PortainerKit+.swift in Sources */, + E77C5FB3267E9B99000B4994 /* DebugView.swift in Sources */, + E77C5FB5267E9D1A000B4994 /* SetupView.swift in Sources */, + E7B10B7426CD82A2005B82BC /* SettingsView+PortainerSection.swift in Sources */, + E751F05B270B315200980DCA /* ContainerListView+ContainerCell.swift in Sources */, + E7B10B7626CD82D5005B82BC /* SettingsView+InterfaceSection.swift in Sources */, + E7339C562674236700A55B5C /* ContainerContextMenu.swift in Sources */, + E75A51E42673A4F000857D2B /* NavigationLinkLabel.swift in Sources */, + E75A51EC2673A7F300857D2B /* ContainerConfigDetailsView.swift in Sources */, + E74AC7F326768543003411AA /* Portainer+PortainerError.swift in Sources */, + E75A51EE2673B17B00857D2B /* Labeled.swift in Sources */, + E75A51E82673A78300857D2B /* ContainerNetworkDetailsView.swift in Sources */, + E75A51D8267384E100857D2B /* AppState.swift in Sources */, + E7BF9E5E2672B5B100AAB6A1 /* HarbourApp.swift in Sources */, + E72112EA267F496B00D6004D /* LabeledSection.swift in Sources */, + E7BF9E8F2672F4C700AAB6A1 /* Globals.swift in Sources */, + E751F05D270B315B00980DCA /* ContainerGridView.swift in Sources */, + E75A51E026739ED100857D2B /* Preferences.swift in Sources */, + E75A51DE26738A2000857D2B /* ContainerDetailView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + E77C5FBB267EA4E5000B4994 /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + E77C5FBA267EA4E5000B4994 /* en */, + ); + name = Localizable.strings; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + E7BF9E642672B5B100AAB6A1 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + 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; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + E7BF9E652672B5B100AAB6A1 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + 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; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + E7BF9E672672B5B100AAB6A1 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; + CODE_SIGN_ENTITLEMENTS = Harbour/Harbour.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 3; + DEVELOPMENT_TEAM = WPN9Y7CDCT; + "ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = NO; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Harbour/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Harbour; + INFOPLIST_KEY_CFBundleExecutable = Harbour; + INFOPLIST_KEY_CFBundleName = Harbour; + INFOPLIST_KEY_CFBundleVersion = 1; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 2.0.0; + PRODUCT_BUNDLE_IDENTIFIER = xyz.shameful.Harbour; + PRODUCT_NAME = Harbour; + SDKROOT = iphoneos; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + E7BF9E682672B5B100AAB6A1 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; + CODE_SIGN_ENTITLEMENTS = Harbour/Harbour.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 3; + DEVELOPMENT_TEAM = WPN9Y7CDCT; + "ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = NO; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Harbour/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Harbour; + INFOPLIST_KEY_CFBundleExecutable = Harbour; + INFOPLIST_KEY_CFBundleName = Harbour; + INFOPLIST_KEY_CFBundleVersion = 1; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 2.0.0; + PRODUCT_BUNDLE_IDENTIFIER = xyz.shameful.Harbour; + PRODUCT_NAME = Harbour; + SDKROOT = iphoneos; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + E7BF9E4E2672B5B000AAB6A1 /* Build configuration list for PBXProject "Harbour" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E7BF9E642672B5B100AAB6A1 /* Debug */, + E7BF9E652672B5B100AAB6A1 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + E7BF9E662672B5B100AAB6A1 /* Build configuration list for PBXNativeTarget "Harbour" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E7BF9E672672B5B100AAB6A1 /* Debug */, + E7BF9E682672B5B100AAB6A1 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + E7BF9E792672E38900AAB6A1 /* XCRemoteSwiftPackageReference "KeychainAccess" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/kishikawakatsumi/KeychainAccess.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 4.0.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + E751F053270B0B5000980DCA /* Indicators */ = { + isa = XCSwiftPackageProductDependency; + productName = Indicators; + }; + E7685BBE26769B2500CD592E /* PortainerKit */ = { + isa = XCSwiftPackageProductDependency; + productName = PortainerKit; + }; + E7BF9E7A2672E38900AAB6A1 /* KeychainAccess */ = { + isa = XCSwiftPackageProductDependency; + package = E7BF9E792672E38900AAB6A1 /* XCRemoteSwiftPackageReference "KeychainAccess" */; + productName = KeychainAccess; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = E7BF9E4B2672B5B000AAB6A1 /* Project object */; +} diff --git a/Harbour.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Harbour.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/Harbour.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Harbour.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Harbour.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/Harbour.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Harbour.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Harbour.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..344c54fb --- /dev/null +++ b/Harbour.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,16 @@ +{ + "object": { + "pins": [ + { + "package": "KeychainAccess", + "repositoryURL": "https://github.com/kishikawakatsumi/KeychainAccess.git", + "state": { + "branch": null, + "revision": "84e546727d66f1adc5439debad16270d0fdd04e7", + "version": "4.2.2" + } + } + ] + }, + "version": 1 +} diff --git a/Harbour.xcodeproj/xcshareddata/xcschemes/Harbour.xcscheme b/Harbour.xcodeproj/xcshareddata/xcschemes/Harbour.xcscheme new file mode 100644 index 00000000..6df1d0c1 --- /dev/null +++ b/Harbour.xcodeproj/xcshareddata/xcschemes/Harbour.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Harbour/Assets.xcassets/AccentColor.colorset/Contents.json b/Harbour/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..ffc8de04 --- /dev/null +++ b/Harbour/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "0.420", + "red" : "0.313" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Harbour/Assets.xcassets/AppIcon.appiconset/1024.png b/Harbour/Assets.xcassets/AppIcon.appiconset/1024.png new file mode 100644 index 0000000000000000000000000000000000000000..f32c1dca06dcc222f4711fc6030f09ca1b84a9b5 GIT binary patch literal 129131 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�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 G{eJ-X>tYK4 literal 0 HcmV?d00001 diff --git a/Harbour/Assets.xcassets/AppIcon.appiconset/120.png b/Harbour/Assets.xcassets/AppIcon.appiconset/120.png new file mode 100644 index 0000000000000000000000000000000000000000..9adff2802a983632ce448f4d7d24049048a74345 GIT binary patch literal 4196 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 Ef+|IJB>(^b literal 0 HcmV?d00001 diff --git a/Harbour/Assets.xcassets/AppIcon.appiconset/167.png b/Harbour/Assets.xcassets/AppIcon.appiconset/167.png new file mode 100644 index 0000000000000000000000000000000000000000..8c15e05e9fa0f623962715c42bb98a1d1f060e06 GIT binary patch literal 6091 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 F{tsA;?=S!W literal 0 HcmV?d00001 diff --git a/Harbour/Assets.xcassets/AppIcon.appiconset/180.png b/Harbour/Assets.xcassets/AppIcon.appiconset/180.png new file mode 100644 index 0000000000000000000000000000000000000000..7a49e660904479cbb2c44d1fde541a54fbff3988 GIT binary patch literal 6794 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 O0o0YXmFg92qy7)c7%g`I literal 0 HcmV?d00001 diff --git a/Harbour/Assets.xcassets/AppIcon.appiconset/20.png b/Harbour/Assets.xcassets/AppIcon.appiconset/20.png new file mode 100644 index 0000000000000000000000000000000000000000..95095fd23732239cfb7a9608f1456cd5ff157d25 GIT binary patch literal 432 zcmV;h0Z;ykP)KS*tmw?6XLd)|D<8*h32HLtzm<>!>sVQVLr zmXDY{HBEWL4cI*x^7_2_eg}TD0Sn|0Ss`FJG1?gg5OhBI$p>Gz$?I=;^(Ak--q6|H zj#E;#5wpZh{88bOxh;U0@ a{8|U15Io3YiO`e)0000!aryE54#`Tg+(^BvATf`t@8R0 zbIWS@P?;(wc%;nI>fh&XZgOK&9Oaf(1U*~y55mL=*+uy6Ze24S{pl1yTW>n~Q{9}8 zROlLr7aGhd{vB>;0&P5zS%8;XOvr?LFcb`#UkY_IeB}aMT#~K%d3Sw-10!tj7L|pq zH?j)xT8D{Q@DQ#6^UI)S%HUXt4@Sn>(@#}nW)=|mId^b`%BIw{#WM{)Ql+v9F2CRh z13p#bl^0Yo#;&K~%L!#_&Y3(Cf*Z9;4s zw5>2O1S5OIrlO`1>ZY)AlkT4Lyzr2(H5i}9we=rzS5_Gm%R36Rbmv<=zSE<*D{sD` zp*@w1c;_80U2*RN6%D9w!|N}3|2=n~qoB8+;?|Qiap3aG54lq_e6GoZw|MX-58dR! z8{B`52d;D9RqneYw>=lR_Y!ws;I8xBeO`)U+k1hR9@E|vLn8ow$eo*~qOn8|-{#?4 zJbFjc2e0$MHSWL4{er$M-MtsN=Yn))WzW*o0qtE_S|;!#{kyg%W~+1s_gb4VDY*4k ziU+<#_g#|io-f@UXJohb58-F#1HimeoO&SJ{#KXC*|_~qG+)yDr2Ad;u_7B=@r&l~ z%q;ZnqXCiUE-r z9hWuz6>%)S9v+1VD$&IkpYX&T9=pZU_ju!#2nU44p{p0Gs|0?1egNbcj!&Yw9i`PM ysYGofdiyau2NCGNACZ57UkZJ?VNt)0nhOBG8|fL>LnKZB0000*j#_Fni|7FHhY}9(kfv!g7yuvqs zzWno53rTdv)}`PaI0w#w$>a?{Bzbo@n8e~07&->o(Bk|&Tz)=|IHbkwOe^HhoQJ=L zkwhmh=5qkg-8-e%SS^*!U zz5$s4aSYoCv?JJi{5=Byj6`WMq6pQs8YoP*R-vvQAATz$ShWsL0JJMug>Kl4gZJYd z*I4k;_j1ieJnB#k8ikn)4);#1lxdJwXrWAksoGnSA@0Ac~7Hps%S{Os^i; zzdy=KZFc+jweNy_&`9*6o5i!{Tg~5ag;kt$EwNKY6$XvgZ&N&b0cz`U$o^<(#KL7b z~3>A>BX82{AG~d zoi#PghE_p^1uV#?h`N>%%{FpQ(Xh9YPe(tfBG8>m)ct|nK2iC4C31L)M_C2 zA=L!p2t|ds{c4l)=}!zJJbowEthYM4@iLrv41W5D6Q|3gcUh_jzO5s?<2p0<$KRp8 z9@#7)0P*8rGHk4gzVIwudOo_9?-AV81TESyv}$tB>=ebKInY0&5SuDA6ROYJ*4hoG ztg#7I)mXI_8#mkF#x3wZaaThFR@GtoD$Jgb1NOriC+}g7iHS4u`lrS&%r~&!m$W#J zZJM=|0*9t7G!4at0L{%oV2v}1^kfV4GAUFgwWM0TUt6V)}`QYqeB zO(7|7O9L!IYQpqP5D*}Y6qcM^9|6rFQB;5f2H>iTdHIDXFH`CsQ?2I7%NR)jCauci zZBA|WOfyrqtx@UTWtZTn!*Jg%Jn}H~>7{uR7woOLdUZ!h5gJv20W?-GaqULgQkg10 z?htIVenRbDj{9%rd1oRoPXY&#z1c~`TEu>R;hEg-!ZZ28u&AV>oxu>I~L^Is-)k(zCb702o*E?RCY;Iuiid8>6L`UEKg)l^n$J@vE`s4eNB{ZUxBb1AhLoFp)cwe4}; zMl4$em0n$=BdW<3sJ^{zeeB-{Wo3zHoNL#oOz-U->pP|fG`TQLkQmh2cSk-~?{HS@ jV7Z?#IdBgA|7ZdL2x)lKNi;jP00000NkvXXu0mjfm*E`L literal 0 HcmV?d00001 diff --git a/Harbour/Assets.xcassets/AppIcon.appiconset/58.png b/Harbour/Assets.xcassets/AppIcon.appiconset/58.png new file mode 100644 index 0000000000000000000000000000000000000000..07c6c3b3abf9a0c752aa5f0582b4578954c06f28 GIT binary patch literal 1750 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 0 { + setupAutoRefreshTimer() + } + } + + // MARK: - Auto refresh + + public func setupAutoRefreshTimer(interval: Double = Preferences.shared.autoRefreshInterval) { + self.logger.debug("(Auto refresh) Interval: \(interval)") + + autoRefreshTimer?.cancel() + + guard interval > 0 else { return } + + autoRefreshTimer = Timer.publish(every: interval, on: .current, in: .common) + .autoconnect() + .sink { _ in + Task { [weak self] in + DispatchQueue.main.async { [weak self] in + self?.fetchingMainScreenData = true + } + + do { + guard let selectedEndpointID = Portainer.shared.selectedEndpoint?.id else { + return + } + + try await Portainer.shared.getContainers(endpointID: selectedEndpointID) + } catch { + await UIDevice.current.generateHaptic(.error) + self?.handle(error) + } + + DispatchQueue.main.async { [weak self] in + self?.fetchingMainScreenData = false + } + } + } + } + + // MARK: - Error handling + + public func handle(_ error: Error, indicator: Indicators.Indicator, _fileID: StaticString = #fileID, _line: Int = #line) { + handle(error, displayIndicator: false, _fileID: _fileID, _line: _line) + + DispatchQueue.main.async { + self.indicators.display(indicator) + } + } + + public func handle(_ error: Error, displayIndicator: Bool = true, _fileID: StaticString = #fileID, _line: Int = #line) { + UIDevice.current.generateHaptic(.error) + logger.error("\(String(describing: error)) [\(_fileID):\(_line)]") + + if displayIndicator { + let style: Indicators.Indicator.Style = .init(subheadlineColor: .red, subheadlineStyle: .primary, iconColor: .red, iconStyle: .primary) + let indicator: Indicators.Indicator = .init(id: UUID().uuidString, icon: "exclamationmark.triangle.fill", headline: "Error!", subheadline: error.localizedDescription, expandedText: error.localizedDescription, dismissType: .after(5), style: style) + DispatchQueue.main.async { + self.indicators.display(indicator) + } + } + } +} diff --git a/Harbour/Data/Portainer/Portainer+AttachedContainer.swift b/Harbour/Data/Portainer/Portainer+AttachedContainer.swift new file mode 100644 index 00000000..bde65384 --- /dev/null +++ b/Harbour/Data/Portainer/Portainer+AttachedContainer.swift @@ -0,0 +1,82 @@ +// +// Portainer+AttachedContainer.swift +// Harbour +// +// Created by royal on 13/06/2021. +// + +import Combine +import Foundation +import os.log +import PortainerKit +import Indicators + +extension Portainer { + class AttachedContainer: ObservableObject { + public let container: PortainerKit.Container + public let messagePassthroughSubject: PortainerKit.WebSocketPassthroughSubject + + @Published public private(set) var attributedString: AttributedString = "" + + private let logger: Logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "Portainer.AttachedContainer") + private var messageCancellable: AnyCancellable? = nil + + public init(container: PortainerKit.Container, messagePassthroughSubject: PortainerKit.WebSocketPassthroughSubject) { + logger.info("Attached to container with ID \(container.id)") + self.container = container + self.messagePassthroughSubject = messagePassthroughSubject + + messageCancellable = messagePassthroughSubject + .filter { + if let result = try? $0.get() { return result.source == .server } + return true + } + .sink(receiveCompletion: passthroughSubjectCompletion, receiveValue: passthroughSubjectValue) + } + + deinit { + messageCancellable?.cancel() + logger.info("Deinitialized") + } + + private func passthroughSubjectCompletion(_ completion: Subscribers.Completion) { + switch completion { + case .finished: + let string = "Session ended." + update(string) + case .failure(let error): + let string = "Session ended, reason: \(String(describing: error))" + update(string) + AppState.shared.handle(error) + } + } + + private func passthroughSubjectValue(_ result: Result) { + switch result { + case .success(let message): + switch message.message { + case .string(let string): + update(string) + case .data(let data): + update(String(describing: data)) + @unknown default: + let string = "Unhandled WebSocketMessage: \(String(describing: result))" + update(string) + logger.debug("\(string)") + } + case .failure(let error): + update(String(describing: error)) + + let indicator: Indicators.Indicator = .init(id: "ContainerWebSocketDisconnected-\(container.id)", icon: "bolt.fill", headline: Localization.WEBSOCKET_DISCONNECTED_TITLE.localizedString, subheadline: error.localizedDescription, dismissType: .after(5)) + AppState.shared.handle(error, indicator: indicator) + } + } + + private func update(_ string: String) { + let attributedString: AttributedString = AttributedString(string) + DispatchQueue.main.async { [weak self] in + self?.attributedString.append(attributedString) + } + } + } +} diff --git a/Harbour/Data/Portainer/Portainer+PortainerError.swift b/Harbour/Data/Portainer/Portainer+PortainerError.swift new file mode 100644 index 00000000..44c10722 --- /dev/null +++ b/Harbour/Data/Portainer/Portainer+PortainerError.swift @@ -0,0 +1,15 @@ +// +// Portainer+PortainerError.swift +// Harbour +// +// Created by royal on 13/06/2021. +// + +import Foundation + +extension Portainer { + enum PortainerError: Error { + case noAPI + case noEndpoint + } +} diff --git a/Harbour/Data/Portainer/Portainer.swift b/Harbour/Data/Portainer/Portainer.swift new file mode 100644 index 00000000..1bb5e960 --- /dev/null +++ b/Harbour/Data/Portainer/Portainer.swift @@ -0,0 +1,320 @@ +// +// Portainer.swift +// Harbour +// +// Created by royal on 11/06/2021. +// + +import Combine +import KeychainAccess +import os.log +import PortainerKit +import SwiftUI + +final class Portainer: ObservableObject { + // MARK: - Public properties + + public static let shared: Portainer = Portainer() + + // MARK: Miscellaneous + + @Published public var isLoggedIn: Bool = false + + // MARK: Endpoint + + @Published public var selectedEndpoint: PortainerKit.Endpoint? = nil { + didSet { + Preferences.shared.selectedEndpointID = selectedEndpoint?.id + + if let endpointID = selectedEndpoint?.id { + Task { + do { + try await getContainers(endpointID: endpointID) + } catch { + AppState.shared.handle(error) + } + } + } else { + containers = [] + } + } + } + + @Published public private(set) var endpoints: [PortainerKit.Endpoint] = [] { + didSet { + if endpoints.contains(where: { $0.id == selectedEndpoint?.id }) { + return + } + + if let storedEndpointID = Preferences.shared.selectedEndpointID, let storedEndpoint = endpoints.first(where: { $0.id == storedEndpointID }) { + selectedEndpoint = storedEndpoint + } else if endpoints.count == 1 { + selectedEndpoint = endpoints.first + } else if endpoints.isEmpty { + selectedEndpoint = nil + } + } + } + + // MARK: Containers + + public let refreshCurrentContainerPassthroughSubject: PassthroughSubject = .init() + @Published public var attachedContainer: AttachedContainer? = nil + @Published public private(set) var containers: [PortainerKit.Container] = [] + + // MARK: - Private variables + + private var activeActions: Set = [] + + // MARK: - Private util + + private let logger: Logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "Portainer") + private let keychain: Keychain = Keychain(service: Bundle.main.bundleIdentifier!, accessGroup: "\(Bundle.main.appIdentifierPrefix)group.\(Bundle.main.bundleIdentifier!)").synchronizable(true).accessibility(.afterFirstUnlock) + private let ud: UserDefaults = Preferences.shared.ud + private var api: PortainerKit? + + // MARK: - init + + private init() { + if let urlString = Preferences.shared.endpointURL, let url = URL(string: urlString) { + logger.debug("Has saved URL: \(url, privacy: .sensitive)") + + if let token = try? keychain.get(KeychainKeys.token) { + logger.debug("Has token, cool! Using it 😊") + api = PortainerKit(url: url, token: token) + DispatchQueue.main.async { + Task { + AppState.shared.fetchingMainScreenData = true + _ = try? await self.getEndpoints() + AppState.shared.fetchingMainScreenData = false + } + } + } + } + } + + // MARK: - Public functions + + /// Logs in to Portainer. + /// - Parameters: + /// - url: Endpoint URL + /// - username: Username + /// - password: Password + /// - savePassword: Should password be saved? + /// - Returns: Result containing JWT token or error. + public func login(url: URL, username: String, password: String, savePassword: Bool) async throws { + logger.debug("Logging in! URL: \(url.absoluteString, privacy: .sensitive)") + let api = PortainerKit(url: url) + self.api = api + + let token = try await api.login(username: username, password: password) + + logger.debug("Successfully logged in!") + + DispatchQueue.main.async { + self.isLoggedIn = true + Preferences.shared.endpointURL = url.absoluteString + } + + try keychain.comment(Localization.KEYCHAIN_TOKEN_COMMENT.localizedString).label("Harbour (token)").set(token, key: KeychainKeys.token) + if savePassword { + let keychain = self.keychain.comment(Localization.KEYCHAIN_CREDS_COMMENT.localizedString) + try keychain.label("Harbour (username)").set(username, key: KeychainKeys.username) + try keychain.label("Harbour (password)").set(password, key: KeychainKeys.password) + } + } + + /// Logs out, removing all local authentication. + public func logOut() { + logger.info("Logging out") + + try? keychain.removeAll() + + self.api = nil + + DispatchQueue.main.async { + self.isLoggedIn = false + self.selectedEndpoint = nil + self.endpoints = [] + self.containers = [] + self.attachedContainer = nil + } + } + + /// Fetches available endpoints. + /// - Returns: `[PortainerKit.Endpoint]` + @discardableResult + public func getEndpoints() async throws -> [PortainerKit.Endpoint] { + logger.debug("Getting endpoints...") + + guard let api = api else { throw PortainerError.noAPI } + + do { + let endpoints = try await api.getEndpoints() + + logger.debug("Got \(endpoints.count) endpoint(s).") + DispatchQueue.main.async { [weak self] in + self?.endpoints = endpoints + self?.isLoggedIn = true + } + + return endpoints + } catch { + handle(error) + throw error + } + } + + /// Fetches available containers for selected endpoint ID. + /// - Parameter endpointID: Endpoint ID + /// - Returns: `[PortainerKit.Container]` + @discardableResult + public func getContainers(endpointID: Int) async throws -> [PortainerKit.Container] { + logger.debug("Getting containers for endpointID: \(endpointID)...") + + guard let api = api else { throw PortainerError.noAPI } + + do { + let containers = try await api.getContainers(for: endpointID) + + logger.debug("Got \(containers.count) container(s) for endpointID: \(endpointID).") + DispatchQueue.main.async { [weak self] in + self?.containers = containers + } + + return containers + } catch { + handle(error) + throw error + } + } + + /// Fetches container details. + /// - Parameter container: Container to be inspected + /// - Returns: `PortainerKit.ContainerDetails` + public func inspectContainer(_ container: PortainerKit.Container) async throws -> PortainerKit.ContainerDetails { + logger.debug("Inspecting container with ID: \(container.id), endpointID: \(self.selectedEndpoint?.id ?? -1)...") + + guard let api = api else { throw PortainerError.noAPI } + guard let endpointID = selectedEndpoint?.id else { throw PortainerError.noEndpoint } + + do { + let containerDetails = try await api.inspectContainer(container.id, endpointID: endpointID) + logger.debug("Got details for containerID: \(container.id), endpointID: \(endpointID).") + return containerDetails + } catch { + handle(error) + throw error + } + } + + /// Executes an action on selected container. + /// - Parameters: + /// - action: Action to be executed + /// - container: Container, where the action will be executed + public func execute(_ action: PortainerKit.ExecuteAction, on container: PortainerKit.Container) async throws { + logger.debug("Executing action \(action.rawValue) for containerID: \(container.id), endpointID: \(self.selectedEndpoint?.id ?? -1)...") + + guard let api = api else { throw PortainerError.noAPI } + guard let endpointID = selectedEndpoint?.id else { throw PortainerError.noEndpoint } + + do { + try await api.execute(action, containerID: container.id, endpointID: endpointID) + logger.debug("Executed action \(action.rawValue) for containerID: \(container.id), endpointID: \(endpointID).") + } catch { + handle(error) + throw error + } + } + + /// Fetches logs from selected container. + /// - Parameters: + /// - container: Get logs from this container + /// - since: Logs since this time + /// - tail: Number of lines + /// - displayTimestamps: Display timestamps? + /// - Returns: `String` logs + public func getLogs(from container: PortainerKit.Container, since: TimeInterval = 0, tail: Int = 100, displayTimestamps: Bool = false) async throws -> String { + logger.debug("Getting logs from containerID: \(container.id), endpointID: \(self.selectedEndpoint?.id ?? -1)...") + + guard let api = api else { throw PortainerError.noAPI } + guard let endpointID = selectedEndpoint?.id else { throw PortainerError.noEndpoint } + + do { + let logs = try await api.getLogs(containerID: container.id, endpointID: endpointID, since: since, tail: tail, displayTimestamps: displayTimestamps) + logger.debug("Got logs from containerID: \(container.id), endpointID: \(self.selectedEndpoint?.id ?? -1)!") + return logs + } catch { + handle(error) + throw error + } + } + + /// Attaches to container through a WebSocket connection. + /// - Parameter container: Container to attach to + /// - Returns: Result containing `AttachedContainer` or error. + @discardableResult + public func attach(to container: PortainerKit.Container) throws -> AttachedContainer { + if let attachedContainer = attachedContainer, attachedContainer.container.id == container.id { + return attachedContainer + } + + logger.debug("Attaching to containerID: \(container.id), endpointID: \(self.selectedEndpoint?.id ?? -1)...") + + guard let api = api else { throw PortainerError.noAPI } + guard let endpointID = selectedEndpoint?.id else { throw PortainerError.noEndpoint } + + do { + let messagePassthroughSubject = try api.attach(to: container.id, endpointID: endpointID) + logger.debug("Attached to containerID: \(container.id), endpointID: \(self.selectedEndpoint?.id ?? -1)!") + + let attachedContainer = AttachedContainer(container: container, messagePassthroughSubject: messagePassthroughSubject) + self.attachedContainer = attachedContainer + + return attachedContainer + } catch { + handle(error) + throw error + } + } + + // MARK: - Private functions + + /// Handles potential errors + /// - Parameter error: Error to handle + private func handle(_ error: Error, _function: StaticString = #function, _fileID: StaticString = #fileID, _line: Int = #line) { + logger.error("\(String(describing: error)) (\(_function) [\(_fileID):\(_line)])") + + if let error = error as? PortainerKit.APIError { + // PortainerKit + switch error { + case .invalidJWTToken: + // Check if has stored creds + if let url = api?.url, let username = try? keychain.get(KeychainKeys.username), let password = try? keychain.get(KeychainKeys.password) { + logger.debug("Received `invalidJWTToken`, but has credentials!") + Task { + do { + try await login(url: url, username: username, password: password, savePassword: true) + try await self.getEndpoints() + } catch { + logger.debug("Credentials invalid, logging out :(") + logOut() + } + } + } + default: + break + } + } + + AppState.shared.handle(error, _fileID: _fileID, _line: _line) + } +} + +private extension Portainer { + enum KeychainKeys { + static let token: String = "token" + static let username: String = "username" + static let password: String = "password" + } +} diff --git a/Harbour/Data/Preferences.swift b/Harbour/Data/Preferences.swift new file mode 100644 index 00000000..337bd480 --- /dev/null +++ b/Harbour/Data/Preferences.swift @@ -0,0 +1,49 @@ +// +// Preferences.swift +// Harbour +// +// Created by royal on 11/06/2021. +// + +import Foundation +import SwiftUI + +class Preferences: ObservableObject { + public static let shared: Preferences = Preferences() + + @AppStorage(Preferences.Key.finishedSetup.rawValue, store: .group) public var finishedSetup: Bool = false + + @AppStorage(Preferences.Key.endpointURL.rawValue, store: .group) public var endpointURL: String? + @AppStorage(Preferences.Key.selectedEndpointID.rawValue, store: .group) public var selectedEndpointID: Int? + + @AppStorage(Preferences.Key.autoRefreshInterval.rawValue, store: .group) public var autoRefreshInterval: Double = 0 + + @AppStorage(Preferences.Key.enableHaptics.rawValue, store: .group) public var enableHaptics: Bool = true + @AppStorage(Preferences.Key.useGridView.rawValue, store: .group) public var useGridView: Bool = false + @AppStorage(Preferences.Key.persistAttachedContainer.rawValue, store: .group) public var persistAttachedContainer: Bool = true + @AppStorage(Preferences.Key.displayContainerDismissedPrompt.rawValue, store: .group) public var displayContainerDismissedPrompt: Bool = true + + public let ud: UserDefaults = .group + + private init() {} +} + +extension Preferences { + enum Key: String, CaseIterable { + case finishedSetup = "FinishedSetup" + + case endpointURL = "EndpointURL" + case selectedEndpointID = "SelectedEndpointID" + + case autoRefreshInterval = "AutoRefreshInterval" + + case enableHaptics = "EnableHaptics" + case useGridView = "UseGridView" + case persistAttachedContainer = "PersistAttachedContainer" + case displayContainerDismissedPrompt = "DisplayContainerDismissedPrompt" + } +} + +extension UserDefaults { + static var group: UserDefaults = UserDefaults(suiteName: "\(Bundle.main.appIdentifierPrefix)group.\(Bundle.main.bundleIdentifier!)")! +} diff --git a/Harbour/Extensions+Modifiers/Array+.swift b/Harbour/Extensions+Modifiers/Array+.swift new file mode 100644 index 00000000..7e7c3810 --- /dev/null +++ b/Harbour/Extensions+Modifiers/Array+.swift @@ -0,0 +1,23 @@ +// +// Array+.swift +// Harbour +// +// Created by royal on 04/10/2021. +// + +import Foundation +import PortainerKit + +extension Array where Element == PortainerKit.Container { + func filtered(query: String) -> Self { + guard !query.isReallyEmpty else { return self } + + let lowercasedQuery = query.lowercased() + return filter { + ($0.displayName ?? "").lowercased().contains(lowercasedQuery) || + $0.id.lowercased().contains(lowercasedQuery) || + $0.imageID.contains(lowercasedQuery) || + ($0.names ?? []).contains(where: { $0.lowercased().contains(lowercasedQuery) }) + } + } +} diff --git a/Harbour/Extensions+Modifiers/Bundle+.swift b/Harbour/Extensions+Modifiers/Bundle+.swift new file mode 100644 index 00000000..9c860977 --- /dev/null +++ b/Harbour/Extensions+Modifiers/Bundle+.swift @@ -0,0 +1,22 @@ +// +// Bundle+.swift +// Harbour +// +// Created by royal on 11/06/2021. +// + +import Foundation + +public extension Bundle { + var buildVersion: String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown" + } + + var buildNumber: String { + Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown" + } + + var appIdentifierPrefix: String { + Bundle.main.infoDictionary?["AppIdentifierPrefix"] as? String ?? "" + } +} diff --git a/Harbour/Extensions+Modifiers/Notification+.swift b/Harbour/Extensions+Modifiers/Notification+.swift new file mode 100644 index 00000000..714e371d --- /dev/null +++ b/Harbour/Extensions+Modifiers/Notification+.swift @@ -0,0 +1,12 @@ +// +// Notification+.swift +// Harbour +// +// Created by royal on 19/06/2021. +// + +import Foundation + +extension Notification.Name { + static let DeviceDidShake = Notification.Name(rawValue: "DeviceDidShake") +} diff --git a/Harbour/Extensions+Modifiers/String+.swift b/Harbour/Extensions+Modifiers/String+.swift new file mode 100644 index 00000000..481c853d --- /dev/null +++ b/Harbour/Extensions+Modifiers/String+.swift @@ -0,0 +1,18 @@ +// +// String+.swift +// Harbour +// +// Created by royal on 12/06/2021. +// + +import Foundation + +extension String { + func capitalizingFirstLetter() -> String { + prefix(1).capitalized + dropFirst() + } + + var isReallyEmpty: Bool { + trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } +} diff --git a/Harbour/Extensions+Modifiers/UIDevice+.swift b/Harbour/Extensions+Modifiers/UIDevice+.swift new file mode 100644 index 00000000..e52c7107 --- /dev/null +++ b/Harbour/Extensions+Modifiers/UIDevice+.swift @@ -0,0 +1,54 @@ +// +// UIDevice+.swift +// Harbour +// +// Created by royal on 11/06/2021. +// + +import AudioToolbox +import CoreHaptics +import UIKit + +@available(iOS 14, *) +extension UIDevice { + enum FeedbackStyle { + case error, success, warning, light, medium, heavy, soft, rigid, selectionChanged + } + + /// Generates a haptic feedback/vibration. + /// - Parameter style: Style of the feedback + func generateHaptic(_ style: FeedbackStyle) { + guard Preferences.shared.enableHaptics else { + return + } + + let supportsHaptics = CHHapticEngine.capabilitiesForHardware().supportsHaptics + if supportsHaptics { + // Haptic Feedback + switch style { + case .error: UINotificationFeedbackGenerator().notificationOccurred(.error) + case .success: UINotificationFeedbackGenerator().notificationOccurred(.success) + case .warning: UINotificationFeedbackGenerator().notificationOccurred(.warning) + case .light: UIImpactFeedbackGenerator(style: .light).impactOccurred() + case .medium: UIImpactFeedbackGenerator(style: .medium).impactOccurred() + case .heavy: UIImpactFeedbackGenerator(style: .heavy).impactOccurred() + case .soft: UIImpactFeedbackGenerator(style: .soft).impactOccurred() + case .rigid: UIImpactFeedbackGenerator(style: .rigid).impactOccurred() + case .selectionChanged: UISelectionFeedbackGenerator().selectionChanged() + } + } else { + // Older devices + switch style { + case .error: AudioServicesPlaySystemSound(1521) + case .success: break + case .warning: break + case .light: AudioServicesPlaySystemSound(1519) + case .medium: break + case .heavy: AudioServicesPlaySystemSound(1520) + case .soft: break + case .rigid: break + case .selectionChanged: AudioServicesPlaySystemSound(1519) + } + } + } +} diff --git a/Harbour/Extensions+Modifiers/UIWindow+.swift b/Harbour/Extensions+Modifiers/UIWindow+.swift new file mode 100644 index 00000000..30ed7a66 --- /dev/null +++ b/Harbour/Extensions+Modifiers/UIWindow+.swift @@ -0,0 +1,16 @@ +// +// UIWindow+.swift +// Harbour +// +// Created by royal on 19/06/2021. +// + +import UIKit + +extension UIWindow { + open override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) { + if motion == .motionShake { + NotificationCenter.default.post(name: .DeviceDidShake, object: nil) + } + } +} diff --git a/Harbour/Globals.swift b/Harbour/Globals.swift new file mode 100644 index 00000000..c37e03dd --- /dev/null +++ b/Harbour/Globals.swift @@ -0,0 +1,27 @@ +// +// Globals.swift +// Harbour +// +// Created by royal on 11/06/2021. +// + +import SwiftUI + +struct Globals { + enum Views { + static let cornerRadius: Double = 14 + static let largeCornerRadius: Double = 18 + + static let secondaryOpacity: Double = 0.3 + static let candyOpacity: Double = 0.12 + + static let springAnimation = Animation.interpolatingSpring(stiffness: 250, damping: 30) + + static let maxButtonWidth: Double = 600 + } + + enum Buttons { + static let pressedOpacity: Double = 0.75 + static let pressedSize: Double = 0.975 + } +} diff --git a/Harbour/Harbour.entitlements b/Harbour/Harbour.entitlements new file mode 100644 index 00000000..dc578ec8 --- /dev/null +++ b/Harbour/Harbour.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.application-groups + + group.xyz.shameful.Harbour + + keychain-access-groups + + $(AppIdentifierPrefix)group.xyz.shameful.Harbour + + + diff --git a/Harbour/HarbourApp.swift b/Harbour/HarbourApp.swift new file mode 100644 index 00000000..4831e7bd --- /dev/null +++ b/Harbour/HarbourApp.swift @@ -0,0 +1,62 @@ +// +// HarbourApp.swift +// Harbour +// +// Created by royal on 10/06/2021. +// + +import SwiftUI +import Indicators + +@main +struct HarbourApp: App { + @StateObject var appState: AppState = .shared + @StateObject var portainer: Portainer = .shared + @StateObject var preferences: Preferences = .shared + + var body: some Scene { + WindowGroup { + ContentView() + .indicatorOverlay(model: appState.indicators) + .sheet(isPresented: $appState.isSettingsSheetPresented) { + SettingsView() + .environmentObject(portainer) + .environmentObject(preferences) + } + .sheet(isPresented: $appState.isSetupSheetPresented, onDismiss: { Preferences.shared.finishedSetup = true }) { + SetupView() + } + .sheet(isPresented: $appState.isContainerConsoleSheetPresented, onDismiss: onContainerConsoleViewDismissed) { + ContainerConsoleView() + } + .onReceive(NotificationCenter.default.publisher(for: .DeviceDidShake, object: nil), perform: onDeviceDidShake) + .defaultAppStorage(.group) + .environmentObject(appState) + .environmentObject(portainer) + .environmentObject(preferences) + } + } + + private func onContainerConsoleViewDismissed() { + guard preferences.persistAttachedContainer else { + portainer.attachedContainer = nil + return + } + + guard preferences.displayContainerDismissedPrompt && portainer.attachedContainer != nil else { return } + + let indicatorID: String = "ContainerDismissedIndicator" + let indicator: Indicators.Indicator = .init(id: indicatorID, icon: "terminal.fill", headline: Localization.CONTAINER_DISMISSED_INDICATOR_TITLE.localizedString, subheadline: Localization.CONTAINER_DISMISSED_INDICATOR_DESCRIPTION.localizedString, dismissType: .after(5), onTap: { + UIDevice.current.generateHaptic(.light) + appState.isContainerConsoleSheetPresented = true + appState.indicators.dismiss(matching: indicatorID) + }) + appState.indicators.display(indicator) + } + + private func onDeviceDidShake(_: Notification) { + guard portainer.attachedContainer != nil else { return } + UIDevice.current.generateHaptic(.light) + appState.isContainerConsoleSheetPresented = true + } +} diff --git a/Harbour/Info.plist b/Harbour/Info.plist new file mode 100644 index 00000000..03970c84 --- /dev/null +++ b/Harbour/Info.plist @@ -0,0 +1,20 @@ + + + + + AppIdentifierPrefix + $(AppIdentifierPrefix) + ITSAppUsesNonExemptEncryption + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + UIBackgroundModes + + fetch + processing + + + diff --git a/Harbour/Localization/Localization.swift b/Harbour/Localization/Localization.swift new file mode 100644 index 00000000..21daaf87 --- /dev/null +++ b/Harbour/Localization/Localization.swift @@ -0,0 +1,39 @@ +// +// Localization.swift +// Harbour +// +// Created by royal on 02/10/2021. +// + +import Foundation + +enum Localization: String { + case CONTAINER_DISMISSED_INDICATOR_TITLE = "%CONTAINER_DISMISSED_INDICATOR_TITLE%" + case CONTAINER_DISMISSED_INDICATOR_DESCRIPTION = "%CONTAINER_DISMISSED_INDICATOR_DESCRIPTION%" + + case KEYCHAIN_TOKEN_COMMENT = "%KEYCHAIN_TOKEN_COMMENT%" + case KEYCHAIN_CREDS_COMMENT = "%KEYCHAIN_CREDS_COMMENT%" + + case SETTINGS_FOOTER = "%SETTINGS_FOOTER%" + + case SETTINGS_PERSIST_ATTACHED_CONTAINER_TITLE = "%SETTINGS_PERSIST_ATTACHED_CONTAINER_TITLE%" + case SETTINGS_PERSIST_ATTACHED_CONTAINER_DESCRIPTION = "%SETTINGS_PERSIST_ATTACHED_CONTAINER_DESCRIPTION%" + case SETTINGS_CONTAINER_DISCONNECTED_PROMPT_TITLE = "%SETTINGS_CONTAINER_DISCONNECTED_PROMPT_TITLE%" + case SETTINGS_CONTAINER_DISCONNECTED_PROMPT_DESCRIPTION = "%SETTINGS_CONTAINER_DISCONNECTED_PROMPT_DESCRIPTION%" + case SETTINGS_ENABLE_HAPTICS_TITLE = "%SETTINGS_ENABLE_HAPTICS_TITLE%" + case SETTINGS_ENABLE_HAPTICS_DESCRIPTION = "%SETTINGS_ENABLE_HAPTICS_DESCRIPTION%" + case SETTINGS_USE_GRID_VIEW_TITLE = "%SETTINGS_USE_GRID_VIEW_TITLE%" + case SETTINGS_USE_GRID_VIEW_DESCRIPTION = "%SETTINGS_USE_GRID_VIEW_DESCRIPTION%" + case SETTINGS_AUTO_REFRESH_TITLE = "%SETTINGS_AUTO_REFRESH_TITLE%" + + case SETUP_FEATURE1_TITLE = "%SETUP_FEATURE1_TITLE%" + case SETUP_FEATURE1_DESCRIPTION = "%SETUP_FEATURE1_DESCRIPTION%" + case SETUP_FEATURE2_TITLE = "%SETUP_FEATURE2_TITLE%" + case SETUP_FEATURE2_DESCRIPTION = "%SETUP_FEATURE2_DESCRIPTION%" + case SETUP_FEATURE3_TITLE = "%SETUP_FEATURE3_TITLE%" + case SETUP_FEATURE3_DESCRIPTION = "%SETUP_FEATURE3_DESCRIPTION%" + + case WEBSOCKET_DISCONNECTED_TITLE = "%WEBSOCKET_DISCONNECTED_TITLE%" + + var localizedString: String { NSLocalizedString(self.rawValue, comment: "") } +} diff --git a/Harbour/Localization/en.lproj/Localizable.strings b/Harbour/Localization/en.lproj/Localizable.strings new file mode 100644 index 00000000..c2c8ffd7 --- /dev/null +++ b/Harbour/Localization/en.lproj/Localizable.strings @@ -0,0 +1,85 @@ +/* + Localizable.strings + Harbour + + Created by royal on 20/06/2021. + en_US +*/ + +// MARK: Generics + +"Refresh" = "Refresh"; +"Are you sure?" = "Are you sure?"; +"Yes" = "Yes"; +"Log in" = "Log in"; +"Log out" = "Log out"; +"Not logged in" = "Not logged in"; + +// MARK: Docker + +"Endpoint" = "Endpoint"; +"No endpoints" = "No endpoints"; +"No containers" = "No containers"; +"Select endpoint" = "Select endpoint"; + +"Mounts" = "Mounts"; +"Network" = "Network"; +"Host" = "Host"; +"Logs" = "Logs"; + +// MARK: Settings + +"%SETTINGS_AUTO_REFRESH_TITLE%" = "Auto refresh"; + +"%SETTINGS_ENABLE_HAPTICS_TITLE%" = "Enable Haptics"; +"%SETTINGS_ENABLE_HAPTICS_DESCRIPTION%" = "You can tone them down if you don't like them as much as I do :]"; +"%SETTINGS_USE_GRID_VIEW_TITLE%" = "Use Grid View"; +"%SETTINGS_USE_GRID_VIEW_DESCRIPTION%" = "You can fit more containers, but it's worse for accessibility"; +"%SETTINGS_PERSIST_ATTACHED_CONTAINER_TITLE%" = "Persist attached container"; +"%SETTINGS_PERSIST_ATTACHED_CONTAINER_DESCRIPTION%" = "Keep connection to the attached container after dismissing"; +"%SETTINGS_CONTAINER_DISCONNECTED_PROMPT_TITLE%" = "Display prompt when dismissing container"; +"%SETTINGS_CONTAINER_DISCONNECTED_PROMPT_DESCRIPTION%" = "Tap it or shake the device to open the attached container"; + +"%SETTINGS_FOOTER%" = "Made with ❤️ (and ☕️) by @rrroyal"; + +// MARK: Setup + +"Hi! Welcome to %@!" = "Hi! Welcome to %@!"; +"Beam me up, Scotty!" = "Beam me up, Scotty!"; + +"%SETUP_FEATURE1_TITLE%" = "Control your containers"; +"%SETUP_FEATURE1_DESCRIPTION%" = "Your Minecraft server is taking up too much ram? Quickly restart it from your phone."; + +"%SETUP_FEATURE2_TITLE%" = "See all of the details"; +"%SETUP_FEATURE2_DESCRIPTION%" = "You can check logs, mounts, network config - everything!"; + +"%SETUP_FEATURE3_TITLE%" = "Attach to them too"; +"%SETUP_FEATURE3_DESCRIPTION%" = "Yeah. I spent way too much time on that one."; + +// MARK: Login + +"Invalid URL" = "Invalid URL"; +"Success!" = "Success!"; + +// MARK: Errors + +"Response unacceptable (%@)" = "Response unacceptable (%@)"; +"Unknown error" = "Unknown error"; +"Decoding failed" = "Decoding failed"; +"Invalid credentials" = "Invalid credentials"; +"Invalid token" = "Invalid token"; +"Unauthorized" = "Unauthorized"; +"Invalid payload" = "Invalid payload"; +"Invalid URL" = "Invalid URL"; + +// MARK: Indicators + +"%CONTAINER_DISMISSED_INDICATOR_TITLE%" = "Container dismissed!"; +"%CONTAINER_DISMISSED_INDICATOR_DESCRIPTION%" = "Tap me to open it again"; + +"%WEBSOCKET_DISCONNECTED_TITLE%" = "WebSocket disconnected!"; + +// MARK: Keychain + +"%KEYCHAIN_TOKEN_COMMENT%" = "This is your authorization token for Portainer - if you delete it, you will be logged out of Harbour."; +"%KEYCHAIN_CREDS_COMMENT%" = "This keychain entry is here because \"Save password\" was on when logging in. This is only used to renew JWT token during authorization. To read more, visit https://harbour.shameful.xyz/docs/faq#save-password"; diff --git a/Harbour/Views/Components/ContainerContextMenu.swift b/Harbour/Views/Components/ContainerContextMenu.swift new file mode 100644 index 00000000..6e76cbe4 --- /dev/null +++ b/Harbour/Views/Components/ContainerContextMenu.swift @@ -0,0 +1,144 @@ +// +// ContainerContextMenu.swift +// Harbour +// +// Created by royal on 12/06/2021. +// + +import SwiftUI +import PortainerKit +import Indicators + +struct ContainerContextMenu: View { + @ObservedObject var container: PortainerKit.Container + + var resumeButton: some View { + Button(action: { execute(.unpause) }) { + Text(PortainerKit.ExecuteAction.unpause.label) + Image(systemName: PortainerKit.ExecuteAction.unpause.icon) + } + } + + var restartButton: some View { + Button(action: { execute(.restart) }) { + Text(PortainerKit.ExecuteAction.restart.label) + Image(systemName: PortainerKit.ExecuteAction.restart.icon) + } + } + + var startButton: some View { + Button(action: { execute(.start) }) { + Text(PortainerKit.ExecuteAction.start.label) + Image(systemName: PortainerKit.ExecuteAction.start.icon) + } + } + + var pauseButton: some View { + Button(action: { execute(.pause) }) { + Text(PortainerKit.ExecuteAction.pause.label) + Image(systemName: PortainerKit.ExecuteAction.pause.icon) + } + } + + var stopButton: some View { + Button(action: { execute(.stop) }) { + Text(PortainerKit.ExecuteAction.stop.label) + Image(systemName: PortainerKit.ExecuteAction.stop.icon) + } + } + + var killButton: some View { + Button(role: .destructive, action: { execute(.kill, haptic: .heavy) }) { + Text(PortainerKit.ExecuteAction.kill.label) + Image(systemName: PortainerKit.ExecuteAction.kill.icon) + } + } + + var body: some View { + Group { + Label(container.status ?? container.state?.rawValue.capitalizingFirstLetter() ?? "Unknown", systemImage: container.state.icon) + + Divider() + + switch container.state { + case .created: + pauseButton + stopButton + restartButton + Divider() + killButton + case .running: + pauseButton + stopButton + restartButton + Divider() + killButton + case .paused: + resumeButton + stopButton + restartButton + Divider() + killButton + case .restarting: + pauseButton + stopButton + Divider() + killButton + case .removing: + killButton + case .exited: + startButton + case .dead: + startButton + case .none: + resumeButton + startButton + restartButton + pauseButton + stopButton + Divider() + killButton + } + + Divider() + + Button(action: { + do { + UIDevice.current.generateHaptic(.light) + try Portainer.shared.attach(to: container) + AppState.shared.isContainerConsoleSheetPresented = true + } catch { + AppState.shared.handle(error) + } + }) { + Label("Attach", systemImage: "terminal") + } + .disabled(container.state != .running) + } + } + + private func execute(_ action: PortainerKit.ExecuteAction, haptic: UIDevice.FeedbackStyle = .medium) { + UIDevice.current.generateHaptic(haptic) + + let style: Indicators.Indicator.Style = .init(subheadlineColor: action.color, subheadlineStyle: .primary, iconColor: action.color, iconStyle: .primary, iconVariants: .fill) + let indicator: Indicators.Indicator = .init(id: "ContainerActionExecution-\(container.id)", icon: action.icon, headline: container.displayName ?? "Container", subheadline: action.label, dismissType: .after(3), style: style) + AppState.shared.indicators.display(indicator) + + Task { + do { + try await Portainer.shared.execute(action, on: container) + + DispatchQueue.main.async { + container.state = action.expectedState + Portainer.shared.refreshCurrentContainerPassthroughSubject.send() + } + + if let endpointID = Portainer.shared.selectedEndpoint?.id { + try await Portainer.shared.getContainers(endpointID: endpointID) + } + } catch { + AppState.shared.handle(error) + } + } + } +} diff --git a/Harbour/Views/Components/CustomSection.swift b/Harbour/Views/Components/CustomSection.swift new file mode 100644 index 00000000..5f443437 --- /dev/null +++ b/Harbour/Views/Components/CustomSection.swift @@ -0,0 +1,43 @@ +// +// CustomSection.swift +// Harbour +// +// Created by royal on 04/10/2021. +// + +import SwiftUI + +/* yeah, i'm sorry */ + +struct CustomSection: View { + let label: String + @ViewBuilder let content: () -> Content + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(LocalizedStringKey(label)) + .font(.footnote) + .foregroundStyle(.secondary) + .textCase(.uppercase) + .padding(.horizontal) + + content() + .padding(.medium) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: Globals.Views.cornerRadius, style: .continuous) + .fill(Color(uiColor: .secondarySystemGroupedBackground)) + ) + } + } +} + +struct CustomSection_Previews: PreviewProvider { + static var previews: some View { + CustomSection(label: "Label") { + Text("ahh") + } + .padding() + .previewLayout(.sizeThatFits) + } +} diff --git a/Harbour/Views/Components/Labeled.swift b/Harbour/Views/Components/Labeled.swift new file mode 100644 index 00000000..cca65cf5 --- /dev/null +++ b/Harbour/Views/Components/Labeled.swift @@ -0,0 +1,54 @@ +// +// Labeled.swift +// Harbour +// +// Created by royal on 11/06/2021. +// + +import SwiftUI + +struct Labeled: View { + let label: String + let content: String? + let monospace: Bool + let lineLimit: Int? + + public init(label: String, content: String?, monospace: Bool = false, lineLimit: Int? = nil) { + self.label = label + self.monospace = monospace + self.lineLimit = lineLimit + + if let content = content, !content.isEmpty { + self.content = content + } else { + self.content = nil + } + } + + public init(label: String, bool: Bool?) { + self.label = label + self.monospace = false + self.lineLimit = 1 + + if let bool = bool { + content = bool ? "✅" : "❌" + } else { + content = "❔" + } + } + + var body: some View { + HStack { + Text(LocalizedStringKey(label)) + + Spacer() + + Text(content ?? "none") + .font(.system(.subheadline, design: monospace ? .monospaced : .default)) + .foregroundColor(content != nil ? .primary : .secondary) + .lineLimit(lineLimit) + .multilineTextAlignment(.trailing) + .textSelection(.enabled) + } + } +} diff --git a/Harbour/Views/Components/LabeledSection.swift b/Harbour/Views/Components/LabeledSection.swift new file mode 100644 index 00000000..62c38eaf --- /dev/null +++ b/Harbour/Views/Components/LabeledSection.swift @@ -0,0 +1,47 @@ +// +// CustomSection.swift +// Harbour +// +// Created by royal on 20/06/2021. +// + +import SwiftUI + +struct LabeledSection: View { + let label: String + let content: String? + let monospace: Bool + + public init(label: String, content: String?, monospace: Bool = false) { + self.label = label + self.monospace = monospace + + if let content = content, !content.isReallyEmpty { + self.content = content + } else { + self.content = nil + } + } + + var body: some View { + CustomSection(label: label) { + Text(content ?? "none") + .font(.system(.callout, design: monospace ? .monospaced : .default)) + .foregroundColor(content != nil ? .primary : .secondary) + .lineLimit(nil) + .contentShape(Rectangle()) + .textSelection(.enabled) + } + } +} + +struct LabeledSection_Previews: PreviewProvider { + static var previews: some View { + Group { + LabeledSection(label: "Headline", content: "Content", monospace: true) + LabeledSection(label: "Headline", content: nil, monospace: false) + } + .previewLayout(.sizeThatFits) + .padding() + } +} diff --git a/Harbour/Views/Components/NavigationLinkLabel.swift b/Harbour/Views/Components/NavigationLinkLabel.swift new file mode 100644 index 00000000..cec1aab2 --- /dev/null +++ b/Harbour/Views/Components/NavigationLinkLabel.swift @@ -0,0 +1,42 @@ +// +// NavigationLinkLabel.swift +// Harbour +// +// Created by royal on 11/06/2021. +// + +import SwiftUI + +struct NavigationLinkLabel: View { + let label: String + let symbolName: String + let backgroundColor: Color + + public init(label: String, symbolName: String, backgroundColor: Color = Color(uiColor: .secondarySystemGroupedBackground)) { + self.label = label + self.symbolName = symbolName + self.backgroundColor = backgroundColor + } + + var body: some View { + HStack { + Image(systemName: symbolName) + .font(.callout.weight(.medium)) + + Text(LocalizedStringKey(label)) + .font(.callout.weight(.medium)) + + Spacer() + + Image(systemName: "chevron.forward") + .font(.subheadline.weight(.bold)) + .foregroundStyle(.secondary) + .opacity(Globals.Views.secondaryOpacity) + } + .padding(.medium) + .background( + RoundedRectangle(cornerRadius: Globals.Views.cornerRadius, style: .continuous) + .fill(backgroundColor) + ) + } +} diff --git a/Harbour/Views/Components/ToolbarTitle.swift b/Harbour/Views/Components/ToolbarTitle.swift new file mode 100644 index 00000000..348e22e9 --- /dev/null +++ b/Harbour/Views/Components/ToolbarTitle.swift @@ -0,0 +1,34 @@ +// +// ToolbarTitle.swift +// Harbour +// +// Created by royal on 08/08/2021. +// + +import SwiftUI + +struct ToolbarTitle: ToolbarContent { + let title: String + let subtitle: String? + + var body: some ToolbarContent { + ToolbarItem(placement: .principal) { + VStack(spacing: 1) { + Text(title) + .font(.headline) + .fixedSize(horizontal: true, vertical: true) + .transition(.move(edge: .bottom)) + + if let subtitle = subtitle { + Text(subtitle) + .font(.footnote) + .opacity(Globals.Views.secondaryOpacity) + .fixedSize(horizontal: true, vertical: true) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .animation(.easeInOut, value: subtitle) + } + } +} + diff --git a/Harbour/Views/Containers List/Grid/ContainerGridView+ContainerCell.swift b/Harbour/Views/Containers List/Grid/ContainerGridView+ContainerCell.swift new file mode 100644 index 00000000..4ad45568 --- /dev/null +++ b/Harbour/Views/Containers List/Grid/ContainerGridView+ContainerCell.swift @@ -0,0 +1,67 @@ +// +// ContainerGridView+ContainerCell.swift +// Harbour +// +// Created by royal on 04/10/2021. +// + +import PortainerKit +import SwiftUI + +extension ContainerGridView { + struct ContainerCell: View { + @ObservedObject var container: PortainerKit.Container + + let circleSize: Double = 10 + let backgroundRectangle: some Shape = RoundedRectangle(cornerRadius: Globals.Views.largeCornerRadius, style: .continuous) + + var body: some View { + VStack(alignment: .leading) { + HStack(alignment: .top) { + if let state = container.state { + Text(state.rawValue.capitalizingFirstLetter()) + .font(.footnote.weight(.medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + } + + Spacer() + + Circle() + .fill(container.state.color) + .frame(width: circleSize, height: circleSize) + .animation(.easeInOut, value: container.state.color) + } + + Spacer() + + if let status = container.status { + Text(status) + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.8) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + } + + Text(container.displayName ?? "Unnamed") + .font(.headline) + .foregroundColor(container.displayName != nil ? .primary : .secondary) + .lineLimit(2) + .minimumScaleFactor(0.6) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.medium) + .aspectRatio(1, contentMode: .fill) + .background(Color(uiColor: .secondarySystemBackground), in: backgroundRectangle) + .contentShape(backgroundRectangle) + .animation(.easeInOut, value: container.state) + .animation(.easeInOut, value: container.status) + .animation(.easeInOut, value: container.displayName) + .transition(.opacity) + } + } +} diff --git a/Harbour/Views/Containers List/Grid/ContainerGridView.swift b/Harbour/Views/Containers List/Grid/ContainerGridView.swift new file mode 100644 index 00000000..60109782 --- /dev/null +++ b/Harbour/Views/Containers List/Grid/ContainerGridView.swift @@ -0,0 +1,40 @@ +// +// ContainerGridView.swift +// Harbour +// +// Created by royal on 04/10/2021. +// + +import SwiftUI +import PortainerKit + +struct ContainerGridView: View { + let containers: [PortainerKit.Container] + + let columns: [GridItem] = [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())] + + var body: some View { + ScrollView { + LazyVGrid(columns: columns) { + ForEach(containers) { container in + NavigationLink(destination: ContainerDetailView(container: container)) { + ContainerCell(container: container) + .contextMenu { + ContainerContextMenu(container: container) + } + } + .buttonStyle(DecreasesOnPressButtonStyle()) + } + } + .padding(.horizontal) + .transition(.opacity) + .animation(.easeInOut, value: containers.count) + } + } +} + +struct ContainerGridView_Previews: PreviewProvider { + static var previews: some View { + ContainerGridView(containers: []) + } +} diff --git a/Harbour/Views/Containers List/List/ContainerListView+ContainerCell.swift b/Harbour/Views/Containers List/List/ContainerListView+ContainerCell.swift new file mode 100644 index 00000000..630cb474 --- /dev/null +++ b/Harbour/Views/Containers List/List/ContainerListView+ContainerCell.swift @@ -0,0 +1,66 @@ +// +// ContainerListView+ContainerCell.swift +// Harbour +// +// Created by royal on 04/10/2021. +// + +import SwiftUI +import PortainerKit + +extension ContainerListView { + struct ContainerCell: View { + @ObservedObject var container: PortainerKit.Container + + let circleSize: Double = 10 + let backgroundRectangle: some Shape = RoundedRectangle(cornerRadius: Globals.Views.largeCornerRadius, style: .continuous) + + @ViewBuilder + var containerStatusSubheadline: some View { + Group { + if let status = container.status, + let state = container.state { + Text("\(status) • \(state.rawValue.capitalizingFirstLetter())") + } else if let fallback = container.status ?? container.state?.rawValue.capitalizingFirstLetter() { + Text(fallback) + } + } + .font(.subheadline.weight(.medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.8) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + } + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 5) { + Text(container.displayName ?? "Unnamed") + .font(.headline) + .foregroundColor(container.displayName != nil ? .primary : .secondary) + .lineLimit(2) + .minimumScaleFactor(0.6) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + + containerStatusSubheadline + } + + Spacer() + + Circle() + .fill(container.state.color) + .frame(width: circleSize, height: circleSize) + .animation(.easeInOut, value: container.state.color) + } + .padding() + .background(Color(uiColor: .secondarySystemBackground), in: backgroundRectangle) + .contentShape(backgroundRectangle) + .animation(.easeInOut, value: container.state) + .animation(.easeInOut, value: container.status) + .animation(.easeInOut, value: container.displayName) + .transition(.opacity) + } + } +} diff --git a/Harbour/Views/Containers List/List/ContainerListView.swift b/Harbour/Views/Containers List/List/ContainerListView.swift new file mode 100644 index 00000000..7f0b78d3 --- /dev/null +++ b/Harbour/Views/Containers List/List/ContainerListView.swift @@ -0,0 +1,38 @@ +// +// ContainerListView.swift +// Harbour +// +// Created by royal on 04/10/2021. +// + +import SwiftUI +import PortainerKit + +struct ContainerListView: View { + let containers: [PortainerKit.Container] + + var body: some View { + ScrollView { + LazyVStack { + ForEach(containers) { container in + NavigationLink(destination: ContainerDetailView(container: container)) { + ContainerCell(container: container) + .contextMenu { + ContainerContextMenu(container: container) + } + } + .buttonStyle(DecreasesOnPressButtonStyle()) + } + } + .padding(.horizontal) + .transition(.opacity) + .animation(.easeInOut, value: containers.count) + } + } +} + +struct ContainerListView_Previews: PreviewProvider { + static var previews: some View { + ContainerListView(containers: []) + } +} diff --git a/Harbour/Views/ContentView.swift b/Harbour/Views/ContentView.swift new file mode 100644 index 00000000..92b6f440 --- /dev/null +++ b/Harbour/Views/ContentView.swift @@ -0,0 +1,141 @@ +// +// ContentView.swift +// Harbour +// +// Created by royal on 10/06/2021. +// + +import PortainerKit +import SwiftUI + +struct ContentView: View { + @EnvironmentObject var appState: AppState + @EnvironmentObject var portainer: Portainer + @EnvironmentObject var preferences: Preferences + + @State private var searchQuery: String = "" + + var toolbarMenu: some View { + Menu(content: { + if !portainer.endpoints.isEmpty { + ForEach(portainer.endpoints) { endpoint in + Button(action: { + UIDevice.current.generateHaptic(.light) + portainer.selectedEndpoint = endpoint + }) { + Text(endpoint.displayName) + if portainer.selectedEndpoint?.id == endpoint.id { + Image(systemName: "checkmark") + } + } + } + } else { + Text("No endpoints") + } + + Divider() + + Button(action: { + UIDevice.current.generateHaptic(.light) + appState.fetchingMainScreenData = true + Task { + do { + try await portainer.getEndpoints() + if let endpointID = portainer.selectedEndpoint?.id { + try await portainer.getContainers(endpointID: endpointID) + } + } catch { + AppState.shared.handle(error) + } + } + appState.fetchingMainScreenData = false + }) { + Label("Refresh", systemImage: "arrow.clockwise") + } + }) { + Image(systemName: "tag") + .symbolVariant(portainer.selectedEndpoint != nil ? .fill : (!portainer.endpoints.isEmpty ? .none : .slash)) + } + .disabled(!portainer.isLoggedIn) + } + + @ViewBuilder + var loggedInView: some View { + if portainer.selectedEndpoint != nil { + if !portainer.containers.isEmpty { + Group { + if preferences.useGridView { + ContainerGridView(containers: portainer.containers.filtered(query: searchQuery)) + } else { + ContainerListView(containers: portainer.containers.filtered(query: searchQuery)) + } + } + .searchable(text: $searchQuery) + } else { + Text("No containers") + .opacity(Globals.Views.secondaryOpacity) + } + } else { + Text("Select endpoint") + .opacity(Globals.Views.secondaryOpacity) + } + } + + var body: some View { + NavigationView { + Group { + if portainer.isLoggedIn { + loggedInView + .refreshable { + if let endpointID = portainer.selectedEndpoint?.id { + appState.fetchingMainScreenData = true + + do { + try await portainer.getContainers(endpointID: endpointID) + } catch { + AppState.shared.handle(error) + } + + appState.fetchingMainScreenData = false + } + } + } else { + Text("Not logged in") + .opacity(Globals.Views.secondaryOpacity) + } + } + .navigationTitle("Harbour") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigation) { + Button(action: { + UIDevice.current.generateHaptic(.soft) + appState.isSettingsSheetPresented = true + }) { + Image(systemName: "gear") + } + } + + ToolbarTitle(title: "Harbour", subtitle: appState.fetchingMainScreenData ? "Refreshing..." : nil) + + ToolbarItem(placement: .primaryAction, content: { toolbarMenu }) + } + } + .transition(.opacity) + .animation(.easeInOut, value: portainer.isLoggedIn) + .animation(.easeInOut, value: portainer.selectedEndpoint != nil) + .animation(.easeInOut, value: portainer.containers.count) + /* .onAppear { + if let endpointID = portainer.selectedEndpoint?.id { + await portainer.getContainers(endpointID: endpointID) + } + } */ + } +} + +struct ContentView_Previews: PreviewProvider { + static var previews: some View { + ContentView() + .environmentObject(Portainer.shared) + } +} diff --git a/Harbour/Views/DebugView.swift b/Harbour/Views/DebugView.swift new file mode 100644 index 00000000..d7c9da7f --- /dev/null +++ b/Harbour/Views/DebugView.swift @@ -0,0 +1,110 @@ +// +// DebugView.swift +// Harbour +// +// Created by royal on 19/06/2021. +// + +#if DEBUG + +import SwiftUI +import OSLog +import Indicators + +struct DebugView: View { + var body: some View { + List { + Section("Build info") { + Labeled(label: "Bundle ID", content: Bundle.main.bundleIdentifier, monospace: true) + Labeled(label: "App prefix", content: Bundle.main.appIdentifierPrefix, monospace: true) + } + + Section("UserDefaults") { + Button("Reset finishedSetup") { + UIDevice.current.generateHaptic(.light) + Preferences.shared.finishedSetup = false + } + + Button("Reset all") { + UIDevice.current.generateHaptic(.heavy) + Preferences.Key.allCases.forEach { Preferences.shared.ud.removeObject(forKey: $0.rawValue) } + exit(0) + } + .accentColor(.red) + } + + Section("Indicators") { + Button("Display manual indicator") { + let indicator: Indicators.Indicator = .init(id: "manual", icon: "bolt", headline: "Headline", subheadline: "Subheadline", expandedText: "Expanded text that is really long and should be truncated normally", dismissType: .manual) + UIDevice.current.generateHaptic(.light) + AppState.shared.indicators.display(indicator) + } + + Button("Display automatic indicator") { + let indicator: Indicators.Indicator = .init(id: "automatic", icon: "bolt", headline: "Headline", subheadline: "Subheadline", expandedText: "Expanded text that is really long and should be truncated normally", dismissType: .after(5)) + UIDevice.current.generateHaptic(.light) + AppState.shared.indicators.display(indicator) + } + } + + Section { + NavigationLink(destination: LogsView()) { + Text("Logs") + } + } + } + .navigationTitle("🤫") + } +} + +extension DebugView { + struct LogsView: View { + @State private var logs: [String] = [] + + var body: some View { + List(logs, id: \.self) { entry in + Text(entry) + .lineLimit(nil) + .frame(maxWidth: .infinity, alignment: .topLeading) + .contentShape(Rectangle()) + .textSelection(.enabled) + } + .font(.system(.footnote, design: .monospaced)) + .listStyle(.plain) + .navigationTitle("Logs") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: { + UIDevice.current.generateHaptic(.light) + getLogs() + }) { + Image(systemName: "arrow.clockwise") + } + } + } + .onAppear(perform: getLogs) + } + + func getLogs() { + do { + let logStore = try OSLogStore(scope: .currentProcessIdentifier) + let entries = try logStore.getEntries() + logs = entries + .compactMap { $0 as? OSLogEntryLog } + .filter { $0.subsystem.contains(Bundle.main.bundleIdentifier!) } + .map { "[\($0.level.rawValue)] \($0.date): \($0.category): \($0.composedMessage)" } + } catch { + logs = [String(describing: error)] + } + } + } +} + +struct DebugView_Previews: PreviewProvider { + static var previews: some View { + DebugView() + } +} + +#endif diff --git a/Harbour/Views/Details/Container/ContainerConfigDetailsView.swift b/Harbour/Views/Details/Container/ContainerConfigDetailsView.swift new file mode 100644 index 00000000..838ff4a7 --- /dev/null +++ b/Harbour/Views/Details/Container/ContainerConfigDetailsView.swift @@ -0,0 +1,55 @@ +// +// ContainerConfigDetailsView.swift +// Harbour +// +// Created by royal on 11/06/2021. +// + +import PortainerKit +import SwiftUI + +struct ContainerConfigDetailsView: View { + let config: PortainerKit.ContainerConfig? + let hostConfig: PortainerKit.HostConfig? + + @ViewBuilder + var emptyDisclaimer: some View { + if config == nil && hostConfig == nil { + Text("No config") + .opacity(Globals.Views.secondaryOpacity) + } + } + + var configSection: some View { + Section("Config") { + if let config = config { + Text(String(describing: config)) + } else { + Text("not loaded") + } + } + } + + var hostConfigSection: some View { + Section("Host config") { + if let hostConfig = hostConfig { + Text(String(describing: hostConfig)) + } else { + Text("not loaded") + } + } + } + + var body: some View { + List { + configSection + hostConfigSection + } + .overlay(emptyDisclaimer) + .navigationTitle("Config") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarTitle(title: "Config", subtitle: nil) + } + } +} diff --git a/Harbour/Views/Details/Container/ContainerConsoleView.swift b/Harbour/Views/Details/Container/ContainerConsoleView.swift new file mode 100644 index 00000000..098a0006 --- /dev/null +++ b/Harbour/Views/Details/Container/ContainerConsoleView.swift @@ -0,0 +1,32 @@ +// +// ContainerConsoleView.swift +// Harbour +// +// Created by royal on 13/06/2021. +// + +import Combine +import PortainerKit +import SwiftUI + +struct ContainerConsoleView: View { + @EnvironmentObject var portainer: Portainer + + var body: some View { + if let attachedContainer = portainer.attachedContainer { + ScrollView { + LazyVStack { + Text(attachedContainer.attributedString) + .font(.system(.footnote, design: .monospaced)) + .lineLimit(nil) + .textSelection(.enabled) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + .padding(.small) + } + } else { + Text("how did you get here? ಠ_ಠ") + .foregroundStyle(.secondary) + } + } +} diff --git a/Harbour/Views/Details/Container/ContainerDetailView.swift b/Harbour/Views/Details/Container/ContainerDetailView.swift new file mode 100644 index 00000000..9eddb66d --- /dev/null +++ b/Harbour/Views/Details/Container/ContainerDetailView.swift @@ -0,0 +1,233 @@ +// +// ContainerDetailView.swift +// Harbour +// +// Created by royal on 11/06/2021. +// + +import PortainerKit +import SwiftUI + +struct ContainerDetailView: View { + @EnvironmentObject var portainer: Portainer + @ObservedObject var container: PortainerKit.Container + + @State private var loading: Bool = false + + @State private var lastLogsSnippet: String? = nil + + let lastLogsTailCount: Int = 5 + + var buttonsSection: some View { + LazyVGrid(columns: Array(repeating: .init(.flexible()), count: 2)) { + NavigationLink(destination: ContainerMountsDetailsView(mounts: container.mounts, details: container.details?.mounts)) { + NavigationLinkLabel(label: "Mounts", symbolName: "externaldrive.fill") + } + + NavigationLink(destination: ContainerNetworkDetailsView(networkSettings: container.networkSettings, details: container.details?.networkSettings, ports: container.ports)) { + NavigationLinkLabel(label: "Network", symbolName: "network") + } + + NavigationLink(destination: ContainerConfigDetailsView(config: container.details?.config, hostConfig: container.details?.hostConfig ?? container.hostConfig)) { + NavigationLinkLabel(label: "Config", symbolName: "server.rack") + } + + NavigationLink(destination: ContainerLogsView(container: container)) { + NavigationLinkLabel(label: "Logs", symbolName: "text.alignleft") + } + } + .buttonStyle(DecreasesOnPressButtonStyle()) + } + + @ViewBuilder + var generalSection: some View { + if let details = container.details { + LabeledSection(label: "ID", content: details.id, monospace: true) + LabeledSection(label: "Created", content: details.created.formatted()) + LabeledSection(label: "PID", content: "\(details.state.pid)", monospace: true) + LabeledSection(label: "Status", content: container.status ?? details.state.status.rawValue, monospace: true) + LabeledSection(label: "Error", content: details.state.error, monospace: true) + LabeledSection(label: "Started at", content: details.state.startedAt?.formatted()) + LabeledSection(label: "Finished at", content: details.state.finishedAt?.formatted()) + } else { + ProgressView() + .padding() + .frame(maxWidth: .infinity, alignment: .center) + } + } + + var logsSection: some View { + CustomSection(label: "Logs (last \(lastLogsTailCount) lines)") { + if let logs = lastLogsSnippet { + Text(logs) + .font(.system(.footnote, design: .monospaced)) + .lineLimit(nil) + .contentShape(Rectangle()) + .frame(maxWidth: .infinity, alignment: .topLeading) + .textSelection(.enabled) + } else { + ProgressView() + .padding() + .frame(maxWidth: .infinity, alignment: .center) + } + } + .transition(.opacity) + } + + var body: some View { + ScrollView { + LazyVStack(spacing: 20) { + buttonsSection + generalSection + logsSection + + if let containerDetails = container.details { + GeneralSection(details: containerDetails) + StateSection(state: containerDetails.state) + GraphDriverSection(graphDriver: containerDetails.graphDriver) + } + } + .padding() + } + .background(Color(uiColor: .systemGroupedBackground).edgesIgnoringSafeArea(.all)) + .animation(.easeInOut, value: lastLogsSnippet) + .animation(.easeInOut, value: container.details) + .navigationTitle(container.displayName ?? container.id) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarTitle(title: container.displayName ?? container.id, subtitle: loading ? "Refreshing..." : nil) + + ToolbarItem(placement: .primaryAction) { + Menu(content: { + ContainerContextMenu(container: container) + + Divider() + + Button(action: { + UIDevice.current.generateHaptic(.light) + Task { + await refresh() + } + }) { + Label("Refresh", systemImage: "arrow.clockwise") + } + }) { + Image(systemName: container.state.icon) + .accentColor(container.state.color) + .animation(.easeInOut, value: container.state) + .transition(.opacity) + } + } + } + .refreshable { await refresh() } + .task { await refresh() } + .onReceive(portainer.refreshCurrentContainerPassthroughSubject) { + Task { await refresh() } + } + } + + private func refresh() async { + loading = true + + Task { + do { + let logs = try await portainer.getLogs(from: container, tail: lastLogsTailCount, displayTimestamps: true) + self.lastLogsSnippet = logs.trimmingCharacters(in: .whitespacesAndNewlines) + } catch { + AppState.shared.handle(error) + } + } + + do { + let containerDetails = try await portainer.inspectContainer(container) + withAnimation { + container.update(from: containerDetails) + } + } catch { + AppState.shared.handle(error) + } + + loading = false + } +} + +fileprivate extension ContainerDetailView { + struct DisclosureSection: View where Content: View { + let label: String + @ViewBuilder let content: () -> Content + + var body: some View { + DisclosureGroup(content: { + VStack(spacing: 20, content: content) + .padding(.top, .medium) + }) { + Text(LocalizedStringKey(label)) + } + } + } + + struct GeneralSection: View { + let details: PortainerKit.ContainerDetails + + var body: some View { + DisclosureSection(label: "General") { + Group { + LabeledSection(label: "Name", content: details.name, monospace: true) + LabeledSection(label: "Image", content: details.image, monospace: true) + LabeledSection(label: "Platform", content: details.platform, monospace: true) + LabeledSection(label: "Path", content: details.path, monospace: true) + LabeledSection(label: "Arguments", content: !details.args.isEmpty ? details.args.joined(separator: ", ") : nil, monospace: true) + } + + Group { + LabeledSection(label: "Mount label", content: details.mountLabel, monospace: true) + LabeledSection(label: "Process label", content: details.processLabel, monospace: true) + } + + Group { + LabeledSection(label: "Restart count", content: "\(details.restartCount)", monospace: true) + LabeledSection(label: "Driver", content: details.driver, monospace: true) + LabeledSection(label: "App armor profile", content: details.appArmorProfile, monospace: true) + LabeledSection(label: "RW size", content: details.sizeRW != nil ? "\(details.sizeRW ?? 0)" : nil, monospace: true) + LabeledSection(label: "RootFS size", content: details.sizeRootFS != nil ? "\(details.sizeRootFS ?? 0)" : nil, monospace: true) + } + + Group { + LabeledSection(label: "resolv.conf path", content: details.resolvConfPath, monospace: true) + LabeledSection(label: "Hostname path", content: details.hostnamePath, monospace: true) + LabeledSection(label: "Hosts path", content: details.hostsPath, monospace: true) + LabeledSection(label: "Log path", content: details.logPath, monospace: true) + } + } + } + } + + struct StateSection: View { + let state: PortainerKit.ContainerState + + var body: some View { + DisclosureSection(label: "State") { + LabeledSection(label: "State", content: state.status.rawValue, monospace: true) + LabeledSection(label: "Running", content: "\(state.running)", monospace: true) + LabeledSection(label: "Paused", content: "\(state.paused)", monospace: true) + LabeledSection(label: "Restarting", content: "\(state.restarting)", monospace: true) + LabeledSection(label: "OOM Killed", content: "\(state.oomKilled)", monospace: true) + LabeledSection(label: "Dead", content: "\(state.dead)", monospace: true) + } + } + } + + struct GraphDriverSection: View { + let graphDriver: PortainerKit.GraphDriver + + var body: some View { + DisclosureSection(label: "GraphDriver") { + LabeledSection(label: "Name", content: graphDriver.name, monospace: true) + LabeledSection(label: "Lower dir", content: graphDriver.data.lowerDir, monospace: true) + LabeledSection(label: "Merged dir", content: graphDriver.data.mergedDir, monospace: true) + LabeledSection(label: "Upper dir", content: graphDriver.data.upperDir, monospace: true) + LabeledSection(label: "Work dir", content: graphDriver.data.workDir, monospace: true) + } + } + } +} diff --git a/Harbour/Views/Details/Container/ContainerLogsView.swift b/Harbour/Views/Details/Container/ContainerLogsView.swift new file mode 100644 index 00000000..14958d89 --- /dev/null +++ b/Harbour/Views/Details/Container/ContainerLogsView.swift @@ -0,0 +1,156 @@ +// +// ContainerLogsView.swift +// Harbour +// +// Created by royal on 11/06/2021. +// + +import PortainerKit +import SwiftUI + +struct ContainerLogsView: View { + @EnvironmentObject var portainer: Portainer + let container: PortainerKit.Container + + @State private var loading: Bool = false + @State private var logs: String = "" + + @State private var tail: Int = 100 { + didSet { + Task { await refresh() } + } + } + @State private var since: TimeInterval = 0 { + didSet { + Task { await refresh() } + } + } + @State private var displayTimestamps: Bool = false { + didSet { + Task { await refresh() } + } + } + + let logsLabelID: String = "LogsLabel" + let tailAmounts: [Int] = [10, 100, 500, 1000, 10_000, 100_000, 1_000_000, 10_000_000] + + @ViewBuilder + var emptyDisclaimer: some View { + if logs.isEmpty { + Text("Empty") + .opacity(Globals.Views.secondaryOpacity) + } + } + + var body: some View { + ScrollView { + ScrollViewReader { scroll in + LazyVStack { + Text(logs) + .font(.system(.footnote, design: .monospaced)) + .lineLimit(nil) + .textSelection(.enabled) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .id(logsLabelID) + } + .padding(.small) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Menu(content: { + // Scroll to top + Button(action: { + UIDevice.current.generateHaptic(.soft) + withAnimation { scroll.scrollTo(logsLabelID, anchor: .top) } + }) { + Label("Scroll to top", systemImage: "arrow.up.to.line") + } + + // Scroll to bottom + Button(action: { + UIDevice.current.generateHaptic(.soft) + withAnimation { scroll.scrollTo(logsLabelID, anchor: .bottom) } + }) { + Label("Scroll to bottom", systemImage: "arrow.down.to.line") + } + + Divider() + + // Lines + Menu("Lines") { + ForEach(tailAmounts, id: \.self) { count in + Button(action: { + UIDevice.current.generateHaptic(.light) + tail = count + }) { + Text("\(count)") + if tail == count { + Image(systemName: "checkmark") + } + } + } + } + + // Since + Menu("Since") { + Button("Creation") { + UIDevice.current.generateHaptic(.light) + since = 0 + } + + Button("Now") { + UIDevice.current.generateHaptic(.light) + since = Date().timeIntervalSince1970 + } + } + + // Timestamps + Button(action: { + UIDevice.current.generateHaptic(.light) + displayTimestamps.toggle() + }) { + Text("Timestamps") + if displayTimestamps { + Image(systemName: "checkmark") + } + } + + Divider() + + // Refresh + Button(action: { + UIDevice.current.generateHaptic(.light) + Task { + await refresh() + } + }) { + Label("Refresh", systemImage: "arrow.clockwise") + } + }) { + Image(systemName: "slider.horizontal.3") + } + } + } + } + } + .background(emptyDisclaimer) + .navigationTitle("Logs") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarTitle(title: "Logs", subtitle: loading ? "Refreshing..." : nil) + } + .task { await refresh() } + } + + private func refresh() async { + loading = true + + do { + let logs = try await portainer.getLogs(from: container, since: since, tail: tail, displayTimestamps: displayTimestamps) + self.logs = logs + } catch { + AppState.shared.handle(error) + } + + loading = false + } +} diff --git a/Harbour/Views/Details/Container/ContainerMountsDetailsView.swift b/Harbour/Views/Details/Container/ContainerMountsDetailsView.swift new file mode 100644 index 00000000..f86ac4b3 --- /dev/null +++ b/Harbour/Views/Details/Container/ContainerMountsDetailsView.swift @@ -0,0 +1,134 @@ +// +// ContainerMountsDetailsView.swift +// Harbour +// +// Created by royal on 11/06/2021. +// + +import PortainerKit +import SwiftUI + +struct ContainerMountsDetailsView: View { + let mounts: [PortainerKit.Mount]? + let details: [PortainerKit.MountPoint]? + + @ViewBuilder + var emptyDisclaimer: some View { + if mounts?.isEmpty ?? true && details?.isEmpty ?? true { + Text("No mounts") + .opacity(Globals.Views.secondaryOpacity) + } + } + + @ViewBuilder + var generalSection: some View { + ForEach(mounts?.sorted(by: { ($0.source ?? "", $0.target ?? "") > ($1.source ?? "", $1.target ?? "") }) ?? [], id: \.self) { mount in + Section { + Group { + Labeled(label: "Source", content: mount.source, monospace: true) + Labeled(label: "Target", content: mount.target, monospace: true) + Labeled(label: "Read only?", bool: mount.readOnly) + Labeled(label: "Type", content: mount.type?.rawValue, monospace: true) + Labeled(label: "Consistency", content: mount.consistency?.rawValue, monospace: true) + Labeled(label: "Bind options", content: mount.bindOptions?.propagation?.rawValue, monospace: true) + } + + Group { + VolumeOptionsSection(volumeOptions: mount.volumeOptions) + TmpfsOptionsSection(tmpfsOptions: mount.tmpfsOptions) + } + } + } + } + + @ViewBuilder + var detailSection: some View { + if let details = details { + ForEach(details.sorted(by: { $0.destination > $1.destination }), id: \.self) { mount in + Section(mount.destination) { + Labeled(label: "Name", content: mount.name, monospace: true) + Labeled(label: "Source", content: mount.source, monospace: true) + Labeled(label: "Destination", content: mount.destination, monospace: true) + + Labeled(label: "Driver", content: mount.driver, monospace: true) + Labeled(label: "Mode", content: mount.mode, monospace: true) + Labeled(label: "Type", content: mount.type, monospace: true) + Labeled(label: "Propagation", content: mount.propagation, monospace: true) + Labeled(label: "RW?", bool: mount.rw) + } + } + } + } + + var body: some View { + List { + generalSection + detailSection + } + .overlay(emptyDisclaimer) + .navigationTitle("Mounts") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarTitle(title: "Mounts", subtitle: nil) + } + } +} + +fileprivate extension ContainerMountsDetailsView { + struct VolumeOptionsSection: View { + let volumeOptions: PortainerKit.VolumeOptions? + + var body: some View { + if let volumeOptions = volumeOptions { + DisclosureGroup("Volume options") { + Labeled(label: "No copy?", bool: volumeOptions.noCopy) + + if let labels = volumeOptions.labels, !labels.isEmpty { + DisclosureGroup("Labels") { + ForEach(labels.sorted(by: >), id: \.key) { key, value in + Labeled(label: key, content: value, monospace: true) + } + } + } else { + Labeled(label: "Labels", content: nil, monospace: true) + } + + if let driverConfig = volumeOptions.driverConfig { + DisclosureGroup("Driver config") { + Labeled(label: "Name", content: driverConfig.name, monospace: true) + + if let options = driverConfig.options, !options.isEmpty { + DisclosureGroup("Options") { + ForEach(options.sorted(by: >), id: \.key) { key, value in + Labeled(label: key, content: value, monospace: true) + } + } + } else { + Labeled(label: "Options", content: nil, monospace: true) + } + } + } else { + Labeled(label: "Driver config", content: nil, monospace: true) + } + } + } else { + Labeled(label: "Volume options", content: nil, monospace: true) + } + } + } + + struct TmpfsOptionsSection: View { + let tmpfsOptions: PortainerKit.TmpfsOptions? + + var body: some View { + if let tmpfsOptions = tmpfsOptions { + DisclosureGroup("tmpfs options") { + Labeled(label: "Mode", content: tmpfsOptions.mode != nil ? "\(tmpfsOptions.mode ?? 0)" : nil, monospace: true) + Labeled(label: "Size (B)", content: tmpfsOptions.sizeBytes != nil ? "\(tmpfsOptions.sizeBytes ?? 0)" : nil, monospace: true) + } + } else { + Labeled(label: "tmpfs options", content: nil, monospace: true) + } + } + } +} diff --git a/Harbour/Views/Details/Container/ContainerNetworkDetailsView.swift b/Harbour/Views/Details/Container/ContainerNetworkDetailsView.swift new file mode 100644 index 00000000..0194837d --- /dev/null +++ b/Harbour/Views/Details/Container/ContainerNetworkDetailsView.swift @@ -0,0 +1,101 @@ +// +// ContainerNetworkDetailsView.swift +// Harbour +// +// Created by royal on 11/06/2021. +// + +import PortainerKit +import SwiftUI + +struct ContainerNetworkDetailsView: View { + let networkSettings: PortainerKit.Container.NetworkSettings? + let details: PortainerKit.ContainerDetails.NetworkSettings? + let ports: [PortainerKit.Port]? + + @ViewBuilder + var emptyDisclaimer: some View { + if networkSettings == nil && details == nil && ports?.isEmpty ?? true { + Text("No network details") + .opacity(Globals.Views.secondaryOpacity) + } + } + + @ViewBuilder + var networkSection: some View { + Section { + if let network = details { + Labeled(label: "Address", content: network.address, monospace: true) + Labeled(label: "Port mapping", content: network.portMapping, monospace: true) + Labeled(label: "Bridge", content: network.bridge, monospace: true) + Labeled(label: "Gateway", content: network.gateway, monospace: true) + Labeled(label: "Mac address", content: network.macAddress, monospace: true) + Labeled(label: "IP prefix len.", content: "\(network.ipPrefixLen)", monospace: true) + // Labeled(label: "Ports", content: String(describing: network.ports), monospace: true) + } + + /* if let network = container.networkSettings?.network { + Labeled(label: "Links", content: network.links?.joined(separator: ", "), monospace: true) + Labeled(label: "Aliases", content: network.aliases?.joined(separator: ", "), monospace: true) + Labeled(label: "Network ID", content: network.networkID, monospace: true) + Labeled(label: "Endpoint ID", content: network.endpointID, monospace: true) + Labeled(label: "Gateway", content: network.gateway, monospace: true) + Labeled(label: "IP Address", content: network.ipAddress?.description, monospace: true) + Labeled(label: "IP prefix len.", content: network.ipPrefixLen?.description, monospace: true) + Labeled(label: "IPv6 Gateway", content: network.ipv6Gateway, monospace: true) + Labeled(label: "Global IPv6 Address", content: network.globalIPv6Address, monospace: true) + Labeled(label: "Global IPv6 prefix len.", content: network.globalIPv6PrefixLen?.description, monospace: true) + Labeled(label: "Mac address", content: network.macAddress, monospace: true) + } */ + } + } + + @ViewBuilder + var portsSection: some View { + if let ports = ports, !ports.isEmpty { + ForEach(ports, id: \.self) { port in + PortSection(port: port) + } + } + } + + var body: some View { + List { + networkSection + portsSection + } + .overlay(emptyDisclaimer) + .navigationTitle("Network") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarTitle(title: "Network", subtitle: nil) + } + } +} + +private extension ContainerNetworkDetailsView { + struct PortSection: View { + let port: PortainerKit.Port + let label: String? + + public init(port: PortainerKit.Port) { + self.port = port + + if let privatePort = port.privatePort, + let type = port.type { + label = "\(privatePort)/\(type.rawValue)" + } else { + label = nil + } + } + + var body: some View { + Section(header: label != nil ? Text(label ?? "") : nil) { + Labeled(label: "IP", content: port.ip != nil ? "\(port.ip ?? "")" : nil, monospace: true) + Labeled(label: "Private port", content: port.privatePort != nil ? "\(port.privatePort ?? 0)" : nil, monospace: true) + Labeled(label: "Public port", content: port.publicPort != nil ? "\(port.publicPort ?? 0)" : nil, monospace: true) + Labeled(label: "Type", content: port.type != nil ? "\(port.type?.rawValue ?? "")" : nil, monospace: true) + } + } + } +} diff --git a/Harbour/Views/LoginView.swift b/Harbour/Views/LoginView.swift new file mode 100644 index 00000000..a478f96b --- /dev/null +++ b/Harbour/Views/LoginView.swift @@ -0,0 +1,212 @@ +// +// LoginView.swift +// Harbour +// +// Created by royal on 11/06/2021. +// + +import SwiftUI +import PortainerKit + +struct LoginView: View { + @Environment(\.presentationMode) var presentationMode + @EnvironmentObject var portainer: Portainer + + @State private var endpoint: String = Preferences.shared.endpointURL ?? "" + @State private var username: String = "" + @State private var password: String = "" + + @State private var savePassword: Bool = false + + @FocusState private var focusedField: FocusField? + // @State private var showLoginHelpMessage: Bool = false + @State private var loading: Bool = false + + @State private var buttonLabel: String? = nil + @State private var buttonColor: Color? = nil + + @State private var errorTimer: Timer? = nil + + var body: some View { + VStack { + Spacer() + + Text("Log in") + .font(.largeTitle.bold()) + + Spacer() + + VStack { + TextField("http://172.17.0.2", text: $endpoint, onCommit: { + guard !endpoint.isReallyEmpty else { return } + + if !endpoint.starts(with: "http") { + UIDevice.current.generateHaptic(.selectionChanged) + endpoint = "http://\(endpoint)" + } + + focusedField = .username + }) + .keyboardType(.URL) + .disableAutocorrection(true) + .autocapitalization(.none) + .textFieldStyle(RoundedTextFieldStyle(fontDesign: .monospaced)) + .focused($focusedField, equals: .endpoint) + + TextField("garyhost", text: $username, onCommit: { + guard !username.isEmpty else { return } + + UIDevice.current.generateHaptic(.selectionChanged) + focusedField = .password + }) + .keyboardType(.default) + .disableAutocorrection(true) + .autocapitalization(.none) + .textFieldStyle(RoundedTextFieldStyle(fontDesign: .monospaced)) + .focused($focusedField, equals: .username) + + SecureField("hunter2", text: $password, onCommit: { + guard !(loading || endpoint.isReallyEmpty || username.isEmpty || password.isEmpty) else { return } + + UIDevice.current.generateHaptic(.light) + login() + }) + .keyboardType(.default) + .disableAutocorrection(true) + .autocapitalization(.none) + .textFieldStyle(RoundedTextFieldStyle(fontDesign: .monospaced)) + .focused($focusedField, equals: .password) + } + + Spacer() + + VStack { + Button(action: { + UIDevice.current.generateHaptic(.light) + login() + }) { + if loading { + ProgressView() + } else { + Group { + if let buttonLabel = buttonLabel { + Text(NSLocalizedString(buttonLabel, comment: "").capitalizingFirstLetter()) + } else { + Text("Log in") + } + } + .transition(.opacity) + } + } + .keyboardShortcut(.defaultAction) + .foregroundColor(.white) + .buttonStyle(PrimaryButtonStyle(backgroundColor: buttonColor ?? .accentColor)) + .animation(.easeInOut, value: loading || endpoint.isReallyEmpty || username.isEmpty || password.isEmpty) + .animation(.easeInOut, value: buttonColor) + .disabled(loading || endpoint.isReallyEmpty || username.isEmpty || password.isEmpty) + + Button(action: { + UIDevice.current.generateHaptic(.selectionChanged) + savePassword.toggle() + }) { + HStack { + Image(systemName: savePassword ? "checkmark" : "circle.dashed") + .symbolVariant(savePassword ? .circle.fill : .none) + .id("SavePasswordIcon:\(savePassword)") + + Text("Save password") + } + .font(.callout.weight(.semibold)) + .opacity(savePassword ? 1 : Globals.Views.secondaryOpacity) + } + .buttonStyle(TransparentButtonStyle()) + .animation(.easeInOut, value: savePassword) + } + + /* if showLoginHelpMessage { + Link(destination: URL(string: "https://harbour.shameful.xyz/docs/setup")!) { + HStack { + Image(systemName: "globe") + Text("Trouble logging in?") + } + .font(.callout.weight(.semibold)) + .opacity(Globals.Views.secondaryOpacity) + } + .buttonStyle(TransparentButtonStyle()) + .frame(maxWidth: .infinity, alignment: .topTrailing) + } */ + } + .padding() + .animation(.easeInOut, value: buttonLabel) + // .animation(.easeInOut, value: showLoginHelpMessage) + // .onAppear(perform: setupLoginHelpMessageTimer) + .onDisappear { + errorTimer?.invalidate() + } + } + + func login() { + guard let url = URL(string: endpoint) else { + UIDevice.current.generateHaptic(.error) + buttonLabel = "Invalid URL" + buttonColor = .red + return + } + + focusedField = nil + + Task { + do { + loading = true + try await portainer.login(url: url, username: username, password: password, savePassword: savePassword) + + UIDevice.current.generateHaptic(.success) + + loading = false + buttonColor = .green + buttonLabel = "Success!" + presentationMode.wrappedValue.dismiss() + + do { + try await portainer.getEndpoints() + } catch { + AppState.shared.handle(error) + } + } catch { + UIDevice.current.generateHaptic(.error) + + loading = false + buttonColor = .red + if let error = error as? PortainerKit.APIError { + buttonLabel = error.description + } else { + buttonLabel = error.localizedDescription + } + + errorTimer?.invalidate() + errorTimer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { _ in + buttonLabel = nil + buttonColor = nil + } + } + } + } + + /* func setupLoginHelpMessageTimer() { + _ = Timer.scheduledTimer(withTimeInterval: 5, repeats: false) { _ in + self.showLoginHelpMessage = true + } + } */ +} + +extension LoginView { + enum FocusField { + case endpoint, username, password + } +} + +struct LoginView_Previews: PreviewProvider { + static var previews: some View { + LoginView() + } +} diff --git a/Harbour/Views/Settings/SettingsView+Components.swift b/Harbour/Views/Settings/SettingsView+Components.swift new file mode 100644 index 00000000..3ba8dbe0 --- /dev/null +++ b/Harbour/Views/Settings/SettingsView+Components.swift @@ -0,0 +1,75 @@ +// +// SettingsView+Components.swift +// Harbour +// +// Created by royal on 18/08/2021. +// + +import SwiftUI + +extension SettingsView { + fileprivate static let vstackSpacing: Double = 4 + + struct SliderOption: View { + @Environment(\.isEnabled) var isEnabled: Bool + let label: String + let description: String? + @Binding var value: Double + let range: ClosedRange + let step: Double + let onEditingChanged: (Bool) -> Void + + var body: some View { + VStack(spacing: vstackSpacing) { + HStack { + Text(LocalizedStringKey(label)) + .font(.headline) + + Spacer() + + if let description = description { + Text(LocalizedStringKey(description)) + .font(.body) + .foregroundStyle(.secondary) + } + } + .opacity(isEnabled ? 1 : Globals.Views.secondaryOpacity) + + Slider(value: $value, in: range, step: step, onEditingChanged: onEditingChanged) + .onChange(of: value) { + if $0 > range.lowerBound && $0 < range.upperBound { + UIDevice.current.generateHaptic(.selectionChanged) + } + } + } + .padding(.vertical, .small) + } + } + + struct ToggleOption: View { + @Environment(\.isEnabled) var isEnabled: Bool + let label: String + let description: String? + @Binding var isOn: Bool + + var body: some View { + Toggle(isOn: $isOn) { + VStack(alignment: .leading, spacing: vstackSpacing) { + Text(LocalizedStringKey(label)) + .font(.headline) + .frame(maxWidth: .infinity, alignment: .leading) + + if let description = description { + Text(LocalizedStringKey(description)) + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(.vertical, .small) + .opacity(isEnabled ? 1 : Globals.Views.secondaryOpacity) + } + .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + } + } +} diff --git a/Harbour/Views/Settings/SettingsView+InterfaceSection.swift b/Harbour/Views/Settings/SettingsView+InterfaceSection.swift new file mode 100644 index 00000000..d29556a5 --- /dev/null +++ b/Harbour/Views/Settings/SettingsView+InterfaceSection.swift @@ -0,0 +1,31 @@ +// +// SettingsView+InterfaceSection.swift +// Harbour +// +// Created by royal on 18/08/2021. +// + +import SwiftUI + +extension SettingsView { + struct InterfaceSection: View { + @EnvironmentObject var preferences: Preferences + + var body: some View { + Section("Interface") { + /// Enable haptics + ToggleOption(label: Localization.SETTINGS_ENABLE_HAPTICS_TITLE.localizedString, description: Localization.SETTINGS_ENABLE_HAPTICS_DESCRIPTION.localizedString, isOn: $preferences.enableHaptics) + + /// Use Grid View + ToggleOption(label: Localization.SETTINGS_USE_GRID_VIEW_TITLE.localizedString, description: Localization.SETTINGS_USE_GRID_VIEW_DESCRIPTION.localizedString, isOn: $preferences.useGridView) + + /// Persist attached container + ToggleOption(label: Localization.SETTINGS_PERSIST_ATTACHED_CONTAINER_TITLE.localizedString, description: Localization.SETTINGS_PERSIST_ATTACHED_CONTAINER_DESCRIPTION.localizedString, isOn: $preferences.persistAttachedContainer) + + /// Display "Container dismissed" prompt + ToggleOption(label: Localization.SETTINGS_CONTAINER_DISCONNECTED_PROMPT_TITLE.localizedString, description: Localization.SETTINGS_CONTAINER_DISCONNECTED_PROMPT_DESCRIPTION.localizedString, isOn: $preferences.displayContainerDismissedPrompt) + .disabled(!preferences.persistAttachedContainer) + } + } + } +} diff --git a/Harbour/Views/Settings/SettingsView+OtherSection.swift b/Harbour/Views/Settings/SettingsView+OtherSection.swift new file mode 100644 index 00000000..f55f701f --- /dev/null +++ b/Harbour/Views/Settings/SettingsView+OtherSection.swift @@ -0,0 +1,73 @@ +// +// SettingsView+OtherSection.swift +// Harbour +// +// Created by royal on 18/08/2021. +// + +import SwiftUI + +extension SettingsView { + struct OtherSection: View { + var madeWithLove: some View { + Link(destination: URL(string: "https://github.com/rrroyal/Harbour")!) { + VStack(spacing: 5) { + Text(Localization.SETTINGS_FOOTER.localizedString) + Text("Harbour v\(Bundle.main.buildVersion) (#\(Bundle.main.buildNumber))") + } + .font(.subheadline.weight(.semibold)) + .foregroundColor(.secondary) + .opacity(Globals.Views.secondaryOpacity) + } + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top) + } + + var body: some View { + Section(header: Text("Other"), footer: madeWithLove) { + NavigationLink("Libraries") { + LibrariesView() + } + + #if DEBUG + NavigationLink("🤫") { + DebugView() + } + #endif + + Link(destination: URL(string: "https://harbour.shameful.xyz/docs")!) { + HStack { + Text("Docs") + Spacer() + Image(systemName: "globe") + } + } + .accentColor(.primary) + } + } + } +} + +extension SettingsView.OtherSection { + struct LibrariesView: View { + typealias Library = (url: URL, label: String) + + let libraries: [Library] = [ + (URL(string: "https://github.com/kishikawakatsumi/KeychainAccess")!, "kishikawakatsumi/KeychainAccess") + ] + + var body: some View { + List(libraries, id: \.url) { library in + Link(destination: library.url) { + HStack { + Text(library.label) + Spacer() + Image(systemName: "globe") + } + } + .accentColor(.primary) + } + .navigationTitle("Libraries") + } + } +} diff --git a/Harbour/Views/Settings/SettingsView+PortainerSection.swift b/Harbour/Views/Settings/SettingsView+PortainerSection.swift new file mode 100644 index 00000000..d47245d9 --- /dev/null +++ b/Harbour/Views/Settings/SettingsView+PortainerSection.swift @@ -0,0 +1,86 @@ +// +// SettingsView+PortainerSection.swift +// Harbour +// +// Created by royal on 18/08/2021. +// + +import SwiftUI + +extension SettingsView { + struct PortainerSection: View { + @EnvironmentObject var portainer: Portainer + @EnvironmentObject var preferences: Preferences + + @State private var isLoginSheetPresented: Bool = false + @State private var isLogoutWarningPresented: Bool = false + + var autoRefreshIntervalDescription: String { + guard preferences.autoRefreshInterval > 0 else { + return "Off" + } + + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.second] + formatter.unitsStyle = .full + + return formatter.string(from: preferences.autoRefreshInterval) ?? "\(preferences.autoRefreshInterval) second(s)" + } + + @ViewBuilder + var loggedInView: some View { + /// Auto-refresh interval + SliderOption(label: Localization.SETTINGS_AUTO_REFRESH_TITLE.localizedString, description: autoRefreshIntervalDescription, value: $preferences.autoRefreshInterval, range: 0...60, step: 1, onEditingChanged: setupAutoRefreshTimer) + .disabled(!Portainer.shared.isLoggedIn) + + Button("Log out", role: .destructive) { + UIDevice.current.generateHaptic(.warning) + isLogoutWarningPresented = true + } + .alert(isPresented: $isLogoutWarningPresented) { + Alert(title: Text("Are you sure?"), + primaryButton: .destructive(Text("Yes"), action: { + UIDevice.current.generateHaptic(.heavy) + portainer.logOut() + }), + secondaryButton: .cancel() + ) + } + } + + var notLoggedInView: some View { + Button("Log in") { + UIDevice.current.generateHaptic(.soft) + isLoginSheetPresented = true + } + } + + var body: some View { + Section("Portainer") { + /// Endpoint URL + if let endpointURL = Preferences.shared.endpointURL { + Labeled(label: "URL", content: endpointURL, monospace: true, lineLimit: 1) + } + + /// Logged in/not logged in label + if portainer.isLoggedIn { + loggedInView + } else { + notLoggedInView + } + } + .animation(.easeInOut, value: portainer.isLoggedIn) + .animation(.easeInOut, value: Preferences.shared.endpointURL) + .transition(.opacity) + .sheet(isPresented: $isLoginSheetPresented) { + LoginView() + } + } + + private func setupAutoRefreshTimer(isEditing: Bool) { + guard !isEditing else { return } + + AppState.shared.setupAutoRefreshTimer(interval: preferences.autoRefreshInterval) + } + } +} diff --git a/Harbour/Views/Settings/SettingsView.swift b/Harbour/Views/Settings/SettingsView.swift new file mode 100644 index 00000000..d41868cd --- /dev/null +++ b/Harbour/Views/Settings/SettingsView.swift @@ -0,0 +1,30 @@ +// +// SettingsView.swift +// Harbour +// +// Created by royal on 11/06/2021. +// + +import SwiftUI + +struct SettingsView: View { + @EnvironmentObject var portainer: Portainer + @EnvironmentObject var preferences: Preferences + + var body: some View { + NavigationView { + List { + PortainerSection() + InterfaceSection() + OtherSection() + } + .navigationTitle("Settings") + } + } +} + +struct SettingsView_Previews: PreviewProvider { + static var previews: some View { + SettingsView() + } +} diff --git a/Harbour/Views/SetupView.swift b/Harbour/Views/SetupView.swift new file mode 100644 index 00000000..ee7e55b8 --- /dev/null +++ b/Harbour/Views/SetupView.swift @@ -0,0 +1,100 @@ +// +// SetupView.swift +// Harbour +// +// Created by royal on 19/06/2021. +// + +import SwiftUI + +struct SetupView: View { + @State private var selection: Int = 0 + + var body: some View { + TabView(selection: $selection) { + WelcomeView(selection: $selection) + .tag(0) + + if !Portainer.shared.isLoggedIn { + LoginView() + .environmentObject(Portainer.shared) + .tag(1) + } + } + .tabViewStyle(.page(indexDisplayMode: .never)) + } +} + +fileprivate struct WelcomeView: View { + @Environment(\.presentationMode) var presentationMode + @Binding var selection: Int + + var body: some View { + VStack { + Spacer() + + Text("Hi! Welcome to \(Text("Harbour").foregroundColor(.accentColor))!") + .font(.largeTitle.bold()) + .multilineTextAlignment(.center) + + Spacer() + + VStack(spacing: 20) { + FeatureCell(image: "power", headline: Localization.SETUP_FEATURE1_TITLE.localizedString, description: Localization.SETUP_FEATURE1_DESCRIPTION.localizedString) + FeatureCell(image: "doc.plaintext", headline: Localization.SETUP_FEATURE2_TITLE.localizedString, description: Localization.SETUP_FEATURE2_DESCRIPTION.localizedString) + FeatureCell(image: "terminal", headline: Localization.SETUP_FEATURE3_TITLE.localizedString, description: Localization.SETUP_FEATURE3_DESCRIPTION.localizedString) + } + + Spacer() + + Button("Beam me up, Scotty!") { + UIDevice.current.generateHaptic(.soft) + if Portainer.shared.isLoggedIn { + presentationMode.wrappedValue.dismiss() + } else { + withAnimation { selection = 1 } + } + } + .buttonStyle(PrimaryButtonStyle()) + } + .padding() + } +} + +fileprivate extension WelcomeView { + struct FeatureCell: View { + let image: String + let headline: String + let description: String + + let imageWidth: Double = 60 + + var body: some View { + HStack(spacing: 10) { + Image(systemName: image) + .font(.title.weight(.semibold)) + .foregroundStyle(Color.accentColor) + .symbolVariant(.fill) + .symbolRenderingMode(.hierarchical) + .frame(width: imageWidth) + + VStack(alignment: .leading, spacing: 2) { + Text(LocalizedStringKey(headline)) + .font(.headline) + .frame(maxWidth: .infinity, alignment: .leading) + + Text(LocalizedStringKey(description)) + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + } +} + +struct SetupView_Previews: PreviewProvider { + static var previews: some View { + SetupView() + } +} diff --git a/Harbour/Views/Styles/Button/DecreasesOnPressButtonStyle.swift b/Harbour/Views/Styles/Button/DecreasesOnPressButtonStyle.swift new file mode 100644 index 00000000..61037baf --- /dev/null +++ b/Harbour/Views/Styles/Button/DecreasesOnPressButtonStyle.swift @@ -0,0 +1,19 @@ +// +// DecreasesOnPressButtonStyle.swift +// Harbour +// +// Created by royal on 11/06/2021. +// + +import SwiftUI + +struct DecreasesOnPressButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .multilineTextAlignment(.center) + // .compositingGroup() + .opacity(configuration.isPressed ? Globals.Buttons.pressedOpacity : 1) + .scaleEffect(configuration.isPressed ? Globals.Buttons.pressedSize : 1) + .animation(Globals.Views.springAnimation, value: configuration.isPressed) + } +} diff --git a/Harbour/Views/Styles/Button/PrimaryButtonStyle.swift b/Harbour/Views/Styles/Button/PrimaryButtonStyle.swift new file mode 100644 index 00000000..23903572 --- /dev/null +++ b/Harbour/Views/Styles/Button/PrimaryButtonStyle.swift @@ -0,0 +1,37 @@ +// +// PrimaryButtonStyle.swift +// Harbour +// +// Created by royal on 11/06/2021. +// + +import SwiftUI + +struct PrimaryButtonStyle: ButtonStyle { + @Environment(\.isEnabled) var isEnabled: Bool + let foregroundColor: Color + let backgroundColor: Color + let font: Font + + public init(foregroundColor: Color = .white, backgroundColor: Color = .accentColor, font: Font = .body.weight(.semibold)) { + self.foregroundColor = foregroundColor + self.backgroundColor = backgroundColor + self.font = font + } + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .multilineTextAlignment(.center) + .foregroundColor(isEnabled ? foregroundColor : .secondary) + .font(font) + .padding() + .frame(maxWidth: Globals.Views.maxButtonWidth, alignment: .center) + .background(isEnabled ? backgroundColor : Color(uiColor: .systemGray5)) + .cornerRadius(Globals.Views.cornerRadius) + // .compositingGroup() + .opacity(configuration.isPressed ? Globals.Buttons.pressedOpacity : 1) + .scaleEffect(configuration.isPressed ? Globals.Buttons.pressedSize : 1) + .animation(Globals.Views.springAnimation, value: configuration.isPressed) + .animation(.easeInOut, value: isEnabled) + } +} diff --git a/Harbour/Views/Styles/Button/TransparentButtonStyle.swift b/Harbour/Views/Styles/Button/TransparentButtonStyle.swift new file mode 100644 index 00000000..f61f8357 --- /dev/null +++ b/Harbour/Views/Styles/Button/TransparentButtonStyle.swift @@ -0,0 +1,21 @@ +// +// TransparentButtonStyle.swift +// Harbour +// +// Created by royal on 02/10/2021. +// + +import SwiftUI + +struct TransparentButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .multilineTextAlignment(.center) + .padding() + .background(Color(uiColor: .systemGray5).opacity(configuration.isPressed ? Globals.Views.secondaryOpacity : 0)) + .cornerRadius(Globals.Views.cornerRadius) + .opacity(configuration.isPressed ? Globals.Buttons.pressedOpacity : 1) + .scaleEffect(configuration.isPressed ? Globals.Buttons.pressedSize : 1) + .animation(Globals.Views.springAnimation, value: configuration.isPressed) + } +} diff --git a/Harbour/Views/Styles/TextField/RoundedTextFieldStyle.swift b/Harbour/Views/Styles/TextField/RoundedTextFieldStyle.swift new file mode 100644 index 00000000..1b2c5053 --- /dev/null +++ b/Harbour/Views/Styles/TextField/RoundedTextFieldStyle.swift @@ -0,0 +1,27 @@ +// +// RoundedTextFieldStyle.swift +// Harbour +// +// Created by royal on 12/06/2021. +// + +import SwiftUI + +struct RoundedTextFieldStyle: TextFieldStyle { + let fontDesign: Font.Design + + init(fontDesign: Font.Design = .default) { + self.fontDesign = fontDesign + } + + func _body(configuration: TextField) -> some View { + configuration + .font(.system(.callout, design: fontDesign).weight(.regular)) + .multilineTextAlignment(.center) + .padding(.medium) + .background( + RoundedRectangle(cornerRadius: Globals.Views.cornerRadius, style: .continuous) + .fill(Color(uiColor: .secondarySystemBackground)) + ) + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..e72bfdda --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. \ No newline at end of file diff --git a/Modules/Indicators/.gitignore b/Modules/Indicators/.gitignore new file mode 100644 index 00000000..bb460e7b --- /dev/null +++ b/Modules/Indicators/.gitignore @@ -0,0 +1,7 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata diff --git a/Modules/Indicators/Package.swift b/Modules/Indicators/Package.swift new file mode 100644 index 00000000..fd7c1039 --- /dev/null +++ b/Modules/Indicators/Package.swift @@ -0,0 +1,18 @@ +// swift-tools-version:5.5 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "Indicators", + platforms: [ + .iOS(.v15) + ], + products: [ + .library(name: "Indicators", targets: ["Indicators"]) + ], + dependencies: [], + targets: [ + .target(name: "Indicators", dependencies: []) + ] +) diff --git a/Modules/Indicators/Sources/Indicators/Indicators+Indicator.swift b/Modules/Indicators/Sources/Indicators/Indicators+Indicator.swift new file mode 100644 index 00000000..6ce37e36 --- /dev/null +++ b/Modules/Indicators/Sources/Indicators/Indicators+Indicator.swift @@ -0,0 +1,88 @@ +import Foundation +import SwiftUI + +public extension Indicators { + struct Indicator: Identifiable, Hashable { + public let id: String + + public let icon: String? + public let headline: String + public let subheadline: String? + public let expandedText: String? + public let dismissType: DismissType + public let style: Style + public let onTap: (() -> Void)? + + public init(id: String, + icon: String? = nil, + headline: String, + subheadline: String? = nil, + expandedText: String? = nil, + dismissType: DismissType, + style: Style = .default, + onTap: (() -> Void)? = nil + ) { + self.id = id + self.icon = icon + self.headline = headline + self.subheadline = subheadline + self.expandedText = expandedText + self.dismissType = dismissType + self.style = style + self.onTap = onTap + } + + // MARK: Identifiable + + public static func == (lhs: Indicators.Indicator, rhs: Indicators.Indicator) -> Bool { + lhs.id == rhs.id && + lhs.headline == rhs.headline && + lhs.subheadline == rhs.subheadline && + lhs.icon == rhs.icon + } + + // MARK: - Hashable + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + } +} + +public extension Indicators.Indicator { + enum DismissType { + case manual + case after(TimeInterval) + } + + struct Style { + public var headlineColor: Color? + public var headlineStyle: HierarchicalShapeStyle + + public var subheadlineColor: Color? + public var subheadlineStyle: HierarchicalShapeStyle + + public var iconColor: Color? + public var iconStyle: HierarchicalShapeStyle + public var iconVariants: SymbolVariants + + public init(headlineColor: Color? = nil, + headlineStyle: HierarchicalShapeStyle = .primary, + subheadlineColor: Color? = nil, + subheadlineStyle: HierarchicalShapeStyle = .primary, + iconColor: Color? = nil, + iconStyle: HierarchicalShapeStyle = .primary, + iconVariants: SymbolVariants = .none + ) { + self.headlineColor = headlineColor + self.headlineStyle = headlineStyle + self.subheadlineColor = subheadlineColor + self.subheadlineStyle = subheadlineStyle + self.iconColor = iconColor + self.iconStyle = iconStyle + self.iconVariants = iconVariants + } + + public static let `default` = Style(headlineStyle: .primary, subheadlineStyle: .secondary, iconStyle: .secondary, iconVariants: .fill) + } +} diff --git a/Modules/Indicators/Sources/Indicators/Indicators+IndicatorView.swift b/Modules/Indicators/Sources/Indicators/Indicators+IndicatorView.swift new file mode 100644 index 00000000..2fade081 --- /dev/null +++ b/Modules/Indicators/Sources/Indicators/Indicators+IndicatorView.swift @@ -0,0 +1,107 @@ +import SwiftUI + +internal extension Indicators { + struct IndicatorView: View { + let indicator: Indicator + @Binding var isExpanded: Bool + + let maxWidth: Double = 300 + let padding: Double = 10 + let backgroundShape: some Shape = RoundedRectangle(cornerRadius: 32, style: .circular) + + var subheadline: String? { + guard let subheadline = indicator.subheadline else { + return nil + } + + if let expandedText = indicator.expandedText { + return isExpanded ? expandedText : subheadline + } else { + return subheadline + } + } + + var body: some View { + HStack { + if let icon = indicator.icon { + Image(systemName: icon) + .font(indicator.subheadline != nil ? .title3 : .footnote) + .symbolVariant(indicator.style.iconVariants) + .foregroundStyle(indicator.style.iconStyle) + .foregroundColor(indicator.style.iconColor) + .animation(.easeInOut, value: indicator.style.iconColor) + .transition(.opacity) + } + + VStack { + Text(indicator.headline) + .font(.footnote) + .fontWeight(.medium) + .lineLimit(1) + .foregroundStyle(indicator.style.headlineStyle) + .foregroundColor(indicator.style.headlineColor) + .animation(.easeInOut, value: indicator.style.headlineColor) + + if let subheadline = subheadline { + Text(subheadline) + .font(.footnote) + .fontWeight(.medium) + .lineLimit(isExpanded ? nil : 1) + .foregroundStyle(indicator.style.subheadlineStyle) + .foregroundColor(indicator.style.subheadlineColor) + .animation(.easeInOut, value: indicator.style.subheadlineColor) + } + } + .padding(.trailing, indicator.icon != nil ? padding : 0) + .padding(.horizontal, indicator.subheadline != nil ? padding : 0) + .multilineTextAlignment(.center) + .minimumScaleFactor(0.8) + .transition(.opacity) + } + .padding(padding) + .padding(.horizontal, padding) + // .background(Material.regular, in: backgroundShape) + .background(backgroundShape.fill(Color(uiColor: .secondarySystemGroupedBackground)).shadow(color: Color.black.opacity(0.1), radius: 14, x: 0, y: 0)) + .frame(maxWidth: isExpanded ? nil : maxWidth) + .animation(.easeInOut, value: indicator.icon) + .animation(.easeInOut, value: indicator.headline) + // .animation(.easeInOut, value: subheadline) + // .animation(.easeInOut, value: isExpanded) + .optionalTapGesture(indicator.onTap) + } + } +} + +private extension View { + @ViewBuilder + func optionalTapGesture(_ action: (() -> Void)?) -> some View { + if let action = action { + onTapGesture(perform: action) + } else { + self + } + } +} + + +struct IndicatorView_Previews: PreviewProvider { + static let isExpanded: Binding = .constant(false) + + static var previews: some View { + Group { + Indicators.IndicatorView(indicator: .init(id: "", icon: nil, headline: "Headline", dismissType: .manual), isExpanded: isExpanded) + + Indicators.IndicatorView(indicator: .init(id: "", icon: "bolt.fill", headline: "Headline", dismissType: .manual), isExpanded: isExpanded) + + Indicators.IndicatorView(indicator: .init(id: "", headline: "Headline", subheadline: "Subheadline", dismissType: .manual), isExpanded: isExpanded) + + Indicators.IndicatorView(indicator: .init(id: "", icon: "bolt.fill", headline: "Headline", subheadline: "Subheadline", dismissType: .manual), isExpanded: isExpanded) + + Indicators.IndicatorView(indicator: .init(id: "", icon: "bolt.fill", headline: "Headline", subheadline: "Subheadline", dismissType: .manual, style: .init(subheadlineColor: .red, iconColor: .red)), isExpanded: isExpanded) + } + .padding() + .background(Color(uiColor: .systemBackground)) + .previewLayout(.sizeThatFits) + .environment(\.colorScheme, .light) + } +} diff --git a/Modules/Indicators/Sources/Indicators/Indicators+IndicatorsOverlay.swift b/Modules/Indicators/Sources/Indicators/Indicators+IndicatorsOverlay.swift new file mode 100644 index 00000000..d2a8d027 --- /dev/null +++ b/Modules/Indicators/Sources/Indicators/Indicators+IndicatorsOverlay.swift @@ -0,0 +1,70 @@ +import SwiftUI + +internal extension Indicators { + struct IndicatorsOverlay: View { + @ObservedObject var model: Indicators + + @State var isExpanded: Bool = false + @State var dragOffset: CGSize = .zero + + let dragInWrongDirectionMultiplier: CGFloat = 0.015 + let dragThreshold: CGFloat = 30 + let transition: AnyTransition = .asymmetric(insertion: .move(edge: .top), removal: .move(edge: .top).combined(with: .opacity)) + let animation: Animation = .interpolatingSpring(mass: 0.5, stiffness: 45, damping: 45, initialVelocity: 15) + + var dragGesture: some Gesture { + DragGesture() + .onChanged { + dragOffset.width = $0.translation.width * dragInWrongDirectionMultiplier + dragOffset.height = $0.translation.height < 0 ? $0.translation.height : $0.translation.height * dragInWrongDirectionMultiplier + } + .onEnded { + dragOffset = .zero + + guard let indicator = model.activeIndicator else { return } + if $0.translation.height > 0 && indicator.expandedText != nil { + UIImpactFeedbackGenerator(style: .soft).impactOccurred() + isExpanded.toggle() + + if isExpanded { + model.timer?.invalidate() + } else { + model.updateTimer() + } + } else if $0.translation.height < dragThreshold { + model.dismiss() + } + } + } + + var body: some View { + Group { + if let indicator = model.activeIndicator { + Indicators.IndicatorView(indicator: indicator, isExpanded: $isExpanded) + .offset(dragOffset) + .gesture(dragGesture) + .transition(transition) + } + } + .frame(maxHeight: .infinity, alignment: .top) + .padding(.horizontal) + .padding(.top, 5) + .animation(animation, value: model.activeIndicator?.id) + .animation(animation, value: dragOffset) + } + } +} + +struct IndicatorsOverlay_Previews: PreviewProvider { + static var previews: some View { + var model: Indicators { + let model = Indicators() + model.display(.init(id: "", icon: nil, headline: "Headline", dismissType: .manual)) + return model + } + + return Text("") + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .indicatorOverlay(model: model) + } +} diff --git a/Modules/Indicators/Sources/Indicators/Indicators.swift b/Modules/Indicators/Sources/Indicators/Indicators.swift new file mode 100644 index 00000000..15eba973 --- /dev/null +++ b/Modules/Indicators/Sources/Indicators/Indicators.swift @@ -0,0 +1,42 @@ +import Foundation + +public class Indicators: ObservableObject { + @Published public private(set) var activeIndicator: Indicator? + + internal var timer: Timer? = nil + + public init() { } + + public func display(_ indicator: Indicator) { + if activeIndicator?.id != indicator.id { + timer?.invalidate() + } + + activeIndicator = indicator + updateTimer() + } + + public func dismiss() { + activeIndicator = nil + timer?.invalidate() + } + + public func dismiss(matching id: String) { + if activeIndicator?.id == id { + dismiss() + } + } + + internal func updateTimer() { + if case .after(let timeout) = activeIndicator?.dismissType { + let storedIndicator = activeIndicator + + timer = Timer.scheduledTimer(withTimeInterval: timeout, repeats: false) { [weak self] _ in + // Check if activeIndicator is still the same as it was previously + if self?.activeIndicator == storedIndicator { + self?.dismiss() + } + } + } + } +} diff --git a/Modules/Indicators/Sources/Indicators/View+indicatorOverlay.swift b/Modules/Indicators/Sources/Indicators/View+indicatorOverlay.swift new file mode 100644 index 00000000..29a963f6 --- /dev/null +++ b/Modules/Indicators/Sources/Indicators/View+indicatorOverlay.swift @@ -0,0 +1,7 @@ +import SwiftUI + +public extension View { + func indicatorOverlay(model: Indicators) -> some View { + overlay(Indicators.IndicatorsOverlay(model: model)) + } +} diff --git a/Modules/PortainerKit/.swiftpm/xcode/xcshareddata/xcschemes/PortainerKit.xcscheme b/Modules/PortainerKit/.swiftpm/xcode/xcshareddata/xcschemes/PortainerKit.xcscheme new file mode 100644 index 00000000..b0730be2 --- /dev/null +++ b/Modules/PortainerKit/.swiftpm/xcode/xcshareddata/xcschemes/PortainerKit.xcscheme @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Modules/PortainerKit/Package.swift b/Modules/PortainerKit/Package.swift new file mode 100644 index 00000000..a563047d --- /dev/null +++ b/Modules/PortainerKit/Package.swift @@ -0,0 +1,20 @@ +// swift-tools-version:5.5 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "PortainerKit", + platforms: [ + .iOS(.v15), + .macOS(.v12) + ], + products: [ + .library(name: "PortainerKit", targets: ["PortainerKit"]) + ], + dependencies: [], + targets: [ + .target(name: "PortainerKit", dependencies: []) + ], + swiftLanguageVersions: [.v5] +) diff --git a/Modules/PortainerKit/README.md b/Modules/PortainerKit/README.md new file mode 100644 index 00000000..6cc6d721 --- /dev/null +++ b/Modules/PortainerKit/README.md @@ -0,0 +1,58 @@ +# PortainerKit +Modern API for [Portainer](https://portainer.io). + +## Usage +### Initialization +```swift +let url: URL = URL(string: "http://127.0.0.1:9000")! +let token: String? = nil // Optional JWT token +let api: PortainerKit = PortainerKit(url: url, token: token) +``` + +### Logging in +```swift +let result = await api.login(username: "garyhost", password: "hunter2") +switch result { + case .success(let token): + // Save JWT token for later usage + case .failure(let error): + // Handle error +} +``` + +### Endpoints +```swift +let result = await api.getEndpoints() +switch result { + case .success(let endpoints): + // Do something with endpoints + case .failure(let error): + // Handle error +} +``` + +### Containers +```swift +let endpointID: Int = 0 // Grabbed from `getEndpoints()` +let result = await api.getContainers(endpointID: endpointID) +switch result { + case .success(let containers): + // Do something with containers + case .failure(let error): + // Handle error +} +``` + +### Executing actions +```swift +let endpointID: Int = 0 // Grabbed from `getEndpoints()` +let containerID: String = "" // Grabbed from `getContainers(endpointID:)` +let action: PortainerKit.ExecuteAction = .start +let result = await api.execute(action, containerID: containerID, endpointID: endpointID) +switch result { + case .success(): + // Handle success + case .failure(let error): + // Handle error +} +``` diff --git a/Modules/PortainerKit/Sources/PortainerKit/PortainerKit+Errors.swift b/Modules/PortainerKit/Sources/PortainerKit/PortainerKit+Errors.swift new file mode 100644 index 00000000..bfaa80e2 --- /dev/null +++ b/Modules/PortainerKit/Sources/PortainerKit/PortainerKit+Errors.swift @@ -0,0 +1,56 @@ +// +// PortainerKit+Errors.swift +// PortainerKit +// +// Created by royal on 10/06/2021. +// + +import Foundation + +@available(iOS 15, macOS 12, *) +public extension PortainerKit { + enum APIError: Error, Comparable { + case custom(_ reason: String) + case responseCodeUnacceptable(_ code: Int) + case unknownError + + case decodingFailed + + case invalidCredentials + case invalidJWTToken + case unauthorized + + case invalidPayload + case invalidURL + + public var description: String { + switch self { + case .custom(let reason): return reason + case .responseCodeUnacceptable(let code): return "Response unacceptable (\(code))" + case .unknownError: return "Unknown error" + case .decodingFailed: return "Decoding failed" + case .invalidCredentials: return "Invalid credentials" + case .invalidJWTToken: return "Invalid token" + case .unauthorized: return "Unauthorized" + case .invalidPayload: return "Invalid payload" + case .invalidURL: return "Invalid URL" + } + } + + internal static func fromMessage(_ string: String?) -> Self { + guard let reason = string?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() else { return .unknownError } + + switch reason { + case "invalid credentials": return .invalidCredentials + case "invalid jwt token": return .invalidJWTToken + case "unauthorized": return .unauthorized + case "invalid request payload": return .invalidPayload + default: return .custom(reason) + } + } + } + + enum DateError: Error { + case invalidDate(dateString: String) + } +} diff --git a/Modules/PortainerKit/Sources/PortainerKit/PortainerKit+RequestPath.swift b/Modules/PortainerKit/Sources/PortainerKit/PortainerKit+RequestPath.swift new file mode 100644 index 00000000..a37a23e3 --- /dev/null +++ b/Modules/PortainerKit/Sources/PortainerKit/PortainerKit+RequestPath.swift @@ -0,0 +1,40 @@ +// +// PortainerKit+RequestPath.swift +// PortainerKit +// +// Created by royal on 11/06/2021. +// + +import Foundation + +@available(iOS 15, macOS 12, *) +internal extension PortainerKit { + enum RequestPath { + case login + case endpoints + case containers(endpointID: Int) + case containerDetails(containerID: String, endpointID: Int) + case executeAction(_ action: ExecuteAction, containerID: String, endpointID: Int) + case logs(containerID: String, endpointID: Int, since: TimeInterval, tail: Int, timestamps: Bool) + case attach + + var path: String { + switch self { + case .login: + return "/api/auth" + case .endpoints: + return "/api/endpoints" + case .containers(let endpointID): + return "/api/endpoints/\(endpointID)/docker/containers/json?all=true" + case .containerDetails(let containerID, let endpointID): + return "/api/endpoints/\(endpointID)/docker/containers/\(containerID)/json" + case .executeAction(let action, let containerID, let endpointID): + return "/api/endpoints/\(endpointID)/docker/containers/\(containerID)/\(action.rawValue)" + case .logs(let containerID, let endpointID, let since, let tail, let timestamps): + return "/api/endpoints/\(endpointID)/docker/containers/\(containerID)/logs?since=\(since)&stderr=true&stdout=true&tail=\(tail)×tamps=\(timestamps)" + case .attach: + return "/api/websocket/attach" + } + } + } +} diff --git a/Modules/PortainerKit/Sources/PortainerKit/PortainerKit+WebSocketMessage.swift b/Modules/PortainerKit/Sources/PortainerKit/PortainerKit+WebSocketMessage.swift new file mode 100644 index 00000000..2cf9fe22 --- /dev/null +++ b/Modules/PortainerKit/Sources/PortainerKit/PortainerKit+WebSocketMessage.swift @@ -0,0 +1,21 @@ +// +// PortainerKit+WebSocketMessage.swift +// PortainerKit +// +// Created by royal on 13/06/2021. +// + +import Foundation + +@available(iOS 15, macOS 12, *) +public extension PortainerKit { + enum MessageSource { + case server + case client + } + + struct WebSocketMessage { + public let message: URLSessionWebSocketTask.Message + public let source: MessageSource + } +} diff --git a/Modules/PortainerKit/Sources/PortainerKit/PortainerKit.swift b/Modules/PortainerKit/Sources/PortainerKit/PortainerKit.swift new file mode 100644 index 00000000..f976e3f4 --- /dev/null +++ b/Modules/PortainerKit/Sources/PortainerKit/PortainerKit.swift @@ -0,0 +1,237 @@ +// +// PortainerKit.swift +// PortainerKit +// +// Created by royal on 10/06/2021. +// + +import Combine +import Foundation + +@available(iOS 15, macOS 12, *) +public class PortainerKit { + public typealias WebSocketPassthroughSubject = PassthroughSubject, Error> + + // MARK: Public properties + + /// Endpoint URL + public let url: URL + + // MARK: Private properties + + /// Module-private `URLSession` + private let session: URLSession + + /// Authorization token + public var token: String? + + // MARK: - init + + /// Initializes PortainerKit with endpoint URL and optional authorization token. + /// - Parameters: + /// - url: Endpoint URL + /// - token: Authorization JWT token + public init(url: URL, token: String? = nil) { + self.url = url + + let configuration = URLSessionConfiguration.default + configuration.httpAdditionalHeaders = ["Accept-Encoding": "gzip"] + configuration.shouldUseExtendedBackgroundIdleMode = true + configuration.networkServiceType = .responsiveData + configuration.timeoutIntervalForRequest = 30 + configuration.timeoutIntervalForResource = 60 + + self.session = URLSession(configuration: configuration) + self.token = token + } + + // MARK: - Public functions + + /// Logs in to Portainer. + /// - Parameters: + /// - username: Username + /// - password: Password + /// - Returns: JWT token + public func login(username: String, password: String) async throws -> String { + var request = try request(for: .login) + request.httpMethod = "POST" + + let body = [ + "Username": username, + "Password": password + ] + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, _) = try await session.data(for: request) + let decoded = try JSONDecoder().decode([String: String].self, from: data) + + if let jwt: String = decoded["jwt"] { + token = jwt + return jwt + } else { + throw APIError.fromMessage(decoded["message"]) + } + } + + /// Fetches available endpoints. + /// - Returns: `[Endpoint]` + public func getEndpoints() async throws -> [Endpoint] { + let request = try request(for: .endpoints) + return try await fetch(request: request) + } + + /// Fetches available containers for supplied endpoint ID. + /// - Parameter endpointID: Endpoint ID + /// - Returns: `[Container]` + public func getContainers(for endpointID: Int) async throws -> [Container] { + let request = try request(for: .containers(endpointID: endpointID)) + return try await fetch(request: request) + } + + /// Inspects the requested container. + /// - Parameters: + /// - containerID: Container ID + /// - endpointID: Endpoint ID + /// - Returns: `ContainerDetails` + public func inspectContainer(_ containerID: String, endpointID: Int) async throws -> ContainerDetails { + let request = try request(for: .containerDetails(containerID: containerID, endpointID: endpointID)) + + let decoder = JSONDecoder() + let dateFormatter = ISO8601DateFormatter() + + /// Dear Docker/Portainer developers - + /// WHY THE HELL DO YOU RETURN FRACTIONAL SECONDS ONLY SOMETIMES + /// Sincerely, deeply upset me. + decoder.dateDecodingStrategy = .custom { decoder -> Date in + let container = try decoder.singleValueContainer() + let str = try container.decode(String.self) + + // ISO8601 with fractional seconds + dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = dateFormatter.date(from: str) { return date } + + // ISO8601 without fractional seconds + dateFormatter.formatOptions = [.withInternetDateTime] + if let date = dateFormatter.date(from: str) { return date } + + throw DateError.invalidDate(dateString: str) + } + + return try await fetch(request: request, decoder: decoder) + } + + /// Executes selected action for container with supplied ID. + /// - Parameters: + /// - action: Executed action + /// - containerID: Container ID + /// - endpointID: Endpoint ID + public func execute(_ action: ExecuteAction, containerID: String, endpointID: Int) async throws { + var request = try request(for: .executeAction(action, containerID: containerID, endpointID: endpointID)) + request.httpMethod = "POST" + + let response = try await session.data(for: request) + if let statusCode = (response.1 as? HTTPURLResponse)?.statusCode { + if !(200...304 ~= statusCode) { + throw APIError.responseCodeUnacceptable(statusCode) + } + } else { + // It shouldn't happen, but we should gracefully handle it anyways. + // For now, we're hoping it worked ¯\_(ツ)_/¯. + assertionFailure("Response isn't HTTPURLResponse 🤨 [\(#fileID):\(#line)]") + } + } + + /// Fetches logs from container with supplied ID. + /// - Parameters: + /// - containerID: Container ID + /// - endpointID: Endpoint ID + /// - since: Fetch logs since then + /// - tail: Number of lines, counting from the end + /// - displayTimestamps: Display timestamps? + /// - Returns: `String` logs + public func getLogs(containerID: String, endpointID: Int, since: TimeInterval = 0, tail: Int = 100, displayTimestamps: Bool = false) async throws -> String { + let request = try request(for: .logs(containerID: containerID, endpointID: endpointID, since: since, tail: tail, timestamps: displayTimestamps)) + + let (data, _) = try await session.data(for: request) + guard let string = String(data: data, encoding: .utf8) ?? String(data: data, encoding: .ascii) else { throw APIError.decodingFailed } + return string + } + + /// Attaches to container with supplied ID. + /// - Parameters: + /// - containerID: Container ID + /// - endpointID: Endpoint ID + /// - Returns: `WebSocketPassthroughSubject` + public func attach(to containerID: String, endpointID: Int) throws -> WebSocketPassthroughSubject { + let url: URL? = { + guard var components: URLComponents = URLComponents(url: self.url.appendingPathComponent(RequestPath.attach.path), resolvingAgainstBaseURL: true) else { return nil } + components.scheme = components.scheme?.replacingOccurrences(of: "http", with: "ws") ?? "ws" + components.queryItems = [ + URLQueryItem(name: "token", value: token), + URLQueryItem(name: "endpointId", value: String(endpointID)), + URLQueryItem(name: "id", value: containerID) + ] + return components.url + }() + + guard let url = url else { throw APIError.invalidURL } + let task = session.webSocketTask(with: url) + let passthroughSubject = WebSocketPassthroughSubject() + + func setReceiveHandler() { + DispatchQueue.main.async { [weak self] in + guard self != nil else { return } + task.receive { + do { + let message = WebSocketMessage(message: try $0.get(), source: .server) + passthroughSubject.send(.success(message)) + setReceiveHandler() + } catch { + passthroughSubject.send(.failure(error)) + } + } + } + } + + setReceiveHandler() + task.resume() + + return passthroughSubject + } + + // MARK: - Private functions + + /// Creates a authorized URLRequest. + /// - Parameter path: Request path + /// - Returns: `URLRequest` with authorization header set. + private func request(for path: RequestPath, overrideURL: URL? = nil) throws -> URLRequest { + guard let url = URL(string: (overrideURL ?? url).absoluteString + path.path) else { throw APIError.invalidURL } + var request = URLRequest(url: url) + + if let token = token { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + + return request + } + + /// Fetches & decodes data for supplied request. + /// - Parameter request: Request + /// - Parameter decoder: JSONDecoder + /// - Returns: Output + private func fetch(request: URLRequest, decoder: JSONDecoder = JSONDecoder()) async throws -> Output { + let response = try await session.data(for: request) + + do { + let decoded = try decoder.decode(Output.self, from: response.0) + return decoded + } catch { + if let errorJson = try? decoder.decode([String: String].self, from: response.0), + let message = errorJson["message"] { + throw APIError.fromMessage(message) + } else { + throw error + } + } + } +} diff --git a/Modules/PortainerKit/Sources/PortainerKit/Types/Container.swift b/Modules/PortainerKit/Sources/PortainerKit/Types/Container.swift new file mode 100644 index 00000000..14f0060c --- /dev/null +++ b/Modules/PortainerKit/Sources/PortainerKit/Types/Container.swift @@ -0,0 +1,73 @@ +// +// Container.swift +// PortainerKit +// +// Created by royal on 11/06/2021. +// + +import Foundation +import Combine + +@available(iOS 15, macOS 12, *) +public extension PortainerKit { + class Container: Identifiable, Codable, Equatable, ObservableObject { + public struct NetworkSettings: Codable { + enum CodingKeys: String, CodingKey { + case network = "Networks" + } + + public let network: Network? + } + + enum CodingKeys: String, CodingKey { + case id = "Id" + case names = "Names" + case image = "Image" + case imageID = "ImageID" + case command = "Command" + case created = "Created" + case ports = "Ports" + case sizeRW = "SizeRw" + case sizeRootFS = "SizeRootFs" + case labels = "Labels" + case state = "State" + case status = "Status" + case hostConfig = "HostConfig" + case networkSettings = "NetworkSettings" + case mounts = "Mounts" + } + + public let id: String + public let names: [String]? + public let image: String + public let imageID: String + public let command: String? + public let created: Date? + public let ports: [Port]? + public let sizeRW: Int64? + public let sizeRootFS: Int64? + public let labels: [String: String]? + public var state: ContainerStatus? + public var status: String? + public let hostConfig: HostConfig? + public let networkSettings: NetworkSettings? + public let mounts: [Mount]? + + public var details: ContainerDetails? = nil + + public static func == (lhs: PortainerKit.Container, rhs: PortainerKit.Container) -> Bool { + lhs.id == rhs.id && + lhs.state == rhs.state && + lhs.status == rhs.status && + lhs.names == rhs.names && + lhs.labels == rhs.labels + } + + // TODO: Update other properties + public func update(from details: PortainerKit.ContainerDetails) { + self.details = details + + self.state = details.state.status + } + } +} diff --git a/Modules/PortainerKit/Sources/PortainerKit/Types/ContainerDetails.swift b/Modules/PortainerKit/Sources/PortainerKit/Types/ContainerDetails.swift new file mode 100644 index 00000000..ce5e7fe5 --- /dev/null +++ b/Modules/PortainerKit/Sources/PortainerKit/Types/ContainerDetails.swift @@ -0,0 +1,90 @@ +// +// ContainerDetails.swift +// PortainerKit +// +// Created by royal on 11/06/2021. +// + +import Foundation + +@available(iOS 15, macOS 12, *) +public extension PortainerKit { + struct ContainerDetails: Identifiable, Codable, Equatable { + public struct NetworkSettings: Codable { + enum CodingKeys: String, CodingKey { + case bridge = "Bridge" + case gateway = "Gateway" + case address = "Address" + case ipPrefixLen = "IPPrefixLen" + case macAddress = "MacAddress" + case portMapping = "PortMapping" + case ports = "Ports" + } + + public let bridge: String + public let gateway: String + public let address: String? + public let ipPrefixLen: Int + public let macAddress: String + public let portMapping: String? + public let ports: Port + } + + enum CodingKeys: String, CodingKey { + case id = "Id" + case created = "Created" + case platform = "Platform" + case path = "Path" + case args = "Args" + case state = "State" + case image = "Image" + case resolvConfPath = "ResolvConfPath" + case hostnamePath = "HostnamePath" + case hostsPath = "HostsPath" + case logPath = "LogPath" + case name = "Name" + case restartCount = "RestartCount" + case driver = "Driver" + case mountLabel = "MountLabel" + case processLabel = "ProcessLabel" + case appArmorProfile = "AppArmorProfile" + case hostConfig = "HostConfig" + case graphDriver = "GraphDriver" + case sizeRW = "SizeRw" + case sizeRootFS = "SizeRootFs" + case mounts = "Mounts" + case config = "Config" + case networkSettings = "NetworkSettings" + } + + public let id: String + public let created: Date + public let platform: String + public let path: String + public let args: [String] + public let state: ContainerState + public let image: String + public let resolvConfPath: String + public let hostnamePath: String + public let hostsPath: String + public let logPath: String + // public let node: Any + public let name: String + public let restartCount: Int + public let driver: String + public let mountLabel: String + public let processLabel: String + public let appArmorProfile: String + public let config: ContainerConfig + public let hostConfig: HostConfig + public let graphDriver: GraphDriver + public let sizeRW: Int64? + public let sizeRootFS: Int64? + public let mounts: [MountPoint] + public let networkSettings: NetworkSettings + + public static func == (lhs: PortainerKit.ContainerDetails, rhs: PortainerKit.ContainerDetails) -> Bool { + lhs.id == rhs.id + } + } +} diff --git a/Modules/PortainerKit/Sources/PortainerKit/Types/Endpoint.swift b/Modules/PortainerKit/Sources/PortainerKit/Types/Endpoint.swift new file mode 100644 index 00000000..f49bf775 --- /dev/null +++ b/Modules/PortainerKit/Sources/PortainerKit/Types/Endpoint.swift @@ -0,0 +1,67 @@ +// +// Endpoint.swift +// PortainerKit +// +// Created by royal on 11/06/2021. +// + +import Foundation + +@available(iOS 15, macOS 12, *) +public extension PortainerKit { + class Endpoint: Identifiable, Codable { + enum CodingKeys: String, CodingKey { + case authorizedTeams = "AuthorizedTeams" + case authorizedUsers = "AuthorizedUsers" + case azureCredentials = "AzureCredentials" + case edgeCheckinInterval = "EdgeCheckinInterval" + case edgeID = "EdgeID" + case edgeKey = "EdgeKey" + case extensions = "Extensions" + case groupID = "GroupID" + case id = "Id" + case kubernetes = "Kubernetes" + case name = "Name" + case publicURL = "PublicURL" + case snapshots = "Snapshots" + case status = "Status" + case tls = "TLS" + case tlsCACert = "TLSCACert" + case tlsCert = "TLSCert" + case tlsConfig = "TLSConfig" + case tlsKey = "TLSKey" + case tagIDs = "TagIds" + case tags = "Tags" + case teamAccessPolicies = "TeamAccessPolicies" + case type = "Type" + case url = "URL" + case userAccessPolicies = "UserAccessPolicies" + } + + public let authorizedTeams: [Int]? + public let authorizedUsers: [Int]? + public let azureCredentials: AzureCredentials? + public let edgeCheckinInterval: Int? + public let edgeID: String? + public let edgeKey: String? + public let extensions: [EndpointExtension]? + public let groupID: String? + public let id: Int + public let kubernetes: KubernetesData? + public let name: String? + public let publicURL: String? + public let snapshots: [DockerSnapshot]? + public let status: EndpointStatus? + public let tls: Bool? + public let tlsCACert: String? + public let tlsCert: String? + public let tlsConfig: TLSConfiguration? + public let tlsKey: String? + public let tagIDs: [Int]? + public let tags: [String]? + public let teamAccessPolicies: AccessPolicy? + public let type: EndpointType? + public let url: String? + public let userAccessPolicies: AccessPolicy? + } +} diff --git a/Modules/PortainerKit/Sources/PortainerKit/Types/Generics.swift b/Modules/PortainerKit/Sources/PortainerKit/Types/Generics.swift new file mode 100644 index 00000000..601fb081 --- /dev/null +++ b/Modules/PortainerKit/Sources/PortainerKit/Types/Generics.swift @@ -0,0 +1,612 @@ +// +// PortainerKit+GenericTypes.swift +// PortianerKit +// +// Created by royal on 11/06/2021. +// + +import Foundation + +// MARK: - Generic enums + +@available(iOS 15, macOS 12, *) +public extension PortainerKit { + enum ContainerStatus: String, Codable { + case created + case running + case paused + case restarting + case removing + case exited + case dead + } + + enum EndpointStatus: Int, Codable { + case up = 1 + case down + } + + enum EndpointType: Int, Codable { + case docker = 1 + case agent + case azure + } + + enum ExecuteAction: String { + case start + case stop + case restart + case kill + case pause + case unpause + + public var expectedState: ContainerStatus { + switch self { + case .start: return .running + case .stop: return .exited + case .restart: return .restarting + case .kill: return .exited + case .pause: return .paused + case .unpause: return .running + } + } + } + + enum MountConsistency: String, Codable, Hashable { + case `default` + case consistent + case cached + case delegated + } + + enum MountType: String, Codable, Hashable { + case bind + case volume + case tmpfs + } +} + +// MARK: - Generic types + +@available(iOS 15, macOS 12, *) +public extension PortainerKit { + struct AccessPolicy: Codable { + enum CodingKeys: String, CodingKey { + case roleID = "RoleID" + } + + public let roleID: Int? + } + + struct AzureCredentials: Codable { + enum CodingKeys: String, CodingKey { + case applicationID = "ApplicationID" + case authenticationKey = "AuthenticationKey" + case tenantID = "TenantID" + } + + public let applicationID: String? + public let authenticationKey: String? + public let tenantID: String? + } + + struct BindOptions: Codable, Hashable { + enum CodingKeys: String, CodingKey { + case propagation = "Propagation" + } + + public enum Propagation: String, Codable { + case `private` + case rprivate + case shared + case rshared + case slave + case rslave + } + + public let propagation: Propagation? + } + + struct ContainerConfig: Codable { + enum CodingKeys: String, CodingKey { + case hostname = "Hostname" + case domainName = "DomainName" + case user = "User" + case attachStdin = "AttachStdin" + case attachStdout = "AttachStdout" + case attachStderr = "AttachStderr" + case exposedPorts = "ExposedPorts" + case tty = "Tty" + case openStdin = "OpenStdin" + case stdinOnce = "StdinOnce" + case env = "Env" + case cmd = "Cmd" + case healthCheck = "HealthCheck" + case argsEscaped = "ArgsEscaped" + case image = "Image" + case volumes = "Volumes" + case workingDir = "WorkingDir" + case entrypoint = "Entrypoint" + case networkDisabled = "NetworkDisabled" + case macAddress = "MacAddress" + case onBuild = "OnBuild" + case labels = "Labels" + case stopSignal = "StopSignal" + case stopTimeout = "StopTimeout" + case shell = "Shell" + } + + public let hostname: String + public let domainName: String? + public let user: String + public let attachStdin: Bool + public let attachStdout: Bool + public let attachStderr: Bool + public let exposedPorts: [String: [String: String]]? + public let tty: Bool + public let openStdin: Bool + public let stdinOnce: Bool + public let env: [String] + public let cmd: [String]? + public let healthCheck: HealthConfig? + public let argsEscaped: Bool? + public let image: String + public let volumes: [String: [String: String]]? + public let workingDir: String + public let entrypoint: [String]? + public let networkDisabled: Bool? + public let macAddress: String? + public let onBuild: [String]? + public let labels: [String: String] + public let stopSignal: String? + public let stopTimeout: Int? + public let shell: [String]? + } + + struct ContainerState: Codable { + enum CodingKeys: String, CodingKey { + case status = "Status" + case running = "Running" + case paused = "Paused" + case restarting = "Restarting" + case oomKilled = "OOMKilled" + case dead = "Dead" + case pid = "Pid" + case error = "Error" + case startedAt = "StartedAt" + case finishedAt = "FinishedAt" + } + + public let status: ContainerStatus + public let running: Bool + public let paused: Bool + public let restarting: Bool + public let oomKilled: Bool + public let dead: Bool + public let pid: Int + public let error: String + public let startedAt: Date? + public let finishedAt: Date? + } + + struct DockerSnapshot: Codable { + enum CodingKeys: String, CodingKey { + case dockerVersion = "DockerVersion" + case healthyContainerCount = "HealthyContainerCount" + case imageCount = "ImageCount" + case runningContainerCount = "RunningContainerCount" + case serviceCount = "ServiceCount" + case stackCount = "StackCount" + case stoppedContainerCount = "StoppedContainerCount" + case swarm = "Swarm" + case time = "Time" + case totalCPU = "TotalCPU" + case totalMemory = "TotalMemory" + case unhealthyContainerCount = "UnhealthyContainerCount" + case volumeCount = "VolumeCount" + } + + public let dockerVersion: String? + public let healthyContainerCount: Int? + public let imageCount: Int? + public let runningContainerCount: Int? + public let serviceCount: Int? + public let stackCount: Int? + public let stoppedContainerCount: Int? + public let swarm: Bool? + public let time: Int? + public let totalCPU: Int? + public let totalMemory: Int? + public let unhealthyContainerCount: Int? + public let volumeCount: Int? + } + + struct DriverConfig: Codable, Hashable { + enum CodingKeys: String, CodingKey { + case name = "Name" + case options = "Options" + } + + public let name: String? + public let options: [String: String]? + } + + struct EndpointExtension: Codable { + enum CodingKeys: String, CodingKey { + case type = "Type" + case url = "URL" + } + + public let type: Int? + public let url: String? + } + + struct HealthConfig: Codable { + enum CodingKeys: String, CodingKey { + case test = "Test" + case interval = "Interval" + case timeout = "Timeout" + case retries = "Retries" + case startPeriod = "StartPeriod" + } + + public let test: [String] + public let interval: Int + public let timeout: Int + public let retries: Int + public let startPeriod: Int + } + + struct GraphDriver: Codable { + public struct GraphDriverData: Codable { + enum CodingKeys: String, CodingKey { + case lowerDir = "LowerDir" + case mergedDir = "MergedDir" + case upperDir = "UpperDir" + case workDir = "WorkDir" + } + + public let lowerDir: String + public let mergedDir: String + public let upperDir: String + public let workDir: String + } + + enum CodingKeys: String, CodingKey { + case name = "Name" + case data = "Data" + } + + public let name: String + public let data: GraphDriverData + } + + struct HostConfig: Codable { + enum CodingKeys: String, CodingKey { + case networkMode = "NetworkMode" + } + + /* "HostConfig": { + "AutoRemove": false, + "Binds": [ + "portainer_data:/data", + "/run/host-services/docker.proxy.sock:/var/run/docker.sock" + ], + "BlkioDeviceReadBps": null, + "BlkioDeviceReadIOps": null, + "BlkioDeviceWriteBps": null, + "BlkioDeviceWriteIOps": null, + "BlkioWeight": 0, + "BlkioWeightDevice": [], + "CapAdd": null, + "CapDrop": null, + "Cgroup": "", + "CgroupParent": "", + "CgroupnsMode": "host", + "ConsoleSize": [ + 0, + 0 + ], + "ContainerIDFile": "", + "CpuCount": 0, + "CpuPercent": 0, + "CpuPeriod": 0, + "CpuQuota": 0, + "CpuRealtimePeriod": 0, + "CpuRealtimeRuntime": 0, + "CpuShares": 0, + "CpusetCpus": "", + "CpusetMems": "", + "DeviceCgroupRules": null, + "DeviceRequests": null, + "Devices": [], + "Dns": [], + "DnsOptions": [], + "DnsSearch": [], + "ExtraHosts": null, + "GroupAdd": null, + "IOMaximumBandwidth": 0, + "IOMaximumIOps": 0, + "IpcMode": "private", + "Isolation": "", + "KernelMemory": 0, + "KernelMemoryTCP": 0, + "Links": null, + "LogConfig": { + "Config": {}, + "Type": "json-file" + }, + "MaskedPaths": [ + "/proc/asound", + "/proc/acpi", + "/proc/kcore", + "/proc/keys", + "/proc/latency_stats", + "/proc/timer_list", + "/proc/timer_stats", + "/proc/sched_debug", + "/proc/scsi", + "/sys/firmware" + ], + "Memory": 0, + "MemoryReservation": 0, + "MemorySwap": 0, + "MemorySwappiness": null, + "NanoCpus": 0, + "NetworkMode": "default", + "OomKillDisable": false, + "OomScoreAdj": 0, + "PidMode": "", + "PidsLimit": null, + "PortBindings": { + "8000/tcp": [ + { + "HostIp": "", + "HostPort": "8000" + } + ], + "9000/tcp": [ + { + "HostIp": "", + "HostPort": "9000" + } + ] + }, + "Privileged": false, + "PublishAllPorts": false, + "ReadonlyPaths": [ + "/proc/bus", + "/proc/fs", + "/proc/irq", + "/proc/sys", + "/proc/sysrq-trigger" + ], + "ReadonlyRootfs": false, + "RestartPolicy": { + "MaximumRetryCount": 0, + "Name": "always" + }, + "Runtime": "runc", + "SecurityOpt": null, + "ShmSize": 67108864, + "UTSMode": "", + "Ulimits": null, + "UsernsMode": "", + "VolumeDriver": "", + "VolumesFrom": null + } */ + + public let networkMode: String? + } + + struct IPAMConfig: Codable { + enum CodingKeys: String, CodingKey { + case ipv4Address = "IPv4Address" + case ipv6Address = "IPv6Address" + case linkLocalIPs = "LinkLocalIPs" + } + + public let ipv4Address: String? + public let ipv6Address: String? + public let linkLocalIPs: [String]? + } + + struct KubernetesConfiguration: Codable { + enum CodingKeys: String, CodingKey { + case ingressClasses = "IngressClasses" + case storageClasses = "StorageClasses" + case useLoadBalancer = "UseLoadBalancer" + case useServerMetrics = "UseServerMetrics" + } + + public let ingressClasses: [KubernetesIngressClassConfig]? + public let storageClasses: [KubernetesStorageClassConfig]? + public let useLoadBalancer: Bool? + public let useServerMetrics: Bool? + } + + struct KubernetesData: Codable { + enum CodingKeys: String, CodingKey { + case configuration = "Configuration" + case snapshots = "Snapshots" + } + + public let configuration: KubernetesConfiguration? + public let snapshots: [KubernetesSnapshot]? + } + + struct KubernetesIngressClassConfig: Codable { + enum CodingKeys: String, CodingKey { + case name = "Name" + case type = "Type" + } + + public let name: String + public let type: String + } + + struct KubernetesSnapshot: Codable { + enum CodingKeys: String, CodingKey { + case kubernetesVersion = "KubernetesVersion" + case nodeCount = "NodeCount" + case time = "Time" + case totalCPU = "TotalCPU" + case totalMemory = "TotalMemory" + } + + public let kubernetesVersion: String? + public let nodeCount: Int? + public let time: Int? + public let totalCPU: Int? + public let totalMemory: Int? + } + + struct KubernetesStorageClassConfig: Codable { + enum CodingKeys: String, CodingKey { + case accessModes = "AccessModes" + case allowVolumeExpansion = "AllowVolumeExpansion" + case name = "Name" + case provisioner = "Provisioner" + } + + public let accessModes: [String]? + public let allowVolumeExpansion: Bool? + public let name: String? + public let provisioner: String? + } + + struct Mount: Codable, Hashable { + enum CodingKeys: String, CodingKey { + case target = "Target" + case source = "Source" + case type = "Type" + case readOnly = "ReadOnly" + case consistency = "Consistency" + case bindOptions = "BindOptions" + case volumeOptions = "VolumeOptions" + case tmpfsOptions = "TmpfsOptions" + } + + public let target: String? + public let source: String? + public let type: MountType? + public let readOnly: Bool? + public let consistency: MountConsistency? + public let bindOptions: BindOptions? + public let volumeOptions: VolumeOptions? + public let tmpfsOptions: TmpfsOptions? + + public static func == (lhs: PortainerKit.Mount, rhs: PortainerKit.Mount) -> Bool { + lhs.target == rhs.target && lhs.source == rhs.source && lhs.type == rhs.type && lhs.readOnly == rhs.readOnly + } + } + + struct MountPoint: Codable, Hashable { + enum CodingKeys: String, CodingKey { + case type = "Type" + case name = "Name" + case source = "Source" + case destination = "Destination" + case driver = "Driver" + case mode = "Mode" + case rw = "RW" + case propagation = "Propagation" + } + + public let type: String + public let name: String? + public let source: String + public let destination: String + public let driver: String? + public let mode: String + public let rw: Bool + public let propagation: String + } + + struct Network: Codable { + enum CodingKeys: String, CodingKey { + case ipamConfig = "IPAMConfig" + case links = "Links" + case aliases = "Aliases" + case networkID = "NetworkID" + case endpointID = "EndpointID" + case gateway = "Gateway" + case ipAddress = "IPAddress" + case ipPrefixLen = "IPPrefixLen" + case ipv6Gateway = "IPv6Gateway" + case globalIPv6Address = "GlobalIPv6Address" + case globalIPv6PrefixLen = "GlobalIPv6PrefixLen" + case macAddress = "MacAddress" + } + + public let ipamConfig: IPAMConfig? + public let links: [String]? + public let aliases: [String]? + public let networkID: String? + public let endpointID: String? + public let gateway: String? + public let ipAddress: String? + public let ipPrefixLen: Int? + public let ipv6Gateway: String? + public let globalIPv6Address: String? + public let globalIPv6PrefixLen: Int64? + public let macAddress: String? + } + + struct Port: Codable, Hashable { + enum CodingKeys: String, CodingKey { + case ip = "IP" + case privatePort = "PrivatePort" + case publicPort = "PublicPort" + case type = "Type" + } + + public enum PortType: String, Codable { + case tcp + case udp + } + + public let ip: String? + public let privatePort: UInt16? + public let publicPort: UInt16? + public let type: PortType? + } + + struct TmpfsOptions: Codable, Hashable { + enum CodingKeys: String, CodingKey { + case sizeBytes = "SizeBytes" + case mode = "Mode" + } + + public let sizeBytes: Int64? + public let mode: Int? + } + + struct TLSConfiguration: Codable { + enum CodingKeys: String, CodingKey { + case tls = "TLS" + case tlsCACert = "TLSCACert" + case tlsCert = "TLSCert" + case tlsKey = "TLSKey" + case tlsSkipVerify = "TLSSkipVerify" + } + + public let tls: Bool? + public let tlsCACert: String? + public let tlsCert: String? + public let tlsKey: String? + public let tlsSkipVerify: Bool? + } + + struct VolumeOptions: Codable, Hashable { + enum CodingKeys: String, CodingKey { + case noCopy = "NoCopy" + case labels = "Labels" + case driverConfig = "DriverConfig" + } + + public let noCopy: Bool? + public let labels: [String: String]? + public let driverConfig: DriverConfig? + } +} diff --git a/README.md b/README.md new file mode 100644 index 00000000..62b9fbe2 --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +

+ Harbour App icon +

Harbour

+

Docker/Portainer management app for iOS

+

+ +## Features +- Real-time data +- See all of the container details (logs too!) +- Built in SwiftUI + +## Installation +**Harbour is available on [TestFlight](https://testflight.apple.com/join/F2vK7xo4), and will be available on the App Store too in the near future 😄** +#### Alternative methods +- Sideload .ipa from [releases](https://github.com/rrroyal/Harbour/releases/latest) +- Build it yourself + +## Coming up +- [ ] Widgets +- [ ] Siri/Shortcuts +- [ ] watchOS app +- [ ] Creating new containers +- [ ] Attaching to containers (including ANSI escape codes & input) +- [ ] Handoff +- [ ] Background refresh (+notifications?) + +## Credits +- [kishikawakatsumi/KeychainAccess](https://github.com/kishikawakatsumi/KeychainAccess) - Keychain wrapper diff --git a/Shared/Extensions+Modifiers/PortainerKit+.swift b/Shared/Extensions+Modifiers/PortainerKit+.swift new file mode 100644 index 00000000..643292de --- /dev/null +++ b/Shared/Extensions+Modifiers/PortainerKit+.swift @@ -0,0 +1,93 @@ +// +// PortainerKit+.swift +// Shared +// +// Created by royal on 11/06/2021. +// + +import PortainerKit +import SwiftUI + +extension PortainerKit.Endpoint { + var displayName: String { name ?? "\(id)" } +} + +extension PortainerKit.Container { + var displayName: String? { + guard let name: String = names?.first?.trimmingCharacters(in: .whitespacesAndNewlines) else { return nil } + return name.starts(with: "/") ? String(name.dropFirst()) : name + } +} + +extension PortainerKit.ExecuteAction { + var color: Color { + switch self { + case .start: return .green + case .stop: return Color(uiColor: .darkGray) + case .restart: return .blue + case .kill: return .red + case .pause: return .orange + case .unpause: return .green + } + } + + var icon: String { + switch self { + case .start: return "play" + case .stop: return "stop" + case .restart: return "restart" + case .kill: return "bolt" + case .pause: return "pause" + case .unpause: return "wake" + } + } + + var label: String { + switch self { + case .start: return "Start" + case .stop: return "Stop" + case .restart: return "Restart" + case .kill: return "Kill" + case .pause: return "Pause" + case .unpause: return "Resume" + } + } +} + +extension PortainerKit.ContainerStatus { + var color: Color { + switch self { + case .created: return .yellow + case .running: return .green + case .paused: return .orange + case .restarting: return .blue + case .removing: return Color(uiColor: .lightGray) + case .exited: return Color(uiColor: .darkGray) + case .dead: return .gray + } + } + + var icon: String { + switch self { + case .created: return "wake" + case .running: return "power" + case .paused: return "pause" + case .restarting: return "restart" + case .removing: return "trash" + case .exited: return "poweroff" + case .dead: return "xmark" + } + } +} + +extension Optional where Wrapped == PortainerKit.ContainerStatus { + var color: Color { + if let color = self?.color { return color } + return .clear + } + + var icon: String { + if let icon = self?.icon { return icon } + return "questionmark" + } +} diff --git a/Shared/Extensions+Modifiers/View+.swift b/Shared/Extensions+Modifiers/View+.swift new file mode 100644 index 00000000..6e9db5e6 --- /dev/null +++ b/Shared/Extensions+Modifiers/View+.swift @@ -0,0 +1,36 @@ +// +// View+.swift +// Shared +// +// Created by royal on 11/06/2021. +// + +import SwiftUI + +// MARK: View+ + +extension View { + @ViewBuilder + func hidden(_ hidden: Bool) -> some View { + if hidden { + self.hidden() + } else { + self + } + } + + func padding(_ edges: Edge.Set, _ length: PaddingSize) -> some View { + padding(edges, length.rawValue) + } + + func padding(_ size: PaddingSize) -> some View { + padding(size.rawValue) + } +} + +// MARK: PaddingSize + +enum PaddingSize: Double { + case small = 5 + case medium = 13 +}