Repository: wakatime/macos-wakatime Branch: main Commit: afc494fe4dea Files: 59 Total size: 27.1 MB Directory structure: gitextract_1hnk5wnk/ ├── .gitattributes ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ └── support-app-request.yaml │ └── workflows/ │ ├── on_pull_request_linter.yml │ └── on_push.yml ├── .gitignore ├── .swiftlint.yml ├── .vscode/ │ └── settings.json ├── AUTHORS ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── Scripts/ │ ├── Firebase/ │ │ └── upload-dSYM.sh │ └── Lint/ │ └── swiftlint ├── WakaTime/ │ ├── AppDelegate.swift │ ├── ConfigFile.swift │ ├── Controls/ │ │ └── WKTextField.swift │ ├── Extensions/ │ │ ├── AXObserverExtension.swift │ │ ├── AXUIElementExtension.swift │ │ ├── BundleExtension.swift │ │ ├── NSRunningApplicationExtension.swift │ │ ├── OptionalExtension.swift │ │ ├── ProcessExtension.swift │ │ ├── StringExtension.swift │ │ └── URLExtension.swift │ ├── Helpers/ │ │ ├── Accessibility.swift │ │ ├── AppInfo.swift │ │ ├── Dependencies.swift │ │ ├── EventSourceObserver.swift │ │ ├── FilterManager.swift │ │ ├── MonitoringManager.swift │ │ ├── PropertiesManager.swift │ │ └── SettingsManager.swift │ ├── Resources/ │ │ ├── Assets.xcassets/ │ │ │ ├── AppIcon.appiconset/ │ │ │ │ └── Contents.json │ │ │ ├── Contents.json │ │ │ ├── WakaTime.imageset/ │ │ │ │ └── Contents.json │ │ │ └── WakaTimeDisabled.imageset/ │ │ │ └── Contents.json │ │ └── GoogleService-Info.plist │ ├── Utils/ │ │ ├── Atomic.swift │ │ ├── Logging.swift │ │ ├── ObjC.h │ │ └── ObjC.m │ ├── Views/ │ │ ├── MonitoredAppsView.swift │ │ └── SettingsView.swift │ ├── WakaTime-Bridging-Header.h │ ├── WakaTime-Info.plist │ ├── WakaTime.entitlements │ ├── WakaTime.swift │ ├── Watchers/ │ │ ├── FileSavedWatcher.swift │ │ ├── MonitoredApp.swift │ │ └── Watcher.swift │ ├── WindowControllers/ │ │ ├── MonitoredAppsWindowController.swift │ │ └── SettingsWindowController.swift │ └── main.swift ├── WakaTime Helper/ │ ├── AppDelegate.swift │ ├── Logging.swift │ ├── WakaTime Helper-Info.plist │ └── main.swift ├── bin/ │ └── prepare_changelog.sh └── project.yml ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitattributes ================================================ ================================================ FILE: .github/ISSUE_TEMPLATE/support-app-request.yaml ================================================ name: Support app request description: Suggest a new app for tracking title: "Support new app: XXX" labels: enhancement body: - type: input id: bundleid attributes: label: BundleId of the app description: Build this app in Xcode and it prints bundleIds of all running apps in the output window validations: required: true - type: textarea id: titles attributes: label: Example window titles description: List some example window titles of the app, which is used to parse the filename for your dashboard validations: required: true ================================================ FILE: .github/workflows/on_pull_request_linter.yml ================================================ name: Tests on: pull_request jobs: lint: runs-on: ubuntu-latest steps: - name: Lint allowed branch names uses: lekterable/branchlint-action@1.2.0 with: allowed: | /^(.+:)?bugfix/.+/i /^(.+:)?docs?/.+/i /^(.+:)?feature/.+/i /^(.+:)?hotfix/.+/i /^(.+:)?major/.+/i /^(.+:)?misc/.+/i /^(.+:)?main$/i - name: Block fixup/squash commits uses: xt0rted/block-autosquash-commits-action@v2 with: repo-token: ${{ secrets.GITHUB_TOKEN }} - # Run only for release branch if: ${{ github.base_ref == 'release' }} name: Check for changelog pattern uses: gandarez/check-pr-body-action@v1.0.3 with: pr_number: ${{ github.event.number }} contains: 'Changelog:' not_contains: '`' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/on_push.yml ================================================ name: Release on: pull_request: types: [opened, reopened, ready_for_review, synchronize] push: branches: [main, release] tags-ignore: "**" jobs: test: name: Tests and Build runs-on: macos-15 steps: - name: Checkout uses: actions/checkout@v3 - name: Install xcodegen via Homebrew for linting and building xcode project run: brew install xcodegen - name: Generate project run: xcodegen - name: Build app to run linters run: xcodebuild -scheme WakaTime -configuration Debug -destination 'generic/platform=macOS' ONLY_ACTIVE_ARCH=NO ARCHS='arm64 x86_64' build version: name: Version concurrency: tagging if: ${{ github.ref == 'refs/heads/release' || github.ref == 'refs/heads/main' }} runs-on: ubuntu-latest needs: [test] outputs: semver: ${{ steps.format.outputs.semver }} semver_tag: ${{ steps.semver-tag.outputs.semver_tag }} ancestor_tag: ${{ steps.semver-tag.outputs.ancestor_tag }} is_prerelease: ${{ steps.semver-tag.outputs.is_prerelease }} steps: - name: Checkout uses: actions/checkout@v3 with: fetch-depth: 0 - name: Calculate semver tag id: semver-tag uses: gandarez/semver-action@master with: prefix: v prerelease_id: alpha develop_branch_name: main main_branch_name: release major_pattern: "(?i)^(.+:)?(major/.+)" - name: Format id: format run: | echo "${{ steps.semver-tag.outputs.semver_tag }}" ver=`echo "${{ steps.semver-tag.outputs.semver_tag }}" | sed 's/^v//'` echo "$ver" echo "semver=$ver" >> $GITHUB_OUTPUT - name: Create tag uses: actions/github-script@v6 with: github-token: ${{ github.token }} script: | github.rest.git.createRef({ owner: context.repo.owner, repo: context.repo.repo, ref: "refs/tags/${{ steps.semver-tag.outputs.semver_tag }}", sha: context.sha }) sign: name: Sign Apple app needs: [version] runs-on: macos-15 steps: - name: Checkout uses: actions/checkout@v3 - name: Update project.yml uses: fjogeleit/yaml-update-action@main with: valueFile: 'project.yml' changes: | { "targets.WakaTime.settings.CURRENT_PROJECT_VERSION": "${{ needs.version.outputs.semver }}", "targets.WakaTime.settings.MARKETING_VERSION": "${{ needs.version.outputs.semver }}" } commitChange: false - name: Install xcodegen via Homebrew for linting and building xcode project run: brew install xcodegen - name: Generate project run: xcodegen - name: Build app id: build run: | xcodebuild -scheme WakaTime -configuration Release -destination 'generic/platform=macOS' ONLY_ACTIVE_ARCH=NO ARCHS='arm64 x86_64' build app=`find /Users/runner/Library/Developer/Xcode/DerivedData/ -name WakaTime.app` echo "$app" lipo -info "$app/Contents/MacOS/WakaTime" directory=`dirname $app` echo "$directory" echo "directory=$directory" >> $GITHUB_OUTPUT - name: Verify universal platform run: | app=`find /Users/runner/Library/Developer/Xcode/DerivedData/ -name WakaTime.app` echo "$app" lipo -info "$app/Contents/MacOS/WakaTime" BIN="$app/Contents/MacOS/WakaTime" archs="$(lipo -archs "$BIN" 2>/dev/null || true)" echo "Reported architectures: $archs" for required in arm64 x86_64; do echo "$archs" | grep -qw "$required" || { echo "❌ Missing required architecture: $required" exit 1 } done - name: Import Code-Signing Certificates uses: Apple-Actions/import-codesign-certs@v1 with: # The certificates in a PKCS12 file encoded as a base64 string p12-file-base64: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_P12_BASE64 }} # The password used to import the PKCS12 file. p12-password: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_PASSWORD }} - name: Codesign env: APP_SIGNING_IDENTITY: ${{ secrets.APPLE_DEVELOPER_IDENTITY }} run: | codesign --force --deep --timestamp --options runtime --sign "$APP_SIGNING_IDENTITY" "${{ steps.build.outputs.directory }}/WakaTime.app" - name: Store Credentials env: NOTARIZATION_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} NOTARIZATION_APPLE_ID: ${{ secrets.AC_USERNAME }} NOTARIZATION_PWD: ${{ secrets.AC_PASSWORD }} run: xcrun notarytool store-credentials "notarytool-profile" --apple-id "$NOTARIZATION_APPLE_ID" --team-id "$NOTARIZATION_TEAM_ID" --password "$NOTARIZATION_PWD" - name: Notarize Helper run: | ditto -c -k --keepParent "${{ steps.build.outputs.directory }}/WakaTime.app/Contents/Library/LoginItems/WakaTime Helper.app" helper.zip xcrun notarytool submit helper.zip --keychain-profile "notarytool-profile" --wait xcrun stapler staple "${{ steps.build.outputs.directory }}/WakaTime.app/Contents/Library/LoginItems/WakaTime Helper.app" - name: Notarize App run: | ditto -c -k --keepParent ${{ steps.build.outputs.directory }}/WakaTime.app main.zip xcrun notarytool submit main.zip --keychain-profile "notarytool-profile" --wait xcrun stapler staple ${{ steps.build.outputs.directory }}/WakaTime.app - name: Zip run: ditto -c -k --sequesterRsrc --keepParent ${{ steps.build.outputs.directory }}/WakaTime.app WakaTime.zip - name: Upload artifacts uses: actions/upload-artifact@v4 with: name: app path: ./WakaTime.zip - name: Remove tag if failure if: ${{ failure() }} uses: actions/github-script@v6 with: github-token: ${{ github.token }} script: | github.rest.git.deleteRef({ owner: context.repo.owner, repo: context.repo.repo, ref: "tags/${{ needs.version.outputs.semver_tag }}" }) changelog: name: Changelog runs-on: ubuntu-latest needs: [version, sign] outputs: changelog: ${{ steps.changelog.outputs.changelog }} steps: - name: Checkout uses: actions/checkout@v3 with: fetch-depth: 0 - if: ${{ github.ref == 'refs/heads/main' }} name: Changelog for main uses: gandarez/changelog-action@v1.2.0 id: changelog-main with: current_tag: ${{ github.sha }} previous_tag: ${{ needs.version.outputs.ancestor_tag }} exclude: | ^Merge pull request .* - if: ${{ github.ref == 'refs/heads/release' }} name: Get related pull request uses: 8BitJonny/gh-get-current-pr@2.2.0 id: changelog-release with: github-token: ${{ secrets.GITHUB_TOKEN }} - name: Prepare changelog id: changelog run: | echo "${{ steps.changelog-main.outputs.changelog || steps.changelog-release.outputs.pr_body }}" > changelog.txt ./bin/prepare_changelog.sh $(echo ${GITHUB_REF#refs/heads/}) "$(cat changelog.txt)" - name: Remove tag if failure if: ${{ failure() }} uses: actions/github-script@v6 with: github-token: ${{ github.token }} script: | github.rest.git.deleteRef({ owner: context.repo.owner, repo: context.repo.repo, ref: "tags/${{ needs.version.outputs.semver_tag }}" }) release: name: Release runs-on: macos-15 needs: [version, sign, changelog] steps: - name: Checkout uses: actions/checkout@v3 with: fetch-depth: 0 - name: Download artifacts uses: actions/download-artifact@v4 with: name: app path: ./ - name: Prepare release folder id: prepare run: | mkdir release mv ./WakaTime.zip release/macos-wakatime.zip - name: "Create release" uses: softprops/action-gh-release@master with: name: ${{ needs.version.outputs.semver_tag }} tag_name: ${{ needs.version.outputs.semver_tag }} body: "## Changelog\n${{ needs.changelog.outputs.changelog }}" prerelease: ${{ needs.version.outputs.is_prerelease }} target_commitish: ${{ github.sha }} draft: false files: | ./release/macos-wakatime.zip env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Remove tag if failure if: ${{ failure() }} uses: actions/github-script@v6 with: github-token: ${{ github.token }} script: | github.rest.git.deleteRef({ owner: context.repo.owner, repo: context.repo.repo, ref: "tags/${{ needs.version.outputs.semver_tag }}" }) ================================================ FILE: .gitignore ================================================ .DS_Store *.xcodeproj xcuserdata/ Mint/ .build/ build/ Package.resolved ================================================ FILE: .swiftlint.yml ================================================ # # .swiftlint.yml # # disabled_rules: - inclusive_language - nesting - redundant_string_enum_value - todo - trailing_comma - type_body_length - vertical_parameter_alignment # 36 false positives. Will disable weak_delegate rule for now - weak_delegate opt_in_rules: - array_init - closure_end_indentation - closure_spacing - contains_over_first_not_nil - empty_count - explicit_init - fatal_error_message - first_where - force_unwrapping - implicit_return - literal_expression_end_indentation - operator_usage_whitespace - overridden_super_call - override_in_extension - private_outlet - redundant_nil_coalescing - sorted_first_last - strict_fileprivate - trailing_closure - unneeded_parentheses_in_closure_argument included: - WakaTime force_cast: error force_try: error force_unwrapping: error trailing_whitespace: ignores_empty_lines: false severity: warning trailing_newline: error trailing_semicolon: error vertical_whitespace: max_empty_lines: 1 severity: warning comma: error colon: severity: error opening_brace: error empty_count: error legacy_constructor: error statement_position: statement_mode: default severity: error legacy_constant: error type_name: min_length: 3 max_length: warning: 45 error: 50 excluded: - T identifier_name: max_length: warning: 40 error: 50 min_length: error: 3 excluded: - x - y - z - i - j - at - on - id - db - rs - to - in - me - up - dx - dy - preferredInterfaceOrientationForPresentation function_parameter_count: warning: 10 error: 10 line_length: warning: 140 error: 140 function_body_length: warning: 150 error: 200 file_length: warning: 1000 error: 1000 cyclomatic_complexity: warning: 30 error: 30 large_tuple: warning: 4 error: 5 switch_case_alignment: indented_cases: true reporter: 'xcode' custom_rules: comments_space: name: 'Space After Comment' regex: '(^ *//\w+)' message: 'There should be a space after //' severity: warning empty_first_line: name: 'Empty First Line' regex: '(^[ a-zA-Z ]*(?:protocol|extension|class|struct|func) [ a-zA-Z0-9:,<>\.\(\)\"-=`]*\{\n( *)?\n)' message: 'There should not be an empty line after a declaration' severity: error empty_line_after_guard: name: 'Empty Line After Guard' regex: '(^ *guard[ a-zA-Z0-9=?.\(\),> - Carlos Henrique Gandarez - Michael Mavris <@MMavrisPaleBlue> - Tobias Lensing <@starbugs> - Chris Pastl ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing ## Setup This project depends on the [xcodegen](https://github.com/yonaskolb/XcodeGen?tab=readme-ov-file#installing) command line tool. ```bash git clone git@github.com:wakatime/macos-wakatime.git cd macos-wakatime xcodegen ``` Then open the `WakaTime.xcodeproj` in [Xcode 15.2](https://developer.apple.com/services-account/download?path=/Developer_Tools/Xcode_15.2/Xcode_15.2.xip). Currently there’s a bug in new Swift compiler versions, so the largest Xcode version working with this app is 15.2. ## Branches This project currently has two branches - `main` - Default branch for every new `feature` or `fix` - `release` - Branch for production releases and hotfixes ## Testing and Linting Build with `Xcode` before creating any pull requests, or your PR won’t pass the automated checks. ## SwiftLint To fix linter warning(s), run `swiftlint --fix`. ## Branching Strategy We require specific branch name prefixes for PRs: - `^major/.+` - `major` - `^feature/.+` - `minor` - `^bugfix/.+` - `patch` - `^docs?/.+` - `build` - `^misc/.+` - `build` More info at [wakatime/semver-action](https://github.com/wakatime/semver-action#branch-names). ## Pull Requests - Big changes, changes to the API, or changes with backward compatibility trade-offs should be first discussed in the Slack. - Search [existing pull requests](https://github.com/wakatime/macos-wakatime/pulls) to see if one has already been submitted for this change. Search the [issues](https://github.com/wakatime/macos-wakatime/issues?q=is%3Aissue) to see if there has been a discussion on this topic and whether your pull request can close any issues. - Code formatting should be consistent with the style used in the existing code. - Don't leave commented out code. A record of this code is already preserved in the commit history. - All commits must be atomic. This means that the commit completely accomplishes a single task. Each commit should result in fully functional code. Multiple tasks should not be combined in a single commit, but a single task should not be split over multiple commits (e.g. one commit per file modified is not a good practice). For more information see . - Each pull request should address a single bug fix or feature. This may consist of multiple commits. If you have multiple, unrelated fixes or enhancements to contribute, submit them as separate pull requests. - Commit messages: - Use the [imperative mood](http://chris.beams.io/posts/git-commit/#imperative) in the title. For example: "Apply editor.indent preference" - Capitalize the title. - Do not end the title with a period. - Separate title from the body with a blank line. If you're committing via GitHub or GitHub Desktop this will be done automatically. - Wrap body at 72 characters. - Completely explain the purpose of the commit. Include a rationale for the change, any caveats, side-effects, etc. - If your pull request fixes an issue in the issue tracker, use the [closes/fixes/resolves syntax](https://help.github.com/articles/closing-issues-via-commit-messages) in the body to indicate this. - See for more tips on writing good commit messages. - Pull request title and description should follow the same guidelines as commit messages. - Rebasing pull requests is OK and encouraged. After submitting your pull request some changes may be requested. Prefer using [git fixup](https://git-scm.com/docs/git-commit#Documentation/git-commit.txt---fixupltcommitgt) rather than adding orphan extra commits to the pull request, then do a push to your fork. As soon as your PR gets approved one of us will merge it by rebasing and squashing any residuary commits that were pushed while reviewing. This will help to keep the commit history of the repository clean. ## Troubleshooting If you have trouble building off `main` branch, try: * close Xcode * `rm -rf ~/Library/Developer/Xcode/DerivedData/WakaTime*` * `rm -rf ./WakaTime.xcodeproj` * `xcodegen` * Open the project in Xcode * Under `Signing & Capabilities`, set your `Team` To read local user preferences, run: defaults read macos-wakatime.WakaTime Any question join us on [Slack](https://wakaslack.herokuapp.com/). ================================================ FILE: LICENSE ================================================ BSD 3-Clause License Copyright (c) 2023 Alan Hamlett. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the names of WakaTime, nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE AND DOCUMENTATION IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ================================================ FILE: README.md ================================================ # macos-wakatime Mac system tray app for automatic time tracking and metrics generated from your Xcode activity. ## Install 1. Download the [latest release](https://github.com/wakatime/macos-wakatime/releases/latest/download/macos-wakatime.zip). 2. Move `WakaTime.app` into your `Applications` folder, and run `WakaTime.app`. 3. Enter your [WakaTime API Key][api key], then press `Save`. 4. Use Xcode like normal and your coding activity will be displayed on your [WakaTime dashboard][dashboard] ## Usage Keep the app running in your system tray, and your Xcode usage will show on your [WakaTime dashboard][dashboard]. ## Building from Source 1. Run `xcodegen` to generate the project. 2. Open the project with Xcode. 3. Click Run (⌘+R). If you run into Accessibility problems, try running `sudo tccutil reset Accessibility`. ## Uninstall To uninstall, move `WakaTime.app` into your mac Trash. If you don’t use any other WakaTime plugins, run `rm -r ~/.wakatime*`. ## Supported Apps WakaTime for Mac can track the time you spend in any app on your mac. It’s a catch-all when we don’t have a plugin for your IDE or app. We add support for specific apps when a custom category, project, or entity type is necessary. For example, when Slack needs the `communicating` category or Figma needs the `designing` category. Only request support for a new app when it needs a custom category, or we can detect the project from the window title. Before requesting support for a new app, first check the [list of supported apps][supported apps]. ## Contributing Pull requests and issues are welcome! See [Contributing][contributing] for more details. Many thanks to all [contributors][authors]! Made with :heart: by the WakaTime Team. [api key]: https://wakatime.com/api-key [dashboard]: https://wakatime.com/ [contributing]: CONTRIBUTING.md [authors]: AUTHORS [supported apps]: https://github.com/wakatime/macos-wakatime/blob/main/WakaTime/Watchers/MonitoredApp.swift#L3 ================================================ FILE: Scripts/Firebase/upload-dSYM.sh ================================================ "${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run" ================================================ FILE: Scripts/Lint/swiftlint ================================================ [File too large to display: 26.9 MB] ================================================ FILE: WakaTime/AppDelegate.swift ================================================ import AppUpdater import Cocoa import UserNotifications class AppDelegate: NSObject, NSApplicationDelegate, StatusBarDelegate, UNUserNotificationCenterDelegate { var window: NSWindow! var statusBarItem: NSStatusItem! let menu = NSMenu() var statusBarA11yItem: NSMenuItem! var statusBarA11ySeparator: NSMenuItem! var statusBarA11yStatus: Bool = true var settingsWindowController = SettingsWindowController() var monitoredAppsWindowController = MonitoredAppsWindowController() var wakaTime: WakaTime? @Atomic var lastTodayTime = 0 @Atomic var lastTodayText = "" @Atomic var lastBrowserWarningTime = 0 let updater = AppUpdater(owner: "wakatime", repo: "macos-wakatime") func applicationDidFinishLaunching(_ aNotification: Notification) { // Configure logging to a log file if activated by the user if PropertiesManager.shouldLogToFile { Logging.default.activateLoggingToFile() } Logging.default.log("Starting WakaTime") // Handle deep links let eventManager = NSAppleEventManager.shared() eventManager.setEventHandler( self, andSelector: #selector(handleGetURL(_:withReplyEvent:)), forEventClass: AEEventClass(kInternetEventClass), andEventID: AEEventID(kAEGetURL) ) let statusBar = NSStatusBar.system statusBarItem = statusBar.statusItem(withLength: NSStatusItem.variableLength) statusBarItem.button?.image = NSImage(named: NSImage.Name("WakaTime")) // refresh code time text when status bar icon clicked statusBarItem.button?.target = self statusBarItem.button?.action = #selector(AppDelegate.onClick(_:)) statusBarItem.button?.sendAction(on: [.leftMouseUp, .rightMouseUp]) statusBarA11yItem = NSMenuItem( title: "* A11y permission needed *", action: #selector(AppDelegate.a11yClicked(_:)), keyEquivalent: "") statusBarA11yItem.isHidden = true menu.addItem(statusBarA11yItem) statusBarA11ySeparator = NSMenuItem.separator() menu.addItem(statusBarA11ySeparator) statusBarA11ySeparator.isHidden = true menu.addItem(withTitle: "Dashboard", action: #selector(AppDelegate.dashboardClicked(_:)), keyEquivalent: "") menu.addItem(withTitle: "Settings", action: #selector(AppDelegate.settingsClicked(_:)), keyEquivalent: "") menu.addItem( withTitle: "Monitored Apps", action: #selector(AppDelegate.monitoredAppsClicked(_:)), keyEquivalent: "") menu.addItem(NSMenuItem.separator()) menu.addItem( withTitle: "Check for Updates", action: #selector(AppDelegate.checkForUpdatesClicked(_:)), keyEquivalent: "") menu.addItem(NSMenuItem.separator()) menu.addItem(withTitle: "Quit", action: #selector(AppDelegate.quitClicked(_:)), keyEquivalent: "") wakaTime = WakaTime(self) settingsWindowController.settingsView.delegate = self Task.detached(priority: .background) { self.fetchToday() } // request notifications permission UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, error in guard granted else { if let msg = error?.localizedDescription { Logging.default.log(msg) } return } } } func applicationWillTerminate(_ notification: Notification) { Logging.default.log("WakaTime will terminate") } @objc func handleGetURL(_ event: NSAppleEventDescriptor, withReplyEvent replyEvent: NSAppleEventDescriptor) { // Handle deep links guard let urlString = event.paramDescriptor(forKeyword: keyDirectObject)?.stringValue, let url = URL(string: urlString), url.scheme == "wakatime", let link = DeepLink(rawValue: url.host ?? "") else { return } switch link { case .settings: showSettings() case .monitoredApps: showMonitoredApps() } } @objc func dashboardClicked(_ sender: AnyObject) { let defaultUrl = "https://wakatime.com/" var url = ConfigFile.getSetting(section: "settings", key: "api_url") ?? defaultUrl if url.isEmpty { url = defaultUrl } url = url .replacingOccurrences(of: "://api.", with: "://") .replacingOccurrences(of: "/api/v1", with: "") .replacingOccurrences(of: "^api\\.", with: "", options: .regularExpression) .replacingOccurrences(of: "/api", with: "") if let url = URL(string: url) { NSWorkspace.shared.open(url) } else { if url != defaultUrl { if let url = URL(string: defaultUrl) { NSWorkspace.shared.open(url) } } } } @objc func settingsClicked(_ sender: AnyObject) { showSettings() } @objc func monitoredAppsClicked(_ sender: AnyObject) { showMonitoredApps() } @objc func checkForUpdatesClicked(_ sender: AnyObject) { updater.check { self.toastNotification("Updating to latest release") }.catch(policy: .allErrors) { error in if error.isCancelled { let alert = NSAlert() alert.messageText = "Up to date" alert.informativeText = "You have the latest version (\(Bundle.main.version))." alert.alertStyle = NSAlert.Style.warning alert.addButton(withTitle: "OK") alert.runModal() } else { Logging.default.log(String(describing: error)) let alert = NSAlert() alert.messageText = "Error" let max = 200 if error.localizedDescription.count <= max { alert.informativeText = error.localizedDescription } else { alert.informativeText = String(error.localizedDescription.prefix(max).appending("…")) } alert.alertStyle = NSAlert.Style.warning alert.addButton(withTitle: "OK") alert.runModal() } } } @objc func a11yClicked(_ sender: AnyObject) { a11yStatusChanged(Accessibility.requestA11yPermission()) } @objc func quitClicked(_ sender: AnyObject) { NSApplication.shared.terminate(self) } @objc func onClick(_ sender: NSStatusItem) { Task.detached(priority: .background) { self.fetchToday() } // statusBarItem.popUpMenu(menu) statusBarItem.menu = menu } func a11yStatusChanged(_ hasPermission: Bool) { guard statusBarA11yStatus != hasPermission else { return } statusBarA11yStatus = hasPermission if hasPermission { statusBarItem.button?.image = NSImage(named: NSImage.Name("WakaTime")) } else { statusBarItem.button?.image = NSImage(named: NSImage.Name("WakaTimeDisabled")) } statusBarA11yItem.isHidden = hasPermission statusBarA11ySeparator.isHidden = hasPermission } private func checkBrowserDuplicateTracking() { // Warn about using both Browser extension and Mac app tracking a browser at same time, once per 12 hrs let time = Int(NSDate().timeIntervalSince1970) if time - lastBrowserWarningTime > Dependencies.twelveHours && MonitoringManager.isMonitoringBrowsing { Task { if let browser = await Dependencies.recentBrowserExtension() { lastBrowserWarningTime = time delegate.toastNotification("Warning: WakaTime \(browser) extension detected. " + "It’s recommended to only track browsing activity with the \(browser) " + "extension or Mac Desktop app, but not both.") } } } } private func showSettings() { NSApp.activate(ignoringOtherApps: true) settingsWindowController.settingsView.setBrowserVisibility() settingsWindowController.showWindow(self) } private func showMonitoredApps() { NSApp.activate(ignoringOtherApps: true) monitoredAppsWindowController.showWindow(self) } internal func toastNotification(_ title: String) { let content = UNMutableNotificationContent() content.title = title content.body = " " let uuidString = UUID().uuidString let request = UNNotificationRequest( identifier: uuidString, content: content, trigger: nil) let notificationCenter = UNUserNotificationCenter.current() notificationCenter.delegate = self notificationCenter.requestAuthorization(options: [.alert, .sound]) { granted, _ in guard granted else { return } DispatchQueue.main.async { notificationCenter.add(request) } } } func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { if #available(macOS 11.0, *) { completionHandler([.banner, .sound]) } else { completionHandler([.alert, .sound]) // Fallback for older macOS versions } } private func setText(_ text: String) { DispatchQueue.main.async { Logging.default.log("Set status bar text: \(text)") self.statusBarItem.button?.title = text.isEmpty ? text : " " + text } } internal func fetchToday() { guard PropertiesManager.shouldDisplayTodayInStatusBar else { setText("") return } let time = Int(NSDate().timeIntervalSince1970) guard lastTodayTime + 120 < time else { setText(lastTodayText) return } lastTodayTime = time let cli = NSString.path( withComponents: ConfigFile.resourcesFolder + ["wakatime-cli"] ) let process = Process() process.launchPath = cli let args = [ "--today", "--today-hide-categories", "true", "--plugin", "macos-wakatime/" + Bundle.main.version, ] Logging.default.log("Fetching coding activity for Today from api: \(args)") process.arguments = args let pipe = Pipe() process.standardOutput = pipe process.standardError = FileHandle.nullDevice do { try process.execute() } catch { Logging.default.log("Failed to run wakatime-cli fetching Today coding activity: \(error)") return } let handle = pipe.fileHandleForReading let data = handle.readDataToEndOfFile() let text = (String(data: data, encoding: String.Encoding.utf8) ?? "").trimmingCharacters(in: .whitespacesAndNewlines) lastTodayText = text setText(text) checkBrowserDuplicateTracking() } } ================================================ FILE: WakaTime/ConfigFile.swift ================================================ import Foundation struct ConfigFile { private static var userHome: [String] { FileManager.default.homeDirectoryForCurrentUser.pathComponents } public static var resourcesFolder: [String] { userHome + [".wakatime"] } private static var filePath: String { NSString.path(withComponents: userHome + [".wakatime.cfg"]) } private static var filePathInternal: String { NSString.path(withComponents: resourcesFolder + ["wakatime-internal.cfg"]) } static func getSetting(section: String, key: String, internalConfig: Bool = false) -> String? { let file = internalConfig ? filePathInternal : filePath let contents: String do { contents = try String(contentsOfFile: file) } catch { Logging.default.log("Failed reading \(file): " + error.localizedDescription) return nil } let lines = contents.split(separator: "\n") var currentSection = "" for line in lines { if line.hasPrefix("[") && line.hasSuffix("]") { currentSection = String(line.dropFirst().dropLast()) } else if currentSection == section { let parts = line.split(separator: "=", maxSplits: 2) if parts.count == 2 && parts[0].trimmingCharacters(in: .whitespacesAndNewlines) == key { return String(parts[1].trimmingCharacters(in: .whitespacesAndNewlines)) } } } return nil } static func setSetting(section: String, key: String, val: String, internalConfig: Bool = false) { let file = internalConfig ? filePathInternal : filePath let contents: String do { contents = try String(contentsOfFile: file) } catch { contents = "[" + section + "]\n" + key + " = " + val do { try contents.write(to: URL(fileURLWithPath: file), atomically: true, encoding: .utf8) } catch { assertionFailure("Failed writing to URL: \(file), Error: " + error.localizedDescription) } } let lines = contents.split(separator: "\n") var output: [String] = [] var currentSection = "" var found = false for line in lines { if line.hasPrefix("[") && line.hasSuffix("]") { if currentSection == section && !found { output.append(key + " = " + val) found = true } output.append(String(line)) currentSection = String(line.dropFirst().dropLast()) } else if currentSection == section { let parts = line.split(separator: "=", maxSplits: 2) if parts.count == 2 && parts[0].trimmingCharacters(in: .whitespacesAndNewlines) == key { if !found { output.append(key + " = " + val) found = true } } else { output.append(String(line)) } } else { output.append(String(line)) } } if !found { if currentSection != section { output.append("[" + section + "]") } output.append(key + " = " + val) } do { try output.joined(separator: "\n").write(to: URL(fileURLWithPath: file), atomically: true, encoding: .utf8) } catch { assertionFailure("Failed writing to URL: \(file), Error: " + error.localizedDescription) } } } ================================================ FILE: WakaTime/Controls/WKTextField.swift ================================================ import AppKit class WKTextField: NSTextField { override func performKeyEquivalent(with event: NSEvent) -> Bool { if event.type == NSEvent.EventType.keyDown { let modifierFlags = event.modifierFlags.rawValue & NSEvent.ModifierFlags.deviceIndependentFlagsMask.rawValue if modifierFlags == NSEvent.ModifierFlags.command.rawValue { switch event.charactersIgnoringModifiers?.first { case "x": if NSApp.sendAction(#selector(NSText.cut(_:)), to: nil, from: self) { return true } case "c": if NSApp.sendAction(#selector(NSText.copy(_:)), to: nil, from: self) { return true } case "v": if NSApp.sendAction(#selector(NSText.paste(_:)), to: nil, from: self) { return true } case "a": if NSApp.sendAction(#selector(NSText.selectAll(_:)), to: nil, from: self) { return true } case "z": if NSApp.sendAction(Selector(("undo:")), to: nil, from: self) { return true } default: break } } else if modifierFlags == NSEvent.ModifierFlags.command.rawValue | NSEvent.ModifierFlags.shift.rawValue { if NSApp.sendAction(Selector(("redo:")), to: nil, from: self) { return true } } } return super.performKeyEquivalent(with: event) } } ================================================ FILE: WakaTime/Extensions/AXObserverExtension.swift ================================================ import AppKit extension AXObserver { static func create(appID: pid_t, callback: AXObserverCallback) throws -> AXObserver { var observer: AXObserver? let error = AXObserverCreate(appID, callback, &observer) guard error == .success else { throw AXObserverError.createFailed(error) } guard let observer else { throw AXObserverError.createFailed(error) } return observer } func add(notification: String, element: AXUIElement, refcon: UnsafeMutableRawPointer?) throws { let error = AXObserverAddNotification(self, element, notification as CFString, refcon) guard error == .success else { Logging.default.log("Add notification \(notification) failed: \(error.rawValue)") throw AXObserverError.addNotificationFailed(error) } // Logging.default.log("Added notification \(notification) to observer \(self)") } func remove(notification: String, element: AXUIElement) throws { let error = AXObserverRemoveNotification(self, element, notification as CFString) guard error == .success else { Logging.default.log("Remove notification \(notification) failed: \(error.rawValue)") throw AXObserverError.removeNotificationFailed(error) } // Logging.default.log("Removed notification \(notification) from observer \(self)") } func addToRunLoop(mode: CFRunLoopMode = .defaultMode) { CFRunLoopAddSource(RunLoop.current.getCFRunLoop(), AXObserverGetRunLoopSource(self), mode) // Logging.default.log("Added observer \(self) to run loop") } func removeFromRunLoop(mode: CFRunLoopMode = .defaultMode) { CFRunLoopRemoveSource(RunLoop.current.getCFRunLoop(), AXObserverGetRunLoopSource(self), mode) // Logging.default.log("Removed observer \(self) from run loop") } } private enum AXObserverError: Error { case createFailed(AXError) case addNotificationFailed(AXError) case removeNotificationFailed(AXError) } ================================================ FILE: WakaTime/Extensions/AXUIElementExtension.swift ================================================ import AppKit struct AXPatternElement { var role: String? var subrole: String? var id: String? var title: String? var value: String? var children: [AXPatternElement] = [] } extension AXUIElement { var selectedText: String? { getValue(for: kAXSelectedTextAttribute) as? String } func getValue(for attribute: String) -> CFTypeRef? { var result: CFTypeRef? guard AXUIElementCopyAttributeValue(self, attribute as CFString, &result) == .success else { return nil } return result } var children: [AXUIElement]? { guard let ref = getValue(for: kAXChildrenAttribute) else { return nil } return ref as? [AXUIElement] } var parent: AXUIElement? { guard let ref = getValue(for: kAXParentAttribute) else { return nil } // swiftlint:disable force_cast return (ref as! AXUIElement) // swiftlint:enable force_cast } var nextSibling: AXUIElement? { guard let parentChildren = self.parent?.children, let currentIndex = parentChildren.firstIndex(of: self) else { return nil } let nextIndex = currentIndex + 1 guard parentChildren.indices.contains(nextIndex) else { return nil } return parentChildren[nextIndex] } var previousSibling: AXUIElement? { guard let parentChildren = self.parent?.children, let currentIndex = parentChildren.firstIndex(of: self) else { return nil } let previousIndex = currentIndex - 1 guard parentChildren.indices.contains(previousIndex) else { return nil } return parentChildren[previousIndex] } var id: String? { guard let ref = getValue(for: kAXIdentifierAttribute) else { return nil } // swiftlint:disable force_cast return (ref as! String) // swiftlint:enable force_cast } var rawTitle: String? { guard let ref = getValue(for: kAXTitleAttribute) else { return nil } // swiftlint:disable force_cast return (ref as! String) // swiftlint:enable force_cast } var role: String? { guard let ref = getValue(for: kAXRoleAttribute) else { return nil } // swiftlint:disable force_cast return (ref as! String) // swiftlint:enable force_cast } var subrole: String? { guard let ref = getValue(for: kAXSubroleAttribute) else { return nil } // swiftlint:disable force_cast return (ref as! String) // swiftlint:enable force_cast } var document: String? { guard let ref = getValue(for: kAXDocumentAttribute) else { return nil } // swiftlint:disable force_cast return (ref as! String) // swiftlint:enable force_cast } var value: String? { guard let ref = getValue(for: kAXValueAttribute) else { return nil } return (ref as? String) } var activeWindow: AXUIElement? { // swiftlint:disable force_cast if let window = getValue(for: kAXFocusedWindowAttribute) { return (window as! AXUIElement) } if let window = getValue(for: kAXMainWindowAttribute) { return (window as! AXUIElement) } if let window = getValue(for: kAXWindowAttribute) { return (window as! AXUIElement) } // swiftlint:enable force_cast return nil } var currentPath: URL? { if let window = activeWindow { if let path = window.document { if path.hasPrefix("file://") { return URL(string: path.dropFirst(7).description) } return URL(string: path) } } if let path = document { if path.hasPrefix("file://") { return URL(string: path.dropFirst(7).description) } return URL(string: path) } return nil } // Traverses the element's children (breadth-first) until visitor() returns false or traversal is completed func traverseDown(visitor: (AXUIElement) -> Bool) { var queue: [AXUIElement] = [self] while !queue.isEmpty { let currentElement = queue.removeFirst() if let children = currentElement.children { for child in children { if !visitor(child) { return } queue.append(child) } } } } func traverseDownDFS( visitor: (AXUIElement) -> Bool, skipDescendantsWhere: ((AXUIElement) -> Bool)? = nil ) { var stack: [AXUIElement] = [self] while !stack.isEmpty { let currentElement = stack.removeLast() if !visitor(currentElement) { return } if skipDescendantsWhere?(currentElement) == true { continue } if let children = currentElement.children { stack.append(contentsOf: children.reversed()) } } } // Traverses the element's parents until visitor() returns false or traversal is completed func traverseUp(visitor: (AXUIElement) -> Bool, element: AXUIElement? = nil) { let element = element ?? self if let parent = element.parent { if !visitor(parent) { return } traverseUp(visitor: visitor, element: parent) } } func firstDescendantWhere( _ condition: (AXUIElement) -> Bool, skipDescendantsWhere: ((AXUIElement) -> Bool)? = nil ) -> AXUIElement? { var matchingDescendant: AXUIElement? traverseDownDFS(visitor: { element in if condition(element) { matchingDescendant = element return false // stop traversal } return true // continue traversal }, skipDescendantsWhere: skipDescendantsWhere) return matchingDescendant } // Find the first descendant whose identifier matches the given identifier func elementById(identifier: String) -> AXUIElement? { firstDescendantWhere { $0.id == identifier } } func firstAncestorWhere(_ condition: (AXUIElement) -> Bool) -> AXUIElement? { var matchingAncestor: AXUIElement? traverseUp { element in if condition(element) { matchingAncestor = element return false } return true } return matchingAncestor } // Index path of `element` relative to self func indexPath(for element: AXUIElement) -> [Int] { var path = [Int]() var currentElement: AXUIElement? = element while let current = currentElement, current != self { if let parent = current.parent { if let index = parent.children?.firstIndex(where: { $0 == current }) { path.insert(index, at: 0) } currentElement = parent } else { // No parent found, stop the loop break } } return path } // Finds the element at the given `indexPath`. `indexPath` must be relative to self. // If no element with the given index path exists, returns nil. func elementAtIndexPath(_ indexPath: [Int]) -> AXUIElement? { var currentElement: AXUIElement = self for index in indexPath { // currentElement.debugPrint() guard let children = currentElement.children, index < children.count else { // Index is out of bounds for the current element's children return nil } currentElement = children[index] } return currentElement } func findByPattern(_ pattern: AXPatternElement, within element: AXUIElement? = nil) -> AXUIElement? { let rootElement = element ?? self func matchesPattern(element: AXUIElement, pattern: AXPatternElement) -> Bool { let roleMatches = pattern.role == nil || element.role == pattern.role let subroleMatches = pattern.subrole == nil || element.subrole == pattern.subrole let titleMatches = pattern.title == nil || element.rawTitle == pattern.title let valueMatches = pattern.value == nil || element.selectedText == pattern.value let idMatches = pattern.id == nil || element.id == pattern.id return roleMatches && subroleMatches && titleMatches && valueMatches && idMatches } func search(element: AXUIElement, pattern: AXPatternElement) -> AXUIElement? { if matchesPattern(element: element, pattern: pattern) { var currentElement = element for childPattern in pattern.children { guard let children = currentElement.children else { return nil } var foundMatch = false for child in children { if let match = search(element: child, pattern: childPattern) { currentElement = match foundMatch = true break } } if !foundMatch { return nil } } return currentElement } else { guard let children = element.children else { return nil } for child in children { if let match = search(element: child, pattern: pattern) { return match } } } return nil } return search(element: rootElement, pattern: pattern) } // Finds the first text area element whose value looks like a URL. Note that Chrome // cuts off the URL scheme, so this only scans for a domain with an optional path. func findAddressField() -> AXUIElement? { firstDescendantWhere { descendant in if descendant.role == kAXTextFieldRole, let value = descendant.value { let pattern = "(([^:\\/\\s]+)\\.([^:\\/\\s\\.]+))(\\/\\w+)*(\\/([\\w\\-\\.]+[^#?\\s]+))?(.*)?(#[\\w\\-]+)?$" do { let regex = try NSRegularExpression(pattern: pattern) let range = NSRange(value.startIndex.. 0 } catch { // print("Regex error: \(error.localizedDescription)") return false } } return false } } func elementAtPosition(x: Float, y: Float) -> AXUIElement? { var element: AXUIElement? AXUIElementCopyElementAtPosition(self, x, y, &element) return element } func elementAtPositionRelativeToWindow(x: CGFloat, y: CGFloat) -> AXUIElement? { // swiftlint:disable force_unwrapping let windowPositionData = getValue(for: kAXPositionAttribute)! let windowSizeData = getValue(for: kAXSizeAttribute)! // swiftlint:enable force_unwrapping var windowPosition = CGPoint() var windowSize = CGSize() // swiftlint:disable force_cast if !AXValueGetValue(windowPositionData as! AXValue, .cgPoint, &windowPosition) || !AXValueGetValue(windowSizeData as! AXValue, .cgSize, &windowSize) { return nil } // swiftlint:enable force_cast let globalX = windowPosition.x + x let globalY = windowPosition.y + y if globalX < windowPosition.x || globalX > windowPosition.x + windowSize.width || globalY < windowPosition.y || globalY > windowPosition.y + windowSize.height { // Point is outside the window bounds return nil } var element: AXUIElement? let systemWideElement = AXUIElementCreateSystemWide() AXUIElementCopyElementAtPosition(systemWideElement, Float(globalX), Float(globalY), &element) return element } func debugPrintSubtree(element: AXUIElement? = nil, depth: Int = 0, highlight indexPath: [Int] = [], currentPath: [Int] = []) { let element = element ?? self if let children = element.children { for (index, child) in children.enumerated() { let indentation = String(repeating: " ", count: depth) let isMultiline = child.value?.contains("\n") ?? false let displayValue = isMultiline ? "[multiple lines]" : (child.value?.components(separatedBy: .newlines).first ?? "?") let ellipsedValue = displayValue.count > 50 ? String(displayValue.prefix(47)) + "..." : displayValue // Check if the current path matches the ancestry path let isOnIndexPath = currentPath + [index] == indexPath.prefix(currentPath.count + 1) let highlightIndicator = isOnIndexPath ? "→ " : " " print( "\(indentation)\(highlightIndicator)Role: \"\(child.role ?? "[undefined]")\", " + "Subrole: \(child.subrole ?? ""), " + "Id: \(id ?? ""), " + "Title: \(child.rawTitle ?? ""), " + "Value: \"\(ellipsedValue)\"" ) debugPrintSubtree(element: child, depth: depth + 1, highlight: indexPath, currentPath: currentPath + [index]) } } } func debugPrintAncestors() { traverseUp { element in let title = element.rawTitle ?? "" let role = element.role ?? "" let subrole = element.subrole ?? "" print("Title: \(title), Role: \(role), Subrole: \(subrole)") return true // Continue traversing up } } func debugPrint() { let isMultiline = value?.contains("\n") ?? false let displayValue = isMultiline ? "[multiple lines]" : (value?.components(separatedBy: .newlines).first ?? "?") let ellipsedValue = displayValue.count > 50 ? String(displayValue.prefix(47)) + "..." : displayValue print( "Role: \(role ?? ""), " + "Subrole: \(subrole ?? ""), " + "Id: \(id ?? ""), " + "Title: \(rawTitle ?? ""), " + "Value: \"\(ellipsedValue)\"" ) } } enum AXUIElementNotification { case selectedTextChanged case focusedUIElementChanged case focusedWindowChanged case valueChanged case uknown static func notificationFrom(string notification: String) -> AXUIElementNotification { switch notification { case "AXSelectedTextChanged": return .selectedTextChanged case "AXFocusedUIElementChanged": return .focusedUIElementChanged case "AXFocusedWindowChanged": return .focusedWindowChanged case "AXValueChanged": return .valueChanged default: return .uknown } } } ================================================ FILE: WakaTime/Extensions/BundleExtension.swift ================================================ import Foundation extension Bundle { var displayName: String { readFromInfoDict(key: "CFBundleDisplayName") ?? "unknown" } var version: String { readFromInfoDict(key: "CFBundleShortVersionString") ?? "unknown" } var build: String { readFromInfoDict(key: "CFBundleVersion") ?? "unknown" } private func readFromInfoDict(key: String) -> String? { infoDictionary?[key] as? String } } ================================================ FILE: WakaTime/Extensions/NSRunningApplicationExtension.swift ================================================ import Cocoa extension NSRunningApplication { var monitoredApp: MonitoredApp? { guard let bundleId = bundleIdentifier else { return nil } return .init(from: bundleId) } } ================================================ FILE: WakaTime/Extensions/OptionalExtension.swift ================================================ import Foundation extension Optional where Wrapped: Collection { var isEmpty: Bool { self?.isEmpty ?? true } } ================================================ FILE: WakaTime/Extensions/ProcessExtension.swift ================================================ import Foundation extension Process { // Runs process.launch() prior to macOS 13 or process.run() on macOS 13 or newer. // Adds Swift exception handling to process.launch(). func execute() throws { if #available(macOS 13.0, *) { // Use Process.run() on macOS 13 or newer. Process.run() throws Swift exceptions. try self.run() } else { // Note: Process.launch() can throw ObjC exceptions. For further reference, see // https://developer.apple.com/documentation/foundation/process/1414189-launch?changes=_3 try ObjC.catchException { self.launch() } } } } ================================================ FILE: WakaTime/Extensions/StringExtension.swift ================================================ import Foundation extension String { func matchesRegex(_ pattern: String) -> Bool { if let regex = try? NSRegularExpression(pattern: pattern) { let range = NSRange(location: 0, length: self.utf16.count) return regex.firstMatch(in: self, options: [], range: range) != nil } return false } func trim() -> String { self.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) } } ================================================ FILE: WakaTime/Extensions/URLExtension.swift ================================================ import Foundation extension URL { init?(stringWithoutScheme string: String) { if string.starts(with: "https?://") { self.init(string: string) } else { self.init(string: "https://\(string)") } } } ================================================ FILE: WakaTime/Helpers/Accessibility.swift ================================================ import AppKit class Accessibility { public static func requestA11yPermission() -> Bool { let prompt = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String let options: NSDictionary = [prompt: true] let appHasPermission = AXIsProcessTrustedWithOptions(options) return appHasPermission } } ================================================ FILE: WakaTime/Helpers/AppInfo.swift ================================================ import Foundation import Cocoa class AppInfo { static func getAppName(bundleId: String) -> String? { let workspace = NSWorkspace.shared guard let appUrl = workspace.urlForApplication(withBundleIdentifier: bundleId), let appBundle = Bundle(url: appUrl) else { return nil } return appBundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? appBundle.object(forInfoDictionaryKey: "CFBundleName") as? String } static func getAppName(_ app: NSRunningApplication) -> String? { guard let bundleId = app.bundleIdentifier else { return nil } return getAppName(bundleId: bundleId) } static func getAppNameForHeartbeat(_ app: NSRunningApplication) -> String? { guard let appName = getAppName(app) else { return nil } return appName.filter { !$0.isWhitespace } } static func getIcon(file path: String) -> NSImage? { guard FileManager.default.fileExists(atPath: path) else { return nil } return NSWorkspace.shared.icon(forFile: path) } static func getIcon(bundleId: String) -> NSImage? { guard let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleId) else { return nil } return getIcon(file: url.absoluteURL.path) } } ================================================ FILE: WakaTime/Helpers/Dependencies.swift ================================================ import Foundation // swiftlint:disable force_unwrapping // swiftlint:disable force_try class Dependencies { public static var twelveHours = 43200 public static func installDependencies() { Task { if !(await isCLILatest()) { downloadCLI() } } } public static var isLocalDevBuild: Bool { Bundle.main.version == "local-build" } public static func recentBrowserExtension() async -> String? { guard let apiKey = ConfigFile.getSetting(section: "settings", key: "api_key"), !apiKey.isEmpty else { return nil } let url = "https://api.wakatime.com/api/v1/users/current/user_agents?api_key=\(apiKey)" let request = URLRequest(url: URL(string: url)!, cachePolicy: .reloadIgnoringCacheData) do { let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { return nil } struct Resp: Decodable { let data: [UserAgent] } struct UserAgent: Decodable { let isBrowserExtension: Bool let editor: String? let lastSeenAt: String? enum CodingKeys: String, CodingKey { case isBrowserExtension = "is_browser_extension" case editor case lastSeenAt = "last_seen_at" } } let release = try JSONDecoder().decode(Resp.self, from: data) let now = Date() for agent in release.data { guard agent.isBrowserExtension, let editor = agent.editor, !editor.isEmpty, let lastSeenAt = agent.lastSeenAt else { continue } let isoDateFormatter = ISO8601DateFormatter() isoDateFormatter.timeZone = TimeZone(secondsFromGMT: 0) isoDateFormatter.formatOptions = [.withInternetDateTime] if let lastSeen = isoDateFormatter.date(from: lastSeenAt) { if Int(now.timeIntervalSince(lastSeen)) > twelveHours { break } } return agent.editor } } catch { Logging.default.log("Request error checking for conflicting browser extension: \(error)") return nil } return nil } private static func getLatestVersion() async throws -> String? { struct Release: Decodable { let tagName: String private enum CodingKeys: String, CodingKey { case tagName = "tag_name" } } let apiUrl = "https://api.github.com/repos/wakatime/wakatime-cli/releases/latest" var request = URLRequest(url: URL(string: apiUrl)!, cachePolicy: .reloadIgnoringCacheData) let lastModified = ConfigFile.getSetting(section: "internal", key: "cli_version_last_modified", internalConfig: true) let currentVersion = ConfigFile.getSetting(section: "internal", key: "cli_version", internalConfig: true) if let lastModified, currentVersion != nil { request.setValue(lastModified, forHTTPHeaderField: "If-Modified-Since") } let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { return nil } let now = Int(NSDate().timeIntervalSince1970) ConfigFile.setSetting(section: "internal", key: "cli_version_last_accessed", val: String(now), internalConfig: true) if httpResponse.statusCode == 304 { // Current version is still the latest version available return currentVersion } else if let lastModified = httpResponse.value(forHTTPHeaderField: "Last-Modified"), let release = try? JSONDecoder().decode(Release.self, from: data) { // Remote version successfully decoded ConfigFile.setSetting(section: "internal", key: "cli_version_last_modified", val: lastModified, internalConfig: true) ConfigFile.setSetting(section: "internal", key: "cli_version", val: release.tagName, internalConfig: true) return release.tagName } else { // Unexpected response return nil } } private static func isCLILatest() async -> Bool { let cli = NSString.path( withComponents: ConfigFile.resourcesFolder + ["wakatime-cli"] ) guard FileManager.default.fileExists(atPath: cli) else { return false } let outputPipe = Pipe() let process = Process() process.launchPath = cli process.arguments = ["--version"] process.standardOutput = outputPipe process.standardError = FileHandle.nullDevice do { try process.run() } catch { // Error running CLI process return false } let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile() let output = String(decoding: outputData, as: UTF8.self) // disable updating wakatime-cli when it was built from source if output.trim() == "" { return true } let version: String? if let regex = try? NSRegularExpression(pattern: "([0-9]+\\.[0-9]+\\.[0-9]+)"), let match = regex.firstMatch(in: output, range: NSRange(output.startIndex..., in: output)), let range = Range(match.range, in: output) { version = String(output[range]) } else { version = nil } let accessed = ConfigFile.getSetting(section: "internal", key: "cli_version_last_accessed", internalConfig: true) if let accessed, let accessed = Int(accessed) { let now = Int(NSDate().timeIntervalSince1970) let fourHours = 4 * 3600 if accessed + fourHours > now { Logging.default.log("Skip checking for wakatime-cli updates because recently checked \(now - accessed) seconds ago") return true } } let remoteVersion = try? await getLatestVersion() guard let remoteVersion else { // Could not retrieve remote version return true } if let version, "v" + version == remoteVersion { // Local version up to date return true } else { // Newer version available return false } } private static func downloadCLI() { let dir = NSString.path(withComponents: ConfigFile.resourcesFolder) if !FileManager.default.fileExists(atPath: dir) { do { try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true, attributes: nil) } catch { Logging.default.log(error.localizedDescription) } } let url = "https://github.com/wakatime/wakatime-cli/releases/latest/download/wakatime-cli-darwin-\(architecture()).zip" let zipFile = NSString.path(withComponents: ConfigFile.resourcesFolder + ["wakatime-cli.zip"]) let cli = NSString.path(withComponents: ConfigFile.resourcesFolder + ["wakatime-cli"]) let cliReal = NSString.path(withComponents: ConfigFile.resourcesFolder + ["wakatime-cli-darwin-\(architecture())"]) if FileManager.default.fileExists(atPath: zipFile) { do { try FileManager.default.removeItem(atPath: zipFile) } catch { Logging.default.log(error.localizedDescription) return } } URLSession.shared.downloadTask(with: URLRequest(url: URL(string: url)!)) { fileUrl, _, _ in guard let fileUrl else { return } do { // download wakatime-cli.zip try FileManager.default.moveItem(at: fileUrl, to: URL(fileURLWithPath: zipFile)) if FileManager.default.fileExists(atPath: cliReal) { do { try FileManager.default.removeItem(atPath: cliReal) } catch { Logging.default.log(error.localizedDescription) return } } // unzip wakatime-cli.zip let process = Process() process.launchPath = "/usr/bin/unzip" process.arguments = [zipFile, "-d", dir] process.standardOutput = FileHandle.nullDevice process.standardError = FileHandle.nullDevice process.launch() process.waitUntilExit() // cleanup wakatime-cli.zip try! FileManager.default.removeItem(atPath: zipFile) // create ~/.wakatime/wakatime-cli symlink do { try FileManager.default.removeItem(atPath: cli) } catch { } try! FileManager.default.createSymbolicLink(atPath: cli, withDestinationPath: cliReal) } catch { Logging.default.log(error.localizedDescription) } }.resume() } private static func architecture() -> String { var systeminfo = utsname() uname(&systeminfo) let machine = withUnsafeBytes(of: &systeminfo.machine) {bufPtr -> String in let data = Data(bufPtr) if let lastIndex = data.lastIndex(where: { $0 != 0 }) { return String(data: data[0...lastIndex], encoding: .isoLatin1)! } else { return String(data: data, encoding: .isoLatin1)! } } if machine == "x86_64" { return "amd64" } return "arm64" } } // swiftlint:enable force_unwrapping // swiftlint:enable force_try ================================================ FILE: WakaTime/Helpers/EventSourceObserver.swift ================================================ import CoreGraphics class EventSourceObserver { let pollIntervalInSeconds: CFTimeInterval var timer: Timer = Timer(timeInterval: 1, repeats: false) { _ in } init(pollIntervalInSeconds: CFTimeInterval) { self.pollIntervalInSeconds = pollIntervalInSeconds timer.invalidate() } func start(activityDetected: @escaping () -> Void) { stop() timer = Timer.scheduledTimer(withTimeInterval: pollIntervalInSeconds, repeats: true) { [self] _ in let secondsSinceLastKeyPress = Self.checkForKeyPresses() let secondsSinceLastMouseMoved = Self.checkForMouseActivity() if secondsSinceLastKeyPress < pollIntervalInSeconds || secondsSinceLastMouseMoved < pollIntervalInSeconds { activityDetected() } } } func stop() { timer.invalidate() } static private func checkForKeyPresses() -> CFTimeInterval { CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: .keyDown) } static private func checkForMouseActivity() -> CFTimeInterval { CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: .mouseMoved) } } ================================================ FILE: WakaTime/Helpers/FilterManager.swift ================================================ import Cocoa class FilterManager { static func filterBrowsedSites(_ url: String) -> Bool { let patterns = Self.parseList(PropertiesManager.currentFilterList) if patterns.isEmpty { return true } // Create scheme-prefixed address versions to allow regular expressions // that incorporate a scheme to match let httpUrl = "http://" + url let httpsUrl = "https://" + url switch PropertiesManager.filterType { case .denylist: for pattern in patterns { if url.matchesRegex(pattern) || httpUrl.matchesRegex(pattern) || httpsUrl.matchesRegex(pattern) { // Address matches a pattern on the denylist. Filter the site out. return false } } case .allowlist: let addressMatchesAllowlist = patterns.contains { pattern in url.matchesRegex(pattern) || httpUrl.matchesRegex(pattern) || httpsUrl.matchesRegex(pattern) } // If none of the patterns on the allowlist match the given address, filter the site out if !addressMatchesAllowlist { return false } } // The given address passed all filters and will be included return true } private static func parseList(_ listString: String) -> [String] { Self.sanitizeList(listString.components(separatedBy: "\n")) } private static func sanitizeList(_ urls: [String]) -> [String] { urls.map { $0.trimmingCharacters(in: CharacterSet.whitespaces) } } } ================================================ FILE: WakaTime/Helpers/MonitoringManager.swift ================================================ import Cocoa import Foundation class MonitoringManager { enum MonitoringState { case on case off } static func isAppMonitored(for bundleId: String) -> Bool { allMonitoredApps.contains(bundleId) } static func isAppMonitored(_ app: NSRunningApplication) -> Bool { guard let bundleId = app.bundleIdentifier else { return false } return isAppMonitored(for: bundleId) } static func isAppElectron(for bundleId: String) -> Bool { MonitoredApp.electronAppIds.contains(bundleId) } static func isAppElectron(_ app: NSRunningApplication) -> Bool { guard let bundleId = app.bundleIdentifier else { return false } return isAppElectron(for: bundleId) } static func isAppXcode(_ app: NSRunningApplication) -> Bool { guard let bundleId = app.bundleIdentifier else { return false } return bundleId == MonitoredApp.xcode.rawValue } static func isAppBrowser(for bundleId: String) -> Bool { MonitoredApp.browserAppIds.contains(bundleId) } static func isAppBrowser(_ app: NSRunningApplication) -> Bool { guard let bundleId = app.bundleIdentifier else { return false } return isAppBrowser(for: bundleId) } static func heartbeatData(_ app: NSRunningApplication) -> HeartbeatData? { let pid = app.processIdentifier guard let activeWindow = AXUIElementCreateApplication(pid).activeWindow, let entity = entity(for: app, activeWindow), let entityUnwrapped = entity.0 else { return nil } let project = project(for: app, activeWindow) var language = language(for: app, activeWindow) if project != nil && language == nil { language = "<>" } let heartbeat = HeartbeatData( entity: entityUnwrapped, entityType: entity.1, project: project, language: language, category: category(for: app, activeWindow) ) return heartbeat } static var isMonitoringBrowsing: Bool { for bundleId in MonitoredApp.browserAppIds { guard AppInfo.getAppName(bundleId: bundleId) != nil, isAppMonitored(for: bundleId) else { continue } return true } return false } static var allMonitoredApps: [String] { if let bundleIds = UserDefaults.standard.stringArray(forKey: monitoringKey) { return bundleIds.filter { MonitoredApp.pluginAppIds[$0] == nil } } else { var bundleIds: [String] = [] let defaults = UserDefaults.standard.dictionaryRepresentation() for key in defaults.keys { if key.starts(with: "is_") && key.contains("_monitored") { if UserDefaults.standard.bool(forKey: key) { let bundleId = key.replacingOccurrences(of: "is_", with: "").replacingOccurrences(of: "_monitored", with: "") bundleIds.append(bundleId) } UserDefaults.standard.removeObject(forKey: key) } } UserDefaults.standard.set(bundleIds, forKey: monitoringKey) UserDefaults.standard.synchronize() return bundleIds.filter { MonitoredApp.pluginAppIds[$0] == nil } } } static func set(monitoringState: MonitoringState, for bundleId: String) { if monitoringState == .on { UserDefaults.standard.set(Array(Set(allMonitoredApps + [bundleId])), forKey: monitoringKey) } else { let apps = allMonitoredApps.filter { $0 != bundleId } UserDefaults.standard.set(apps, forKey: monitoringKey) } UserDefaults.standard.synchronize() } static func enableByDefault(_ bundleId: String) { if AppInfo.getIcon(bundleId: bundleId) != nil && AppInfo.getAppName(bundleId: bundleId) != nil { MonitoringManager.set(monitoringState: .on, for: bundleId) } let setAppId = bundleId.appending("-setapp") if AppInfo.getIcon(bundleId: setAppId) != nil && AppInfo.getAppName(bundleId: setAppId) != nil { MonitoringManager.set(monitoringState: .on, for: setAppId) } } static var monitoringKey = "wakatime_monitored_apps" static func entity(for app: NSRunningApplication, _ element: AXUIElement) -> (String?, EntityType)? { if MonitoringManager.isAppBrowser(app) { guard let url = currentBrowserUrl(for: app, element), FilterManager.filterBrowsedSites(url) else { return nil } guard PropertiesManager.domainPreference == .domain else { return (url, .url) } return (domainFromUrl(url), .domain) } guard let monitoredApp = app.monitoredApp else { return (title(for: app, element), .app) } switch monitoredApp { case .canva: // Canva obviously implements tabs in a different way than the tab content UI. // Due to this circumstance, it's possible to just sample an element from the // Canva window which is positioned underneath the tab bar and trace to the // web area root which appears to be properly titled. All the UI zoom settings // in Canva only change the tab content or sub content of the tab content, hence // this should be relatively safe. In cases where this fails, nil should be // returned as a consequence of the web area not being found. let someElem = element.elementAtPositionRelativeToWindow(x: 10, y: 60) let webArea = someElem?.firstAncestorWhere { $0.role == "AXWebArea" } return (webArea?.rawTitle, .app) case .notes: let skipHighCostElements: (AXUIElement) -> Bool = { element in guard let role = element.role else { return false } // If we don't skip them, Notes app itself costs a lot of CPU time to create AXUIElement for us to traverse. // AXOutline -> Folder Sidebar of Notes // AXTable -> List View of items of selected folder. It may contain thousands of rows, and // each row may have text and image element. if role == "AXOutline" || role == "AXTable" { return true } return false } // There's apparently two text editor implementations in Apple Notes. One uses a web view, // the other appears to be a native implementation based on the `ICTK2MacTextView` class. let webAreaElement = element.firstDescendantWhere({ $0.role == "AXWebArea" }, skipDescendantsWhere: skipHighCostElements ) if let webAreaElement { // WebView-based implementation let titleElement = webAreaElement.firstDescendantWhere { $0.role == kAXStaticTextRole } return (titleElement?.value, .app) } else { // ICTK2MacTextView let textAreaElement = element.firstDescendantWhere({ $0.role == kAXTextAreaRole }, skipDescendantsWhere: skipHighCostElements) if let value = textAreaElement?.value { let title = extractPrefix(value, separator: "\n") return (title, .app) } return nil } default: return (title(for: app, element), .app) } } // swiftlint:disable cyclomatic_complexity static func title(for app: NSRunningApplication, _ element: AXUIElement) -> String? { guard let monitoredApp = app.monitoredApp else { return extractPrefix(element.rawTitle) } switch monitoredApp { case .adobeaftereffect: return extractPrefix(element.rawTitle) case .adobebridge: return extractPrefix(element.rawTitle) case .adobeillustrator: return extractPrefix(element.rawTitle) case .adobemediaencoder: return extractPrefix(element.rawTitle) case .adobephotoshop: return extractPrefix(element.rawTitle) case .adobepremierepro: return extractPrefix(element.rawTitle) case .arcbrowser: fatalError("\(monitoredApp.rawValue) should never use window title as entity") case .beeper: return extractPrefix(element.rawTitle) case .brave: fatalError("\(monitoredApp.rawValue) should never use window title as entity") case .canva: fatalError("\(monitoredApp.rawValue) should never use window title as entity") case .chrome, .chromebeta, .chromecanary: fatalError("\(monitoredApp.rawValue) should never use window title as entity") case .figma: guard let title = extractPrefix(element.rawTitle, separator: " – "), title != "Figma", title != "Drafts" else { return nil } return title case .firefox: fatalError("\(monitoredApp.rawValue) should never use window title as entity") case .github: return extractPrefix(element.rawTitle, separator: " - ") case .imessage: return extractPrefix(element.rawTitle, separator: " - ") case .inkscape: return extractPrefix(element.rawTitle) case .iterm2: return extractPrefix(element.rawTitle, separator: " - ") case .linear: return extractPrefix(element.rawTitle, separator: " - ") case .miro: return extractSuffix(element.rawTitle) case .notes: fatalError("\(monitoredApp.rawValue) should never use window title as entity") case .notion: return extractPrefix(element.rawTitle, separator: " - ") case .postman: guard let title = extractPrefix(element.rawTitle, separator: " - ", fullTitle: true), title != "Postman" else { return nil } return title case .rocketchat: return extractPrefix(element.rawTitle) case .slack: return extractPrefix(element.rawTitle, separator: " - ") case .safari: fatalError("\(monitoredApp.rawValue) should never use window title as entity") case .safaripreview: fatalError("\(monitoredApp.rawValue) should never use window title as entity") case .tableplus: return extractPrefix(element.rawTitle, separator: " - ") case .terminal: return extractPrefix(element.rawTitle, separator: " - ") case .warp: guard let title = extractPrefix(element.rawTitle, separator: " - "), title != "Warp" else { return nil } return title case .wecom: return extractPrefix(element.rawTitle, separator: " - ") case .whatsapp: return extractPrefix(element.rawTitle, separator: " - ") case .xcode: fatalError("\(monitoredApp.rawValue) should never use window title as entity") case .zoom: return extractPrefix(element.rawTitle, separator: " - ") case .zed: return extractPrefix(element.rawTitle, separator: " — ") } } static func category(for app: NSRunningApplication, _ element: AXUIElement) -> Category { guard let monitoredApp = app.monitoredApp else { return .coding } if isAppBrowser(app) { guard let url = currentBrowserUrl(for: app, element) else { return .browsing } return category(from: url) } switch monitoredApp { case .adobeaftereffect: return .designing case .adobebridge: return .designing case .adobeillustrator: return .designing case .adobemediaencoder: return .designing case .adobephotoshop: return .designing case .adobepremierepro: return .designing case .arcbrowser: return .browsing case .beeper: return .communicating case .brave: return .browsing case .canva: return .designing case .chrome, .chromebeta, .chromecanary: return .browsing case .figma: return .designing case .firefox: return .browsing case .github: return .codereviewing case .imessage: return .communicating case .inkscape: return .designing case .iterm2: return .coding case .linear: return .planning case .miro: return .planning case .notes: return .writingdocs case .notion: return .writingdocs case .postman: return .debugging case .rocketchat: return .communicating case .slack: return .communicating case .safari: return .browsing case .safaripreview: return .browsing case .tableplus: return .debugging case .terminal: return .coding case .warp: return .coding case .wecom: return .communicating case .whatsapp: return .meeting case .xcode: fatalError("\(monitoredApp.rawValue) should never use window title") case .zoom: return .meeting case .zed: return .coding } } // swiftlint:enable cyclomatic_complexity static func category(from url: String) -> Category { let patterns = [ "github.com/[^/]+/[^/]+/pull/.*$", "gitlab.com/[^/]+/[^/]+/[^/]+/merge_requests/.*$", "bitbucket.org/[^/]+/[^/]+/pull-requests/.*$", ] for pattern in patterns { do { let regex = try NSRegularExpression(pattern: pattern) let nsrange = NSRange(url.startIndex.. String? { guard let monitoredApp = app.monitoredApp else { guard let url = currentBrowserUrl(for: app, element) else { return nil } return project(from: url) } // TODO: detect repo from GitHub Desktop Client if possible switch monitoredApp { case .slack: return extractSuffix(element.rawTitle, separator: " - ", offset: 1) case .zed: return extractSuffix(element.rawTitle, separator: " — ") default: guard let url = currentBrowserUrl(for: app, element) else { return nil } return project(from: url) } } struct Pattern { var expression: String var group: Int } static func project(from url: String) -> String? { let patterns: [Pattern] = [ Pattern(expression: "github.com/[^/]+/([^/]+)/?.*$", group: 1), Pattern(expression: "gitlab.com/[^/]+/([^/]+)/?.*$", group: 1), Pattern(expression: "bitbucket.org/[^/]+/([^/]+)/?.*$", group: 1), Pattern(expression: "app.circleci.com/.*/?(github|bitbucket|gitlab)/[^/]+/([^/]+)/?.*$", group: 2), Pattern(expression: "app.travis-ci.com/(github|bitbucket|gitlab)/[^/]+/([^/]+)/?.*$", group: 2), Pattern(expression: "app.travis-ci.org/(github|bitbucket|gitlab)/[^/]+/([^/]+)/?.*$", group: 2) ] for pattern in patterns { do { let regex = try NSRegularExpression(pattern: pattern.expression) let nsrange = NSRange(url.startIndex.. String? { guard let monitoredApp = app.monitoredApp else { return nil } switch monitoredApp { case .canva: return "Image (svg)" case .chrome, .chromebeta, .chromecanary: do { guard let url = currentBrowserUrl(for: app, element) else { return nil } let regex = try NSRegularExpression(pattern: "github.com/[^/]+/[^/]+/?$") let nsrange = NSRange(url.startIndex.. String? { guard let monitoredApp = app.monitoredApp else { return nil } var address: String? switch monitoredApp { case .arcbrowser: let addressField = element.findAddressField() address = addressField?.value case .brave: let addressField = element.findAddressField() address = addressField?.value case .chrome, .chromebeta, .chromecanary: let addressField = element.findAddressField() address = addressField?.value case .firefox: let addressField = element.findAddressField() address = addressField?.value case .linear: let projectLabel = element.firstDescendantWhere { $0.value == "Project" } let projectButton = projectLabel?.nextSibling?.firstDescendantWhere { $0.role == kAXButtonRole } return projectButton?.rawTitle case .safari: let addressField = element.elementById(identifier: "WEB_BROWSER_ADDRESS_AND_SEARCH_FIELD") address = addressField?.value case .safaripreview: let addressField = element.elementById(identifier: "WEB_BROWSER_ADDRESS_AND_SEARCH_FIELD") address = addressField?.value default: return nil } return address } static func extractPrefix(_ str: String?, separator: String? = nil, minCount: Int? = nil, fullTitle: Bool = false) -> String? { guard let str = str else { return nil } guard let separator = separator else { return getFirstPrefixMatch(str) } let parts = str.components(separatedBy: separator) guard !parts.isEmpty else { return nil } guard let item = parts.first else { return nil } if let minCount = minCount, minCount > 0, parts.count < minCount { return nil } if item.trimmingCharacters(in: .whitespacesAndNewlines) != "" { if fullTitle { return str.trimmingCharacters(in: .whitespacesAndNewlines) } return item.trimmingCharacters(in: .whitespacesAndNewlines) } return nil } static func extractSuffix(_ str: String?, separator: String? = nil, offset: Int = 0) -> String? { guard let str = str else { return nil } guard let separator = separator else { return getFirstSuffixMatch(str) } var parts = str.components(separatedBy: separator) guard !parts.isEmpty else { return nil } guard parts.count > 1 else { return nil } var i = offset while i > 0 { guard parts.count > 1 else { return nil } parts.removeLast() i += 1 } guard let item = parts.last else { return nil } if item.trimmingCharacters(in: .whitespacesAndNewlines) != "" { return item.trimmingCharacters(in: .whitespacesAndNewlines) } return nil } static func domainFromUrl(_ url: String) -> String? { guard let host = URL(stringWithoutScheme: url)?.host else { return nil } let domain = host.replacingOccurrences(of: "^www.", with: "", options: .regularExpression) guard let port = URL(stringWithoutScheme: url)?.port else { return domain } return "\(domain):\(port)" } static let separators = [ "-", "᠆", "‐", "‑", "‒", "–", "—", "―", "⸺", "⸻", "︱", "︲", "﹘", "﹣", "-", ] static func getFirstPrefixMatch(_ str: String) -> String { guard !str.isEmpty else { return str.trimmingCharacters(in: .whitespacesAndNewlines) } for separator in separators { let parts = str.components(separatedBy: separator) guard parts.count > 1 else { continue } guard let item = parts.first else { continue } let trimmed = item.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { continue } return trimmed } return str.trimmingCharacters(in: .whitespacesAndNewlines) } static func getFirstSuffixMatch(_ str: String) -> String { guard !str.isEmpty else { return str.trimmingCharacters(in: .whitespacesAndNewlines) } for separator in separators { let parts = str.components(separatedBy: separator) guard parts.count > 1 else { continue } guard let item = parts.last else { continue } let trimmed = item.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { continue } return trimmed } return str.trimmingCharacters(in: .whitespacesAndNewlines) } } struct HeartbeatData { var entity: String var entityType: EntityType var project: String? var language: String? var category: Category? } ================================================ FILE: WakaTime/Helpers/PropertiesManager.swift ================================================ import Foundation class PropertiesManager { enum DomainPreferenceType: String { case domain case url } enum FilterType: String { case denylist case allowlist } enum Keys: String { case shouldLaunchOnLogin = "launch_on_login" case shouldLogToFile = "log_to_file" case shouldRequestA11y = "request_a11y" case shouldAutomaticallyDownloadUpdates = "should_automatically_download_updates" case hasLaunchedBefore = "has_launched_before" case shouldDisplayTodayInStatusBar = "status_bar_text" case domainPreference = "domain_preference" case filterType = "filter_type" case denylist = "denylist" case allowlist = "allowlist" } static var shouldLaunchOnLogin: Bool { get { guard UserDefaults.standard.string(forKey: Keys.shouldLaunchOnLogin.rawValue) != nil else { UserDefaults.standard.set(true, forKey: Keys.shouldLaunchOnLogin.rawValue) return true } return UserDefaults.standard.bool(forKey: Keys.shouldLaunchOnLogin.rawValue) } set { UserDefaults.standard.set(newValue, forKey: Keys.shouldLaunchOnLogin.rawValue) UserDefaults.standard.synchronize() } } static var shouldLogToFile: Bool { get { guard UserDefaults.standard.string(forKey: Keys.shouldLogToFile.rawValue) != nil else { UserDefaults.standard.set(false, forKey: Keys.shouldLogToFile.rawValue) return false } return UserDefaults.standard.bool(forKey: Keys.shouldLogToFile.rawValue) } set { UserDefaults.standard.set(newValue, forKey: Keys.shouldLogToFile.rawValue) UserDefaults.standard.synchronize() if newValue { Logging.default.activateLoggingToFile() } else { Logging.default.deactivateLoggingToFile() } } } static var shouldAutomaticallyDownloadUpdates: Bool { get { guard UserDefaults.standard.string(forKey: Keys.shouldAutomaticallyDownloadUpdates.rawValue) != nil else { UserDefaults.standard.set(true, forKey: Keys.shouldAutomaticallyDownloadUpdates.rawValue) return true } return UserDefaults.standard.bool(forKey: Keys.shouldAutomaticallyDownloadUpdates.rawValue) } set { UserDefaults.standard.set(newValue, forKey: Keys.shouldAutomaticallyDownloadUpdates.rawValue) UserDefaults.standard.synchronize() } } static var shouldRequestA11yPermission: Bool { get { guard UserDefaults.standard.string(forKey: Keys.shouldRequestA11y.rawValue) != nil else { UserDefaults.standard.set(true, forKey: Keys.shouldRequestA11y.rawValue) return true } return UserDefaults.standard.bool(forKey: Keys.shouldRequestA11y.rawValue) } set { UserDefaults.standard.set(newValue, forKey: Keys.shouldRequestA11y.rawValue) UserDefaults.standard.synchronize() } } static var shouldDisplayTodayInStatusBar: Bool { get { guard UserDefaults.standard.string(forKey: Keys.shouldDisplayTodayInStatusBar.rawValue) != nil else { UserDefaults.standard.set(true, forKey: Keys.shouldDisplayTodayInStatusBar.rawValue) return true } return UserDefaults.standard.bool(forKey: Keys.shouldDisplayTodayInStatusBar.rawValue) } set { UserDefaults.standard.set(newValue, forKey: Keys.shouldDisplayTodayInStatusBar.rawValue) UserDefaults.standard.synchronize() } } static var hasLaunchedBefore: Bool { get { guard UserDefaults.standard.string(forKey: Keys.hasLaunchedBefore.rawValue) != nil else { return false } return UserDefaults.standard.bool(forKey: Keys.hasLaunchedBefore.rawValue) } set { UserDefaults.standard.set(newValue, forKey: Keys.hasLaunchedBefore.rawValue) UserDefaults.standard.synchronize() } } static var domainPreference: DomainPreferenceType { get { guard let domainPreferenceString = UserDefaults.standard.string(forKey: Keys.domainPreference.rawValue) else { return .domain } return DomainPreferenceType(rawValue: domainPreferenceString) ?? .domain } set { UserDefaults.standard.set(newValue.rawValue, forKey: Keys.domainPreference.rawValue) UserDefaults.standard.synchronize() } } static var filterType: FilterType { get { guard let filterTypeString = UserDefaults.standard.string(forKey: Keys.filterType.rawValue) else { return .allowlist } return FilterType(rawValue: filterTypeString) ?? .denylist } set { UserDefaults.standard.set(newValue.rawValue, forKey: Keys.filterType.rawValue) UserDefaults.standard.synchronize() } } static var denylist: String { get { guard let denylist = UserDefaults.standard.string(forKey: Keys.denylist.rawValue) else { return "" } return denylist } set { UserDefaults.standard.set(newValue, forKey: Keys.denylist.rawValue) UserDefaults.standard.synchronize() } } static var allowlist: String { get { guard let allowlist = UserDefaults.standard.string(forKey: Keys.allowlist.rawValue) else { return "https?://(\\w\\.)*github\\.com/\n" + "https?://(\\w\\.)*gitlab\\.com/\n" + "^stackoverflow\\.com/\n" + "^docs\\.python\\.org/\n" + "https?://(\\w\\.)*golang\\.org/\n" + "https?://(\\w\\.)*go\\.dev/\n" + "https?://(\\w\\.)*npmjs\\.com/\n" + "https?//localhost[:\\d+]?/" } return allowlist } set { UserDefaults.standard.set(newValue, forKey: Keys.allowlist.rawValue) UserDefaults.standard.synchronize() } } static var currentFilterList: String { switch Self.filterType { case .denylist: return Self.denylist case .allowlist: return Self.allowlist } } } ================================================ FILE: WakaTime/Helpers/SettingsManager.swift ================================================ import Foundation import ServiceManagement class SettingsManager { #if !SIMULATE_OLD_MACOS static let simulateOldMacOS = false #else static let simulateOldMacOS = true #endif static func loginItemRegistered() -> Bool { if #available(macOS 13.0, *) { return SMAppService.mainApp.status != .notFound } else { return false } } static func shouldRegisterAsLoginItem() -> Bool { guard !loginItemRegistered(), PropertiesManager.shouldLaunchOnLogin else { return false } return !Dependencies.isLocalDevBuild } static func registerAsLoginItem() { PropertiesManager.shouldLaunchOnLogin = true // Use SMAppService on macOS 13 or newer to add WakaTime to the "Open at Login" list and SMLoginItemSetEnabled // for older versions of macOS to add WakaTime to the "Allow in Background" list if #available(macOS 13.0, *), !simulateOldMacOS { do { try SMAppService.mainApp.register() Logging.default.log("Registered for login") } catch let error { Logging.default.log(error.localizedDescription) } } else { if SMLoginItemSetEnabled("macos-wakatime.WakaTimeHelper" as CFString, true) { Logging.default.log("Login item enabled successfully.") } else { Logging.default.log("Failed to enable login item.") } } } static func unregisterAsLoginItem() { PropertiesManager.shouldLaunchOnLogin = false if #available(macOS 13.0, *), !simulateOldMacOS { do { try SMAppService.mainApp.unregister() Logging.default.log("Unregistered for login") } catch let error { Logging.default.log(error.localizedDescription) } } else { if SMLoginItemSetEnabled("macos-wakatime.WakaTimeHelper" as CFString, false) { Logging.default.log("Login item disabled successfully.") } else { Logging.default.log("Failed to disable login item.") } } } } ================================================ FILE: WakaTime/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json ================================================ { "images" : [ { "filename" : "16.png", "idiom" : "mac", "scale" : "1x", "size" : "16x16" }, { "filename" : "32.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" }, { "filename" : "32.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" }, { "filename" : "64.png", "idiom" : "mac", "scale" : "2x", "size" : "32x32" }, { "filename" : "128.png", "idiom" : "mac", "scale" : "1x", "size" : "128x128" }, { "filename" : "256.png", "idiom" : "mac", "scale" : "2x", "size" : "128x128" }, { "filename" : "256.png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" }, { "filename" : "512.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" }, { "filename" : "512.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" }, { "filename" : "1024.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: WakaTime/Resources/Assets.xcassets/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: WakaTime/Resources/Assets.xcassets/WakaTime.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "scale" : "1x" }, { "filename" : "32.png", "idiom" : "universal", "scale" : "2x" }, { "filename" : "64.png", "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 }, "properties" : { "template-rendering-intent" : "template" } } ================================================ FILE: WakaTime/Resources/Assets.xcassets/WakaTimeDisabled.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "scale" : "1x" }, { "filename" : "32.png", "idiom" : "universal", "scale" : "2x" }, { "filename" : "64.png", "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 }, "properties" : { "template-rendering-intent" : "template" } } ================================================ FILE: WakaTime/Resources/GoogleService-Info.plist ================================================ CLIENT_ID 473173879994-q78fldrplnkhrr4oa5h10mkv15g1ng2g.apps.googleusercontent.com REVERSED_CLIENT_ID com.googleusercontent.apps.473173879994-q78fldrplnkhrr4oa5h10mkv15g1ng2g API_KEY AIzaSyDBdPD7ZIMm7XtDueuhOBd-rx7kF3jIc0U GCM_SENDER_ID 473173879994 PLIST_VERSION 1 BUNDLE_ID macos-wakatime.WakaTime PROJECT_ID wakatime-macos-desktop-app STORAGE_BUCKET wakatime-macos-desktop-app.appspot.com IS_ADS_ENABLED IS_ANALYTICS_ENABLED IS_APPINVITE_ENABLED IS_GCM_ENABLED IS_SIGNIN_ENABLED GOOGLE_APP_ID 1:473173879994:ios:c9a7680a9e365351282683 ================================================ FILE: WakaTime/Utils/Atomic.swift ================================================ import Foundation @propertyWrapper struct Atomic { private var value: Value private let lock = NSLock() init(wrappedValue value: Value) { self.value = value } var wrappedValue: Value { get { getValue() } set { setValue(newValue) } } func getValue() -> Value { lock.lock() defer { lock.unlock() } return value } mutating func setValue(_ newValue: Value) { lock.lock() defer { lock.unlock() } value = newValue } } ================================================ FILE: WakaTime/Utils/Logging.swift ================================================ import Foundation import os.log class Logging { static let `default` = Logging() private var filePath: String? private init() {} // Configures logging to also write to a file at the given path. func configure(filePath: String) { self.filePath = filePath } func activateLoggingToFile() { let userHome = FileManager.default.homeDirectoryForCurrentUser.pathComponents let logFilePath = NSString.path(withComponents: userHome + [".wakatime", "macos-wakatime.log"]) configure(filePath: logFilePath) } func deactivateLoggingToFile() { filePath = nil } func log(_ message: String, type: OSLogType = .default) { os_log("%{public}@", log: .default, type: type, message) if let filePath { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS" let timestamp = dateFormatter.string(from: Date()) let logMessage = "\(timestamp): \(message)\n" // Attempt to append the log message to the log file if let fileHandle = FileHandle(forWritingAtPath: filePath) { fileHandle.seekToEndOfFile() if let data = logMessage.data(using: .utf8) { fileHandle.write(data) } fileHandle.closeFile() } else { // If the file does not exist, create it try? logMessage.write(toFile: filePath, atomically: true, encoding: .utf8) } } } } ================================================ FILE: WakaTime/Utils/ObjC.h ================================================ #ifndef ObjC_h #define ObjC_h #import @interface ObjC : NSObject + (BOOL)catchException:(void(^)(void))tryBlock error:(__autoreleasing NSError **)error; @end #endif /* ObjC_h */ ================================================ FILE: WakaTime/Utils/ObjC.m ================================================ #import #import "ObjC.h" @implementation ObjC + (BOOL)catchException:(void(^)(void))tryBlock error:(__autoreleasing NSError **)error { @try { tryBlock(); return YES; } @catch (NSException *exception) { *error = [[NSError alloc] initWithDomain:exception.name code:0 userInfo:exception.userInfo]; return NO; } } @end ================================================ FILE: WakaTime/Views/MonitoredAppsView.swift ================================================ import AppKit class MonitoredAppsView: NSView, NSOutlineViewDataSource, NSOutlineViewDelegate { struct AppData: Equatable { let bundleId: String let icon: NSImage let name: String let tag: Int } private var outlineView: NSOutlineView! private var runningApps: [AppData] = [] private func refreshRunningApps() { var apps = [AppData]() let bundleIds = sort(Array(Set(MonitoredApp.allBundleIds + getRunningApps() + MonitoringManager.allMonitoredApps))) var index = 0 for bundleId in bundleIds { if let icon = AppInfo.getIcon(bundleId: bundleId), let name = AppInfo.getAppName(bundleId: bundleId) { apps.append(AppData(bundleId: bundleId, icon: icon, name: name, tag: index)) index += 1 } let setAppBundleId = bundleId.appending("-setapp") if let icon = AppInfo.getIcon(bundleId: setAppBundleId), let name = AppInfo.getAppName(bundleId: setAppBundleId) { apps.append(AppData(bundleId: setAppBundleId, icon: icon, name: name, tag: index)) index += 1 } } runningApps = apps } private func getRunningApps() -> [String] { var ids: [String] = [] for runningApp in NSWorkspace.shared.runningApplications where runningApp.activationPolicy == .regular { guard let id = runningApp.bundleIdentifier else { continue } let bundleId = id.replacingOccurrences(of: "-setapp$", with: "", options: .regularExpression) guard !MonitoredApp.unsupportedAppIds.contains(where: { $0 == bundleId }), !MonitoredApp.allBundleIds.contains(where: { $0 == bundleId }) else { continue } ids.append(bundleId) } return ids } private func sort(_ bundleIds: [String]) -> [String] { bundleIds.sorted { let left = AppInfo.getAppName(bundleId: $0) ?? $0 let right = AppInfo.getAppName(bundleId: $1) ?? $1 return left.localizedCaseInsensitiveCompare(right) == ComparisonResult.orderedAscending } } override init(frame frameRect: NSRect) { super.init(frame: frameRect) setupOutlineView() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func setupOutlineView() { let scrollView = NSScrollView() scrollView.hasVerticalScroller = true outlineView = NSOutlineView() outlineView.dataSource = self outlineView.delegate = self scrollView.documentView = outlineView addSubview(scrollView) scrollView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), scrollView.topAnchor.constraint(equalTo: topAnchor), scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), ]) let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("AppColumn")) outlineView.addTableColumn(column) outlineView.headerView = nil outlineView.outlineTableColumn = column outlineView.indentationPerLevel = 0.0 } func reloadData() { refreshRunningApps() outlineView.reloadData() } // MARK: NSOutlineViewDataSource func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int { runningApps.count } func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool { false } func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any { runningApps[index] } // MARK: NSOutlineViewDelegate func outlineView(_ outlineView: NSOutlineView, shouldSelectItem item: Any) -> Bool { false } func outlineView(_ outlineView: NSOutlineView, heightOfRowByItem item: Any) -> CGFloat { 50 } func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? { guard let appData = item as? AppData else { return nil } let cellView = outlineView.makeView( withIdentifier: NSUserInterfaceItemIdentifier("AppCell"), owner: self ) as? NSTableCellView ?? NSTableCellView() // Clear existing subviews to prevent duplication cellView.subviews.forEach { $0.removeFromSuperview() } let imageView = NSImageView() imageView.translatesAutoresizingMaskIntoConstraints = false imageView.image = appData.icon imageView.image?.size = NSSize(width: 20, height: 20) let nameLabel = NSTextField(labelWithString: appData.name) nameLabel.translatesAutoresizingMaskIntoConstraints = false let action = switchOrLink(appData) cellView.addSubview(imageView) cellView.addSubview(nameLabel) cellView.addSubview(action) // Determine if the current item is the last in the list let isLastItem = runningApps.last == appData if !isLastItem { let divider = NSView() divider.translatesAutoresizingMaskIntoConstraints = false divider.wantsLayer = true divider.layer?.backgroundColor = NSColor.separatorColor.cgColor cellView.addSubview(divider) NSLayoutConstraint.activate([ divider.heightAnchor.constraint(equalToConstant: 1), divider.leadingAnchor.constraint(equalTo: cellView.leadingAnchor), divider.trailingAnchor.constraint(equalTo: cellView.trailingAnchor), divider.bottomAnchor.constraint(equalTo: cellView.bottomAnchor) ]) } NSLayoutConstraint.activate([ imageView.leadingAnchor.constraint(equalTo: cellView.leadingAnchor, constant: 5), imageView.centerYAnchor.constraint(equalTo: cellView.centerYAnchor), imageView.widthAnchor.constraint(equalToConstant: 20), imageView.heightAnchor.constraint(equalToConstant: 20), nameLabel.leadingAnchor.constraint(equalTo: imageView.trailingAnchor, constant: 10), nameLabel.centerYAnchor.constraint(equalTo: cellView.centerYAnchor), nameLabel.trailingAnchor.constraint(equalTo: action.leadingAnchor, constant: -5), action.trailingAnchor.constraint(equalTo: cellView.trailingAnchor, constant: -10), action.centerYAnchor.constraint(equalTo: cellView.centerYAnchor), ]) return cellView } func switchOrLink(_ appData: AppData) -> NSView { if MonitoredApp.pluginAppIds[appData.bundleId] != nil { let button = NSButton() button.translatesAutoresizingMaskIntoConstraints = false button.bezelStyle = NSButton.BezelStyle.rounded button.title = "Install plugin" button.action = #selector(clickInstallPlugin(_:)) button.widthAnchor.constraint(equalToConstant: 100).isActive = true button.tag = appData.tag return button } let isMonitored = MonitoringManager.isAppMonitored(for: appData.bundleId) let switchControl = NSSwitch() switchControl.translatesAutoresizingMaskIntoConstraints = false switchControl.state = isMonitored ? .on : .off switchControl.target = self switchControl.action = #selector(switchToggled(_:)) switchControl.tag = appData.tag return switchControl } @objc func switchToggled(_ sender: NSSwitch) { let appData = runningApps[sender.tag] MonitoringManager.set(monitoringState: sender.state == .on ? .on : .off, for: appData.bundleId) } @objc func clickInstallPlugin(_ sender: NSButton) { let appData = runningApps[sender.tag] guard let path = MonitoredApp.pluginAppIds[appData.bundleId], let url = URL(string: "https://wakatime.com/\(path)") else { return } NSWorkspace.shared.open(url) } } ================================================ FILE: WakaTime/Views/SettingsView.swift ================================================ import AppKit class SettingsView: NSView, NSTextFieldDelegate, NSTextViewDelegate { var delegate: StatusBarDelegate? // MARK: API Key lazy var apiKeyLabel: NSTextField = { NSTextField(labelWithString: "WakaTime API Key:") }() lazy var apiKeyTextField: WKTextField = { let textField = WKTextField(frame: .zero) textField.stringValue = ConfigFile.getSetting(section: "settings", key: "api_key") ?? "" textField.delegate = self return textField }() lazy var apiKeyStackView: NSStackView = { let stack = NSStackView(views: [apiKeyLabel, apiKeyTextField]) stack.alignment = .leading stack.orientation = .vertical stack.spacing = 5 return stack }() // MARK: Checkboxes lazy var launchAtLoginCheckbox: NSButton = { let checkbox = NSButton( checkboxWithTitle: "Launch at login", target: self, action: #selector(launchAtLoginCheckboxClicked) ) checkbox.state = PropertiesManager.shouldLaunchOnLogin ? .on : .off return checkbox }() lazy var enableLoggingCheckbox: NSButton = { let checkbox = NSButton( checkboxWithTitle: "Enable logging to ~/.wakatime/macos-wakatime.log", target: self, action: #selector(enableLoggingCheckboxClicked) ) checkbox.state = PropertiesManager.shouldLogToFile ? .on : .off return checkbox }() lazy var statusBarTextCheckbox: NSButton = { let checkbox = NSButton( checkboxWithTitle: "Show today’s time in status bar", target: self, action: #selector(enableStatusBarTextCheckboxClicked) ) checkbox.state = PropertiesManager.shouldDisplayTodayInStatusBar ? .on : .off return checkbox }() lazy var requestA11yCheckbox: NSButton = { let checkbox = NSButton( checkboxWithTitle: "Enable stats from Xcode by requesting accessibility permission", target: self, action: #selector(enableA11yCheckboxClicked) ) checkbox.state = PropertiesManager.shouldRequestA11yPermission ? .on : .off return checkbox }() lazy var checkboxesStackView: NSStackView = { let stack = NSStackView(views: [launchAtLoginCheckbox, statusBarTextCheckbox, requestA11yCheckbox, enableLoggingCheckbox]) stack.alignment = .leading stack.orientation = .vertical stack.spacing = 10 return stack }() // MARK: Domain Preference lazy var browserLabel: NSTextField = { var label = NSTextField(labelWithString: "The settings below are only applicable because you’ve enabled " + "monitoring a browser in the Monitored Apps menu.") label.lineBreakMode = .byWordWrapping // Enable word wrapping label.maximumNumberOfLines = 0 // Set to 0 to allow unlimited lines label.preferredMaxLayoutWidth = 380 return label }() lazy var domainPreferenceLabel: NSTextField = { NSTextField(labelWithString: "Browser Tracking:") }() lazy var domainPreferenceControl: NSSegmentedControl = { let control = NSSegmentedControl() control.segmentStyle = .texturedRounded control.segmentCount = 2 control.setLabel("Domain only", forSegment: 0) control.setLabel("Full url", forSegment: 1) control.trackingMode = .selectOne // Ensure only one option can be selected at a time control.action = #selector(domainPreferenceDidChange(_:)) return control }() // MARK: Denylist/Allowlist lazy var filterTypeLabel: NSTextField = { NSTextField(labelWithString: "Browser Filter:") }() lazy var filterSegmentedControl: NSSegmentedControl = { let control = NSSegmentedControl() control.segmentStyle = .texturedRounded control.segmentCount = 2 control.setLabel("All except denied sites", forSegment: 0) control.setLabel("Only allowed sites", forSegment: 1) control.trackingMode = .selectOne // Ensure only one option can be selected at a time control.action = #selector(segmentedControlDidChange(_:)) return control }() lazy var filterListLabel: NSTextField = { NSTextField(labelWithString: "") }() lazy var filterTextView: NSTextView = { let textView = NSTextView() textView.isEditable = true textView.isRichText = false textView.isSelectable = true textView.autoresizingMask = [.width] textView.isVerticallyResizable = true textView.isHorizontallyResizable = false textView.textContainer?.containerSize = NSSize(width: self.bounds.width, height: CGFloat.greatestFiniteMagnitude) textView.textContainer?.widthTracksTextView = true textView.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular) textView.delegate = self return textView }() lazy var filterScrollView: NSScrollView = { let scrollView = NSScrollView() scrollView.hasVerticalScroller = true scrollView.documentView = filterTextView scrollView.translatesAutoresizingMaskIntoConstraints = false scrollView.heightAnchor.constraint(greaterThanOrEqualToConstant: 100).isActive = true return scrollView }() lazy var filterRemarksLabel: NSTextField = { var label = NSTextField(labelWithString: "") label.lineBreakMode = .byWordWrapping // Enable word wrapping label.maximumNumberOfLines = 0 // Set to 0 to allow unlimited lines label.preferredMaxLayoutWidth = 380 return label }() lazy var domainStackView: NSStackView = { let stack = NSStackView(views: [ domainPreferenceLabel, domainPreferenceControl ]) stack.alignment = .leading stack.orientation = .vertical stack.spacing = 10 stack.translatesAutoresizingMaskIntoConstraints = false return stack }() lazy var filterStackView: NSStackView = { let stack = NSStackView(views: [ filterTypeLabel, filterSegmentedControl, filterListLabel, filterScrollView, filterRemarksLabel ]) stack.alignment = .leading stack.orientation = .vertical stack.spacing = 10 stack.translatesAutoresizingMaskIntoConstraints = false return stack }() // MARK: Version Label lazy var versionLabel: NSTextField = { let versionString = "Version: \(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "")" let versionLabel = NSTextField(labelWithString: versionString) return versionLabel }() lazy var stackView: NSStackView = { let stackView = NSStackView(views: [ apiKeyStackView, checkboxesStackView, browserLabel, domainStackView, filterStackView, versionLabel ]) stackView.alignment = .leading stackView.orientation = .vertical stackView.spacing = 25 stackView.distribution = .equalSpacing stackView.edgeInsets = NSEdgeInsets(top: 10, left: 10, bottom: 10, right: 10) stackView.translatesAutoresizingMaskIntoConstraints = false stackView.addConstraint( NSLayoutConstraint( item: filterStackView, attribute: .width, relatedBy: .equal, toItem: stackView, attribute: .width, multiplier: 1, constant: -(stackView.edgeInsets.left + stackView.edgeInsets.right) ) ) return stackView }() // MARK: Lifecycle init() { super.init(frame: .zero) addSubview(stackView) setupConstraints() setBrowserVisibility() updateDomainPreference(animate: false) updateFilterControls(animate: false) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } // MARK: Callbacks @objc func launchAtLoginCheckboxClicked() { PropertiesManager.shouldLaunchOnLogin = launchAtLoginCheckbox.state == .on if launchAtLoginCheckbox.state == .on { SettingsManager.registerAsLoginItem() } else { SettingsManager.unregisterAsLoginItem() } } @objc func enableLoggingCheckboxClicked() { PropertiesManager.shouldLogToFile = enableLoggingCheckbox.state == .on if enableLoggingCheckbox.state == .on { PropertiesManager.shouldLogToFile = true } else { PropertiesManager.shouldLogToFile = false } } @objc func enableStatusBarTextCheckboxClicked() { PropertiesManager.shouldDisplayTodayInStatusBar = statusBarTextCheckbox.state == .on if statusBarTextCheckbox.state == .on { PropertiesManager.shouldDisplayTodayInStatusBar = true } else { PropertiesManager.shouldDisplayTodayInStatusBar = false } delegate?.fetchToday() } @objc func enableA11yCheckboxClicked() { PropertiesManager.shouldRequestA11yPermission = requestA11yCheckbox.state == .on if requestA11yCheckbox.state == .on { PropertiesManager.shouldRequestA11yPermission = true } else { PropertiesManager.shouldRequestA11yPermission = false } } @objc func domainPreferenceDidChange(_ sender: NSSegmentedControl) { PropertiesManager.domainPreference = sender.selectedSegment == 0 ? .domain : .url updateDomainPreference(animate: true) } @objc func segmentedControlDidChange(_ sender: NSSegmentedControl) { PropertiesManager.filterType = sender.selectedSegment == 0 ? .denylist : .allowlist updateFilterControls(animate: true) } // MARK: NSTextFieldDelegate func controlTextDidChange(_ obj: Notification) { ConfigFile.setSetting(section: "settings", key: "api_key", val: apiKeyTextField.stringValue) } // MARK: NSTextViewDelegate func textDidChange(_ notification: Notification) { guard let textView = notification.object as? NSTextView else { return } switch PropertiesManager.filterType { case .denylist: PropertiesManager.denylist = textView.string case .allowlist: PropertiesManager.allowlist = textView.string } } // MARK: Constraints private func setupConstraints() { NSLayoutConstraint.activate([ stackView.topAnchor.constraint(equalTo: topAnchor, constant: 20), stackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20), stackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20), ]) } func setBrowserVisibility() { if MonitoringManager.isMonitoringBrowsing { browserLabel.isHidden = false domainStackView.isHidden = false filterStackView.isHidden = false } else { browserLabel.isHidden = true domainStackView.isHidden = true filterStackView.isHidden = true } adjustWindowSize(animate: false) } // MARK: State Helpers private func updateDomainPreference(animate: Bool) { var selectedSegment: Int switch PropertiesManager.domainPreference { case .domain: selectedSegment = 0 case .url: selectedSegment = 1 } domainPreferenceControl.setSelected(true, forSegment: selectedSegment) adjustWindowSize(animate: animate) } private func updateFilterControls(animate: Bool) { let denylistTitle = "Denylist:" let denylistRemarks = "Sites that you don't want to show in your reports. " + "Only applicable to browsing activity. One regex per line." let allowlistTitle = "Allowlist:" let allowlistRemarks = "Sites that you want to show in your reports. " + "Only applicable to browsing activity. One regex per line." var title: String var remarks: String var list: String var selectedSegment: Int switch PropertiesManager.filterType { case .denylist: title = denylistTitle remarks = denylistRemarks list = PropertiesManager.denylist selectedSegment = 0 case .allowlist: title = allowlistTitle remarks = allowlistRemarks list = PropertiesManager.allowlist selectedSegment = 1 } filterListLabel.stringValue = title filterRemarksLabel.stringValue = remarks filterTextView.string = list filterSegmentedControl.setSelected(true, forSegment: selectedSegment) adjustWindowSize(animate: animate) } func adjustWindowSize(animate: Bool) { guard let window = self.window else { return } let newHeight = stackView.fittingSize.height + 70 var newWindowFrame = window.frame newWindowFrame.size.height = newHeight newWindowFrame.origin.y += window.frame.height - newWindowFrame.height // Adjust origin to keep the top-left corner stationary window.setFrame(newWindowFrame, display: true, animate: animate) } } ================================================ FILE: WakaTime/WakaTime-Bridging-Header.h ================================================ // // Use this file to import your target's public headers that you would like to expose to Swift. // #import "Utils/ObjC.h" ================================================ FILE: WakaTime/WakaTime-Info.plist ================================================ CFBundleIdentifier LSUIElement CFBundleURLTypes CFBundleTypeRole Editor CFBundleURLSchemes wakatime ================================================ FILE: WakaTime/WakaTime.entitlements ================================================ com.apple.security.app-sandbox com.apple.security.notifications ================================================ FILE: WakaTime/WakaTime.swift ================================================ import AppKit import Firebase import Foundation class WakaTime: HeartbeatEventHandler { // MARK: Watcher let watcher = Watcher() let delegate: StatusBarDelegate // MARK: Watcher State // Note: The lastEntity and lastTime member vars are read and written on a worker thread. // To ensure that they can be accessed concurrently from other threads without issues, // they are declared atomic here @Atomic var lastEntity = "" @Atomic var lastTime = 0 @Atomic var lastCategory = Category.coding // MARK: Initialization and Setup init(_ delegate: StatusBarDelegate) { self.delegate = delegate Dependencies.installDependencies() if SettingsManager.shouldRegisterAsLoginItem() { SettingsManager.registerAsLoginItem() } if PropertiesManager.shouldRequestA11yPermission && !Accessibility.requestA11yPermission() { delegate.a11yStatusChanged(false) } configureFirebase() checkForApiKey() watcher.heartbeatEventHandler = self watcher.statusBarDelegate = delegate if !PropertiesManager.hasLaunchedBefore { for bundleId in MonitoredApp.defaultEnabledApps { MonitoringManager.enableByDefault(bundleId) } PropertiesManager.hasLaunchedBefore = true } } private func configureFirebase() { // Needed for uncaught exception reporting UserDefaults.standard.register( defaults: ["NSApplicationCrashOnExceptions": true] ) FirebaseApp.configure() } private func checkForApiKey() { let apiKey = ConfigFile.getSetting(section: "settings", key: "api_key") if apiKey.isEmpty { openSettingsDeeplink() } } private func openSettingsDeeplink() { guard let url = DeepLink.settings.url else { return } NSWorkspace.shared.open(url) } private func openMonitoredAppsDeeplink() { guard let url = DeepLink.monitoredApps.url else { return } NSWorkspace.shared.open(url) } // MARK: Watcher Event Handling private func shouldSendHeartbeat(entity: String, time: Int, isWrite: Bool, category: Category) -> Bool { if isWrite { return true } if category != lastCategory { return true } if !entity.isEmpty && entity != lastEntity { return true } if lastTime + 120 < time { return true } return false } public func handleHeartbeatEvent( app: NSRunningApplication, entity: String, entityType: EntityType, project: String?, language: String?, category: Category?, isWrite: Bool) { let time = Int(NSDate().timeIntervalSince1970) let category = category ?? Category.coding guard shouldSendHeartbeat(entity: entity, time: time, isWrite: isWrite, category: category) else { return } // make sure we should be tracking this app to avoid race condition bugs // do this after shouldSendHeartbeat for better performance because handleEvent may // be called frequently guard MonitoringManager.isAppMonitored(app) else { return } guard let appName = AppInfo.getAppNameForHeartbeat(app), let appVersion = watcher.getAppVersion(app) else { return } let cli = NSString.path( withComponents: ConfigFile.resourcesFolder + ["wakatime-cli"] ) let process = Process() process.launchPath = cli var args = [ "--entity", entity, "--entity-type", entityType.rawValue, "--category", category.rawValue.replacingOccurrences(of: "_", with: " "), "--plugin", "\(appName)/\(appVersion) macos-wakatime/" + Bundle.main.version, "--alternate-branch", "<>", ] if let project = project { args.append("--project") args.append(project) } else { args.append("--alternate-project") args.append("<>") } if let language = language { args.append("--language") args.append(language) } if isWrite { args.append("--write") } Logging.default.log("Sending heartbeat with: \(args)") lastEntity = entity lastTime = time lastCategory = category process.arguments = args process.standardOutput = FileHandle.nullDevice process.standardError = FileHandle.nullDevice do { // Use WakaTime's custom execute() method to run the process. This will call Process.launch() // with ObjC exception bridging on macOS 12 or earlier and Process.run() on macOS 13 or newer. try process.execute() } catch { Logging.default.log("Failed to run wakatime-cli: \(error)") } delegate.fetchToday() } } enum DeepLink: String { case settings case monitoredApps var url: URL? { URL(string: "wakatime://\(self)") } } enum EntityType: String { case file case app case domain case url } enum Category: String { case browsing case building case codereviewing = "code reviewing" case coding case communicating case debugging case designing case indexing case learning case manualtesting = "manual testing" case meeting case planning case researching case runningtests = "running tests" case translating case writingdocs = "writing docs" case writingtests = "writing tests" } protocol StatusBarDelegate: AnyObject { func a11yStatusChanged(_ hasPermission: Bool) func toastNotification(_ title: String) func fetchToday() } protocol HeartbeatEventHandler { func handleHeartbeatEvent( app: NSRunningApplication, entity: String, entityType: EntityType, project: String?, language: String?, category: Category?, isWrite: Bool) } ================================================ FILE: WakaTime/Watchers/FileSavedWatcher.swift ================================================ class FileMonitor { private let fileURL: URL private var dispatchObject: DispatchSourceFileSystemObject? public var fileChangedEventHandler: (() -> Void)? init?(filePath: URL, queue: DispatchQueue) { self.fileURL = filePath let folderURL = fileURL.deletingLastPathComponent() // monitor enclosing folder to track changes by Xcode let descriptor = open(folderURL.path, O_EVTONLY) guard descriptor >= -1 else { Logging.default.log("open failed: \(descriptor)"); return nil } dispatchObject = DispatchSource.makeFileSystemObjectSource(fileDescriptor: descriptor, eventMask: .write, queue: queue) dispatchObject?.setEventHandler { [weak self] in self?.fileChangedEventHandler?() } dispatchObject?.setCancelHandler { close(descriptor) } dispatchObject?.activate() } deinit { dispatchObject?.cancel() } } ================================================ FILE: WakaTime/Watchers/MonitoredApp.swift ================================================ import AppKit enum MonitoredApp: String, CaseIterable { case adobeaftereffect = "com.adobe.AfterEffects" case adobebridge = "com.adobe.bridge14" case adobeillustrator = "com.adobe.illustrator" case adobemediaencoder = "com.adobe.ame.application.24" case adobephotoshop = "com.adobe.Photoshop" case adobepremierepro = "com.adobe.PremierePro.24" case arcbrowser = "company.thebrowser.Browser" case beeper = "im.beeper" case brave = "com.brave.Browser" case canva = "com.canva.CanvaDesktop" case chrome = "com.google.Chrome" case chromebeta = "com.google.Chrome.beta" case chromecanary = "com.google.Chrome.canary" case figma = "com.figma.Desktop" case firefox = "org.mozilla.firefox" case github = "com.github.GitHubClient" case imessage = "com.apple.MobileSMS" case inkscape = "org.inkscape.Inkscape" case iterm2 = "com.googlecode.iterm2" case linear = "com.linear" case miro = "com.electron.realtimeboard" case notes = "com.apple.Notes" case notion = "notion.id" case postman = "com.postmanlabs.mac" case rocketchat = "chat.rocket" case safari = "com.apple.Safari" case safaripreview = "com.apple.SafariTechnologyPreview" case slack = "com.tinyspeck.slackmacgap" case tableplus = "com.tinyapp.TablePlus" case terminal = "com.apple.Terminal" case warp = "dev.warp.Warp-Stable" case wecom = "com.tencent.WeWorkMac" case whatsapp = "net.whatsapp.WhatsApp" case xcode = "com.apple.dt.Xcode" case zed = "dev.zed.Zed" case zoom = "us.zoom.xos" init?(from bundleId: String) { if let app = MonitoredApp(rawValue: bundleId) { self = app } else if let app = MonitoredApp(rawValue: bundleId.replacingOccurrences(of: "-setapp$", with: "", options: .regularExpression)) { self = app } else { return nil } } // Hide these from the Monitored Apps menu static let unsupportedAppIds = [ "com.apple.finder", "macos-wakatime.WakaTime", ] // link to plugin install pages with wakatime.com domain prepended for apps with plugins available static let pluginAppIds: [String: String] = [ "aptana.studio": "aptana", "com.google.android.studio": "android-studio", "com.jetbrains.CLion": "clion", "com.jetbrains.DataSpell": "dataspell", "com.jetbrains.PhpStorm": "phpstorm", "com.jetbrains.PyCharm": "pycharm", "com.jetbrains.pycharm.ce": "pycharm", "com.jetbrains.RubyMine": "rubymine", "com.jetbrains.RustRover": "rustrover", "com.jetbrains.WebStorm": "webstorm", "com.jetbrains.goland": "goland", "com.jetbrains.intellij": "intellij-idea", "com.jetbrains.intellij.ce": "intellij-idea", "com.jetbrains.rider": "rider", "com.microsoft.VSCode": "vs-code", "com.microsoft.VSCodeInsiders": "vs-code", "com.Roblox.RobloxStudio": "roblox-studio", "com.sublimetext.2": "sublime", "com.sublimetext.3": "sublime", "com.sublimetext.4": "sublime", "com.todesktop.230313mzl4w4u92": "cursor", "com.visualstudio.code.oss": "vs-code", "com.vscodium": "vs-code", "epp.package.committers": "eclipse", "epp.package.cpp": "eclipse", "epp.package.dsl": "eclipse", "epp.package.embedcpp": "eclipse", "epp.package.java": "eclipse", "epp.package.jee": "eclipse", "epp.package.modeling": "eclipse", "epp.package.parallel": "eclipse", "epp.package.php": "eclipse", "epp.package.rcp": "eclipse", "epp.package.scout": "eclipse", "org.vim.MacVim": "vim", ] static var allBundleIds: [String] { MonitoredApp.allCases.map { $0.rawValue } } static let electronAppIds = [ MonitoredApp.figma.rawValue, MonitoredApp.slack.rawValue, ] static let browserAppIds = [ MonitoredApp.arcbrowser.rawValue, MonitoredApp.brave.rawValue, MonitoredApp.chrome.rawValue, MonitoredApp.chromebeta.rawValue, MonitoredApp.chromecanary.rawValue, MonitoredApp.firefox.rawValue, MonitoredApp.safari.rawValue, MonitoredApp.safaripreview.rawValue, ] // list apps which are enabled by default on first run static let defaultEnabledApps = [ MonitoredApp.canva.rawValue, MonitoredApp.figma.rawValue, MonitoredApp.github.rawValue, MonitoredApp.linear.rawValue, MonitoredApp.notes.rawValue, MonitoredApp.notion.rawValue, MonitoredApp.postman.rawValue, MonitoredApp.tableplus.rawValue, MonitoredApp.xcode.rawValue, MonitoredApp.zoom.rawValue, MonitoredApp.zed.rawValue, ] } ================================================ FILE: WakaTime/Watchers/Watcher.swift ================================================ import Cocoa import Foundation import AppKit class Watcher: NSObject { private let callbackQueue = DispatchQueue(label: "com.WakaTime.Watcher.callbackQueue", qos: .utility) private let monitorQueue = DispatchQueue(label: "com.WakaTime.Watcher.monitorQueue", qos: .utility) var appVersions: [String: String] = [:] var eventSourceObserver: EventSourceObserver? var heartbeatEventHandler: HeartbeatEventHandler? var statusBarDelegate: StatusBarDelegate? var lastCheckedA11y = Date() var isBuilding = false var activeApp: NSRunningApplication? private var observer: AXObserver? private var observingElement: AXUIElement? private var observingActivityTextElement: AXUIElement? private var fileMonitor: FileMonitor? private var selectedText: String? private var lastValidHeartbeatForApp = [String: HeartbeatData]() override init() { super.init() eventSourceObserver = EventSourceObserver(pollIntervalInSeconds: 1) NSWorkspace.shared.notificationCenter.addObserver( self, selector: #selector(appChanged), name: NSWorkspace.didActivateApplicationNotification, object: nil ) if let app = NSWorkspace.shared.frontmostApplication { handleAppChanged(app) } } deinit { NSWorkspace.shared.notificationCenter.removeObserver(self) // needed prior macOS 11 only } @objc private func appChanged(_ notification: Notification) { guard let newApp = notification.userInfo?["NSWorkspaceApplicationKey"] as? NSRunningApplication else { return } handleAppChanged(newApp) } private func handleAppChanged(_ app: NSRunningApplication) { if app != activeApp { // swiftlint:disable line_length Logging.default.log("App changed from \(activeApp?.localizedName ?? "nil") to \(app.localizedName ?? "nil") (\(app.bundleIdentifier ?? "nil"))") eventSourceObserver?.stop() // swiftlint:enable line_length if let oldApp = activeApp { unwatch(app: oldApp) } activeApp = app self.statusBarDelegate?.fetchToday() if let bundleId = app.bundleIdentifier, MonitoringManager.isAppMonitored(for: bundleId) { watch(app: app) } } setAppVersion(app) } private func setAppVersion(_ app: NSRunningApplication) { guard let id = app.bundleIdentifier, appVersions[id] == nil, let url = app.bundleURL, let bundle = Bundle(url: url) else { return } appVersions[id] = "\(bundle.version)-\(bundle.build)".filter { !$0.isWhitespace } } public func getAppVersion(_ app: NSRunningApplication) -> String? { guard let id = app.bundleIdentifier else { return nil } return appVersions[id] } private func watch(app: NSRunningApplication) { setAppVersion(app) do { if MonitoringManager.isAppElectron(app) { let pid = app.processIdentifier let axApp = AXUIElementCreateApplication(pid) let result = AXUIElementSetAttributeValue(axApp, "AXManualAccessibility" as CFString, true as CFTypeRef) if result.rawValue != 0 { let appName = app.localizedName ?? "UnknownApp" Logging.default.log("Setting AXManualAccessibility on \(appName) failed (\(result.rawValue))") } } let observer = try AXObserver.create(appID: app.processIdentifier, callback: observerCallback) let this = Unmanaged.passUnretained(self).toOpaque() let axApp = AXUIElementCreateApplication(app.processIdentifier) try observer.add(notification: kAXFocusedUIElementChangedNotification, element: axApp, refcon: this) try observer.add(notification: kAXFocusedWindowChangedNotification, element: axApp, refcon: this) try observer.add(notification: kAXSelectedTextChangedNotification, element: axApp, refcon: this) if MonitoringManager.isAppElectron(app) { try observer.add(notification: kAXValueChangedNotification, element: axApp, refcon: this) } observer.addToRunLoop() self.observer = observer self.observingElement = axApp self.statusBarDelegate?.a11yStatusChanged(true) if MonitoringManager.isAppXcode(app), let activeWindow = axApp.activeWindow { if let currentPath = activeWindow.currentPath { self.documentPath = currentPath } observeActivityText(activeWindow: activeWindow) } else { eventSourceObserver?.start { [weak self] in self?.callbackQueue.async { guard let app = self?.activeApp, !MonitoringManager.isAppXcode(app), let bundleId = app.bundleIdentifier else { return } var heartbeat = MonitoringManager.heartbeatData(app) if let heartbeat { self?.lastValidHeartbeatForApp[bundleId] = heartbeat } else { heartbeat = self?.lastValidHeartbeatForApp[bundleId] } if let heartbeat { self?.heartbeatEventHandler?.handleHeartbeatEvent( app: app, entity: heartbeat.entity, entityType: heartbeat.entityType, project: heartbeat.project, language: heartbeat.language, category: heartbeat.category, isWrite: false ) } } } } } catch { Logging.default.log("Failed to setup AXObserver: \(error.localizedDescription)") guard PropertiesManager.shouldRequestA11yPermission else { return } // TODO: App could be still launching, retry setting AXObserver in 20 seconds for this app if lastCheckedA11y.timeIntervalSinceNow > 60 { lastCheckedA11y = Date() self.statusBarDelegate?.a11yStatusChanged(Accessibility.requestA11yPermission()) } } } private func unwatch(app: NSRunningApplication) { if let observer { observer.removeFromRunLoop() guard let observingElement else { fatalError("observingElement should not be nil here") } try? observer.remove(notification: kAXFocusedUIElementChangedNotification, element: observingElement) try? observer.remove(notification: kAXFocusedWindowChangedNotification, element: observingElement) try? observer.remove(notification: kAXSelectedTextChangedNotification, element: observingElement) if MonitoringManager.isAppElectron(app) { try? observer.remove(notification: kAXValueChangedNotification, element: observingElement) } self.observingElement = nil self.observer = nil } } func observeActivityText(activeWindow: AXUIElement) { let this = Unmanaged.passUnretained(self).toOpaque() activeWindow.traverseDown { element in if let id = element.id, id == "Activity Text" { // Remove previously observed "Activity Text" value observer, if any if let observingActivityTextElement { try? self.observer?.remove(notification: kAXValueChangedNotification, element: observingActivityTextElement) } do { // Update the current isBuilding state when the observed "Activity Text" UI element changes self.isBuilding = checkIsBuilding(activityText: element.value) if let path = self.documentPath { self.handleNotificationEvent(path: path, isWrite: false) } // Try to add observer to the current "Activity Text" UI element try self.observer?.add(notification: kAXValueChangedNotification, element: element, refcon: this) observingActivityTextElement = element } catch { observingActivityTextElement = nil } return false // "Activity Text" element found, abort traversal } return true // continue traversal } } func checkIsBuilding(activityText: String?) -> Bool { activityText == "Build" || (activityText?.contains("Building") == true) } var documentPath: URL? { didSet { if documentPath != oldValue { guard let newPath = documentPath else { return } Logging.default.log("Document changed: \(newPath)") handleNotificationEvent(path: newPath, isWrite: false) fileMonitor = nil fileMonitor = FileMonitor(filePath: newPath, queue: monitorQueue) fileMonitor?.fileChangedEventHandler = { [weak self] in self?.handleNotificationEvent(path: newPath, isWrite: true) } } } } public func handleNotificationEvent(path: URL, isWrite: Bool) { callbackQueue.async { guard let app = self.activeApp else { return } self.heartbeatEventHandler?.handleHeartbeatEvent( app: app, entity: path.path, entityType: .file, project: nil, language: nil, category: self.isBuilding ? Category.building : Category.coding, isWrite: isWrite ) } } } private func observerCallback( _ observer: AXObserver, _ element: AXUIElement, _ notification: CFString, _ refcon: UnsafeMutableRawPointer? ) { guard let refcon = refcon else { return } let this = Unmanaged.fromOpaque(refcon).takeUnretainedValue() guard let app = this.activeApp else { return } let axNotification = AXUIElementNotification.notificationFrom(string: notification as String) switch axNotification { case .selectedTextChanged: if MonitoringManager.isAppXcode(app) { guard !element.selectedText.isEmpty, let currentPath = element.currentPath else { return } this.heartbeatEventHandler?.handleHeartbeatEvent( app: app, entity: currentPath.path, entityType: EntityType.file, project: nil, language: nil, category: this.isBuilding ? Category.building : Category.coding, isWrite: false) } case .focusedUIElementChanged: if MonitoringManager.isAppXcode(app) { guard let currentPath = element.currentPath else { return } this.documentPath = currentPath } case .focusedWindowChanged: if MonitoringManager.isAppXcode(app) { this.observeActivityText(activeWindow: element) } case .valueChanged: if MonitoringManager.isAppXcode(app) { if let id = element.id, id == "Activity Text" { this.isBuilding = this.checkIsBuilding(activityText: element.value) if let path = this.documentPath { this.handleNotificationEvent(path: path, isWrite: false) } } } default: break } } ================================================ FILE: WakaTime/WindowControllers/MonitoredAppsWindowController.swift ================================================ import AppKit class MonitoredAppsWindowController: NSWindowController { let monitoredAppsView = MonitoredAppsView() convenience init() { self.init(window: nil) let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 400, height: 450), styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false ) window.center() window.title = "Monitored Apps" window.contentView = monitoredAppsView self.window = window } override func showWindow(_ sender: Any?) { monitoredAppsView.reloadData() super.showWindow(sender) } } ================================================ FILE: WakaTime/WindowControllers/SettingsWindowController.swift ================================================ import AppKit class SettingsWindowController: NSWindowController, NSTextFieldDelegate { public let settingsView = SettingsView() convenience init() { self.init(window: nil) let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 440, height: 470), styleMask: [.titled, .closable], backing: .buffered, defer: false ) window.center() window.title = "Settings" window.contentView = settingsView self.window = window settingsView.adjustWindowSize(animate: false) } } ================================================ FILE: WakaTime/main.swift ================================================ import Cocoa let delegate = AppDelegate() let application = NSApplication.shared application.delegate = delegate application.run() ================================================ FILE: WakaTime Helper/AppDelegate.swift ================================================ import Cocoa class AppDelegate: NSObject, NSApplicationDelegate { struct Constants { static let mainAppBundleID = "macos-wakatime.WakaTime" } func applicationDidFinishLaunching(_ aNotification: Notification) { let userHome = FileManager.default.homeDirectoryForCurrentUser.pathComponents let logFilePath = NSString.path(withComponents: userHome + [".wakatime", "macos-wakatime-helper.log"]) Logging.default.configure(filePath: logFilePath) Logging.default.log("Starting WakaTime Helper") let runningApps = NSWorkspace.shared.runningApplications let isRunning = runningApps.contains { $0.bundleIdentifier == Constants.mainAppBundleID } if !isRunning { Logging.default.log("WakaTime is not running") var path = Bundle.main.bundlePath as NSString for _ in 1...4 { path = path.deletingLastPathComponent as NSString } let fileURL = URL(fileURLWithPath: path as String) Logging.default.log("Attempting to open WakaTime at \"\(fileURL.absoluteString)\"") NSWorkspace.shared.openApplication( at: fileURL, configuration: NSWorkspace.OpenConfiguration() ) { _, error in if let error { Logging.default.log(error.localizedDescription) } } } else { Logging.default.log("WakaTime is already running") } } } ================================================ FILE: WakaTime Helper/Logging.swift ================================================ import Foundation import os.log class Logging { static let `default` = Logging() private var filePath: String? private init() {} // Configures logging to also write to a file at the given path. func configure(filePath: String) { self.filePath = filePath } func log(_ message: String, type: OSLogType = .default) { os_log("%{public}@", log: .default, type: type, message) if let filePath = self.filePath { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS" let timestamp = dateFormatter.string(from: Date()) let logMessage = "\(timestamp): \(message)\n" // Attempt to append the log message to the log file if let fileHandle = FileHandle(forWritingAtPath: filePath) { fileHandle.seekToEndOfFile() if let data = logMessage.data(using: .utf8) { fileHandle.write(data) } fileHandle.closeFile() } else { // If the file does not exist, create it try? logMessage.write(toFile: filePath, atomically: true, encoding: .utf8) } } } } ================================================ FILE: WakaTime Helper/WakaTime Helper-Info.plist ================================================ LSBackgroundOnly ================================================ FILE: WakaTime Helper/main.swift ================================================ import Cocoa let delegate = AppDelegate() let application = NSApplication.shared application.delegate = delegate application.run() ================================================ FILE: bin/prepare_changelog.sh ================================================ #!/bin/bash set -e if [[ $# -ne 2 ]]; then echo 'incorrect number of arguments' exit 1 fi # Read arguments branch=$1 changelog=$2 slack= clean_up() { changelog="${changelog//\`/}" changelog="${changelog//\'/}" changelog="${changelog//\"/}" } replace_for_release() { changelog="${changelog//'%'/'%25'}" changelog="${changelog//$'\n'/'%0A'}" changelog="${changelog//$'\r'/'%0D'}" } replace_for_slack() { slack="${slack//'%'/'%25'}" slack="${slack//$'\n'/'%0A'}" slack="${slack//$'\r'/'%0D'}" } slack_output_for_main() { local IFS=$'\n' # make newlines the only separator local temp= for j in ${changelog} do temp="${temp}$(echo "$j" | awk '{printf "";$1=""; print $0 }')\n" done slack="*Changelog*\n${temp}" } slack_output_for_release() { local IFS=$'\n' # make newlines the only separator local temp= for j in ${changelog} do temp="${temp}${j}\n" done slack="*Changelog*\n${temp}" } parse_for_main() { changelog=$(awk 'f;/## Changelog/{f=1}' <<< "$changelog") } parse_for_release() { changelog=$(awk 'f;/Changelog:/{f=1}' <<< "$changelog") } case $branch in main) parse_for_main clean_up slack_output_for_main replace_for_release ;; release) parse_for_release [ -z "$changelog" ] && exit 1 clean_up slack_output_for_release replace_for_release replace_for_slack ;; *) exit 1 ;; esac echo "::set-output name=changelog::${changelog}" echo "::set-output name=slack::${slack}" ================================================ FILE: project.yml ================================================ name: WakaTime options: bundleIdPrefix: macos-wakatime createIntermediateGroups: true packages: AppUpdater: url: https://github.com/alanhamlett/AppUpdater branch: master Firebase: url: https://github.com/firebase/firebase-ios-sdk from: 11.11.0 targets: WakaTime: type: application platform: macOS deploymentTarget: 10.15 sources: [WakaTime] settings: CURRENT_PROJECT_VERSION: local-build MARKETING_VERSION: local-build INFOPLIST_FILE: WakaTime/WakaTime-Info.plist GENERATE_INFOPLIST_FILE: YES CODE_SIGN_STYLE: Automatic DEVELOPMENT_TEAM: ${SV_DEVELOPMENT_TEAM} ENABLE_HARDENED_RUNTIME: YES DEAD_CODE_STRIPPING: YES SWIFT_OBJC_BRIDGING_HEADER: WakaTime/WakaTime-Bridging-Header.h postCompileScripts: - script: ./Scripts/Lint/swiftlint lint --quiet name: Swiftlint dependencies: - target: WakaTime Helper - package: AppUpdater - package: Firebase product: FirebaseCrashlytics postBuildScripts: - script: | LOGIN_ITEMS_DIR="$BUILT_PRODUCTS_DIR/$PRODUCT_NAME.app/Contents/Library/LoginItems" rm -rf "$LOGIN_ITEMS_DIR" mkdir -p "$LOGIN_ITEMS_DIR" mv "$BUILT_PRODUCTS_DIR/$PRODUCT_NAME.app/Contents/Resources/WakaTime Helper.app" "$LOGIN_ITEMS_DIR/" name: Move "WakaTime Helper.app" to LoginItems - script: Scripts/Firebase/upload-dSYM.sh name: Firebase inputFiles: - ${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${TARGET_NAME} - $(SRCROOT)/$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH) WakaTime Helper: type: application platform: macOS deploymentTarget: 10.15 sources: [WakaTime Helper] settings: CURRENT_PROJECT_VERSION: local-build MARKETING_VERSION: local-build INFOPLIST_FILE: WakaTime Helper/WakaTime Helper-Info.plist GENERATE_INFOPLIST_FILE: YES CODE_SIGN_STYLE: Automatic DEVELOPMENT_TEAM: ${SV_DEVELOPMENT_TEAM} ENABLE_HARDENED_RUNTIME: YES DEAD_CODE_STRIPPING: YES SKIP_INSTALL: YES postCompileScripts: - script: ./Scripts/Lint/swiftlint lint --quiet name: Swiftlint