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=?.\(\),><!`]*\{[ a-zA-Z0-9=?.\(\),><!`\"]*\}\n *(?!(?:return|guard))\S+)'
message: 'There should be an empty line after a guard'
severity: error
empty_line_after_super:
name: 'Empty Line After Super'
regex: '(^ *super\.[ a-zA-Z0-9=?.\(\)\{\}:,><!`\"]*\n *(?!(?:\}|return))\S+)'
message: 'There should be an empty line after super'
severity: error
================================================
FILE: .vscode/settings.json
================================================
{
"lldb.library": "/Applications/Xcode.app/Contents/SharedFrameworks/LLDB.framework/Versions/A/LLDB",
"githubPullRequests.ignoredPullRequestBranches": [
"main"
]
}
================================================
FILE: AUTHORS
================================================
WakaTime is written and maintained by Alan Hamlett and various contributors:
- Alan Hamlett <alan.hamlett@gmail.com>
- Carlos Henrique Gandarez <gandarez@gmail.com>
- Michael Mavris <@MMavrisPaleBlue>
- Tobias Lensing <@starbugs>
- Chris Pastl <chris@crispybits.app>
================================================
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 <http://www.freshconsulting.com/atomic-commits>.
- 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 <http://chris.beams.io/posts/git-commit> 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..<value.endIndex, in: value)
let matches = regex.numberOfMatches(in: value, options: [], range: range)
return matches > 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 ?? "<nil>"), " +
"Id: \(id ?? "<nil>"), " +
"Title: \(child.rawTitle ?? "<nil>"), " +
"Value: \"\(ellipsedValue)\""
)
debugPrintSubtree(element: child, depth: depth + 1, highlight: indexPath, currentPath: currentPath + [index])
}
}
}
func debugPrintAncestors() {
traverseUp { element in
let title = element.rawTitle ?? "<nil>"
let role = element.role ?? "<nil>"
let subrole = element.subrole ?? "<nil>"
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 ?? "<nil>"), " +
"Subrole: \(subrole ?? "<nil>"), " +
"Id: \(id ?? "<nil>"), " +
"Title: \(rawTitle ?? "<nil>"), " +
"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() == "<local-build>" {
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 = "<<LAST_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..<url.endIndex, in: url)
if regex.firstMatch(in: url, options: [], range: nsrange) != nil {
return .codereviewing
}
} catch {
Logging.default.log("Regex error: \(error)")
continue
}
}
return .coding
}
static func project(for app: NSRunningApplication, _ element: AXUIElement) -> 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..<url.endIndex, in: url)
if let match = regex.firstMatch(in: url, options: [], range: nsrange) {
// Adjusted to capture the right group based on the pattern.
// The group index might be 2 if the pattern includes a platform prefix before the project name.
let range = match.range(at: pattern.group)
if range.location != NSNotFound, let range = Range(range, in: url) {
return String(url[range])
}
}
} catch {
Logging.default.log("Regex error: \(error)")
continue
}
}
// Return nil if no pattern matches
return nil
}
static func language(for app: NSRunningApplication, _ element: AXUIElement) -> 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..<url.endIndex, in: url)
if regex.firstMatch(in: url, options: [], range: nsrange) != nil {
let languages = element.firstDescendantWhere { $0.role == "AXStaticText" && $0.value == "Languages" }
guard let languages = languages else { return nil }
guard let wrapper = languages.parent?.parent else { return nil }
let langList = wrapper.firstDescendantWhere { $0.role == "AXList" }
guard let langList = langList else { return nil }
let link = langList.firstDescendantWhere { $0.role == "AXLink" }
guard let link = link else { return nil }
let lang = link.firstDescendantWhere { $0.role == "AXStaticText" }
guard let lang = lang else { return nil }
return lang.value
}
return nil
} catch {
Logging.default.log("Error parsing language from browser: \(error)")
return nil
}
case .figma:
return "Image (svg)"
case .inkscape:
return "Image (svg)"
case .postman:
return "HTTP Request"
default:
return nil
}
}
static func currentBrowserUrl(for app: NSRunningApplication, _ element: AXUIElement) -> 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
================================================
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CLIENT_ID</key>
<string>473173879994-q78fldrplnkhrr4oa5h10mkv15g1ng2g.apps.googleusercontent.com</string>
<key>REVERSED_CLIENT_ID</key>
<string>com.googleusercontent.apps.473173879994-q78fldrplnkhrr4oa5h10mkv15g1ng2g</string>
<key>API_KEY</key>
<string>AIzaSyDBdPD7ZIMm7XtDueuhOBd-rx7kF3jIc0U</string>
<key>GCM_SENDER_ID</key>
<string>473173879994</string>
<key>PLIST_VERSION</key>
<string>1</string>
<key>BUNDLE_ID</key>
<string>macos-wakatime.WakaTime</string>
<key>PROJECT_ID</key>
<string>wakatime-macos-desktop-app</string>
<key>STORAGE_BUCKET</key>
<string>wakatime-macos-desktop-app.appspot.com</string>
<key>IS_ADS_ENABLED</key>
<false></false>
<key>IS_ANALYTICS_ENABLED</key>
<false></false>
<key>IS_APPINVITE_ENABLED</key>
<true></true>
<key>IS_GCM_ENABLED</key>
<true></true>
<key>IS_SIGNIN_ENABLED</key>
<true></true>
<key>GOOGLE_APP_ID</key>
<string>1:473173879994:ios:c9a7680a9e365351282683</string>
</dict>
</plist>
================================================
FILE: WakaTime/Utils/Atomic.swift
================================================
import Foundation
@propertyWrapper
struct Atomic<Value> {
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 <Foundation/Foundation.h>
@interface ObjC : NSObject
+ (BOOL)catchException:(void(^)(void))tryBlock error:(__autoreleasing NSError **)error;
@end
#endif /* ObjC_h */
================================================
FILE: WakaTime/Utils/ObjC.m
================================================
#import <Foundation/Foundation.h>
#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
================================================
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string></string>
<key>LSUIElement</key>
<true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>wakatime</string>
</array>
</dict>
</array>
</dict>
</plist>
================================================
FILE: WakaTime/WakaTime.entitlements
================================================
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<false/>
<key>com.apple.security.notifications</key>
<true/>
</dict>
</plist>
================================================
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",
"<<LAST_BRANCH>>",
]
if let project = project {
args.append("--project")
args.append(project)
} else {
args.append("--alternate-project")
args.append("<<LAST_PROJECT>>")
}
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<Watcher>.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
================================================
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>LSBackgroundOnly</key>
<true/>
</dict>
</plist>
================================================
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 "<https://github.com/wakatime/macos-wakatime/commit/"$1"|"$1">";$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
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
Condensed preview — 59 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (178K chars).
[
{
"path": ".gitattributes",
"chars": 1,
"preview": "\n"
},
{
"path": ".github/ISSUE_TEMPLATE/support-app-request.yaml",
"chars": 595,
"preview": "name: Support app request\ndescription: Suggest a new app for tracking\ntitle: \"Support new app: XXX\"\nlabels: enhancement\n"
},
{
"path": ".github/workflows/on_pull_request_linter.yml",
"chars": 977,
"preview": "name: Tests\n\non: pull_request\n\njobs:\n lint:\n runs-on: ubuntu-latest\n steps:\n -\n name: Lint allowed br"
},
{
"path": ".github/workflows/on_push.yml",
"chars": 9523,
"preview": "name: Release\n\non:\n pull_request:\n types: [opened, reopened, ready_for_review, synchronize]\n push:\n branches: [m"
},
{
"path": ".gitignore",
"chars": 72,
"preview": ".DS_Store\n*.xcodeproj\nxcuserdata/\nMint/\n.build/\nbuild/\nPackage.resolved\n"
},
{
"path": ".swiftlint.yml",
"chars": 2838,
"preview": "#\n# .swiftlint.yml\n#\n#\n\ndisabled_rules:\n - inclusive_language\n - nesting\n - redundant_string_enum_value\n - todo\n - "
},
{
"path": ".vscode/settings.json",
"chars": 183,
"preview": "{\n \"lldb.library\": \"/Applications/Xcode.app/Contents/SharedFrameworks/LLDB.framework/Versions/A/LLDB\",\n \"githubPul"
},
{
"path": "AUTHORS",
"chars": 268,
"preview": "WakaTime is written and maintained by Alan Hamlett and various contributors:\n\n- Alan Hamlett <alan.hamlett@gmail.com>\n- "
},
{
"path": "CONTRIBUTING.md",
"chars": 4272,
"preview": "# Contributing\n\n## Setup\n\nThis project depends on the [xcodegen](https://github.com/yonaskolb/XcodeGen?tab=readme-ov-fil"
},
{
"path": "LICENSE",
"chars": 1501,
"preview": "BSD 3-Clause License\n\nCopyright (c) 2023 Alan Hamlett.\n\nRedistribution and use in source and binary forms, with or witho"
},
{
"path": "README.md",
"chars": 1978,
"preview": "# macos-wakatime\n\nMac system tray app for automatic time tracking and metrics generated from your Xcode activity.\n\n## In"
},
{
"path": "Scripts/Firebase/upload-dSYM.sh",
"chars": 81,
"preview": "\"${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run\""
},
{
"path": "WakaTime/AppDelegate.swift",
"chars": 11437,
"preview": "import AppUpdater\nimport Cocoa\nimport UserNotifications\n\nclass AppDelegate: NSObject, NSApplicationDelegate, StatusBarDe"
},
{
"path": "WakaTime/ConfigFile.swift",
"chars": 3667,
"preview": "import Foundation\n\nstruct ConfigFile {\n private static var userHome: [String] {\n FileManager.default.homeDirec"
},
{
"path": "WakaTime/Controls/WKTextField.swift",
"chars": 1501,
"preview": "import AppKit\n\nclass WKTextField: NSTextField {\n override func performKeyEquivalent(with event: NSEvent) -> Bool {\n "
},
{
"path": "WakaTime/Extensions/AXObserverExtension.swift",
"chars": 2029,
"preview": "import AppKit\n\nextension AXObserver {\n static func create(appID: pid_t, callback: AXObserverCallback) throws -> AXObs"
},
{
"path": "WakaTime/Extensions/AXUIElementExtension.swift",
"chars": 15216,
"preview": "import AppKit\n\nstruct AXPatternElement {\n var role: String?\n var subrole: String?\n var id: String?\n var titl"
},
{
"path": "WakaTime/Extensions/BundleExtension.swift",
"chars": 448,
"preview": "import Foundation\n\nextension Bundle {\n var displayName: String {\n readFromInfoDict(key: \"CFBundleDisplayName\")"
},
{
"path": "WakaTime/Extensions/NSRunningApplicationExtension.swift",
"chars": 197,
"preview": "import Cocoa\n\nextension NSRunningApplication {\n var monitoredApp: MonitoredApp? {\n guard let bundleId = bundle"
},
{
"path": "WakaTime/Extensions/OptionalExtension.swift",
"chars": 128,
"preview": "import Foundation\n\nextension Optional where Wrapped: Collection {\n var isEmpty: Bool {\n self?.isEmpty ?? true\n"
},
{
"path": "WakaTime/Extensions/ProcessExtension.swift",
"chars": 654,
"preview": "import Foundation\n\nextension Process {\n // Runs process.launch() prior to macOS 13 or process.run() on macOS 13 or ne"
},
{
"path": "WakaTime/Extensions/StringExtension.swift",
"chars": 455,
"preview": "import Foundation\n\nextension String {\n func matchesRegex(_ pattern: String) -> Bool {\n if let regex = try? NSR"
},
{
"path": "WakaTime/Extensions/URLExtension.swift",
"chars": 253,
"preview": "import Foundation\n\nextension URL {\n init?(stringWithoutScheme string: String) {\n if string.starts(with: \"https"
},
{
"path": "WakaTime/Helpers/Accessibility.swift",
"chars": 336,
"preview": "import AppKit\n\nclass Accessibility {\n public static func requestA11yPermission() -> Bool {\n let prompt = kAXTr"
},
{
"path": "WakaTime/Helpers/AppInfo.swift",
"chars": 1346,
"preview": "import Foundation\nimport Cocoa\n\nclass AppInfo {\n static func getAppName(bundleId: String) -> String? {\n let wo"
},
{
"path": "WakaTime/Helpers/Dependencies.swift",
"chars": 10127,
"preview": "import Foundation\n\n// swiftlint:disable force_unwrapping\n// swiftlint:disable force_try\nclass Dependencies {\n public "
},
{
"path": "WakaTime/Helpers/EventSourceObserver.swift",
"chars": 1205,
"preview": "import CoreGraphics\n\nclass EventSourceObserver {\n let pollIntervalInSeconds: CFTimeInterval\n var timer: Timer = Ti"
},
{
"path": "WakaTime/Helpers/FilterManager.swift",
"chars": 1664,
"preview": "import Cocoa\n\nclass FilterManager {\n static func filterBrowsedSites(_ url: String) -> Bool {\n let patterns = S"
},
{
"path": "WakaTime/Helpers/MonitoringManager.swift",
"chars": 25058,
"preview": "import Cocoa\nimport Foundation\n\nclass MonitoringManager {\n enum MonitoringState {\n case on\n case off\n "
},
{
"path": "WakaTime/Helpers/PropertiesManager.swift",
"chars": 6703,
"preview": "import Foundation\n\nclass PropertiesManager {\n enum DomainPreferenceType: String {\n case domain\n case ur"
},
{
"path": "WakaTime/Helpers/SettingsManager.swift",
"chars": 2227,
"preview": "import Foundation\nimport ServiceManagement\n\nclass SettingsManager {\n#if !SIMULATE_OLD_MACOS\n static let simulateOldMa"
},
{
"path": "WakaTime/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json",
"chars": 1201,
"preview": "{\n \"images\" : [\n {\n \"filename\" : \"16.png\",\n \"idiom\" : \"mac\",\n \"scale\" : \"1x\",\n \"size\" : \"16x16\"\n"
},
{
"path": "WakaTime/Resources/Assets.xcassets/Contents.json",
"chars": 63,
"preview": "{\n \"info\" : {\n \"author\" : \"xcode\",\n \"version\" : 1\n }\n}\n"
},
{
"path": "WakaTime/Resources/Assets.xcassets/WakaTime.imageset/Contents.json",
"chars": 398,
"preview": "{\n \"images\" : [\n {\n \"idiom\" : \"universal\",\n \"scale\" : \"1x\"\n },\n {\n \"filename\" : \"32.png\",\n "
},
{
"path": "WakaTime/Resources/Assets.xcassets/WakaTimeDisabled.imageset/Contents.json",
"chars": 398,
"preview": "{\n \"images\" : [\n {\n \"idiom\" : \"universal\",\n \"scale\" : \"1x\"\n },\n {\n \"filename\" : \"32.png\",\n "
},
{
"path": "WakaTime/Resources/GoogleService-Info.plist",
"chars": 1134,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/P"
},
{
"path": "WakaTime/Utils/Atomic.swift",
"chars": 532,
"preview": "import Foundation\n\n@propertyWrapper\nstruct Atomic<Value> {\n private var value: Value\n private let lock = NSLock()\n"
},
{
"path": "WakaTime/Utils/Logging.swift",
"chars": 1567,
"preview": "import Foundation\nimport os.log\n\nclass Logging {\n static let `default` = Logging()\n private var filePath: String?\n"
},
{
"path": "WakaTime/Utils/ObjC.h",
"chars": 209,
"preview": "#ifndef ObjC_h\n#define ObjC_h\n\n#import <Foundation/Foundation.h>\n\n@interface ObjC : NSObject\n\n+ (BOOL)catchException:(vo"
},
{
"path": "WakaTime/Utils/ObjC.m",
"chars": 393,
"preview": "#import <Foundation/Foundation.h>\n\n#import \"ObjC.h\"\n\n@implementation ObjC\n\n+ (BOOL)catchException:(void(^)(void))tryBloc"
},
{
"path": "WakaTime/Views/MonitoredAppsView.swift",
"chars": 8294,
"preview": "import AppKit\n\nclass MonitoredAppsView: NSView, NSOutlineViewDataSource, NSOutlineViewDelegate {\n struct AppData: Equ"
},
{
"path": "WakaTime/Views/SettingsView.swift",
"chars": 13635,
"preview": "import AppKit\n\nclass SettingsView: NSView, NSTextFieldDelegate, NSTextViewDelegate {\n var delegate: StatusBarDelegate"
},
{
"path": "WakaTime/WakaTime-Bridging-Header.h",
"chars": 127,
"preview": "//\n// Use this file to import your target's public headers that you would like to expose to Swift.\n//\n\n#import \"Utils/O"
},
{
"path": "WakaTime/WakaTime-Info.plist",
"chars": 482,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/P"
},
{
"path": "WakaTime/WakaTime.entitlements",
"chars": 307,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/P"
},
{
"path": "WakaTime/WakaTime.swift",
"chars": 6133,
"preview": "import AppKit\nimport Firebase\nimport Foundation\n\nclass WakaTime: HeartbeatEventHandler {\n // MARK: Watcher\n\n let w"
},
{
"path": "WakaTime/Watchers/FileSavedWatcher.swift",
"chars": 942,
"preview": "class FileMonitor {\n private let fileURL: URL\n private var dispatchObject: DispatchSourceFileSystemObject?\n\n pu"
},
{
"path": "WakaTime/Watchers/MonitoredApp.swift",
"chars": 4844,
"preview": "import AppKit\n\nenum MonitoredApp: String, CaseIterable {\n case adobeaftereffect = \"com.adobe.AfterEffects\"\n case a"
},
{
"path": "WakaTime/Watchers/Watcher.swift",
"chars": 12126,
"preview": "import Cocoa\nimport Foundation\nimport AppKit\n\nclass Watcher: NSObject {\n private let callbackQueue = DispatchQueue(la"
},
{
"path": "WakaTime/WindowControllers/MonitoredAppsWindowController.swift",
"chars": 678,
"preview": "import AppKit\n\nclass MonitoredAppsWindowController: NSWindowController {\n let monitoredAppsView = MonitoredAppsView()"
},
{
"path": "WakaTime/WindowControllers/SettingsWindowController.swift",
"chars": 596,
"preview": "import AppKit\n\nclass SettingsWindowController: NSWindowController, NSTextFieldDelegate {\n public let settingsView = S"
},
{
"path": "WakaTime/main.swift",
"chars": 132,
"preview": "import Cocoa\n\nlet delegate = AppDelegate()\nlet application = NSApplication.shared\napplication.delegate = delegate\napplic"
},
{
"path": "WakaTime Helper/AppDelegate.swift",
"chars": 1534,
"preview": "import Cocoa\n\nclass AppDelegate: NSObject, NSApplicationDelegate {\n struct Constants {\n static let mainAppBund"
},
{
"path": "WakaTime Helper/Logging.swift",
"chars": 1243,
"preview": "import Foundation\nimport os.log\n\nclass Logging {\n static let `default` = Logging()\n private var filePath: String?\n"
},
{
"path": "WakaTime Helper/WakaTime Helper-Info.plist",
"chars": 226,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/P"
},
{
"path": "WakaTime Helper/main.swift",
"chars": 132,
"preview": "import Cocoa\n\nlet delegate = AppDelegate()\nlet application = NSApplication.shared\napplication.delegate = delegate\napplic"
},
{
"path": "bin/prepare_changelog.sh",
"chars": 1672,
"preview": "#!/bin/bash\n\nset -e\n\nif [[ $# -ne 2 ]]; then\n echo 'incorrect number of arguments'\n exit 1\nfi\n\n# Read arguments\nbr"
},
{
"path": "project.yml",
"chars": 2242,
"preview": "name: WakaTime\n\noptions:\n bundleIdPrefix: macos-wakatime\n createIntermediateGroups: true\n\npackages:\n AppUpdater:\n "
}
]
// ... and 1 more files (download for full content)
About this extraction
This page contains the full source code of the wakatime/macos-wakatime GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 59 files (27.1 MB), approximately 39.2k tokens. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.