├── .github └── workflows │ └── ci.yml ├── .swift-format ├── .swift-version ├── .swiftformat ├── .swiftlint.yml ├── Assets ├── aware-macos-icon.sketch ├── aware-visionos-icon.sketch └── icon-pngs │ ├── icon_128x128.png │ ├── icon_128x128@2x.png │ ├── icon_16x16.png │ ├── icon_16x16@2x.png │ ├── icon_256x256.png │ ├── icon_256x256@2x.png │ ├── icon_32x32.png │ ├── icon_32x32@2x.png │ ├── icon_512x512.png │ └── icon_512x512@2x.png ├── Aware.xcodeproj ├── .gitignore ├── project.pbxproj └── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── Aware ├── App.swift ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── icon_128x128.png │ │ ├── icon_128x128@2x.png │ │ ├── icon_16x16.png │ │ ├── icon_16x16@2x.png │ │ ├── icon_256x256.png │ │ ├── icon_256x256@2x.png │ │ ├── icon_32x32.png │ │ ├── icon_32x32@2x.png │ │ ├── icon_512x512.png │ │ └── icon_512x512@2x.png │ ├── AppIcon.solidimagestack │ │ ├── Back.solidimagestacklayer │ │ │ ├── Content.imageset │ │ │ │ ├── Back.png │ │ │ │ └── Contents.json │ │ │ └── Contents.json │ │ ├── Contents.json │ │ ├── Front.solidimagestacklayer │ │ │ ├── Content.imageset │ │ │ │ ├── Contents.json │ │ │ │ └── Front.png │ │ │ └── Contents.json │ │ └── Middle.solidimagestacklayer │ │ │ ├── Content.imageset │ │ │ ├── Contents.json │ │ │ └── Middle.png │ │ │ └── Contents.json │ └── Contents.json ├── Aware.entitlements ├── Info.plist ├── PrivacyInfo.xcprivacy ├── Shared │ ├── AsyncStream+Extensions.swift │ ├── Duration+Extensions.swift │ ├── LogExport.swift │ ├── NotificationCenter+AsyncSequence.swift │ ├── NotificationCenter+Observer.swift │ ├── SuspendingClock+Drift.swift │ ├── TimerFormatStyle.swift │ ├── TimerState.swift │ ├── UTCClock.swift │ └── UserDefaults+AsyncSequence.swift ├── macOS │ ├── ActivityMonitor.swift │ ├── MenuBar.swift │ ├── MenuBarTimelineView.swift │ ├── NSApplication+Activate.swift │ ├── NSEvent+AsyncStream.swift │ ├── SettingsView.swift │ ├── View+NSStatusItem.swift │ └── View+NSWindow.swift └── visionOS │ ├── ActivityMonitor.swift │ ├── BackgroundTask.swift │ ├── NotificationName+Nonisolated.swift │ ├── Settings.bundle │ └── Root.plist │ ├── TimerTextView.swift │ ├── TimerView.swift │ └── TimerWindow.swift ├── AwareTests ├── AsyncStreamTests.swift ├── NotificationCenterTests.swift ├── TimerFormatStyleTests.swift ├── TimerStateTests.swift └── UTCClockTests.swift ├── LICENSE ├── README.md ├── ci_scripts └── ci_pre_xcodebuild.sh └── itunes-connect.md /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | env: 6 | CI_DERIVED_DATA_PATH: "${{ github.workspace }}/DerivedData" 7 | CI_RESULT_BUNDLE_PATH: "${{ github.workspace }}/resultbundle.xcresult" 8 | CI_XCODE_PROJECT: Aware.xcodeproj 9 | CI_XCODE_SCHEME: Aware 10 | 11 | jobs: 12 | build: 13 | runs-on: macos-14 14 | timeout-minutes: 10 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Set Xcode version 20 | run: sudo xcode-select --switch /Applications/Xcode_15.3.app 21 | 22 | - name: Print Xcode version 23 | run: xcodebuild -version -sdk 24 | 25 | - name: Resolve package dependencies 26 | run: | 27 | xcodebuild -resolvePackageDependencies \ 28 | -project "$CI_XCODE_PROJECT" \ 29 | -scheme "$CI_XCODE_SCHEME" \ 30 | -derivedDataPath "$CI_DERIVED_DATA_PATH" \ 31 | | xcpretty 32 | exit ${PIPESTATUS[0]} 33 | 34 | - name: Build 35 | run: | 36 | xcodebuild build-for-testing \ 37 | -scheme "$CI_XCODE_SCHEME" \ 38 | -project "$CI_XCODE_PROJECT" \ 39 | -derivedDataPath "$CI_DERIVED_DATA_PATH" \ 40 | -resultBundlePath "build-for-testing.xcresult" \ 41 | CODE_SIGN_IDENTITY=- \ 42 | AD_HOC_CODE_SIGNING_ALLOWED=YES \ 43 | | xcpretty 44 | exit ${PIPESTATUS[0]} 45 | 46 | - name: Test 47 | run: | 48 | xcodebuild test-without-building \ 49 | -scheme "$CI_XCODE_SCHEME" \ 50 | -project "$CI_XCODE_PROJECT" \ 51 | -derivedDataPath "$CI_DERIVED_DATA_PATH" \ 52 | -resultBundlePath "test-without-building.xcresult" \ 53 | -test-timeouts-enabled YES \ 54 | -maximum-test-execution-time-allowance 1800 \ 55 | | xcpretty 56 | exit ${PIPESTATUS[0]} 57 | 58 | - name: Upload xcresult 59 | uses: actions/upload-artifact@v4 60 | with: 61 | name: ${{ matrix.platform }}-test-xcresult 62 | path: "*.xcresult" 63 | if-no-files-found: error 64 | retention-days: 7 65 | -------------------------------------------------------------------------------- /.swift-format: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "lineLength": 120, 4 | "indentation": { 5 | "spaces": 4 6 | }, 7 | "indentConditionalCompilationBlocks": false, 8 | "rules": { 9 | "UseLetInEveryBoundCaseVariable": false 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.swift-version: -------------------------------------------------------------------------------- 1 | 5.0 2 | -------------------------------------------------------------------------------- /.swiftformat: -------------------------------------------------------------------------------- 1 | --ifdef no-indent 2 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | disabled_rules: 2 | - cyclomatic_complexity 3 | - function_body_length 4 | - identifier_name 5 | 6 | trailing_comma: 7 | mandatory_comma: true 8 | -------------------------------------------------------------------------------- /Assets/aware-macos-icon.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josh/Aware/e27b77b3efc8cb6558f66ccff39b0e642f8702cf/Assets/aware-macos-icon.sketch -------------------------------------------------------------------------------- /Assets/aware-visionos-icon.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josh/Aware/e27b77b3efc8cb6558f66ccff39b0e642f8702cf/Assets/aware-visionos-icon.sketch -------------------------------------------------------------------------------- /Assets/icon-pngs/icon_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josh/Aware/e27b77b3efc8cb6558f66ccff39b0e642f8702cf/Assets/icon-pngs/icon_128x128.png -------------------------------------------------------------------------------- /Assets/icon-pngs/icon_128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josh/Aware/e27b77b3efc8cb6558f66ccff39b0e642f8702cf/Assets/icon-pngs/icon_128x128@2x.png -------------------------------------------------------------------------------- /Assets/icon-pngs/icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josh/Aware/e27b77b3efc8cb6558f66ccff39b0e642f8702cf/Assets/icon-pngs/icon_16x16.png -------------------------------------------------------------------------------- /Assets/icon-pngs/icon_16x16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josh/Aware/e27b77b3efc8cb6558f66ccff39b0e642f8702cf/Assets/icon-pngs/icon_16x16@2x.png -------------------------------------------------------------------------------- /Assets/icon-pngs/icon_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josh/Aware/e27b77b3efc8cb6558f66ccff39b0e642f8702cf/Assets/icon-pngs/icon_256x256.png -------------------------------------------------------------------------------- /Assets/icon-pngs/icon_256x256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josh/Aware/e27b77b3efc8cb6558f66ccff39b0e642f8702cf/Assets/icon-pngs/icon_256x256@2x.png -------------------------------------------------------------------------------- /Assets/icon-pngs/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josh/Aware/e27b77b3efc8cb6558f66ccff39b0e642f8702cf/Assets/icon-pngs/icon_32x32.png -------------------------------------------------------------------------------- /Assets/icon-pngs/icon_32x32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josh/Aware/e27b77b3efc8cb6558f66ccff39b0e642f8702cf/Assets/icon-pngs/icon_32x32@2x.png -------------------------------------------------------------------------------- /Assets/icon-pngs/icon_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josh/Aware/e27b77b3efc8cb6558f66ccff39b0e642f8702cf/Assets/icon-pngs/icon_512x512.png -------------------------------------------------------------------------------- /Assets/icon-pngs/icon_512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josh/Aware/e27b77b3efc8cb6558f66ccff39b0e642f8702cf/Assets/icon-pngs/icon_512x512@2x.png -------------------------------------------------------------------------------- /Aware.xcodeproj/.gitignore: -------------------------------------------------------------------------------- 1 | xcuserdata/ 2 | -------------------------------------------------------------------------------- /Aware.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 55; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 030840142BD8498300C75EE3 /* UserDefaults+AsyncSequence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030840132BD8498300C75EE3 /* UserDefaults+AsyncSequence.swift */; }; 11 | 03162DE12BB12F3B0004BFDE /* AsyncStream+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03162DE02BB12F3B0004BFDE /* AsyncStream+Extensions.swift */; }; 12 | 03162DE32BB130880004BFDE /* AsyncStreamTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03162DE22BB130880004BFDE /* AsyncStreamTests.swift */; }; 13 | 032E60A82BD7243E008BB2B4 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = 032E60A72BD7243E008BB2B4 /* Settings.bundle */; platformFilters = (xros, ); }; 14 | 032E60AA2BD737A5008BB2B4 /* TimerFormatStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 032E60A92BD737A5008BB2B4 /* TimerFormatStyle.swift */; }; 15 | 032E60AC2BD737EC008BB2B4 /* TimerFormatStyleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 032E60AB2BD737EC008BB2B4 /* TimerFormatStyleTests.swift */; }; 16 | 032E60AE2BD74BEF008BB2B4 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 032E60AD2BD74BEF008BB2B4 /* SettingsView.swift */; platformFilters = (macos, ); }; 17 | 03409BC42B897F7C00EF8EE9 /* ActivityMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03409BC32B897F7C00EF8EE9 /* ActivityMonitor.swift */; platformFilters = (xros, ); }; 18 | 0341CB6A2B9C2CC800CC0C96 /* NSEvent+AsyncStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0341CB692B9C2CC800CC0C96 /* NSEvent+AsyncStream.swift */; platformFilters = (macos, ); }; 19 | 0341CB712B9C3FCE00CC0C96 /* UTCClock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0341CB702B9C3FCE00CC0C96 /* UTCClock.swift */; }; 20 | 0341CB732B9C456400CC0C96 /* UTCClockTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0341CB722B9C456400CC0C96 /* UTCClockTests.swift */; }; 21 | 0341CB752B9CDEAC00CC0C96 /* TimerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0341CB742B9CDEAC00CC0C96 /* TimerState.swift */; }; 22 | 0341CB772B9CDEC400CC0C96 /* TimerStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0341CB762B9CDEC400CC0C96 /* TimerStateTests.swift */; }; 23 | 0341CB792B9D4CEB00CC0C96 /* TimerWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0341CB782B9D4CEB00CC0C96 /* TimerWindow.swift */; platformFilters = (xros, ); }; 24 | 0347D5992BAF8B5400C5741E /* NotificationCenter+AsyncSequence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0347D5982BAF8B5400C5741E /* NotificationCenter+AsyncSequence.swift */; }; 25 | 0347D59B2BAF8CB200C5741E /* NotificationCenterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0347D59A2BAF8CB200C5741E /* NotificationCenterTests.swift */; }; 26 | 0347D59D2BAF9A3F00C5741E /* SuspendingClock+Drift.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0347D59C2BAF9A3F00C5741E /* SuspendingClock+Drift.swift */; }; 27 | 0347D59F2BAFB97300C5741E /* BackgroundTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0347D59E2BAFB97300C5741E /* BackgroundTask.swift */; platformFilters = (xros, ); }; 28 | 035820952BDB11880099E707 /* View+NSStatusItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 035820942BDB11880099E707 /* View+NSStatusItem.swift */; platformFilters = (macos, ); }; 29 | 035820972BDB122B0099E707 /* View+NSWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 035820962BDB122B0099E707 /* View+NSWindow.swift */; platformFilters = (macos, ); }; 30 | 035820992BDB24EC0099E707 /* NSApplication+Activate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 035820982BDB24EC0099E707 /* NSApplication+Activate.swift */; platformFilters = (macos, ); }; 31 | 036569CE2B9A40C8003D3DCA /* MenuBarTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 036569CD2B9A40C8003D3DCA /* MenuBarTimelineView.swift */; platformFilters = (macos, ); }; 32 | 036DA9B52B7AF52E0066B4B2 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 036DA9B42B7AF52E0066B4B2 /* App.swift */; }; 33 | 036EBD1B1C1408C200121D0B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 036EBD1A1C1408C200121D0B /* Assets.xcassets */; }; 34 | 037195362B804E4C00B807ED /* ActivityMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 037195352B804E4C00B807ED /* ActivityMonitor.swift */; platformFilters = (macos, ); }; 35 | 0381B4992B808A5A002213F6 /* MenuBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0381B4982B808A5A002213F6 /* MenuBar.swift */; platformFilters = (macos, ); }; 36 | 03830C992BA6079D00532C40 /* NotificationCenter+Observer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03830C982BA6079D00532C40 /* NotificationCenter+Observer.swift */; }; 37 | 038858802BA54DBA003E287D /* Duration+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0388587F2BA54DBA003E287D /* Duration+Extensions.swift */; }; 38 | 0394D1752B845FB400FE7020 /* TimerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0394D1742B845FB400FE7020 /* TimerView.swift */; platformFilters = (xros, ); }; 39 | 0394D1772B84630E00FE7020 /* TimerTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0394D1762B84630E00FE7020 /* TimerTextView.swift */; platformFilters = (xros, ); }; 40 | 03CC1F472BD8B241000BA17D /* LogExport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03CC1F462BD8B241000BA17D /* LogExport.swift */; }; 41 | 03E00DD42BB0A82100A6C522 /* NotificationName+Nonisolated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03E00DD32BB0A82100A6C522 /* NotificationName+Nonisolated.swift */; platformFilters = (xros, ); }; 42 | 03F1C34E2B92AE6E0084572C /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 03F1C34D2B92AE300084572C /* PrivacyInfo.xcprivacy */; }; 43 | /* End PBXBuildFile section */ 44 | 45 | /* Begin PBXContainerItemProxy section */ 46 | 03F9E22B1C24CAD3001DBE86 /* PBXContainerItemProxy */ = { 47 | isa = PBXContainerItemProxy; 48 | containerPortal = 036EBD0D1C1408C200121D0B /* Project object */; 49 | proxyType = 1; 50 | remoteGlobalIDString = 036EBD141C1408C200121D0B; 51 | remoteInfo = Aware; 52 | }; 53 | /* End PBXContainerItemProxy section */ 54 | 55 | /* Begin PBXFileReference section */ 56 | 030840132BD8498300C75EE3 /* UserDefaults+AsyncSequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserDefaults+AsyncSequence.swift"; sourceTree = ""; }; 57 | 03162DE02BB12F3B0004BFDE /* AsyncStream+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AsyncStream+Extensions.swift"; sourceTree = ""; }; 58 | 03162DE22BB130880004BFDE /* AsyncStreamTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncStreamTests.swift; sourceTree = ""; }; 59 | 032E60A72BD7243E008BB2B4 /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = ""; }; 60 | 032E60A92BD737A5008BB2B4 /* TimerFormatStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerFormatStyle.swift; sourceTree = ""; }; 61 | 032E60AB2BD737EC008BB2B4 /* TimerFormatStyleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerFormatStyleTests.swift; sourceTree = ""; }; 62 | 032E60AD2BD74BEF008BB2B4 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 63 | 03409BC32B897F7C00EF8EE9 /* ActivityMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityMonitor.swift; sourceTree = ""; }; 64 | 0341CB692B9C2CC800CC0C96 /* NSEvent+AsyncStream.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSEvent+AsyncStream.swift"; sourceTree = ""; }; 65 | 0341CB702B9C3FCE00CC0C96 /* UTCClock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTCClock.swift; sourceTree = ""; }; 66 | 0341CB722B9C456400CC0C96 /* UTCClockTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTCClockTests.swift; sourceTree = ""; }; 67 | 0341CB742B9CDEAC00CC0C96 /* TimerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerState.swift; sourceTree = ""; }; 68 | 0341CB762B9CDEC400CC0C96 /* TimerStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerStateTests.swift; sourceTree = ""; }; 69 | 0341CB782B9D4CEB00CC0C96 /* TimerWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerWindow.swift; sourceTree = ""; }; 70 | 0347D5982BAF8B5400C5741E /* NotificationCenter+AsyncSequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationCenter+AsyncSequence.swift"; sourceTree = ""; }; 71 | 0347D59A2BAF8CB200C5741E /* NotificationCenterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCenterTests.swift; sourceTree = ""; }; 72 | 0347D59C2BAF9A3F00C5741E /* SuspendingClock+Drift.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SuspendingClock+Drift.swift"; sourceTree = ""; }; 73 | 0347D59E2BAFB97300C5741E /* BackgroundTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTask.swift; sourceTree = ""; }; 74 | 035820942BDB11880099E707 /* View+NSStatusItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+NSStatusItem.swift"; sourceTree = ""; }; 75 | 035820962BDB122B0099E707 /* View+NSWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+NSWindow.swift"; sourceTree = ""; }; 76 | 035820982BDB24EC0099E707 /* NSApplication+Activate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSApplication+Activate.swift"; sourceTree = ""; }; 77 | 036569CD2B9A40C8003D3DCA /* MenuBarTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarTimelineView.swift; sourceTree = ""; }; 78 | 036DA9B42B7AF52E0066B4B2 /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = ""; }; 79 | 036EBD151C1408C200121D0B /* Aware.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Aware.app; sourceTree = BUILT_PRODUCTS_DIR; }; 80 | 036EBD1A1C1408C200121D0B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 81 | 037195352B804E4C00B807ED /* ActivityMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityMonitor.swift; sourceTree = ""; }; 82 | 0381B4982B808A5A002213F6 /* MenuBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBar.swift; sourceTree = ""; }; 83 | 0381B49A2B808FF4002213F6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 84 | 03830C982BA6079D00532C40 /* NotificationCenter+Observer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationCenter+Observer.swift"; sourceTree = ""; }; 85 | 0388587F2BA54DBA003E287D /* Duration+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Duration+Extensions.swift"; sourceTree = ""; }; 86 | 038D0B381C4DDD5600040C44 /* Aware.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = Aware.entitlements; sourceTree = ""; }; 87 | 0394D1742B845FB400FE7020 /* TimerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerView.swift; sourceTree = ""; }; 88 | 0394D1762B84630E00FE7020 /* TimerTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerTextView.swift; sourceTree = ""; }; 89 | 03CC1F462BD8B241000BA17D /* LogExport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogExport.swift; sourceTree = ""; }; 90 | 03E00DD32BB0A82100A6C522 /* NotificationName+Nonisolated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationName+Nonisolated.swift"; sourceTree = ""; }; 91 | 03F1C34D2B92AE300084572C /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 92 | 03F9E2261C24CAD3001DBE86 /* AwareTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AwareTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 93 | /* End PBXFileReference section */ 94 | 95 | /* Begin PBXFrameworksBuildPhase section */ 96 | 036EBD121C1408C200121D0B /* Frameworks */ = { 97 | isa = PBXFrameworksBuildPhase; 98 | buildActionMask = 2147483647; 99 | files = ( 100 | ); 101 | runOnlyForDeploymentPostprocessing = 0; 102 | }; 103 | 03F9E2231C24CAD3001DBE86 /* Frameworks */ = { 104 | isa = PBXFrameworksBuildPhase; 105 | buildActionMask = 2147483647; 106 | files = ( 107 | ); 108 | runOnlyForDeploymentPostprocessing = 0; 109 | }; 110 | /* End PBXFrameworksBuildPhase section */ 111 | 112 | /* Begin PBXGroup section */ 113 | 0341CB6F2B9C35EE00CC0C96 /* Shared */ = { 114 | isa = PBXGroup; 115 | children = ( 116 | 03162DE02BB12F3B0004BFDE /* AsyncStream+Extensions.swift */, 117 | 0388587F2BA54DBA003E287D /* Duration+Extensions.swift */, 118 | 03CC1F462BD8B241000BA17D /* LogExport.swift */, 119 | 0347D5982BAF8B5400C5741E /* NotificationCenter+AsyncSequence.swift */, 120 | 03830C982BA6079D00532C40 /* NotificationCenter+Observer.swift */, 121 | 0347D59C2BAF9A3F00C5741E /* SuspendingClock+Drift.swift */, 122 | 032E60A92BD737A5008BB2B4 /* TimerFormatStyle.swift */, 123 | 0341CB742B9CDEAC00CC0C96 /* TimerState.swift */, 124 | 030840132BD8498300C75EE3 /* UserDefaults+AsyncSequence.swift */, 125 | 0341CB702B9C3FCE00CC0C96 /* UTCClock.swift */, 126 | ); 127 | path = Shared; 128 | sourceTree = ""; 129 | }; 130 | 036EBD0C1C1408C200121D0B = { 131 | isa = PBXGroup; 132 | children = ( 133 | 036EBD171C1408C200121D0B /* Aware */, 134 | 03F9E2271C24CAD3001DBE86 /* AwareTests */, 135 | 036EBD161C1408C200121D0B /* Products */, 136 | ); 137 | sourceTree = ""; 138 | }; 139 | 036EBD161C1408C200121D0B /* Products */ = { 140 | isa = PBXGroup; 141 | children = ( 142 | 036EBD151C1408C200121D0B /* Aware.app */, 143 | 03F9E2261C24CAD3001DBE86 /* AwareTests.xctest */, 144 | ); 145 | name = Products; 146 | sourceTree = ""; 147 | }; 148 | 036EBD171C1408C200121D0B /* Aware */ = { 149 | isa = PBXGroup; 150 | children = ( 151 | 036DA9B42B7AF52E0066B4B2 /* App.swift */, 152 | 0341CB6F2B9C35EE00CC0C96 /* Shared */, 153 | 0381B49C2B8095FB002213F6 /* macOS */, 154 | 0381B49D2B809661002213F6 /* visionOS */, 155 | 036EBD1A1C1408C200121D0B /* Assets.xcassets */, 156 | 038D0B381C4DDD5600040C44 /* Aware.entitlements */, 157 | 03F1C34D2B92AE300084572C /* PrivacyInfo.xcprivacy */, 158 | 0381B49A2B808FF4002213F6 /* Info.plist */, 159 | ); 160 | path = Aware; 161 | sourceTree = ""; 162 | }; 163 | 0381B49C2B8095FB002213F6 /* macOS */ = { 164 | isa = PBXGroup; 165 | children = ( 166 | 037195352B804E4C00B807ED /* ActivityMonitor.swift */, 167 | 0381B4982B808A5A002213F6 /* MenuBar.swift */, 168 | 036569CD2B9A40C8003D3DCA /* MenuBarTimelineView.swift */, 169 | 035820982BDB24EC0099E707 /* NSApplication+Activate.swift */, 170 | 0341CB692B9C2CC800CC0C96 /* NSEvent+AsyncStream.swift */, 171 | 032E60AD2BD74BEF008BB2B4 /* SettingsView.swift */, 172 | 035820942BDB11880099E707 /* View+NSStatusItem.swift */, 173 | 035820962BDB122B0099E707 /* View+NSWindow.swift */, 174 | ); 175 | path = macOS; 176 | sourceTree = ""; 177 | }; 178 | 0381B49D2B809661002213F6 /* visionOS */ = { 179 | isa = PBXGroup; 180 | children = ( 181 | 03409BC32B897F7C00EF8EE9 /* ActivityMonitor.swift */, 182 | 0347D59E2BAFB97300C5741E /* BackgroundTask.swift */, 183 | 03E00DD32BB0A82100A6C522 /* NotificationName+Nonisolated.swift */, 184 | 0394D1762B84630E00FE7020 /* TimerTextView.swift */, 185 | 0394D1742B845FB400FE7020 /* TimerView.swift */, 186 | 0341CB782B9D4CEB00CC0C96 /* TimerWindow.swift */, 187 | 032E60A72BD7243E008BB2B4 /* Settings.bundle */, 188 | ); 189 | path = visionOS; 190 | sourceTree = ""; 191 | }; 192 | 03F9E2271C24CAD3001DBE86 /* AwareTests */ = { 193 | isa = PBXGroup; 194 | children = ( 195 | 03162DE22BB130880004BFDE /* AsyncStreamTests.swift */, 196 | 0347D59A2BAF8CB200C5741E /* NotificationCenterTests.swift */, 197 | 032E60AB2BD737EC008BB2B4 /* TimerFormatStyleTests.swift */, 198 | 0341CB762B9CDEC400CC0C96 /* TimerStateTests.swift */, 199 | 0341CB722B9C456400CC0C96 /* UTCClockTests.swift */, 200 | ); 201 | path = AwareTests; 202 | sourceTree = ""; 203 | }; 204 | /* End PBXGroup section */ 205 | 206 | /* Begin PBXNativeTarget section */ 207 | 036EBD141C1408C200121D0B /* Aware */ = { 208 | isa = PBXNativeTarget; 209 | buildConfigurationList = 036EBD221C1408C200121D0B /* Build configuration list for PBXNativeTarget "Aware" */; 210 | buildPhases = ( 211 | 036EBD111C1408C200121D0B /* Sources */, 212 | 036EBD121C1408C200121D0B /* Frameworks */, 213 | 036EBD131C1408C200121D0B /* Resources */, 214 | ); 215 | buildRules = ( 216 | ); 217 | dependencies = ( 218 | ); 219 | name = Aware; 220 | productName = Aware; 221 | productReference = 036EBD151C1408C200121D0B /* Aware.app */; 222 | productType = "com.apple.product-type.application"; 223 | }; 224 | 03F9E2251C24CAD3001DBE86 /* AwareTests */ = { 225 | isa = PBXNativeTarget; 226 | buildConfigurationList = 03F9E22F1C24CAD3001DBE86 /* Build configuration list for PBXNativeTarget "AwareTests" */; 227 | buildPhases = ( 228 | 03F9E2221C24CAD3001DBE86 /* Sources */, 229 | 03F9E2231C24CAD3001DBE86 /* Frameworks */, 230 | 03F9E2241C24CAD3001DBE86 /* Resources */, 231 | ); 232 | buildRules = ( 233 | ); 234 | dependencies = ( 235 | 03F9E22C1C24CAD3001DBE86 /* PBXTargetDependency */, 236 | ); 237 | name = AwareTests; 238 | productName = AwareTests; 239 | productReference = 03F9E2261C24CAD3001DBE86 /* AwareTests.xctest */; 240 | productType = "com.apple.product-type.bundle.unit-test"; 241 | }; 242 | /* End PBXNativeTarget section */ 243 | 244 | /* Begin PBXProject section */ 245 | 036EBD0D1C1408C200121D0B /* Project object */ = { 246 | isa = PBXProject; 247 | attributes = { 248 | BuildIndependentTargetsInParallel = YES; 249 | LastSwiftUpdateCheck = 0720; 250 | LastUpgradeCheck = 1520; 251 | TargetAttributes = { 252 | 036EBD141C1408C200121D0B = { 253 | CreatedOnToolsVersion = 7.1.1; 254 | DevelopmentTeam = 5SW9VUVYKC; 255 | LastSwiftMigration = 1020; 256 | ProvisioningStyle = Automatic; 257 | SystemCapabilities = { 258 | com.apple.Sandbox = { 259 | enabled = 1; 260 | }; 261 | }; 262 | }; 263 | 03F9E2251C24CAD3001DBE86 = { 264 | CreatedOnToolsVersion = 7.2; 265 | DevelopmentTeam = 5SW9VUVYKC; 266 | LastSwiftMigration = 1020; 267 | ProvisioningStyle = Automatic; 268 | TestTargetID = 036EBD141C1408C200121D0B; 269 | }; 270 | }; 271 | }; 272 | buildConfigurationList = 036EBD101C1408C200121D0B /* Build configuration list for PBXProject "Aware" */; 273 | compatibilityVersion = "Xcode 3.2"; 274 | developmentRegion = en; 275 | hasScannedForEncodings = 0; 276 | knownRegions = ( 277 | en, 278 | Base, 279 | ); 280 | mainGroup = 036EBD0C1C1408C200121D0B; 281 | productRefGroup = 036EBD161C1408C200121D0B /* Products */; 282 | projectDirPath = ""; 283 | projectRoot = ""; 284 | targets = ( 285 | 036EBD141C1408C200121D0B /* Aware */, 286 | 03F9E2251C24CAD3001DBE86 /* AwareTests */, 287 | ); 288 | }; 289 | /* End PBXProject section */ 290 | 291 | /* Begin PBXResourcesBuildPhase section */ 292 | 036EBD131C1408C200121D0B /* Resources */ = { 293 | isa = PBXResourcesBuildPhase; 294 | buildActionMask = 2147483647; 295 | files = ( 296 | 036EBD1B1C1408C200121D0B /* Assets.xcassets in Resources */, 297 | 03F1C34E2B92AE6E0084572C /* PrivacyInfo.xcprivacy in Resources */, 298 | 032E60A82BD7243E008BB2B4 /* Settings.bundle in Resources */, 299 | ); 300 | runOnlyForDeploymentPostprocessing = 0; 301 | }; 302 | 03F9E2241C24CAD3001DBE86 /* Resources */ = { 303 | isa = PBXResourcesBuildPhase; 304 | buildActionMask = 2147483647; 305 | files = ( 306 | ); 307 | runOnlyForDeploymentPostprocessing = 0; 308 | }; 309 | /* End PBXResourcesBuildPhase section */ 310 | 311 | /* Begin PBXSourcesBuildPhase section */ 312 | 036EBD111C1408C200121D0B /* Sources */ = { 313 | isa = PBXSourcesBuildPhase; 314 | buildActionMask = 2147483647; 315 | files = ( 316 | 036DA9B52B7AF52E0066B4B2 /* App.swift in Sources */, 317 | 03162DE12BB12F3B0004BFDE /* AsyncStream+Extensions.swift in Sources */, 318 | 038858802BA54DBA003E287D /* Duration+Extensions.swift in Sources */, 319 | 03CC1F472BD8B241000BA17D /* LogExport.swift in Sources */, 320 | 0347D5992BAF8B5400C5741E /* NotificationCenter+AsyncSequence.swift in Sources */, 321 | 03830C992BA6079D00532C40 /* NotificationCenter+Observer.swift in Sources */, 322 | 0347D59D2BAF9A3F00C5741E /* SuspendingClock+Drift.swift in Sources */, 323 | 032E60AA2BD737A5008BB2B4 /* TimerFormatStyle.swift in Sources */, 324 | 0341CB752B9CDEAC00CC0C96 /* TimerState.swift in Sources */, 325 | 030840142BD8498300C75EE3 /* UserDefaults+AsyncSequence.swift in Sources */, 326 | 0341CB712B9C3FCE00CC0C96 /* UTCClock.swift in Sources */, 327 | 037195362B804E4C00B807ED /* ActivityMonitor.swift in Sources */, 328 | 0381B4992B808A5A002213F6 /* MenuBar.swift in Sources */, 329 | 036569CE2B9A40C8003D3DCA /* MenuBarTimelineView.swift in Sources */, 330 | 035820992BDB24EC0099E707 /* NSApplication+Activate.swift in Sources */, 331 | 0341CB6A2B9C2CC800CC0C96 /* NSEvent+AsyncStream.swift in Sources */, 332 | 032E60AE2BD74BEF008BB2B4 /* SettingsView.swift in Sources */, 333 | 035820952BDB11880099E707 /* View+NSStatusItem.swift in Sources */, 334 | 035820972BDB122B0099E707 /* View+NSWindow.swift in Sources */, 335 | 03409BC42B897F7C00EF8EE9 /* ActivityMonitor.swift in Sources */, 336 | 0347D59F2BAFB97300C5741E /* BackgroundTask.swift in Sources */, 337 | 03E00DD42BB0A82100A6C522 /* NotificationName+Nonisolated.swift in Sources */, 338 | 0394D1772B84630E00FE7020 /* TimerTextView.swift in Sources */, 339 | 0394D1752B845FB400FE7020 /* TimerView.swift in Sources */, 340 | 0341CB792B9D4CEB00CC0C96 /* TimerWindow.swift in Sources */, 341 | ); 342 | runOnlyForDeploymentPostprocessing = 0; 343 | }; 344 | 03F9E2221C24CAD3001DBE86 /* Sources */ = { 345 | isa = PBXSourcesBuildPhase; 346 | buildActionMask = 2147483647; 347 | files = ( 348 | 03162DE32BB130880004BFDE /* AsyncStreamTests.swift in Sources */, 349 | 0347D59B2BAF8CB200C5741E /* NotificationCenterTests.swift in Sources */, 350 | 032E60AC2BD737EC008BB2B4 /* TimerFormatStyleTests.swift in Sources */, 351 | 0341CB772B9CDEC400CC0C96 /* TimerStateTests.swift in Sources */, 352 | 0341CB732B9C456400CC0C96 /* UTCClockTests.swift in Sources */, 353 | ); 354 | runOnlyForDeploymentPostprocessing = 0; 355 | }; 356 | /* End PBXSourcesBuildPhase section */ 357 | 358 | /* Begin PBXTargetDependency section */ 359 | 03F9E22C1C24CAD3001DBE86 /* PBXTargetDependency */ = { 360 | isa = PBXTargetDependency; 361 | target = 036EBD141C1408C200121D0B /* Aware */; 362 | targetProxy = 03F9E22B1C24CAD3001DBE86 /* PBXContainerItemProxy */; 363 | }; 364 | /* End PBXTargetDependency section */ 365 | 366 | /* Begin XCBuildConfiguration section */ 367 | 036EBD201C1408C200121D0B /* Debug */ = { 368 | isa = XCBuildConfiguration; 369 | buildSettings = { 370 | ALWAYS_SEARCH_USER_PATHS = NO; 371 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 372 | CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; 373 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; 374 | CLANG_CXX_LIBRARY = "libc++"; 375 | CLANG_ENABLE_MODULES = YES; 376 | CLANG_ENABLE_OBJC_ARC = YES; 377 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 378 | CLANG_WARN_BOOL_CONVERSION = YES; 379 | CLANG_WARN_COMMA = YES; 380 | CLANG_WARN_CONSTANT_CONVERSION = YES; 381 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 382 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 383 | CLANG_WARN_EMPTY_BODY = YES; 384 | CLANG_WARN_ENUM_CONVERSION = YES; 385 | CLANG_WARN_INFINITE_RECURSION = YES; 386 | CLANG_WARN_INT_CONVERSION = YES; 387 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 388 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 389 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 390 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 391 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 392 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 393 | CLANG_WARN_STRICT_PROTOTYPES = YES; 394 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 395 | CLANG_WARN_UNREACHABLE_CODE = YES; 396 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 397 | CODE_SIGN_IDENTITY = "-"; 398 | COPY_PHASE_STRIP = NO; 399 | DEAD_CODE_STRIPPING = YES; 400 | DEBUG_INFORMATION_FORMAT = dwarf; 401 | ENABLE_HARDENED_RUNTIME = YES; 402 | ENABLE_STRICT_OBJC_MSGSEND = YES; 403 | ENABLE_TESTABILITY = YES; 404 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 405 | GCC_C_LANGUAGE_STANDARD = gnu99; 406 | GCC_DYNAMIC_NO_PIC = NO; 407 | GCC_NO_COMMON_BLOCKS = YES; 408 | GCC_OPTIMIZATION_LEVEL = 0; 409 | GCC_PREPROCESSOR_DEFINITIONS = ( 410 | "DEBUG=1", 411 | "$(inherited)", 412 | ); 413 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 414 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 415 | GCC_WARN_UNDECLARED_SELECTOR = YES; 416 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 417 | GCC_WARN_UNUSED_FUNCTION = YES; 418 | GCC_WARN_UNUSED_VARIABLE = YES; 419 | MACOSX_DEPLOYMENT_TARGET = 14.4; 420 | MTL_ENABLE_DEBUG_INFO = YES; 421 | ONLY_ACTIVE_ARCH = YES; 422 | SDKROOT = macosx; 423 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 424 | SWIFT_STRICT_CONCURRENCY = complete; 425 | XROS_DEPLOYMENT_TARGET = 1.1; 426 | }; 427 | name = Debug; 428 | }; 429 | 036EBD211C1408C200121D0B /* Release */ = { 430 | isa = XCBuildConfiguration; 431 | buildSettings = { 432 | ALWAYS_SEARCH_USER_PATHS = NO; 433 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 434 | CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; 435 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; 436 | CLANG_CXX_LIBRARY = "libc++"; 437 | CLANG_ENABLE_MODULES = YES; 438 | CLANG_ENABLE_OBJC_ARC = YES; 439 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 440 | CLANG_WARN_BOOL_CONVERSION = YES; 441 | CLANG_WARN_COMMA = YES; 442 | CLANG_WARN_CONSTANT_CONVERSION = YES; 443 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 444 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 445 | CLANG_WARN_EMPTY_BODY = YES; 446 | CLANG_WARN_ENUM_CONVERSION = YES; 447 | CLANG_WARN_INFINITE_RECURSION = YES; 448 | CLANG_WARN_INT_CONVERSION = YES; 449 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 450 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 451 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 452 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 453 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 454 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 455 | CLANG_WARN_STRICT_PROTOTYPES = YES; 456 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 457 | CLANG_WARN_UNREACHABLE_CODE = YES; 458 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 459 | CODE_SIGN_IDENTITY = "-"; 460 | COPY_PHASE_STRIP = NO; 461 | DEAD_CODE_STRIPPING = YES; 462 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 463 | ENABLE_HARDENED_RUNTIME = YES; 464 | ENABLE_NS_ASSERTIONS = NO; 465 | ENABLE_STRICT_OBJC_MSGSEND = YES; 466 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 467 | GCC_C_LANGUAGE_STANDARD = gnu99; 468 | GCC_NO_COMMON_BLOCKS = YES; 469 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 470 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 471 | GCC_WARN_UNDECLARED_SELECTOR = YES; 472 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 473 | GCC_WARN_UNUSED_FUNCTION = YES; 474 | GCC_WARN_UNUSED_VARIABLE = YES; 475 | MACOSX_DEPLOYMENT_TARGET = 14.4; 476 | MTL_ENABLE_DEBUG_INFO = NO; 477 | SDKROOT = macosx; 478 | SWIFT_STRICT_CONCURRENCY = complete; 479 | XROS_DEPLOYMENT_TARGET = 1.1; 480 | }; 481 | name = Release; 482 | }; 483 | 036EBD231C1408C200121D0B /* Debug */ = { 484 | isa = XCBuildConfiguration; 485 | buildSettings = { 486 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 487 | ASSETCATALOG_COMPILER_GENERATE_ASSET_SYMBOLS = NO; 488 | CODE_SIGN_ENTITLEMENTS = Aware/Aware.entitlements; 489 | CODE_SIGN_IDENTITY = "Apple Development"; 490 | "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; 491 | CODE_SIGN_STYLE = Automatic; 492 | COMBINE_HIDPI_IMAGES = YES; 493 | CURRENT_PROJECT_VERSION = 1; 494 | DEAD_CODE_STRIPPING = YES; 495 | DEVELOPMENT_TEAM = 5SW9VUVYKC; 496 | ENABLE_HARDENED_RUNTIME = YES; 497 | GENERATE_INFOPLIST_FILE = YES; 498 | INFOPLIST_FILE = Aware/Info.plist; 499 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; 500 | INFOPLIST_KEY_LSUIElement = YES; 501 | LD_RUNPATH_SEARCH_PATHS = ( 502 | "$(inherited)", 503 | "@executable_path/../Frameworks", 504 | ); 505 | MARKETING_VERSION = 1.2.0; 506 | PRODUCT_BUNDLE_IDENTIFIER = com.awaremac.Aware; 507 | PRODUCT_NAME = "$(TARGET_NAME)"; 508 | PROVISIONING_PROFILE_SPECIFIER = ""; 509 | SUPPORTED_PLATFORMS = "macosx xros xrsimulator"; 510 | SUPPORTS_MACCATALYST = NO; 511 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 512 | SWIFT_VERSION = 5.0; 513 | TARGETED_DEVICE_FAMILY = 7; 514 | }; 515 | name = Debug; 516 | }; 517 | 036EBD241C1408C200121D0B /* Release */ = { 518 | isa = XCBuildConfiguration; 519 | buildSettings = { 520 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 521 | ASSETCATALOG_COMPILER_GENERATE_ASSET_SYMBOLS = NO; 522 | CODE_SIGN_ENTITLEMENTS = Aware/Aware.entitlements; 523 | CODE_SIGN_IDENTITY = "Apple Development"; 524 | "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; 525 | CODE_SIGN_STYLE = Automatic; 526 | COMBINE_HIDPI_IMAGES = YES; 527 | CURRENT_PROJECT_VERSION = 1; 528 | DEAD_CODE_STRIPPING = YES; 529 | DEVELOPMENT_TEAM = 5SW9VUVYKC; 530 | ENABLE_HARDENED_RUNTIME = YES; 531 | GENERATE_INFOPLIST_FILE = YES; 532 | INFOPLIST_FILE = Aware/Info.plist; 533 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; 534 | INFOPLIST_KEY_LSUIElement = YES; 535 | LD_RUNPATH_SEARCH_PATHS = ( 536 | "$(inherited)", 537 | "@executable_path/../Frameworks", 538 | ); 539 | MARKETING_VERSION = 1.2.0; 540 | PRODUCT_BUNDLE_IDENTIFIER = com.awaremac.Aware; 541 | PRODUCT_NAME = "$(TARGET_NAME)"; 542 | PROVISIONING_PROFILE_SPECIFIER = ""; 543 | SUPPORTED_PLATFORMS = "macosx xros xrsimulator"; 544 | SUPPORTS_MACCATALYST = NO; 545 | SWIFT_COMPILATION_MODE = wholemodule; 546 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 547 | SWIFT_VERSION = 5.0; 548 | TARGETED_DEVICE_FAMILY = 7; 549 | }; 550 | name = Release; 551 | }; 552 | 03F9E22D1C24CAD3001DBE86 /* Debug */ = { 553 | isa = XCBuildConfiguration; 554 | buildSettings = { 555 | BUNDLE_LOADER = "$(TEST_HOST)"; 556 | CODE_SIGN_IDENTITY = "Apple Development"; 557 | CODE_SIGN_STYLE = Automatic; 558 | COMBINE_HIDPI_IMAGES = YES; 559 | DEAD_CODE_STRIPPING = YES; 560 | DEVELOPMENT_TEAM = 5SW9VUVYKC; 561 | GENERATE_INFOPLIST_FILE = YES; 562 | LD_RUNPATH_SEARCH_PATHS = ( 563 | "$(inherited)", 564 | "@executable_path/../Frameworks", 565 | "@loader_path/../Frameworks", 566 | ); 567 | PRODUCT_BUNDLE_IDENTIFIER = com.awaremac.AwareTests; 568 | PRODUCT_NAME = "$(TARGET_NAME)"; 569 | PROVISIONING_PROFILE_SPECIFIER = ""; 570 | SUPPORTED_PLATFORMS = "macosx xros xrsimulator"; 571 | SUPPORTS_MACCATALYST = NO; 572 | SWIFT_VERSION = 5.0; 573 | TARGETED_DEVICE_FAMILY = 7; 574 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Aware.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Aware"; 575 | }; 576 | name = Debug; 577 | }; 578 | 03F9E22E1C24CAD3001DBE86 /* Release */ = { 579 | isa = XCBuildConfiguration; 580 | buildSettings = { 581 | BUNDLE_LOADER = "$(TEST_HOST)"; 582 | CODE_SIGN_IDENTITY = "Apple Development"; 583 | CODE_SIGN_STYLE = Automatic; 584 | COMBINE_HIDPI_IMAGES = YES; 585 | DEAD_CODE_STRIPPING = YES; 586 | DEVELOPMENT_TEAM = 5SW9VUVYKC; 587 | GENERATE_INFOPLIST_FILE = YES; 588 | LD_RUNPATH_SEARCH_PATHS = ( 589 | "$(inherited)", 590 | "@executable_path/../Frameworks", 591 | "@loader_path/../Frameworks", 592 | ); 593 | PRODUCT_BUNDLE_IDENTIFIER = com.awaremac.AwareTests; 594 | PRODUCT_NAME = "$(TARGET_NAME)"; 595 | PROVISIONING_PROFILE_SPECIFIER = ""; 596 | SUPPORTED_PLATFORMS = "macosx xros xrsimulator"; 597 | SUPPORTS_MACCATALYST = NO; 598 | SWIFT_COMPILATION_MODE = wholemodule; 599 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 600 | SWIFT_VERSION = 5.0; 601 | TARGETED_DEVICE_FAMILY = 7; 602 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Aware.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Aware"; 603 | }; 604 | name = Release; 605 | }; 606 | /* End XCBuildConfiguration section */ 607 | 608 | /* Begin XCConfigurationList section */ 609 | 036EBD101C1408C200121D0B /* Build configuration list for PBXProject "Aware" */ = { 610 | isa = XCConfigurationList; 611 | buildConfigurations = ( 612 | 036EBD201C1408C200121D0B /* Debug */, 613 | 036EBD211C1408C200121D0B /* Release */, 614 | ); 615 | defaultConfigurationIsVisible = 0; 616 | defaultConfigurationName = Release; 617 | }; 618 | 036EBD221C1408C200121D0B /* Build configuration list for PBXNativeTarget "Aware" */ = { 619 | isa = XCConfigurationList; 620 | buildConfigurations = ( 621 | 036EBD231C1408C200121D0B /* Debug */, 622 | 036EBD241C1408C200121D0B /* Release */, 623 | ); 624 | defaultConfigurationIsVisible = 0; 625 | defaultConfigurationName = Release; 626 | }; 627 | 03F9E22F1C24CAD3001DBE86 /* Build configuration list for PBXNativeTarget "AwareTests" */ = { 628 | isa = XCConfigurationList; 629 | buildConfigurations = ( 630 | 03F9E22D1C24CAD3001DBE86 /* Debug */, 631 | 03F9E22E1C24CAD3001DBE86 /* Release */, 632 | ); 633 | defaultConfigurationIsVisible = 0; 634 | defaultConfigurationName = Release; 635 | }; 636 | /* End XCConfigurationList section */ 637 | }; 638 | rootObject = 036EBD0D1C1408C200121D0B /* Project object */; 639 | } 640 | -------------------------------------------------------------------------------- /Aware.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Aware.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Aware/App.swift: -------------------------------------------------------------------------------- 1 | // 2 | // App.swift 3 | // Aware 4 | // 5 | // Created by Joshua Peek on 2/12/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct AwareApp: App { 12 | var body: some Scene { 13 | #if os(macOS) 14 | MenuBar() 15 | Settings { 16 | SettingsView() 17 | } 18 | #endif 19 | 20 | #if os(visionOS) 21 | TimerWindow() 22 | #endif 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Aware/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "size" : "16x16", 5 | "idiom" : "mac", 6 | "filename" : "icon_16x16.png", 7 | "scale" : "1x" 8 | }, 9 | { 10 | "size" : "16x16", 11 | "idiom" : "mac", 12 | "filename" : "icon_16x16@2x.png", 13 | "scale" : "2x" 14 | }, 15 | { 16 | "size" : "32x32", 17 | "idiom" : "mac", 18 | "filename" : "icon_32x32.png", 19 | "scale" : "1x" 20 | }, 21 | { 22 | "size" : "32x32", 23 | "idiom" : "mac", 24 | "filename" : "icon_32x32@2x.png", 25 | "scale" : "2x" 26 | }, 27 | { 28 | "size" : "128x128", 29 | "idiom" : "mac", 30 | "filename" : "icon_128x128.png", 31 | "scale" : "1x" 32 | }, 33 | { 34 | "size" : "128x128", 35 | "idiom" : "mac", 36 | "filename" : "icon_128x128@2x.png", 37 | "scale" : "2x" 38 | }, 39 | { 40 | "size" : "256x256", 41 | "idiom" : "mac", 42 | "filename" : "icon_256x256.png", 43 | "scale" : "1x" 44 | }, 45 | { 46 | "size" : "256x256", 47 | "idiom" : "mac", 48 | "filename" : "icon_256x256@2x.png", 49 | "scale" : "2x" 50 | }, 51 | { 52 | "size" : "512x512", 53 | "idiom" : "mac", 54 | "filename" : "icon_512x512.png", 55 | "scale" : "1x" 56 | }, 57 | { 58 | "size" : "512x512", 59 | "idiom" : "mac", 60 | "filename" : "icon_512x512@2x.png", 61 | "scale" : "2x" 62 | } 63 | ], 64 | "info" : { 65 | "version" : 1, 66 | "author" : "xcode" 67 | } 68 | } -------------------------------------------------------------------------------- /Aware/Assets.xcassets/AppIcon.appiconset/icon_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josh/Aware/e27b77b3efc8cb6558f66ccff39b0e642f8702cf/Aware/Assets.xcassets/AppIcon.appiconset/icon_128x128.png -------------------------------------------------------------------------------- /Aware/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josh/Aware/e27b77b3efc8cb6558f66ccff39b0e642f8702cf/Aware/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png -------------------------------------------------------------------------------- /Aware/Assets.xcassets/AppIcon.appiconset/icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josh/Aware/e27b77b3efc8cb6558f66ccff39b0e642f8702cf/Aware/Assets.xcassets/AppIcon.appiconset/icon_16x16.png -------------------------------------------------------------------------------- /Aware/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josh/Aware/e27b77b3efc8cb6558f66ccff39b0e642f8702cf/Aware/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png -------------------------------------------------------------------------------- /Aware/Assets.xcassets/AppIcon.appiconset/icon_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josh/Aware/e27b77b3efc8cb6558f66ccff39b0e642f8702cf/Aware/Assets.xcassets/AppIcon.appiconset/icon_256x256.png -------------------------------------------------------------------------------- /Aware/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josh/Aware/e27b77b3efc8cb6558f66ccff39b0e642f8702cf/Aware/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png -------------------------------------------------------------------------------- /Aware/Assets.xcassets/AppIcon.appiconset/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josh/Aware/e27b77b3efc8cb6558f66ccff39b0e642f8702cf/Aware/Assets.xcassets/AppIcon.appiconset/icon_32x32.png -------------------------------------------------------------------------------- /Aware/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josh/Aware/e27b77b3efc8cb6558f66ccff39b0e642f8702cf/Aware/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png -------------------------------------------------------------------------------- /Aware/Assets.xcassets/AppIcon.appiconset/icon_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josh/Aware/e27b77b3efc8cb6558f66ccff39b0e642f8702cf/Aware/Assets.xcassets/AppIcon.appiconset/icon_512x512.png -------------------------------------------------------------------------------- /Aware/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josh/Aware/e27b77b3efc8cb6558f66ccff39b0e642f8702cf/Aware/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png -------------------------------------------------------------------------------- /Aware/Assets.xcassets/AppIcon.solidimagestack/Back.solidimagestacklayer/Content.imageset/Back.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josh/Aware/e27b77b3efc8cb6558f66ccff39b0e642f8702cf/Aware/Assets.xcassets/AppIcon.solidimagestack/Back.solidimagestacklayer/Content.imageset/Back.png -------------------------------------------------------------------------------- /Aware/Assets.xcassets/AppIcon.solidimagestack/Back.solidimagestacklayer/Content.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Back.png", 5 | "idiom" : "vision", 6 | "scale" : "2x" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Aware/Assets.xcassets/AppIcon.solidimagestack/Back.solidimagestacklayer/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Aware/Assets.xcassets/AppIcon.solidimagestack/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | }, 6 | "layers" : [ 7 | { 8 | "filename" : "Front.solidimagestacklayer" 9 | }, 10 | { 11 | "filename" : "Middle.solidimagestacklayer" 12 | }, 13 | { 14 | "filename" : "Back.solidimagestacklayer" 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /Aware/Assets.xcassets/AppIcon.solidimagestack/Front.solidimagestacklayer/Content.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Front.png", 5 | "idiom" : "vision", 6 | "scale" : "2x" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Aware/Assets.xcassets/AppIcon.solidimagestack/Front.solidimagestacklayer/Content.imageset/Front.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josh/Aware/e27b77b3efc8cb6558f66ccff39b0e642f8702cf/Aware/Assets.xcassets/AppIcon.solidimagestack/Front.solidimagestacklayer/Content.imageset/Front.png -------------------------------------------------------------------------------- /Aware/Assets.xcassets/AppIcon.solidimagestack/Front.solidimagestacklayer/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Aware/Assets.xcassets/AppIcon.solidimagestack/Middle.solidimagestacklayer/Content.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Middle.png", 5 | "idiom" : "vision", 6 | "scale" : "2x" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Aware/Assets.xcassets/AppIcon.solidimagestack/Middle.solidimagestacklayer/Content.imageset/Middle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josh/Aware/e27b77b3efc8cb6558f66ccff39b0e642f8702cf/Aware/Assets.xcassets/AppIcon.solidimagestack/Middle.solidimagestacklayer/Content.imageset/Middle.png -------------------------------------------------------------------------------- /Aware/Assets.xcassets/AppIcon.solidimagestack/Middle.solidimagestacklayer/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Aware/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Aware/Aware.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Aware/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ITSAppUsesNonExemptEncryption 6 | 7 | UIBackgroundModes 8 | 9 | fetch 10 | processing 11 | 12 | BGTaskSchedulerPermittedIdentifiers 13 | 14 | fetchActivityMonitor 15 | processingActivityMonitor 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /Aware/PrivacyInfo.xcprivacy: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSPrivacyTracking 6 | 7 | NSPrivacyTrackingDomains 8 | 9 | NSPrivacyCollectedDataTypes 10 | 11 | NSPrivacyAccessedAPITypes 12 | 13 | 14 | NSPrivacyAccessedAPIType 15 | NSPrivacyAccessedAPICategorySystemBootTime 16 | NSPrivacyAccessedAPITypeReasons 17 | 18 | 35F9.1 19 | 20 | 21 | 22 | NSPrivacyAccessedAPIType 23 | NSPrivacyAccessedAPICategoryUserDefaults 24 | NSPrivacyAccessedAPITypeReasons 25 | 26 | CA92.1 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /Aware/Shared/AsyncStream+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AsyncStream+Extensions.swift 3 | // Aware 4 | // 5 | // Created by Joshua Peek on 3/24/24. 6 | // 7 | 8 | extension AsyncStream { 9 | /// Annoying wrapper to get 10 | /// typealias Yield = @discardableResult (Element) -> Continuation.YieldResult 11 | struct Yield: Sendable { 12 | private let continuation: Continuation 13 | 14 | fileprivate init(continuation: Continuation) { 15 | self.continuation = continuation 16 | } 17 | 18 | @discardableResult 19 | func callAsFunction(_ value: Element) -> Continuation.YieldResult { 20 | continuation.yield(value) 21 | } 22 | } 23 | 24 | init( 25 | _ elementType: Element.Type = Element.self, 26 | bufferingPolicy limit: Continuation.BufferingPolicy = .unbounded, 27 | _ build: @Sendable @escaping (Yield) async -> Void 28 | ) { 29 | self.init(elementType, bufferingPolicy: limit) { continuation in 30 | let task = Task { 31 | await build(Yield(continuation: continuation)) 32 | continuation.finish() 33 | } 34 | continuation.onTermination = { _ in 35 | task.cancel() 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Aware/Shared/Duration+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Duration+Extensions.swift 3 | // Aware 4 | // 5 | // Created by Joshua Peek on 3/15/24. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Duration { 11 | /// Construct a Duration given a number of minutes represented as a BinaryInteger. 12 | /// - Returns: A Duration representing a given number of minutes. 13 | static func minutes(_ minutes: some BinaryInteger) -> Duration { 14 | seconds(minutes * 60) 15 | } 16 | 17 | /// Construct a Duration given a number of hours represented as a BinaryInteger. 18 | /// - Returns: A Duration representing a given number of hours. 19 | static func hours(_ hours: some BinaryInteger) -> Duration { 20 | minutes(hours * 60) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Aware/Shared/LogExport.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LogExport.swift 3 | // Aware 4 | // 5 | // Created by Joshua Peek on 4/23/24. 6 | // 7 | 8 | import Foundation 9 | import OSLog 10 | 11 | private nonisolated(unsafe) let logger = Logger( 12 | subsystem: "com.awaremac.Aware", category: "LogExport" 13 | ) 14 | 15 | enum LogExportError: Error { 16 | case missingLibraryDirectory 17 | } 18 | 19 | func exportLogs() async throws -> URL { 20 | logger.info("Starting OSLog export") 21 | 22 | let store = try OSLogStore(scope: .currentProcessIdentifier) 23 | let predicate = NSPredicate(format: "subsystem == 'com.awaremac.Aware'") 24 | let date = Date.now.addingTimeInterval(-3600) 25 | let position = store.position(date: date) 26 | 27 | await Task.yield() 28 | 29 | logger.debug("Starting to gather entries from OSLogStore") 30 | let clock = ContinuousClock() 31 | let start = clock.now 32 | let entries = try store.getEntries(at: position, matching: predicate) 33 | logger.debug("Finished gathering logs from OSLogStore in \(clock.now - start)") 34 | 35 | try Task.checkCancellation() 36 | 37 | var data = Data() 38 | for entry in entries { 39 | guard let entry = entry as? OSLogEntryLog else { continue } 40 | let date = entry.date.formatted(date: .omitted, time: .standard) 41 | let category = entry.category 42 | let message = entry.composedMessage 43 | let line = "[\(date)] [\(category)] \(message)\n" 44 | data.append(contentsOf: line.utf8) 45 | } 46 | 47 | try Task.checkCancellation() 48 | 49 | guard let libraryURL = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first else { 50 | throw LogExportError.missingLibraryDirectory 51 | } 52 | let fileURL = 53 | libraryURL 54 | .appendingPathComponent("Logs", isDirectory: true) 55 | .appendingPathComponent("Aware.log") 56 | try data.write(to: fileURL) 57 | 58 | logger.info("Finished OSLog export") 59 | 60 | return fileURL 61 | } 62 | -------------------------------------------------------------------------------- /Aware/Shared/NotificationCenter+AsyncSequence.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NotificationCenter+AsyncSequence.swift 3 | // Aware 4 | // 5 | // Created by Joshua Peek on 3/23/24. 6 | // 7 | 8 | import Foundation 9 | import OSLog 10 | 11 | private nonisolated(unsafe) let logger = Logger( 12 | subsystem: "com.awaremac.Aware", category: "NotificationCenter+AsyncSequence" 13 | ) 14 | 15 | extension NotificationCenter { 16 | /// Returns an asynchronous sequence of notifications produced by this center for multiple notification names 17 | /// and optional source object. Similar to calling `AsyncAlgorithms` `merge` over multiple 18 | /// `notifications(named:object:)`. 19 | /// - Parameters: 20 | /// - names: An array of notification names. 21 | /// - object: A source object of notifications. 22 | /// - Returns: A merged asynchronous sequence of notifications from the center. 23 | func mergeNotifications( 24 | named names: [Notification.Name], 25 | object: AnyObject? = nil 26 | ) -> MergedNotifications { 27 | let stream = AsyncStream(bufferingPolicy: .bufferingNewest(7)) { continuation in 28 | let observers = names.map { name in 29 | logger.debug("Listening for \(name.rawValue, privacy: .public) notifications") 30 | return observe(for: name, object: object) { notification in 31 | logger.debug("Received \(name.rawValue, privacy: .public)") 32 | continuation.yield(notification) 33 | } 34 | } 35 | 36 | continuation.onTermination = { _ in 37 | logger.debug("Canceling notification observers") 38 | for observer in observers { 39 | observer.cancel() 40 | } 41 | } 42 | } 43 | 44 | return MergedNotifications(stream: stream) 45 | } 46 | } 47 | 48 | struct MergedNotifications: AsyncSequence, @unchecked Sendable { 49 | typealias Element = Notification 50 | 51 | private let stream: AsyncStream 52 | 53 | fileprivate init(stream: AsyncStream) { 54 | self.stream = stream 55 | } 56 | 57 | func makeAsyncIterator() -> Iterator { 58 | Iterator(iterator: stream.makeAsyncIterator()) 59 | } 60 | 61 | struct Iterator: AsyncIteratorProtocol { 62 | private var iterator: AsyncStream.Iterator 63 | 64 | fileprivate init(iterator: AsyncStream.Iterator) { 65 | self.iterator = iterator 66 | } 67 | 68 | mutating func next() async -> Notification? { 69 | await iterator.next() 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Aware/Shared/NotificationCenter+Observer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NotificationCenter+Observer.swift 3 | // Aware 4 | // 5 | // Created by Joshua Peek on 3/16/24. 6 | // 7 | 8 | import Foundation 9 | 10 | extension NotificationCenter { 11 | struct Observer: @unchecked Sendable { 12 | /// The notification center this observer uses. 13 | private let center: NotificationCenter 14 | 15 | /// Reference to internal non-sendable `NSNotificationReceiver` object. 16 | /// See apple/swift-corelibs-foundation for source. 17 | private let observer: AnyObject 18 | 19 | /// Create sendable Observer wrapper around `NSNotificationReceiver`. 20 | fileprivate init(center: NotificationCenter, observer: AnyObject) { 21 | self.center = center 22 | self.observer = observer 23 | } 24 | 25 | /// Removes observer from the notification center's dispatch table. 26 | func cancel() { 27 | center.removeObserver(observer) 28 | } 29 | } 30 | 31 | /// Adds an entry to the notification center to receive notifications that passed to the provided block. 32 | func observe( 33 | for name: Notification.Name, 34 | object: AnyObject? = nil, 35 | using block: @Sendable @escaping (Notification) -> Void 36 | ) -> Observer { 37 | let observer = addObserver( 38 | forName: name, 39 | object: object, 40 | queue: nil, 41 | using: block 42 | ) 43 | return Observer(center: self, observer: observer) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Aware/Shared/SuspendingClock+Drift.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SuspendingClock+Drift.swift 3 | // Aware 4 | // 5 | // Created by Joshua Peek on 3/23/24. 6 | // 7 | 8 | import Foundation 9 | import OSLog 10 | 11 | private nonisolated(unsafe) let logger = Logger( 12 | subsystem: "com.awaremac.Aware", category: "SuspendingClock+Drift" 13 | ) 14 | 15 | #if canImport(AppKit) 16 | import AppKit 17 | 18 | private let notificationCenter: NotificationCenter = NSWorkspace.shared.notificationCenter 19 | 20 | private let notifications: [Notification.Name] = [ 21 | NSWorkspace.willSleepNotification, 22 | NSWorkspace.didWakeNotification, 23 | NSWorkspace.screensDidSleepNotification, 24 | NSWorkspace.screensDidWakeNotification, 25 | NSWorkspace.willPowerOffNotification, 26 | ] 27 | 28 | #elseif canImport(UIKit) 29 | import UIKit 30 | 31 | private let notificationCenter: NotificationCenter = .default 32 | 33 | private let notifications: [Notification.Name] = [ 34 | UIApplication.nonisolatedDidEnterBackgroundNotification, 35 | UIApplication.nonisolatedWillEnterForegroundNotification, 36 | ] 37 | 38 | #endif 39 | 40 | extension SuspendingClock { 41 | /// Monitor the system's suspending clock's drift compared to the system's continous clock. 42 | /// When the drift exceeds the threshold, return from this async function. 43 | /// - Parameter threshold: The minium duration of acceptable drift 44 | /// - Throws: `CancellationError` when task is canceled 45 | /// - Returns: The drift duration above the `threshold` 46 | @discardableResult 47 | func monitorDrift(threshold: Duration) async throws -> Duration { 48 | logger.info("Starting SuspendingClock drift monitor") 49 | defer { logger.info("Finished SuspendingClock drift monitor") } 50 | 51 | let continuousClock = ContinuousClock() 52 | let suspendingClock = self 53 | 54 | let continuousStart = continuousClock.now 55 | let suspendingStart = suspendingClock.now 56 | var drift: Duration = .zero 57 | 58 | for await notification in notificationCenter.mergeNotifications(named: notifications) { 59 | logger.log("Received \(notification.name.rawValue, privacy: .public)") 60 | 61 | let continuousDuration = continuousClock.now - continuousStart 62 | assert(continuousDuration > .zero) 63 | 64 | let suspendingDuration = suspendingClock.now - suspendingStart 65 | assert(suspendingDuration > .zero) 66 | 67 | drift = continuousDuration - suspendingDuration 68 | assert(drift >= .milliseconds(-1), "suspending clock running ahead of continuous clock") 69 | 70 | logger.debug( 71 | """ 72 | continuous \(continuousDuration, privacy: .public) - \ 73 | suspending \(suspendingDuration, privacy: .public) = \ 74 | drift \(drift, privacy: .public) 75 | """ 76 | ) 77 | 78 | if drift > threshold { 79 | logger.log("suspending drift exceeded threshold: \(drift, privacy: .public)") 80 | break 81 | } 82 | 83 | try Task.checkCancellation() 84 | } 85 | 86 | try Task.checkCancellation() 87 | return drift 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Aware/Shared/TimerFormatStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimerFormatStyle.swift 3 | // Aware 4 | // 5 | // Created by Joshua Peek on 4/22/24. 6 | // 7 | 8 | import Foundation 9 | 10 | struct TimerFormatStyle: FormatStyle, Codable { 11 | enum Style: String, CaseIterable, Codable { 12 | // 1 hr, 15 min 13 | case abbreviated 14 | 15 | // 1h 15m 16 | case condensedAbbreviated 17 | 18 | // 1hr 15min 19 | case narrow 20 | 21 | // 1 hour, 15 minutes 22 | case wide 23 | 24 | // one hour, fifteen minutes 25 | case spellOut 26 | 27 | // 1:15 28 | case digits 29 | 30 | var exampleText: String { 31 | switch self { 32 | case .abbreviated: return "1 hr, 15 min" 33 | case .condensedAbbreviated: return "1h 15m" 34 | case .narrow: return "1hr 15min" 35 | case .wide: return "1 hour, 15 minutes" 36 | case .spellOut: return "one hour, fifteen minutes" 37 | case .digits: return "1:15" 38 | } 39 | } 40 | } 41 | 42 | var style: Style 43 | var showSeconds: Bool 44 | 45 | func format(_ value: Duration) -> String { 46 | let clampedValue = value < .zero ? .zero : value 47 | 48 | switch style { 49 | case .digits: 50 | let pattern: Duration.TimeFormatStyle.Pattern = 51 | showSeconds 52 | ? .hourMinuteSecond(padHourToLength: 1) 53 | : .hourMinute(padHourToLength: 1, roundSeconds: .down) 54 | let format: Duration.TimeFormatStyle = .time(pattern: pattern) 55 | return format.format(clampedValue) 56 | 57 | case .abbreviated, .condensedAbbreviated, .narrow, .wide, .spellOut: 58 | let referenceDate = Date(timeIntervalSinceReferenceDate: 0) 59 | let timeInterval = TimeInterval(clampedValue.components.seconds) 60 | let range = referenceDate ..< Date(timeIntervalSinceReferenceDate: timeInterval) 61 | 62 | let componentsFormatFields: Set = 63 | if showSeconds { 64 | [.hour, .minute, .second] 65 | } else { 66 | [.hour, .minute] 67 | } 68 | 69 | let componentsFormatStyle: Date.ComponentsFormatStyle.Style = 70 | switch style { 71 | case .abbreviated: .abbreviated 72 | case .condensedAbbreviated: .condensedAbbreviated 73 | case .narrow: .narrow 74 | case .spellOut: .spellOut 75 | case .wide: .wide 76 | case .digits: fatalError("unreachable") 77 | } 78 | 79 | let formatStyle: Date.ComponentsFormatStyle = .components( 80 | style: componentsFormatStyle, fields: componentsFormatFields 81 | ) 82 | 83 | return formatStyle.format(range) 84 | } 85 | } 86 | 87 | /// Return interval formatted text needs to be updated at depending on if seconds are shown. 88 | /// Also see 89 | var refreshInterval: TimeInterval { 90 | showSeconds ? 1.0 : 60.0 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Aware/Shared/TimerState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimerState.swift 3 | // Aware 4 | // 5 | // Created by Joshua Peek on 3/9/24. 6 | // 7 | 8 | import Foundation 9 | 10 | struct TimerState: Sendable { 11 | let clock: C 12 | 13 | private var state: InternalState 14 | 15 | private enum InternalState: Hashable, Sendable { 16 | case idle 17 | case grace(start: C.Instant, expires: C.Instant) 18 | case active(start: C.Instant) 19 | } 20 | 21 | /// Initializes timer in idle state. 22 | /// - Parameter clock: A clock instance 23 | init(clock: C) { 24 | self.clock = clock 25 | state = .idle 26 | } 27 | 28 | /// Initializes timer in active state since the specified start time. 29 | /// - Parameters: 30 | /// - start: When the timer has started 31 | /// - clock: A clock instance 32 | init(since start: C.Instant, clock: C) { 33 | self.clock = clock 34 | assert(start <= clock.now, "start should be now or in the past") 35 | state = .active(start: start) 36 | } 37 | 38 | /// Initializes timer in active state until the specified expiration time. 39 | /// - Parameters: 40 | /// - start: When the timer has started 41 | /// - expires: When the timer should expire 42 | /// - clock: A clock instance 43 | init(since start: C.Instant, until expires: C.Instant, clock: C) { 44 | self.clock = clock 45 | assert(start <= clock.now, "start should be now or in the past") 46 | assert(expires > clock.now, "expires should be in the future") 47 | state = .grace(start: start, expires: expires) 48 | } 49 | 50 | /// Check if the timer is active. 51 | var isActive: Bool { 52 | switch state { 53 | case .idle: 54 | false 55 | case let .grace(_, expires): 56 | clock.now < expires 57 | case .active: 58 | true 59 | } 60 | } 61 | 62 | /// Check if the timer is idle. 63 | var isIdle: Bool { 64 | !isActive 65 | } 66 | 67 | /// Check timer has associated expiration, regardless of it being valid. 68 | var hasExpiration: Bool { 69 | if case .grace = state { 70 | true 71 | } else { 72 | false 73 | } 74 | } 75 | 76 | /// Get valid timer start instant. Return `nil` if idle or grace period has expired. 77 | var start: C.Instant? { 78 | switch state { 79 | case .idle: 80 | nil 81 | case let .grace(start, expires): 82 | clock.now < expires ? start : nil 83 | case let .active(start): 84 | start 85 | } 86 | } 87 | 88 | /// If timer has an expiration, return the instant. 89 | var expires: C.Instant? { 90 | switch state { 91 | case .idle: 92 | nil 93 | case let .grace(_, expires): 94 | clock.now < expires ? expires : nil 95 | case .active: 96 | nil 97 | } 98 | } 99 | 100 | /// Get duration the timer has been running for. 101 | /// - Parameter end: The current clock instant 102 | /// - Returns: the duration or zero if timer is idle 103 | func duration(to end: C.Instant) -> C.Duration { 104 | if let start { 105 | start.duration(to: end) 106 | } else { 107 | C.Duration.zero 108 | } 109 | } 110 | 111 | /// Regardless of state, deactivate the timer putting it in idle mode. 112 | mutating func deactivate() { 113 | state = .idle 114 | } 115 | 116 | /// Activate the timer if it's idle or expired. Otherwise, perserve the current start instant. 117 | mutating func activate() { 118 | switch state { 119 | case .idle: 120 | state = .active(start: clock.now) 121 | case let .grace(start, expires): 122 | let now = clock.now 123 | state = .active(start: now < expires ? start : now) 124 | case .active: 125 | () 126 | } 127 | } 128 | 129 | /// Activates the timer state until the specified expiration time. 130 | /// 131 | /// - Parameters: 132 | /// - expires: The instant at which the timer state should expire. 133 | /// 134 | mutating func activate(until expires: C.Instant) { 135 | assert(expires > clock.now, "expires should be in the future") 136 | let now = clock.now 137 | switch state { 138 | case .idle: 139 | state = .grace(start: now, expires: expires) 140 | case let .grace(start, oldExpires): 141 | state = .grace(start: now < oldExpires ? start : now, expires: expires) 142 | case let .active(start): 143 | state = .grace(start: start, expires: expires) 144 | } 145 | } 146 | 147 | /// Activates the timer state for the specified duration. 148 | /// 149 | /// - Parameters: 150 | /// - duration: The duration for which the timer state should be active. 151 | /// 152 | mutating func activate(for duration: C.Duration) { 153 | assert(duration > .zero, "duration should be positive") 154 | activate(until: clock.now.advanced(by: duration)) 155 | } 156 | 157 | /// Activates the timer state setting the start to now regardless of the current state. 158 | mutating func restart() { 159 | state = .active(start: clock.now) 160 | } 161 | } 162 | 163 | extension TimerState: Equatable { 164 | /// Returns a Boolean value indicating whether two values are equal. 165 | static func == (lhs: Self, rhs: Self) -> Bool { 166 | lhs.state == rhs.state 167 | } 168 | } 169 | 170 | extension TimerState: CustomStringConvertible where C.Duration == Swift.Duration { 171 | /// A textual representation of this timer state. 172 | var description: String { 173 | switch state { 174 | case .idle: 175 | return "idle" 176 | 177 | case let .grace(start, expires): 178 | let now = clock.now 179 | if now < expires { 180 | let startFormatted = start.duration(to: now).formatted(.time(pattern: .hourMinuteSecond)) 181 | let expiresFormatted = now.duration(to: expires).formatted( 182 | .time(pattern: .hourMinuteSecond)) 183 | return "active[\(startFormatted), expires in \(expiresFormatted)]" 184 | } else { 185 | return "idle[expired]" 186 | } 187 | 188 | case let .active(start): 189 | let now = clock.now 190 | let startFormatted = start.duration(to: now).formatted(.time(pattern: .hourMinuteSecond)) 191 | return "active[\(startFormatted)]" 192 | } 193 | } 194 | } 195 | 196 | extension TimerState where C == UTCClock { 197 | init() { 198 | self.init(clock: UTCClock()) 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /Aware/Shared/UTCClock.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UTCClock.swift 3 | // Aware 4 | // 5 | // Created by Joshua Peek on 3/8/24. 6 | // 7 | 8 | import Foundation 9 | 10 | // Backport proposed Foundation UTCClock 11 | // https://github.com/apple/swift-evolution/blob/main/proposals/0329-clock-instant-duration.md#clocks-outside-of-the-standard-library 12 | struct UTCClock: Clock { 13 | struct Instant: InstantProtocol { 14 | let date: Date 15 | 16 | init(_ date: Date) { 17 | self.date = date 18 | } 19 | 20 | static var now: Self { .init(.now) } 21 | 22 | static func < (lhs: Self, rhs: Self) -> Bool { 23 | lhs.date < rhs.date 24 | } 25 | 26 | func advanced(by duration: Duration) -> Self { 27 | Self(date.addingTimeInterval(duration.timeInterval)) 28 | } 29 | 30 | func duration(to other: Self) -> Duration { 31 | Duration(timeInterval: other.date.timeIntervalSince(date)) 32 | } 33 | } 34 | 35 | let minimumResolution: Duration = .nanoseconds(100) 36 | var now: Instant { .now } 37 | 38 | func sleep(for duration: Duration, tolerance: Duration? = nil) async throws { 39 | try await ContinuousClock().sleep(for: duration, tolerance: tolerance) 40 | } 41 | 42 | func sleep(until deadline: Instant, tolerance: Duration?) async throws { 43 | try await sleep(for: now.duration(to: deadline), tolerance: tolerance) 44 | } 45 | } 46 | 47 | // extension Date: InstantProtocol { 48 | // public func advanced(by duration: Duration) -> Date { 49 | // addingTimeInterval(duration.timeInterval) 50 | // } 51 | // 52 | // public func duration(to other: Date) -> Duration { 53 | // Duration(timeInterval: other.timeIntervalSince(self)) 54 | // } 55 | // } 56 | 57 | extension Duration { 58 | init(timeInterval: TimeInterval) { 59 | let seconds = Int64(timeInterval) 60 | let attoseconds = Int64((timeInterval - TimeInterval(seconds)) * 1_000_000_000_000_000_000) 61 | self.init(secondsComponent: seconds, attosecondsComponent: attoseconds) 62 | } 63 | 64 | var timeInterval: TimeInterval { 65 | TimeInterval(components.seconds) 66 | + (TimeInterval(components.attoseconds) / 1_000_000_000_000_000_000) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Aware/Shared/UserDefaults+AsyncSequence.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserDefaults+AsyncSequence.swift 3 | // Aware 4 | // 5 | // Created by Joshua Peek on 4/23/24. 6 | // 7 | 8 | import Foundation 9 | import OSLog 10 | 11 | private nonisolated(unsafe) let logger = Logger( 12 | subsystem: "com.awaremac.Aware", category: "UserDefaults+AsyncSequence" 13 | ) 14 | 15 | extension UserDefaults { 16 | fileprivate class Observer: NSObject, @unchecked Sendable { 17 | private let store: UserDefaults 18 | private let keyPath: String 19 | private let block: @Sendable (Element?) -> Void 20 | 21 | init(store: UserDefaults, keyPath: String, block: @escaping @Sendable (Element?) -> Void) { 22 | self.store = store 23 | self.keyPath = keyPath 24 | self.block = block 25 | } 26 | 27 | override func observeValue( 28 | forKeyPath keyPath: String?, 29 | of object: Any?, 30 | change: [NSKeyValueChangeKey: Any]?, 31 | context: UnsafeMutableRawPointer? 32 | ) { 33 | assert(keyPath == self.keyPath, "unexpected keyPath") 34 | assert(object as? UserDefaults == store, "unexpected store") 35 | assert(context == nil, "unexpected context") 36 | block(change?[.newKey] as? Element) 37 | } 38 | 39 | func cancel() { 40 | store.removeObserver(self, forKeyPath: keyPath) 41 | } 42 | } 43 | 44 | func updates( 45 | forKeyPath keyPath: String, 46 | type _: Element.Type = Element.self, 47 | initial: Bool = false 48 | ) -> AsyncStream { 49 | .init(bufferingPolicy: .bufferingNewest(1)) { continuation in 50 | let observer = Observer(store: self, keyPath: keyPath) { value in 51 | logger.debug( 52 | "Yielding UserDefaults \"\(keyPath, privacy: .public)\" value: \(String(describing: value))") 53 | continuation.yield(value) 54 | } 55 | 56 | let options: NSKeyValueObservingOptions = initial ? [.initial, .new] : [.new] 57 | addObserver(observer, forKeyPath: keyPath, options: options, context: nil) 58 | 59 | continuation.onTermination = { _ in 60 | logger.debug("Canceling UserDefaults \"\(keyPath, privacy: .public)\" observer") 61 | observer.cancel() 62 | } 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Aware/macOS/ActivityMonitor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ActivityMonitor.swift 3 | // Aware 4 | // 5 | // Created by Joshua Peek on 2/16/24. 6 | // 7 | 8 | #if os(macOS) 9 | 10 | import AppKit 11 | import OSLog 12 | 13 | private nonisolated(unsafe) let logger = Logger( 14 | subsystem: "com.awaremac.Aware", category: "ActivityMonitor" 15 | ) 16 | 17 | struct ActivityMonitor { 18 | /// Initial timer state 19 | let initialState: TimerState 20 | 21 | struct Configuration: Equatable { 22 | /// The duration since the last user event to consider time idle. 23 | var userIdle: Duration 24 | 25 | /// The duration of idle timer tolerance 26 | var userIdleTolerance: Duration = .seconds(5) 27 | } 28 | 29 | let configuration: Configuration 30 | 31 | /// Subscribe to an async stream of the latest `TimerState` events. 32 | /// - Returns: An async sequence of `TimerState` values. 33 | func updates() -> AsyncStream> { 34 | AsyncStream(bufferingPolicy: .bufferingNewest(1)) { @MainActor yield in 35 | do { 36 | logger.log("Starting ActivityMonitor update task: \(initialState, privacy: .public)") 37 | 38 | var state = initialState { 39 | didSet { 40 | let newValue = state 41 | if oldValue != newValue { 42 | logger.log( 43 | "State changed from \(oldValue, privacy: .public) to \(newValue, privacy: .public)") 44 | yield(newValue) 45 | } else { 46 | logger.debug("No state change \(newValue, privacy: .public)") 47 | } 48 | } 49 | } 50 | 51 | async let notificationsTask: () = { @MainActor in 52 | for await name in NSWorkspace.shared.notificationCenter.mergeNotifications( 53 | named: sleepWakeNotifications 54 | ).map(\.name) { 55 | logger.log("Received \(name.rawValue, privacy: .public)") 56 | 57 | switch name { 58 | case NSWorkspace.willSleepNotification, NSWorkspace.screensDidSleepNotification, 59 | NSWorkspace.willPowerOffNotification: 60 | state.deactivate() 61 | case NSWorkspace.didWakeNotification, NSWorkspace.screensDidWakeNotification: 62 | state.restart() 63 | default: 64 | assertionFailure("unexpected notification: \(name.rawValue)") 65 | } 66 | } 67 | }() 68 | 69 | async let userDefaultsTask: () = { @MainActor in 70 | let store = UserDefaults.standard 71 | for await value in store.updates(forKeyPath: "reset", type: Bool.self, initial: true) { 72 | logger.debug("Received UserDefaults \"reset\" change") 73 | if value == true { 74 | state.restart() 75 | } 76 | if value != nil { 77 | logger.debug("Cleaning up \"reset\" key") 78 | store.removeObject(forKey: "reset") 79 | } 80 | } 81 | }() 82 | 83 | while !Task.isCancelled { 84 | let lastUserEvent = secondsSinceLastUserEvent() 85 | let idleRemaining = configuration.userIdle - lastUserEvent 86 | logger.debug("Last user event \(lastUserEvent, privacy: .public) ago") 87 | 88 | if idleRemaining <= .zero || isMainDisplayAsleep() { 89 | state.deactivate() 90 | 91 | logger.debug("Waiting for user activity event") 92 | let now: ContinuousClock.Instant = .now 93 | try await waitUntilNextUserActivityEvent() 94 | logger.debug("Received user activity event after \(.now - now, privacy: .public)") 95 | } else { 96 | state.activate() 97 | 98 | logger.debug("Sleeping for \(idleRemaining, privacy: .public)") 99 | let now: ContinuousClock.Instant = .now 100 | try await Task.sleep(for: idleRemaining, tolerance: configuration.userIdleTolerance) 101 | logger.debug("Slept for \(.now - now, privacy: .public)") 102 | } 103 | } 104 | 105 | await notificationsTask 106 | await userDefaultsTask 107 | 108 | assert(Task.isCancelled) 109 | try Task.checkCancellation() 110 | 111 | logger.log("Finished ActivityMonitor update task") 112 | } catch is CancellationError { 113 | logger.log("ActivityMonitor update task canceled") 114 | } catch { 115 | logger.error("ActivityMonitor update task canceled unexpectedly: \(error, privacy: .public)") 116 | } 117 | } 118 | } 119 | } 120 | 121 | private let sleepWakeNotifications = [ 122 | NSWorkspace.willSleepNotification, 123 | NSWorkspace.didWakeNotification, 124 | NSWorkspace.screensDidSleepNotification, 125 | NSWorkspace.screensDidWakeNotification, 126 | NSWorkspace.willPowerOffNotification, 127 | ] 128 | 129 | private let userActivityEventMask: NSEvent.EventTypeMask = [ 130 | .leftMouseDown, 131 | .rightMouseDown, 132 | .mouseMoved, 133 | .keyDown, 134 | .scrollWheel, 135 | ] 136 | 137 | private let userActivityEventTypes: [CGEventType] = [ 138 | .leftMouseDown, 139 | .rightMouseDown, 140 | .mouseMoved, 141 | .keyDown, 142 | .scrollWheel, 143 | ] 144 | 145 | func waitUntilNextUserActivityEvent() async throws { 146 | for await _ in NSEvent.globalEvents(matching: userActivityEventMask) { 147 | return 148 | } 149 | } 150 | 151 | private func secondsSinceLastUserEvent() -> Duration { 152 | userActivityEventTypes.map { eventType in 153 | CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: eventType) 154 | }.min().map { ti in Duration(timeInterval: ti) } ?? .zero 155 | } 156 | 157 | private func isMainDisplayAsleep() -> Bool { 158 | CGDisplayIsAsleep(CGMainDisplayID()) == 1 159 | } 160 | 161 | #endif 162 | -------------------------------------------------------------------------------- /Aware/macOS/MenuBar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MenuBar.swift 3 | // Aware 4 | // 5 | // Created by Joshua Peek on 2/16/24. 6 | // 7 | 8 | #if os(macOS) 9 | 10 | import AppKit 11 | import OSLog 12 | import SwiftUI 13 | 14 | private nonisolated(unsafe) let logger = Logger( 15 | subsystem: "com.awaremac.Aware", category: "MenuBar" 16 | ) 17 | 18 | struct MenuBar: Scene { 19 | var body: some Scene { 20 | MenuBarExtra { 21 | MenuBarContentView() 22 | } label: { 23 | TimerMenuBarLabel() 24 | } 25 | } 26 | } 27 | 28 | struct TimerMenuBarLabel: View { 29 | @State private var timerState = TimerState() 30 | @State private var statusItem: NSStatusItem? 31 | 32 | // User configurable idle time in seconds (defaults to 2 minutes) 33 | @AppStorage("userIdleSeconds") private var userIdleSeconds: Int = 120 34 | 35 | @AppStorage("formatStyle") private var timerFormatStyle: TimerFormatStyle.Style = .condensedAbbreviated 36 | @AppStorage("showSeconds") private var showSeconds: Bool = false 37 | 38 | private var timerFormat: TimerFormatStyle { 39 | TimerFormatStyle(style: timerFormatStyle, showSeconds: showSeconds) 40 | } 41 | 42 | private var activityMonitorConfiguration: ActivityMonitor.Configuration { 43 | ActivityMonitor.Configuration( 44 | userIdle: .seconds(max(1, userIdleSeconds)) 45 | ) 46 | } 47 | 48 | var body: some View { 49 | Group { 50 | if let start = timerState.start { 51 | MenuBarTimelineView(.periodic(from: start.date, by: timerFormat.refreshInterval)) { context in 52 | let duration = timerState.duration(to: UTCClock.Instant(context.date)) 53 | Text(duration, format: timerFormat) 54 | } 55 | } else { 56 | Text(.seconds(0), format: timerFormat) 57 | } 58 | } 59 | .task(id: activityMonitorConfiguration) { 60 | let activityMonitor = ActivityMonitor(initialState: timerState, configuration: activityMonitorConfiguration) 61 | logger.log("Starting ActivityMonitor updates task: \(timerState, privacy: .public)") 62 | for await state in activityMonitor.updates() { 63 | logger.log("Received ActivityMonitor state: \(state, privacy: .public)") 64 | timerState = state 65 | } 66 | logger.log("Finished ActivityMonitor updates task: \(timerState, privacy: .public)") 67 | } 68 | .bindStatusItem($statusItem) 69 | .onChange(of: timerState.isIdle) { _, isIdle in 70 | assert(statusItem?.button != nil, "missing statusItem button") 71 | statusItem?.button?.appearsDisabled = isIdle 72 | } 73 | } 74 | } 75 | 76 | struct MenuBarContentView: View { 77 | var body: some View { 78 | SettingsLink() 79 | .keyboardShortcut(",") 80 | Divider() 81 | Button("Quit") { 82 | NSApplication.shared.terminate(nil) 83 | }.keyboardShortcut("q") 84 | } 85 | } 86 | 87 | #endif 88 | -------------------------------------------------------------------------------- /Aware/macOS/MenuBarTimelineView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MenuBarTimelineView.swift 3 | // Aware 4 | // 5 | // Created by Joshua Peek on 3/7/24. 6 | // 7 | 8 | #if os(macOS) 9 | 10 | import SwiftUI 11 | 12 | // Using TimelineView within MenuBarExtra content seems to beachball on macOS Sonoma 14.4. 13 | // Reported Feedback FB13678902 on Mar 7, 2024. 14 | struct MenuBarTimelineView: View 15 | where Schedule: TimelineSchedule, Content: View 16 | { 17 | let schedule: Schedule 18 | let content: (MenuBarTimelineViewDefaultContext) -> Content 19 | 20 | private let startDate = Date() 21 | 22 | @State private var context: MenuBarTimelineViewDefaultContext = .init(date: .now) 23 | 24 | init( 25 | _ schedule: Schedule, 26 | @ViewBuilder content: @escaping (MenuBarTimelineViewDefaultContext) -> Content 27 | ) { 28 | self.schedule = schedule 29 | self.content = content 30 | } 31 | 32 | var body: some View { 33 | content(context) 34 | .task(id: startDate) { 35 | for date in schedule.entries(from: startDate, mode: .normal) { 36 | let duration: Duration = .init(timeInterval: date.timeIntervalSinceNow) 37 | if duration > .zero { 38 | do { 39 | try await Task.sleep(for: duration) 40 | } catch is CancellationError { 41 | return 42 | } catch { 43 | assertionFailure("sleep threw unknown error") 44 | return 45 | } 46 | } 47 | assert(date <= Date.now, "didn't sleep long enough") 48 | context = .init(date: date) 49 | } 50 | } 51 | } 52 | } 53 | 54 | struct MenuBarTimelineViewDefaultContext { 55 | let date: Date 56 | } 57 | 58 | #endif 59 | -------------------------------------------------------------------------------- /Aware/macOS/NSApplication+Activate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSApplication+Activate.swift 3 | // Aware 4 | // 5 | // Created by Joshua Peek on 4/25/24. 6 | // 7 | 8 | #if canImport(AppKit) 9 | 10 | import AppKit 11 | import OSLog 12 | 13 | private nonisolated(unsafe) let logger = Logger( 14 | subsystem: "com.awaremac.Aware", category: "NSApp+Activate" 15 | ) 16 | 17 | extension NSApplication { 18 | func activateAggressively() { 19 | let start: ContinuousClock.Instant = .now 20 | let deadline: ContinuousClock.Instant = start.advanced(by: .seconds(3)) 21 | 22 | Task(priority: .high) { 23 | while isActive == false && deadline > .now && !Task.isCancelled { 24 | activate() 25 | await Task.yield() 26 | } 27 | 28 | assert(isActive, "expected app to be active") 29 | logger.debug("\(self) application took \(.now - start) to activate") 30 | } 31 | } 32 | } 33 | 34 | #endif 35 | -------------------------------------------------------------------------------- /Aware/macOS/NSEvent+AsyncStream.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSEvent+AsyncStream.swift 3 | // Aware 4 | // 5 | // Created by Joshua Peek on 3/8/24. 6 | // 7 | 8 | #if canImport(AppKit) 9 | 10 | import AppKit 11 | 12 | extension NSEvent { 13 | struct Monitor: @unchecked Sendable { 14 | /// The event handler object 15 | let eventMonitor: Any? 16 | 17 | fileprivate init(_ eventMonitor: Any?) { 18 | self.eventMonitor = eventMonitor 19 | } 20 | 21 | /// Removes the specified event monitor. 22 | func cancel() { 23 | assert(eventMonitor != nil, "event monitor failed to install") 24 | if let eventMonitor { 25 | NSEvent.removeMonitor(eventMonitor) 26 | } 27 | } 28 | } 29 | 30 | static func globalEvents(matching mask: NSEvent.EventTypeMask) -> AsyncStream { 31 | AsyncStream(bufferingPolicy: .bufferingNewest(7)) { continuation in 32 | let monitor = NSEvent.addGlobalMonitorForEvents(matching: mask) { event in 33 | continuation.yield(event) 34 | } 35 | 36 | let eventMonitor = Monitor(monitor) 37 | continuation.onTermination = { [eventMonitor] _ in 38 | eventMonitor.cancel() 39 | } 40 | } 41 | } 42 | } 43 | 44 | #endif 45 | -------------------------------------------------------------------------------- /Aware/macOS/SettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsView.swift 3 | // Aware 4 | // 5 | // Created by Joshua Peek on 4/22/24. 6 | // 7 | 8 | #if os(macOS) 9 | 10 | import OSLog 11 | import ServiceManagement 12 | import SwiftUI 13 | 14 | private nonisolated(unsafe) let logger = Logger( 15 | subsystem: "com.awaremac.Aware", category: "SettingsView" 16 | ) 17 | 18 | struct SettingsView: View { 19 | @AppStorage("reset") private var resetTimer: Bool = false 20 | @AppStorage("userIdleSeconds") private var userIdleSeconds: Int = 120 21 | @AppStorage("formatStyle") private var timerFormatStyle: TimerFormatStyle.Style = .condensedAbbreviated 22 | @AppStorage("showSeconds") private var showSeconds: Bool = false 23 | 24 | @State private var lastLoginItemRegistration: Result? 25 | 26 | @State private var exportingLogs: Bool = false 27 | @State private var showExportErrored: Bool = false 28 | 29 | @State private var window: NSWindow? 30 | @State private var windowIsVisible: Bool = false 31 | 32 | var body: some View { 33 | Form { 34 | Section { 35 | Picker("Format Style:", selection: $timerFormatStyle) { 36 | ForEach(TimerFormatStyle.Style.allCases, id: \.self) { style in 37 | Text(style.exampleText) 38 | } 39 | } 40 | 41 | Toggle("Show Seconds", isOn: $showSeconds) 42 | } 43 | 44 | Spacer() 45 | .frame(width: 0, height: 0) 46 | .padding(.top) 47 | 48 | Section { 49 | LabeledContent("Reset after:") { 50 | TextField("Idle Seconds", value: $userIdleSeconds, format: .number) 51 | .multilineTextAlignment(.trailing) 52 | .labelsHidden() 53 | .frame(width: 50) 54 | Stepper("Idle Seconds", value: $userIdleSeconds, step: 30) 55 | .labelsHidden() 56 | Text("seconds of inactivity") 57 | .padding(.leading, 5) 58 | } 59 | 60 | Button("Reset Timer") { 61 | self.resetTimer = true 62 | } 63 | } 64 | 65 | Spacer() 66 | .frame(width: 0, height: 0) 67 | .padding(.top) 68 | 69 | Section { 70 | LabeledContent("Login Item:") { 71 | Toggle("Open at Login", isOn: openAtLogin) 72 | } 73 | } 74 | 75 | Spacer() 76 | .frame(width: 0, height: 0) 77 | .padding(.top) 78 | 79 | Section { 80 | Button(exportingLogs ? "Exporting Developer Logs..." : "Export Developer Logs") { 81 | self.exportingLogs = true 82 | Task(priority: .low) { 83 | do { 84 | let logURL = try await exportLogs() 85 | NSWorkspace.shared.activateFileViewerSelecting([logURL]) 86 | self.showExportErrored = false 87 | } catch { 88 | self.showExportErrored = true 89 | } 90 | self.exportingLogs = false 91 | } 92 | } 93 | .disabled(exportingLogs) 94 | .alert(isPresented: $showExportErrored) { 95 | Alert( 96 | title: Text("Export Error"), 97 | message: Text("Couldn't export logs"), 98 | dismissButton: .default(Text("OK")) 99 | ) 100 | } 101 | } 102 | } 103 | .padding() 104 | .frame(width: 350) 105 | .bindWindow($window) 106 | .onChange(of: windowIsVisible) { oldValue, newValue in 107 | logger.debug("Window visibility change: \(oldValue) -> \(newValue)") 108 | 109 | if oldValue == false && newValue == true { 110 | NSApp.activateAggressively() 111 | } 112 | } 113 | .task(id: window) { 114 | guard let window else { 115 | assertionFailure("no window is set") 116 | return 117 | } 118 | 119 | self.windowIsVisible = window.isVisible 120 | 121 | logger.debug("Starting to observe occlusion state changes for \(window)") 122 | let notifications = NotificationCenter.default.notifications( 123 | named: NSWindow.didChangeOcclusionStateNotification, 124 | object: window 125 | ).map { _ in () } 126 | 127 | for await _ in notifications { 128 | logger.debug("Window occlusion state changed for \(window): \(window.isVisible)") 129 | self.windowIsVisible = window.isVisible 130 | } 131 | 132 | logger.debug("Finished observing occlusion state changes for \(window)") 133 | } 134 | } 135 | 136 | var openAtLogin: Binding { 137 | .init { 138 | switch lastLoginItemRegistration { 139 | case let .success(enabled): return enabled 140 | default: return SMAppService.mainApp.status == .enabled 141 | } 142 | } set: { enabled in 143 | lastLoginItemRegistration = Result { 144 | if enabled { 145 | try SMAppService.mainApp.register() 146 | } else { 147 | try SMAppService.mainApp.unregister() 148 | } 149 | return SMAppService.mainApp.status == .enabled 150 | } 151 | } 152 | } 153 | } 154 | 155 | #Preview { 156 | SettingsView() 157 | } 158 | 159 | #endif 160 | -------------------------------------------------------------------------------- /Aware/macOS/View+NSStatusItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View+NSStatusItem.swift 3 | // Aware 4 | // 5 | // Created by Joshua Peek on 4/25/24. 6 | // 7 | 8 | #if canImport(AppKit) 9 | 10 | import AppKit 11 | import SwiftUI 12 | 13 | extension View { 14 | @MainActor 15 | func bindStatusItem(_ statusItem: Binding) -> some View { 16 | onAppear { 17 | let statusItems = NSApp.windows.filter { window in 18 | window.className == "NSStatusBarWindow" 19 | }.compactMap { window in 20 | window.value(forKey: "statusItem") as? NSStatusItem 21 | } 22 | 23 | assert(!statusItems.isEmpty, "no NSStatusItems found") 24 | assert(statusItems.count == 1, "multiple NSStatusItems found") 25 | statusItem.wrappedValue = statusItems.first 26 | } 27 | } 28 | } 29 | 30 | #endif 31 | -------------------------------------------------------------------------------- /Aware/macOS/View+NSWindow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View+NSWindow.swift 3 | // Aware 4 | // 5 | // Created by Joshua Peek on 4/25/24. 6 | // 7 | 8 | #if canImport(AppKit) 9 | 10 | import AppKit 11 | import SwiftUI 12 | 13 | private struct WindowAccessorView: NSViewRepresentable { 14 | @Binding var window: NSWindow? 15 | 16 | func makeNSView(context _: Context) -> NSView { 17 | let view = NSView() 18 | view.translatesAutoresizingMaskIntoConstraints = false 19 | Task { 20 | assert(view.window != nil, "window accessor fail to detect window") 21 | self.window = view.window 22 | } 23 | return view 24 | } 25 | 26 | func updateNSView(_: NSView, context _: Context) {} 27 | } 28 | 29 | extension View { 30 | func bindWindow(_ window: Binding) -> some View { 31 | background(WindowAccessorView(window: window)) 32 | } 33 | } 34 | 35 | #endif 36 | -------------------------------------------------------------------------------- /Aware/visionOS/ActivityMonitor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ActivityMonitor.swift 3 | // Aware 4 | // 5 | // Created by Joshua Peek on 2/23/24. 6 | // 7 | 8 | #if os(visionOS) 9 | 10 | import OSLog 11 | import UIKit 12 | 13 | private nonisolated(unsafe) let logger = Logger( 14 | subsystem: "com.awaremac.Aware", category: "ActivityMonitor" 15 | ) 16 | 17 | let fetchActivityMonitorTask: BackgroundTask = .appRefresh("fetchActivityMonitor") 18 | let processingActivityMonitorTask: BackgroundTask = .processing("processingActivityMonitor") 19 | 20 | struct ActivityMonitor { 21 | /// Initial timer state 22 | let initialState: TimerState 23 | 24 | struct Configuration: Equatable { 25 | /// The minimum number of seconds to schedule between background tasks. 26 | var backgroundTaskInterval: Duration 27 | 28 | /// The duration the app can be in the background and be considered active if it's opened again. 29 | var backgroundGracePeriod: Duration 30 | 31 | /// The duration after locking the device it can be considered active if it's unlocked again. 32 | var lockGracePeriod: Duration 33 | 34 | /// The max duration to allow the suspending clock to drift from the continuous clock. 35 | var maxSuspendingClockDrift: Duration 36 | } 37 | 38 | let configuration: Configuration 39 | 40 | /// Subscribe to an async stream of the latest `TimerState` events. 41 | /// - Returns: An async sequence of `TimerState` values. 42 | func updates() -> AsyncStream> { 43 | AsyncStream(bufferingPolicy: .bufferingNewest(1)) { @MainActor yield in 44 | do { 45 | logger.log("Starting ActivityMonitor update task: \(initialState)") 46 | 47 | var state = initialState { 48 | didSet { 49 | let newValue = state 50 | if oldValue != newValue { 51 | logger.log( 52 | "State changed from \(oldValue, privacy: .public) to \(newValue, privacy: .public)") 53 | yield(newValue) 54 | } else { 55 | logger.debug("No state change \(newValue, privacy: .public)") 56 | } 57 | } 58 | } 59 | 60 | let app = UIApplication.shared 61 | 62 | // Set initial state 63 | assert(app.applicationState != .background) 64 | assert(app.isProtectedDataAvailable) 65 | state.activate() 66 | 67 | let center = NotificationCenter.default 68 | 69 | async let driftTask: () = { @MainActor in 70 | while !Task.isCancelled { 71 | try await SuspendingClock().monitorDrift(threshold: configuration.maxSuspendingClockDrift) 72 | state.deactivate() 73 | 74 | if app.applicationState != .background { 75 | assert(app.isProtectedDataAvailable, "expected protected data to be available") 76 | state.activate() 77 | } else if app.isProtectedDataAvailable { 78 | state.activate(for: configuration.backgroundGracePeriod) 79 | } 80 | } 81 | }() 82 | 83 | async let userDefaultsTask: () = { @MainActor in 84 | let store = UserDefaults.standard 85 | for await value in store.updates(forKeyPath: "reset", type: Bool.self, initial: true) { 86 | logger.debug("Received UserDefaults \"reset\" change") 87 | if value == true { 88 | state.restart() 89 | } 90 | if value != nil { 91 | logger.debug("Cleaning up \"reset\" key") 92 | store.removeObject(forKey: "reset") 93 | } 94 | } 95 | }() 96 | 97 | let notificationNames = [ 98 | UIApplication.didEnterBackgroundNotification, 99 | UIApplication.willEnterForegroundNotification, 100 | UIApplication.protectedDataDidBecomeAvailableNotification, 101 | UIApplication.protectedDataWillBecomeUnavailableNotification, 102 | fetchActivityMonitorTask.notification, 103 | processingActivityMonitorTask.notification, 104 | ] 105 | 106 | for await notificationName in center.mergeNotifications(named: notificationNames).map(\.name) { 107 | logger.log("Received \(notificationName.rawValue, privacy: .public)") 108 | 109 | let oldState = state 110 | 111 | switch notificationName { 112 | case UIApplication.didEnterBackgroundNotification: 113 | assert(app.applicationState == .background) 114 | assert(app.isProtectedDataAvailable) 115 | state.activate(for: configuration.backgroundGracePeriod) 116 | 117 | case UIApplication.willEnterForegroundNotification: 118 | assert(app.applicationState != .background) 119 | assert(app.isProtectedDataAvailable) 120 | state.activate() 121 | 122 | case UIApplication.protectedDataDidBecomeAvailableNotification: 123 | assert(app.applicationState == .background) 124 | assert(app.isProtectedDataAvailable) 125 | state.activate(for: configuration.backgroundGracePeriod) 126 | 127 | case UIApplication.protectedDataWillBecomeUnavailableNotification: 128 | assert(app.applicationState == .background) 129 | assert(app.isProtectedDataAvailable) 130 | state.activate(for: configuration.lockGracePeriod) 131 | 132 | case fetchActivityMonitorTask.notification, 133 | processingActivityMonitorTask.notification: 134 | 135 | if app.applicationState == .background { 136 | if app.isProtectedDataAvailable { 137 | // Running in background while device is unlocked 138 | state.activate(for: configuration.backgroundGracePeriod) 139 | } else { 140 | // Running in background while device is locked 141 | state.deactivate() 142 | } 143 | } else { 144 | // Active in foreground 145 | assert(app.isProtectedDataAvailable, "expected protected data to be available") 146 | assert(state.isActive, "expected to already be active") 147 | assert(!state.hasExpiration, "expected to not have expiration") 148 | state.activate() 149 | } 150 | 151 | default: 152 | assertionFailure("unexpected notification: \(notificationName.rawValue)") 153 | } 154 | 155 | // It would be nice to do this in the state didSet hook, but we need async 156 | await rescheduleBackgroundTasks(oldState: oldState, newState: state) 157 | } 158 | 159 | try await driftTask 160 | await userDefaultsTask 161 | 162 | assert(Task.isCancelled) 163 | try Task.checkCancellation() 164 | 165 | logger.log("Finished ActivityMonitor update task") 166 | } catch is CancellationError { 167 | logger.log("ActivityMonitor update task canceled") 168 | } catch { 169 | logger.error("ActivityMonitor update task canceled unexpectedly: \(error, privacy: .public)") 170 | } 171 | } 172 | } 173 | 174 | private func rescheduleBackgroundTasks(oldState: TimerState, newState: TimerState) async { 175 | if newState.hasExpiration { 176 | let taskBeginDate = UTCClock.Instant.now.advanced(by: configuration.backgroundTaskInterval).date 177 | await fetchActivityMonitorTask.reschedule(for: taskBeginDate) 178 | await processingActivityMonitorTask.reschedule(for: taskBeginDate) 179 | // e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"fetchActivityMonitor"] 180 | // e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"processingActivityMonitor"] 181 | } else if oldState.hasExpiration { 182 | await fetchActivityMonitorTask.cancel() 183 | await processingActivityMonitorTask.cancel() 184 | } 185 | } 186 | } 187 | 188 | #endif 189 | -------------------------------------------------------------------------------- /Aware/visionOS/BackgroundTask.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BackgroundTask.swift 3 | // Aware 4 | // 5 | // Created by Joshua Peek on 3/23/24. 6 | // 7 | 8 | #if canImport(BackgroundTasks) 9 | 10 | import BackgroundTasks 11 | import OSLog 12 | import SwiftUI 13 | 14 | private nonisolated(unsafe) let logger = Logger( 15 | subsystem: "com.awaremac.Aware", category: "BackgroundTask" 16 | ) 17 | 18 | @available(macOS, unavailable) 19 | actor BackgroundTask { 20 | enum TaskRequestType { 21 | case appRefresh 22 | case processing(requiresExternalPower: Bool, requiresNetworkConnectivity: Bool) 23 | } 24 | 25 | struct ScheduledTask { 26 | let submittedAt: Date 27 | let earliestBeginAt: Date 28 | } 29 | 30 | struct RanTask { 31 | let task: ScheduledTask? 32 | let ranAt: Date 33 | } 34 | 35 | let identifier: String 36 | let notification: Notification.Name 37 | private let taskRequestType: TaskRequestType 38 | 39 | var scheduledTask: ScheduledTask? 40 | var lastRanTask: RanTask? 41 | 42 | static func appRefresh(_ identifier: String) -> BackgroundTask { 43 | BackgroundTask(identifier: identifier, taskRequestType: .appRefresh) 44 | } 45 | 46 | static func processing( 47 | _ identifier: String, 48 | requiresExternalPower: Bool = false, 49 | requiresNetworkConnectivity: Bool = false 50 | ) -> BackgroundTask { 51 | BackgroundTask( 52 | identifier: identifier, 53 | taskRequestType: .processing( 54 | requiresExternalPower: requiresExternalPower, 55 | requiresNetworkConnectivity: requiresNetworkConnectivity 56 | ) 57 | ) 58 | } 59 | 60 | init(identifier: String, taskRequestType: TaskRequestType) { 61 | self.identifier = identifier 62 | self.taskRequestType = taskRequestType 63 | notification = Notification.Name(identifier) 64 | } 65 | 66 | private var request: BGTaskRequest { 67 | switch taskRequestType { 68 | case .appRefresh: 69 | return BGAppRefreshTaskRequest(identifier: identifier) 70 | case let .processing(requiresExternalPower, requiresNetworkConnectivity): 71 | let request = BGProcessingTaskRequest(identifier: identifier) 72 | request.requiresExternalPower = requiresExternalPower 73 | request.requiresNetworkConnectivity = requiresNetworkConnectivity 74 | return request 75 | } 76 | } 77 | 78 | fileprivate func run() { 79 | let identifier = self.identifier 80 | logger.log("Starting background task: \(identifier, privacy: .public)") 81 | 82 | if let scheduledTask { 83 | let submittedAgo: Duration = UTCClock.Instant(scheduledTask.submittedAt).duration(to: .now) 84 | let earliestBeginAgo: Duration = UTCClock.Instant(scheduledTask.earliestBeginAt).duration( 85 | to: .now) 86 | logger.log( 87 | "Background submitted at \(scheduledTask.submittedAt, privacy: .public), \(submittedAgo, privacy: .public) ago" 88 | ) 89 | logger.log( 90 | "Requested to run after \(scheduledTask.earliestBeginAt, privacy: .public), \(earliestBeginAgo, privacy: .public) after" 91 | ) 92 | } else { 93 | logger.error( 94 | "Running background task, but no scheduled \(identifier, privacy: .public) task noted") 95 | } 96 | lastRanTask = RanTask(task: scheduledTask, ranAt: .now) 97 | 98 | let notification = Notification(name: self.notification, object: self) 99 | logger.log("Posting \(notification.name.rawValue, privacy: .public) notification") 100 | NotificationCenter.default.post(notification) 101 | 102 | logger.log("Finished background task: \(identifier, privacy: .public)") 103 | } 104 | 105 | func cancel() async { 106 | let pendingCount = await countPendingTaskRequests() 107 | guard pendingCount > 0 else { 108 | logger.debug("No scheduled tasks to cancel") 109 | return 110 | } 111 | assert(pendingCount <= 1, "more than one background task was scheduled") 112 | 113 | scheduledTask = nil 114 | let identifier = identifier 115 | logger.info("Canceling \(pendingCount) \(identifier, privacy: .public) task") 116 | BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: identifier) 117 | } 118 | 119 | func reschedule(for beginDate: Date) async { 120 | let earliestBeginAt: Date = await earliestPendingTaskRequestBeginDate() ?? .distantPast 121 | guard beginDate > earliestBeginAt.addingTimeInterval(60) else { 122 | let identifier = identifier 123 | logger.debug("\(identifier, privacy: .public) task already scheduled for \(beginDate, privacy: .public)") 124 | return 125 | } 126 | 127 | await cancel() 128 | schedule(for: beginDate) 129 | } 130 | 131 | func schedule(for beginDate: Date) { 132 | let identifier = identifier 133 | let request = request 134 | 135 | request.earliestBeginDate = beginDate 136 | 137 | do { 138 | try BGTaskScheduler.shared.submit(request) 139 | scheduledTask = ScheduledTask(submittedAt: .now, earliestBeginAt: beginDate) 140 | logger.info("Scheduled \(identifier, privacy: .public) task after \(beginDate, privacy: .public)") 141 | } catch let error as BGTaskScheduler.Error { 142 | switch error.code { 143 | case .unavailable: 144 | #if !targetEnvironment(simulator) 145 | logger.info("App can’t schedule background work") 146 | #endif 147 | case .tooManyPendingTaskRequests: 148 | logger.error("Too many pending \(identifier, privacy: .public) tasks requested") 149 | case .notPermitted: 150 | logger.error("App isn’t permitted to launch \(identifier, privacy: .public) task") 151 | @unknown default: 152 | logger.error( 153 | "Unknown error scheduling \(identifier, privacy: .public) task: \(error, privacy: .public)") 154 | } 155 | } catch { 156 | logger.error("Unknown error scheduling \(identifier, privacy: .public) task: \(error, privacy: .public)") 157 | } 158 | } 159 | 160 | private nonisolated func countPendingTaskRequests() async -> Int { 161 | await BGTaskScheduler.shared.pendingTaskRequests().filter { request in 162 | request.identifier == self.identifier 163 | }.count 164 | } 165 | 166 | private nonisolated func earliestPendingTaskRequestBeginDate() async -> Date? { 167 | await BGTaskScheduler.shared.pendingTaskRequests().compactMap(\.earliestBeginDate).min() 168 | } 169 | } 170 | 171 | @available(macOS, unavailable) 172 | extension Scene { 173 | func backgroundTask(_ task: BackgroundTask) -> some Scene { 174 | backgroundTask(.appRefresh(task.identifier)) { 175 | await task.run() 176 | } 177 | } 178 | } 179 | 180 | #endif 181 | -------------------------------------------------------------------------------- /Aware/visionOS/NotificationName+Nonisolated.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NotificationName+Nonisolated.swift 3 | // Aware 4 | // 5 | // Created by Joshua Peek on 3/24/24. 6 | // 7 | 8 | #if canImport(UIKit) 9 | import UIKit 10 | 11 | // Seems like an SDK bug these notification name constants are marked as isolated to the main actor. 12 | extension UIApplication { 13 | nonisolated static let nonisolatedDidEnterBackgroundNotification = Notification.Name( 14 | "UIApplicationDidEnterBackgroundNotification") 15 | nonisolated static let nonisolatedWillEnterForegroundNotification = Notification.Name( 16 | "UIApplicationWillEnterForegroundNotification") 17 | } 18 | 19 | #endif 20 | -------------------------------------------------------------------------------- /Aware/visionOS/Settings.bundle/Root.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreferenceSpecifiers 6 | 7 | 8 | Type 9 | PSMultiValueSpecifier 10 | Title 11 | Format Style 12 | Key 13 | formatStyle 14 | DefaultValue 15 | condensedAbbreviated 16 | Values 17 | 18 | abbreviated 19 | condensedAbbreviated 20 | narrow 21 | wide 22 | spellOut 23 | digits 24 | 25 | Titles 26 | 27 | 1 hr, 15 min 28 | 1h 15m 29 | 1hr 15min 30 | 1 hour, 15 minutes 31 | one hour, fifteen minutes 32 | 1:15 33 | 34 | 35 | 36 | Type 37 | PSToggleSwitchSpecifier 38 | Title 39 | Show Seconds 40 | Key 41 | showSeconds 42 | DefaultValue 43 | 44 | 45 | 46 | Type 47 | PSToggleSwitchSpecifier 48 | Title 49 | Glass Background 50 | Key 51 | glassBackground 52 | DefaultValue 53 | 54 | 55 | 56 | Type 57 | PSToggleSwitchSpecifier 58 | Title 59 | Reset Timer 60 | Key 61 | reset 62 | DefaultValue 63 | 64 | 65 | 66 | Type 67 | PSGroupSpecifier 68 | Title 69 | Timer Settings 70 | 71 | 72 | Type 73 | PSTextFieldSpecifier 74 | Title 75 | Background Task Interval 76 | Key 77 | backgroundTaskInterval 78 | DefaultValue 79 | 300 80 | KeyboardType 81 | NumberPad 82 | 83 | 84 | Type 85 | PSTextFieldSpecifier 86 | Title 87 | Background Grace Period 88 | Key 89 | backgroundGracePeriod 90 | DefaultValue 91 | 7200 92 | KeyboardType 93 | NumberPad 94 | 95 | 96 | Type 97 | PSTextFieldSpecifier 98 | Title 99 | Lock Grace Period 100 | Key 101 | lockGracePeriod 102 | DefaultValue 103 | 60 104 | KeyboardType 105 | NumberPad 106 | 107 | 108 | Type 109 | PSTextFieldSpecifier 110 | Title 111 | Max Suspending Clock Drift 112 | Key 113 | maxSuspendingClockDrift 114 | DefaultValue 115 | 10 116 | KeyboardType 117 | NumberPad 118 | 119 | 120 | 121 | 122 | -------------------------------------------------------------------------------- /Aware/visionOS/TimerTextView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimerTextView.swift 3 | // Aware 4 | // 5 | // Created by Joshua Peek on 2/19/24. 6 | // 7 | 8 | #if os(visionOS) 9 | 10 | import SwiftUI 11 | 12 | struct TimerTextView: View { 13 | var duration: Duration = .seconds(0) 14 | var format = TimerFormatStyle(style: .condensedAbbreviated, showSeconds: false) 15 | var glassBackground: Bool = true 16 | 17 | var body: some View { 18 | Text(duration, format: format) 19 | .lineLimit(1) 20 | .padding() 21 | .font(.system(size: 900, weight: .medium)) 22 | .minimumScaleFactor(0.01) 23 | .frame(maxWidth: .infinity, maxHeight: .infinity) 24 | .glassBackgroundEffect(displayMode: glassBackground ? .always : .never) 25 | } 26 | } 27 | 28 | #Preview("0m", traits: .fixedLayout(width: 240, height: 135)) { 29 | TimerTextView() 30 | } 31 | 32 | #Preview("15m", traits: .fixedLayout(width: 240, height: 135)) { 33 | TimerTextView(duration: .minutes(15)) 34 | } 35 | 36 | #Preview("1h", traits: .fixedLayout(width: 240, height: 135)) { 37 | TimerTextView(duration: .hours(1)) 38 | } 39 | 40 | #Preview("1h 15m", traits: .fixedLayout(width: 240, height: 135)) { 41 | TimerTextView(duration: .minutes(75)) 42 | } 43 | 44 | #endif 45 | -------------------------------------------------------------------------------- /Aware/visionOS/TimerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimerView.swift 3 | // Aware 4 | // 5 | // Created by Joshua Peek on 2/19/24. 6 | // 7 | 8 | #if os(visionOS) 9 | 10 | import OSLog 11 | import SwiftUI 12 | 13 | private nonisolated(unsafe) let logger = Logger( 14 | subsystem: "com.awaremac.Aware", category: "TimerView" 15 | ) 16 | 17 | struct TimerView: View { 18 | @State private var timerState = TimerState() 19 | 20 | @AppStorage("showSeconds") private var showSeconds: Bool = false 21 | @AppStorage("formatStyle") private var timerFormatStyle: TimerFormatStyle.Style = .condensedAbbreviated 22 | @AppStorage("glassBackground") private var glassBackground: Bool = true 23 | 24 | @AppStorage("backgroundTaskInterval") private var backgroundTaskInterval: Int = 300 25 | @AppStorage("backgroundGracePeriod") private var backgroundGracePeriod: Int = 7200 26 | @AppStorage("lockGracePeriod") private var lockGracePeriod: Int = 60 27 | @AppStorage("maxSuspendingClockDrift") private var maxSuspendingClockDrift: Int = 10 28 | 29 | private var timerFormat: TimerFormatStyle { 30 | TimerFormatStyle(style: timerFormatStyle, showSeconds: showSeconds) 31 | } 32 | 33 | private var activityMonitorConfiguration: ActivityMonitor.Configuration { 34 | ActivityMonitor.Configuration( 35 | backgroundTaskInterval: .seconds(backgroundTaskInterval), 36 | backgroundGracePeriod: .seconds(backgroundGracePeriod), 37 | lockGracePeriod: .seconds(lockGracePeriod), 38 | maxSuspendingClockDrift: .seconds(maxSuspendingClockDrift) 39 | ) 40 | } 41 | 42 | var body: some View { 43 | Group { 44 | if let start = timerState.start { 45 | TimelineView(.periodic(from: start.date, by: timerFormat.refreshInterval)) { context in 46 | let duration = timerState.duration(to: UTCClock.Instant(context.date)) 47 | TimerTextView(duration: duration, format: timerFormat, glassBackground: glassBackground) 48 | } 49 | } else { 50 | TimerTextView(duration: .zero, format: timerFormat, glassBackground: glassBackground) 51 | } 52 | } 53 | .task(id: activityMonitorConfiguration) { 54 | let activityMonitor = ActivityMonitor(initialState: timerState, configuration: activityMonitorConfiguration) 55 | logger.log("Starting ActivityMonitor updates task: \(timerState, privacy: .public)") 56 | for await state in activityMonitor.updates() { 57 | logger.log("Received ActivityMonitor state: \(state, privacy: .public)") 58 | timerState = state 59 | } 60 | logger.log("Finished ActivityMonitor updates task: \(timerState, privacy: .public)") 61 | } 62 | } 63 | } 64 | 65 | #Preview(traits: .fixedLayout(width: 200, height: 100)) { 66 | TimerView() 67 | } 68 | 69 | #endif 70 | -------------------------------------------------------------------------------- /Aware/visionOS/TimerWindow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimerWindow.swift 3 | // Aware 4 | // 5 | // Created by Joshua Peek on 3/9/24. 6 | // 7 | 8 | #if os(visionOS) 9 | 10 | import OSLog 11 | import SwiftUI 12 | 13 | private nonisolated(unsafe) let logger = Logger( 14 | subsystem: "com.awaremac.Aware", category: "TimerWindow" 15 | ) 16 | 17 | struct TimerWindow: Scene { 18 | var body: some Scene { 19 | WindowGroup { 20 | TimerView() 21 | } 22 | .defaultSize(width: 240, height: 135) 23 | .windowResizability(.contentSize) 24 | .windowStyle(.plain) 25 | .backgroundTask(fetchActivityMonitorTask) 26 | .backgroundTask(processingActivityMonitorTask) 27 | } 28 | } 29 | 30 | #endif 31 | -------------------------------------------------------------------------------- /AwareTests/AsyncStreamTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import Aware 4 | 5 | final class AsyncStreamTests: XCTestCase { 6 | func testSimpleAsyncStream() async throws { 7 | @Sendable func answer() async -> Int { 8 | 42 9 | } 10 | 11 | let stream = AsyncStream { [answer] yield in 12 | let n = await answer() 13 | yield(n) 14 | yield(n + 1) 15 | yield(n + 2) 16 | } 17 | 18 | var numbers: [Int] = [] 19 | for await n in stream { 20 | numbers.append(n) 21 | } 22 | 23 | XCTAssertEqual(numbers, [42, 43, 44]) 24 | } 25 | 26 | func testMapAsyncStream() async throws { 27 | let stream1 = AsyncStream { continuation in 28 | continuation.yield(1) 29 | continuation.yield(2) 30 | continuation.yield(3) 31 | continuation.finish() 32 | } 33 | 34 | let stream2 = AsyncStream { yield in 35 | for await n in stream1 { 36 | yield(n * 2) 37 | } 38 | } 39 | 40 | var numbers: [Int] = [] 41 | for await n in stream2 { 42 | numbers.append(n) 43 | } 44 | 45 | XCTAssertEqual(numbers, [2, 4, 6]) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /AwareTests/NotificationCenterTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import Aware 4 | 5 | final class NotificationCenterTests: XCTestCase { 6 | let center = NotificationCenter.default 7 | let fooNotification = Notification.Name("fooNotification") 8 | let barNotification = Notification.Name("barNotification") 9 | let userInfo = ["message": "Hello, world!"] 10 | 11 | func testSendableObserver() { 12 | let expectation = expectation(description: "fooNotification") 13 | 14 | let observer = center.observe(for: fooNotification) { [userInfo] notification in 15 | XCTAssertEqual(notification.name.rawValue, "fooNotification") 16 | XCTAssertEqual(notification.userInfo as? [String: String], userInfo) 17 | expectation.fulfill() 18 | } 19 | 20 | center.post(name: fooNotification, object: nil, userInfo: userInfo) 21 | center.post(name: barNotification, object: nil, userInfo: userInfo) 22 | wait(for: [expectation], timeout: 1.0) 23 | 24 | observer.cancel() 25 | } 26 | 27 | func testMergeNotifications() { 28 | let fooExpectation = expectation(description: "fooNotification") 29 | let barExpectation = expectation(description: "barNotification") 30 | 31 | let consumerTask = Task { [center, fooNotification, barNotification] in 32 | for await notification in center.mergeNotifications(named: [fooNotification, barNotification]) { 33 | if notification.name.rawValue == "fooNotification" { 34 | fooExpectation.fulfill() 35 | } 36 | if notification.name.rawValue == "barNotification" { 37 | barExpectation.fulfill() 38 | } 39 | } 40 | } 41 | 42 | let producerTask = Task { [center, fooNotification, barNotification, userInfo] in 43 | try? await Task.sleep(for: .milliseconds(100)) 44 | center.post(name: fooNotification, object: nil, userInfo: userInfo) 45 | center.post(name: barNotification, object: nil, userInfo: userInfo) 46 | } 47 | 48 | wait(for: [fooExpectation, barExpectation], timeout: 1.0) 49 | consumerTask.cancel() 50 | producerTask.cancel() 51 | } 52 | 53 | func testPostBeforeSubscriptionDropped() { 54 | let expectation = expectation(description: "fooNotification") 55 | 56 | for _ in 1 ... 5 { 57 | center.post(name: fooNotification, object: nil, userInfo: ["message": "Goodbye, world!"]) 58 | } 59 | 60 | let consumerTask = Task { [center, fooNotification, userInfo] in 61 | for await notification in center.notifications(named: fooNotification) { 62 | XCTAssertEqual(notification.userInfo as? [String: String], userInfo) 63 | break 64 | } 65 | expectation.fulfill() 66 | } 67 | 68 | let producerTask = Task { [center, fooNotification, userInfo] in 69 | try? await Task.sleep(for: .milliseconds(100)) 70 | center.post(name: fooNotification, object: nil, userInfo: userInfo) 71 | } 72 | 73 | wait(for: [expectation], timeout: 1.0) 74 | consumerTask.cancel() 75 | producerTask.cancel() 76 | } 77 | 78 | func testSingleBufferingPolicy() { 79 | let notifications = center.notifications(named: fooNotification) 80 | 81 | for i in 1 ... 10 { 82 | center.post(name: fooNotification, object: nil, userInfo: ["count": i]) 83 | } 84 | 85 | let expectation = expectation(description: "fooNotification") 86 | 87 | let consumerTask = Task { 88 | let iterator = notifications.makeAsyncIterator() 89 | 90 | var total = 0 91 | for i in 4 ... 10 { 92 | let notification = await iterator.next() 93 | XCTAssertNotNil(notification) 94 | XCTAssertEqual(notification?.userInfo as? [String: Int], ["count": i]) 95 | total += 1 96 | } 97 | XCTAssertEqual(total, 7) 98 | 99 | expectation.fulfill() 100 | } 101 | 102 | wait(for: [expectation], timeout: 1.0) 103 | consumerTask.cancel() 104 | } 105 | 106 | func testMultipleBufferingPolicy() { 107 | let notifications = center.mergeNotifications(named: [fooNotification]) 108 | 109 | for i in 1 ... 10 { 110 | center.post(name: fooNotification, object: nil, userInfo: ["count": i]) 111 | } 112 | 113 | let expectation = expectation(description: "fooNotification") 114 | 115 | let consumerTask = Task { 116 | var iterator = notifications.makeAsyncIterator() 117 | 118 | var total = 0 119 | for i in 4 ... 10 { 120 | let notification = await iterator.next() 121 | XCTAssertNotNil(notification) 122 | XCTAssertEqual(notification?.userInfo as? [String: Int], ["count": i]) 123 | total += 1 124 | } 125 | XCTAssertEqual(total, 7) 126 | 127 | expectation.fulfill() 128 | } 129 | 130 | wait(for: [expectation], timeout: 1.0) 131 | consumerTask.cancel() 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /AwareTests/TimerFormatStyleTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import Aware 4 | 5 | final class TimerFormatStyleTests: XCTestCase { 6 | func testAbbreviatedWithoutSeconds() { 7 | let format = TimerFormatStyle(style: .abbreviated, showSeconds: false) 8 | XCTAssertEqual(format.format(.zero), "0 min") 9 | 10 | XCTAssertEqual(format.format(.seconds(1)), "0 min") 11 | XCTAssertEqual(format.format(.seconds(59)), "0 min") 12 | XCTAssertEqual(format.format(.minutes(1)), "1 min") 13 | XCTAssertEqual(format.format(.seconds(119)), "1 min") 14 | XCTAssertEqual(format.format(.seconds(61)), "1 min") 15 | XCTAssertEqual(format.format(.minutes(15)), "15 min") 16 | XCTAssertEqual(format.format(.minutes(59)), "59 min") 17 | XCTAssertEqual(format.format(.seconds(3599)), "59 min") 18 | 19 | XCTAssertEqual(format.format(.hours(1)), "1 hr") 20 | XCTAssertEqual(format.format(.seconds(3661)), "1 hr, 1 min") 21 | XCTAssertEqual(format.format(.seconds(4500)), "1 hr, 15 min") 22 | XCTAssertEqual(format.format(.seconds(7200)), "2 hr") 23 | 24 | XCTAssertEqual(format.format(.seconds(-1)), "0 min") 25 | XCTAssertEqual(format.format(.seconds(-90)), "0 min") 26 | XCTAssertEqual(format.format(.hours(-1)), "0 min") 27 | 28 | XCTAssertEqual(format.format(.seconds(Int.max)), "0 min") 29 | XCTAssertEqual(format.format(.seconds(Int.min)), "0 min") 30 | } 31 | 32 | func testAbbreviatedWithSeconds() { 33 | let format = TimerFormatStyle(style: .abbreviated, showSeconds: true) 34 | XCTAssertEqual(format.format(.zero), "0 sec") 35 | 36 | XCTAssertEqual(format.format(.seconds(1)), "1 sec") 37 | XCTAssertEqual(format.format(.seconds(59)), "59 sec") 38 | 39 | XCTAssertEqual(format.format(.minutes(1)), "1 min") 40 | XCTAssertEqual(format.format(.seconds(119)), "1 min, 59 sec") 41 | XCTAssertEqual(format.format(.seconds(61)), "1 min, 1 sec") 42 | XCTAssertEqual(format.format(.minutes(15)), "15 min") 43 | XCTAssertEqual(format.format(.minutes(59)), "59 min") 44 | XCTAssertEqual(format.format(.seconds(3599)), "59 min, 59 sec") 45 | 46 | XCTAssertEqual(format.format(.hours(1)), "1 hr") 47 | XCTAssertEqual(format.format(.seconds(3661)), "1 hr, 1 min, 1 sec") 48 | XCTAssertEqual(format.format(.seconds(4500)), "1 hr, 15 min") 49 | XCTAssertEqual(format.format(.seconds(7200)), "2 hr") 50 | 51 | XCTAssertEqual(format.format(.seconds(-1)), "0 sec") 52 | XCTAssertEqual(format.format(.seconds(-90)), "0 sec") 53 | XCTAssertEqual(format.format(.hours(-1)), "0 sec") 54 | 55 | XCTAssertEqual(format.format(.seconds(Int.max)), "0 sec") 56 | XCTAssertEqual(format.format(.seconds(Int.min)), "0 sec") 57 | } 58 | 59 | func testCondensedAbbreviatedWithoutSeconds() { 60 | let format = TimerFormatStyle(style: .condensedAbbreviated, showSeconds: false) 61 | XCTAssertEqual(format.format(.zero), "0m") 62 | 63 | XCTAssertEqual(format.format(.seconds(1)), "0m") 64 | XCTAssertEqual(format.format(.seconds(59)), "0m") 65 | XCTAssertEqual(format.format(.minutes(1)), "1m") 66 | XCTAssertEqual(format.format(.seconds(119)), "1m") 67 | XCTAssertEqual(format.format(.seconds(61)), "1m") 68 | XCTAssertEqual(format.format(.minutes(15)), "15m") 69 | XCTAssertEqual(format.format(.minutes(59)), "59m") 70 | XCTAssertEqual(format.format(.seconds(3599)), "59m") 71 | 72 | XCTAssertEqual(format.format(.hours(1)), "1h") 73 | XCTAssertEqual(format.format(.seconds(3661)), "1h 1m") 74 | XCTAssertEqual(format.format(.seconds(4500)), "1h 15m") 75 | XCTAssertEqual(format.format(.seconds(7200)), "2h") 76 | 77 | XCTAssertEqual(format.format(.seconds(-1)), "0m") 78 | XCTAssertEqual(format.format(.seconds(-90)), "0m") 79 | XCTAssertEqual(format.format(.hours(-1)), "0m") 80 | 81 | XCTAssertEqual(format.format(.seconds(Int.max)), "0m") 82 | XCTAssertEqual(format.format(.seconds(Int.min)), "0m") 83 | } 84 | 85 | func testCondensedAbbreviatedWithSeconds() { 86 | let format = TimerFormatStyle(style: .condensedAbbreviated, showSeconds: true) 87 | XCTAssertEqual(format.format(.zero), "0s") 88 | 89 | XCTAssertEqual(format.format(.seconds(1)), "1s") 90 | XCTAssertEqual(format.format(.seconds(59)), "59s") 91 | 92 | XCTAssertEqual(format.format(.minutes(1)), "1m") 93 | XCTAssertEqual(format.format(.seconds(119)), "1m 59s") 94 | XCTAssertEqual(format.format(.seconds(61)), "1m 1s") 95 | XCTAssertEqual(format.format(.minutes(15)), "15m") 96 | XCTAssertEqual(format.format(.minutes(59)), "59m") 97 | XCTAssertEqual(format.format(.seconds(3599)), "59m 59s") 98 | 99 | XCTAssertEqual(format.format(.hours(1)), "1h") 100 | XCTAssertEqual(format.format(.seconds(3661)), "1h 1m 1s") 101 | XCTAssertEqual(format.format(.seconds(4500)), "1h 15m") 102 | XCTAssertEqual(format.format(.seconds(7200)), "2h") 103 | 104 | XCTAssertEqual(format.format(.seconds(-1)), "0s") 105 | XCTAssertEqual(format.format(.seconds(-90)), "0s") 106 | XCTAssertEqual(format.format(.hours(-1)), "0s") 107 | 108 | XCTAssertEqual(format.format(.seconds(Int.max)), "0s") 109 | XCTAssertEqual(format.format(.seconds(Int.min)), "0s") 110 | } 111 | 112 | func testNarrowWithoutSeconds() { 113 | let format = TimerFormatStyle(style: .narrow, showSeconds: false) 114 | XCTAssertEqual(format.format(.zero), "0min") 115 | 116 | XCTAssertEqual(format.format(.seconds(1)), "0min") 117 | XCTAssertEqual(format.format(.seconds(59)), "0min") 118 | XCTAssertEqual(format.format(.minutes(1)), "1min") 119 | XCTAssertEqual(format.format(.seconds(119)), "1min") 120 | XCTAssertEqual(format.format(.seconds(61)), "1min") 121 | XCTAssertEqual(format.format(.minutes(15)), "15min") 122 | XCTAssertEqual(format.format(.minutes(59)), "59min") 123 | XCTAssertEqual(format.format(.seconds(3599)), "59min") 124 | 125 | XCTAssertEqual(format.format(.hours(1)), "1hr") 126 | XCTAssertEqual(format.format(.seconds(3661)), "1hr 1min") 127 | XCTAssertEqual(format.format(.seconds(4500)), "1hr 15min") 128 | XCTAssertEqual(format.format(.seconds(7200)), "2hr") 129 | 130 | XCTAssertEqual(format.format(.seconds(-1)), "0min") 131 | XCTAssertEqual(format.format(.seconds(-90)), "0min") 132 | XCTAssertEqual(format.format(.hours(-1)), "0min") 133 | 134 | XCTAssertEqual(format.format(.seconds(Int.max)), "0min") 135 | XCTAssertEqual(format.format(.seconds(Int.min)), "0min") 136 | } 137 | 138 | func testNarrowWithSeconds() { 139 | let format = TimerFormatStyle(style: .narrow, showSeconds: true) 140 | XCTAssertEqual(format.format(.zero), "0sec") 141 | 142 | XCTAssertEqual(format.format(.seconds(1)), "1sec") 143 | XCTAssertEqual(format.format(.seconds(59)), "59sec") 144 | 145 | XCTAssertEqual(format.format(.minutes(1)), "1min") 146 | XCTAssertEqual(format.format(.seconds(119)), "1min 59sec") 147 | XCTAssertEqual(format.format(.seconds(61)), "1min 1sec") 148 | XCTAssertEqual(format.format(.minutes(15)), "15min") 149 | XCTAssertEqual(format.format(.minutes(59)), "59min") 150 | XCTAssertEqual(format.format(.seconds(3599)), "59min 59sec") 151 | 152 | XCTAssertEqual(format.format(.hours(1)), "1hr") 153 | XCTAssertEqual(format.format(.seconds(3661)), "1hr 1min 1sec") 154 | XCTAssertEqual(format.format(.seconds(4500)), "1hr 15min") 155 | XCTAssertEqual(format.format(.seconds(7200)), "2hr") 156 | 157 | XCTAssertEqual(format.format(.seconds(-1)), "0sec") 158 | XCTAssertEqual(format.format(.seconds(-90)), "0sec") 159 | XCTAssertEqual(format.format(.hours(-1)), "0sec") 160 | 161 | XCTAssertEqual(format.format(.seconds(Int.max)), "0sec") 162 | XCTAssertEqual(format.format(.seconds(Int.min)), "0sec") 163 | } 164 | 165 | func testWideWithoutSeconds() { 166 | let format = TimerFormatStyle(style: .wide, showSeconds: false) 167 | XCTAssertEqual(format.format(.zero), "0 minutes") 168 | 169 | XCTAssertEqual(format.format(.seconds(1)), "0 minutes") 170 | XCTAssertEqual(format.format(.seconds(59)), "0 minutes") 171 | XCTAssertEqual(format.format(.minutes(1)), "1 minute") 172 | XCTAssertEqual(format.format(.seconds(119)), "1 minute") 173 | XCTAssertEqual(format.format(.seconds(61)), "1 minute") 174 | XCTAssertEqual(format.format(.minutes(15)), "15 minutes") 175 | XCTAssertEqual(format.format(.minutes(59)), "59 minutes") 176 | XCTAssertEqual(format.format(.seconds(3599)), "59 minutes") 177 | 178 | XCTAssertEqual(format.format(.hours(1)), "1 hour") 179 | XCTAssertEqual(format.format(.seconds(3661)), "1 hour, 1 minute") 180 | XCTAssertEqual(format.format(.seconds(4500)), "1 hour, 15 minutes") 181 | XCTAssertEqual(format.format(.seconds(7200)), "2 hours") 182 | 183 | XCTAssertEqual(format.format(.seconds(-1)), "0 minutes") 184 | XCTAssertEqual(format.format(.seconds(-90)), "0 minutes") 185 | XCTAssertEqual(format.format(.hours(-1)), "0 minutes") 186 | 187 | XCTAssertEqual(format.format(.seconds(Int.max)), "0 minutes") 188 | XCTAssertEqual(format.format(.seconds(Int.min)), "0 minutes") 189 | } 190 | 191 | func testWideWithSeconds() { 192 | let format = TimerFormatStyle(style: .wide, showSeconds: true) 193 | XCTAssertEqual(format.format(.zero), "0 seconds") 194 | 195 | XCTAssertEqual(format.format(.seconds(1)), "1 second") 196 | XCTAssertEqual(format.format(.seconds(59)), "59 seconds") 197 | 198 | XCTAssertEqual(format.format(.minutes(1)), "1 minute") 199 | XCTAssertEqual(format.format(.seconds(119)), "1 minute, 59 seconds") 200 | XCTAssertEqual(format.format(.seconds(61)), "1 minute, 1 second") 201 | XCTAssertEqual(format.format(.minutes(15)), "15 minutes") 202 | XCTAssertEqual(format.format(.minutes(59)), "59 minutes") 203 | XCTAssertEqual(format.format(.seconds(3599)), "59 minutes, 59 seconds") 204 | 205 | XCTAssertEqual(format.format(.hours(1)), "1 hour") 206 | XCTAssertEqual(format.format(.seconds(3661)), "1 hour, 1 minute, 1 second") 207 | XCTAssertEqual(format.format(.seconds(4500)), "1 hour, 15 minutes") 208 | XCTAssertEqual(format.format(.seconds(7200)), "2 hours") 209 | 210 | XCTAssertEqual(format.format(.seconds(-1)), "0 seconds") 211 | XCTAssertEqual(format.format(.seconds(-90)), "0 seconds") 212 | XCTAssertEqual(format.format(.hours(-1)), "0 seconds") 213 | 214 | XCTAssertEqual(format.format(.seconds(Int.max)), "0 seconds") 215 | XCTAssertEqual(format.format(.seconds(Int.min)), "0 seconds") 216 | } 217 | 218 | func testSpellOutWithoutSeconds() { 219 | let format = TimerFormatStyle(style: .spellOut, showSeconds: false) 220 | XCTAssertEqual(format.format(.zero), "zero minutes") 221 | 222 | XCTAssertEqual(format.format(.seconds(1)), "zero minutes") 223 | XCTAssertEqual(format.format(.seconds(59)), "zero minutes") 224 | XCTAssertEqual(format.format(.minutes(1)), "one minute") 225 | XCTAssertEqual(format.format(.seconds(119)), "one minute") 226 | XCTAssertEqual(format.format(.seconds(61)), "one minute") 227 | XCTAssertEqual(format.format(.minutes(15)), "fifteen minutes") 228 | XCTAssertEqual(format.format(.minutes(59)), "fifty-nine minutes") 229 | XCTAssertEqual(format.format(.seconds(3599)), "fifty-nine minutes") 230 | 231 | XCTAssertEqual(format.format(.hours(1)), "one hour") 232 | XCTAssertEqual(format.format(.seconds(3661)), "one hour, one minute") 233 | XCTAssertEqual(format.format(.seconds(4500)), "one hour, fifteen minutes") 234 | XCTAssertEqual(format.format(.seconds(7200)), "two hours") 235 | 236 | XCTAssertEqual(format.format(.seconds(-1)), "zero minutes") 237 | XCTAssertEqual(format.format(.seconds(-90)), "zero minutes") 238 | XCTAssertEqual(format.format(.hours(-1)), "zero minutes") 239 | 240 | XCTAssertEqual(format.format(.seconds(Int.max)), "zero minutes") 241 | XCTAssertEqual(format.format(.seconds(Int.min)), "zero minutes") 242 | } 243 | 244 | func testSpellOutWithSeconds() { 245 | let format = TimerFormatStyle(style: .spellOut, showSeconds: true) 246 | XCTAssertEqual(format.format(.zero), "zero seconds") 247 | 248 | XCTAssertEqual(format.format(.seconds(1)), "one second") 249 | XCTAssertEqual(format.format(.seconds(59)), "fifty-nine seconds") 250 | 251 | XCTAssertEqual(format.format(.minutes(1)), "one minute") 252 | XCTAssertEqual(format.format(.seconds(119)), "one minute, fifty-nine seconds") 253 | XCTAssertEqual(format.format(.seconds(61)), "one minute, one second") 254 | XCTAssertEqual(format.format(.minutes(15)), "fifteen minutes") 255 | XCTAssertEqual(format.format(.minutes(59)), "fifty-nine minutes") 256 | XCTAssertEqual(format.format(.seconds(3599)), "fifty-nine minutes, fifty-nine seconds") 257 | 258 | XCTAssertEqual(format.format(.hours(1)), "one hour") 259 | XCTAssertEqual(format.format(.seconds(3661)), "one hour, one minute, one second") 260 | XCTAssertEqual(format.format(.seconds(4500)), "one hour, fifteen minutes") 261 | XCTAssertEqual(format.format(.seconds(7200)), "two hours") 262 | 263 | XCTAssertEqual(format.format(.seconds(-1)), "zero seconds") 264 | XCTAssertEqual(format.format(.seconds(-90)), "zero seconds") 265 | XCTAssertEqual(format.format(.hours(-1)), "zero seconds") 266 | 267 | XCTAssertEqual(format.format(.seconds(Int.max)), "zero seconds") 268 | XCTAssertEqual(format.format(.seconds(Int.min)), "zero seconds") 269 | } 270 | 271 | func testDigitsWithoutSeconds() { 272 | let format = TimerFormatStyle(style: .digits, showSeconds: false) 273 | XCTAssertEqual(format.format(.zero), "0:00") 274 | 275 | XCTAssertEqual(format.format(.seconds(1)), "0:00") 276 | XCTAssertEqual(format.format(.seconds(59)), "0:00") 277 | XCTAssertEqual(format.format(.minutes(1)), "0:01") 278 | XCTAssertEqual(format.format(.seconds(119)), "0:01") 279 | XCTAssertEqual(format.format(.seconds(61)), "0:01") 280 | XCTAssertEqual(format.format(.minutes(15)), "0:15") 281 | XCTAssertEqual(format.format(.minutes(59)), "0:59") 282 | XCTAssertEqual(format.format(.seconds(3599)), "0:59") 283 | 284 | XCTAssertEqual(format.format(.hours(1)), "1:00") 285 | XCTAssertEqual(format.format(.seconds(3661)), "1:01") 286 | XCTAssertEqual(format.format(.seconds(4500)), "1:15") 287 | XCTAssertEqual(format.format(.seconds(7200)), "2:00") 288 | 289 | XCTAssertEqual(format.format(.seconds(-1)), "0:00") 290 | XCTAssertEqual(format.format(.seconds(-90)), "0:00") 291 | XCTAssertEqual(format.format(.hours(-1)), "0:00") 292 | 293 | XCTAssertEqual(format.format(.seconds(Int.max)), "2,562,047,788,015,215:30") 294 | XCTAssertEqual(format.format(.seconds(Int.min)), "0:00") 295 | } 296 | 297 | func testDigitsWithSeconds() { 298 | let format = TimerFormatStyle(style: .digits, showSeconds: true) 299 | XCTAssertEqual(format.format(.zero), "0:00:00") 300 | 301 | XCTAssertEqual(format.format(.seconds(1)), "0:00:01") 302 | XCTAssertEqual(format.format(.seconds(59)), "0:00:59") 303 | 304 | XCTAssertEqual(format.format(.minutes(1)), "0:01:00") 305 | XCTAssertEqual(format.format(.seconds(119)), "0:01:59") 306 | XCTAssertEqual(format.format(.seconds(61)), "0:01:01") 307 | XCTAssertEqual(format.format(.minutes(15)), "0:15:00") 308 | XCTAssertEqual(format.format(.minutes(59)), "0:59:00") 309 | XCTAssertEqual(format.format(.seconds(3599)), "0:59:59") 310 | 311 | XCTAssertEqual(format.format(.hours(1)), "1:00:00") 312 | XCTAssertEqual(format.format(.seconds(3661)), "1:01:01") 313 | XCTAssertEqual(format.format(.seconds(4500)), "1:15:00") 314 | XCTAssertEqual(format.format(.seconds(7200)), "2:00:00") 315 | 316 | XCTAssertEqual(format.format(.seconds(-1)), "0:00:00") 317 | XCTAssertEqual(format.format(.seconds(-90)), "0:00:00") 318 | XCTAssertEqual(format.format(.hours(-1)), "0:00:00") 319 | 320 | XCTAssertEqual(format.format(.seconds(Int.max)), "2,562,047,788,015,215:30:07") 321 | XCTAssertEqual(format.format(.seconds(Int.min)), "0:00:00") 322 | } 323 | } 324 | -------------------------------------------------------------------------------- /AwareTests/TimerStateTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import Aware 4 | 5 | final class PausedClock: @unchecked Sendable, Clock { 6 | var now: Instant 7 | init() { now = .now } 8 | 9 | typealias Instant = ContinuousClock.Instant 10 | var minimumResolution: Duration { ContinuousClock().minimumResolution } 11 | func sleep(until _: Instant, tolerance _: Duration?) async throws {} 12 | 13 | func advance(by duration: Duration) { 14 | now = now.advanced(by: duration) 15 | } 16 | } 17 | 18 | final class TimerStateTests: XCTestCase { 19 | let clock: PausedClock = .init() 20 | 21 | func testIsActive() { 22 | var timer: TimerState 23 | 24 | timer = TimerState(clock: clock) 25 | XCTAssertFalse(timer.isActive) 26 | 27 | let start = clock.now.advanced(by: .seconds(-30)) 28 | timer = TimerState(since: start, clock: clock) 29 | XCTAssertTrue(timer.isActive) 30 | 31 | timer = TimerState(since: start, until: clock.now.advanced(by: .seconds(150)), clock: clock) 32 | XCTAssertTrue(timer.isActive) 33 | 34 | timer = TimerState(since: start, until: clock.now.advanced(by: .seconds(30)), clock: clock) 35 | clock.advance(by: .seconds(60)) 36 | XCTAssertFalse(timer.isActive) 37 | } 38 | 39 | func testIsIdle() { 40 | var timer: TimerState 41 | 42 | timer = TimerState(clock: clock) 43 | XCTAssertTrue(timer.isIdle) 44 | 45 | let start = clock.now.advanced(by: .seconds(-30)) 46 | timer = TimerState(since: start, clock: clock) 47 | XCTAssertFalse(timer.isIdle) 48 | 49 | timer = TimerState(since: start, until: clock.now.advanced(by: .seconds(150)), clock: clock) 50 | XCTAssertFalse(timer.isIdle) 51 | 52 | timer = TimerState(since: start, until: clock.now.advanced(by: .seconds(30)), clock: clock) 53 | clock.advance(by: .seconds(60)) 54 | XCTAssertTrue(timer.isIdle) 55 | } 56 | 57 | func testStart() { 58 | var timer: TimerState 59 | 60 | timer = TimerState(clock: clock) 61 | XCTAssertNil(timer.start) 62 | 63 | let start = clock.now.advanced(by: .seconds(-30)) 64 | timer = TimerState(since: start, clock: clock) 65 | XCTAssertEqual(timer.start, start) 66 | 67 | timer = TimerState(since: start, until: clock.now.advanced(by: .seconds(150)), clock: clock) 68 | XCTAssertEqual(timer.start, start) 69 | 70 | timer = TimerState(since: start, until: clock.now.advanced(by: .seconds(30)), clock: clock) 71 | clock.advance(by: .seconds(60)) 72 | XCTAssertNil(timer.start) 73 | } 74 | 75 | func testExpires() { 76 | var timer: TimerState 77 | 78 | timer = TimerState(clock: clock) 79 | XCTAssertNil(timer.expires) 80 | 81 | let start = clock.now.advanced(by: .seconds(-30)) 82 | timer = TimerState(since: start, clock: clock) 83 | XCTAssertNil(timer.expires) 84 | 85 | let expires = clock.now.advanced(by: .seconds(150)) 86 | timer = TimerState(since: start, until: expires, clock: clock) 87 | XCTAssertEqual(timer.expires, expires) 88 | 89 | timer = TimerState(since: start, until: clock.now.advanced(by: .seconds(30)), clock: clock) 90 | clock.advance(by: .seconds(60)) 91 | XCTAssertNil(timer.expires) 92 | } 93 | 94 | func testDuration() { 95 | var timer: TimerState 96 | 97 | timer = TimerState(clock: clock) 98 | XCTAssertEqual(timer.duration(to: clock.now), .seconds(0)) 99 | 100 | let start = clock.now.advanced(by: .seconds(-30)) 101 | timer = TimerState(since: start, clock: clock) 102 | XCTAssertEqual(timer.duration(to: clock.now), .seconds(30)) 103 | 104 | let expires = clock.now.advanced(by: .seconds(150)) 105 | timer = TimerState(since: start, until: expires, clock: clock) 106 | XCTAssertEqual(timer.duration(to: clock.now), .seconds(30)) 107 | 108 | timer = TimerState(since: start, until: clock.now.advanced(by: .seconds(30)), clock: clock) 109 | clock.advance(by: .seconds(60)) 110 | XCTAssertEqual(timer.duration(to: clock.now), .seconds(0)) 111 | } 112 | 113 | func testDeactivate() { 114 | var timer: TimerState 115 | 116 | timer = TimerState(clock: clock) 117 | timer.deactivate() 118 | XCTAssertEqual(String(describing: timer), "idle") 119 | 120 | timer = TimerState(since: clock.now.advanced(by: .seconds(-300)), clock: clock) 121 | timer.deactivate() 122 | XCTAssertEqual(String(describing: timer), "idle") 123 | 124 | timer = TimerState( 125 | since: clock.now.advanced(by: .seconds(-300)), until: clock.now.advanced(by: .seconds(30)), 126 | clock: clock 127 | ) 128 | timer.deactivate() 129 | XCTAssertEqual(String(describing: timer), "idle") 130 | 131 | timer = TimerState( 132 | since: clock.now.advanced(by: .seconds(-300)), until: clock.now.advanced(by: .seconds(30)), 133 | clock: clock 134 | ) 135 | clock.advance(by: .seconds(60)) 136 | timer.deactivate() 137 | XCTAssertEqual(String(describing: timer), "idle") 138 | } 139 | 140 | func testActivate() { 141 | var timer: TimerState 142 | 143 | timer = TimerState(clock: clock) 144 | timer.activate() 145 | XCTAssertEqual(String(describing: timer), "active[0:00:00]") 146 | 147 | timer = TimerState(since: clock.now.advanced(by: .seconds(-300)), clock: clock) 148 | timer.activate() 149 | XCTAssertEqual(String(describing: timer), "active[0:05:00]") 150 | 151 | timer = TimerState( 152 | since: clock.now.advanced(by: .seconds(-300)), until: clock.now.advanced(by: .seconds(30)), 153 | clock: clock 154 | ) 155 | timer.activate() 156 | XCTAssertEqual(String(describing: timer), "active[0:05:00]") 157 | 158 | timer = TimerState( 159 | since: clock.now.advanced(by: .seconds(-300)), until: clock.now.advanced(by: .seconds(30)), 160 | clock: clock 161 | ) 162 | clock.advance(by: .seconds(60)) 163 | timer.activate() 164 | XCTAssertEqual(String(describing: timer), "active[0:00:00]") 165 | } 166 | 167 | func testActivateUntil() { 168 | var timer: TimerState 169 | 170 | timer = TimerState(clock: clock) 171 | timer.activate(until: clock.now.advanced(by: .seconds(60))) 172 | XCTAssertEqual(String(describing: timer), "active[0:00:00, expires in 0:01:00]") 173 | 174 | timer = TimerState(since: clock.now.advanced(by: .seconds(-300)), clock: clock) 175 | timer.activate(until: clock.now.advanced(by: .seconds(60))) 176 | XCTAssertEqual(String(describing: timer), "active[0:05:00, expires in 0:01:00]") 177 | 178 | timer = TimerState( 179 | since: clock.now.advanced(by: .seconds(-300)), until: clock.now.advanced(by: .seconds(30)), 180 | clock: clock 181 | ) 182 | timer.activate(until: clock.now.advanced(by: .seconds(60))) 183 | XCTAssertEqual(String(describing: timer), "active[0:05:00, expires in 0:01:00]") 184 | 185 | timer = TimerState( 186 | since: clock.now.advanced(by: .seconds(-300)), until: clock.now.advanced(by: .seconds(30)), 187 | clock: clock 188 | ) 189 | clock.advance(by: .seconds(60)) 190 | timer.activate(until: clock.now.advanced(by: .seconds(60))) 191 | XCTAssertEqual(String(describing: timer), "active[0:00:00, expires in 0:01:00]") 192 | } 193 | 194 | func testActivateFor() { 195 | var timer: TimerState 196 | 197 | timer = TimerState(clock: clock) 198 | timer.activate(for: .seconds(60)) 199 | XCTAssertEqual(String(describing: timer), "active[0:00:00, expires in 0:01:00]") 200 | 201 | timer = TimerState(since: clock.now.advanced(by: .seconds(-300)), clock: clock) 202 | timer.activate(for: .seconds(60)) 203 | XCTAssertEqual(String(describing: timer), "active[0:05:00, expires in 0:01:00]") 204 | 205 | timer = TimerState( 206 | since: clock.now.advanced(by: .seconds(-300)), until: clock.now.advanced(by: .seconds(30)), 207 | clock: clock 208 | ) 209 | timer.activate(for: .seconds(60)) 210 | XCTAssertEqual(String(describing: timer), "active[0:05:00, expires in 0:01:00]") 211 | 212 | timer = TimerState( 213 | since: clock.now.advanced(by: .seconds(-300)), until: clock.now.advanced(by: .seconds(30)), 214 | clock: clock 215 | ) 216 | clock.advance(by: .seconds(60)) 217 | timer.activate(for: .seconds(60)) 218 | XCTAssertEqual(String(describing: timer), "active[0:00:00, expires in 0:01:00]") 219 | } 220 | 221 | func testEquatable() { 222 | XCTAssertEqual(TimerState(clock: clock), TimerState(clock: clock)) 223 | XCTAssertEqual( 224 | TimerState(since: clock.now, clock: clock), TimerState(since: clock.now, clock: clock) 225 | ) 226 | XCTAssertNotEqual(TimerState(since: clock.now, clock: clock), TimerState(clock: clock)) 227 | XCTAssertNotEqual( 228 | TimerState(clock: clock), 229 | TimerState(since: clock.now.advanced(by: .seconds(-1)), clock: clock) 230 | ) 231 | } 232 | 233 | func testCustomStringConvertible() { 234 | var timer: TimerState 235 | 236 | timer = TimerState(clock: clock) 237 | XCTAssertEqual(String(describing: timer), "idle") 238 | 239 | timer = TimerState(since: clock.now.advanced(by: .seconds(-5)), clock: clock) 240 | XCTAssertEqual(String(describing: timer), "active[0:00:05]") 241 | 242 | timer = TimerState(since: clock.now.advanced(by: .seconds(-300)), clock: clock) 243 | XCTAssertEqual(String(describing: timer), "active[0:05:00]") 244 | 245 | timer = TimerState( 246 | since: clock.now.advanced(by: .seconds(-300)), until: clock.now.advanced(by: .seconds(150)), 247 | clock: clock 248 | ) 249 | XCTAssertEqual(String(describing: timer), "active[0:05:00, expires in 0:02:30]") 250 | 251 | timer = TimerState( 252 | since: clock.now.advanced(by: .seconds(-300)), until: clock.now.advanced(by: .seconds(30)), 253 | clock: clock 254 | ) 255 | clock.advance(by: .seconds(60)) 256 | XCTAssertEqual(String(describing: timer), "idle[expired]") 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /AwareTests/UTCClockTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import Aware 4 | 5 | final class UTCClockTests: XCTestCase { 6 | func testSleep() async throws { 7 | try await UTCClock().sleep(for: .seconds(1)) 8 | } 9 | 10 | func testAdvancedByDuration() throws { 11 | let now = UTCClock().now 12 | let then = now.advanced(by: .seconds(15)) 13 | XCTAssertEqual(now.duration(to: then), .seconds(15)) 14 | XCTAssertEqual(then.duration(to: now), .seconds(-15)) 15 | } 16 | 17 | func testDurationToTimeInterval() throws { 18 | XCTAssertEqual(Duration.seconds(0).timeInterval, 0.0) 19 | XCTAssertEqual(Duration.seconds(60).timeInterval, 60.0) 20 | 21 | XCTAssertEqual(Duration.milliseconds(0).timeInterval, 0.0) 22 | XCTAssertEqual(Duration.milliseconds(50).timeInterval, 0.05) 23 | XCTAssertEqual(Duration.milliseconds(1500).timeInterval, 1.5) 24 | 25 | XCTAssertEqual(Duration.microseconds(0).timeInterval, 0.0) 26 | XCTAssertEqual(Duration.microseconds(50).timeInterval, 0.00005) 27 | } 28 | 29 | func testTimeIntervalToDuration() throws { 30 | XCTAssertEqual(Duration(timeInterval: 0.0), .seconds(0)) 31 | XCTAssertEqual(Duration(timeInterval: 60.0), .seconds(60)) 32 | 33 | XCTAssertEqual(Duration(timeInterval: 0.05), .milliseconds(50)) 34 | XCTAssertEqual(Duration(timeInterval: 1.5), .milliseconds(1500)) 35 | 36 | XCTAssertEqual(Duration(timeInterval: 0.00005), .microseconds(50)) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2016 Joshua Peek, Patrick Marsceill. All rights reserved. 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Aware 2 | 3 | Aware is a menubar app for macOS and visionOS that displays how long you've been actively using your computer. 4 | 5 | ![dark](https://cloud.githubusercontent.com/assets/896475/12149285/eee30008-b470-11e5-81e9-de7072a11827.png) 6 | ![light](https://cloud.githubusercontent.com/assets/896475/12149287/eeeac37e-b470-11e5-9bda-8a2502a39148.png) 7 | 8 | ## Installing the app 9 | 10 | 11 | 12 | [View in Mac App Store](https://itunes.apple.com/us/app/aware/id1082170746?mt=12) or [download the latest release from GitHub](https://github.com/josh/Aware/releases/latest). 13 | 14 | ## Development information 15 | 16 | Requires Xcode 10.2 17 | 18 | ``` sh 19 | $ git clone https://github.com/josh/Aware 20 | $ cd Aware/ 21 | $ open Aware.xcodeproj/ 22 | ``` 23 | 24 | ## License 25 | 26 | Copyright © 2016 Joshua Peek, Patrick Marsceill. All rights reserved. 27 | -------------------------------------------------------------------------------- /ci_scripts/ci_pre_xcodebuild.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -ex 4 | 5 | if [ "$CI_PRODUCT_PLATFORM" = 'macOS' ] && [ "$CI_XCODEBUILD_ACTION" = 'build-for-testing' ]; then 6 | sed -i'~' 's/ENABLE_HARDENED_RUNTIME = YES;/ENABLE_HARDENED_RUNTIME = NO;/g' \ 7 | "$CI_PRIMARY_REPOSITORY_PATH/$CI_XCODE_PROJECT/project.pbxproj" 8 | fi 9 | -------------------------------------------------------------------------------- /itunes-connect.md: -------------------------------------------------------------------------------- 1 | # iTunes Connect App 2 | 3 | ## Localizable Information 4 | 5 | **Name** Aware 6 | **Privacy Policy URL** https://awaremac.com/privacy 7 | 8 | ## General Information 9 | 10 | **Bundle ID** `com.awaremac.Aware` 11 | **SKU** Aware1 12 | **Primary Language** English 13 | **Primary Category** Productivity 14 | **License Agreement** Apple's Standard License Agreement 15 | **Rating** Ages 4+ 16 | 17 | ## Pricing and Availability 18 | 19 | **Price** USD 0 (Free) 20 | **Availability** Available in all territories 21 | **Volume Purchase Program** Available with a volume discount for educational institutions 22 | **Bitcode Auto-Recompilation** Use bitcode auto-recompilation 23 | 24 | ## General App Information 25 | 26 | **Rating** Ages 4+ 27 | **Copyright** 2016 Joshua Peek, Patrick Marsceill. 28 | 29 | ## macOS App 30 | 31 | **Screenshots** 32 | 33 | ![](https://github.com/josh/Aware/blob/01eafd94e497221940242d4489e9c7f702472b89/assets/images/screenshot1.png) 34 | ![](https://github.com/josh/Aware/blob/01eafd94e497221940242d4489e9c7f702472b89/assets/images/screenshot2.png) 35 | 36 | **Description** 37 | 38 | A simple menubar app for macOS that tracks how long you've been actively using your computer. 39 | 40 | Aware tells you how long you've been using your computer in hours and minutes. It knows this because it detects the movements of your mouse and the keystrokes on your keyboard. After a short time of inactivity (a break), Aware will pause the timer, then reset and start again when more activity is detected. 41 | 42 | There are no assumptions made as to what you do with this information. No popups or alarms to tell you to take a break, just a record of time tracked in your menubar for easy access. 43 | 44 | **Keywords** aware,time,timer,activity,usage,break 45 | **Support URL** http://awaremac.com 46 | **Marketing URL** http://awaremac.com 47 | 48 | ## visionOS App 49 | 50 | **Description** 51 | 52 | A simple usage timer for visionOS that shows you how long you've been continuously wearing Vision Pro. 53 | 54 | Open Aware and place the timer window anywhere you'd like. The timer keeps running until you take off the device and restarts from zero when you put it back on to start a new session. No need to close the app, the timer resets itself. 55 | 56 | Use Aware to remind yourself to take breaks from wearing Vision Pro or gauge your productivity after a long session. 57 | 58 | **Keywords** aware,time,timer,activity,usage,break 59 | **Support URL** http://awaremac.com 60 | **Marketing URL** http://awaremac.com 61 | --------------------------------------------------------------------------------