├── .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
--------------------------------------------------------------------------------