From b2544a51b9a27fa6ff0fbfa9ad3e4585a9513d5f Mon Sep 17 00:00:00 2001 From: Didi Hoffmann Date: Thu, 19 Oct 2023 01:32:43 +0200 Subject: [PATCH] v.0.3 --- README.md | 15 +- app/hog/hog.xcodeproj/project.pbxproj | 188 +----- .../UserInterfaceState.xcuserstate | Bin 43679 -> 53238 bytes .../logo_bw_bar.imageset/Contents.json | 21 + .../logo_bw_bar.imageset/logo_bw_bar.png | Bin 0 -> 7659 bytes app/hog/hog/DetailView.swift | 560 ++++++------------ app/hog/hog/InstallView.swift | 150 +++++ app/hog/hog/SettingsView.swift | 165 ++++++ app/hog/hog/UpdateView.swift | 55 ++ app/hog/hog/hog.entitlements | 2 + app/hog/hog/hogApp.swift | 10 +- app/hog/widget/AppIntent.swift | 18 - .../AccentColor.colorset/Contents.json | 11 - .../AppIcon.appiconset/Contents.json | 58 -- app/hog/widget/Assets.xcassets/Contents.json | 6 - .../WidgetBackground.colorset/Contents.json | 11 - app/hog/widget/Info.plist | 11 - app/hog/widget/widget.entitlements | 8 - app/hog/widget/widget.swift | 63 -- app/hog/widget/widgetBundle.swift | 16 - install.sh | 35 ++ power_logger.py | 247 ++++++-- settings.ini | 1 + 23 files changed, 821 insertions(+), 830 deletions(-) create mode 100644 app/hog/hog/Assets.xcassets/logo_bw_bar.imageset/Contents.json create mode 100644 app/hog/hog/Assets.xcassets/logo_bw_bar.imageset/logo_bw_bar.png create mode 100644 app/hog/hog/InstallView.swift create mode 100644 app/hog/hog/SettingsView.swift create mode 100644 app/hog/hog/UpdateView.swift delete mode 100644 app/hog/widget/AppIntent.swift delete mode 100644 app/hog/widget/Assets.xcassets/AccentColor.colorset/Contents.json delete mode 100644 app/hog/widget/Assets.xcassets/AppIcon.appiconset/Contents.json delete mode 100644 app/hog/widget/Assets.xcassets/Contents.json delete mode 100644 app/hog/widget/Assets.xcassets/WidgetBackground.colorset/Contents.json delete mode 100644 app/hog/widget/Info.plist delete mode 100644 app/hog/widget/widget.entitlements delete mode 100644 app/hog/widget/widget.swift delete mode 100644 app/hog/widget/widgetBundle.swift mode change 100644 => 100755 install.sh diff --git a/README.md b/README.md index 0f91320..769be16 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,8 @@ Following keys are currently used: - `upload_delta`: This is the time delta data should be uploaded in seconds. - `api_url`: The url endpoint the data should be uploaded to. You can use the https://github.com/green-coding-berlin/green-metrics-tool if you want but also write/ use your own backend. - `web_url`: The url where the analytics can be found. We will append the machine ID to this so make sure the end of the string is a `=` +- `resolve_coalitions`: The way macOS works is that it looks as apps and not processes. So it can happen that when you look at your power data you see your shell as the main power hog. + This is because your shell has probably spawn the process that is using a lot of resources. Please add the name of the coalition to this list to resolve this error. ## The desktop App @@ -122,6 +124,17 @@ All data is saved in an sqlite database that is located under: /Library/Application Support/berlin.green-coding.hog/db.db ``` +## Updating + +We currently don't support an automatic update. You will have to: + +- Download the current app and move it into your Applications folder from https://github.com/green-coding-berlin/hog/releases . The file will be called `hog.app.zip` +- Rerun in the install script which will overwrite any custom changes you have made! +``` +sudo mv /etc/hog_settings.ini /etc/hog_settings.ini.back +curl -fsSL https://raw.githubusercontent.com/green-coding-berlin/hog/main/install.sh | sudo bash +``` + ## Contributing PRs are always welcome. Feel free to drop us an email or look into the issues. @@ -141,4 +154,4 @@ The hog is developed to not need any dependencies. - If you can't see the hog logo in the menu bar because of the notch there are multiple solutions. - you can use a tool like https://www.macbartender.com/Bartender4/ - - you can use the the command `$ sudo /usr/local/bin/hog/power_logger.py -w` to display the url. \ No newline at end of file + - you can use the the command `$ sudo /usr/local/bin/hog/power_logger.py -w` to display the url. diff --git a/app/hog/hog.xcodeproj/project.pbxproj b/app/hog/hog.xcodeproj/project.pbxproj index 356336f..202ec8a 100644 --- a/app/hog/hog.xcodeproj/project.pbxproj +++ b/app/hog/hog.xcodeproj/project.pbxproj @@ -7,13 +7,9 @@ objects = { /* Begin PBXBuildFile section */ - 0A21F0132AC6D0DA0036252A /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0A21F0122AC6D0DA0036252A /* WidgetKit.framework */; }; - 0A21F0152AC6D0DA0036252A /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0A21F0142AC6D0DA0036252A /* SwiftUI.framework */; }; - 0A21F0182AC6D0DA0036252A /* widgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A21F0172AC6D0DA0036252A /* widgetBundle.swift */; }; - 0A21F01A2AC6D0DA0036252A /* widget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A21F0192AC6D0DA0036252A /* widget.swift */; }; - 0A21F01C2AC6D0DA0036252A /* AppIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A21F01B2AC6D0DA0036252A /* AppIntent.swift */; }; - 0A21F01E2AC6D0DB0036252A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0A21F01D2AC6D0DB0036252A /* Assets.xcassets */; }; - 0A21F0232AC6D0DB0036252A /* widgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0A21F0102AC6D0DA0036252A /* widgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 0A1636632AE05DA2001C38B4 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A1636622AE05DA2001C38B4 /* SettingsView.swift */; }; + 0A1636652AE05E3E001C38B4 /* InstallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A1636642AE05E3E001C38B4 /* InstallView.swift */; }; + 0A16366E2AE097CC001C38B4 /* UpdateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A16366D2AE097CC001C38B4 /* UpdateView.swift */; }; 0A69EDE42AA0820E00F4A364 /* DetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A69EDE32AA0820E00F4A364 /* DetailView.swift */; }; 0A6C64572AAF5F9A00664D98 /* demo_db.db in Resources */ = {isa = PBXBuildFile; fileRef = 0A6C64562AAF5F9A00664D98 /* demo_db.db */; }; 0AEC07772A40D4C2003C82E7 /* hogApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AEC07762A40D4C2003C82E7 /* hogApp.swift */; }; @@ -25,13 +21,6 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ - 0A21F0212AC6D0DB0036252A /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 0AEC076B2A40D4C2003C82E7 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 0A21F00F2AC6D0DA0036252A; - remoteInfo = widgetExtension; - }; 0AEC07852A40D4C3003C82E7 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 0AEC076B2A40D4C2003C82E7 /* Project object */; @@ -55,7 +44,6 @@ dstPath = ""; dstSubfolderSpec = 13; files = ( - 0A21F0232AC6D0DB0036252A /* widgetExtension.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -63,15 +51,11 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 0A21F0102AC6D0DA0036252A /* widgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = widgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 0A1636622AE05DA2001C38B4 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + 0A1636642AE05E3E001C38B4 /* InstallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstallView.swift; sourceTree = ""; }; + 0A16366D2AE097CC001C38B4 /* UpdateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateView.swift; sourceTree = ""; }; 0A21F0122AC6D0DA0036252A /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; 0A21F0142AC6D0DA0036252A /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; - 0A21F0172AC6D0DA0036252A /* widgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = widgetBundle.swift; sourceTree = ""; }; - 0A21F0192AC6D0DA0036252A /* widget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = widget.swift; sourceTree = ""; }; - 0A21F01B2AC6D0DA0036252A /* AppIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIntent.swift; sourceTree = ""; }; - 0A21F01D2AC6D0DB0036252A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 0A21F01F2AC6D0DB0036252A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 0A21F0202AC6D0DB0036252A /* widget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = widget.entitlements; sourceTree = ""; }; 0A69EDE32AA0820E00F4A364 /* DetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailView.swift; sourceTree = ""; }; 0A6C64552AACBA5A00664D98 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 0A6C64562AAF5F9A00664D98 /* demo_db.db */ = {isa = PBXFileReference; lastKnownFileType = text; path = demo_db.db; sourceTree = ""; }; @@ -88,15 +72,6 @@ /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ - 0A21F00D2AC6D0DA0036252A /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 0A21F0152AC6D0DA0036252A /* SwiftUI.framework in Frameworks */, - 0A21F0132AC6D0DA0036252A /* WidgetKit.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; 0AEC07702A40D4C2003C82E7 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -130,26 +105,12 @@ name = Frameworks; sourceTree = ""; }; - 0A21F0162AC6D0DA0036252A /* widget */ = { - isa = PBXGroup; - children = ( - 0A21F0172AC6D0DA0036252A /* widgetBundle.swift */, - 0A21F0192AC6D0DA0036252A /* widget.swift */, - 0A21F01B2AC6D0DA0036252A /* AppIntent.swift */, - 0A21F01D2AC6D0DB0036252A /* Assets.xcassets */, - 0A21F01F2AC6D0DB0036252A /* Info.plist */, - 0A21F0202AC6D0DB0036252A /* widget.entitlements */, - ); - path = widget; - sourceTree = ""; - }; 0AEC076A2A40D4C2003C82E7 = { isa = PBXGroup; children = ( 0AEC07752A40D4C2003C82E7 /* hog */, 0AEC07872A40D4C3003C82E7 /* hogTests */, 0AEC07912A40D4C3003C82E7 /* hogUITests */, - 0A21F0162AC6D0DA0036252A /* widget */, 0A21F0112AC6D0DA0036252A /* Frameworks */, 0AEC07742A40D4C2003C82E7 /* Products */, ); @@ -161,7 +122,6 @@ 0AEC07732A40D4C2003C82E7 /* hog.app */, 0AEC07842A40D4C3003C82E7 /* hogTests.xctest */, 0AEC078E2A40D4C3003C82E7 /* hogUITests.xctest */, - 0A21F0102AC6D0DA0036252A /* widgetExtension.appex */, ); name = Products; sourceTree = ""; @@ -172,6 +132,9 @@ 0A6C64552AACBA5A00664D98 /* Info.plist */, 0AEC07762A40D4C2003C82E7 /* hogApp.swift */, 0A69EDE32AA0820E00F4A364 /* DetailView.swift */, + 0A1636622AE05DA2001C38B4 /* SettingsView.swift */, + 0A1636642AE05E3E001C38B4 /* InstallView.swift */, + 0A16366D2AE097CC001C38B4 /* UpdateView.swift */, 0AEC077A2A40D4C3003C82E7 /* Assets.xcassets */, 0AEC077F2A40D4C3003C82E7 /* hog.entitlements */, 0AEC077C2A40D4C3003C82E7 /* Preview Content */, @@ -208,23 +171,6 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - 0A21F00F2AC6D0DA0036252A /* widgetExtension */ = { - isa = PBXNativeTarget; - buildConfigurationList = 0A21F0272AC6D0DB0036252A /* Build configuration list for PBXNativeTarget "widgetExtension" */; - buildPhases = ( - 0A21F00C2AC6D0DA0036252A /* Sources */, - 0A21F00D2AC6D0DA0036252A /* Frameworks */, - 0A21F00E2AC6D0DA0036252A /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = widgetExtension; - productName = widgetExtension; - productReference = 0A21F0102AC6D0DA0036252A /* widgetExtension.appex */; - productType = "com.apple.product-type.app-extension"; - }; 0AEC07722A40D4C2003C82E7 /* hog */ = { isa = PBXNativeTarget; buildConfigurationList = 0AEC07982A40D4C3003C82E7 /* Build configuration list for PBXNativeTarget "hog" */; @@ -237,7 +183,6 @@ buildRules = ( ); dependencies = ( - 0A21F0222AC6D0DB0036252A /* PBXTargetDependency */, ); name = hog; productName = hog; @@ -287,12 +232,12 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; + KnownAssetTags = ( + New, + ); LastSwiftUpdateCheck = 1500; LastUpgradeCheck = 1500; TargetAttributes = { - 0A21F00F2AC6D0DA0036252A = { - CreatedOnToolsVersion = 15.0; - }; 0AEC07722A40D4C2003C82E7 = { CreatedOnToolsVersion = 14.3.1; }; @@ -322,20 +267,11 @@ 0AEC07722A40D4C2003C82E7 /* hog */, 0AEC07832A40D4C3003C82E7 /* hogTests */, 0AEC078D2A40D4C3003C82E7 /* hogUITests */, - 0A21F00F2AC6D0DA0036252A /* widgetExtension */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ - 0A21F00E2AC6D0DA0036252A /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 0A21F01E2AC6D0DB0036252A /* Assets.xcassets in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; 0AEC07712A40D4C2003C82E7 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -363,21 +299,14 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - 0A21F00C2AC6D0DA0036252A /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 0A21F0182AC6D0DA0036252A /* widgetBundle.swift in Sources */, - 0A21F01A2AC6D0DA0036252A /* widget.swift in Sources */, - 0A21F01C2AC6D0DA0036252A /* AppIntent.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; 0AEC076F2A40D4C2003C82E7 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 0A1636632AE05DA2001C38B4 /* SettingsView.swift in Sources */, 0A69EDE42AA0820E00F4A364 /* DetailView.swift in Sources */, + 0A16366E2AE097CC001C38B4 /* UpdateView.swift in Sources */, + 0A1636652AE05E3E001C38B4 /* InstallView.swift in Sources */, 0AEC07772A40D4C2003C82E7 /* hogApp.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -402,11 +331,6 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ - 0A21F0222AC6D0DB0036252A /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 0A21F00F2AC6D0DA0036252A /* widgetExtension */; - targetProxy = 0A21F0212AC6D0DB0036252A /* PBXContainerItemProxy */; - }; 0AEC07862A40D4C3003C82E7 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 0AEC07722A40D4C2003C82E7 /* hog */; @@ -420,69 +344,6 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ - 0A21F0252AC6D0DB0036252A /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; - CODE_SIGN_ENTITLEMENTS = widget/widget.entitlements; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = SBWA476E6F; - ENABLE_HARDENED_RUNTIME = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = widget/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = widget; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - "@executable_path/../../../../Frameworks", - ); - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = "berlin.green-coding.hog.widget"; - PRODUCT_NAME = "$(TARGET_NAME)"; - SKIP_INSTALL = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - }; - name = Debug; - }; - 0A21F0262AC6D0DB0036252A /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; - CODE_SIGN_ENTITLEMENTS = widget/widget.entitlements; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = SBWA476E6F; - ENABLE_HARDENED_RUNTIME = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = widget/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = widget; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - "@executable_path/../../../../Frameworks", - ); - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = "berlin.green-coding.hog.widget"; - PRODUCT_NAME = "$(TARGET_NAME)"; - SKIP_INSTALL = YES; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - }; - name = Release; - }; 0AEC07962A40D4C3003C82E7 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -615,7 +476,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"hog/Preview Content\""; DEVELOPMENT_TEAM = SBWA476E6F; @@ -631,7 +492,8 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.2; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MARKETING_VERSION = 0.3; PRODUCT_BUNDLE_IDENTIFIER = "berlin.green-coding.hog"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -650,7 +512,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"hog/Preview Content\""; DEVELOPMENT_TEAM = SBWA476E6F; @@ -666,7 +528,8 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.2; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MARKETING_VERSION = 0.3; PRODUCT_BUNDLE_IDENTIFIER = "berlin.green-coding.hog"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -753,15 +616,6 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - 0A21F0272AC6D0DB0036252A /* Build configuration list for PBXNativeTarget "widgetExtension" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 0A21F0252AC6D0DB0036252A /* Debug */, - 0A21F0262AC6D0DB0036252A /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; 0AEC076E2A40D4C2003C82E7 /* Build configuration list for PBXProject "hog" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/app/hog/hog.xcodeproj/project.xcworkspace/xcuserdata/didi.xcuserdatad/UserInterfaceState.xcuserstate b/app/hog/hog.xcodeproj/project.xcworkspace/xcuserdata/didi.xcuserdatad/UserInterfaceState.xcuserstate index 4446bd481aed53e3406dadc52241e7685e6ac3f1..895136595e489fd7583abe0b57d83a4ac80d6391 100644 GIT binary patch literal 53238 zcmeFa2S60Z`!KvSvwd&(4i%(WQ0xlQL5&Gru`6I#Jm6FmIJ`SlVoY|@do|6NM9`S1 z>CH6LO|P0B(_?z?J>N6Cw+9Fs@_Y09zu))&5_8Ah?ewRYnW<{5s||)yQ(xc^hdILW zoWO~k#Hn)!t@Z_iwf=^AgS>(2<+bpwd{D^WICqf0YK5;l6zsvFlQ&m-+|$Y?`^zxsZ+w&;AFjpqxB;)l&G=}%4j+R}d@4Q-pN`MK=i>A5h4>=8315y|a4X(|x8oi7W_%02 z6+eTY#n0jA@m{cpIksTkPFF0WFy&3T1YF|L3WaB$#vvray!{Y?j{eDC&}~VMe-7PnY>QkB5#xT z$pP{)`GkDR6Q1VZ!qvhx!ggVY zaJ_JwaJ#TexI?&CxKDUgcuaU)ctUtm*eARoyePaW91uPgz7)O^z81b0einWa{t*5Y ziI^bv5&Me$#QtKUI6xdICW(W@!QyZ+RZJ6e#9T2?%ohv9La|7kB2E=&i;Ki+ahbSW ztQG6URbrF4T0B}@CmtiN7f%+?5-$)h7B3MuiJQe1@hWkfxI=tUd`Ns)+#^0BJ}N#Y zJ}y2XJ}EvWJ}15`z9POUz9oJrek6V@el30@{wiTfmRyo9MM=?8oD?thlln`E(f}z- z%9e7ZTxpCnRvITAA&r;vr2?r$njlS-N~Nh%nN%*#mljA1rA1Puv_z_rd{V7cFRhl= zOD9ODNM}grNaso$q-&+?r0b;{q#LE1q?@H%q+6xiq}!#tqz9x&rN^Y_r01nqrPri4 zrMILHq|c`SKij zt~^hkFE5Z6%8TU1a;3aPu926^b@IvbDe|fEY4Yjv8Std3E>$)vS14C0JC$pdTb0|C-O9bnOUld2E6S_N zYs%}&e&r42P30}+ZRLICQ{`*r8|4?}SCy!|+DGlH_EY<-iRu7#pqiu(QU|L;)Ddc$ znxp2bd1}5|piWXJtJP|a>Qk4h%hcs+t-3;8sn)6W>MC`udaQb!dWw3gdYXEkdcL|t z-Kk!yUZ-BK-k{#7-lX2F-lE>B-l5*7?ol66pHZJxUshjHzfr$czf-?ge^7r^e^P%| ze^Gx`e^Yk`+ct}9)eUDvp_yLPy4cHQE-&-IY& zVb^1>$6Zgmo^ie6de!xs>vh+guD4ttyFPb)<@(z7tLsl4>qOV}D7}{+ttaS1^G^trUZPLaXX$179DT0dsISrkdQcDPP5NqmjlNcI){oYY z)lbq-)=$w-)z8+?(J$67(J$3E>X+$T^sV|m`fmMR{XYGE{Q>pj6udQ zW4MuQq!^=(3}cKj*2pvRjbdY-G2d8VEHoAwi;YTSiQzSBjAh1hqt>W5f=0+#W2`lf zG0rtM7#A9s8XJwvj4O?+jP1q_<9g!`<4)r)<8I?2<6+}5<4NNg<0a!|<2B1U2ffNxZQ4#JIdX|-P7I6o#-Cm9_UVT4{{H74{;B5 zC%bdpx$ZIUvF>s1BitqK3GPYm`R)boh3-Y}#cr>=%DvQG=WcWdD+V<*)z$6ebk5-1 zoQLbl#mt#jma@{<9Gnk-!&=$wLDhBMV6c^o;(D0aB&}R8E*fMPKHsdW^b9I4D9Xwz z9+Qz=keXGToSBuAmYkcPmYP~f+2svTUF%8?4t zM4`VS6!6#8`2v-mBilFD@mWS%an6|h-1Owk?99~U%-o`a6e_Hn30*7o|T=Nn3j>7mY53v>~EQAS&8yq?g&7f@!XLNZE9d4 zr$@=#s~r6wVZsZdl=7eI{cm}C%8IJFbi?h5>d~`TbWHEqxcG!Vef#xK9FUllGRQHTVFv!J4Y{^mN*dAW3PFJ*x?UMr#@{!P1(wi=A@Qn(C{30U%}`$SX}& zOrn?^{1c*pmXIlX7~!RQxF|us!IM5ZH7z|OGb=l1(u|q2<}O^cxT?BldF_e@e`Ba= z^|8m9C!BKXX=j~%&IKDTymaGbS8i_EdX>#s7;{L=&CVZ_mYJMgl39?PSq#i!Om0zD zazS=xae8s;n2fBF96+dn>(t*MBG-J%TZG}a}B@-r^eau8N$sA%1H%FPH_f4KMcIve0Kvn>Gi)q*ptf(k0 zDhmaG3BbTQJ}Luf+WZX`EGIj)FlS70T5^8jm>gKJ?1JRntn`fJ{M5p<;-Z|itdg|+ zh3gmzj!I0+%uHRsx2(Kk_I#T~Hvq=XwLkiT0POYyXswt7=sevrwAmo%&7W>@P8W2V znwg$GrZA@IX!DkW>MOh?Cjjs%!TW+($bw-vj$eyO*ea+ zu~q}wnHlLYgu>*UVwgr|Rsk$vL26ENMtVtBPF8kSW_DUyrx~mYOgH1rgugQb7{aK; zF}dmMUs$ttZ1d6Uj#&>p+My~N((M;LEKT}#EYX>O<2n{-$4$(B9^UM4_5}v8Ze2zW z06jZ>{iwus=^0sUEz;96*255D)~$nYsn&xqYNTbYXShL+j}W~*yjD8L8vBXEk2%>K z2u%;D^z>P`E;&0p1KLXkjOx^S0((3r*9IMX+O7+$t<#4ebEY{M+8X2xCBqtI7~pAX zY>d%uV+{YE31heFpS$ju^UYyUe`uvAe%-n;=`c^W$W~uDY|=yr4mx(nTd?gK~AL*N8@6g>`(pl2`vM^7p^aOU8X@nztcxfS1y9|C90 zi+DeN6I?Iv;`i|f_#<$@d=1W*@9~fLXZ$Pvo$#bTI9akuE*T5Xl?Dosl zPqu^m)8qpMw7f%ESK#O=6nEQLc)PG!jSNui%6U=$H)Q_|N!^r*%4!sWlN#o5P zd$~L=pDW-ByJDcARZMLPF+Gu~h+Cjo5myYF0sGd*O2f+Y1a2l%VJ8B_CUKLwDcn?U z8aJJrVX?Rvi%-Q_%%%@hVavF3t^%ai9BwXrn-7e*ulb_+lKGnXy7>ltdz-1S9*|#U z%l&Jnc~{pi^McB1{}lO}AMklWpMg>pq1w9IP^~XGr?w`v+%8ty5NrgWsz1Z%vV}s%hCWrgHS0>Z|t$nkNLjjmz79b;c0sVG(p!TvHov*HPe?g4|8H7DV%M z5WXw8l^~Mqxdsr{jod0OVDgVW<;eYDN=u$KU7=o z3tFSHK1Rk?=@}6zRpxIBRQtk;M@4D72^22(HZ1eOkP;(>JI*ko+*Nu;9->GApv1~X ze{Dkuv`>aLmSNKO5T(nS>#O{Ahn`^xSVVwe_Hy}ZBJ**eel~X@m$HpJhdY-$k2{~c zfZG79ca)h7+;g;MVf-o~J>rcEIc0aRxOJTMR)jkVn(xB8t?`mIZ18`7p zU0t)&O5uzOn-8*8SzcRL19hw=0DHyhu_I{!v6Zr}ZJ@u(z23sTW)_-7uq4ZDAiv4I z1#k?5xXc&Y!oA78%@tRGs_qNTOZTQ_q*bRbO|=_%&%zoH2Par6dIKXs zJy9+oW7u$u_TMaqo#O+p$NmWIaN$W z!_qc+)2?u=-G;BGqSPYx$RlSw15pwevw5>wWmEJ`XhIZv7It;;%lIX6m0X($~GBG7l39G}6W>a%=|TLMS3JU`}gbJ=vy z%?CT&jCr-dhQhqc1Foo0el=(&AW1=W^(_uoGvDD1e-Id+zhP0((uP19GS;;@BY#ih z#P_ZG=%Po8`vs<9>x@(vg+l&dSB;f%)1VQwY3@s$8vC21M6;& zUf`DiuuosQH0TQz_?G$uZS80L5A$C58Dr)Z;j6>Iv*|KQd-dhw9pKhO|57#sx()dYt5{=AUCbf=fj7J zQtP8rsRk`$m3(M{xyB5!4?g&?#>|@Gl&D9mxRfpMz>gZ?L9=-@`{I;?lI>arC8Dhh zTUoGr1D?{(Ymoy!mh?<;W;k6OjgE;-a=p3Uta2Whh;k{wB(j_zPDH0ME_M=_>!+Yo z&Erhdq~`Hk(dp<6bS65>Ji$EKJjFcYe~gQ5Ko_x*Uud4#f-W{sIxH`m9)%kYW-v5Io$R<^2}-#_DnL?SfsKO|q8-7`k!@ zI7)qMzr=|B5A_EoZ8im{GWX*Uh_~R4*vJ{ILM4BmQE7#US=V#WcTCI0-KurP9Mj7N$Rf9p_ z1i4ciAt<?s;%Ty$D%QuYv~qG3cG2fwuV@}P!U=R zc}!=5v+8E_j4cQvj+yj`V6Z-*PYxp(9fGa$n-WfUjm?A{fs8Kq5q-e4#`Z z5=aCT^4G2Q1rtO5L@zvUSX#%-%Z6o%5UR2Y2Q62CT|XtUJkShvpeid8f_AO8Qaqf6 z90?F+8E^TxD!_fU0FvsKfuE-t+&3m9)tv)=fQz}?Q2aqajyVX(eH=)F>yklohk0fT zy4F1FFClp&K=S6lg5=o%$=HsN^hCw}=a6g_U3(>2(GCJmiwa_GO>NCGU#NI3*v-t0 z^>BNElWE|E>!f-4n22U&EK;;otv{Ab8_hm2EIF#RM|5k?UJ)#5)45`Lw?mhXI@h$a zxm7i**RDVJ%4=@Ab=UomKlS39_DV;zuegpY&%NcW{A~!l2V22cybHX=cbey#=b7i5 z7ckZO9<FgWjv?H4A!Im{Z>=?PW;{-6Q%^R9$ufun9@-8QrS(YNv;K@Lou9=x#IO_aD|n zBME(5)jknj*@N~%c0_lZK-Co=LK8`YTh&3cZARuHdm*i&`wc9b4h@WqYEkzlr;P4q zwaFiBc;#lOmKN2js(T@~p!@Y+JrU~Vz<6`#ThY}uERkmK*l|a6vx!o%>f<*U$DP@; z@U(kdk1A-*%kP5Y78Z5pxGg2n^@LKuf{6g=ibhCyv5Zw)1bc)I(s4edo!5ZM6o3H7 zsgPoR4kVS|1n$~PJ@Cf)6Qhar-;AAO3xLqDV6u!LQ>KO}1BK!SE5E`jvyxp)Dtfs|}N4nb1( z88+{as9KXc$^v|r!%ey#f3$wJgg8EZV`)z+p;;E#L46F^4f@BFv)1 z#JnbAY+(^g9gHokVppW3ZtiF+>A_KU$u3;+X{osp!wyH|eq72mI0pB|u{aLL;{@CX z_cgCGuQzWnZ!~W*Z#Hi+Z@mUQ(Sz{-ZZPt3LouYJfk)afZ-dtwSf|@W7_(GN|3daEy_St)ig`d_{%eKeOBDs-Lj0AgqK!3P{&YG4=@gQ;_7unzH=bI4vAN zft*8}iL*AFyKGn5cAV47VkZv3$KWHt#K&XtIP*^Pu2wuAO#HiswQ8cd4`vz zZ^49utQ$KaZqZW9)(cP6X{r!U1X+oTa52!x1oIwqw|TF5-&Srno`fgkDG(RA-+aJ) zkTo3>cCdl!TUHGQXyJ0dKga^4eV95|>19?Qq-VSOuu z_$y#~bvQ|jg((WWq3Y$#6iYfpTA;l;QTFX0Z9hoI59upITwUN#>w9|pq*SK(@0GY|5b zf)>!tJr#`r{E)TecqsC841NF zGJHE8pI|;~KG%v*!Y7;0n|sZy@<0>ICUi5y%xHdFFLfJ9YI0EUPFbq2N-Z({?y%G}?=4F)a= z15051#^Md(@E~*k+TNX@Q*9@{p26i>e4Y8G`Bp0vMwS?Y>d}$vQy}o|tFfbvEUG^% z@+7RGvE1JX({-A@4L`u8?8LX@UHA@sC%y}^VeY}Z@xAyye82gQ`L6k%`M!C;{J{Ls z{K)*+{KWipCw>sP#>03Iegr>?AH$F1C-9SakNFw!ke|$-&0ox4DPBo&fZ}r~J{Nw0 zVMf`3M2K!fYHWy25mEwdA1sT1dIPmVKNF0;$_ju}cqQyC3p-rl4od~gbcA%$`RyMj zhQBn`g=!lishRaLBTx(33(a=Q7p#+Agr$O5k1hKX5I6~V0W0S`wl?L}bPO^;R2mSl z$hXuB9as~wGfEP}Kec7BcB}`@Cps==r~PpErOdejEPDCksou4<^^pHqV~xlG%WHT) zLzLIe&s*>t<`?!7&MuFvUR#tEzr$VC3Rz3S^c^BE#TdEX4N8ajkWm$7qV|ovNniX_{xAZ)w>LW!c8^4d9VQX6q54J=}2tf z>=2^au#`?rY@a{^8Q(19raBKtRnl4%(u7ceMH z2I*x5+4=&#HPzKHs+$4%^0Ry(tb!rHZkFU6^pG%QAdPGw5Hm?611J(G5?e_U8AOpp z(RmEaJ=kL82gBWlow*(djo_h$I`*;*$64FS2r`n4BFQ9$j3%ihjigf~Qy*PU9XTVNMg$&xv;8ZAeC(#If9HQN0OsR9(*bwg`is0gf}PHBo~h9 zPXj3oY!s9WD;bOl*swtDi1YyMAls+fmH`MnuE9ygVe>h-d{l?0te*wW2r6bxf!fZu zHf|kB6sKquMR61*P^3_lY<=xbQT$7!n3RwSFv*FeluRO%$rLh`Oe2t%>~V6Rr-bF^ z;x;m16bZZ{!X;;cpB~h9rZuvpvLb&ysB&$^VXZ7^Oke7>+4hVg4@DY9Jt>NzNTtYK z=@}gfe0Nw{yVCbN0zn+(!5Y@ zJs`WczLBCRih7tp%xMf&#*Q96nsM~t=$hJ^+R;n{7!9Kv?QLwd{&v<2NG(|b#0Lz> zSyKm2tz;z|YFCdy%_H@YlHui3p-$#yyDXk+*$$C=5a`LOZLAI45CE%+AWpB051U457 zi)|0aBos1efLdCK$@=LH`d$jGah=cBx>A_h1$kVRo*Wwmt@n%>_OE44jg9_5D8*)O z&a$U;DC2OilgQ~{N05^t_w`hA8by65>Pt~Siu!LQXOJ_=S>$Yr5-9@aHjtu`jH4xY zXvprwavs65YQ@H$yRa|MIh>iwUVp3*C-kens?T0d(gA(xgBEckOEAu zCpUmq(P7R9uih4NBe4=a4qvQm5_JZ93%S+yK(ukGJII~(_Bea2?L=QIxr?E*72LOA z-tJjvNp=s}P3|T4fh2o?JV+j*2t?snijJUYv?UwLDH=o3xQ?=64|&9v4cu;uawtlT zuxxXJ4b`V`))w+Kd4@boQ5r>=6lJw>;(N$GGT-Vc12`|ph|W&2Fw^E}0Od}T{)Yw!75ir|tmmIWGave%_ zGK+0!I7pB(!?(;EYzjIlTQxohqMwl;A@h!W4r%XSlCQ|ujsvooV3{@`JSR~!18fTTG}AVf z;@kSR$CeVIDUe{DAA%+}wYCA&`$kY)x_rz$JcWKZg#&i5fz8xqnbL;U_Wnj-9-UjM zvv#LNG!te~bZ*7UI{^(c$cp***nYwoP5$0B*&*DVoUKPoQP%(6%PG@WA({Fzr6R$X6Hg+7Pi7_{efN^#<^RxR_RcAfH6hREnmx@`EvedOG+w zsutIT)y*lshGn7U3)tK(^~4v95~Gvu`^vHuAlPp0(dCo*RMu1q4>UcCqOw*zm>+E3 z&Kj$#SPGL59@HSP56Bs+UO9z1Mj(Bx&Ih7&*ziv5gU=3ej>0BzLcNDPo`)tML_KfBJ6|DT-j>Qffd^{uA60!pBlqAH52DXO8!M-gzcWfU!^sFtD?6s_FJ2W-;kSMzK5 zwR|%W|2jtebq?`E8%-3grsx#L08ahi#{ifrXw!_P3xTZ-#GBu=7WT13+z}Qt_tHv_kzD9EUM3#0N-Cx#KVd`VKC+>uFa=Whh>0KbFZ345)sgWXm)P_%}kwG=f| zbTmclC^}{ve-mKcE&Q#>$3w&G8J-icW0dpQQ+Ln_s_f@$1tL1Ih9#e@$~|zWg5W15ZW^$RN_>IDb0WkI&t@`YixJg%E6oz*ilmOBg~!kGm*%$Y9?LqUiV`hY$r z^bmRqy@Y5XM(8cX3UNZb0Ad>m@luL_5HF)>6GfL(bOl9MQUqkyvP0-=Gi70bFp%kE z0tELcYIRuhRTNT`EPZU}|2}ryS+o0}*2jbl;IRT&7hA$SR>%P!E97#!*^8pB458o+ z^fBE5^-)4Tkf;F4^|lrPl5m~ zMf)ha^I!@WY6M>!h3}&1#)BzbSP2v^)Cu(hh^LzufT=y%U+6HkscMCi5}fB=gao`fq2t31*nc?{GkHny`Tp`RT$L z!kNNZ!r8((!nwkE!ui4l6x~hHJrn`T+)L4Y6x~nJ0~CQEfQ5Z{hj3w-$S)N(3YP(q zU(Sepk3-~7Pz0?0IVKFB|Ch=wpF>Izg#15cbqPBem0wHIBMz0{z^MF2;U;(iCO%5h zW7hlQK<8i{P;QLfJY5jhIsRF_S%=Q9VKCM-FnAu*>bYXi% z^*ALdFU?k^Jc&-nPYKW3)c%Y`?Ry!uKg^iLGo6@)6@c#~!G)KAzF!euWtNq&pV9X# z7Ja`>(Tk2{^;RePeoJ_}jlSQY=%r5d{Q;xz4~36}k12YYqSq*Ty^X$K5vC^6#i?N!ru%p zyU2?W;o2bzq9{tDEGnWZYNAWjMT4SuD0-Ko_b7Uwq5~9tK+%U3eMAu~#3wsMk4@oX zFE}nBh8rrzG7A6Hq3|y#`j(>a7=?fTFNOc}6fO<{3Kxe`^jVm~#SuW^;z({cdr|Z` zLnwFy`o7YEb-I`dv@K>(^ks{fMbTH?qiu1Fcm$B9IF`}&*NnEs@r<^=>58^{H7Diu z3e)ylr{iLAqD9*xj0q(94?x@Ect+dbbfWD}WGzk;r<0}POh(pXxmZCF1pXoZK+#WO zs>X<7zJt3}oFmR{qv}5?`uSk078e6miOVl9cSVK^BM1?&`WXKeOb@jCH(@doim@h0(R@fPt`@ivO1DDFXV zPl|g{98GZy#l0zxr8th__#NV|Fq^&G_Fjwk0h`4MVK$5VIo@lW^e>nFFL2qX8JB&A z;ywl&AtGSbz`}WdVJJ%jrIC;)EhR~Vq`}eB0-aTdkd z6z5Q!OYs1lhtVqhaOzl~rphtGs~} zh_=BIK0Z5dODbd~izq(wFeTYRECr>7;dNy$P1ar8e3J6&@+`H*S@KEJH(lluX zB+E(DDK2c0W>Q?lR?ls1HDhPoK+2Jm-YHc`^R`H{r8&}Eii;^Op%~c2#F;5`Q$}0c z0E2TP#nP15Dk71EUy&!HF~GLK@=9Trf$km5xYFQ&MX;w3wz7F*IvSJ{$Q zf&;`T_BxWbIwEP8{VQqzc}Xkn0!b^~L2*@B(n@!Or0ptB>;U>f>0yww5-6NCEz%x} zech9@(&N%omYkKIwB+nkkeHFs?mngZL>wR*EWKa}+2x0+Vo|j8y0qWU$L~Ob z5NNAj*($xkl1*3=f9HoRi8Y+W1o-#v9yj)o&7d!0|X>icX-IgPzEV% z5Kx1NRwE?rLM$$(LlLWWmbL1PwV#{_QbX=f@%k2d0EL|pAT^>O@h>uGOTt?0TpkQb z*76W}D8?wI2bDn| zIDu6@(X47);-tLuJBUHKK%N5UEy{&*kz6d7$P?s=a;ZE?2F>tficg{VREkfd_;iZT zp!iIR&!YJ3?G_(pCx^3t5a9{$BVZ?ovwwE#4J-o-PDip(VINcj?4I{A$;TEjyelmn zoE~|oqEl+CVCPnI$I_z?ReCCDF|eD^+p#!1!@SFJlr>cao8i#t`i|vC9IE_`(xP&B zW*>&1(GUU^?|H0|4 zUT%ie4!J@0%Z>6XIUon+klZA%meV zI`JJ6c3_Vrq!(EmFKn)B!7fTWil-+mm zv3DH@m13R6W&ie{ClyzE#vabz3Xf@;b$pGl%%XSOqG5S^f8SV>uaa-!Qm!Kp%2x~f z`H$rt@=n3aA1Pli-yohS-y}mU5R9~|D1u-FAotZ2Z?^)EpoH$E_&SQ+6yHGcP1nh{ z%D2h4%e&+|W_Siw;HmR<wxb8NS|Rx%z0V9MzVKBZBz5oqt{kd z1%k_t_O7g68SpJzQBzm%t*#$!Z6<`EFWar^tEpwli75?mZY}H!W0g~?*k(g$5>6-X z@ZIXdrh?$;!<>iHe!<@h&j${T`461a4)ggy{u=Bz`9t|5`D6JL`BV8b`E&UT`AhjL zif^U(Hi~bjco)TBD%?r&T@>F<@jVpp-XVX(G!1U3{DW=3$*?_);(KkSgJOtJ+j+jQ ziO^CDJ7=!`o9D|;Os?_S3^(O(<@qWy*l&tL@qJrIGep7lX(O|zRy(oU5MTw#K z!S2~_O1#q7vfq?Gmi_intJ04LKtF8d<{o6gS!%VCWNm&ABbYKo8EzSK$}r2Ad*pES zojRkHv4E6Hs*Uil3zTDT<$__!)|yrT96DpWmU3 zvyoCcO3735xuFUiPe}1z2Pt2q_%#aqFCkd6AASS%ZOK=Ol)S@h*_~mZmWsS1T#>=G^UcLfO0;QJu z?_cfgzaRBVQr;-bO5>u?Sf}Sk1-9sen?VU!nEA$GT5zf~E3lV-hjO&CPB}(duNKWkC8Q`lnNtxXZib;^x^ zRLb=byvLA~!GiY+L=C?Ij@bz>oxW~Y?z9o{4hs>#0v0KPy8u{h=YYLa=RV~LhKctp z4=4{R4=E2Tdz43%N0rAEV2;q)w-kRz@%I$}K=F?h|3vZ66#qi;uRD|{!;+lzS`=4`)jGC^pWF|F(k{&H;79~Bq zhlc7Hl_fLrN$NNzrb#bADfLK(QY5+)KlHrhAXTASVj-ehY$0Or!_{)?Oi|}BM4YNl zQ>Uvl)S2omwM;EnE7aMP#8DDYNdhH(DCtW{KT7&jl1SmS1~PDmIya1n3&V)G1e8XS zUGa)YsMh>Kp2t>RamD>O1PY>U%0Uk@G0Yr=)QK}DVhcdtbuAatwjUX3^rU_(V4&4{H=L3xK$bCy4J&DrZWH;`7IVQcHYeC^cAN; zaCVy(uO(=Gw7yzD4TLbLnPrreQ&K?*2kMfAc@xo%z>0Kb>ih*3x0Av{W9r#5~x!*~;(XL8S&M1cBrZn=hRjxf;Zf zTeLBhEa-BRrc>@Ht-#_4TE4{*7Ig;*6SXpsPFkrpNt>)q(WYwCwCUOmZKej0sH9{G zC9u?0lvGnvLy3<`e*f!DS% z%rqU$JSn2UpZ+gm{_|>+b}K{7+bE$9V&1_J^G@w9c-=$E@sylky`KoE>G8;(Xb0dA zYAmHpdx(;gTeLltoYGD7d|YEGW!e)=c{-ITPukOSA;ZZxv^TZ4w70c)w0E`lwD+|G+6R=JMd4U1atEJFfpaaL0fH?HR6F3>#-sa(#;n^y3@4iH&ny z^BHC(xj@eZZ`+OFt#B=5E{>Zzak!{)J(BY5YwkGD9H--I*HRlbeHLooc9;g7Ds`?k z3^VIp4KBZ{(Y4AIa0Oix7m)IG#feYVCAgZ)4+i z7B)U{n0ii?TU~cTva;(o*X^!d@=i*gqU0G$AURg z+ojaF9<)(Ov~&sKVl>>}leWqhzSpvb|g z9dn`%)#_8>R-do*Wc`g++of&Uj#SsPpsuHEaXsgH-UTbNkCGQCd2tKC=0(>_3_34S z@-iblc2axgj47pM<(0)nrRCFSRhAV`DK0E8oj$E{a`C*b3KY+skq=p^P62i=cE=WG zmzI?lOevlP&9FAC%n{dq?)A-VQ&IS0;Pl+}qY~Gpr)I(hg}FI!F|Y&Vw_WddJ@5}) zAF_eJO3CYHmfhwjuv^LXDJ8F&MfMM0Sobvibtm7re(1WBpIkq)PTrv8EvJ*;SSP7+-vlUVy6<@U>!mYSXuSuZ_C zkA8(>V5P9Kreba#PGt<##5tuj`Tkl$Zdy&Mh$ z2#)WtUp4Z(eLizIIli4J(PxJN`V#PvL5UZq!4@(U%uQUWFcNRvM(`IGV-ifb|POG2rzs zH0RX9@}#^z;XKFfuRDEj(&0{7a5v~zFfE#oI!qf*m8*0(YIBFaO}|>dM&GXQ(0A(B z>euPl>o-uoC*^xlKAQ3|ls3H1s&>>x5C^>OWQr@sIhPuvm z(Cs7rQyW7+u`qPRVcKx2e67R9ep~c!^lx?83N?!IDU?s$qW_@(sQ*OybjlY~er`vd z=yzL$^2y!Yp~5V)LmyjV;Ba$rY(-gj%rMqmIBq#OJvF@5r`<1y=}HQBmG(C`!?3H8 z+a2{`iyuQZU_b9Qh9(U%bi***hR299dQd)too>Oy|5=pJrhE?Nb16UO8l#sHZNx~U zjaVbjh&O=D$5Q?X%0m`c9_5dsJiHfD-a4P*P^VkK2FCyK6D^FvU@sX%C_nBn=TulG zp)rD$97*}{hbj42Ct8FN(?~VaI_T_1rjZTCmjR2K-(tX`7BCyC%Y!V8aa|r{(e*hM zMuAb-v7-`WI)m#3W1>-NOfn`LQ;eww2#pfT!-mX>lrN?HBnl^%^HV54^%`S_G1Hi3 zlo{nlg)!ThLwP7Wo$@m%Ka=txD9R{5oAPtma1RC7|J$dQ7*&?Xz=onP>^=C)lSl5i z7?1<76~@Yr>)2p4GJyFhU*2M@qI|`n0oEkVgN>SSZE6DBV$SXYu&$(^2B*Dy>zaI1 zy@8cJ$Ej;H8%KBSXuWar-vZbv#;L|>0I)NRGmW#1vnfB1@(U=x$O716%2!g}OZlp> zzrZ+;VfFcxpC88R{OW3ODOo3?z-A-3OWirq#kh!-yqLmy@b=j+U6t%E{%}h4N9nsfnJkAx{HJ&mawMr^qJof{XbUJ_5*lTzGJnOu^ z)!1jeK=}sB`@8JizE;zC)!YO3^`ydo+3-qEO-)PB%u3BpFV0TSO-(DVv`t?7Po-t& zhA-qa_W$jg&Pd-h-eySh7Ufs981GO%zy|pD#$YYof$&18J6C<%ZXrH0;NrX%<73K) zT8vLA-vn3X0UX&ze*0c2I7nidGnTK6U$~g<#@EI-#<#|I#`ne##*fBN1~^&PP<}1t zn<;-Z<=0UjEP?fuKbG>x!Pzy&Z`@Gh593ca=SFS}M`9S1hZF?LQ_3Gt`4cFABIQp4 zrMGH+ZLqkmc3CYPkpWxTZGQpmI<2kt&46p5mihy5B7IEzpG$*tYT=3~IC%i%sQsX< zXtH$>Mg#0&@6d#EDH3E#*(I&Zt3q&1R8zn=FH#u}0%_j^JNKZD?M3X=Gw3PR0oYDI z+lG3D^{3J^ux53r#u~GAQ)yi?xHB5zcFe$7>&W@#exM@@HRjv((lS$0;a0@z0>6JH zxOCETM`eVU+a2wW2M?q>#@*W;>jqQe6w04U`O_$W`c`*>yN|oCyB~#H3V5)Z&Z7L; z3^8H=b{%)}moxu69L3b>!ECsu%(}d`^P}mF>{?q`)6`D2!iNa3dnudh>*2tQFqZh1 zS@-@`SiY$_X<7N{nW;6NPb~T49>!g@#Xa0T!VQ|lIh4PU@|Q%;6mh4x(?MT#k9McJ z(?o2mmR2NWw15A?5jq#U`bL-GI+<@s1SSJmzGdd1aumBFfDf7W4 zoL)FMrP4F}@TKiOETNT@XY1$OOy{s*_jq@H7lY?-au>RbAV-h#7g7FVGpm|Q9E`cd zz1$I;+dYvxa(Wc6b-c-5l+?L{;4m--l6ix?a0N8%tqznA3i%u74)RyA+suMJICS!6 z(2%B;0UKrKmDz=!mzDAW+}vlP8$5BEt7or1eTR=og$vS?#^;w#nmlD{*_^oxD=P*y zz%`BgbarcfkH}TOvj!*iq`V>a@Md#Wd zeb&m?T31yA!M4tr1t77n+=UwD6^kR+L9?ShnzxkCo&~VBJ_EAZb7hNeO$BPqn{S=E z!j_f%=@y=ZY5H(D3Xg0D-wz<=9eTa8U$tyNIHi{Ck!i{j$BLELaIoy*}$ zxhdRCu8i|@P24fuN!*#-rQBuQ<=mBUjrfh+9o$3Q9_~@@aqda(Y3>E?9quFUYwi!E zAP+p&AG<*r(gtx-Y!gu3m@bmb6 z{4xFte~W*DyIMUqUSkwyAJyT%;Xh=s*~4M^lijl*5g3@^RQEJ@ntO&D3=Uw4mr{Ns za7D^rM)^(K_%&pZyTUyi&Ym4)-5UfP^K#0A6AKQZwQf}%czB-1PzNN{;R`WE3Qk4& z>q2EAxS;qiOR`gjtdf>q=B~8Wkr{u^m<5LDipAyr8gFx=HFNabvlo0PSXT3%SM0 z4&_E=H|!~WPI+0`ue_U#A!m8vJGx2O-o_Jbd^{*Z87r(LH#rai8GqJ64;r~TGzd#|0nZtZnn zug7}5)a#92@Avw=*Duj}v^zQ~x@UBBbnoc6=!EFL(fy;-qcfwkqjRIjMjsJ9n(7|E+!$SZ%qG~0WnE2gJXuq z438;@DT*nHnHV!EW=hPom>Ds%V#;G?$IOkHAG0v#=$K1mcE&s%^J(v>-beQK_CBZg z4ZWZ4{Z8*MdjHz{_uhZTqF53u#7eQfVq;=sW8-7{#P*9#j2#$T9J?@fZR{1Xx5mC2 z`)=%eu?J$mi2Xf|k8{OE$Hm0O#-+xMiJKlbJFYseF>Za_ad9;6gt(L9PKi4$?xMI$ z;x@)@in}6ib6jiO*0_h`-i-SxzGwWv`0??R;>+SI;^)NAi(e356TdWmdHjm_y7-3p zljE(vss0n(4J0U6|I-y^}$b|HS+=POJ$q7>urX|csSdmbd(425~!nq0OCu~T# zDB+TXjR~6)u1MIN(3-F-;m(A+6Lu%um+(NsLkW8l9!+>W;mL%j6MpQI&?m3Y@;+zu zxv|f_KEL$s+qa@`sPBfpyZZh=EuHya5{BddyYKtnS{_j5RU#sa;(-XrAs`TlAcq{{ zEujb^h>9RcAc|B3qKJ4crFGX@wOaRmw^m#0Zr!c6%IdT4$%3kbYJx05-k|+K*MjZ^-4A*Y^eE_6(7WJy!7GA8 zf&sy+f>#Ge1ZM_k2NwsI2A78bL!v|0g|I`KL!O5G6FMbyTIh_>S)p@6=Y=i^T@(ro zT@|`IG$J%AbWLbXXl!U)=(gnkCh0L%u=1uOt80xSW91L6Tm05~8O zfCQib7yuSP0ptUU0Tlo#Kn73%Y5{cs4WJ%i26O;A0UH6E0o?#Ezz6UH1_0XurvZNg zUIN|#{s#O5_yG6>oCcf@oB^B#{00aBt^|ew!-0{&IAA<50SE&o15RfWmJY+=r@-C=vf_J^+yhlD4D!@}X=sp0A2 ztZ-p?LwHkob3{+XNW|`ly%GB(4n!P`I1zCw;!MQ3hzk*yBA!Hij$9HM8%c;{MzSM0 zk^D$Oq$pAwX^iwo4nz(`aidD3s-tS6lu@cEb(B7;CCU_Kj#nItp<@mY!Da32MIx9kOWi>l7nhMN{}981hs-Jpf*rD$OGyI^?-Uo`#~o_H$k^R zw?V&y?t<=t9)KQ!o`61r#z0?U$Hh*FofJDIc3SL=*jcgQSXyjhY-{X5?D^QY;BUb| z@JetPI0g&?gTcw*bZ|D90;YlKU=}zZ%mwqoYH%xf6SxQ53+@B=g9pJsfOmj*f%ky- zfqw*F1^*p4E^cw$nmBMAGA=tVH%<^IiYtsOiYtjz#Hr)-aV>GCxQ;k?oF}d?ZY1td z-0`>zaW~^$$GwNlfrLTgAW0B7Bo&el!9cJOJR}=Jg3us*NCTuBG6?w|G6D%e4nTf} z9EBW%oQ9l*oQGV5T!TD@e2$+IKQn%I{M`8Y@xk%o@z{7`d_}w>zB#@n-W0zf-WqR< z-x>c?{NeZ`@xMT)LFYq*prKG8Gz=OJjf7@F*Fp23Y$yjRfQq1n&?0C9v=`LrT^d|Zee@Hx-cq#F>#0QCQ51@Qjlzz=Zo(eH-X<+fTATz(TAdV` zv?d9h6rYrsl$1nDDo83x(j+w`>5>ddrlbu?ZAl$Tj-<^=TatcEx{)-NJSRCiIX#(> zT%25%T$wCOmM1Hd>yqn}8X}s{Uh~B>a*0Bsc%x> zrG7~ppEfCNYTAsnS!wXJk~C-9k+j=sZ_+-ceMujmJ~4e!dQf_3`pWdx>5=K2^uqM2 zbY*&7dVRVs-H>ifZ~br9adY}e`tJ1W>37ndlo^_d$`occWp-x{ zW`3VJnz=1=U*?g_W0@y1PiLOZJfC?n^G@d7%=?*-GM{EXLrq65K`loGp+Zq%s0dUP zY7GjG%0l5$G!!2tMirq-QBsr)B}XYx8q@}q5A_S`1nM;E9O@$K3hFxQSJZ9Pd(;Qi z80sr}0(vrfDtaDzDf(OV3Umk>fQ~_f(GYY38i!_}nP?W8gDydrqbt!ebPZaEHlR&t zGx|9C3i=xQCi*w@6U;cwM9dV-bj&QwT+9N@V$3qka!e2=6qATa#-w1qgeCtlL=+vtDMs$$FRdZ`P-* z&)9L;3D~*V1=z*dW!UA|71$&!21~$_uoNr<%fzy=9Bdi3605}Ou^m_kb~Dz6^+3N3l1t|Kb+o5^(EqQd}+0fHUK)xOUt|oD=89d2oK*cHCav zPq@>#bGVDRE4b^po4AL#SGdo(FSxJx@%Tyj`S>OHZ}8vYL-9a-7(N`IfQRGr@jSc$ zFUA+)OYs%>_4sPM1#iRK@f-0@yc_Sq58y}fKj3%b_u%*8kK<3_&*0DF@8JK!KgYkq zzs0{JOdw1qOeM@9%poi%1Q9|BKmwmoPN*cv2y#LbVH05sp^MN%@Dch6Lxin_0AV{} z7vVJF9N_}t65$HrI^kEsZ-hI9yM%j$2iX&{L$guYh1spy+p@1_|4m#-1QB6GI5CZg zAmaX0!E|CSkwwfWa*2FmIZ;ZiBGwRViFL%yL_cwuI6~Y;+(q0&+)q3}JVQKByg__G zd`f&yd_{am{D=6FI7XUD3L>SEGDs*=770%xl5$8i5`$Dpl9A*jC8>^7Pii2UNH&t4 zw2|Z_xk!Vg?@6PiA4tbYCrDRG*GV@?zmcAho{?UVUX$LDCy*zTr;%rn(PS!l9XXfG zB1_0>vXN@(RYDXofDL#w5!Y3;O5 z+D6)D+7?L1CVDg7PTxrHrhDn5^d0ow z^nLV$^uzR{^kej^^!M}+^fCGu#(2gg##F`(#%x9yBZ3jlh+%*k@r(pUIs?PNF|rwC z28F?86fi^#2}8!HW;8N%3_Ziha4p7DV(mK&Rk%B{|I z17tO3>#>p1HQ>n7_r*6*zQtcR>8tiMovqRY+b`G1yX0Y?v`D`9rz!tNM*p2LFb_=_eZDHHk9c&l7 zm+fZ{vcG4Ku=lfnVjpH7WnW|8V?SU&Vn1d7!~Vej#Qu^$Gk;G0{QQOa+4*_-?0jy1 zLB1=0PyW69SDf*jNt~&i8JyXid7OouC7f?Kk(@Og5GRfU<-j;_P8uhJlgZ(5iaBb| zCXS!8mvfqPlk+F%0p~I2DR&}w7I!Xp0e2C14L6%h=L)z&u83R6t>V^jYq@H!mfOg+ zbKP7Yx1T%2-O3Gcf8g%t9^szhUgZA4ea?NseaU^z{mh%fo6eiXo5Nem1M}i}i99Bc z&ExV4cp{#JSHdgjNqHvT23{MlgXiFF=568mc*DFA-ZtJ2-Y(t|-Z9=u-WlFa-Ywp3 z-XFZ@yjQ$8ym!10{3-nD{8{`t{N;QQKaLOO!}xH18b5=N;%D*kd?H`K7xN|jVty&V z;{Um=d^umqSMk++Cx0*hHvek@umD?7R$wj|E;vzevEWL<^@5uPe-`{z@S@;V!P|m& z1^);p38o5W2xbfB2^I)q1aJXDkSV|j2m+!YM?e)62r2|sf*L`spkB}*&i*USfl5nbUhH$oUu5g(!SO^fV5{3&Sg$cqWVTv$am@Om;bA&V@Unmrc zg+;SiZr+AZiSiDs{D&8jEF5W5LE#52MFFqhXD1KVFtPoRJQ|K$aQutW%tpqEfOBj+| zNuH!oqLNr7Hi=W>mkdjGOO8rzN$yDQO72S@NuElcOI}IdO5RI8NXCj57A-DXR<}&#rEQ(#V<<2OGG7YCGL_vCHqT$Dmh$owB&fnsgkoL7fLRdTr0U* z@>|L8B_B&ZmyRo)SURP2dg-jvxupwA7nd$8U0xbg8d?f01(kwJ<4Y4ut4bS6n@aVi z#?q6e*Gg}c-YUIQwy-Ru3{bYJEWE6stfEX>R#jG0wxjHD*^#niWhcvZ<+k#Ua!2{* z@^j_4%3qbgEq`DBp?s|TYsG|$$raNoW>(Cp09J%mgjYmXL|4RAfGZ#s&Zaz#o- zUWKy4U2(ADUgh-4HI;1o?$}#D9=_Kh?=`877=>q8@DNq_MT_cT=!lejlrW7M3NJ&zPlqMBOE2YiS7HO;0 zBDG2F(v4E5)Gh6n_Dc6i_e&2*4@wV9k4leAPf5>6&r2^#FH2vopSwP8{krwa^`7-d z*8eFRFAI{bmPN|e$YNxPGK35%L&>saI2l1klCfo6S%FL>lgNr?2AN&9S>}>?WImZ+ zHYgjG?UNml9h04uU6b9C-I3juJ(N9`{Uv)Y8>^aL6;u^kwX$k;RbnMSx^&Ov#zG5W=qZCng@zW3ZNogfmIL`Bt?!QPa#l<6%s{>qD)bts8iG{ z8Wqio7KKUCuh^m3tN2lIP;pdoTyaWqMsZ8=Sn*u(O7T|lui}&9i*lTDuJSu&tP-M3 zP$nr;l<7*O60O842}+Vus4P?#DNB@P%1Wh7S*@&5)++0i8s!${KINU-akVRJakb^O z8*0C=Jz0CH_G;~o+Fxt$)jq3zQTwX)P3_;c?^P33Q&iJcvs80c^HpnA$*Ob}QiWFG zRYX;eimED52~|>+T%}aisTx!|l|f}xZBq5A_Nfl24ylf)j;T(n&Zy3-E~&1nZm6EC zUa8)w{#LzLeNc_5zSNDYn^-rwZff1iI%Hj8ow06f-Q~J})JxRyYKoer=BW8Du(u~tg)J)OL z)Xdh*(=5=0YSwCy8ngzhA!tY%ie{ZASHse9G<=O*qtvK0YE8YSQPZqx(U>$FG**pG zGom@Gd0oG-KA}FpzM;Om{$TxY^$+SF*Z)=jy#9Ut*ZHnBFj}VNS!mh6N3a8PJZFt)l z)R@u8Z`3xr8~u%gjo&wpH12LZ)Oe)vSmVjYGmYmOZ#CX&yxVxc@loTGrYTJeo0c^# zZwhJxHib1sG(|NfHDQ`)O^l|zru-&elb}i5Bx{m4DVkJG>Zbap#wK0U=B7POSDW7I z=IUZ~sXC+%t;6bwx*Q!%N7o5-l{%SDu2bsjbQ)c&Zj;Wf>(=$^26V%^5nVucP)2m->vuR`}F<#LH)3PkAA=YC;eglQT;LfJ^d^F zJN>`d{*l|f@@Fz5^hgUPVL z&}Qf`I1HN&BZh5;?S`F(-G+UJ1BQc!!-k`VV}=uk2Q8CY!dq}HJuTN;-WwMh zzcU6K0mhZaHAbirW`rBlj2XsEBifi{#2FdJJY&9*XA~Gk#zv#nXg6*&I*lHq*Vt$5 zH|{d-H6AmbHl8zHG+r~_H2!A1V|;G>XqszUU|MWiW?F6vGKHE}npT@4O>0aUCX@+d z!kX|VqAABjHLWw{n(|C+lhU-w^rPuc>x9*K4-pQzGS{)zHa{2eB1oH`A_qG^TQ1j zHvl%EH%K;^H~g^S+J<+QMV44gk|o8GZppCVEi? z#bp_^j99i=c3AdW_FI0k9I~9ZT(aD<+_pTmytKTryt90?j9I=~$6M!FS6IQ;cx$3H z*_vuaSTn5{E6$p2C0j*SiM7~TYAv@)tyNaJRbf?G)z*5e%evqCd)xT7Rc-jTiZ)Bz z*0xh^m)owj-E6zncE9a;+sn2$ZGX4@)Aph5lWm%9rfrUGzHO0hi7n2SZbR9!YGR6iA`Zs+qAYOTZ_$P+hDWWx@^O?9k$)JeYOL(L$;H)Gq&@#OSY@F>$dy0 zhqfoSXYH%oA??t1SUbF3-EL|(w_DrW+fTJ$Z@<%ixBY(mqxKi=uiD?XziiD_ibjO*Fa~+R6-gS(1 ze6>%oPqxpp&#}+9FS0MO2ipPmRrb|(kUiEOXV13h+4**%z0h84m)d1^xm{^j*>!e< z-DEf0JM9kpCi`~#A^Q>gG5bmT8T&c=E&F}@Bl}bPbNeg%2m6@)Yv+W{$(>U>6FSA6 zot-~*-spVZ`M&c*=UC?#$6Ut($707a$8yI?N2~+tfH~j}gd@{|c4Rq74!Wbnp>QZ2 zwT?Q6(P44e9CpWMhs)t{^f*Qw0mpX7u1)JV)o*Ipq}yb0W;jVsp|j9g>@0IuI%Q6| zQ|YX8);k-W8=X$4%h~1ZcJ?~`&Ozs}bHo{N{@^_Ce6eN07RZ*|Et)OfEr+-KvE{RC zifg)SmTQh{sVl?bmcG?VjSE?w;kI>t5hq>|W+x?hbN?x>vek zZn!(uo$k(Xqug0;ygS=Xc2nFmx76L{-r>IH{@4}LmDwfeYV7iK4Rj56jdTUN_ICZ; z^-I@@uG3xTx-N8G?z-A_z3YD0qpqi2&%0iAz46TTeCG-Etn{q*M0?hHVm)!53=hUb z_E0@MkH{nOlz1vV>pj(;8js%7;cdw%!A z?j_ydbXRoOb!)mCx^>-$yU%t1-u-9ygYL)Oe|5j;e%<|d_dh+;dS>>_>6zcNsAp-< zw>>L*LV5r_@E%f6agU;>wa48v+_R%+chA0_AA7F$-08X7bHC?d&sT4-H`=?#yVeWx zrg+o6NH5xp_2RwxUa_~#Tj`Z~Gnly#IPX_AcrT>5c0p_Og2`dK-GJy_`jh?X{tQ3LkMn2y$$pAo=r8kE`!)UszsYa&JNzF1fPdJ($A7?o(Eqdlxc`*@ ztpB|KuK%h3S^x6>mHlD;>-r1&Mg5ZglK%4k%6?D(K>xP>9sP&|9t=L z{=5Az2F4Fe8kjmTV_^2cyn%%SO9s9f_--I(Aa(#U03Co0zz5O>G6qlsm;vknZJ=a8 zH?VPFWZ=la*?|WGUj`=+&Kv{{LI)9p0rg+`a#v8X0TyUH)t3%4tfU< z4Bi-;IW&Lho1x`HD~FH5k(P-&t#c17V{b=K8 z^JvRx>!@Y4ZFJ}8uYpN{wSj~{dH@-q1Xux1fEOqTR0P%sssc5E+CW{PInWX?1XU?3t!yhZjmEXYG;~{x+ zPL`c|6fv4eAQFirVhoW?j3rWtR6<2eB&vu>#AIR$QB6!GYKU5*j!+ZRi5bL4#7trq zp&=F%ONdoOJF%8nM{FXt632+oh~vZw;v{j3_?-BHI8B@*&J!1iZ-^_z9pWzWBXN(o zPy9?gBAyYy5if|B#Gjxi&{qQk&pg(X0ZonN303N^- z36&M34l=fMk#kGC(F65Ar}hCVx1AI10W1r@;wBlcQ^ogKu_oeyr@pv zNNduDv?c9Gd(wmSB)v#)GLDQVhmb?bVdQXf1UZr%MXE=W31lLfM5d5RaspXM7Lmnd z30X=`A*;!$WDVIw&L@|V%gJ_fExC?dPi`T%k~_#fr^qwpCGtD+ zHu)oYk9Yq>P;C@#*_(VN|{l8sJ>J`%93)R94RL?Tx8NDZQbs9-9DilGKmu~Zy2no6NEscb5TDxj3q1geB8r79^EHJO@C&7tN}8fqRj zpISgIq!v+&sU_43YAv;i+Dz@Dc2j$(z0_gq2=xW^HFcS~Mtw(Jr*2VqsRz_g)MM%i z^$Ycs`jvV`s{t*e1zJw))4gd^+Ke`*`_q2(K-!-UpabbabPyd(htQ#P7#&3qp@-7L z=ma{EPNUQ53_6d_r_1O`bS;hOdb*jOM$e>Y(HeRlJ)d4cchZ~a&GZ&}E4_{0PJc}A zpm)>z=uhbV^r!T3`UHK3K1-id(_hkG)92}L=x^y8^ga49{VV-D{RjPm{)>?@EW23Wd^cdGGm#!tiQ}wW+!u# zxyuH~JY=4-09l}Hv@AiEC`*!!ktNH<%2H&hvT?FB*?3vLtUy*GE0s->O_ohj%aE*I zwnVm6woJBMwnDa2wo2A6>yWLMt&wezZIkVm?U8*dJ1jdUJ1sjSyCb_R`%!jJc3<{D z_LJ

}S~{*<;zSvX`&1GrKCCb6#|~r% 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π!T7Y0kBRBwY|@f5k2Sd^ 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 b#oK_s^<30d#fQ!Gc?r Bool { return fileManager.fileExists(atPath: db_path) } -class SettingsManager: ObservableObject { - var lookBackTime:Int = 0 - - @Published var machine_uuid: String = "Loading ..." - @Published var powermetrics: Int = 0 - @Published var api_url: String = "Loading ..." - @Published var web_url: String = "Loading ..." - @Published var upload_data: Bool = true - @Published var upload_backlog: Int = 0 +class LoadingClass { + + var lookBackTime:Int = 0 + @Published var isLoading: Bool = false + + func loadDataFrom() { + fatalError("loadDataFrom() must be overridden in subclasses") + } + + public func refreshData(lookBackTime: Int = 0) -> Void{ + if self.isLoading == true { + return + } - init(){ self.isLoading = true + self.lookBackTime = lookBackTime DispatchQueue.global(qos: .userInitiated).async { self.loadDataFrom() DispatchQueue.main.async { self.isLoading = false } - } - } - func loadDataFrom() { - var db: OpaquePointer? - - if sqlite3_open(db_path, &db) != SQLITE_OK { // Open database - print("error opening database") - return - } - - let lastMeasurementQuery = "SELECT machine_uuid, powermetrics, api_url, web_url, upload_data FROM settings ORDER BY time DESC LIMIT 1;" - var queryStatement: OpaquePointer? - - var new_machine_uuid = "Loading ..." - var new_powermetrics: Int = 0 - var new_api_url = "Loading ..." - var new_web_url = "Loading ..." - var upload_data = true - - if sqlite3_prepare_v2(db, lastMeasurementQuery, -1, &queryStatement, nil) == SQLITE_OK { - if sqlite3_step(queryStatement) == SQLITE_ROW { - new_machine_uuid = String(cString: sqlite3_column_text(queryStatement, 0)) - new_powermetrics = Int(sqlite3_column_int(queryStatement, 1)) - new_api_url = String(cString: sqlite3_column_text(queryStatement, 2)) - new_web_url = String(cString: sqlite3_column_text(queryStatement, 3)) - upload_data = sqlite3_column_int(queryStatement, 4) != 0 // assuming it's stored as 0 for false, non-0 for true - } - sqlite3_finalize(queryStatement) } - - let uploadCountQuery = "SELECT COUNT(*) FROM measurements WHERE uploaded = 0;" - var new_upload_backlog: Int = 0 - - if sqlite3_prepare_v2(db, uploadCountQuery, -1, &queryStatement, nil) == SQLITE_OK { - if sqlite3_step(queryStatement) == SQLITE_ROW { - new_upload_backlog = Int(sqlite3_column_int(queryStatement, 0)) - } - sqlite3_finalize(queryStatement) // Always finalize your statement when done - } else { - print("SELECT statement could not be prepared") - } - - sqlite3_close(db) - - DispatchQueue.main.async { - self.machine_uuid = new_machine_uuid - self.powermetrics = new_powermetrics - self.api_url = new_api_url - self.web_url = new_web_url - self.upload_data = upload_data - self.upload_backlog = new_upload_backlog - } - } - } - -class ValueManager: ObservableObject { - var lookBackTime:Int = 0 +class ValueManager: LoadingClass, ObservableObject{ + @Published var energy: Int64 = 0 @Published var providerRunning: Bool = false @Published var topApp: String = "Loading..." - @Published var isLoading: Bool = true enum ValueType { @@ -239,17 +189,7 @@ class ValueManager: ObservableObject { case string } - public func refreshData(lookBackTime: Int = 0) -> Void{ - - self.isLoading = true - self.lookBackTime = lookBackTime - - DispatchQueue.global(qos: .userInitiated).async { - self.loadDataFrom() - } - } - - func loadDataFrom() { + override func loadDataFrom() { var db: OpaquePointer? @@ -304,7 +244,6 @@ class ValueManager: ObservableObject { self.energy = newEnergy self.providerRunning = newScriptRunning self.topApp = newTopApp - self.isLoading = false } sqlite3_close(db) @@ -335,6 +274,7 @@ class ValueManager: ObservableObject { return nil } } + struct TopProcess: Codable, Identifiable { let id: UUID = UUID() // Add this line if you want a unique identifier let name: String @@ -346,8 +286,7 @@ struct TopProcess: Codable, Identifiable { } } -class TopProcessData: Identifiable, ObservableObject, RandomAccessCollection { - var lookBackTime:Int = 0 +class TopProcessData: LoadingClass, Identifiable, ObservableObject, RandomAccessCollection { typealias Element = TopProcess typealias Index = Array.Index @@ -356,8 +295,6 @@ class TopProcessData: Identifiable, ObservableObject, RandomAccessCollection { var startIndex: Index { lines.startIndex } var endIndex: Index { lines.endIndex } - @Published var isLoading: Bool = true - subscript(position: Index) -> Element { lines[position] } @@ -379,16 +316,8 @@ class TopProcessData: Identifiable, ObservableObject, RandomAccessCollection { } } - public func refreshData(lookBackTime: Int = 0) -> Void{ - self.isLoading = true - self.lookBackTime = lookBackTime - DispatchQueue.global(qos: .userInitiated).async { - self.loadDataFrom() - } - } - - private func loadDataFrom() { + override func loadDataFrom() { var db: OpaquePointer? @@ -433,8 +362,6 @@ class TopProcessData: Identifiable, ObservableObject, RandomAccessCollection { } DispatchQueue.main.async { self.lines = newLines - self.isLoading = false - } } @@ -459,8 +386,7 @@ struct DataPoint: Codable, Identifiable { } } -class ChartData: ObservableObject, RandomAccessCollection { - var lookBackTime:Int = 0 +class ChartData: LoadingClass, ObservableObject, RandomAccessCollection { typealias Element = DataPoint typealias Index = Array.Index @@ -469,25 +395,12 @@ class ChartData: ObservableObject, RandomAccessCollection { var startIndex: Index { points.startIndex } var endIndex: Index { points.endIndex } - @Published var isLoading: Bool = false - subscript(position: Index) -> Element { points[position] } - public func refreshData(lookBackTime: Int = 0) -> Void{ - self.isLoading = true - self.lookBackTime = lookBackTime - DispatchQueue.global(qos: .userInitiated).async { - self.loadDataFrom() - DispatchQueue.main.async { - self.isLoading = false - } - } - } - - private func loadDataFrom() { + override func loadDataFrom() { var db: OpaquePointer? // SQLite database object @@ -544,18 +457,14 @@ struct PointsGraph: View { .padding() } else { VStack { - if chartData.isEmpty { - Text("No Data! Please enable provider app.").font(.largeTitle) - }else{ - Chart(chartData) { - BarMark( - x: .value("Time", $0.time!), - y: .value("Energy", $0.combined_energy) - ) - } - .chartYAxisLabel("mJ") - .chartXAxisLabel("Time") + Chart(chartData) { + BarMark( + x: .value("Time", $0.time!), + y: .value("Energy", $0.combined_energy) + ) } + .chartYAxisLabel("mJ") + .chartXAxisLabel("Time") } } } @@ -637,102 +546,123 @@ struct TextInputView: View { } } -struct DataView: View { - @State var chartData = ChartData() - @State var lineData = TopProcessData() - @ObservedObject var valueManager = ValueManager() +struct DataView: View { + + @StateObject var chartData = ChartData() + @StateObject var lineData = TopProcessData() + @StateObject var valueManager = ValueManager() + @StateObject var settingsManager = SettingsManager() + @State private var isHovering = false @State private var refreshFlag = false - var settingsManager = SettingsManager() - var lookBackTime: Int + var viewModel: ViewModel + var whoAmI: TabSelection + @State private var text: String = "" @State private var isTextInputViewPresented: Bool = false - - init(lookBackTime: Int = 0) { - self.lookBackTime = lookBackTime + func refresh() { self.chartData.refreshData(lookBackTime: self.lookBackTime) self.lineData.refreshData(lookBackTime: self.lookBackTime) self.valueManager.refreshData(lookBackTime: self.lookBackTime) } + init(lookBackTime: Int = 0, viewModel: ViewModel, whoAmI: TabSelection) { + self.lookBackTime = lookBackTime + self.viewModel = viewModel + self.whoAmI = whoAmI + } + var body: some View { VStack(){ - - HStack { - VStack(alignment: .leading, spacing: 8) { - Text("This is a very minimalistic overview of your energy usage.") - } - - Spacer(minLength: 10) - - Button(action: { - self.chartData.refreshData(lookBackTime: self.lookBackTime) - self.lineData.refreshData(lookBackTime: self.lookBackTime) - self.valueManager.refreshData(lookBackTime: self.lookBackTime) - }) { - Image(systemName: "goforward") + if chartData.isLoading == false && chartData.isEmpty { + Text("No Data for this timeframe!").font(.largeTitle) + Text("Please make sure the data collection app is running! For more details please check out the documentation under:") + Link(destination: URL(string: "https://github.com/green-coding-berlin/hog#power-logger")!) { + Text("https://github.com/green-coding-berlin/hog#power-logger") } - Button(action: { - exit(0) - }) { - Image(systemName: "x.circle") + }else{ + HStack { + VStack(alignment: .leading, spacing: 8) { + Text("This is a very minimalistic overview of your energy usage.") + } + + Spacer(minLength: 10) + + Button(action: { + self.refresh() + }) { + Image(systemName: "goforward") + } + Button(action: { + exit(0) + }) { + Image(systemName: "x.circle") + } + } - - } - VStack{ - - VStack(spacing: 0) { - if valueManager.isLoading { - Text("Loading") - } else { - ProcessBadge(title: "App with the highest energy usage", color: Color("chartColor2"), process: valueManager.topApp) - EnergyBadge(title: "System energy usage", color: Color("chartColor2"), image: "clock.badge.checkmark", value: valueManager.energy) - if valueManager.providerRunning { - TextBadge(title: "", color: Color("chartColor2"), image: "checkmark.seal", value: "All measurement systems are functional") + VStack{ + + VStack(spacing: 0) { + if valueManager.isLoading { + Text("Loading") } else { - HStack{ - TextBadge(title: "", color: Color("redish"), image: "exclamationmark.octagon", value: "Measurement systems not running!") - Link(destination: URL(string: "https://github.com/green-coding-berlin/hog#power-logger")!) { - Image(systemName: "questionmark.circle.fill") - .font(.system(size: 24)) + ProcessBadge(title: "App with the highest energy usage", color: Color("chartColor2"), process: valueManager.topApp) + EnergyBadge(title: "System energy usage", color: Color("chartColor2"), image: "clock.badge.checkmark", value: valueManager.energy) + if valueManager.providerRunning { + TextBadge(title: "", color: Color("chartColor2"), image: "checkmark.seal", value: "All measurement systems are functional") + } else { + HStack{ + TextBadge(title: "", color: Color("redish"), image: "exclamationmark.octagon", value: "Measurement systems not running!") + Link(destination: URL(string: "https://github.com/green-coding-berlin/hog#power-logger")!) { + Image(systemName: "questionmark.circle.fill") + .font(.system(size: 24)) + } } } + // HStack{ + // TextBadge(title: "", color: Color("menuTab"), image: "person.crop.circle.badge.clock", value: "Project: Hog Development") + // Button(action: { + // isTextInputViewPresented = true + // }) { + // Image(systemName: "pencil.circle") + // } + // + // } + // .sheet(isPresented: $isTextInputViewPresented) { + // TextInputView(text: $text, isPresented: $isTextInputViewPresented) + // } } -// HStack{ -// TextBadge(title: "", color: Color("menuTab"), image: "person.crop.circle.badge.clock", value: "No project set") -// Button(action: { -// isTextInputViewPresented = true -// }) { -// Image(systemName: "pencil.circle") -// } -// } -// .sheet(isPresented: $isTextInputViewPresented) { -// TextInputView(text: $text, isPresented: $isTextInputViewPresented) -// } - } - Button(action: { - if let url = URL(string: "\(settingsManager.web_url)\(settingsManager.machine_uuid)") { - NSWorkspace.shared.open(url) + Button(action: { + if let url = URL(string: "\(settingsManager.web_url)\(settingsManager.machine_uuid)") { + NSWorkspace.shared.open(url) + } + }) { + Text("View Detailed Analytics") + .padding(10) } - }) { - Text("View Detailed Analytics") - .padding(10) + + } - - + + PointsGraph(chartData: chartData) + TopProcessTable(tpData: lineData) + } + } - PointsGraph(chartData: chartData) - TopProcessTable(tpData: lineData) - + } + .padding() + .onReceive(viewModel.$selectedTab) { tab in + if tab == self.whoAmI { + self.refresh() } + } - }.padding() } } @@ -803,78 +733,17 @@ func TextBadge(title: String, color: Color, image: String, value: String)->some .frame(maxWidth: .infinity, alignment: .leading) } -struct CopyPasteTextField: NSViewRepresentable { - @Binding var text: String - - func makeNSView(context: Context) -> NSTextField { - let textField = NSTextField() - textField.isBordered = true - textField.backgroundColor = NSColor.textBackgroundColor - return textField - } - - func updateNSView(_ nsView: NSTextField, context: Context) { - nsView.stringValue = text - } -} -struct OneView: View { +class WindowFocusTracker: ObservableObject { + @Published var isKeyWindow: Bool = false + private var cancellables: Set = [] - var body: some View { - Text("1) Open Terminal").font(.headline) - HStack{ - Button(action: { - let workspace = NSWorkspace.shared - if let url = URL(string: "file:///System/Applications/Utilities/Terminal.app") { - let configuration = NSWorkspace.OpenConfiguration() - workspace.open(url, configuration: configuration, completionHandler: nil) - } - }) { - HStack { - Image(systemName: "terminal") // This is a symbolic representation, actual symbol might differ. - Text("Terminal") - } - } - - Text("If the button does not work please look under the Utilities folder in your Apps and start the terminal.") - }.padding() - } -} - -struct TwoView: View { - @State private var text = "curl -fsSL https://raw.githubusercontent.com/green-coding-berlin/hog/main/install.sh | sudo bash" - - var body: some View { - Text("2) Run this command").font(.headline) - - HStack(spacing: 20) { - CopyPasteTextField(text: $text) - - Button("Copy Text") { - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(text, forType: .string) - } - }.padding() - } -} -struct ThreeView: View { - @State private var showInfo: Bool = false - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - HStack { - Text("3) You will need to enter your password and install xcode tools.").font(.headline) - Button(action: { - showInfo.toggle() - }) { - Image(systemName: "info.circle") - } - } - if showInfo { - Text("We are very aware that this is a risky operation. The problem is that the program needs to run as root and also the installer needs to activate the program.") + init() { + NSApplication.shared.publisher(for: \.keyWindow) + .sink { [weak self] keyWindow in + self?.isKeyWindow = (keyWindow != nil) } - } + .store(in: &cancellables) } } @@ -883,7 +752,7 @@ enum TabSelection: Hashable { } -class InstallViewModel: ObservableObject { +class ViewModel: ObservableObject { @Published var renderToggle: Bool = false @Published var selectedTab: TabSelection = .last5Minutes @@ -892,148 +761,53 @@ class InstallViewModel: ObservableObject { } } -struct StepsView: View { - var body: some View { - HStack{ - Image("logo2") - .resizable() - .scaledToFit() - .frame(width: 50, height: 50) - Text("Welcome to the hog").font(.title) - Spacer() - Button(action: { - exit(0) - }) { - Image(systemName: "x.circle") - } - - } - Text("It looks like you haven't installed the program that we need to collect the power measurments. Please follow these steps:") - Divider() - OneView() - TwoView() - ThreeView() - Text("4) All done. Now check").font(.headline) - - } -} - - -struct InstallView: View { - @ObservedObject var viewModel: InstallViewModel - - @State private var showingAlert = false - - var body: some View { - VStack(alignment: .leading, spacing: 10) { // Set alignment to .leading - - StepsView() - Button("Re-check if the power data is reported") { - viewModel.toggleRender() - - }.padding() - Divider() - Text("If you just want to see the interface you can also load some demo data visualises what is possible with the hog.") - Button("View with demo data") { - guard let sourceURL = Bundle.main.url(forResource: "demo_db", withExtension: "db") else { - print("Source file not found!") - return - } - db_path = sourceURL.path() - viewModel.selectedTab = .allTime - viewModel.toggleRender() - } - .alert(isPresented: $showingAlert) { - Alert(title: Text("There was an error copying demo data."), - message: Text("Please look at the logs and submit an issue! https://github.com/green-coding-berlin/hog/issues/new"), - dismissButton: .default(Text("Got it!"))) - } - Divider() - Text("You can also read our documentation for all the details under: https://github.com/green-coding-berlin/hog") - - }.padding() - - } - -} - -private struct SettingDetailView: View { - let title: String - let value: String - - var body: some View { - Group { - Text(title) - .bold() - Text(value) - .padding(.bottom, 10) - } - } -} -struct SettingsView: View { - - @ObservedObject var settingsManager = SettingsManager() - - var body: some View { - VStack(alignment: .leading) { - Text("Settings") - .font(.headline) - .bold() - Text("These are the settings that are set by the power logger.\nPlease refer to https://github.com/green-coding-berlin/hog#settings") - Divider().padding() - SettingDetailView(title: "Machine ID:", value: settingsManager.machine_uuid) - SettingDetailView(title: "Powermetrics Intervall:", value: "\(settingsManager.powermetrics)") - SettingDetailView(title: "Upload to URL:", value: settingsManager.api_url) - SettingDetailView(title: "Web View URL:", value: settingsManager.web_url) - SettingDetailView(title: "Upload data:", value: settingsManager.upload_data ? "Yes" : "No") - SettingDetailView(title: "Upload Backlog Count:", value: "\(settingsManager.upload_backlog)") - - } - .padding() - } -} - - - - struct DetailView: View { - - @ObservedObject var viewModel = InstallViewModel() + + @ObservedObject var windowFocusTracker = WindowFocusTracker() + @ObservedObject var viewModel = ViewModel() @Environment(\.colorScheme) var colorScheme var body: some View { - if checkDB() { - TabView(selection: $viewModel.selectedTab) { - DataView(lookBackTime: 300000) - .tabItem { - Label("Last 5 Minutes", systemImage: "list.dash") - } - .tag(TabSelection.last5Minutes) - - DataView(lookBackTime: 86400000) - .tabItem { - Label("Last 24 Hours", systemImage: "square.and.pencil") - } - .tag(TabSelection.last24Hours) - - DataView() - .tabItem { - Label("All Time", systemImage: "square.and.pencil") - } - .tag(TabSelection.allTime) - - SettingsView() - .tabItem { - Label("Settings", systemImage: "square.and.pencil") - } - .tag(TabSelection.settings) + if checkDB(){ + if windowFocusTracker.isKeyWindow{ + ReleaseCheckerView() + TabView(selection: $viewModel.selectedTab) { + DataView(lookBackTime: 300000, viewModel: viewModel, whoAmI: TabSelection.last5Minutes) + .tabItem { + Label("Last 5 Minutes", systemImage: "list.dash") + } + .tag(TabSelection.last5Minutes) + + + DataView(lookBackTime: 86400000, viewModel: viewModel, whoAmI: TabSelection.last24Hours) + .tabItem { + Label("Last 24 Hours", systemImage: "square.and.pencil") + } + .tag(TabSelection.last24Hours) + + + DataView(viewModel: viewModel, whoAmI: TabSelection.allTime) + .tabItem { + Label("All Time", systemImage: "square.and.pencil") + } + .tag(TabSelection.allTime) + + + SettingsView(viewModel: viewModel, whoAmI: TabSelection.settings) + .tabItem { + Label("Settings", systemImage: "square.and.pencil") + } + .tag(TabSelection.settings) + + } + .padding() + .background(colorScheme == .light ? Color.white : Color.black) + }else{ + Text("Please return focus to window to display data. You can do this by clicking here.") } - .padding() - .background(colorScheme == .light ? Color.white : Color.black) } else { InstallView(viewModel: viewModel) } - } } diff --git a/app/hog/hog/InstallView.swift b/app/hog/hog/InstallView.swift new file mode 100644 index 0000000..4419bee --- /dev/null +++ b/app/hog/hog/InstallView.swift @@ -0,0 +1,150 @@ +// +// InstallView.swift +// hog +// +// Created by Didi Hoffmann on 18.10.23. +// + +import SwiftUI + +struct OneView: View { + + var body: some View { + Text("1) Open Terminal").font(.headline) + HStack{ + Button(action: { + let workspace = NSWorkspace.shared + if let url = URL(string: "file:///System/Applications/Utilities/Terminal.app") { + let configuration = NSWorkspace.OpenConfiguration() + workspace.open(url, configuration: configuration, completionHandler: nil) + } + }) { + HStack { + Image(systemName: "terminal") // This is a symbolic representation, actual symbol might differ. + Text("Terminal") + } + } + + Text("If the button does not work please look under the Utilities folder in your Apps and start the terminal.") + }.padding() + } +} + +struct CopyPasteTextField: NSViewRepresentable { + @Binding var text: String + + func makeNSView(context: Context) -> NSTextField { + let textField = NSTextField() + textField.isBordered = true + textField.backgroundColor = NSColor.textBackgroundColor + return textField + } + + func updateNSView(_ nsView: NSTextField, context: Context) { + nsView.stringValue = text + } +} + + +struct TwoView: View { + @State private var text = "curl -fsSL https://raw.githubusercontent.com/green-coding-berlin/hog/main/install.sh | sudo bash" + + var body: some View { + Text("2) Run this command").font(.headline) + + HStack(spacing: 20) { + CopyPasteTextField(text: $text) + + Button("Copy Text") { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + } + }.padding() + } +} +struct ThreeView: View { + @State private var showInfo: Bool = false + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("3) You will need to enter your password and install xcode tools.").font(.headline) + Button(action: { + showInfo.toggle() + }) { + Image(systemName: "info.circle") + } + } + if showInfo { + Text("We are very aware that this is a risky operation. The problem is that the program needs to run as root and also the installer needs to activate the program.") + } + } + } +} + + +struct StepsView: View { + var body: some View { + HStack{ + Image("logo2") + .resizable() + .scaledToFit() + .frame(width: 50, height: 50) + Text("Welcome to the hog").font(.title) + Spacer() + Button(action: { + exit(0) + }) { + Image(systemName: "x.circle") + } + + } + Text("It looks like you haven't installed the program that we need to collect the power measurments. Please follow these steps:") + Divider() + OneView() + TwoView() + ThreeView() + Text("4) All done. Now check").font(.headline) + + } +} + + +struct InstallView: View { + @ObservedObject var viewModel: ViewModel + + @State private var showingAlert = false + + var body: some View { + VStack(alignment: .leading, spacing: 10) { // Set alignment to .leading + + StepsView() + Button("Re-check if the power data is reported") { + viewModel.toggleRender() + + }.padding() + Divider() + Text("If you just want to see the interface you can also load some demo data visualises what is possible with the hog.") + Button("View with demo data") { + guard let sourceURL = Bundle.main.url(forResource: "demo_db", withExtension: "db") else { + print("Source file not found!") + return + } + db_path = sourceURL.path() + viewModel.selectedTab = .allTime + viewModel.toggleRender() + } + .alert(isPresented: $showingAlert) { + Alert(title: Text("There was an error copying demo data."), + message: Text("Please look at the logs and submit an issue! https://github.com/green-coding-berlin/hog/issues/new"), + dismissButton: .default(Text("Got it!"))) + } + Divider() + Text("You can also read our documentation for all the details under: https://github.com/green-coding-berlin/hog") + + }.padding() + + } + +} diff --git a/app/hog/hog/SettingsView.swift b/app/hog/hog/SettingsView.swift new file mode 100644 index 0000000..ccfd016 --- /dev/null +++ b/app/hog/hog/SettingsView.swift @@ -0,0 +1,165 @@ +// +// SettingsView.swift +// hog +// +// Created by Didi Hoffmann on 18.10.23. +// + +import SwiftUI +import SQLite3 +import Charts +import AppKit + + +class SettingsManager: ObservableObject { + + @Published var machine_uuid: String = "Loading ..." + @Published var powermetrics: Int = 0 + @Published var api_url: String = "Loading ..." + @Published var web_url: String = "Loading ..." + @Published var upload_data: Bool = true + + @Published var upload_backlog: Int = 0 + + @Published var isLoading: Bool = false + + + init(){ + self.isLoading = true + + DispatchQueue.global(qos: .userInitiated).async { + self.loadDataFrom() + DispatchQueue.main.async { + self.isLoading = false + } + } + } + + func loadDataFrom() { + var db: OpaquePointer? + + if sqlite3_open(db_path, &db) != SQLITE_OK { // Open database + print("error opening database") + return + } + + let lastMeasurementQuery = "SELECT machine_uuid, powermetrics, api_url, web_url, upload_data FROM settings ORDER BY time DESC LIMIT 1;" + var queryStatement: OpaquePointer? + + var new_machine_uuid = "Loading ..." + var new_powermetrics: Int = 0 + var new_api_url = "Loading ..." + var new_web_url = "Loading ..." + var upload_data = true + + if sqlite3_prepare_v2(db, lastMeasurementQuery, -1, &queryStatement, nil) == SQLITE_OK { + if sqlite3_step(queryStatement) == SQLITE_ROW { + new_machine_uuid = String(cString: sqlite3_column_text(queryStatement, 0)) + new_powermetrics = Int(sqlite3_column_int(queryStatement, 1)) + new_api_url = String(cString: sqlite3_column_text(queryStatement, 2)) + new_web_url = String(cString: sqlite3_column_text(queryStatement, 3)) + upload_data = sqlite3_column_int(queryStatement, 4) != 0 // assuming it's stored as 0 for false, non-0 for true + } + sqlite3_finalize(queryStatement) + } + + let uploadCountQuery = "SELECT COUNT(*) FROM measurements WHERE uploaded = 0;" + var new_upload_backlog: Int = 0 + + if sqlite3_prepare_v2(db, uploadCountQuery, -1, &queryStatement, nil) == SQLITE_OK { + if sqlite3_step(queryStatement) == SQLITE_ROW { + new_upload_backlog = Int(sqlite3_column_int(queryStatement, 0)) + } + sqlite3_finalize(queryStatement) // Always finalize your statement when done + } else { + print("SELECT statement could not be prepared") + } + + sqlite3_close(db) + + DispatchQueue.main.async { + self.machine_uuid = new_machine_uuid + self.powermetrics = new_powermetrics + self.api_url = new_api_url + self.web_url = new_web_url + self.upload_data = upload_data + self.upload_backlog = new_upload_backlog + } + + } + +} + +private struct SettingDetailView: View { + let title: String + let value: String + + var body: some View { + Group { + Text(title) + .bold() + Text(value) + .padding(.bottom, 10) + } + } +} + + +struct SettingsView: View { + + @ObservedObject var settingsManager = SettingsManager() + var viewModel: ViewModel + var whoAmI: TabSelection + + init(viewModel: ViewModel, whoAmI: TabSelection){ + self.viewModel = viewModel + self.whoAmI = whoAmI + } + + var body: some View { + VStack(alignment: .leading) { + HStack { + VStack(alignment: .leading, spacing: 8) { + Text("Settings") + .font(.headline) + .bold() + } + + Spacer(minLength: 10) + + Button(action: { + self.settingsManager.loadDataFrom() + }) { + Image(systemName: "goforward") + } + Button(action: { + exit(0) + }) { + Image(systemName: "x.circle") + } + + } + + if settingsManager.isLoading { + Text("Loading") + } else { + Text("These are the settings that are set by the power logger.\nPlease refer to https://github.com/green-coding-berlin/hog#settings") + Divider().padding() + SettingDetailView(title: "Machine ID:", value: settingsManager.machine_uuid) + SettingDetailView(title: "Powermetrics Intervall:", value: "\(settingsManager.powermetrics)") + SettingDetailView(title: "Upload to URL:", value: settingsManager.api_url) + SettingDetailView(title: "Web View URL:", value: settingsManager.web_url) + SettingDetailView(title: "Upload data:", value: settingsManager.upload_data ? "Yes" : "No") + SettingDetailView(title: "Upload Backlog Count:", value: "\(settingsManager.upload_backlog)") + } + + } + .padding() + .onReceive(viewModel.$selectedTab) { tab in + if tab == self.whoAmI { + self.settingsManager.loadDataFrom() + } + } + + } +} diff --git a/app/hog/hog/UpdateView.swift b/app/hog/hog/UpdateView.swift new file mode 100644 index 0000000..944d592 --- /dev/null +++ b/app/hog/hog/UpdateView.swift @@ -0,0 +1,55 @@ +// +// UpdateView.swift +// hog +// +// Created by Didi Hoffmann on 19.10.23. +// + +import SwiftUI +import Combine + +struct ReleaseCheckerView: View { + @State private var hasNewRelease: Bool = false + @State private var latestVersion: String = "" + + let currentVersion = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "0.1" + + var body: some View { + VStack { + if hasNewRelease { + Text("A new Hog version is available. Please update!") + Text("See https://github.com/green-coding-berlin/hog/blob/main/README.md#updating") + } + } + .task { + await checkLatestRelease() + } + } + + func fetchLatestRelease() async throws -> GitHubRelease? { + let url = URL(string: "https://api.github.com/repos/green-coding-berlin/hog/releases/latest")! + + let (data, response) = try await URLSession.shared.data(from: url) + + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + return nil + } + + return try JSONDecoder().decode(GitHubRelease.self, from: data) + } + + func checkLatestRelease() async { + if let releaseData = try? await fetchLatestRelease() { + self.latestVersion = releaseData.tagName + self.hasNewRelease = self.latestVersion > self.currentVersion + } + } +} + +struct GitHubRelease: Decodable { + let tagName: String + + enum CodingKeys: String, CodingKey { + case tagName = "tag_name" + } +} diff --git a/app/hog/hog/hog.entitlements b/app/hog/hog/hog.entitlements index 852fa1a..ee95ab7 100644 --- a/app/hog/hog/hog.entitlements +++ b/app/hog/hog/hog.entitlements @@ -4,5 +4,7 @@ com.apple.security.app-sandbox + com.apple.security.network.client + diff --git a/app/hog/hog/hogApp.swift b/app/hog/hog/hogApp.swift index a77be75..ba40e45 100644 --- a/app/hog/hog/hogApp.swift +++ b/app/hog/hog/hogApp.swift @@ -9,12 +9,12 @@ import SwiftUI @main struct hogApp: App { - var body: some Scene { - - MenuBarExtra("QuickView", image: "logo") { - DetailView().frame( - minWidth: 600, minHeight: 850) + @State var observer: NSKeyValueObservation? + var body: some Scene { + MenuBarExtra("QuickView", image: "logo_bw_bar") { + DetailView() + .frame(minWidth: 600, minHeight: 850) }.menuBarExtraStyle(WindowMenuBarExtraStyle()) } } diff --git a/app/hog/widget/AppIntent.swift b/app/hog/widget/AppIntent.swift deleted file mode 100644 index b6c1cb1..0000000 --- a/app/hog/widget/AppIntent.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// AppIntent.swift -// widget -// -// Created by Didi Hoffmann on 29.09.23. -// - -import WidgetKit -import AppIntents - -struct ConfigurationAppIntent: WidgetConfigurationIntent { - static var title: LocalizedStringResource = "Configuration" - static var description = IntentDescription("This is an example widget.") - - // An example configurable parameter. - @Parameter(title: "Favorite Emoji", default: "😃") - var favoriteEmoji: String -} diff --git a/app/hog/widget/Assets.xcassets/AccentColor.colorset/Contents.json b/app/hog/widget/Assets.xcassets/AccentColor.colorset/Contents.json deleted file mode 100644 index eb87897..0000000 --- a/app/hog/widget/Assets.xcassets/AccentColor.colorset/Contents.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "colors" : [ - { - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/app/hog/widget/Assets.xcassets/AppIcon.appiconset/Contents.json b/app/hog/widget/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 3f00db4..0000000 --- a/app/hog/widget/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "images" : [ - { - "idiom" : "mac", - "scale" : "1x", - "size" : "16x16" - }, - { - "idiom" : "mac", - "scale" : "2x", - "size" : "16x16" - }, - { - "idiom" : "mac", - "scale" : "1x", - "size" : "32x32" - }, - { - "idiom" : "mac", - "scale" : "2x", - "size" : "32x32" - }, - { - "idiom" : "mac", - "scale" : "1x", - "size" : "128x128" - }, - { - "idiom" : "mac", - "scale" : "2x", - "size" : "128x128" - }, - { - "idiom" : "mac", - "scale" : "1x", - "size" : "256x256" - }, - { - "idiom" : "mac", - "scale" : "2x", - "size" : "256x256" - }, - { - "idiom" : "mac", - "scale" : "1x", - "size" : "512x512" - }, - { - "idiom" : "mac", - "scale" : "2x", - "size" : "512x512" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/app/hog/widget/Assets.xcassets/Contents.json b/app/hog/widget/Assets.xcassets/Contents.json deleted file mode 100644 index 73c0059..0000000 --- a/app/hog/widget/Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/app/hog/widget/Assets.xcassets/WidgetBackground.colorset/Contents.json b/app/hog/widget/Assets.xcassets/WidgetBackground.colorset/Contents.json deleted file mode 100644 index eb87897..0000000 --- a/app/hog/widget/Assets.xcassets/WidgetBackground.colorset/Contents.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "colors" : [ - { - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/app/hog/widget/Info.plist b/app/hog/widget/Info.plist deleted file mode 100644 index 0f118fb..0000000 --- a/app/hog/widget/Info.plist +++ /dev/null @@ -1,11 +0,0 @@ - - - - - NSExtension - - NSExtensionPointIdentifier - com.apple.widgetkit-extension - - - diff --git a/app/hog/widget/widget.entitlements b/app/hog/widget/widget.entitlements deleted file mode 100644 index 852fa1a..0000000 --- a/app/hog/widget/widget.entitlements +++ /dev/null @@ -1,8 +0,0 @@ - - - - - com.apple.security.app-sandbox - - - diff --git a/app/hog/widget/widget.swift b/app/hog/widget/widget.swift deleted file mode 100644 index 4c9a719..0000000 --- a/app/hog/widget/widget.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// widget.swift -// widget -// -// Created by Didi Hoffmann on 29.09.23. -// - -import WidgetKit -import SwiftUI - -struct Provider: AppIntentTimelineProvider { - func placeholder(in context: Context) -> SimpleEntry { - SimpleEntry(date: Date(), configuration: ConfigurationAppIntent()) - } - - func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> SimpleEntry { - SimpleEntry(date: Date(), configuration: configuration) - } - - func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline { - var entries: [SimpleEntry] = [] - - // Generate a timeline consisting of five entries an hour apart, starting from the current date. - let currentDate = Date() - for hourOffset in 0 ..< 5 { - let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)! - let entry = SimpleEntry(date: entryDate, configuration: configuration) - entries.append(entry) - } - - return Timeline(entries: entries, policy: .atEnd) - } -} - -struct SimpleEntry: TimelineEntry { - let date: Date - let configuration: ConfigurationAppIntent -} - -struct widgetEntryView : View { - var entry: Provider.Entry - - var body: some View { - VStack { - Text("Time:") - Text(entry.date, style: .time) - - Text("Favorite Emoji:") - Text(entry.configuration.favoriteEmoji) - } - } -} - -struct widget: Widget { - let kind: String = "widget" - - var body: some WidgetConfiguration { - AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in - widgetEntryView(entry: entry) - .containerBackground(.fill.tertiary, for: .widget) - } - } -} diff --git a/app/hog/widget/widgetBundle.swift b/app/hog/widget/widgetBundle.swift deleted file mode 100644 index 79d5c4a..0000000 --- a/app/hog/widget/widgetBundle.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// widgetBundle.swift -// widget -// -// Created by Didi Hoffmann on 29.09.23. -// - -import WidgetKit -import SwiftUI - -@main -struct widgetBundle: WidgetBundle { - var body: some Widget { - widget() - } -} diff --git a/install.sh b/install.sh old mode 100644 new mode 100755 index 074962c..f1b2a00 --- a/install.sh +++ b/install.sh @@ -33,6 +33,7 @@ install_xcode_clt() { # Call the function to ensure Xcode Command Line Tools are installed install_xcode_clt +# Checks if the hog is already running hog_running_output=$(launchctl list | grep berlin.green-coding.hog || echo "") if [[ ! -z "$hog_running_output" ]]; then @@ -40,6 +41,10 @@ if [[ ! -z "$hog_running_output" ]]; then rm -f /tmp/latest_release.zip fi +### +# Downloads and moves the code +### + ZIP_LOCATION=$(curl -s https://api.github.com/repos/green-coding-berlin/hog/releases/latest | grep -o 'https://[^"]*/hog_power_logger.zip') curl -fLo /tmp/latest_release.zip $ZIP_LOCATION @@ -52,6 +57,36 @@ chmod 755 /usr/local/bin/hog chmod -R 755 /usr/local/bin/hog/ chmod +x /usr/local/bin/hog/power_logger.py +### +# Writing the config file +### + +read -p "In order for the app to work with all features please allow us to upload some data. [Y/n]: " upload_data +upload_data=${upload_data:-Y} +upload_data=$(echo "$upload_data" | tr '[:upper:]' '[:lower:]') + +if [[ $upload_data == "y" || $upload_data == "yes" ]]; then + upload_flag="true" +else + upload_flag="false" +fi + +cat > /etc/hog_settings.ini << EOF +[DEFAULT] +api_url = https://api.green-coding.berlin/v1/hog/add +web_url = https://metrics.green-coding.berlin/hog-details.html?machine_uuid= +upload_delta = 300 +powermetrics = 5000 +upload_data = $upload_flag +resolve_coalitions=com.googlecode.iterm2,com.apple.Terminal,com.vix.cron +EOF + +echo "Configuration written to /etc/hog_settings.ini" + +### +# Setting up the background demon +### + mv -f /usr/local/bin/hog/berlin.green-coding.hog.plist /Library/LaunchDaemons/berlin.green-coding.hog.plist sed -i '' "s|PATH_PLEASE_CHANGE|/usr/local/bin/hog/|g" /Library/LaunchDaemons/berlin.green-coding.hog.plist diff --git a/power_logger.py b/power_logger.py index d1fb2b1..80c8964 100755 --- a/power_logger.py +++ b/power_logger.py @@ -19,15 +19,21 @@ import configparser import sqlite3 import http +import threading +import logging +import select + from datetime import timezone from pathlib import Path from libs import caribou -VERSION = '0.2.1' +VERSION = '0.3' + +LOG_LEVELS = ['debug', 'info', 'warning', 'error', 'critical'] # Shared variable to signal the thread to stop -stop_signal = False +stop_signal = threading.Event() stats = { 'combined_energy': 0, @@ -39,16 +45,17 @@ def sigint_handler(_, __): global stop_signal - if stop_signal: + if stop_signal.is_set(): # If you press CTR-C the second time we bail - sys.exit() + sys.exit(2) - stop_signal = True - print('Received stop signal. Terminating all processes.') + stop_signal.set() + logging.info('❗ Terminating all processes. Please be patient, this might take a few seconds.') def siginfo_handler(_, __): print(SETTINGS) print(stats) + logging.info(f"System stats:\n{stats}\n{SETTINGS}") signal.signal(signal.SIGINT, sigint_handler) signal.signal(signal.SIGTERM, sigint_handler) @@ -70,6 +77,7 @@ def siginfo_handler(_, __): 'api_url': 'https://api.green-coding.berlin/v1/hog/add', 'web_url': 'https://metrics.green-coding.berlin/hog-details.html?machine_uuid=', 'upload_data': True, + 'resolve_coalitions': ['com.googlecode.iterm2,com.apple.Terminal,com.vix.cron'] } script_dir = os.path.dirname(os.path.realpath(__file__)) @@ -92,74 +100,98 @@ def siginfo_handler(_, __): 'api_url': config['DEFAULT'].get('api_url', default_settings['api_url']), 'web_url': config['DEFAULT'].get('web_url', default_settings['web_url']), 'upload_data': bool(config['DEFAULT'].getboolean('upload_data', default_settings['upload_data'])), + 'resolve_coalitions': config['DEFAULT'].get('resolve_coalitions', default_settings['resolve_coalitions']), } + SETTINGS['resolve_coalitions'] = [x.strip().lower() for x in SETTINGS['resolve_coalitions'].split(',')] else: SETTINGS = default_settings - - machine_uuid = None conn = sqlite3.connect(DATABASE_FILE) c = conn.cursor() +# This is a replacement for time.sleep as we need to check periodically if we need to exit +# We choose a max exit time of one second as we don't want to wake up too often. +def sleeper(stop_event, duration): + end_time = time.time() + duration + while time.time() < end_time: + if stop_event.is_set(): + return + time.sleep(1) -def run_powermetrics(debug: bool, filename: str = None): - - def process_lines(lines, debug): - buffer = [] - last_upload_time = time.time() - for line in lines: - line = line.strip().replace('&', '&') - buffer.append(line) - if line == '': - parse_powermetrics_output(''.join(buffer)) - if debug: - print(stats) - sys.stdout.flush() +def run_powermetrics(local_stop_signal, filename: str = None): + buffer = [] - buffer = [] + def process_line(line, buffer): + line = line.strip().replace('&', '&') + buffer.append(line) - if SETTINGS['upload_data']: - current_time = time.time() - if current_time - last_upload_time >= SETTINGS['upload_delta']: - upload_data_to_endpoint() - last_upload_time = current_time + if line == '': + logging.debug('Parsing new input') + parse_powermetrics_output(''.join(buffer)) + buffer.clear() - if stop_signal: - break + logging.info(stats) if filename: + logging.info(f"Reading file {filename}") with open(filename, 'r', encoding='utf-8') as file: - lines = file.readlines() - process_lines(lines, debug) + for line in file.readlines(): + process_line(line, buffer) + else: cmd = ['powermetrics', '--show-all', '-i', str(SETTINGS['powermetrics']), '-f', 'plist'] - with subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True) as process: - process_lines(process.stdout, debug) + logging.info(f"Starting powermetrics process: {' '.join(cmd)}") - if stop_signal: - process.terminate() - - # Make sure that all data has been uploaded when exiting - upload_data_to_endpoint() + with subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True) as process: -def upload_data_to_endpoint(): - retry_counter = 0 - while True: - retry_counter += 1 + os.set_blocking(process.stdout.fileno(), False) + + partial_buffer = '' + while not local_stop_signal.is_set(): + # Make sure that the timeout is greater than the output is coming in + rlist, _, _ = select.select([process.stdout], [], [], int(SETTINGS['powermetrics'] / 1_000 * 2 )) + if rlist: + # This is a little hacky. The problem is that select just reads data and doesn't respect the lines + # so it happens that we read in the middle of a line. + data = rlist[0].read() + data = partial_buffer + data + lines = data.splitlines() + try: + if not data.endswith('\n'): + partial_buffer = lines.pop() + else: + partial_buffer = '' + + for line in lines: + process_line(line, buffer) + except IndexError: + # This happens when the process is killed before we exit here so stop_signal should be set. If not + # there is a problem with powermetrics and we should report and exit. + if not local_stop_signal.is_set(): + logging.error('The pipe to powermetrics has been closed. Exiting') + local_stop_signal.set() + + +def upload_data_to_endpoint(local_stop_signal): + thread_conn = sqlite3.connect(DATABASE_FILE) + tc = thread_conn.cursor() + + while not local_stop_signal.is_set(): # We need to limit the amount of data here as otherwise the payload becomes to big - c.execute('SELECT id, time, data FROM measurements WHERE uploaded = 0 LIMIT 10;') - rows = c.fetchall() + tc.execute('SELECT id, time, data FROM measurements WHERE uploaded = 0 LIMIT 10;') + rows = tc.fetchall() - if not rows or retry_counter > 3: - retry_counter = 0 - break + # When everything is uploaded we sleep + if not rows: + sleeper(local_stop_signal, SETTINGS['upload_delta']) + continue payload = [] for row in rows: @@ -178,32 +210,44 @@ def upload_data_to_endpoint(): 'machine_uuid': machine_uuid, 'row_id': row_id }) + request_data = json.dumps(payload).encode('utf-8') req = urllib.request.Request(url=SETTINGS['api_url'], data=request_data, headers={'content-type': 'application/json'}, method='POST') + + logging.info(f"Uploading {len(payload)} rows to: {SETTINGS['api_url']}") + try: with urllib.request.urlopen(req) as response: if response.status == 204: for p in payload: - c.execute('UPDATE measurements SET uploaded = ?, data = NULL WHERE id = ?;', (int(time.time()), p['row_id'])) - conn.commit() + tc.execute('UPDATE measurements SET uploaded = ?, data = NULL WHERE id = ?;', (int(time.time()), p['row_id'])) + thread_conn.commit() + logging.debug('Upload 👌') else: - print(f"Failed to upload data: {payload}\n HTTP status: {response.status}") + logging.info(f"Failed to upload data: {payload}\n HTTP status: {response.status}") + sleeper(local_stop_signal, SETTINGS['upload_delta']) # Sleep if there is an error + except (urllib.error.HTTPError, ConnectionRefusedError, urllib.error.URLError, http.client.RemoteDisconnected, - ConnectionResetError): - break + ConnectionResetError) as exc: + logging.debug(f"Upload exception: {exc}") + sleeper(local_stop_signal, SETTINGS['upload_delta']) # Sleep if there is an error + + thread_conn.close() + + def find_top_processes(data: list, elapsed_ns:int): # As iterm2 will probably show up as it spawns the processes called from the shell we look at the tasks new_data = [] for coalition in data: - if coalition['name'] == 'com.googlecode.iterm2' or coalition['name'].strip() == '': + if coalition['name'].lower() in SETTINGS['resolve_coalitions'] or coalition['name'].strip() == '': new_data.extend(coalition['tasks']) else: new_data.append(coalition) @@ -232,7 +276,7 @@ def parse_powermetrics_output(output: str): data['timezone'] = time.tzname data['timestamp'] = int(data['timestamp'].replace(tzinfo=timezone.utc).timestamp() * 1e3) except xml.parsers.expat.ExpatError as exc: - print(data) + logging.error(f"XML Error:\n{data}") raise exc compressed_data = zlib.compress(str(json.dumps(data)).encode()) @@ -281,6 +325,7 @@ def parse_powermetrics_output(output: str): (data['timestamp'], process['name'], process['energy_impact'], cpu_per)) conn.commit() + logging.debug('Data added to the DB') def save_settings(): global machine_uuid @@ -296,7 +341,7 @@ def save_settings(): last_web_url.strip() == SETTINGS['web_url'].strip() and last_upload_delta == SETTINGS['upload_delta'] and last_upload_data == SETTINGS['upload_data']): - return + return False else: machine_uuid = str(uuid.uuid1()) @@ -313,19 +358,72 @@ def save_settings(): )) conn.commit() + logging.debug(f"Saved Settings:\n{SETTINGS}") + + return True + +def check_DB(local_stop_signal): + # The powermetrics script should return ever SETTINGS['powermetrics'] ms but because of the way we batch things + # we will not get values every n ms so we have quite a big value here. + # powermetrics = 5000 ms in production and 1000 in dev mode + + interval_sec = SETTINGS['powermetrics'] * 20 / 1_000 + + # We first sleep for quite some time to give the program some time to add data to the DB + sleeper(local_stop_signal, interval_sec) + + thread_conn = sqlite3.connect(DATABASE_FILE) + tc = thread_conn.cursor() + + while not local_stop_signal.is_set(): + n_ago = int((time.time() - interval_sec) * 1_000) + + tc.execute('SELECT MAX(time) FROM measurements') + result = tc.fetchone() + + if result and result[0]: + if result[0] < n_ago: + logging.error('No new data in DB. Exiting to be restarted by the os') + local_stop_signal.set() + else: + logging.error('We are not getting values from the DB for checker thread.') + + logging.debug('DB Check ✅') + sleeper(local_stop_signal, interval_sec) + + thread_conn.close() + + +def is_power_logger_running(): + try: + subprocess.check_output(['pgrep', '-f', sys.argv[0]]) + logging.error(f"There is already a {sys.argv[0]} process running! Maybe check launchctl?") + sys.exit(4) + except subprocess.CalledProcessError: + return False if __name__ == '__main__': parser = argparse.ArgumentParser(description= - '''A powermetrics wrapper that does simple parsing and writes to a file.''') - parser.add_argument('-d', '--debug', action='store_true', help='Enable debug/ development mode') + ''' + A power collection script that records a multitude of metrics and saves them to + a database. Also uploads the data to a server. + Exit codes: + 1 - run as root + 2 - force quit + 3 - db not updated + 4 - already a power_logger process is running + ''') + parser.add_argument('-d', '--dev', action='store_true', help='Enable development mode api endpoints and log level.') parser.add_argument('-w', '--website', action='store_true', help='Shows the website URL') parser.add_argument('-f', '--file', type=str, help='Path to the input file') + parser.add_argument('-v', '--log-level', choices=LOG_LEVELS, default='info', help='Logging level (debug, info, warning, error, critical)') + parser.add_argument('-o', '--output-file', type=str, help='Path to the output log file.') args = parser.parse_args() - if args.debug: + if args.dev: SETTINGS = { 'powermetrics' : 1000, 'upload_delta': 5, @@ -333,11 +431,25 @@ def save_settings(): 'web_url': 'http://metrics.green-coding.internal:9142/hog-details.html?machine_uuid=', 'upload_data': True, } + args.log_level = 'debug' + + log_level = getattr(logging, args.log_level.upper()) + + if args.output_file: + logging.basicConfig(filename=args.output_file, level=log_level, format='[%(levelname)s] %(asctime)s - %(message)s') + else: + logging.basicConfig(level=log_level, format='[%(levelname)s] %(asctime)s - %(message)s') + + logging.debug('Program started 🎉') + logging.debug(f"Using db: {DATABASE_FILE}") + if os.geteuid() != 0: - print('The script needs to be run as root!') + logging.error('The script needs to be run as root!') sys.exit(1) + is_power_logger_running() + # Make sure that everyone can write to the DB os.chmod(DATABASE_FILE, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IWGRP | @@ -347,13 +459,24 @@ def save_settings(): # Make sure the DB is migrated caribou.upgrade(DATABASE_FILE, MIGRATIONS_PATH) - save_settings() + if not save_settings(): + logging.debug(f"Setting: {SETTINGS}") + if args.website: print('Please visit this url for detailed analytics:') print(f"{SETTINGS['web_url']}{machine_uuid}") - sys.exit() + sys.exit(0) + + if SETTINGS['upload_data']: + upload_thread = threading.Thread(target=upload_data_to_endpoint, args=(stop_signal,)) + upload_thread.start() + logging.debug('Upload thread started') + + db_checker_thread = threading.Thread(target=check_DB, args=(stop_signal,), daemon=True) + db_checker_thread.start() + logging.debug('DB checker thread started') - run_powermetrics(args.debug, args.file) + run_powermetrics(stop_signal, args.file) c.close() diff --git a/settings.ini b/settings.ini index 1c63343..b639bd8 100644 --- a/settings.ini +++ b/settings.ini @@ -4,3 +4,4 @@ web_url = https://metrics.green-coding.berlin/hog-details.html?machine_uuid= upload_delta = 300 powermetrics = 5000 upload_data = true +resolve_coalitions=com.googlecode.iterm2,com.apple.Terminal,com.vix.cron \ No newline at end of file