├── .github └── FUNDING.yml ├── .gitignore ├── Config ├── DeveloperID.xcconfig ├── Framework.xcconfig └── Main.xcconfig ├── LICENSE ├── README.md ├── StatusBuddy.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved └── xcshareddata │ └── xcschemes │ ├── StatusBuddy-DeveloperID.xcscheme │ ├── StatusBuddy.xcscheme │ ├── StatusCore.xcscheme │ ├── StatusCoreTests.xcscheme │ ├── StatusUI.xcscheme │ └── StatusUITests.xcscheme ├── StatusBuddy ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── icon_128.png │ │ ├── icon_128@2x.png │ │ ├── icon_16.png │ │ ├── icon_16@2x.png │ │ ├── icon_256.png │ │ ├── icon_256@2x.png │ │ ├── icon_32.png │ │ ├── icon_32@2x.png │ │ ├── icon_512.png │ │ └── icon_512@2x.png │ ├── Contents.json │ ├── IssueBadgeColor.colorset │ │ └── Contents.json │ ├── checkmark.imageset │ │ ├── Contents.json │ │ └── Semibold-S.pdf │ ├── gear.imageset │ │ ├── Contents.json │ │ └── gear.pdf │ └── statusbutton.imageset │ │ ├── Contents.json │ │ ├── normal.png │ │ └── retina.png ├── Base.lproj │ └── Main.storyboard ├── Info.plist ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── StatusBuddy-DeveloperID.entitlements ├── StatusBuddy.entitlements ├── Support │ ├── AppDelegate.swift │ ├── LaunchAtLoginHelper.swift │ ├── Preferences.swift │ └── UpdateController.swift └── Views │ └── PreferencesView.swift ├── StatusBuddyHelper ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json ├── Base.lproj │ └── Main.storyboard ├── Bundle+MainApp.swift ├── HelperAppDelegate.swift ├── Info.plist └── StatusBuddyHelper.entitlements ├── StatusCore ├── Info.plist ├── Source │ ├── Core │ │ ├── AppleStatusChecker.swift │ │ └── StatusChecker.swift │ ├── Models │ │ ├── Service.swift │ │ └── StatusResponse.swift │ └── StatusCore.swift └── StatusCore.h ├── StatusCoreTests ├── Data │ ├── Bundle+TestData.swift │ ├── customer-no-issues.json │ ├── customer-one-ongoing-issue.json │ ├── customer-three-ongoing-issues.json │ ├── customer-three-resolved-issues.json │ ├── developer-no-issues.json │ ├── developer-one-ongoing-issue.json │ ├── developer-one-resolved-issue.json │ └── developer-one-scheduled-issue.json └── EventFilteringTests.swift ├── StatusUI ├── Source │ ├── AppKit │ │ ├── EventMonitor.swift │ │ ├── HostingWindowController │ │ │ ├── HostingWindowController.swift │ │ │ └── WindowEnvironment.swift │ │ ├── StatusBarFlowController.swift │ │ └── StatusBarMenuWindowController.swift │ ├── Definitions │ │ ├── Bundle+StatusUI.swift │ │ ├── Colors.swift │ │ └── StatusUI.swift │ ├── Models │ │ ├── DashboardItem+StatusCore.swift │ │ ├── DashboardItem.swift │ │ ├── DetailGroup+StatusCore.swift │ │ ├── DetailGroup.swift │ │ └── ServiceScope.swift │ ├── Notifications │ │ ├── NotificationManager.swift │ │ └── NotificationPresenter.swift │ ├── Resources │ │ └── StatusUI.xcassets │ │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ │ ├── Contents.json │ │ │ ├── ErrorColor.colorset │ │ │ └── Contents.json │ │ │ ├── GroupSeparator.colorset │ │ │ └── Contents.json │ │ │ ├── ItemBackgroundDark.colorset │ │ │ └── Contents.json │ │ │ ├── MenuBarContentSecondaryShadowColor.colorset │ │ │ └── Contents.json │ │ │ ├── MenuBarContentShadowColor.colorset │ │ │ └── Contents.json │ │ │ ├── SuccessColor.colorset │ │ │ └── Contents.json │ │ │ ├── WarningColor.colorset │ │ │ └── Contents.json │ │ │ └── WarningTextColor.colorset │ │ │ └── Contents.json │ ├── ViewModels │ │ ├── DashboardViewModel.swift │ │ ├── DetailViewModel.swift │ │ └── RootViewModel.swift │ └── Views │ │ ├── DashboardItemView.swift │ │ ├── DashboardView.swift │ │ ├── DetailGroupView.swift │ │ ├── DetailView.swift │ │ ├── Modifiers │ │ ├── StatusItemBackgroundModifier.swift │ │ └── WindowChrome.swift │ │ └── RootView.swift └── StatusUI.h ├── StatusUITests ├── DashboardViewModelTests.swift ├── DetailViewModelTests.swift └── NotificationManagerTests.swift ├── TestFlight └── WhatToTest.txt └── images ├── StatusBuddy-Icon-2021.png └── StatusBuddy-Screenshot-2021.png /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: insidegui 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | __MACOSX 3 | *.pbxuser 4 | !default.pbxuser 5 | *.mode1v3 6 | !default.mode1v3 7 | *.mode2v3 8 | !default.mode2v3 9 | *.perspectivev3 10 | !default.perspectivev3 11 | xcuserdata 12 | profile 13 | *.moved-aside 14 | DerivedData 15 | .idea/ 16 | Crashlytics.sh 17 | generatechangelog.sh 18 | Pods/ 19 | Carthage 20 | Provisioning 21 | Crashlytics.sh 22 | Sharing.h 23 | Tests/Private 24 | Design/Icon 25 | Build/ 26 | MockServer/ 27 | TestFlight/WhatToTest.*.txt -------------------------------------------------------------------------------- /Config/DeveloperID.xcconfig: -------------------------------------------------------------------------------- 1 | // 2 | // DeveloperID.xcconfig 3 | // StatusBuddy 4 | // 5 | // Created by Guilherme Rambo on 21/08/22. 6 | // Copyright © 2022 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | // Configuration settings file format documentation can be found at: 10 | // https://help.apple.com/xcode/#/dev745c5c974 11 | 12 | #include "Main.xcconfig" 13 | 14 | OTHER_SWIFT_FLAGS=-D ENABLE_SPARKLE 15 | 16 | CODE_SIGN_ENTITLEMENTS = StatusBuddy/StatusBuddy-DeveloperID.entitlements 17 | -------------------------------------------------------------------------------- /Config/Framework.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Main.xcconfig" 2 | 3 | OTHER_SWIFT_FLAGS[config=*-DeveloperID]=-D ENABLE_SPARKLE 4 | -------------------------------------------------------------------------------- /Config/Main.xcconfig: -------------------------------------------------------------------------------- 1 | // 2 | // Versions.xcconfig 3 | // StatusBuddy 4 | // 5 | // Created by Guilherme Rambo on 21/12/21. 6 | // Copyright © 2021 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | // Configuration settings file format documentation can be found at: 10 | // https://help.apple.com/xcode/#/dev745c5c974 11 | 12 | VERSIONING_SYSTEM = apple-generic 13 | 14 | MARKETING_VERSION = 2.0 // Applies to all targets 15 | 16 | CURRENT_PROJECT_VERSION = 99 // Applies to all targets 17 | 18 | DYLIB_CURRENT_VERSION = $(CURRENT_PROJECT_VERSION) 19 | 20 | // Sparkle must be weak-linked so that it can be stripped out of App Store version 21 | OTHER_LDFLAGS=-weak_framework Sparkle 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Guilherme Rambo 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are met: 5 | 6 | - Redistributions of source code must retain the above copyright notice, this 7 | list of conditions and the following disclaimer. 8 | 9 | - Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 14 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 15 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 16 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 17 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 18 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 19 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 20 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 21 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 22 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # StatusBuddy 4 | 5 | Keep track of Apple's developer and consumer system statuses right in your menu bar. 6 | 7 | StatusBuddy is a simple app that shows an icon on your Mac's menu bar. When an Apple service is having issues, the icon shows an exclamation mark, and you can click it to see what's going on. 8 | 9 | While a service is experiencing issues, you may also click the notification icon to the right of the service's name to be notified when the service goes back to normal. 10 | 11 | The app will show the same issues Apple reports in their official system status dashboards for developers and consumers, so it includes both developer services such as App Store Connect and TestFlight and consumer services such as Apple Music and TV+. 12 | 13 | 14 | 15 | # Download 16 | 17 | StatusBuddy is available for free. If you prefer, you can pay any amount you'd like in order to support its continued development on Gumroad, using the link below. 18 | 19 | [You can always get the latest build on Gumroad](https://statusbuddy.app). 20 | 21 | [You may also get the latest StatusBuddy beta on TestFlight](https://testflight.apple.com/join/MK6zSKdG) (requires macOS Monterey and the TestFlight app installed). 22 | 23 | **StatusBuddy requires macOS Big Sur or later** -------------------------------------------------------------------------------- /StatusBuddy.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /StatusBuddy.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /StatusBuddy.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "sparkle", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/sparkle-project/Sparkle.git", 7 | "state" : { 8 | "revision" : "9d85a02fe7916caa7531847452c4933d331503a5", 9 | "version" : "2.3.1" 10 | } 11 | } 12 | ], 13 | "version" : 2 14 | } 15 | -------------------------------------------------------------------------------- /StatusBuddy.xcodeproj/xcshareddata/xcschemes/StatusBuddy-DeveloperID.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 43 | 49 | 50 | 51 | 52 | 53 | 64 | 66 | 72 | 73 | 74 | 75 | 78 | 79 | 82 | 83 | 86 | 87 | 90 | 91 | 94 | 95 | 98 | 99 | 100 | 101 | 107 | 109 | 115 | 116 | 117 | 118 | 120 | 121 | 124 | 125 | 127 | 128 | 129 | -------------------------------------------------------------------------------- /StatusBuddy.xcodeproj/xcshareddata/xcschemes/StatusBuddy.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 43 | 49 | 50 | 51 | 52 | 53 | 64 | 66 | 72 | 73 | 74 | 75 | 78 | 79 | 82 | 83 | 86 | 87 | 90 | 91 | 94 | 95 | 98 | 99 | 100 | 101 | 107 | 109 | 115 | 116 | 117 | 118 | 120 | 121 | 124 | 125 | 127 | 128 | 129 | -------------------------------------------------------------------------------- /StatusBuddy.xcodeproj/xcshareddata/xcschemes/StatusCore.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 42 | 43 | 53 | 54 | 60 | 61 | 67 | 68 | 69 | 70 | 72 | 73 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /StatusBuddy.xcodeproj/xcshareddata/xcschemes/StatusCoreTests.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 14 | 15 | 17 | 23 | 24 | 25 | 26 | 27 | 37 | 38 | 44 | 45 | 47 | 48 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /StatusBuddy.xcodeproj/xcshareddata/xcschemes/StatusUI.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 42 | 43 | 53 | 54 | 60 | 61 | 67 | 68 | 69 | 70 | 72 | 73 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /StatusBuddy.xcodeproj/xcshareddata/xcschemes/StatusUITests.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 14 | 15 | 17 | 23 | 24 | 25 | 26 | 27 | 37 | 38 | 44 | 45 | 47 | 48 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /StatusBuddy/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "1.000", 9 | "green" : "0.502", 10 | "red" : "0.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "display-p3", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.792", 27 | "green" : "0.471", 28 | "red" : "0.145" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /StatusBuddy/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_16.png", 5 | "idiom" : "mac", 6 | "scale" : "1x", 7 | "size" : "16x16" 8 | }, 9 | { 10 | "filename" : "icon_16@2x.png", 11 | "idiom" : "mac", 12 | "scale" : "2x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "icon_32.png", 17 | "idiom" : "mac", 18 | "scale" : "1x", 19 | "size" : "32x32" 20 | }, 21 | { 22 | "filename" : "icon_32@2x.png", 23 | "idiom" : "mac", 24 | "scale" : "2x", 25 | "size" : "32x32" 26 | }, 27 | { 28 | "filename" : "icon_128.png", 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "filename" : "icon_128@2x.png", 35 | "idiom" : "mac", 36 | "scale" : "2x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "icon_256.png", 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "256x256" 44 | }, 45 | { 46 | "filename" : "icon_256@2x.png", 47 | "idiom" : "mac", 48 | "scale" : "2x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "icon_512.png", 53 | "idiom" : "mac", 54 | "scale" : "1x", 55 | "size" : "512x512" 56 | }, 57 | { 58 | "filename" : "icon_512@2x.png", 59 | "idiom" : "mac", 60 | "scale" : "2x", 61 | "size" : "512x512" 62 | } 63 | ], 64 | "info" : { 65 | "author" : "xcode", 66 | "version" : 1 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /StatusBuddy/Assets.xcassets/AppIcon.appiconset/icon_128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/StatusBuddy/f7a3777b295f3b89fa967f8ee70e103b7cbd9443/StatusBuddy/Assets.xcassets/AppIcon.appiconset/icon_128.png -------------------------------------------------------------------------------- /StatusBuddy/Assets.xcassets/AppIcon.appiconset/icon_128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/StatusBuddy/f7a3777b295f3b89fa967f8ee70e103b7cbd9443/StatusBuddy/Assets.xcassets/AppIcon.appiconset/icon_128@2x.png -------------------------------------------------------------------------------- /StatusBuddy/Assets.xcassets/AppIcon.appiconset/icon_16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/StatusBuddy/f7a3777b295f3b89fa967f8ee70e103b7cbd9443/StatusBuddy/Assets.xcassets/AppIcon.appiconset/icon_16.png -------------------------------------------------------------------------------- /StatusBuddy/Assets.xcassets/AppIcon.appiconset/icon_16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/StatusBuddy/f7a3777b295f3b89fa967f8ee70e103b7cbd9443/StatusBuddy/Assets.xcassets/AppIcon.appiconset/icon_16@2x.png -------------------------------------------------------------------------------- /StatusBuddy/Assets.xcassets/AppIcon.appiconset/icon_256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/StatusBuddy/f7a3777b295f3b89fa967f8ee70e103b7cbd9443/StatusBuddy/Assets.xcassets/AppIcon.appiconset/icon_256.png -------------------------------------------------------------------------------- /StatusBuddy/Assets.xcassets/AppIcon.appiconset/icon_256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/StatusBuddy/f7a3777b295f3b89fa967f8ee70e103b7cbd9443/StatusBuddy/Assets.xcassets/AppIcon.appiconset/icon_256@2x.png -------------------------------------------------------------------------------- /StatusBuddy/Assets.xcassets/AppIcon.appiconset/icon_32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/StatusBuddy/f7a3777b295f3b89fa967f8ee70e103b7cbd9443/StatusBuddy/Assets.xcassets/AppIcon.appiconset/icon_32.png -------------------------------------------------------------------------------- /StatusBuddy/Assets.xcassets/AppIcon.appiconset/icon_32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/StatusBuddy/f7a3777b295f3b89fa967f8ee70e103b7cbd9443/StatusBuddy/Assets.xcassets/AppIcon.appiconset/icon_32@2x.png -------------------------------------------------------------------------------- /StatusBuddy/Assets.xcassets/AppIcon.appiconset/icon_512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/StatusBuddy/f7a3777b295f3b89fa967f8ee70e103b7cbd9443/StatusBuddy/Assets.xcassets/AppIcon.appiconset/icon_512.png -------------------------------------------------------------------------------- /StatusBuddy/Assets.xcassets/AppIcon.appiconset/icon_512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/StatusBuddy/f7a3777b295f3b89fa967f8ee70e103b7cbd9443/StatusBuddy/Assets.xcassets/AppIcon.appiconset/icon_512@2x.png -------------------------------------------------------------------------------- /StatusBuddy/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /StatusBuddy/Assets.xcassets/IssueBadgeColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x00", 9 | "green" : "0x00", 10 | "red" : "0xBD" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /StatusBuddy/Assets.xcassets/checkmark.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Semibold-S.pdf", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | }, 12 | "properties" : { 13 | "preserves-vector-representation" : true, 14 | "template-rendering-intent" : "template" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /StatusBuddy/Assets.xcassets/checkmark.imageset/Semibold-S.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/StatusBuddy/f7a3777b295f3b89fa967f8ee70e103b7cbd9443/StatusBuddy/Assets.xcassets/checkmark.imageset/Semibold-S.pdf -------------------------------------------------------------------------------- /StatusBuddy/Assets.xcassets/gear.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "gear.pdf", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | }, 12 | "properties" : { 13 | "preserves-vector-representation" : true, 14 | "template-rendering-intent" : "template" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /StatusBuddy/Assets.xcassets/gear.imageset/gear.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/StatusBuddy/f7a3777b295f3b89fa967f8ee70e103b7cbd9443/StatusBuddy/Assets.xcassets/gear.imageset/gear.pdf -------------------------------------------------------------------------------- /StatusBuddy/Assets.xcassets/statusbutton.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "normal.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "retina.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | }, 22 | "properties" : { 23 | "preserves-vector-representation" : true, 24 | "template-rendering-intent" : "template" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /StatusBuddy/Assets.xcassets/statusbutton.imageset/normal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/StatusBuddy/f7a3777b295f3b89fa967f8ee70e103b7cbd9443/StatusBuddy/Assets.xcassets/statusbutton.imageset/normal.png -------------------------------------------------------------------------------- /StatusBuddy/Assets.xcassets/statusbutton.imageset/retina.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/StatusBuddy/f7a3777b295f3b89fa967f8ee70e103b7cbd9443/StatusBuddy/Assets.xcassets/statusbutton.imageset/retina.png -------------------------------------------------------------------------------- /StatusBuddy/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIconFile 10 | 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | $(MARKETING_VERSION) 21 | CFBundleVersion 22 | $(CURRENT_PROJECT_VERSION) 23 | ITSAppUsesNonExemptEncryption 24 | 25 | LSApplicationCategoryType 26 | public.app-category.utilities 27 | LSMinimumSystemVersion 28 | $(MACOSX_DEPLOYMENT_TARGET) 29 | LSMultipleInstancesProhibited 30 | 31 | LSUIElement 32 | 33 | NSAccentColorName 34 | AccentColor 35 | NSHumanReadableCopyright 36 | Copyright © 2020 Guilherme Rambo. All rights reserved. 37 | NSMainStoryboardFile 38 | Main 39 | NSPrincipalClass 40 | NSApplication 41 | NSSupportsSuddenTermination 42 | 43 | SUEnableInstallerLauncherService 44 | 45 | SUFeedURL 46 | https://sparkle.statusbuddy.app/appcast.xml 47 | SUPublicEDKey 48 | dj8ljUPnwoLj/dLs6HyJg5Ayw+t8zWtgjQUfQsH56ww= 49 | SUScheduledCheckInterval 50 | 86400 51 | 52 | 53 | -------------------------------------------------------------------------------- /StatusBuddy/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /StatusBuddy/StatusBuddy-DeveloperID.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.developer.usernotifications.time-sensitive 6 | 7 | com.apple.security.cs.allow-jit 8 | 9 | com.apple.security.app-sandbox 10 | 11 | com.apple.security.network.client 12 | 13 | com.apple.security.temporary-exception.mach-lookup.global-name 14 | 15 | $(PRODUCT_BUNDLE_IDENTIFIER)-spks 16 | $(PRODUCT_BUNDLE_IDENTIFIER)-spki 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /StatusBuddy/StatusBuddy.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.developer.usernotifications.time-sensitive 6 | 7 | com.apple.security.app-sandbox 8 | 9 | com.apple.security.cs.allow-jit 10 | 11 | com.apple.security.network.client 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /StatusBuddy/Support/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // StatusBuddy 4 | // 5 | // Created by Guilherme Rambo on 11/02/20. 6 | // Copyright © 2020 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | import SwiftUI 11 | import StatusCore 12 | import Combine 13 | import StatusUI 14 | 15 | @NSApplicationMain 16 | class AppDelegate: NSObject, NSApplicationDelegate { 17 | 18 | private lazy var updateController = UpdateController() 19 | 20 | var window: NSWindow! 21 | 22 | let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) 23 | 24 | private lazy var cancellables = Set() 25 | 26 | private let preferences = Preferences() 27 | 28 | private(set) lazy var rootViewModel: RootViewModel = { 29 | RootViewModel(with: [ 30 | .developer: AppleStatusChecker(endpoint: .developerFeedURL, format: .JSONCallback), 31 | .customer: AppleStatusChecker(endpoint: .consumerFeedURL, format: .JSON) 32 | ]) 33 | }() 34 | 35 | private lazy var flowController: StatusBarFlowController = { 36 | StatusBarFlowController( 37 | viewModel: rootViewModel, 38 | notificationManager: notificationManager 39 | ) 40 | }() 41 | 42 | private lazy var windowController: StatusBarMenuWindowController = { 43 | StatusBarMenuWindowController( 44 | statusItem: statusItem, 45 | contentViewController: flowController, 46 | topMargin: StatusBarFlowController.topMargin 47 | ) 48 | }() 49 | 50 | private lazy var notificationManager = NotificationManager() 51 | 52 | func applicationDidFinishLaunching(_ aNotification: Notification) { 53 | updateButton() 54 | 55 | windowController.handleEscape = { [weak self] _ in 56 | self?.hideUI(sender: nil) 57 | } 58 | 59 | rootViewModel.startPeriodicUpdates() 60 | 61 | rootViewModel.$hasActiveIssues 62 | .assign(to: \.issueBadgeVisible, on: self) 63 | .store(in: &cancellables) 64 | 65 | rootViewModel.$latestResponses 66 | .assign(to: \.latestResponses, on: notificationManager) 67 | .store(in: &cancellables) 68 | 69 | preferences.$enableTimeSensitiveNotifications 70 | .assign(to: \.enableTimeSensitiveNotifications, on: notificationManager.presenter) 71 | .store(in: &cancellables) 72 | 73 | statusItem.button?.menu = contextualMenu 74 | 75 | if !preferences.hasLaunchedBefore { 76 | perform(#selector(showUI(sender:)), with: nil, afterDelay: 0.2) 77 | 78 | preferences.hasLaunchedBefore.toggle() 79 | } 80 | 81 | rootViewModel.showSettingsMenu = { [weak self] in 82 | self?.showSettingsMenuFromUI() 83 | } 84 | 85 | updateController.activate() 86 | } 87 | 88 | private var imageForCurrentStatus: NSImage? { 89 | NSImage(named: .init("statusbutton")) 90 | } 91 | 92 | private func updateButton() { 93 | guard let button = statusItem.button else { return } 94 | 95 | button.image = imageForCurrentStatus 96 | button.image?.size = NSSize(width: 20, height: 20) 97 | button.action = #selector(toggleUI) 98 | } 99 | 100 | private var issueBadgeVisible: Bool = false { 101 | didSet { 102 | guard issueBadgeVisible != oldValue else { return } 103 | 104 | updateBadge() 105 | } 106 | } 107 | 108 | private lazy var badgeView: NSTextField = { 109 | let v = NSTextField(labelWithString: "!") 110 | 111 | v.font = .systemFont(ofSize: 12, weight: .bold) 112 | v.textColor = .labelColor 113 | v.translatesAutoresizingMaskIntoConstraints = false 114 | let s = NSShadow() 115 | s.shadowColor = NSColor(named: "IssueBadgeColor") 116 | s.shadowBlurRadius = 1 117 | s.shadowOffset = .zero 118 | v.shadow = s 119 | 120 | return v 121 | }() 122 | 123 | private func updateBadge() { 124 | guard let button = statusItem.button else { return } 125 | 126 | if badgeView.superview == nil { 127 | button.addSubview(badgeView) 128 | NSLayoutConstraint.activate([ 129 | badgeView.trailingAnchor.constraint(equalTo: button.trailingAnchor, constant: 0), 130 | badgeView.bottomAnchor.constraint(equalTo: button.bottomAnchor, constant: 0), 131 | ]) 132 | } 133 | 134 | badgeView.isHidden = !issueBadgeVisible 135 | } 136 | 137 | @objc func toggleUI(_ sender: Any?) { 138 | if windowController.window?.isVisible == true { 139 | hideUI(sender: sender) 140 | } else { 141 | showUI(sender: sender) 142 | } 143 | } 144 | 145 | @objc func showUI(sender: Any?) { 146 | rootViewModel.refresh(nil) 147 | 148 | windowController.showWindow(sender) 149 | } 150 | 151 | func hideUI(sender: Any?) { 152 | // Go back if showing detail. 153 | guard rootViewModel.selectedDashboardItem == nil else { 154 | rootViewModel.selectedDashboardItem = nil 155 | return 156 | } 157 | 158 | windowController.close() 159 | } 160 | 161 | func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { 162 | showUI(sender: nil) 163 | 164 | return true 165 | } 166 | 167 | // MARK: - Menu 168 | 169 | private lazy var contextualMenu: NSMenu = { 170 | let m = NSMenu(title: "StatusBuddy") 171 | 172 | let prefsItem = NSMenuItem(title: "Preferences…", action: #selector(preferencesMenuItemAction), keyEquivalent: ",") 173 | prefsItem.target = self 174 | 175 | let quitItem = NSMenuItem(title: "Quit", action: #selector(NSApplication.terminate), keyEquivalent: "Q") 176 | quitItem.target = NSApp 177 | 178 | m.addItem(prefsItem) 179 | m.addItem(.separator()) 180 | m.addItem(quitItem) 181 | 182 | return m 183 | }() 184 | 185 | private var preferencesWindowController: NSWindowController? 186 | 187 | @objc private func preferencesMenuItemAction(_ sender: NSMenuItem) { 188 | defer { hideUI(sender: sender) } 189 | 190 | if let preferencesWindowController { 191 | preferencesWindowController.showWindow(sender) 192 | return 193 | } 194 | 195 | let controller = HostingWindowController( 196 | rootView: PreferencesView() 197 | .padding() 198 | .environmentObject(preferences) 199 | .environmentObject(updateController) 200 | ) 201 | 202 | controller.showWindow(sender) 203 | 204 | preferencesWindowController = controller 205 | 206 | controller.willClose = { [weak self] _ in 207 | self?.preferencesWindowController = nil 208 | } 209 | } 210 | 211 | private func closePreferences() { 212 | preferencesWindowController?.close() 213 | preferencesWindowController = nil 214 | } 215 | 216 | @objc private func showSettingsMenuFromUI() { 217 | contextualMenu.popUp(positioning: nil, at: NSEvent.mouseLocation, in: nil) 218 | } 219 | 220 | } 221 | 222 | -------------------------------------------------------------------------------- /StatusBuddy/Support/LaunchAtLoginHelper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LaunchAtLoginHelper.swift 3 | // StatusBuddy 4 | // 5 | // Created by Guilherme Rambo on 21/12/21. 6 | // Copyright © 2021 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | import ServiceManagement 11 | 12 | struct LaunchAtLoginFailure: LocalizedError { 13 | var errorDescription: String? 14 | 15 | static let enable = LaunchAtLoginFailure(errorDescription: "Sorry, enabling launch at login failed. Make sure that you don't have multiple copies of the app on your Mac.") 16 | static let disable = LaunchAtLoginFailure(errorDescription: "Sorry, disabling launch at login failed. Make sure that you don't have multiple copies of the app on your Mac.") 17 | } 18 | 19 | protocol LaunchAtLoginProvider: AnyObject { 20 | func checkEnabled() -> Bool 21 | func setEnabled(_ enabled: Bool) -> LaunchAtLoginFailure? 22 | } 23 | 24 | final class LaunchAtLoginHelper: LaunchAtLoginProvider { 25 | 26 | static let helperAppIdentifier = "tech.buddysoftware.StatusBuddyHelper" 27 | 28 | func checkEnabled() -> Bool { 29 | // Not actually deprecated according to the headers. 30 | guard let jobDictsPtr = SMCopyAllJobDictionaries(kSMDomainUserLaunchd) else { return false } 31 | 32 | guard let dicts = jobDictsPtr.takeUnretainedValue() as? [[String: Any]] else { return false } 33 | 34 | return dicts.contains(where: { $0["Label"] as? String == Self.helperAppIdentifier }) 35 | } 36 | 37 | func setEnabled(_ enabled: Bool) -> LaunchAtLoginFailure? { 38 | #if DEBUG 39 | guard !UserDefaults.standard.bool(forKey: "SBSimulateLaunchAtLoginEnablementError") else { 40 | return .enable 41 | } 42 | #endif 43 | 44 | if enabled { 45 | return SMLoginItemSetEnabled(Self.helperAppIdentifier as CFString, true) ? nil : .enable 46 | } else { 47 | return SMLoginItemSetEnabled(Self.helperAppIdentifier as CFString, false) ? nil : .disable 48 | } 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /StatusBuddy/Support/Preferences.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Preferences.swift 3 | // StatusBuddy 4 | // 5 | // Created by Guilherme Rambo on 11/02/20. 6 | // Copyright © 2020 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | 12 | final class Preferences: ObservableObject { 13 | 14 | static let forPreviews = Preferences(defaults: UserDefaults(), launchAtLogin: PreviewLaunchAtLoginProvider()) 15 | 16 | private var appURL: URL { Bundle.main.bundleURL } 17 | 18 | let defaults: UserDefaults 19 | private let launchAtLogin: LaunchAtLoginProvider 20 | 21 | private struct Keys { 22 | static let enableTimeSensitiveNotifications = "enableTimeSensitiveNotifications" 23 | } 24 | 25 | init(defaults: UserDefaults = .standard, 26 | launchAtLogin: LaunchAtLoginProvider = LaunchAtLoginHelper()) 27 | { 28 | self.defaults = defaults 29 | self.launchAtLogin = launchAtLogin 30 | 31 | self.defaults.register(defaults: [ 32 | Keys.enableTimeSensitiveNotifications: true 33 | ]) 34 | 35 | enableTimeSensitiveNotifications = defaults.bool(forKey: Keys.enableTimeSensitiveNotifications) 36 | } 37 | 38 | @Published var enableTimeSensitiveNotifications: Bool = true { 39 | didSet { 40 | defaults.set(enableTimeSensitiveNotifications, forKey: Keys.enableTimeSensitiveNotifications) 41 | } 42 | } 43 | 44 | var hasLaunchedBefore: Bool { 45 | get { 46 | guard !UserDefaults.standard.bool(forKey: "SBSimulateFirstLaunch") else { return false } 47 | 48 | return defaults.bool(forKey: #function) 49 | } 50 | set { defaults.set(newValue, forKey: #function) } 51 | } 52 | 53 | var isLaunchAtLoginEnabled: Bool { launchAtLogin.checkEnabled() } 54 | 55 | @discardableResult 56 | func setLaunchAtLoginEnabled(to enabled: Bool) -> LaunchAtLoginFailure? { 57 | if enabled { 58 | guard !isLaunchAtLoginEnabled else { return nil } 59 | 60 | objectWillChange.send() 61 | 62 | return launchAtLogin.setEnabled(true) 63 | } else { 64 | guard isLaunchAtLoginEnabled else { return nil } 65 | 66 | objectWillChange.send() 67 | 68 | return launchAtLogin.setEnabled(false) 69 | } 70 | } 71 | 72 | } 73 | 74 | fileprivate final class PreviewLaunchAtLoginProvider: LaunchAtLoginProvider { 75 | 76 | var isEnabled = false 77 | 78 | func checkEnabled() -> Bool { isEnabled } 79 | 80 | func setEnabled(_ enabled: Bool) -> LaunchAtLoginFailure? { 81 | isEnabled = enabled 82 | 83 | return nil 84 | } 85 | 86 | } 87 | -------------------------------------------------------------------------------- /StatusBuddy/Support/UpdateController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UpdateController.swift 3 | // StatusBuddy 4 | // 5 | // Created by Guilherme Rambo on 01/07/21. 6 | // Copyright © 2021 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | #if ENABLE_SPARKLE 12 | import Sparkle 13 | #endif 14 | 15 | final class UpdateController: NSObject, ObservableObject { 16 | 17 | var isAvailable: Bool { 18 | #if ENABLE_SPARKLE 19 | return true 20 | #else 21 | return false 22 | #endif 23 | } 24 | 25 | #if ENABLE_SPARKLE 26 | private lazy var controller: SPUStandardUpdaterController = { 27 | SPUStandardUpdaterController( 28 | startingUpdater: false, 29 | updaterDelegate: self, 30 | userDriverDelegate: self 31 | ) 32 | }() 33 | #endif 34 | 35 | @Published var automaticallyCheckForUpdates = false { 36 | didSet { 37 | #if ENABLE_SPARKLE 38 | controller.updater.automaticallyChecksForUpdates = automaticallyCheckForUpdates 39 | #endif 40 | } 41 | } 42 | 43 | func activate() { 44 | #if ENABLE_SPARKLE 45 | controller.startUpdater() 46 | automaticallyCheckForUpdates = controller.updater.automaticallyChecksForUpdates 47 | #endif 48 | } 49 | 50 | func checkForUpdates() { 51 | #if ENABLE_SPARKLE 52 | controller.checkForUpdates(nil) 53 | #else 54 | let alert = NSAlert() 55 | alert.messageText = "Updates Not Supported" 56 | alert.informativeText = "This build doesn't support automatic updates." 57 | alert.addButton(withTitle: "OK") 58 | alert.runModal() 59 | #endif 60 | } 61 | 62 | } 63 | 64 | #if ENABLE_SPARKLE 65 | extension UpdateController: SPUUpdaterDelegate, SPUStandardUserDriverDelegate { 66 | 67 | } 68 | #endif 69 | -------------------------------------------------------------------------------- /StatusBuddy/Views/PreferencesView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PreferencesView.swift 3 | // StatusBuddy 4 | // 5 | // Created by Guilherme Rambo on 21/12/21. 6 | // Copyright © 2021 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct PreferencesView: View { 12 | @EnvironmentObject var preferences: Preferences 13 | @EnvironmentObject var updateController: UpdateController 14 | 15 | @Environment(\.closeWindow) var closeWindow 16 | 17 | var body: some View { 18 | Form { 19 | VStack(alignment: .leading, spacing: 12) { 20 | Toggle("Launch StatusBuddy at login", isOn: .init(get: { 21 | preferences.isLaunchAtLoginEnabled 22 | }, set: { isEnabled in 23 | preferences.setLaunchAtLoginEnabled(to: isEnabled) 24 | })) 25 | 26 | VStack(alignment: .leading) { 27 | Toggle("Use time sensitive notifications", isOn: .init(get: { 28 | preferences.enableTimeSensitiveNotifications 29 | }, set: { isEnabled in 30 | preferences.enableTimeSensitiveNotifications = isEnabled 31 | })) 32 | 33 | Text("StatusBuddy will use time sensitive notifications to alert you when a system comes back online.") 34 | .font(.callout) 35 | .foregroundColor(.secondary) 36 | .lineLimit(nil) 37 | .fixedSize(horizontal: false, vertical: true) 38 | .padding(.bottom, 6) 39 | .frame(maxHeight: 50, alignment: .topLeading) 40 | } 41 | 42 | if updateController.isAvailable { 43 | VStack(alignment: .leading) { 44 | Toggle("Check for updates automatically", isOn: $updateController.automaticallyCheckForUpdates) 45 | 46 | Button("Check Now") { 47 | updateController.checkForUpdates() 48 | } 49 | } 50 | } 51 | } 52 | 53 | HStack { 54 | Spacer() 55 | 56 | Button("Done") { 57 | closeWindow() 58 | } 59 | .keyboardShortcut(.defaultAction) 60 | } 61 | .padding(.top) 62 | } 63 | .frame(maxWidth: 320) 64 | .windowTitle("StatusBuddy Preferences") 65 | } 66 | } 67 | 68 | struct PreferencesView_Previews: PreviewProvider { 69 | static var previews: some View { 70 | PreferencesView() 71 | .padding() 72 | .environmentObject(Preferences.forPreviews) 73 | .environmentObject(UpdateController()) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /StatusBuddyHelper/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /StatusBuddyHelper/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac", 5 | "scale" : "1x", 6 | "size" : "16x16" 7 | }, 8 | { 9 | "idiom" : "mac", 10 | "scale" : "2x", 11 | "size" : "16x16" 12 | }, 13 | { 14 | "idiom" : "mac", 15 | "scale" : "1x", 16 | "size" : "32x32" 17 | }, 18 | { 19 | "idiom" : "mac", 20 | "scale" : "2x", 21 | "size" : "32x32" 22 | }, 23 | { 24 | "idiom" : "mac", 25 | "scale" : "1x", 26 | "size" : "128x128" 27 | }, 28 | { 29 | "idiom" : "mac", 30 | "scale" : "2x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "idiom" : "mac", 35 | "scale" : "1x", 36 | "size" : "256x256" 37 | }, 38 | { 39 | "idiom" : "mac", 40 | "scale" : "2x", 41 | "size" : "256x256" 42 | }, 43 | { 44 | "idiom" : "mac", 45 | "scale" : "1x", 46 | "size" : "512x512" 47 | }, 48 | { 49 | "idiom" : "mac", 50 | "scale" : "2x", 51 | "size" : "512x512" 52 | } 53 | ], 54 | "info" : { 55 | "author" : "xcode", 56 | "version" : 1 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /StatusBuddyHelper/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /StatusBuddyHelper/Bundle+MainApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Bundle+MainApp.swift 3 | // StatusBuddyHelper 4 | // 5 | // Created by Guilherme Rambo on 21/12/21. 6 | // Copyright © 2021 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension Bundle { 12 | 13 | /// Returns the URL for the app's main bundle. 14 | public var mainAppBundleURL: URL { 15 | #if DEBUG 16 | guard !Bundle.main.bundleURL.path.contains("DerivedData") else { 17 | return derivedDataMainAppBundleURL 18 | } 19 | #endif 20 | 21 | return bundledMainAppBundleURL 22 | } 23 | 24 | /// Returns `true` if the app is running from within the Xcode DerivedData folder. 25 | /// Always returns `false` in release builds. 26 | /// Assumes the path for the folder contains `DerivedData`. 27 | public var isInDerivedDataFolder: Bool { 28 | #if DEBUG 29 | return bundlePath.contains("DerivedData") 30 | #else 31 | return false 32 | #endif 33 | } 34 | 35 | private var bundledMainAppBundleURL: URL { 36 | return bundleURL 37 | .deletingLastPathComponent() // StatusBuddyHelper.app 38 | .deletingLastPathComponent() // LoginItems 39 | .deletingLastPathComponent() // Library 40 | .deletingLastPathComponent() // Contents 41 | } 42 | 43 | #if DEBUG 44 | /// Handles StatusBuddyHelper running directly from within Xcode, in which case it 45 | /// will be outside of the main app bundle. 46 | private var derivedDataMainAppBundleURL: URL { 47 | let mainAppURL = Bundle.main.bundleURL 48 | .deletingLastPathComponent() 49 | .appendingPathComponent("StatusBuddy.app") 50 | 51 | guard FileManager.default.fileExists(atPath: mainAppURL.path) else { 52 | return bundledMainAppBundleURL 53 | } 54 | 55 | return mainAppURL 56 | } 57 | #endif 58 | 59 | } 60 | -------------------------------------------------------------------------------- /StatusBuddyHelper/HelperAppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HelperAppDelegate.swift 3 | // StatusBuddyHelper 4 | // 5 | // Created by Guilherme Rambo on 21/12/21. 6 | // Copyright © 2021 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | import os.log 11 | 12 | @main 13 | final class HelperAppDelegate: NSObject, NSApplicationDelegate { 14 | 15 | private let log = OSLog(subsystem: "tech.buddysoftware.StatusBuddyHelper", category: String(describing: HelperAppDelegate.self)) 16 | 17 | func applicationDidFinishLaunching(_ aNotification: Notification) { 18 | let config = NSWorkspace.OpenConfiguration() 19 | config.activates = false 20 | config.addsToRecentItems = false 21 | config.promptsUserIfNeeded = false 22 | 23 | NSWorkspace.shared.openApplication( 24 | at: Bundle.main.mainAppBundleURL, 25 | configuration: config) { _, error in 26 | if let error = error { 27 | os_log("Failed to launch main app: %{public}@", log: self.log, type: .fault, String(describing: error)) 28 | } else { 29 | os_log("Main app launched successfully", log: self.log, type: .info) 30 | } 31 | 32 | DispatchQueue.main.async { NSApp?.terminate(nil) } 33 | } 34 | } 35 | 36 | } 37 | 38 | -------------------------------------------------------------------------------- /StatusBuddyHelper/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | LSBackgroundOnly 6 | 7 | LSUIElement 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /StatusBuddyHelper/StatusBuddyHelper.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /StatusCore/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | $(MARKETING_VERSION) 19 | CFBundleVersion 20 | $(CURRENT_PROJECT_VERSION) 21 | NSHumanReadableCopyright 22 | Copyright © 2020 Guilherme Rambo. All rights reserved. 23 | 24 | 25 | -------------------------------------------------------------------------------- /StatusCore/Source/Core/AppleStatusChecker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppleStatusChecker.swift 3 | // StatusCore 4 | // 5 | // Created by Guilherme Rambo on 11/02/20. 6 | // Copyright © 2020 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | import JavaScriptCore 12 | import os.log 13 | 14 | public final class AppleStatusChecker: StatusChecker { 15 | 16 | public enum ResponseFormat: Hashable { 17 | case JSON 18 | case JSONCallback 19 | } 20 | 21 | enum ResponseHandler { 22 | case JSON 23 | case JSONCallback(JSContext) 24 | } 25 | 26 | private let log = OSLog(subsystem: StatusCore.subsystemName, category: String(describing: AppleStatusChecker.self)) 27 | 28 | let endpoint: URL 29 | let format: ResponseFormat 30 | private let responseHandler: ResponseHandler 31 | 32 | public init(endpoint: URL, format: ResponseFormat) { 33 | self.endpoint = endpoint 34 | self.format = format 35 | 36 | switch format { 37 | case .JSON: 38 | self.responseHandler = .JSON 39 | case .JSONCallback: 40 | self.responseHandler = .JSONCallback(JSContext()) 41 | } 42 | } 43 | 44 | private var currentURL: URL { 45 | guard var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) else { 46 | return endpoint 47 | } 48 | 49 | // Copying the same behavior from Apple's web UI. 50 | var queryItems: [URLQueryItem] = components.queryItems ?? [] 51 | queryItems.append(URLQueryItem(name: "_", value: String(Date().timeIntervalSince1970))) 52 | 53 | components.queryItems = queryItems 54 | 55 | return components.url ?? endpoint 56 | } 57 | 58 | private lazy var session: URLSession = { 59 | let config = URLSessionConfiguration.default 60 | config.waitsForConnectivity = true 61 | config.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData 62 | return URLSession(configuration: config) 63 | }() 64 | 65 | private lazy var decoder: JSONDecoder = { 66 | let decoder = JSONDecoder() 67 | decoder.dateDecodingStrategy = .millisecondsSince1970 68 | return decoder 69 | }() 70 | 71 | public func check() -> StatusResponsePublisher { 72 | let handler = self.responseHandler 73 | 74 | return session.dataTaskPublisher(for: currentURL) 75 | .tryMap({ 76 | if UserDefaults.standard.bool(forKey: "SBSimulateNetworkingError") { 77 | throw NSError(domain: "StatusBuddy", code: -1, userInfo: [NSLocalizedFailureReasonErrorKey: "Simulated networking error."]) 78 | } else { 79 | return try handler.apply(to: $0.data) 80 | } 81 | }) 82 | .decode(type: StatusResponse.self, decoder: decoder) 83 | .retry(3) 84 | .receive(on: DispatchQueue.main) 85 | .eraseToAnyPublisher() 86 | } 87 | 88 | 89 | 90 | } 91 | 92 | private extension AppleStatusChecker.ResponseHandler { 93 | 94 | func apply(to data: Data) throws -> Data { 95 | switch self { 96 | case .JSON: 97 | return data 98 | case .JSONCallback(let context): 99 | return try processJavascript(data, using: context) 100 | } 101 | } 102 | 103 | private func processJavascript(_ input: Data, using context: JSContext) throws -> Data { 104 | context.evaluateScript(""" 105 | function jsonCallback(json) { 106 | return JSON.stringify(json); 107 | } 108 | """) 109 | 110 | guard let output = context.evaluateScript(String(decoding: input, as: UTF8.self)) else { 111 | throw CocoaError(.coderValueNotFound) 112 | } 113 | 114 | guard output.isString else { throw CocoaError(.coderValueNotFound) } 115 | 116 | return Data(output.toString().utf8) 117 | } 118 | 119 | } 120 | -------------------------------------------------------------------------------- /StatusCore/Source/Core/StatusChecker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StatusChecker.swift 3 | // StatusCore 4 | // 5 | // Created by Guilherme Rambo on 29/06/21. 6 | // Copyright © 2021 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | 12 | public typealias StatusResponsePublisher = AnyPublisher 13 | 14 | public protocol StatusChecker { 15 | func check() -> StatusResponsePublisher 16 | } 17 | -------------------------------------------------------------------------------- /StatusCore/Source/Models/Service.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Service.swift 3 | // StatusCore 4 | // 5 | // Created by Guilherme Rambo on 11/02/20. 6 | // Copyright © 2020 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public struct Service: Hashable, Codable { 12 | 13 | public struct Event: Hashable, Codable { 14 | public let epochStartDate: Date? 15 | public let epochEndDate: Date? 16 | public let message: String 17 | public let eventStatus: String 18 | 19 | static let resolvedStatuses = ["resolved", "completed"] 20 | static let scheduledStatuses = ["upcoming"] 21 | } 22 | 23 | public let serviceName: String 24 | public let redirectUrl: String? 25 | public let events: [Event] 26 | } 27 | 28 | public enum EventFilter: Int { 29 | case ongoing 30 | case recent 31 | case scheduled 32 | case operational 33 | } 34 | 35 | public extension Service { 36 | var eventsSortedByStartDateDescending: [Event] { 37 | events.sorted(by: { ($0.epochStartDate ?? .distantPast) > ($1.epochStartDate ?? .distantPast) }) 38 | } 39 | 40 | var latestEvent: Event? { eventsSortedByStartDateDescending.first } 41 | 42 | func events(filteredBy filter: EventFilter) -> [Event] { 43 | switch filter { 44 | case .ongoing: 45 | return activeEvents 46 | case .recent: 47 | return recentEvents 48 | case .scheduled: 49 | return scheduledEvents 50 | case .operational: 51 | return [] 52 | } 53 | } 54 | } 55 | 56 | public extension Service { 57 | var activeEvents: [Event] { events.filter { !(Event.resolvedStatuses + Event.scheduledStatuses).contains($0.eventStatus) } } 58 | var recentEvents: [Event] { events.filter { Event.resolvedStatuses.contains($0.eventStatus) } } 59 | var scheduledEvents: [Event] { events.filter { Event.scheduledStatuses.contains($0.eventStatus) } } 60 | 61 | var hasActiveEvents: Bool { !activeEvents.isEmpty } 62 | var hasRecentEvents: Bool { !recentEvents.isEmpty } 63 | var hasScheduledEvents: Bool { !scheduledEvents.isEmpty } 64 | } 65 | 66 | public extension StatusResponse { 67 | var hasActiveEvents: Bool { !services.filter({ $0.hasActiveEvents }).isEmpty } 68 | var hasRecentEvents: Bool { !services.filter({ $0.hasRecentEvents }).isEmpty } 69 | var hasScheduledEvents: Bool { !services.filter({ $0.hasScheduledEvents }).isEmpty } 70 | } 71 | -------------------------------------------------------------------------------- /StatusCore/Source/Models/StatusResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StatusResponse.swift 3 | // StatusCore 4 | // 5 | // Created by Guilherme Rambo on 11/02/20. 6 | // Copyright © 2020 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public struct StatusResponse: Hashable, Codable { 12 | public let services: [Service] 13 | } 14 | -------------------------------------------------------------------------------- /StatusCore/Source/StatusCore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StatusCore.swift 3 | // StatusCore 4 | // 5 | // Created by Guilherme Rambo on 29/06/21. 6 | // Copyright © 2021 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct StatusCore { 12 | static let subsystemName = "com.nsbrltda.StatusBuddy.StatusCore" 13 | } 14 | -------------------------------------------------------------------------------- /StatusCore/StatusCore.h: -------------------------------------------------------------------------------- 1 | // 2 | // StatusCore.h 3 | // StatusCore 4 | // 5 | // Created by Guilherme Rambo on 11/02/20. 6 | // Copyright © 2020 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | //! Project version number for StatusCore. 12 | FOUNDATION_EXPORT double StatusCoreVersionNumber; 13 | 14 | //! Project version string for StatusCore. 15 | FOUNDATION_EXPORT const unsigned char StatusCoreVersionString[]; 16 | 17 | // In this header, you should import all the public headers of your framework using statements like #import 18 | 19 | 20 | -------------------------------------------------------------------------------- /StatusCoreTests/Data/Bundle+TestData.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @testable import StatusCore 3 | 4 | private final class _StubForTestBundleInit { } 5 | 6 | extension Bundle { 7 | static let test = Bundle(for: _StubForTestBundleInit.self) 8 | 9 | func load(_ type: T.Type, from filename: String) throws -> T { 10 | guard let fileURL = url(forResource: filename, withExtension: "json") else { 11 | assertionFailure("Missing file \(filename).json") 12 | throw NSError(domain: "", code: 1, userInfo: nil) 13 | } 14 | 15 | let data = try Data(contentsOf: fileURL) 16 | 17 | return try JSONDecoder().decode(type, from: data) 18 | } 19 | } 20 | 21 | extension StatusResponse { 22 | static func developerOneResolvedIssue() throws -> StatusResponse { 23 | try Bundle.test.load(StatusResponse.self, from: "developer-one-resolved-issue") 24 | } 25 | 26 | static func developerOneScheduledIssue() throws -> StatusResponse { 27 | try Bundle.test.load(StatusResponse.self, from: "developer-one-scheduled-issue") 28 | } 29 | 30 | static func customerThreeResolvedIssues() throws -> StatusResponse { 31 | try Bundle.test.load(StatusResponse.self, from: "customer-three-resolved-issues") 32 | } 33 | 34 | static func customerOneOngoingIssue() throws -> StatusResponse { 35 | try Bundle.test.load(StatusResponse.self, from: "customer-one-ongoing-issue") 36 | } 37 | 38 | static func customerThreeOngoingIssues() throws -> StatusResponse { 39 | try Bundle.test.load(StatusResponse.self, from: "customer-three-ongoing-issues") 40 | } 41 | 42 | static func developerOneOngoingIssue() throws -> StatusResponse { 43 | try Bundle.test.load(StatusResponse.self, from: "developer-one-ongoing-issue") 44 | } 45 | 46 | static func customerNoIssues() throws -> StatusResponse { 47 | try Bundle.test.load(StatusResponse.self, from: "customer-no-issues") 48 | } 49 | 50 | static func developerNoIssues() throws -> StatusResponse { 51 | try Bundle.test.load(StatusResponse.self, from: "developer-no-issues") 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /StatusCoreTests/Data/customer-no-issues.json: -------------------------------------------------------------------------------- 1 | { 2 | "services": [ 3 | { 4 | "serviceName": "App Store", 5 | "events": [] 6 | }, 7 | { 8 | "serviceName": "Apple Arcade", 9 | "events": [] 10 | }, 11 | { 12 | "serviceName": "Apple Books", 13 | "events": [] 14 | }, 15 | { 16 | "serviceName": "Apple Business Manager", 17 | "events": [ 18 | ] 19 | }, 20 | { 21 | "serviceName": "Apple Card", 22 | "events": [] 23 | }, 24 | { 25 | "serviceName": "Apple Cash", 26 | "events": [] 27 | }, 28 | { 29 | "serviceName": "Apple Fitness+", 30 | "events": [] 31 | }, 32 | { 33 | "serviceName": "Apple ID", 34 | "events": [] 35 | }, 36 | { 37 | "serviceName": "Apple Music", 38 | "events": [] 39 | }, 40 | { 41 | "serviceName": "Apple Music for Artists", 42 | "events": [] 43 | }, 44 | { 45 | "serviceName": "Apple Music radio", 46 | "events": [] 47 | }, 48 | { 49 | "serviceName": "Apple Music Subscriptions", 50 | "events": [] 51 | }, 52 | { 53 | "serviceName": "Apple Online Store", 54 | "events": [] 55 | }, 56 | { 57 | "events": [], 58 | "serviceName": "Apple Pay", 59 | "redirectUrl": "https://developer.apple.com/apple-pay/" 60 | }, 61 | { 62 | "serviceName": "Apple School Manager", 63 | "events": [] 64 | }, 65 | { 66 | "serviceName": "Apple TV Channels", 67 | "events": [] 68 | }, 69 | { 70 | "serviceName": "Apple TV+", 71 | "events": [] 72 | }, 73 | { 74 | "serviceName": "AppleCare on Device", 75 | "events": [] 76 | }, 77 | { 78 | "serviceName": "Device Enrollment Program", 79 | "events": [ 80 | ] 81 | }, 82 | { 83 | "serviceName": "Dictation", 84 | "events": [] 85 | }, 86 | { 87 | "serviceName": "Documents in the Cloud", 88 | "events": [] 89 | }, 90 | { 91 | "serviceName": "FaceTime", 92 | "events": [] 93 | }, 94 | { 95 | "serviceName": "Find My", 96 | "events": [] 97 | }, 98 | { 99 | "serviceName": "Fleetsmith", 100 | "events": [] 101 | }, 102 | { 103 | "serviceName": "Game Center", 104 | "events": [] 105 | }, 106 | { 107 | "serviceName": "Global Service Exchange", 108 | "events": [] 109 | }, 110 | { 111 | "serviceName": "iCloud Account & Sign In", 112 | "events": [] 113 | }, 114 | { 115 | "serviceName": "iCloud Backup", 116 | "events": [] 117 | }, 118 | { 119 | "serviceName": "iCloud Bookmarks & Tabs", 120 | "events": [] 121 | }, 122 | { 123 | "serviceName": "iCloud Calendar", 124 | "events": [] 125 | }, 126 | { 127 | "serviceName": "iCloud Contacts", 128 | "events": [] 129 | }, 130 | { 131 | "serviceName": "iCloud Drive", 132 | "events": [] 133 | }, 134 | { 135 | "serviceName": "iCloud Keychain", 136 | "events": [] 137 | }, 138 | { 139 | "serviceName": "iCloud Mail", 140 | "events": [] 141 | }, 142 | { 143 | "serviceName": "iCloud Notes", 144 | "events": [] 145 | }, 146 | { 147 | "serviceName": "iCloud Reminders", 148 | "events": [] 149 | }, 150 | { 151 | "serviceName": "iCloud Storage Upgrades", 152 | "events": [] 153 | }, 154 | { 155 | "serviceName": "iCloud Web Apps (iCloud.com)", 156 | "events": [] 157 | }, 158 | { 159 | "serviceName": "iMessage", 160 | "events": [] 161 | }, 162 | { 163 | "serviceName": "iOS Device Activation", 164 | "events": [] 165 | }, 166 | { 167 | "serviceName": "iTunes Match", 168 | "events": [] 169 | }, 170 | { 171 | "serviceName": "iTunes Store", 172 | "events": [] 173 | }, 174 | { 175 | "serviceName": "iTunes U", 176 | "events": [] 177 | }, 178 | { 179 | "serviceName": "iWork for iCloud", 180 | "events": [] 181 | }, 182 | { 183 | "serviceName": "Mac App Store", 184 | "events": [] 185 | }, 186 | { 187 | "serviceName": "macOS Software Update", 188 | "events": [] 189 | }, 190 | { 191 | "serviceName": "Mail Drop", 192 | "events": [] 193 | }, 194 | { 195 | "serviceName": "Maps Display", 196 | "events": [] 197 | }, 198 | { 199 | "serviceName": "Maps Routing & Navigation", 200 | "events": [] 201 | }, 202 | { 203 | "serviceName": "Maps Search", 204 | "events": [] 205 | }, 206 | { 207 | "serviceName": "Maps Traffic", 208 | "events": [] 209 | }, 210 | { 211 | "serviceName": "News", 212 | "events": [] 213 | }, 214 | { 215 | "serviceName": "Photos", 216 | "events": [] 217 | }, 218 | { 219 | "serviceName": "Podcasts", 220 | "events": [] 221 | }, 222 | { 223 | "serviceName": "Radio", 224 | "events": [] 225 | }, 226 | { 227 | "serviceName": "Schooltime", 228 | "events": [] 229 | }, 230 | { 231 | "serviceName": "Schoolwork", 232 | "events": [] 233 | }, 234 | { 235 | "serviceName": "Screen Time", 236 | "events": [] 237 | }, 238 | { 239 | "serviceName": "Sign in with Apple", 240 | "events": [] 241 | }, 242 | { 243 | "serviceName": "Siri", 244 | "events": [] 245 | }, 246 | { 247 | "serviceName": "Spotlight suggestions", 248 | "events": [] 249 | }, 250 | { 251 | "serviceName": "Stocks", 252 | "events": [] 253 | }, 254 | { 255 | "serviceName": "Volume Purchase Program", 256 | "events": [] 257 | }, 258 | { 259 | "serviceName": "Walkie-Talkie", 260 | "events": [] 261 | }, 262 | { 263 | "serviceName": "Weather", 264 | "events": [] 265 | } 266 | ] 267 | } 268 | -------------------------------------------------------------------------------- /StatusCoreTests/Data/customer-one-ongoing-issue.json: -------------------------------------------------------------------------------- 1 | { 2 | "drpost": false, 3 | "drMessage": null, 4 | "services": [ 5 | { 6 | "redirectUrl": null, 7 | "serviceName": "App Store", 8 | "events": [] 9 | }, 10 | { 11 | "redirectUrl": null, 12 | "serviceName": "Apple Arcade", 13 | "events": [] 14 | }, 15 | { 16 | "redirectUrl": null, 17 | "serviceName": "Apple Books", 18 | "events": [] 19 | }, 20 | { 21 | "redirectUrl": null, 22 | "serviceName": "Apple Business Manager", 23 | "events": [] 24 | }, 25 | { 26 | "redirectUrl": null, 27 | "serviceName": "Apple Card", 28 | "events": [] 29 | }, 30 | { 31 | "redirectUrl": null, 32 | "serviceName": "Apple Cash", 33 | "events": [] 34 | }, 35 | { 36 | "redirectUrl": null, 37 | "serviceName": "Apple Fitness+", 38 | "events": [] 39 | }, 40 | { 41 | "redirectUrl": null, 42 | "serviceName": "Apple ID", 43 | "events": [] 44 | }, 45 | { 46 | "redirectUrl": null, 47 | "serviceName": "Apple Music", 48 | "events": [] 49 | }, 50 | { 51 | "redirectUrl": null, 52 | "serviceName": "Apple Music radio", 53 | "events": [] 54 | }, 55 | { 56 | "redirectUrl": null, 57 | "serviceName": "Apple Music Subscriptions", 58 | "events": [] 59 | }, 60 | { 61 | "redirectUrl": null, 62 | "serviceName": "Apple Online Store", 63 | "events": [] 64 | }, 65 | { 66 | "redirectUrl": "https://web.archive.org/web/20210426135940/https://developer.apple.com/apple-pay/", 67 | "serviceName": "Apple Pay", 68 | "events": [] 69 | }, 70 | { 71 | "redirectUrl": null, 72 | "serviceName": "Apple School Manager", 73 | "events": [] 74 | }, 75 | { 76 | "redirectUrl": null, 77 | "serviceName": "Apple TV Channels", 78 | "events": [] 79 | }, 80 | { 81 | "redirectUrl": null, 82 | "serviceName": "Apple TV+", 83 | "events": [] 84 | }, 85 | { 86 | "redirectUrl": null, 87 | "serviceName": "AppleCare on Device", 88 | "events": [] 89 | }, 90 | { 91 | "redirectUrl": null, 92 | "serviceName": "Device Enrollment Program", 93 | "events": [] 94 | }, 95 | { 96 | "redirectUrl": null, 97 | "serviceName": "Dictation", 98 | "events": [] 99 | }, 100 | { 101 | "redirectUrl": null, 102 | "serviceName": "Documents in the Cloud", 103 | "events": [] 104 | }, 105 | { 106 | "redirectUrl": null, 107 | "serviceName": "FaceTime", 108 | "events": [] 109 | }, 110 | { 111 | "redirectUrl": null, 112 | "serviceName": "Find My", 113 | "events": [] 114 | }, 115 | { 116 | "redirectUrl": null, 117 | "serviceName": "Fleetsmith", 118 | "events": [] 119 | }, 120 | { 121 | "redirectUrl": null, 122 | "serviceName": "Game Center", 123 | "events": [] 124 | }, 125 | { 126 | "redirectUrl": null, 127 | "serviceName": "Global Service Exchange", 128 | "events": [] 129 | }, 130 | { 131 | "redirectUrl": null, 132 | "serviceName": "iCloud Account & Sign In", 133 | "events": [] 134 | }, 135 | { 136 | "redirectUrl": null, 137 | "serviceName": "iCloud Backup", 138 | "events": [] 139 | }, 140 | { 141 | "redirectUrl": null, 142 | "serviceName": "iCloud Bookmarks & Tabs", 143 | "events": [] 144 | }, 145 | { 146 | "redirectUrl": null, 147 | "serviceName": "iCloud Calendar", 148 | "events": [] 149 | }, 150 | { 151 | "redirectUrl": null, 152 | "serviceName": "iCloud Contacts", 153 | "events": [] 154 | }, 155 | { 156 | "redirectUrl": null, 157 | "serviceName": "iCloud Drive", 158 | "events": [] 159 | }, 160 | { 161 | "redirectUrl": null, 162 | "serviceName": "iCloud Keychain", 163 | "events": [ 164 | { 165 | "usersAffected": "Some users were affected", 166 | "epochStartDate": 1619291760000, 167 | "epochEndDate": 1619304900000, 168 | "messageId": "2000000560", 169 | "statusType": "Issue", 170 | "datePosted": "04/26/2021 06:30 PDT", 171 | "startDate": "04/24/2021 12:16 PDT", 172 | "endDate": "04/24/2021 15:55 PDT", 173 | "affectedServices": null, 174 | "eventStatus": "resolved", 175 | "message": "Users experienced a problem with this service." 176 | } 177 | ] 178 | }, 179 | { 180 | "redirectUrl": null, 181 | "serviceName": "iCloud Mail", 182 | "events": [ 183 | { 184 | "usersAffected": "Some users are affected", 185 | "epochStartDate": 1619435580000, 186 | "epochEndDate": null, 187 | "messageId": "1000000310", 188 | "statusType": "Outage", 189 | "datePosted": "04/26/2021 06:30 PDT", 190 | "startDate": "04/26/2021 04:13 PDT", 191 | "endDate": null, 192 | "affectedServices": null, 193 | "eventStatus": "ongoing", 194 | "message": "Users may be experiencing intermittent issues with this service." 195 | } 196 | ] 197 | }, 198 | { 199 | "redirectUrl": null, 200 | "serviceName": "iCloud Notes", 201 | "events": [] 202 | }, 203 | { 204 | "redirectUrl": null, 205 | "serviceName": "iCloud Reminders", 206 | "events": [] 207 | }, 208 | { 209 | "redirectUrl": null, 210 | "serviceName": "iCloud Storage Upgrades", 211 | "events": [] 212 | }, 213 | { 214 | "redirectUrl": null, 215 | "serviceName": "iCloud Web Apps (iCloud.com)", 216 | "events": [] 217 | }, 218 | { 219 | "redirectUrl": null, 220 | "serviceName": "iMessage", 221 | "events": [] 222 | }, 223 | { 224 | "redirectUrl": null, 225 | "serviceName": "iOS Device Activation", 226 | "events": [] 227 | }, 228 | { 229 | "redirectUrl": null, 230 | "serviceName": "iTunes Match", 231 | "events": [] 232 | }, 233 | { 234 | "redirectUrl": null, 235 | "serviceName": "iTunes Store", 236 | "events": [] 237 | }, 238 | { 239 | "redirectUrl": null, 240 | "serviceName": "iTunes U", 241 | "events": [] 242 | }, 243 | { 244 | "redirectUrl": null, 245 | "serviceName": "iWork for iCloud", 246 | "events": [] 247 | }, 248 | { 249 | "redirectUrl": null, 250 | "serviceName": "Mac App Store", 251 | "events": [] 252 | }, 253 | { 254 | "redirectUrl": null, 255 | "serviceName": "macOS Software Update", 256 | "events": [] 257 | }, 258 | { 259 | "redirectUrl": null, 260 | "serviceName": "Mail Drop", 261 | "events": [] 262 | }, 263 | { 264 | "redirectUrl": null, 265 | "serviceName": "Maps Display", 266 | "events": [] 267 | }, 268 | { 269 | "redirectUrl": null, 270 | "serviceName": "Maps Routing & Navigation", 271 | "events": [] 272 | }, 273 | { 274 | "redirectUrl": null, 275 | "serviceName": "Maps Search", 276 | "events": [] 277 | }, 278 | { 279 | "redirectUrl": null, 280 | "serviceName": "Maps Traffic", 281 | "events": [] 282 | }, 283 | { 284 | "redirectUrl": null, 285 | "serviceName": "News", 286 | "events": [] 287 | }, 288 | { 289 | "redirectUrl": null, 290 | "serviceName": "Photos", 291 | "events": [] 292 | }, 293 | { 294 | "redirectUrl": null, 295 | "serviceName": "Radio", 296 | "events": [] 297 | }, 298 | { 299 | "redirectUrl": null, 300 | "serviceName": "Schooltime", 301 | "events": [] 302 | }, 303 | { 304 | "redirectUrl": null, 305 | "serviceName": "Schoolwork", 306 | "events": [] 307 | }, 308 | { 309 | "redirectUrl": null, 310 | "serviceName": "Screen Time", 311 | "events": [] 312 | }, 313 | { 314 | "redirectUrl": null, 315 | "serviceName": "Sign in with Apple", 316 | "events": [] 317 | }, 318 | { 319 | "redirectUrl": null, 320 | "serviceName": "Siri", 321 | "events": [] 322 | }, 323 | { 324 | "redirectUrl": null, 325 | "serviceName": "Spotlight suggestions", 326 | "events": [] 327 | }, 328 | { 329 | "redirectUrl": null, 330 | "serviceName": "Stocks", 331 | "events": [] 332 | }, 333 | { 334 | "redirectUrl": null, 335 | "serviceName": "Volume Purchase Program", 336 | "events": [] 337 | }, 338 | { 339 | "redirectUrl": null, 340 | "serviceName": "Walkie-Talkie", 341 | "events": [] 342 | }, 343 | { 344 | "redirectUrl": null, 345 | "serviceName": "Weather", 346 | "events": [] 347 | } 348 | ] 349 | } -------------------------------------------------------------------------------- /StatusCoreTests/Data/customer-three-ongoing-issues.json: -------------------------------------------------------------------------------- 1 | { 2 | "services": [ 3 | { 4 | "serviceName": "App Store", 5 | "events": [] 6 | }, 7 | { 8 | "serviceName": "Apple Arcade", 9 | "events": [] 10 | }, 11 | { 12 | "serviceName": "Apple Books", 13 | "events": [] 14 | }, 15 | { 16 | "serviceName": "Apple Business Manager", 17 | "events": [ 18 | { 19 | "message": "Apple Business Manager was temporarily unavailable during system maintenance.", 20 | "eventStatus": "ongoing", 21 | "epochEndDate": null, 22 | "epochStartDate": 646419600 23 | }, 24 | { 25 | "message": "Some users may have experienced an issue with the service.", 26 | "eventStatus": "resolved", 27 | "epochEndDate": 646430300, 28 | "epochStartDate": 646419200 29 | } 30 | ] 31 | }, 32 | { 33 | "serviceName": "Apple Card", 34 | "events": [] 35 | }, 36 | { 37 | "serviceName": "Apple Cash", 38 | "events": [] 39 | }, 40 | { 41 | "serviceName": "Apple Fitness+", 42 | "events": [] 43 | }, 44 | { 45 | "serviceName": "Apple ID", 46 | "events": [] 47 | }, 48 | { 49 | "serviceName": "Apple Music", 50 | "events": [] 51 | }, 52 | { 53 | "serviceName": "Apple Music for Artists", 54 | "events": [] 55 | }, 56 | { 57 | "serviceName": "Apple Music radio", 58 | "events": [] 59 | }, 60 | { 61 | "serviceName": "Apple Music Subscriptions", 62 | "events": [] 63 | }, 64 | { 65 | "serviceName": "Apple Online Store", 66 | "events": [] 67 | }, 68 | { 69 | "events": [], 70 | "serviceName": "Apple Pay", 71 | "redirectUrl": "https://developer.apple.com/apple-pay/" 72 | }, 73 | { 74 | "serviceName": "Apple School Manager", 75 | "events": [ 76 | { 77 | "message": "This service may have been slow or unavailable.", 78 | "eventStatus": "ongoing", 79 | "epochEndDate": null, 80 | "epochStartDate": 646419600 81 | } 82 | ] 83 | }, 84 | { 85 | "serviceName": "Apple TV Channels", 86 | "events": [] 87 | }, 88 | { 89 | "serviceName": "Apple TV+", 90 | "events": [] 91 | }, 92 | { 93 | "serviceName": "AppleCare on Device", 94 | "events": [] 95 | }, 96 | { 97 | "serviceName": "Device Enrollment Program", 98 | "events": [ 99 | { 100 | "message": "Device Enrollment Program was temporarily unavailable during system maintenance.", 101 | "eventStatus": "ongoing", 102 | "epochEndDate": null, 103 | "epochStartDate": 646419600 104 | } 105 | ] 106 | }, 107 | { 108 | "serviceName": "Dictation", 109 | "events": [] 110 | }, 111 | { 112 | "serviceName": "Documents in the Cloud", 113 | "events": [] 114 | }, 115 | { 116 | "serviceName": "FaceTime", 117 | "events": [] 118 | }, 119 | { 120 | "serviceName": "Find My", 121 | "events": [] 122 | }, 123 | { 124 | "serviceName": "Fleetsmith", 125 | "events": [] 126 | }, 127 | { 128 | "serviceName": "Game Center", 129 | "events": [] 130 | }, 131 | { 132 | "serviceName": "Global Service Exchange", 133 | "events": [] 134 | }, 135 | { 136 | "serviceName": "iCloud Account & Sign In", 137 | "events": [] 138 | }, 139 | { 140 | "serviceName": "iCloud Backup", 141 | "events": [] 142 | }, 143 | { 144 | "serviceName": "iCloud Bookmarks & Tabs", 145 | "events": [] 146 | }, 147 | { 148 | "serviceName": "iCloud Calendar", 149 | "events": [] 150 | }, 151 | { 152 | "serviceName": "iCloud Contacts", 153 | "events": [] 154 | }, 155 | { 156 | "serviceName": "iCloud Drive", 157 | "events": [] 158 | }, 159 | { 160 | "serviceName": "iCloud Keychain", 161 | "events": [] 162 | }, 163 | { 164 | "serviceName": "iCloud Mail", 165 | "events": [] 166 | }, 167 | { 168 | "serviceName": "iCloud Notes", 169 | "events": [] 170 | }, 171 | { 172 | "serviceName": "iCloud Reminders", 173 | "events": [] 174 | }, 175 | { 176 | "serviceName": "iCloud Storage Upgrades", 177 | "events": [] 178 | }, 179 | { 180 | "serviceName": "iCloud Web Apps (iCloud.com)", 181 | "events": [] 182 | }, 183 | { 184 | "serviceName": "iMessage", 185 | "events": [] 186 | }, 187 | { 188 | "serviceName": "iOS Device Activation", 189 | "events": [] 190 | }, 191 | { 192 | "serviceName": "iTunes Match", 193 | "events": [] 194 | }, 195 | { 196 | "serviceName": "iTunes Store", 197 | "events": [] 198 | }, 199 | { 200 | "serviceName": "iTunes U", 201 | "events": [] 202 | }, 203 | { 204 | "serviceName": "iWork for iCloud", 205 | "events": [] 206 | }, 207 | { 208 | "serviceName": "Mac App Store", 209 | "events": [] 210 | }, 211 | { 212 | "serviceName": "macOS Software Update", 213 | "events": [] 214 | }, 215 | { 216 | "serviceName": "Mail Drop", 217 | "events": [] 218 | }, 219 | { 220 | "serviceName": "Maps Display", 221 | "events": [] 222 | }, 223 | { 224 | "serviceName": "Maps Routing & Navigation", 225 | "events": [] 226 | }, 227 | { 228 | "serviceName": "Maps Search", 229 | "events": [] 230 | }, 231 | { 232 | "serviceName": "Maps Traffic", 233 | "events": [] 234 | }, 235 | { 236 | "serviceName": "News", 237 | "events": [] 238 | }, 239 | { 240 | "serviceName": "Photos", 241 | "events": [] 242 | }, 243 | { 244 | "serviceName": "Podcasts", 245 | "events": [] 246 | }, 247 | { 248 | "serviceName": "Radio", 249 | "events": [] 250 | }, 251 | { 252 | "serviceName": "Schooltime", 253 | "events": [] 254 | }, 255 | { 256 | "serviceName": "Schoolwork", 257 | "events": [] 258 | }, 259 | { 260 | "serviceName": "Screen Time", 261 | "events": [] 262 | }, 263 | { 264 | "serviceName": "Sign in with Apple", 265 | "events": [] 266 | }, 267 | { 268 | "serviceName": "Siri", 269 | "events": [] 270 | }, 271 | { 272 | "serviceName": "Spotlight suggestions", 273 | "events": [] 274 | }, 275 | { 276 | "serviceName": "Stocks", 277 | "events": [] 278 | }, 279 | { 280 | "serviceName": "Volume Purchase Program", 281 | "events": [] 282 | }, 283 | { 284 | "serviceName": "Walkie-Talkie", 285 | "events": [] 286 | }, 287 | { 288 | "serviceName": "Weather", 289 | "events": [] 290 | } 291 | ] 292 | } 293 | -------------------------------------------------------------------------------- /StatusCoreTests/Data/customer-three-resolved-issues.json: -------------------------------------------------------------------------------- 1 | { 2 | "services": [ 3 | { 4 | "serviceName": "App Store", 5 | "events": [] 6 | }, 7 | { 8 | "serviceName": "Apple Arcade", 9 | "events": [] 10 | }, 11 | { 12 | "serviceName": "Apple Books", 13 | "events": [] 14 | }, 15 | { 16 | "serviceName": "Apple Business Manager", 17 | "events": [ 18 | { 19 | "message": "Apple Business Manager was temporarily unavailable during system maintenance.", 20 | "eventStatus": "completed", 21 | "epochEndDate": 646430400, 22 | "epochStartDate": 646419600 23 | }, 24 | { 25 | "message": "Some users may have experienced an issue with the service.", 26 | "eventStatus": "completed", 27 | "epochEndDate": 646430300, 28 | "epochStartDate": 646419200 29 | } 30 | ] 31 | }, 32 | { 33 | "serviceName": "Apple Card", 34 | "events": [] 35 | }, 36 | { 37 | "serviceName": "Apple Cash", 38 | "events": [] 39 | }, 40 | { 41 | "serviceName": "Apple Fitness+", 42 | "events": [] 43 | }, 44 | { 45 | "serviceName": "Apple ID", 46 | "events": [] 47 | }, 48 | { 49 | "serviceName": "Apple Music", 50 | "events": [] 51 | }, 52 | { 53 | "serviceName": "Apple Music for Artists", 54 | "events": [] 55 | }, 56 | { 57 | "serviceName": "Apple Music radio", 58 | "events": [] 59 | }, 60 | { 61 | "serviceName": "Apple Music Subscriptions", 62 | "events": [] 63 | }, 64 | { 65 | "serviceName": "Apple Online Store", 66 | "events": [] 67 | }, 68 | { 69 | "events": [], 70 | "serviceName": "Apple Pay", 71 | "redirectUrl": "https://developer.apple.com/apple-pay/" 72 | }, 73 | { 74 | "serviceName": "Apple School Manager", 75 | "events": [ 76 | { 77 | "message": "This service may have been slow or unavailable.", 78 | "eventStatus": "completed", 79 | "epochEndDate": 646430400, 80 | "epochStartDate": 646419600 81 | } 82 | ] 83 | }, 84 | { 85 | "serviceName": "Apple TV Channels", 86 | "events": [] 87 | }, 88 | { 89 | "serviceName": "Apple TV+", 90 | "events": [] 91 | }, 92 | { 93 | "serviceName": "AppleCare on Device", 94 | "events": [] 95 | }, 96 | { 97 | "serviceName": "Device Enrollment Program", 98 | "events": [ 99 | { 100 | "message": "Device Enrollment Program was temporarily unavailable during system maintenance.", 101 | "eventStatus": "completed", 102 | "epochEndDate": 646430400, 103 | "epochStartDate": 646419600 104 | } 105 | ] 106 | }, 107 | { 108 | "serviceName": "Dictation", 109 | "events": [] 110 | }, 111 | { 112 | "serviceName": "Documents in the Cloud", 113 | "events": [] 114 | }, 115 | { 116 | "serviceName": "FaceTime", 117 | "events": [] 118 | }, 119 | { 120 | "serviceName": "Find My", 121 | "events": [] 122 | }, 123 | { 124 | "serviceName": "Fleetsmith", 125 | "events": [] 126 | }, 127 | { 128 | "serviceName": "Game Center", 129 | "events": [] 130 | }, 131 | { 132 | "serviceName": "Global Service Exchange", 133 | "events": [] 134 | }, 135 | { 136 | "serviceName": "iCloud Account & Sign In", 137 | "events": [] 138 | }, 139 | { 140 | "serviceName": "iCloud Backup", 141 | "events": [] 142 | }, 143 | { 144 | "serviceName": "iCloud Bookmarks & Tabs", 145 | "events": [] 146 | }, 147 | { 148 | "serviceName": "iCloud Calendar", 149 | "events": [] 150 | }, 151 | { 152 | "serviceName": "iCloud Contacts", 153 | "events": [] 154 | }, 155 | { 156 | "serviceName": "iCloud Drive", 157 | "events": [] 158 | }, 159 | { 160 | "serviceName": "iCloud Keychain", 161 | "events": [] 162 | }, 163 | { 164 | "serviceName": "iCloud Mail", 165 | "events": [] 166 | }, 167 | { 168 | "serviceName": "iCloud Notes", 169 | "events": [] 170 | }, 171 | { 172 | "serviceName": "iCloud Reminders", 173 | "events": [] 174 | }, 175 | { 176 | "serviceName": "iCloud Storage Upgrades", 177 | "events": [] 178 | }, 179 | { 180 | "serviceName": "iCloud Web Apps (iCloud.com)", 181 | "events": [] 182 | }, 183 | { 184 | "serviceName": "iMessage", 185 | "events": [] 186 | }, 187 | { 188 | "serviceName": "iOS Device Activation", 189 | "events": [] 190 | }, 191 | { 192 | "serviceName": "iTunes Match", 193 | "events": [] 194 | }, 195 | { 196 | "serviceName": "iTunes Store", 197 | "events": [] 198 | }, 199 | { 200 | "serviceName": "iTunes U", 201 | "events": [] 202 | }, 203 | { 204 | "serviceName": "iWork for iCloud", 205 | "events": [] 206 | }, 207 | { 208 | "serviceName": "Mac App Store", 209 | "events": [] 210 | }, 211 | { 212 | "serviceName": "macOS Software Update", 213 | "events": [] 214 | }, 215 | { 216 | "serviceName": "Mail Drop", 217 | "events": [] 218 | }, 219 | { 220 | "serviceName": "Maps Display", 221 | "events": [] 222 | }, 223 | { 224 | "serviceName": "Maps Routing & Navigation", 225 | "events": [] 226 | }, 227 | { 228 | "serviceName": "Maps Search", 229 | "events": [] 230 | }, 231 | { 232 | "serviceName": "Maps Traffic", 233 | "events": [] 234 | }, 235 | { 236 | "serviceName": "News", 237 | "events": [] 238 | }, 239 | { 240 | "serviceName": "Photos", 241 | "events": [] 242 | }, 243 | { 244 | "serviceName": "Podcasts", 245 | "events": [] 246 | }, 247 | { 248 | "serviceName": "Radio", 249 | "events": [] 250 | }, 251 | { 252 | "serviceName": "Schooltime", 253 | "events": [] 254 | }, 255 | { 256 | "serviceName": "Schoolwork", 257 | "events": [] 258 | }, 259 | { 260 | "serviceName": "Screen Time", 261 | "events": [] 262 | }, 263 | { 264 | "serviceName": "Sign in with Apple", 265 | "events": [] 266 | }, 267 | { 268 | "serviceName": "Siri", 269 | "events": [] 270 | }, 271 | { 272 | "serviceName": "Spotlight suggestions", 273 | "events": [] 274 | }, 275 | { 276 | "serviceName": "Stocks", 277 | "events": [] 278 | }, 279 | { 280 | "serviceName": "Volume Purchase Program", 281 | "events": [] 282 | }, 283 | { 284 | "serviceName": "Walkie-Talkie", 285 | "events": [] 286 | }, 287 | { 288 | "serviceName": "Weather", 289 | "events": [] 290 | } 291 | ] 292 | } 293 | -------------------------------------------------------------------------------- /StatusCoreTests/Data/developer-no-issues.json: -------------------------------------------------------------------------------- 1 | {"services":[{"events":[],"serviceName":"Account","redirectUrl":"https://developer.apple.com/account/"},{"events":[],"serviceName":"APNS","redirectUrl":"https://developer.apple.com/notifications/"},{"events":[],"serviceName":"APNS Sandbox","redirectUrl":"https://developer.apple.com/notifications/"},{"events":[],"serviceName":"App Attest","redirectUrl":"https://developer.apple.com/documentation/devicecheck"},{"events":[],"serviceName":"App Store Automatic App Updates","redirectUrl":"https://developer.apple.com/app-store/app-updates/"},{"events":[],"serviceName":"App Store Connect","redirectUrl":"https://itunesconnect.apple.com/"},{"events":[],"serviceName":"App Store Connect Analytics","redirectUrl":"https://appstoreconnect.apple.com/analytics"},{"events":[],"serviceName":"App Store Connect API","redirectUrl":"https://developer.apple.com/app-store-connect/api/"},{"serviceName":"App Store Connect App Processing","events":[]},{"events":[],"serviceName":"App Store Receipt Verification","redirectUrl":"https://developer.apple.com/documentation/appstorereceipts/verifyreceipt"},{"events":[],"serviceName":"App Store Sandbox","redirectUrl":" https://developer.apple.com/documentation/storekit/in-app_purchase/testing_in-app_purchases_with_sandbox"},{"events":[],"serviceName":"App Store Server Notifications","redirectUrl":" https://developer.apple.com/documentation/appstoreservernotifications"},{"events":[],"serviceName":"Apple Developer Forums","redirectUrl":"https://forums.developer.apple.com"},{"events":[],"serviceName":"Apple Maps API","redirectUrl":"https://developer.apple.com/maps/"},{"events":[],"serviceName":"Apple Music API","redirectUrl":"https://developer.apple.com/musickit/"},{"serviceName":"Apple News API","events":[]},{"events":[],"serviceName":"Apple Pay - Developer","redirectUrl":"https://developer.apple.com/apple-pay/"},{"serviceName":"Apple Podcasts Connect","events":[]},{"events":[],"serviceName":"Certificates, Identifiers & Profiles","redirectUrl":"https://developer.apple.com/account/"},{"events":[],"serviceName":"CloudKit Console","redirectUrl":"https://icloud.developer.apple.com/dashboard"},{"events":[],"serviceName":"CloudKit Database","redirectUrl":"https://developer.apple.com/icloud/cloudkit/"},{"events":[],"serviceName":"Code-level Support","redirectUrl":"https://developer.apple.com/account/?view=support"},{"events":[],"serviceName":"Contact Us","redirectUrl":"https://developer.apple.com/contact/"},{"events":[],"serviceName":"Developer Documentation","redirectUrl":"https://developer.apple.com/reference"},{"serviceName":"Developer ID Notary Service","events":[]},{"events":[],"serviceName":"Device Check","redirectUrl":"https://developer.apple.com/documentation/devicecheck"},{"events":[],"serviceName":"Enterprise App Verification","redirectUrl":"https://support.apple.com/en-us/HT204460"},{"events":[],"serviceName":"Feedback Assistant","redirectUrl":"https://bugreport.apple.com/"},{"serviceName":"In-App Purchases","events":[]},{"events":[],"serviceName":"MapKit JS Dashboard","redirectUrl":"https://maps.developer.apple.com"},{"serviceName":"MFi Portal","events":[]},{"events":[],"serviceName":"News Publisher","redirectUrl":"https://developer.apple.com/news-publisher/"},{"events":[],"serviceName":"Program Enrollment and Renewals","redirectUrl":"https://developer.apple.com/enroll/"},{"events":[],"serviceName":"Software Downloads","redirectUrl":"https://developer.apple.com/download/"},{"events":[],"serviceName":"TestFlight","redirectUrl":"https://developer.apple.com/testflight/"},{"events":[],"serviceName":"Videos","redirectUrl":"https://developer.apple.com/videos/"},{"events":[],"serviceName":"Xcode Automatic Configuration","redirectUrl":"https://developer.apple.com/xcode/"},{"events":[],"serviceName":"Xcode Cloud","redirectUrl":"https://developer.apple.com/xcode-cloud/"}]} -------------------------------------------------------------------------------- /StatusCoreTests/Data/developer-one-ongoing-issue.json: -------------------------------------------------------------------------------- 1 | { 2 | "drpost": false, 3 | "drMessage": null, 4 | "services": [ 5 | { 6 | "redirectUrl": "https://web.archive.org/web/20201104231054/https://developer.apple.com/account/", 7 | "events": [], 8 | "serviceName": "Account" 9 | }, 10 | { 11 | "redirectUrl": "https://web.archive.org/web/20201104231054/https://developer.apple.com/notifications/", 12 | "events": [], 13 | "serviceName": "APNS" 14 | }, 15 | { 16 | "redirectUrl": "https://web.archive.org/web/20201104231054/https://developer.apple.com/notifications/", 17 | "events": [], 18 | "serviceName": "APNS Sandbox" 19 | }, 20 | { 21 | "redirectUrl": "https://web.archive.org/web/20201104231054/https://developer.apple.com/documentation/devicecheck", 22 | "events": [], 23 | "serviceName": "App Attest" 24 | }, 25 | { 26 | "redirectUrl": "https://web.archive.org/web/20201104231054/https://developer.apple.com/app-store/app-updates/", 27 | "events": [], 28 | "serviceName": "App Store Automatic App Updates" 29 | }, 30 | { 31 | "redirectUrl": "https://web.archive.org/web/20201104231054/https://itunesconnect.apple.com/", 32 | "events": [ 33 | { 34 | "usersAffected": "Some users were affected", 35 | "epochStartDate": 1603409280000, 36 | "epochEndDate": 1603411800000, 37 | "messageId": "2000000026", 38 | "statusType": "Outage", 39 | "datePosted": "11/02/2020 10:12 PST", 40 | "startDate": "10/22/2020 16:28 PDT", 41 | "endDate": "10/22/2020 17:10 PDT", 42 | "affectedServices": null, 43 | "eventStatus": "resolved", 44 | "message": "Users experienced a problem with this service." 45 | } 46 | ], 47 | "serviceName": "App Store Connect" 48 | }, 49 | { 50 | "redirectUrl": "https://web.archive.org/web/20201104231054/https://developer.apple.com/app-store-connect/api/", 51 | "events": [], 52 | "serviceName": "App Store Connect API" 53 | }, 54 | { 55 | "redirectUrl": null, 56 | "events": [], 57 | "serviceName": "App Store Connect App Processing" 58 | }, 59 | { 60 | "redirectUrl": "https://web.archive.org/web/20201104231054/https://developer.apple.com/documentation/appstorereceipts/verifyreceipt", 61 | "events": [], 62 | "serviceName": "App Store Receipt Verification" 63 | }, 64 | { 65 | "redirectUrl": " https://developer.apple.com/documentation/storekit/in-app_purchase/testing_in-app_purchases_with_sandbox", 66 | "events": [ 67 | { 68 | "usersAffected": "Some users were affected", 69 | "epochStartDate": 1602777600000, 70 | "epochEndDate": 1603324800000, 71 | "messageId": "1000000102", 72 | "statusType": "Issue", 73 | "datePosted": "11/02/2020 10:12 PST", 74 | "startDate": "10/15/2020 09:00 PDT", 75 | "endDate": "10/21/2020 17:00 PDT", 76 | "affectedServices": null, 77 | "eventStatus": "resolved", 78 | "message": "Users experienced a problem with this service." 79 | } 80 | ], 81 | "serviceName": "App Store Sandbox" 82 | }, 83 | { 84 | "redirectUrl": " https://developer.apple.com/documentation/appstoreservernotifications", 85 | "events": [], 86 | "serviceName": "App Store Server Notifications" 87 | }, 88 | { 89 | "redirectUrl": "https://web.archive.org/web/20201104231054/https://forums.developer.apple.com", 90 | "events": [], 91 | "serviceName": "Apple Developer Forums" 92 | }, 93 | { 94 | "redirectUrl": "https://web.archive.org/web/20201104231054/https://developer.apple.com/maps/", 95 | "events": [], 96 | "serviceName": "Apple Maps API" 97 | }, 98 | { 99 | "redirectUrl": "https://web.archive.org/web/20201104231054/https://developer.apple.com/musickit/", 100 | "events": [], 101 | "serviceName": "Apple Music API" 102 | }, 103 | { 104 | "redirectUrl": null, 105 | "events": [], 106 | "serviceName": "Apple News API" 107 | }, 108 | { 109 | "redirectUrl": "https://web.archive.org/web/20201104231054/https://developer.apple.com/apple-pay/", 110 | "events": [], 111 | "serviceName": "Apple Pay - Developer" 112 | }, 113 | { 114 | "redirectUrl": "https://web.archive.org/web/20201104231054/https://developer.apple.com/account/", 115 | "events": [], 116 | "serviceName": "Certificates, Identifiers & Profiles" 117 | }, 118 | { 119 | "redirectUrl": "https://web.archive.org/web/20201104231054/https://icloud.developer.apple.com/dashboard", 120 | "events": [], 121 | "serviceName": "CloudKit Dashboard" 122 | }, 123 | { 124 | "redirectUrl": "https://web.archive.org/web/20201104231054/https://developer.apple.com/account/?view=support", 125 | "events": [], 126 | "serviceName": "Code-level Support" 127 | }, 128 | { 129 | "redirectUrl": "https://web.archive.org/web/20201104231054/https://developer.apple.com/contact/", 130 | "events": [ 131 | { 132 | "usersAffected": "All users were affected", 133 | "epochStartDate": 1603209600000, 134 | "epochEndDate": 1603220400000, 135 | "messageId": "2000000024", 136 | "statusType": "Maintenance", 137 | "datePosted": "11/02/2020 10:12 PST", 138 | "startDate": "10/20/2020 09:00 PDT", 139 | "endDate": "10/20/2020 12:00 PDT", 140 | "affectedServices": [ 141 | "Contact Us", 142 | "Program Enrollment and Renewals", 143 | "Software Downloads", 144 | "Videos" 145 | ], 146 | "eventStatus": "completed", 147 | "message": "Service was unavailable." 148 | }, 149 | { 150 | "usersAffected": "All users were affected", 151 | "epochStartDate": 1602259200000, 152 | "epochEndDate": 1602266400000, 153 | "messageId": "2000000002", 154 | "statusType": "Maintenance", 155 | "datePosted": "11/02/2020 10:12 PST", 156 | "startDate": "10/09/2020 09:00 PDT", 157 | "endDate": "10/09/2020 11:00 PDT", 158 | "affectedServices": [ 159 | "Contact Us", 160 | "Program Enrollment and Renewals", 161 | "Software Downloads", 162 | "Videos" 163 | ], 164 | "eventStatus": "completed", 165 | "message": "Service was unavailable." 166 | } 167 | ], 168 | "serviceName": "Contact Us" 169 | }, 170 | { 171 | "redirectUrl": "https://web.archive.org/web/20201104231054/https://developer.apple.com/reference", 172 | "events": [], 173 | "serviceName": "Developer Documentation" 174 | }, 175 | { 176 | "redirectUrl": null, 177 | "events": [ 178 | { 179 | "usersAffected": "All users were affected", 180 | "epochStartDate": 1604214060000, 181 | "epochEndDate": 1604221200000, 182 | "messageId": "2000000030", 183 | "statusType": "Outage", 184 | "datePosted": "11/02/2020 10:12 PST", 185 | "startDate": "11/01/2020 00:01 PDT", 186 | "endDate": "11/01/2020 01:00 PST", 187 | "affectedServices": null, 188 | "eventStatus": "resolved", 189 | "message": "Users experienced a problem with this service." 190 | }, 191 | { 192 | "usersAffected": "All users were affected", 193 | "epochStartDate": 1603479600000, 194 | "epochEndDate": 1603482240000, 195 | "messageId": "2000000027", 196 | "statusType": "Outage", 197 | "datePosted": "11/02/2020 10:12 PST", 198 | "startDate": "10/23/2020 12:00 PDT", 199 | "endDate": "10/23/2020 12:44 PDT", 200 | "affectedServices": null, 201 | "eventStatus": "resolved", 202 | "message": "Users experienced a problem with this service." 203 | }, 204 | { 205 | "usersAffected": "All users were affected", 206 | "epochStartDate": 1602690900000, 207 | "epochEndDate": 1602695160000, 208 | "messageId": "2000000022", 209 | "statusType": "Outage", 210 | "datePosted": "11/02/2020 10:12 PST", 211 | "startDate": "10/14/2020 08:55 PDT", 212 | "endDate": "10/14/2020 10:06 PDT", 213 | "affectedServices": null, 214 | "eventStatus": "resolved", 215 | "message": "Users experienced a problem with this service." 216 | }, 217 | { 218 | "usersAffected": "Some users were affected", 219 | "epochStartDate": 1604315460000, 220 | "epochEndDate": 1604320680000, 221 | "messageId": "2000000031", 222 | "statusType": "Issue", 223 | "datePosted": "11/02/2020 10:12 PST", 224 | "startDate": "11/02/2020 03:11 PST", 225 | "endDate": "11/02/2020 04:38 PST", 226 | "affectedServices": null, 227 | "eventStatus": "resolved", 228 | "message": "Users may have experienced issues with the service." 229 | } 230 | ], 231 | "serviceName": "Developer ID Notary Service" 232 | }, 233 | { 234 | "redirectUrl": "https://web.archive.org/web/20201104231054/https://developer.apple.com/documentation/devicecheck", 235 | "events": [], 236 | "serviceName": "Device Check" 237 | }, 238 | { 239 | "redirectUrl": "https://web.archive.org/web/20201104231054/https://support.apple.com/en-us/HT204460", 240 | "events": [], 241 | "serviceName": "Enterprise App Verification" 242 | }, 243 | { 244 | "redirectUrl": "https://web.archive.org/web/20201104231054/https://bugreport.apple.com/", 245 | "events": [], 246 | "serviceName": "Feedback Assistant" 247 | }, 248 | { 249 | "redirectUrl": null, 250 | "events": [], 251 | "serviceName": "In-App Purchases" 252 | }, 253 | { 254 | "redirectUrl": "https://web.archive.org/web/20201104231054/https://maps.developer.apple.com", 255 | "events": [], 256 | "serviceName": "MapKit JS Dashboard" 257 | }, 258 | { 259 | "redirectUrl": "https://web.archive.org/web/20201104231054/https://developer.apple.com/news-publisher/", 260 | "events": [], 261 | "serviceName": "News Publisher" 262 | }, 263 | { 264 | "redirectUrl": "https://web.archive.org/web/20201104231054/https://developer.apple.com/enroll/", 265 | "events": [ 266 | { 267 | "usersAffected": "Some users were affected", 268 | "epochStartDate": 1603234020000, 269 | "epochEndDate": 1603236540000, 270 | "messageId": "2000000025", 271 | "statusType": "Issue", 272 | "datePosted": "11/02/2020 10:12 PST", 273 | "startDate": "10/20/2020 15:47 PDT", 274 | "endDate": "10/20/2020 16:29 PDT", 275 | "affectedServices": null, 276 | "eventStatus": "resolved", 277 | "message": "Users experienced a problem with this service." 278 | }, 279 | { 280 | "usersAffected": "All users were affected", 281 | "epochStartDate": 1603209600000, 282 | "epochEndDate": 1603220400000, 283 | "messageId": "2000000024", 284 | "statusType": "Maintenance", 285 | "datePosted": "11/02/2020 10:12 PST", 286 | "startDate": "10/20/2020 09:00 PDT", 287 | "endDate": "10/20/2020 12:00 PDT", 288 | "affectedServices": [ 289 | "Contact Us", 290 | "Program Enrollment and Renewals", 291 | "Software Downloads", 292 | "Videos" 293 | ], 294 | "eventStatus": "completed", 295 | "message": "Due to maintenance, some services were unavailable." 296 | }, 297 | { 298 | "usersAffected": "All users were affected", 299 | "epochStartDate": 1602259200000, 300 | "epochEndDate": 1602266400000, 301 | "messageId": "2000000002", 302 | "statusType": "Maintenance", 303 | "datePosted": "11/02/2020 10:12 PST", 304 | "startDate": "10/09/2020 09:00 PDT", 305 | "endDate": "10/09/2020 11:00 PDT", 306 | "affectedServices": [ 307 | "Contact Us", 308 | "Program Enrollment and Renewals", 309 | "Software Downloads", 310 | "Videos" 311 | ], 312 | "eventStatus": "completed", 313 | "message": "Due to maintenance, some services were unavailable." 314 | } 315 | ], 316 | "serviceName": "Program Enrollment and Renewals" 317 | }, 318 | { 319 | "redirectUrl": "https://web.archive.org/web/20201104231054/https://developer.apple.com/download/", 320 | "events": [ 321 | { 322 | "usersAffected": "All users were affected", 323 | "epochStartDate": 1603209600000, 324 | "epochEndDate": 1603220400000, 325 | "messageId": "2000000024", 326 | "statusType": "Maintenance", 327 | "datePosted": "11/02/2020 10:12 PST", 328 | "startDate": "10/20/2020 09:00 PDT", 329 | "endDate": "10/20/2020 12:00 PDT", 330 | "affectedServices": [ 331 | "Contact Us", 332 | "Program Enrollment and Renewals", 333 | "Software Downloads", 334 | "Videos" 335 | ], 336 | "eventStatus": "completed", 337 | "message": "Due to maintenance, some services were unavailable." 338 | }, 339 | { 340 | "usersAffected": "All users were affected", 341 | "epochStartDate": 1602259200000, 342 | "epochEndDate": 1602266400000, 343 | "messageId": "2000000002", 344 | "statusType": "Maintenance", 345 | "datePosted": "11/02/2020 10:12 PST", 346 | "startDate": "10/09/2020 09:00 PDT", 347 | "endDate": "10/09/2020 11:00 PDT", 348 | "affectedServices": [ 349 | "Contact Us", 350 | "Program Enrollment and Renewals", 351 | "Software Downloads", 352 | "Videos" 353 | ], 354 | "eventStatus": "completed", 355 | "message": "Due to maintenance, some services were unavailable." 356 | } 357 | ], 358 | "serviceName": "Software Downloads" 359 | }, 360 | { 361 | "redirectUrl": "https://web.archive.org/web/20201104231054/https://developer.apple.com/testflight/", 362 | "events": [ 363 | { 364 | "usersAffected": "All users were affected", 365 | "epochStartDate": 1603482060000, 366 | "epochEndDate": 1603482300000, 367 | "messageId": "2000000028", 368 | "statusType": "Outage", 369 | "datePosted": "11/02/2020 10:12 PST", 370 | "startDate": "10/23/2020 12:41 PDT", 371 | "endDate": "10/23/2020 12:45 PDT", 372 | "affectedServices": null, 373 | "eventStatus": "resolved", 374 | "message": "Users experienced a problem with this service." 375 | } 376 | ], 377 | "serviceName": "TestFlight" 378 | }, 379 | { 380 | "redirectUrl": "https://web.archive.org/web/20201104231054/https://developer.apple.com/videos/", 381 | "events": [ 382 | { 383 | "usersAffected": "All users were affected", 384 | "epochStartDate": 1603209600000, 385 | "epochEndDate": null, 386 | "messageId": "2000000024", 387 | "statusType": "Maintenance", 388 | "datePosted": "11/02/2020 10:12 PST", 389 | "startDate": "10/20/2020 09:00 PDT", 390 | "endDate": null, 391 | "affectedServices": [ 392 | "Videos" 393 | ], 394 | "eventStatus": "ongoing", 395 | "message": "Due to maintenance, some services are unavailable." 396 | }, 397 | { 398 | "usersAffected": "All users were affected", 399 | "epochStartDate": 1602259200000, 400 | "epochEndDate": 1602266400000, 401 | "messageId": "2000000002", 402 | "statusType": "Maintenance", 403 | "datePosted": "11/02/2020 10:12 PST", 404 | "startDate": "10/09/2020 09:00 PDT", 405 | "endDate": "10/09/2020 11:00 PDT", 406 | "affectedServices": [ 407 | "Contact Us", 408 | "Program Enrollment and Renewals", 409 | "Software Downloads", 410 | "Videos" 411 | ], 412 | "eventStatus": "completed", 413 | "message": "Due to maintenance, some services were unavailable." 414 | } 415 | ], 416 | "serviceName": "Videos" 417 | }, 418 | { 419 | "redirectUrl": "https://web.archive.org/web/20201104231054/https://developer.apple.com/xcode/", 420 | "events": [], 421 | "serviceName": "Xcode Automatic Configuration" 422 | } 423 | ] 424 | } -------------------------------------------------------------------------------- /StatusCoreTests/Data/developer-one-resolved-issue.json: -------------------------------------------------------------------------------- 1 | { 2 | "services": [ 3 | { 4 | "events": [], 5 | "serviceName": "Account", 6 | "redirectUrl": "https://developer.apple.com/account/" 7 | }, 8 | { 9 | "events": [], 10 | "serviceName": "APNS", 11 | "redirectUrl": "https://developer.apple.com/notifications/" 12 | }, 13 | { 14 | "events": [], 15 | "serviceName": "APNS Sandbox", 16 | "redirectUrl": "https://developer.apple.com/notifications/" 17 | }, 18 | { 19 | "events": [], 20 | "serviceName": "App Attest", 21 | "redirectUrl": "https://developer.apple.com/documentation/devicecheck" 22 | }, 23 | { 24 | "events": [], 25 | "serviceName": "App Store Automatic App Updates", 26 | "redirectUrl": "https://developer.apple.com/app-store/app-updates/" 27 | }, 28 | { 29 | "events": [], 30 | "serviceName": "App Store Connect", 31 | "redirectUrl": "https://itunesconnect.apple.com/" 32 | }, 33 | { 34 | "events": [], 35 | "serviceName": "App Store Connect Analytics", 36 | "redirectUrl": "https://appstoreconnect.apple.com/analytics" 37 | }, 38 | { 39 | "events": [], 40 | "serviceName": "App Store Connect API", 41 | "redirectUrl": "https://developer.apple.com/app-store-connect/api/" 42 | }, 43 | { 44 | "serviceName": "App Store Connect App Processing", 45 | "events": [] 46 | }, 47 | { 48 | "events": [], 49 | "serviceName": "App Store Receipt Verification", 50 | "redirectUrl": "https://developer.apple.com/documentation/appstorereceipts/verifyreceipt" 51 | }, 52 | { 53 | "events": [], 54 | "serviceName": "App Store Sandbox", 55 | "redirectUrl": " https://developer.apple.com/documentation/storekit/in-app_purchase/testing_in-app_purchases_with_sandbox" 56 | }, 57 | { 58 | "events": [], 59 | "serviceName": "App Store Server Notifications", 60 | "redirectUrl": " https://developer.apple.com/documentation/appstoreservernotifications" 61 | }, 62 | { 63 | "events": [], 64 | "serviceName": "Apple Developer Forums", 65 | "redirectUrl": "https://forums.developer.apple.com" 66 | }, 67 | { 68 | "events": [], 69 | "serviceName": "Apple Maps API", 70 | "redirectUrl": "https://developer.apple.com/maps/" 71 | }, 72 | { 73 | "events": [], 74 | "serviceName": "Apple Music API", 75 | "redirectUrl": "https://developer.apple.com/musickit/" 76 | }, 77 | { 78 | "serviceName": "Apple News API", 79 | "events": [] 80 | }, 81 | { 82 | "events": [], 83 | "serviceName": "Apple Pay - Developer", 84 | "redirectUrl": "https://developer.apple.com/apple-pay/" 85 | }, 86 | { 87 | "serviceName": "Apple Podcasts Connect", 88 | "events": [] 89 | }, 90 | { 91 | "events": [], 92 | "serviceName": "Certificates, Identifiers & Profiles", 93 | "redirectUrl": "https://developer.apple.com/account/" 94 | }, 95 | { 96 | "events": [], 97 | "serviceName": "CloudKit Console", 98 | "redirectUrl": "https://icloud.developer.apple.com/dashboard" 99 | }, 100 | { 101 | "events": [], 102 | "serviceName": "CloudKit Database", 103 | "redirectUrl": "https://developer.apple.com/icloud/cloudkit/" 104 | }, 105 | { 106 | "events": [], 107 | "serviceName": "Code-level Support", 108 | "redirectUrl": "https://developer.apple.com/account/?view=support" 109 | }, 110 | { 111 | "events": [], 112 | "serviceName": "Contact Us", 113 | "redirectUrl": "https://developer.apple.com/contact/" 114 | }, 115 | { 116 | "events": [], 117 | "serviceName": "Developer Documentation", 118 | "redirectUrl": "https://developer.apple.com/reference" 119 | }, 120 | { 121 | "serviceName": "Developer ID Notary Service", 122 | "events": [ 123 | { 124 | "message": "Users may have experienced issues with the service.", 125 | "eventStatus": "resolved", 126 | "epochEndDate": 646078200, 127 | "epochStartDate": 646075500 128 | } 129 | ] 130 | }, 131 | { 132 | "events": [], 133 | "serviceName": "Device Check", 134 | "redirectUrl": "https://developer.apple.com/documentation/devicecheck" 135 | }, 136 | { 137 | "events": [], 138 | "serviceName": "Enterprise App Verification", 139 | "redirectUrl": "https://support.apple.com/en-us/HT204460" 140 | }, 141 | { 142 | "events": [], 143 | "serviceName": "Feedback Assistant", 144 | "redirectUrl": "https://bugreport.apple.com/" 145 | }, 146 | { 147 | "serviceName": "In-App Purchases", 148 | "events": [] 149 | }, 150 | { 151 | "events": [], 152 | "serviceName": "MapKit JS Dashboard", 153 | "redirectUrl": "https://maps.developer.apple.com" 154 | }, 155 | { 156 | "serviceName": "MFi Portal", 157 | "events": [] 158 | }, 159 | { 160 | "events": [], 161 | "serviceName": "News Publisher", 162 | "redirectUrl": "https://developer.apple.com/news-publisher/" 163 | }, 164 | { 165 | "events": [], 166 | "serviceName": "Program Enrollment and Renewals", 167 | "redirectUrl": "https://developer.apple.com/enroll/" 168 | }, 169 | { 170 | "events": [], 171 | "serviceName": "Software Downloads", 172 | "redirectUrl": "https://developer.apple.com/download/" 173 | }, 174 | { 175 | "events": [], 176 | "serviceName": "TestFlight", 177 | "redirectUrl": "https://developer.apple.com/testflight/" 178 | }, 179 | { 180 | "events": [], 181 | "serviceName": "Videos", 182 | "redirectUrl": "https://developer.apple.com/videos/" 183 | }, 184 | { 185 | "events": [], 186 | "serviceName": "Xcode Automatic Configuration", 187 | "redirectUrl": "https://developer.apple.com/xcode/" 188 | }, 189 | { 190 | "events": [], 191 | "serviceName": "Xcode Cloud", 192 | "redirectUrl": "https://developer.apple.com/xcode-cloud/" 193 | } 194 | ] 195 | } 196 | -------------------------------------------------------------------------------- /StatusCoreTests/EventFilteringTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import StatusCore 3 | 4 | final class EventFilteringTests: XCTestCase { 5 | 6 | func testFilteringRecentDeveloperEvents() throws { 7 | let response = try StatusResponse.developerOneResolvedIssue() 8 | 9 | XCTAssertEqual(response.services.filter({ $0.hasRecentEvents }).count, 1) 10 | XCTAssertEqual(response.services.filter({ $0.hasActiveEvents }).count, 0) 11 | } 12 | 13 | func testFilteringRecentCustomerEvents() throws { 14 | let response = try StatusResponse.customerThreeResolvedIssues() 15 | 16 | XCTAssertEqual(response.services.filter({ $0.hasRecentEvents }).count, 3) 17 | XCTAssertEqual(response.services.filter({ $0.hasActiveEvents }).count, 0) 18 | } 19 | 20 | func testFilteringMostRecentCustomerEvent() throws { 21 | let response = try StatusResponse.customerThreeResolvedIssues() 22 | let targetService = response.services.first(where: { $0.serviceName == "Apple Business Manager" })! 23 | 24 | XCTAssertEqual(targetService.latestEvent?.message, "Apple Business Manager was temporarily unavailable during system maintenance.") 25 | } 26 | 27 | func testFilteringActiveCustomerEvents() throws { 28 | let response = try StatusResponse.customerOneOngoingIssue() 29 | 30 | XCTAssertEqual(response.services.filter(\.hasActiveEvents).count, 1) 31 | XCTAssertEqual(response.services.filter(\.hasActiveEvents).first?.latestEvent?.message, "Users may be experiencing intermittent issues with this service.") 32 | } 33 | 34 | func testFilteringActiveDeveloperEvents() throws { 35 | let response = try StatusResponse.developerOneOngoingIssue() 36 | 37 | XCTAssertEqual(response.services.filter(\.hasActiveEvents).count, 1) 38 | XCTAssertEqual(response.services.filter(\.hasActiveEvents).first?.latestEvent?.message, "Due to maintenance, some services are unavailable.") 39 | } 40 | 41 | func testEventFilterEnumForActiveEvents() throws { 42 | let response = try StatusResponse.customerOneOngoingIssue() 43 | 44 | XCTAssertEqual(response.services.filter({ !$0.events(filteredBy: .ongoing).isEmpty }).count, 1) 45 | } 46 | 47 | func testEventFilterEnumForRecentEvents() throws { 48 | let response = try StatusResponse.customerThreeResolvedIssues() 49 | 50 | XCTAssertEqual(response.services.filter({ !$0.events(filteredBy: .recent).isEmpty }).count, 3) 51 | } 52 | 53 | func testFilteringScheduledDeveloperEvents() throws { 54 | let response = try StatusResponse.developerOneScheduledIssue() 55 | 56 | XCTAssertEqual(response.services.filter({ $0.hasScheduledEvents }).count, 1) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /StatusUI/Source/AppKit/EventMonitor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EventMonitor.swift 3 | // StatusBuddy 4 | // 5 | // Created by Guilherme Rambo on 11/02/20. 6 | // Copyright © 2020 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import AppKit 10 | 11 | // Brought to you by: https://www.raywenderlich.com/450-menus-and-popovers-in-menu-bar-apps-for-macos 12 | 13 | public class EventMonitor { 14 | private var monitor: Any? 15 | private let mask: NSEvent.EventTypeMask 16 | private let handler: (NSEvent?) -> Void 17 | 18 | public init(mask: NSEvent.EventTypeMask, handler: @escaping (NSEvent?) -> Void) { 19 | self.mask = mask 20 | self.handler = handler 21 | } 22 | 23 | deinit { 24 | stop() 25 | } 26 | 27 | public func start() { 28 | monitor = NSEvent.addGlobalMonitorForEvents(matching: mask, handler: handler) 29 | } 30 | 31 | public func stop() { 32 | if monitor != nil { 33 | NSEvent.removeMonitor(monitor!) 34 | monitor = nil 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /StatusUI/Source/AppKit/HostingWindowController/HostingWindowController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HostingWindowController.swift 3 | // StatusBuddy 4 | // 5 | // Created by Guilherme Rambo on 21/12/21. 6 | // Copyright © 2021 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | import SwiftUI 11 | 12 | public final class HostingWindowController: NSWindowController, NSWindowDelegate where Content: View { 13 | 14 | /// Invoked shortly before the hosting window controller's window is closed. 15 | public var willClose: ((HostingWindowController) -> Void)? 16 | 17 | public init(rootView: Content) { 18 | let window = NSWindow( 19 | contentRect: NSRect(x: 0, y: 0, width: NSView.noIntrinsicMetric, height: NSView.noIntrinsicMetric), 20 | styleMask: [.titled, .closable], 21 | backing: .buffered, 22 | defer: false, 23 | screen: nil 24 | ) 25 | 26 | window.title = "StatusBuddy Preferences" 27 | 28 | super.init(window: window) 29 | 30 | let controller = NSHostingController( 31 | rootView: rootView 32 | .environment(\.closeWindow, { [weak self] in self?.close() }) 33 | .environment(\.cocoaWindow, window) 34 | ) 35 | 36 | contentViewController = controller 37 | window.setContentSize(controller.view.fittingSize) 38 | 39 | window.isReleasedWhenClosed = false 40 | window.delegate = self 41 | } 42 | 43 | public required init?(coder: NSCoder) { 44 | fatalError() 45 | } 46 | 47 | public override func showWindow(_ sender: Any?) { 48 | super.showWindow(sender) 49 | 50 | window?.center() 51 | } 52 | 53 | public func windowWillClose(_ notification: Notification) { 54 | contentViewController = nil 55 | 56 | willClose?(self) 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /StatusUI/Source/AppKit/HostingWindowController/WindowEnvironment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WindowEnvironment.swift 3 | // StatusBuddy 4 | // 5 | // Created by Guilherme Rambo on 21/12/21. 6 | // Copyright © 2021 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | // MARK: - Public API 12 | 13 | public extension EnvironmentValues { 14 | 15 | /// Closes the window that's hosting this view. 16 | /// Only available when the view hierarchy is being presented with `HostingWindowController`. 17 | var closeWindow: () -> Void { 18 | get { self[CloseWindowEnvironmentKey.self] } 19 | set { self[CloseWindowEnvironmentKey.self] = newValue } 20 | } 21 | 22 | } 23 | 24 | public extension View { 25 | 26 | /// Sets the title for the window that contains this SwiftUI view. 27 | /// Only available when the view hierarchy is being presented with `HostingWindowController`. 28 | func windowTitle(_ title: String) -> some View { 29 | environment(\.windowTitle, title) 30 | } 31 | 32 | } 33 | 34 | // MARK: - Hosting Window Environment Keys 35 | 36 | private struct CloseWindowEnvironmentKey: EnvironmentKey { 37 | static let defaultValue: () -> Void = { } 38 | } 39 | 40 | private struct WindowTitleEnvironmentKey: EnvironmentKey { 41 | static let defaultValue: String = "" 42 | } 43 | 44 | private struct HostingWindowKey: EnvironmentKey { 45 | static let defaultValue: () -> NSWindow? = { nil } 46 | } 47 | 48 | extension EnvironmentValues { 49 | 50 | /// Set and used internally by `HostingWindowController`. 51 | var cocoaWindow: NSWindow? { 52 | get { self[HostingWindowKey.self]() } 53 | set { self[HostingWindowKey.self] = { [weak newValue] in newValue } } 54 | } 55 | 56 | var windowTitle: String { 57 | get { self[WindowTitleEnvironmentKey.self] } 58 | set { 59 | self[WindowTitleEnvironmentKey.self] = newValue 60 | cocoaWindow?.title = newValue 61 | } 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /StatusUI/Source/AppKit/StatusBarFlowController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StatusBarFlowController.swift 3 | // StatusUI 4 | // 5 | // Created by Guilherme Rambo on 29/06/21. 6 | // Copyright © 2021 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | import SwiftUI 11 | import StatusCore 12 | 13 | public final class StatusBarFlowController: NSViewController { 14 | 15 | public static var topMargin: CGFloat { RootView.topPaddingToAccomodateShadow } 16 | 17 | private lazy var rootView: NSView = { 18 | let v = RootView() 19 | .environmentObject(viewModel) 20 | .environmentObject(notificationManager) 21 | 22 | return NSHostingView(rootView: v) 23 | }() 24 | 25 | let viewModel: RootViewModel 26 | let notificationManager: NotificationManager 27 | 28 | public init(viewModel: RootViewModel, notificationManager: NotificationManager) { 29 | self.viewModel = viewModel 30 | self.notificationManager = notificationManager 31 | 32 | super.init(nibName: nil, bundle: nil) 33 | } 34 | 35 | public required init?(coder: NSCoder) { 36 | fatalError() 37 | } 38 | 39 | public override func loadView() { 40 | let containerView = NSView() 41 | 42 | view = containerView 43 | 44 | containerView.addSubview(rootView) 45 | rootView.translatesAutoresizingMaskIntoConstraints = false 46 | 47 | NSLayoutConstraint.activate([ 48 | rootView.leadingAnchor.constraint(equalTo: view.leadingAnchor), 49 | rootView.trailingAnchor.constraint(equalTo: view.trailingAnchor), 50 | rootView.topAnchor.constraint(equalTo: view.topAnchor), 51 | rootView.bottomAnchor.constraint(equalTo: view.bottomAnchor), 52 | ]) 53 | 54 | preferredContentSize = view.fittingSize 55 | } 56 | 57 | } 58 | 59 | public extension URL { 60 | static var developerFeedURL: URL { 61 | if let overrideStr = UserDefaults.standard.string(forKey: "SBDeveloperFeedURL"), 62 | let overrideURL = URL(string: overrideStr) { 63 | return overrideURL 64 | } else { 65 | return URL(string: "https://www.apple.com/support/systemstatus/data/developer/system_status_en_US.js?callback=jsonCallback")! 66 | } 67 | } 68 | 69 | static var consumerFeedURL: URL { 70 | if let overrideStr = UserDefaults.standard.string(forKey: "SBConsumerFeedURL"), 71 | let overrideURL = URL(string: overrideStr) { 72 | return overrideURL 73 | } else { 74 | return URL(string: "https://www.apple.com/support/systemstatus/data/system_status_en_US.js")! 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /StatusUI/Source/AppKit/StatusBarMenuWindowController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StatusBarMenuWindowController.swift 3 | // StatusUI 4 | // 5 | // Created by Gui Rambo on 04/12/20. 6 | // 7 | 8 | import AppKit 9 | import os.log 10 | 11 | public final class StatusBarMenuWindowController: NSWindowController { 12 | 13 | private let log = OSLog(subsystem: StatusUI.subsystemName, category: String(describing: StatusBarMenuWindowController.self)) 14 | 15 | public let statusItem: NSStatusItem? 16 | 17 | public var windowWillClose: () -> Void = { } 18 | 19 | let topMargin: CGFloat 20 | 21 | public init(statusItem: NSStatusItem?, contentViewController: NSViewController, topMargin: CGFloat = 0) { 22 | self.statusItem = statusItem 23 | self.topMargin = topMargin 24 | 25 | let window = StatusBarMenuWindow(statusItem: statusItem) 26 | window.contentViewController = contentViewController 27 | 28 | super.init(window: window) 29 | 30 | window.delegate = self 31 | setupContentSizeObservation() 32 | } 33 | 34 | public required init?(coder: NSCoder) { 35 | fatalError() 36 | } 37 | 38 | private var clickOutsideEventMonitor: EventMonitor? 39 | private var escapeKeyEventMonitor: Any? 40 | 41 | private func postBeginMenuTrackingNotification() { 42 | DistributedNotificationCenter.default().post(name: .init("com.apple.HIToolbox.beginMenuTrackingNotification"), object: nil) 43 | } 44 | 45 | private func postEndMenuTrackingNotification() { 46 | DistributedNotificationCenter.default().post(name: .init("com.apple.HIToolbox.endMenuTrackingNotification"), object: nil) 47 | } 48 | 49 | public override func showWindow(_ sender: Any?) { 50 | postBeginMenuTrackingNotification() 51 | 52 | NSApp.activate(ignoringOtherApps: true) 53 | 54 | repositionWindow() 55 | 56 | window?.alphaValue = 1 57 | 58 | super.showWindow(sender) 59 | 60 | startMonitoringInterestingEvents() 61 | } 62 | 63 | public var handleEscape: ((StatusBarMenuWindowController) -> Void)? 64 | 65 | private func startMonitoringInterestingEvents() { 66 | clickOutsideEventMonitor = EventMonitor(mask: [.leftMouseDown, .rightMouseDown], handler: { [weak self] event in 67 | guard let self = self else { return } 68 | 69 | #if DEBUG 70 | guard !UserDefaults.standard.bool(forKey: "EnableStickyMenuBarWindow") else { return } 71 | #endif 72 | 73 | self.close() 74 | }) 75 | clickOutsideEventMonitor?.start() 76 | 77 | escapeKeyEventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event -> NSEvent? in 78 | guard let self = self else { return event } 79 | 80 | if event.keyCode == 53 { 81 | if let handleEscape = self.handleEscape { 82 | handleEscape(self) 83 | } else { 84 | self.close() 85 | } 86 | return nil 87 | } else { 88 | return event 89 | } 90 | } 91 | } 92 | 93 | private func stopMonitoringEvents() { 94 | clickOutsideEventMonitor?.stop() 95 | clickOutsideEventMonitor = nil 96 | 97 | if let escapeMonitor = escapeKeyEventMonitor { 98 | NSEvent.removeMonitor(escapeMonitor) 99 | escapeKeyEventMonitor = nil 100 | } 101 | } 102 | 103 | public override func close() { 104 | postEndMenuTrackingNotification() 105 | 106 | NSAnimationContext.beginGrouping() 107 | NSAnimationContext.current.completionHandler = { 108 | super.close() 109 | 110 | self.stopMonitoringEvents() 111 | } 112 | window?.animator().alphaValue = 0 113 | NSAnimationContext.endGrouping() 114 | } 115 | 116 | // MARK: - Positioning relative to status item 117 | 118 | private struct Metrics { 119 | static let margin: CGFloat = 5 120 | } 121 | 122 | @objc private func repositionWindow() { 123 | guard let referenceWindow = statusItem?.button?.window, let window = window else { 124 | os_log("Couldn't find reference window for repositioning status bar menu window, centering instead", log: self.log, type: .debug) 125 | self.window?.center() 126 | return 127 | } 128 | 129 | let width = contentViewController?.preferredContentSize.width ?? window.frame.width 130 | let height = contentViewController?.preferredContentSize.height ?? window.frame.height 131 | var x = referenceWindow.frame.origin.x + referenceWindow.frame.width / 2 - window.frame.width / 2 132 | 133 | if let screen = referenceWindow.screen { 134 | // If the window extrapolates the limits of the screen, reposition it. 135 | if (x + width) > (screen.visibleFrame.origin.x + screen.visibleFrame.width) { 136 | x = (screen.visibleFrame.origin.x + screen.visibleFrame.width) - width - Metrics.margin 137 | } 138 | } 139 | 140 | let rect = NSRect( 141 | x: x, 142 | y: (referenceWindow.frame.origin.y - height - Metrics.margin) + topMargin, 143 | width: width, 144 | height: height 145 | ) 146 | 147 | window.setFrame(rect, display: false, animate: false) 148 | } 149 | 150 | // MARK: - Auto size/position based on content controller 151 | 152 | private var contentSizeObservation: NSKeyValueObservation? 153 | 154 | public override var contentViewController: NSViewController? { 155 | didSet { 156 | setupContentSizeObservation() 157 | } 158 | } 159 | 160 | private var previouslyObservedContentSize: NSSize? 161 | 162 | private func setupContentSizeObservation() { 163 | contentSizeObservation?.invalidate() 164 | contentSizeObservation = nil 165 | 166 | guard let controller = contentViewController else { return } 167 | 168 | contentSizeObservation = controller.observe(\.preferredContentSize, options: [.initial, .new]) { [weak self] controller, _ in 169 | self?.updateForNewContentSize(from: controller) 170 | } 171 | } 172 | 173 | private func updateForNewContentSize(from controller: NSViewController) { 174 | defer { previouslyObservedContentSize = controller.preferredContentSize } 175 | 176 | guard controller.preferredContentSize != previouslyObservedContentSize else { return } 177 | 178 | repositionWindow() 179 | } 180 | 181 | } 182 | 183 | // MARK: - Window delegate 184 | 185 | extension StatusBarMenuWindowController: NSWindowDelegate { 186 | 187 | public func windowWillClose(_ notification: Notification) { 188 | windowWillClose() 189 | } 190 | 191 | public func windowDidBecomeKey(_ notification: Notification) { 192 | statusItem?.button?.highlight(true) 193 | } 194 | 195 | public func windowDidResignKey(_ notification: Notification) { 196 | statusItem?.button?.highlight(false) 197 | } 198 | 199 | } 200 | 201 | private final class StatusBarMenuWindow: NSWindow { 202 | 203 | convenience init(statusItem: NSStatusItem?) { 204 | self.init( 205 | contentRect: NSRect(x: 0, y: 0, width: 0, height: 0), 206 | styleMask: [.fullSizeContentView, .borderless], 207 | backing: .buffered, 208 | defer: false, 209 | screen: statusItem?.button?.window?.screen 210 | ) 211 | 212 | isMovable = false 213 | titleVisibility = .hidden 214 | titlebarAppearsTransparent = true 215 | level = .statusBar 216 | isOpaque = false 217 | backgroundColor = .clear 218 | hasShadow = false 219 | } 220 | 221 | override var acceptsFirstResponder: Bool { true } 222 | override var canBecomeKey: Bool { true } 223 | override var canBecomeMain: Bool { true } 224 | 225 | } 226 | -------------------------------------------------------------------------------- /StatusUI/Source/Definitions/Bundle+StatusUI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Bundle+StatusUI.swift 3 | // StatusUI 4 | // 5 | // Created by Guilherme Rambo on 29/06/21. 6 | // Copyright © 2021 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | private final class _StubForStatusUIBundleInit {} 12 | 13 | extension Bundle { 14 | static let statusUI = Bundle(for: _StubForStatusUIBundleInit.self) 15 | } 16 | -------------------------------------------------------------------------------- /StatusUI/Source/Definitions/Colors.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Colors.swift 3 | // StatusBuddyNewUIPrototype 4 | // 5 | // Created by Guilherme Rambo on 28/06/21. 6 | // 7 | 8 | import SwiftUI 9 | import Cocoa 10 | 11 | extension Color { 12 | static let primaryText = Color(NSColor.labelColor) 13 | static let secondaryText = Color(NSColor.secondaryLabelColor) 14 | static let accent = Color(NSColor.controlAccentColor) 15 | static let success = Color("SuccessColor", bundle: .statusUI) 16 | static let warning = Color("WarningColor", bundle: .statusUI) 17 | static let warningText = Color("WarningTextColor", bundle: .statusUI) 18 | static let error = Color("ErrorColor", bundle: .statusUI) 19 | static let background = Color(NSColor.windowBackgroundColor) 20 | static let groupSeparator = Color("GroupSeparator", bundle: .statusUI) 21 | 22 | static let itemBackground = Color(NSColor(name: .init("itemBackground"), dynamicProvider: { appearance in 23 | if appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua { 24 | return NSColor(named: .init("ItemBackgroundDark"), bundle: .statusUI)! 25 | } else { 26 | return .windowBackgroundColor 27 | } 28 | })) 29 | } 30 | -------------------------------------------------------------------------------- /StatusUI/Source/Definitions/StatusUI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StatusUI.swift 3 | // StatusUI 4 | // 5 | // Created by Guilherme Rambo on 29/06/21. 6 | // Copyright © 2021 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | public struct StatusUI { 12 | static let subsystemName = "com.nsbrltda.StatusBuddy.StatusUI" 13 | 14 | public static var transitionDuration: TimeInterval { 15 | if NSApp.currentEvent?.modifierFlags.contains(.shift) == true { 16 | return 10 17 | } else { 18 | return 0.5 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /StatusUI/Source/Models/DashboardItem+StatusCore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DashboardItem+StatusCore.swift 3 | // StatusUI 4 | // 5 | // Created by Guilherme Rambo on 30/06/21. 6 | // Copyright © 2021 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import StatusCore 11 | 12 | extension DashboardItem { 13 | init(with response: StatusResponse, in scope: ServiceScope) { 14 | self.init( 15 | with: scope, 16 | subtitle: Self.subtitle(with: response), 17 | iconColor: Self.iconColor(for: response), 18 | subtitleColor: Self.subtitleColor(for: response) 19 | ) 20 | } 21 | } 22 | 23 | fileprivate extension DashboardItem { 24 | static func subtitle(with response: StatusResponse) -> String { 25 | let servicesWithActiveIssues = response.services.filter({ $0.hasActiveEvents }) 26 | 27 | if servicesWithActiveIssues.count == 0 { 28 | let servicesWithScheduledIssues = response.services.filter({ $0.hasScheduledEvents }) 29 | if servicesWithScheduledIssues.count == 0 { 30 | let servicesWithRecentIssues = response.services.filter({ $0.hasRecentEvents }) 31 | 32 | if servicesWithRecentIssues.count == 0 { 33 | return "All Systems Operational" 34 | } else if servicesWithRecentIssues.count == 1 { 35 | return String(format: "Recent Issue: %@", servicesWithRecentIssues[0].serviceName) 36 | } else { 37 | return String(format: "%d Recent Issues", servicesWithRecentIssues.count) 38 | } 39 | } else if servicesWithScheduledIssues.count == 1 { 40 | return String(format: "Scheduled: %@", servicesWithScheduledIssues[0].serviceName) 41 | } else { 42 | return String(format: "%d Services with Scheduled Maintenance", servicesWithScheduledIssues.count) 43 | } 44 | } else if servicesWithActiveIssues.count == 1 { 45 | return String(format: "Outage: %@", servicesWithActiveIssues[0].serviceName) 46 | } else { 47 | return String(format: "%d Ongoing Issues", servicesWithActiveIssues.count) 48 | } 49 | } 50 | 51 | static func subtitleColor(for response: StatusResponse) -> Color { 52 | if response.hasActiveEvents { 53 | return .error 54 | } else if response.hasScheduledEvents { 55 | return .scheduledIssue 56 | } else if response.hasRecentEvents { 57 | return .warningText 58 | } else { 59 | return .secondaryText 60 | } 61 | } 62 | 63 | static func iconColor(for response: StatusResponse) -> Color { 64 | if response.hasActiveEvents { 65 | return .error 66 | } else if response.hasScheduledEvents { 67 | return .scheduledIssue 68 | } else if response.hasRecentEvents { 69 | return .warning 70 | } else { 71 | return .accent 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /StatusUI/Source/Models/DashboardItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DashboardItem.swift 3 | // StatusBuddyNewUIPrototype 4 | // 5 | // Created by Guilherme Rambo on 28/06/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct DashboardItem: Hashable, Identifiable { 11 | public var id: String { scope.id } 12 | var title: String { scope.title } 13 | var iconName: String { scope.iconName } 14 | 15 | let scope: ServiceScope 16 | let subtitle: String 17 | let iconColor: Color 18 | let subtitleColor: Color 19 | 20 | public init(with scope: ServiceScope, 21 | subtitle: String? = nil, 22 | iconColor: Color? = nil, 23 | subtitleColor: Color? = nil) 24 | { 25 | self.scope = scope 26 | self.subtitle = subtitle ?? "All Systems Operational" 27 | self.iconColor = iconColor ?? .accent 28 | self.subtitleColor = subtitleColor ?? .secondaryText 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /StatusUI/Source/Models/DetailGroup+StatusCore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DetailGroup+StatusCore.swift 3 | // StatusUI 4 | // 5 | // Created by Guilherme Rambo on 01/07/21. 6 | // Copyright © 2021 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import StatusCore 11 | import SwiftUI 12 | 13 | extension DetailGroup { 14 | 15 | static func generateGroups(with response: StatusResponse, in scope: ServiceScope) -> [DetailGroup] { 16 | let servicesWithOngoingIssues = response.services.filter { $0.hasActiveEvents } 17 | let servicesWithScheduledIssues = response.services.filter { $0.hasScheduledEvents } 18 | let servicesWithRecentIssues = response.services.filter { $0.hasRecentEvents && !$0.hasActiveEvents } 19 | let servicesWithoutIssues = response.services.filter { !$0.hasRecentEvents && !$0.hasActiveEvents } 20 | 21 | let scheduledIssueItems = servicesWithScheduledIssues.compactMap { DetailGroupItem(for: .scheduled, in: $0) } 22 | let ongoingIssueItems = servicesWithOngoingIssues.compactMap { DetailGroupItem(for: .ongoing, in: $0) } 23 | let recentIssueItems = servicesWithRecentIssues.compactMap { DetailGroupItem(for: .recent, in: $0) } 24 | let operationalItems = servicesWithoutIssues.compactMap({ DetailGroupItem(for: .operational, in: $0) }) 25 | 26 | var groups: [DetailGroup] = [] 27 | 28 | if !scheduledIssueItems.isEmpty { 29 | let group = DetailGroup( 30 | id: "SCHEDULED", 31 | scope: scope, 32 | iconName: "calendar", 33 | title: "SCHEDULED MAINTENANCE", 34 | accentColor: .scheduledIssue, 35 | supportsNotifications: false, 36 | items: scheduledIssueItems 37 | ) 38 | groups.append(group) 39 | } 40 | 41 | if !ongoingIssueItems.isEmpty { 42 | let group = DetailGroup( 43 | id: "ONGOING", 44 | scope: scope, 45 | iconName: "x.circle.fill", 46 | title: "ACTIVE ISSUES", 47 | accentColor: .error, 48 | supportsNotifications: true, 49 | items: ongoingIssueItems 50 | ) 51 | groups.append(group) 52 | } 53 | 54 | if !recentIssueItems.isEmpty { 55 | let group = DetailGroup( 56 | id: "RECENT", 57 | scope: scope, 58 | iconName: "exclamationmark.triangle.fill", 59 | title: "RECENT ISSUES", 60 | accentColor: .warningText, 61 | supportsNotifications: false, 62 | items: recentIssueItems 63 | ) 64 | groups.append(group) 65 | } 66 | 67 | if !operationalItems.isEmpty { 68 | let group = DetailGroup( 69 | id: "OPERATIONAL", 70 | scope: scope, 71 | iconName: "checkmark.circle.fill", 72 | title: "OPERATIONAL", 73 | accentColor: .success, 74 | supportsNotifications: false, 75 | items: operationalItems 76 | ) 77 | groups.append(group) 78 | } 79 | 80 | return groups 81 | } 82 | 83 | } 84 | 85 | extension DetailGroupItem { 86 | 87 | init(for type: EventFilter, in service: Service) { 88 | self.init( 89 | id: service.serviceName, 90 | title: service.serviceName, 91 | subtitle: Self.subtitle(for: type, in: service), 92 | formattedResolutionTime: Self.formattedResolutionTime(for: type, in: service), 93 | formattedScheduledStartTime: Self.formattedScheduledStartTime(for: service), 94 | formattedScheduledEndTime: Self.formattedScheduledEndTime(for: service) 95 | ) 96 | } 97 | 98 | private static func relevantEvent(for type: EventFilter, in service: Service) -> Service.Event? { 99 | if type == .scheduled { 100 | return service.events(filteredBy: type).sorted(by: { $0.nonOptionalFutureEndDate > $1.nonOptionalFutureEndDate }).first 101 | } else { 102 | return service.events(filteredBy: type).sorted(by: { $0.nonOptionalStartDate > $1.nonOptionalStartDate }).first 103 | } 104 | } 105 | 106 | private static func subtitle(for type: EventFilter, in service: Service) -> String? { 107 | relevantEvent(for: type, in: service)?.message 108 | } 109 | 110 | private static func formattedScheduledStartTime(for service: Service) -> String? { 111 | guard let event = relevantEvent(for: .scheduled, in: service), let startDate = event.epochStartDate, startDate > Date() else { return nil } 112 | return Self.scheduledStartDateFormatter.string(from: startDate) 113 | } 114 | 115 | private static func formattedScheduledEndTime(for service: Service) -> String? { 116 | guard let event = relevantEvent(for: .scheduled, in: service), let endDate = event.epochEndDate, endDate > Date() else { return nil } 117 | 118 | if Calendar(identifier: .gregorian).isDate(event.nonOptionalStartDate, inSameDayAs: endDate) { 119 | return Self.scheduledEndDateFormatterTimeOnly.string(from: endDate) 120 | } else { 121 | return Self.scheduledEndDateFormatter.string(from: endDate) 122 | } 123 | } 124 | 125 | private static func formattedResolutionTime(for type: EventFilter, in service: Service) -> String? { 126 | guard let event = relevantEvent(for: type, in: service), let endDate = event.epochEndDate else { return nil } 127 | return Self.endDateFormatter.string(from: endDate) 128 | } 129 | 130 | static let endDateFormatter: DateFormatter = { 131 | let f = DateFormatter() 132 | 133 | f.doesRelativeDateFormatting = true 134 | f.timeStyle = .short 135 | f.dateStyle = .short 136 | f.formattingContext = .middleOfSentence 137 | 138 | return f 139 | }() 140 | 141 | static let scheduledStartDateFormatter: DateFormatter = { 142 | let f = DateFormatter() 143 | 144 | f.doesRelativeDateFormatting = true 145 | f.timeStyle = .short 146 | f.dateStyle = .short 147 | f.formattingContext = .beginningOfSentence 148 | 149 | return f 150 | }() 151 | 152 | static let scheduledEndDateFormatter: DateFormatter = { 153 | let f = DateFormatter() 154 | 155 | f.doesRelativeDateFormatting = true 156 | f.timeStyle = .short 157 | f.dateStyle = .short 158 | f.formattingContext = .beginningOfSentence 159 | 160 | return f 161 | }() 162 | 163 | static let scheduledEndDateFormatterTimeOnly: DateFormatter = { 164 | let f = DateFormatter() 165 | 166 | f.doesRelativeDateFormatting = false 167 | f.timeStyle = .short 168 | f.dateStyle = .none 169 | f.formattingContext = .middleOfSentence 170 | 171 | return f 172 | }() 173 | } 174 | 175 | 176 | private extension Service.Event { 177 | var nonOptionalStartDate: Date { epochStartDate ?? .distantPast } 178 | var nonOptionalFutureEndDate: Date { 179 | guard let epochEndDate, epochEndDate > Date() else { return .distantPast } 180 | return epochEndDate 181 | } 182 | } 183 | 184 | extension Color { 185 | static let scheduledIssue: Color = { 186 | if #available(macOS 12.0, *) { 187 | return .indigo 188 | } else { 189 | return .purple 190 | } 191 | }() 192 | } 193 | -------------------------------------------------------------------------------- /StatusUI/Source/Models/DetailGroup.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DetailGroup.swift 3 | // StatusBuddyNewUIPrototype 4 | // 5 | // Created by Guilherme Rambo on 28/06/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct DetailGroupItem: Hashable, Identifiable { 11 | let id: String 12 | let title: String 13 | let subtitle: String? 14 | let formattedResolutionTime: String? 15 | var formattedScheduledStartTime: String? = nil 16 | var formattedScheduledEndTime: String? = nil 17 | } 18 | 19 | struct DetailGroup: Hashable, Identifiable { 20 | let id: String 21 | let scope: ServiceScope 22 | let iconName: String 23 | let title: String 24 | let accentColor: Color 25 | let supportsNotifications: Bool 26 | let items: [DetailGroupItem] 27 | } 28 | -------------------------------------------------------------------------------- /StatusUI/Source/Models/ServiceScope.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ServiceScope.swift 3 | // StatusBuddyNewUIPrototype 4 | // 5 | // Created by Guilherme Rambo on 28/06/21. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct ServiceScope: Hashable, Identifiable { 11 | let order: Int 12 | public let id: String 13 | let iconName: String 14 | let title: String 15 | } 16 | 17 | public extension ServiceScope { 18 | static let developer = ServiceScope( 19 | order: 1, 20 | id: "DEVELOPER", 21 | iconName: "hammer.fill", 22 | title: "Developer Services" 23 | ) 24 | 25 | static let customer = ServiceScope( 26 | order: 0, 27 | id: "CUSTOMER", 28 | iconName: "person.fill", 29 | title: "Customer Services" 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /StatusUI/Source/Notifications/NotificationManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NotificationManager.swift 3 | // NotificationManager 4 | // 5 | // Created by Guilherme Rambo on 21/07/21. 6 | // Copyright © 2021 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import StatusCore 11 | import Combine 12 | import os.log 13 | 14 | public final class NotificationManager: ObservableObject { 15 | 16 | public struct Registration: Identifiable, Hashable { 17 | public var id: String { serviceName } 18 | let scope: ServiceScope 19 | let serviceName: String 20 | } 21 | 22 | private let log = OSLog(subsystem: StatusUI.subsystemName, category: String(describing: NotificationManager.self)) 23 | 24 | private lazy var cancellables = Set() 25 | 26 | @Published public var latestResponses: [ServiceScope: StatusResponse] = [:] 27 | 28 | @Published public private(set) var registrations: [Registration] = [] 29 | 30 | public let presenter: NotificationPresenter 31 | 32 | public init(with presenter: NotificationPresenter = DefaultNotificationPresenter()) { 33 | self.presenter = presenter 34 | 35 | $latestResponses.sink { [weak self] newResponses in 36 | guard let self = self else { return } 37 | self.processUpdatedResponses(newResponses, oldValue: self.latestResponses) 38 | }.store(in: &cancellables) 39 | } 40 | 41 | public func hasNotificationsEnabled(for serviceName: String, in scope: ServiceScope) -> Bool { 42 | registrations.contains(where: { $0.scope == scope && $0.serviceName == serviceName }) 43 | } 44 | 45 | public func toggleNotificationsEnabled(for serviceName: String, in scope: ServiceScope) { 46 | presenter.requestNotificationPermissionIfNeeded() 47 | 48 | if let registrationIndex = registrations.firstIndex(where: { $0.serviceName == serviceName && $0.scope == scope }) { 49 | registrations.remove(at: registrationIndex) 50 | 51 | os_log("Removed notification registration for %{public}@", log: self.log, type: .debug, serviceName) 52 | } else { 53 | let newRegistration = Registration(scope: scope, serviceName: serviceName) 54 | registrations.append(newRegistration) 55 | 56 | os_log("Created notification registration for %{public}@", log: self.log, type: .debug, serviceName) 57 | } 58 | } 59 | 60 | private func servicesPendingNotification(in responses: [ServiceScope: StatusResponse]) -> [Service] { 61 | responses.compactMap { scope, response -> [Service]? in 62 | guard registrations.contains(where: { $0.scope == scope }) else { return nil } 63 | return response.services.filter { service in 64 | registrations.contains(where: { $0.serviceName == service.serviceName }) 65 | } 66 | }.flatMap({ $0 }) 67 | } 68 | 69 | private func processUpdatedResponses(_ responses: [ServiceScope: StatusResponse], oldValue: [ServiceScope: StatusResponse]) { 70 | os_log("%{public}@", log: log, type: .debug, #function) 71 | 72 | let oldStates = servicesPendingNotification(in: oldValue) 73 | let newStates = servicesPendingNotification(in: responses) 74 | 75 | let notifications: [ServiceRestoredNotification] = newStates.compactMap { newService in 76 | guard let oldService = oldStates.first(where: { $0.serviceName == newService.serviceName }) else { return nil } 77 | 78 | guard oldService.hasActiveEvents, !newService.hasActiveEvents else { return nil } 79 | 80 | return ServiceRestoredNotification(id: newService.serviceName, serviceName: newService.serviceName) 81 | } 82 | 83 | guard !notifications.isEmpty else { return } 84 | 85 | os_log("Produced %{public}d service restored notification(s)", log: self.log, type: .debug, notifications.count) 86 | 87 | notifications.forEach { notification in 88 | presenter.present(notification) 89 | 90 | if let registrationIndex = registrations.firstIndex(where: { $0.serviceName == notification.serviceName }) { 91 | registrations.remove(at: registrationIndex) 92 | } 93 | } 94 | } 95 | 96 | } 97 | -------------------------------------------------------------------------------- /StatusUI/Source/Notifications/NotificationPresenter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NotificationPresenter.swift 3 | // NotificationPresenter 4 | // 5 | // Created by Guilherme Rambo on 21/07/21. 6 | // Copyright © 2021 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UserNotifications 11 | import os.log 12 | 13 | public struct ServiceRestoredNotification: Identifiable, Hashable { 14 | public let id: String 15 | public let serviceName: String 16 | } 17 | 18 | public protocol NotificationPresenter: AnyObject { 19 | var enableTimeSensitiveNotifications: Bool { get set } 20 | func present(_ notification: ServiceRestoredNotification) 21 | func requestNotificationPermissionIfNeeded() 22 | } 23 | 24 | public final class DefaultNotificationPresenter: NSObject, NotificationPresenter, UNUserNotificationCenterDelegate { 25 | 26 | private let log = OSLog(subsystem: StatusUI.subsystemName, category: String(describing: DefaultNotificationPresenter.self)) 27 | 28 | public var enableTimeSensitiveNotifications: Bool = false 29 | 30 | public override init() { 31 | super.init() 32 | 33 | UNUserNotificationCenter.current().delegate = self 34 | } 35 | 36 | public func present(_ notification: ServiceRestoredNotification) { 37 | let content = UNMutableNotificationContent() 38 | content.title = notification.serviceName 39 | content.body = "This service's issues are now resolved." 40 | 41 | if #available(macOS 12.0, *), enableTimeSensitiveNotifications { 42 | content.interruptionLevel = .timeSensitive 43 | } 44 | 45 | let request = UNNotificationRequest(identifier: notification.id, content: content, trigger: nil) 46 | 47 | UNUserNotificationCenter.current().add(request) { [weak self] error in 48 | guard let self = self else { return } 49 | 50 | if let error = error { 51 | os_log("Failed to request notification presentation: %{public}@", log: self.log, type: .error, String(describing: error)) 52 | } 53 | } 54 | } 55 | 56 | public func requestNotificationPermissionIfNeeded() { 57 | UNUserNotificationCenter.current().getNotificationSettings { [weak self] settings in 58 | guard let self = self else { return } 59 | 60 | if settings.authorizationStatus != .authorized { self.requestPermission() } 61 | } 62 | } 63 | 64 | private func requestPermission() { 65 | UNUserNotificationCenter.current().requestAuthorization(options: [.sound, .alert]) { [weak self] result, error in 66 | guard let self = self else { return } 67 | 68 | if let error = error { 69 | os_log("Error requesting notification authorization: %{public}@", log: self.log, type: .debug, String(describing: error)) 70 | } else { 71 | os_log("Notification authorization status = %{public}@", log: self.log, type: .debug, String(describing: result)) 72 | } 73 | } 74 | } 75 | 76 | public func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { 77 | completionHandler(.banner) 78 | } 79 | 80 | } 81 | -------------------------------------------------------------------------------- /StatusUI/Source/Resources/StatusUI.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "1.000", 9 | "green" : "0.502", 10 | "red" : "0.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "display-p3", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.792", 27 | "green" : "0.471", 28 | "red" : "0.145" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /StatusUI/Source/Resources/StatusUI.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /StatusUI/Source/Resources/StatusUI.xcassets/ErrorColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.059", 9 | "green" : "0.059", 10 | "red" : "0.702" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "display-p3", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.227", 27 | "green" : "0.361", 28 | "red" : "0.855" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /StatusUI/Source/Resources/StatusUI.xcassets/GroupSeparator.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.898", 9 | "green" : "0.898", 10 | "red" : "0.898" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "display-p3", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.204", 27 | "green" : "0.204", 28 | "red" : "0.208" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /StatusUI/Source/Resources/StatusUI.xcassets/ItemBackgroundDark.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.239", 9 | "green" : "0.239", 10 | "red" : "0.239" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /StatusUI/Source/Resources/StatusUI.xcassets/MenuBarContentSecondaryShadowColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "0.000", 8 | "blue" : "0.000", 9 | "green" : "0.000", 10 | "red" : "0.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "0.300", 26 | "blue" : "0.000", 27 | "green" : "0.000", 28 | "red" : "0.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /StatusUI/Source/Resources/StatusUI.xcassets/MenuBarContentShadowColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "0.140", 8 | "blue" : "0.000", 9 | "green" : "0.000", 10 | "red" : "0.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "0.220", 26 | "blue" : "0.000", 27 | "green" : "0.000", 28 | "red" : "0.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /StatusUI/Source/Resources/StatusUI.xcassets/SuccessColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.000", 9 | "green" : "0.608", 10 | "red" : "0.173" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "display-p3", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.000", 27 | "green" : "0.608", 28 | "red" : "0.173" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /StatusUI/Source/Resources/StatusUI.xcassets/WarningColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.000", 9 | "green" : "0.706", 10 | "red" : "0.898" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "display-p3", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.000", 27 | "green" : "0.706", 28 | "red" : "0.898" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /StatusUI/Source/Resources/StatusUI.xcassets/WarningTextColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.000", 9 | "green" : "0.608", 10 | "red" : "0.776" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "display-p3", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.000", 27 | "green" : "0.608", 28 | "red" : "0.776" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /StatusUI/Source/ViewModels/DashboardViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DashboardViewModel.swift 3 | // StatusBuddyNewUIPrototype 4 | // 5 | // Created by Guilherme Rambo on 28/06/21. 6 | // 7 | 8 | import SwiftUI 9 | import StatusCore 10 | 11 | public struct DashboardViewModel { 12 | public enum State { 13 | case loading 14 | case loaded([DashboardItem]) 15 | case failure(String) 16 | } 17 | 18 | public let state: State 19 | 20 | public init(with state: State = .loading) { 21 | self.state = state 22 | } 23 | } 24 | 25 | extension DashboardViewModel { 26 | init(with responses: [ServiceScope: StatusResponse]) { 27 | let items: [DashboardItem] = responses 28 | .sorted(by: { $0.key.order < $1.key.order }) 29 | .map { DashboardItem(with: $1, in: $0) } 30 | 31 | self.init(with: .loaded(items)) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /StatusUI/Source/ViewModels/DetailViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DetailViewModel.swift 3 | // StatusBuddyNewUIPrototype 4 | // 5 | // Created by Guilherme Rambo on 28/06/21. 6 | // 7 | 8 | import SwiftUI 9 | import StatusCore 10 | 11 | struct DetailViewModel { 12 | let scope: ServiceScope 13 | 14 | let groups: [DetailGroup] 15 | 16 | init(with groups: [DetailGroup], in scope: ServiceScope) { 17 | self.scope = scope 18 | self.groups = groups 19 | } 20 | } 21 | 22 | extension DetailViewModel { 23 | init(with response: StatusResponse, in scope: ServiceScope) { 24 | self.init(with: DetailGroup.generateGroups(with: response, in: scope), in: scope) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /StatusUI/Source/ViewModels/RootViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RootViewModel.swift 3 | // StatusUI 4 | // 5 | // Created by Guilherme Rambo on 29/06/21. 6 | // Copyright © 2021 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | import os.log 12 | import StatusCore 13 | 14 | public final class RootViewModel: ObservableObject { 15 | 16 | @Published public var selectedDashboardItem: DashboardItem? 17 | @Published public private(set) var latestResponses: [ServiceScope: StatusResponse] = [:] 18 | @Published private(set) var dashboard = DashboardViewModel() 19 | @Published private(set) var details: [ServiceScope: DetailViewModel] = [:] 20 | @Published public private(set) var hasActiveIssues = false 21 | 22 | public var showSettingsMenu: () -> Void = { } 23 | 24 | private let log = OSLog(subsystem: StatusUI.subsystemName, category: String(describing: RootViewModel.self)) 25 | 26 | let checkers: [ServiceScope: StatusChecker] 27 | let updateInterval: TimeInterval 28 | 29 | private lazy var cancellables = Set() 30 | 31 | private static var deafultRefreshInterval: TimeInterval { 32 | if let refreshStr = UserDefaults.standard.string(forKey: "SBRefreshInterval"), let refreshInt = Int(refreshStr) { 33 | return TimeInterval(refreshInt) 34 | } else { 35 | return 10 * 60 36 | } 37 | } 38 | 39 | public init(with checkers: [ServiceScope: StatusChecker] = [:], 40 | dashboard: DashboardViewModel = DashboardViewModel()) 41 | { 42 | self.checkers = checkers 43 | self.updateInterval = Self.deafultRefreshInterval 44 | 45 | $latestResponses.map({ $0.values.contains(where: { $0.hasActiveEvents }) }).assign(to: &$hasActiveIssues) 46 | } 47 | 48 | private var updateTimer: Timer? 49 | 50 | public func startPeriodicUpdates() { 51 | guard updateTimer == nil else { return } 52 | 53 | os_log("%{public}@", log: log, type: .debug, #function) 54 | 55 | updateTimer = Timer.scheduledTimer(withTimeInterval: updateInterval, repeats: true, block: { [weak self] _ in 56 | self?.refresh(nil) 57 | }) 58 | updateTimer?.tolerance = updateInterval / 3 59 | 60 | refresh(nil) 61 | } 62 | 63 | public func stopPeriodicUpdates() { 64 | os_log("%{public}@", log: log, type: .debug, #function) 65 | 66 | updateTimer?.invalidate() 67 | updateTimer = nil 68 | } 69 | 70 | private var inFlightRefresh: Cancellable? 71 | 72 | public func refresh(_ completion: (() -> Void)? = nil) { 73 | os_log("%{public}@", log: log, type: .debug, #function) 74 | 75 | inFlightRefresh?.cancel() 76 | inFlightRefresh = nil 77 | 78 | let publishers = checkers.map { scope, checker in 79 | checker.check().map { (scope, $0) } 80 | } 81 | 82 | inFlightRefresh = Publishers.MergeMany(publishers).collect().sink { [weak self] result in 83 | guard let self = self else { return } 84 | 85 | if case .failure(let error) = result { 86 | os_log("Status check failed with error: %{public}@", log: self.log, type: .error, String(describing: error)) 87 | 88 | self.dashboard = DashboardViewModel(with: .failure(String(describing: error))) 89 | } 90 | 91 | completion?() 92 | } receiveValue: { [weak self] results in 93 | guard let self = self else { return } 94 | 95 | results.forEach { scope, response in 96 | self.latestResponses[scope] = response 97 | self.details[scope] = DetailViewModel(with: response, in: scope) 98 | } 99 | 100 | self.dashboard = DashboardViewModel(with: self.latestResponses) 101 | } 102 | } 103 | 104 | } 105 | -------------------------------------------------------------------------------- /StatusUI/Source/Views/DashboardItemView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DashboardItemView.swift 3 | // StatusBuddyNewUIPrototype 4 | // 5 | // Created by Guilherme Rambo on 28/06/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct DashboardItemView: View { 11 | let item: DashboardItem 12 | 13 | init(_ item: DashboardItem) { 14 | self.item = item 15 | } 16 | 17 | var body: some View { 18 | HStack { 19 | Image(systemName: item.iconName) 20 | .font(.system(size: 16, weight: .medium, design: .rounded)) 21 | .frame(width: 40, height: 40, alignment: .center) 22 | .foregroundColor(.white) 23 | .background(Circle().foregroundColor(item.iconColor)) 24 | .accessibility(hidden: true) 25 | VStack(alignment: .leading, spacing: 3) { 26 | Text(item.title) 27 | .foregroundColor(.primaryText) 28 | .font(.headline) 29 | Text(item.subtitle) 30 | .foregroundColor(item.subtitleColor) 31 | .font(.subheadline) 32 | } 33 | .accessibilityElement(children: .combine) 34 | } 35 | .frame(maxWidth: .infinity, alignment: .leading) 36 | .statusItemBackground() 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /StatusUI/Source/Views/DashboardView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DashboardView.swift 3 | // StatusBuddyNewUIPrototype 4 | // 5 | // Created by Guilherme Rambo on 28/06/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct DashboardView: View { 11 | @ObservedObject var viewModel = RootViewModel() 12 | @Binding var selectedItem: DashboardItem? 13 | 14 | var body: some View { 15 | VStack(alignment: .leading, spacing: 12) { 16 | HStack(alignment: .bottom) { 17 | Text("StatusBuddy") 18 | .foregroundColor(.accent) 19 | 20 | Spacer() 21 | 22 | Button { 23 | viewModel.showSettingsMenu() 24 | } label: { 25 | Image(systemName: "gearshape.fill") 26 | } 27 | .buttonStyle(.borderless) 28 | } 29 | .font(.system(size: 15, weight: .semibold, design: .rounded)) 30 | 31 | Group { 32 | switch viewModel.dashboard.state { 33 | case .loaded(let items): 34 | itemList(with: items) 35 | case .loading: 36 | loadingView 37 | .frame(maxWidth: .infinity, minHeight: 90, maxHeight: .infinity) 38 | case .failure(let message): 39 | failureView(with: message) 40 | } 41 | } 42 | } 43 | .padding() 44 | } 45 | 46 | @ViewBuilder 47 | private func itemList(with items: [DashboardItem]) -> some View { 48 | VStack(alignment: .leading, spacing: 12) { 49 | ForEach(items) { item in 50 | DashboardItemView(item) 51 | .onTapGesture { selectedItem = item } 52 | } 53 | } 54 | } 55 | 56 | private var loadingView: some View { 57 | ProgressView() 58 | .progressViewStyle(.circular) 59 | .controlSize(.small) 60 | } 61 | 62 | @ViewBuilder 63 | private func failureView(with message: String) -> some View { 64 | Text("Sorry, I couldn't load the status right now.\n\(message)") 65 | .font(.system(.caption)) 66 | .multilineTextAlignment(.center) 67 | .lineLimit(nil) 68 | .foregroundColor(.secondary) 69 | .frame(maxHeight: .infinity) 70 | } 71 | } 72 | 73 | #if DEBUG 74 | struct DashboardView_Previews: PreviewProvider { 75 | static var previews: some View { 76 | Group { 77 | DashboardView(selectedItem: .constant(nil)) 78 | DashboardView(selectedItem: .constant(nil)) 79 | .preferredColorScheme(.dark) 80 | DashboardView(viewModel: RootViewModel(dashboard: DashboardViewModel(with: .loaded([ 81 | DashboardItem(with: .customer, subtitle: "Outage: Maps Routing & Navigation", iconColor: .error, subtitleColor: .error), 82 | DashboardItem(with: .developer, subtitle: "3 Recent Issues", iconColor: .warning, subtitleColor: .warningText) 83 | ]))), selectedItem: .constant(nil)) 84 | } 85 | } 86 | } 87 | #endif 88 | -------------------------------------------------------------------------------- /StatusUI/Source/Views/DetailGroupView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DetailGroupView.swift 3 | // StatusBuddyNewUIPrototype 4 | // 5 | // Created by Guilherme Rambo on 28/06/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct DetailGroupItemView: View { 11 | @EnvironmentObject var notificationManager: NotificationManager 12 | 13 | let item: DetailGroupItem 14 | let group: DetailGroup 15 | 16 | init(for item: DetailGroupItem, in group: DetailGroup) { 17 | self.item = item 18 | self.group = group 19 | } 20 | 21 | var body: some View { 22 | VStack(alignment: .leading, spacing: 4) { 23 | HStack { 24 | Text(item.title) 25 | .font(.system(size: 13, weight: .medium)) 26 | .foregroundColor(.primaryText) 27 | 28 | Spacer() 29 | 30 | if group.supportsNotifications { notificationView } 31 | } 32 | 33 | Group { 34 | if let resolutionTime = item.formattedResolutionTime { 35 | if let startTime = item.formattedScheduledStartTime, let endTime = item.formattedScheduledEndTime { 36 | Text("\(startTime) — \(endTime)") 37 | .font(.system(size: 12, weight: .medium)) 38 | } else { 39 | Text("Ended " + resolutionTime) 40 | .font(.system(size: 12, weight: .medium)) 41 | } 42 | } 43 | 44 | if let subtitle = item.subtitle { 45 | Text(subtitle) 46 | .font(.system(size: 11)) 47 | } 48 | } 49 | .foregroundColor(.secondaryText) 50 | } 51 | .frame(maxWidth: .infinity, alignment: .topLeading) 52 | } 53 | 54 | private var notificationView: some View { 55 | Button { 56 | notificationManager.toggleNotificationsEnabled(for: item.id, in: group.scope) 57 | } label: { 58 | Image(systemName: "rectangle.fill.badge.checkmark") 59 | .foregroundColor(notificationManager.hasNotificationsEnabled(for: item.id, in: group.scope) ? Color.accent : Color.primaryText) 60 | } 61 | .buttonStyle(PlainButtonStyle()) 62 | .accessibility(label: Text("Configure Notifications")) 63 | } 64 | } 65 | 66 | struct DetailGroupView: View { 67 | let group: DetailGroup 68 | 69 | init(_ group: DetailGroup) { 70 | self.group = group 71 | } 72 | 73 | var body: some View { 74 | VStack(alignment: .leading, spacing: 16) { 75 | HStack(spacing: 2) { 76 | Image(systemName: group.iconName) 77 | Text(group.title) 78 | Spacer() 79 | } 80 | .foregroundColor(group.accentColor) 81 | .font(.system(size: 11, weight: .medium)) 82 | 83 | VStack(alignment: .leading, spacing: 10) { 84 | ForEach(group.items) { item in 85 | VStack { 86 | DetailGroupItemView(for: item, in: group) 87 | if item.id != group.items.last?.id { 88 | if #available(macOS 12.0, *) { 89 | Rectangle() 90 | .frame(height: 0.5) 91 | .foregroundStyle(.tertiary) 92 | .opacity(0.3) 93 | .accessibility(hidden: true) 94 | } else { 95 | Divider() 96 | .foregroundColor(.groupSeparator) 97 | } 98 | } 99 | } 100 | } 101 | } 102 | } 103 | .statusItemBackground(padding: 10) 104 | } 105 | } 106 | 107 | #if DEBUG 108 | extension DetailGroup { 109 | static let recentIssuesPreview: DetailGroup = { 110 | DetailGroup( 111 | id: "RECENTS", 112 | scope: .developer, 113 | iconName: "exclamationmark.triangle.fill", 114 | title: "RECENT ISSUES", 115 | accentColor: .warning, 116 | supportsNotifications: false, 117 | items: [ 118 | DetailGroupItem( 119 | id: "Developer ID Notary Service", 120 | title: "Developer ID Notary Service", 121 | subtitle: "Developer ID Notary Service was temporarily unavailable during system maintenance.", 122 | formattedResolutionTime: "2 hours ago" 123 | ), 124 | DetailGroupItem( 125 | id: "App Store Connect", 126 | title: "App Store Connect", 127 | subtitle: "Users may have experienced issues with the service.", 128 | formattedResolutionTime: "5 hours ago" 129 | ) 130 | ] 131 | ) 132 | }() 133 | 134 | static let activeIssuesPreview: DetailGroup = { 135 | DetailGroup( 136 | id: "ACTIVE", 137 | scope: .developer, 138 | iconName: "x.circle.fill", 139 | title: "ACTIVE ISSUES", 140 | accentColor: .error, 141 | supportsNotifications: true, 142 | items: [ 143 | DetailGroupItem( 144 | id: "Developer ID Notary Service", 145 | title: "Developer ID Notary Service", 146 | subtitle: "Developer ID Notary Service is temporarily unavailable during system maintenance.", 147 | formattedResolutionTime: nil 148 | ), 149 | DetailGroupItem( 150 | id: "App Store Connect", 151 | title: "App Store Connect", 152 | subtitle: "Users may be experiencing issues with the service.", 153 | formattedResolutionTime: nil 154 | ) 155 | ] 156 | ) 157 | }() 158 | } 159 | 160 | struct DetailGroupView_Previews: PreviewProvider { 161 | static var previews: some View { 162 | Group { 163 | DetailGroupView(.recentIssuesPreview) 164 | .preferredColorScheme(.light) 165 | DetailGroupView(.recentIssuesPreview) 166 | .preferredColorScheme(.dark) 167 | 168 | DetailGroupView(.activeIssuesPreview) 169 | .preferredColorScheme(.light) 170 | DetailGroupView(.activeIssuesPreview) 171 | .preferredColorScheme(.dark) 172 | } 173 | } 174 | } 175 | #endif 176 | -------------------------------------------------------------------------------- /StatusUI/Source/Views/DetailView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DetailView.swift 3 | // StatusBuddyNewUIPrototype 4 | // 5 | // Created by Guilherme Rambo on 28/06/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct DetailView: View { 11 | @ObservedObject var viewModel: RootViewModel 12 | 13 | let scope: ServiceScope 14 | let groups: [DetailGroup] 15 | 16 | var body: some View { 17 | VStack(alignment: .leading, spacing: 0) { 18 | Text(scope.title) 19 | .font(.system(size: 15, weight: .semibold, design: .rounded)) 20 | .padding([.top, .leading]) 21 | .padding(.leading, 6) 22 | .foregroundColor(.primaryText) 23 | 24 | ScrollView { 25 | VStack(alignment: .leading, spacing: 12) { 26 | ForEach(groups) { group in 27 | DetailGroupView(group) 28 | } 29 | } 30 | .padding() 31 | } 32 | } 33 | } 34 | } 35 | 36 | #if DEBUG 37 | struct DetailView_Previews: PreviewProvider { 38 | static var previews: some View { 39 | DetailView(viewModel: RootViewModel(), scope: .developer, groups: [ 40 | .activeIssuesPreview, 41 | .recentIssuesPreview 42 | ]) 43 | } 44 | } 45 | #endif 46 | -------------------------------------------------------------------------------- /StatusUI/Source/Views/Modifiers/StatusItemBackgroundModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StatusItemBackgroundModifier.swift 3 | // StatusBuddyNewUIPrototype 4 | // 5 | // Created by Guilherme Rambo on 28/06/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | fileprivate struct ItemBackgroundModifier: ViewModifier { 11 | let maxWidth: CGFloat 12 | let padding: CGFloat? 13 | let cornerRadius: CGFloat 14 | 15 | init(maxWidth: CGFloat, padding: CGFloat?, cornerRadius: CGFloat = 10) { 16 | self.maxWidth = maxWidth 17 | self.padding = padding 18 | self.cornerRadius = cornerRadius 19 | } 20 | 21 | private var shape: some Shape { 22 | RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) 23 | } 24 | 25 | func body(content: Content) -> some View { 26 | Group { 27 | if #available(macOS 12.0, *) { 28 | content 29 | .padding(.all, padding) 30 | .frame(maxWidth: maxWidth, alignment: .leading) 31 | .background(Material.thin, in: shape) 32 | } else { 33 | content 34 | .padding(.all, padding) 35 | .frame(maxWidth: maxWidth, alignment: .leading) 36 | .background(Color.itemBackground) 37 | .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) 38 | } 39 | } 40 | .overlay(shape.stroke(Color.white.opacity(0.1), lineWidth: .onePixel)) 41 | .shadow(color: .menuBarItemShadow, radius: 7, x: -1.5, y: 1.5) 42 | .shadow(color: .menuBarItemSecondaryShadow, radius: 1, x: 0, y: 0) 43 | } 44 | } 45 | 46 | extension View { 47 | func statusItemBackground(maxWidth: CGFloat = 320, padding: CGFloat? = nil) -> some View { 48 | self.modifier( 49 | ItemBackgroundModifier( 50 | maxWidth: maxWidth, 51 | padding: padding 52 | ) 53 | ) 54 | } 55 | } 56 | 57 | extension Color { 58 | static let menuBarItemShadow = Color("MenuBarContentShadowColor", bundle: .statusUI) 59 | static let menuBarItemSecondaryShadow = Color("MenuBarContentSecondaryShadowColor", bundle: .statusUI) 60 | } 61 | -------------------------------------------------------------------------------- /StatusUI/Source/Views/Modifiers/WindowChrome.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WindowChrome.swift 3 | // StatusUI 4 | // 5 | // Created by Guilherme Rambo on 29/06/21. 6 | // Copyright © 2021 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct WindowChromeConfiguration { 12 | var cornerRadius: CGFloat 13 | var shadowRadius: CGFloat 14 | var smallShadowRadius: CGFloat 15 | var shadowOpacity: CGFloat 16 | var smallShadowOpacity: CGFloat 17 | var padding: CGFloat 18 | } 19 | 20 | private struct WindowChromeModifier: ViewModifier { 21 | 22 | private struct BackgroundShape: ViewModifier { 23 | let cornerRadius: CGFloat 24 | 25 | private var shape: some Shape { 26 | RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) 27 | } 28 | 29 | func body(content: Content) -> some View { 30 | if #available(macOS 12.0, *) { 31 | content 32 | .overlay(shape.stroke(Color.white.opacity(0.4), lineWidth: .onePixel)) 33 | .background(Material.ultraThin, in: shape) 34 | } else { 35 | content 36 | .background(Color.background) 37 | .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) 38 | } 39 | } 40 | } 41 | 42 | let configuration: WindowChromeConfiguration 43 | 44 | func body(content: Content) -> some View { 45 | content 46 | .modifier(BackgroundShape(cornerRadius: configuration.cornerRadius)) 47 | .clipShape(RoundedRectangle(cornerRadius: configuration.cornerRadius, style: .continuous)) 48 | .shadow(color: Color.black.opacity(configuration.shadowOpacity), radius: configuration.shadowRadius) 49 | .shadow(color: Color.black.opacity(configuration.smallShadowOpacity), radius: configuration.smallShadowRadius, x: 0, y: 0) 50 | .padding(configuration.padding) 51 | } 52 | 53 | } 54 | 55 | extension View { 56 | func windowChrome(_ configuration: WindowChromeConfiguration) -> some View { 57 | self.modifier(WindowChromeModifier(configuration: configuration)) 58 | } 59 | } 60 | 61 | extension CGFloat { 62 | 63 | static let onePixel: CGFloat = { 64 | #if os(iOS) 65 | return 1 / UIScreen.main.nativeScale 66 | #elseif os(watchOS) 67 | return 1 68 | #else 69 | let scale = NSScreen.main?.backingScaleFactor ?? 2 70 | return 1 / scale 71 | #endif 72 | }() 73 | 74 | } 75 | 76 | extension WindowChromeConfiguration { 77 | static let `default` = WindowChromeConfiguration(cornerRadius: 16.0, shadowRadius: 10.0, smallShadowRadius: 1.438049853372434, shadowOpacity: 0.22, smallShadowOpacity: 0.5210904255319149, padding: 26.0) 78 | } 79 | -------------------------------------------------------------------------------- /StatusUI/Source/Views/RootView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RootView.swift 3 | // StatusUI 4 | // 5 | // Created by Guilherme Rambo on 29/06/21. 6 | // Copyright © 2021 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct RootView: View { 12 | @EnvironmentObject var viewModel: RootViewModel 13 | 14 | static let shadowRadius: CGFloat = 10 15 | static let topPaddingToAccomodateShadow: CGFloat = 26 16 | static let minWidth: CGFloat = 346 17 | 18 | var body: some View { 19 | Group { 20 | if let selectedItem = viewModel.selectedDashboardItem { 21 | DetailView( 22 | viewModel: viewModel, 23 | scope: selectedItem.scope, 24 | groups: viewModel.details[selectedItem.scope]?.groups ?? [] 25 | ) 26 | .frame(minWidth: Self.minWidth, maxWidth: .infinity, minHeight: 323, maxHeight: .infinity, alignment: .topLeading) 27 | .windowChrome(.default) 28 | } else { 29 | DashboardView( 30 | viewModel: viewModel, 31 | selectedItem: $viewModel.selectedDashboardItem 32 | ) 33 | .frame(minWidth: Self.minWidth, maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) 34 | .windowChrome(.default) 35 | } 36 | } 37 | .onAppear { 38 | viewModel.startPeriodicUpdates() 39 | } 40 | } 41 | } 42 | 43 | struct RootView_Previews: PreviewProvider { 44 | static var previews: some View { 45 | RootView() 46 | .environmentObject(RootViewModel(with: [:])) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /StatusUI/StatusUI.h: -------------------------------------------------------------------------------- 1 | // 2 | // StatusUI.h 3 | // StatusUI 4 | // 5 | // Created by Guilherme Rambo on 29/06/21. 6 | // Copyright © 2021 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | //! Project version number for StatusUI. 12 | FOUNDATION_EXPORT double StatusUIVersionNumber; 13 | 14 | //! Project version string for StatusUI. 15 | FOUNDATION_EXPORT const unsigned char StatusUIVersionString[]; 16 | 17 | // In this header, you should import all the public headers of your framework using statements like #import 18 | 19 | 20 | -------------------------------------------------------------------------------- /StatusUITests/DashboardViewModelTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import StatusCore 3 | @testable import StatusUI 4 | 5 | extension DashboardViewModel { 6 | var items: [DashboardItem] { 7 | guard case .loaded(let items) = state else { fatalError("Can't get items while not in loaded state.") } 8 | 9 | return items 10 | } 11 | } 12 | 13 | final class DashboardViewModelTests: XCTestCase { 14 | 15 | func testGeneratingDashboardWithNoIssues() throws { 16 | let customerResponse = try StatusResponse.customerNoIssues() 17 | let developerResponse = try StatusResponse.developerNoIssues() 18 | 19 | let viewModel = DashboardViewModel(with: [ 20 | .customer: customerResponse, 21 | .developer: developerResponse 22 | ]) 23 | 24 | XCTAssertEqual(viewModel.items.count, 2) 25 | 26 | XCTAssertEqual(viewModel.items[0].title, "Customer Services") 27 | XCTAssertEqual(viewModel.items[0].subtitle, "All Systems Operational") 28 | XCTAssertEqual(viewModel.items[0].iconColor, .accent) 29 | XCTAssertEqual(viewModel.items[0].subtitleColor, .secondaryText) 30 | 31 | XCTAssertEqual(viewModel.items[1].title, "Developer Services") 32 | XCTAssertEqual(viewModel.items[1].subtitle, "All Systems Operational") 33 | XCTAssertEqual(viewModel.items[1].iconColor, .accent) 34 | XCTAssertEqual(viewModel.items[1].subtitleColor, .secondaryText) 35 | } 36 | 37 | func testGeneratingDashboardWithRecentIssues() throws { 38 | let customerResponse = try StatusResponse.customerThreeResolvedIssues() 39 | let developerResponse = try StatusResponse.developerOneResolvedIssue() 40 | 41 | let viewModel = DashboardViewModel(with: [ 42 | .customer: customerResponse, 43 | .developer: developerResponse 44 | ]) 45 | 46 | XCTAssertEqual(viewModel.items.count, 2) 47 | 48 | XCTAssertEqual(viewModel.items[0].title, "Customer Services") 49 | XCTAssertEqual(viewModel.items[0].subtitle, "3 Recent Issues") 50 | XCTAssertEqual(viewModel.items[0].iconColor, .warning) 51 | XCTAssertEqual(viewModel.items[0].subtitleColor, .warningText) 52 | 53 | XCTAssertEqual(viewModel.items[1].title, "Developer Services") 54 | XCTAssertEqual(viewModel.items[1].subtitle, "Recent Issue: Developer ID Notary Service") 55 | XCTAssertEqual(viewModel.items[1].iconColor, .warning) 56 | XCTAssertEqual(viewModel.items[1].subtitleColor, .warningText) 57 | } 58 | 59 | func testGeneratingDashboardWithOngoingIssues() throws { 60 | let customerResponse = try StatusResponse.customerThreeOngoingIssues() 61 | let developerResponse = try StatusResponse.developerOneOngoingIssue() 62 | 63 | let viewModel = DashboardViewModel(with: [ 64 | .customer: customerResponse, 65 | .developer: developerResponse 66 | ]) 67 | 68 | XCTAssertEqual(viewModel.items.count, 2) 69 | 70 | XCTAssertEqual(viewModel.items[0].title, "Customer Services") 71 | XCTAssertEqual(viewModel.items[0].subtitle, "3 Ongoing Issues") 72 | XCTAssertEqual(viewModel.items[0].iconColor, .error) 73 | XCTAssertEqual(viewModel.items[0].subtitleColor, .error) 74 | 75 | XCTAssertEqual(viewModel.items[1].title, "Developer Services") 76 | XCTAssertEqual(viewModel.items[1].subtitle, "Outage: Videos") 77 | XCTAssertEqual(viewModel.items[1].iconColor, .error) 78 | XCTAssertEqual(viewModel.items[1].subtitleColor, .error) 79 | } 80 | 81 | func testGeneratingDashboardWithScheduledIssues() throws { 82 | let customerResponse = try StatusResponse.customerThreeOngoingIssues() 83 | let developerResponse = try StatusResponse.developerOneScheduledIssue() 84 | 85 | let viewModel = DashboardViewModel(with: [ 86 | .customer: customerResponse, 87 | .developer: developerResponse 88 | ]) 89 | 90 | XCTAssertEqual(viewModel.items.count, 2) 91 | 92 | XCTAssertEqual(viewModel.items[0].title, "Customer Services") 93 | XCTAssertEqual(viewModel.items[0].subtitle, "3 Ongoing Issues") 94 | XCTAssertEqual(viewModel.items[0].iconColor, .error) 95 | XCTAssertEqual(viewModel.items[0].subtitleColor, .error) 96 | 97 | XCTAssertEqual(viewModel.items[1].title, "Developer Services") 98 | XCTAssertEqual(viewModel.items[1].subtitle, "Scheduled: App Store Connect - TestFlight") 99 | XCTAssertEqual(viewModel.items[1].iconColor, .scheduledIssue) 100 | XCTAssertEqual(viewModel.items[1].subtitleColor, .scheduledIssue) 101 | } 102 | 103 | } 104 | -------------------------------------------------------------------------------- /StatusUITests/DetailViewModelTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import StatusCore 3 | @testable import StatusUI 4 | 5 | final class DetailViewModelTests: XCTestCase { 6 | 7 | func testGeneratingCustomerDetailViewModelWithThreeOngoingIssues() throws { 8 | let response = try StatusResponse.customerThreeOngoingIssues() 9 | 10 | let viewModel = DetailViewModel(with: response, in: .customer) 11 | 12 | XCTAssertEqual(viewModel.groups.count, 2) 13 | XCTAssertEqual(viewModel.groups[0].id, "ONGOING") 14 | XCTAssertEqual(viewModel.groups[0].items.count, 3) 15 | XCTAssertEqual(viewModel.groups[1].id, "OPERATIONAL") 16 | XCTAssertEqual(viewModel.groups[1].items.count, 62) 17 | } 18 | 19 | func testGeneratingCustomerDetailViewModelWithThreeRecentIssues() throws { 20 | let response = try StatusResponse.customerThreeResolvedIssues() 21 | 22 | let viewModel = DetailViewModel(with: response, in: .customer) 23 | 24 | XCTAssertEqual(viewModel.groups.count, 2) 25 | XCTAssertEqual(viewModel.groups[0].id, "RECENT") 26 | XCTAssertEqual(viewModel.groups[0].items.count, 3) 27 | XCTAssertEqual(viewModel.groups[1].id, "OPERATIONAL") 28 | XCTAssertEqual(viewModel.groups[1].items.count, 62) 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /StatusUITests/NotificationManagerTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import XCTest 3 | @testable import StatusCore 4 | @testable import StatusUI 5 | 6 | final class NotificationManagerTests: XCTestCase { 7 | 8 | private func makeManager() -> (NotificationManager, MockNotificationPresenter) { 9 | let presenter = MockNotificationPresenter() 10 | let manager = NotificationManager(with: presenter) 11 | return (manager, presenter) 12 | } 13 | 14 | func testRegisteringNotificationRequestsForPermission() { 15 | let (manager, presenter) = makeManager() 16 | 17 | manager.toggleNotificationsEnabled(for: "TEST", in: .customer) 18 | 19 | XCTAssertTrue(presenter.permissionRequested) 20 | } 21 | 22 | func testTogglingNotificationOnAndOff() { 23 | let (manager, _) = makeManager() 24 | 25 | XCTAssertEqual(manager.registrations.count, 0) 26 | 27 | manager.toggleNotificationsEnabled(for: "TEST", in: .customer) 28 | 29 | XCTAssertEqual(manager.registrations.count, 1) 30 | 31 | manager.toggleNotificationsEnabled(for: "TEST", in: .customer) 32 | 33 | XCTAssertEqual(manager.registrations.count, 0) 34 | } 35 | 36 | func testOutageResolutionTriggersNotification() { 37 | let (manager, presenter) = makeManager() 38 | 39 | let testServiceWithIssue = Service(serviceName: "Test", redirectUrl: nil, events: [ 40 | Service.Event(epochStartDate: Date(), epochEndDate: nil, message: "Test outage", eventStatus: "Outage") 41 | ]) 42 | let testServiceWithoutIssue = Service(serviceName: "Test", redirectUrl: nil, events: [ 43 | Service.Event(epochStartDate: .distantPast, epochEndDate: Date(), message: "Test outage (resolved)", eventStatus: "resolved") 44 | ]) 45 | 46 | manager.toggleNotificationsEnabled(for: "Test", in: .customer) 47 | 48 | manager.latestResponses = [.customer: StatusResponse(services: [testServiceWithIssue])] 49 | 50 | XCTAssertEqual(presenter.presentedNotifications.count, 0) 51 | 52 | manager.latestResponses = [.customer: StatusResponse(services: [testServiceWithoutIssue])] 53 | 54 | XCTAssertEqual(presenter.presentedNotifications.count, 1) 55 | 56 | // Registration should be removed after delivering the notification. 57 | XCTAssertEqual(manager.registrations.count, 0) 58 | } 59 | 60 | } 61 | 62 | final class MockNotificationPresenter: NotificationPresenter { 63 | 64 | var enableTimeSensitiveNotifications: Bool = false 65 | 66 | private(set) var permissionRequested = false 67 | private(set) var presentedNotifications: [ServiceRestoredNotification] = [] 68 | 69 | func requestNotificationPermissionIfNeeded() { 70 | permissionRequested = true 71 | } 72 | 73 | func present(_ notification: ServiceRestoredNotification) { 74 | presentedNotifications.append(notification) 75 | } 76 | 77 | } 78 | -------------------------------------------------------------------------------- /TestFlight/WhatToTest.txt: -------------------------------------------------------------------------------- 1 | Just making sure the build doesn't expire :) 2 | -------------------------------------------------------------------------------- /images/StatusBuddy-Icon-2021.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/StatusBuddy/f7a3777b295f3b89fa967f8ee70e103b7cbd9443/images/StatusBuddy-Icon-2021.png -------------------------------------------------------------------------------- /images/StatusBuddy-Screenshot-2021.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/StatusBuddy/f7a3777b295f3b89fa967f8ee70e103b7cbd9443/images/StatusBuddy-Screenshot-2021.png --------------------------------------------------------------------------------