Skip to content

Commit a0066f8

Browse files
devsemihclaude
andcommitted
Implement AppJail menu bar utility
Add app blocking, browser tab blocking via URL keywords, and monitoring engine that enforces focus by terminating blocked apps and closing matching browser tabs using AppleScript. - Menu bar only app with no dock icon - Block any installed app via toggle - Block browser tabs matching URL keywords (Safari, Chrome, Edge, Brave, Arc, Dia, Vivaldi, Opera) - Violation panel alerts on enforcement - Onboarding flow for Accessibility + Automation permissions - GitHub Actions workflow for DMG builds - README, LICENSE (MIT), .gitignore Co-Authored-By: Claude Opus 4.6 <[email protected]>
1 parent 4b7655c commit a0066f8

24 files changed

Lines changed: 948 additions & 13 deletions

.github/workflows/build.yml

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
name: Build & Release
2+
3+
on:
4+
push:
5+
branches: [main]
6+
tags: ["v*"]
7+
pull_request:
8+
branches: [main]
9+
10+
jobs:
11+
build:
12+
name: Build AppJail
13+
runs-on: macos-latest
14+
15+
steps:
16+
- name: Checkout
17+
uses: actions/checkout@v4
18+
19+
- name: Select latest Xcode
20+
run: |
21+
XCODE_PATH=$(ls -d /Applications/Xcode*.app 2>/dev/null | sort -V | tail -1)
22+
if [ -z "$XCODE_PATH" ]; then
23+
echo "No Xcode found"
24+
exit 1
25+
fi
26+
echo "Using $XCODE_PATH"
27+
sudo xcode-select -s "$XCODE_PATH"
28+
xcodebuild -version
29+
30+
- name: Build
31+
run: |
32+
xcodebuild -project appjail.xcodeproj \
33+
-scheme appjail \
34+
-configuration Release \
35+
-derivedDataPath build \
36+
CODE_SIGN_IDENTITY="-" \
37+
CODE_SIGNING_REQUIRED=NO \
38+
CODE_SIGNING_ALLOWED=NO
39+
40+
- name: Create DMG
41+
run: |
42+
APP_PATH="build/Build/Products/Release/appjail.app"
43+
DMG_NAME="AppJail.dmg"
44+
45+
mkdir -p dmg_staging
46+
cp -R "$APP_PATH" dmg_staging/
47+
48+
# Create a symlink to /Applications for drag-install
49+
ln -s /Applications dmg_staging/Applications
50+
51+
hdiutil create -volname "AppJail" \
52+
-srcfolder dmg_staging \
53+
-ov -format UDZO \
54+
"$DMG_NAME"
55+
56+
- name: Upload DMG artifact
57+
uses: actions/upload-artifact@v4
58+
with:
59+
name: AppJail-DMG
60+
path: AppJail.dmg
61+
62+
release:
63+
name: Create Release
64+
needs: build
65+
if: startsWith(github.ref, 'refs/tags/v')
66+
runs-on: ubuntu-latest
67+
permissions:
68+
contents: write
69+
70+
steps:
71+
- name: Download DMG
72+
uses: actions/download-artifact@v4
73+
with:
74+
name: AppJail-DMG
75+
76+
- name: Create GitHub Release
77+
uses: softprops/action-gh-release@v2
78+
with:
79+
files: AppJail.dmg
80+
generate_release_notes: true

.gitignore

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Xcode
2+
build/
3+
DerivedData/
4+
*.xcuserstate
5+
*.xcscmblueprint
6+
xcuserdata/
7+
8+
# macOS
9+
.DS_Store
10+
.AppleDouble
11+
.LSOverride
12+
._*
13+
14+
# CocoaPods / SPM
15+
Pods/
16+
.build/
17+
18+
# Misc
19+
*.ipa
20+
*.dSYM.zip
21+
*.dSYM
22+
*.dmg

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Semih Aslan
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# AppJail
2+
3+
A lightweight macOS menu bar utility that blocks distracting apps and browser tabs.
4+
5+
AppJail sits in your menu bar and enforces focus by terminating blocked applications and closing browser tabs that match URL keywords — all powered by native macOS APIs.
6+
7+
![macOS](https://img.shields.io/badge/macOS-26.0%2B-black?logo=apple)
8+
![Swift](https://img.shields.io/badge/Swift-6-orange?logo=swift)
9+
![License](https://img.shields.io/badge/License-MIT-blue)
10+
11+
## Features
12+
13+
- **Block Apps** — Toggle any installed application to blocked. When activated, the app is immediately terminated.
14+
- **Block Browser Tabs** — Add URL keywords (e.g. `youtube`, `reddit`, `twitter`). Matching tabs are closed when you switch to a browser.
15+
- **Menu Bar Only** — Runs entirely from the menu bar with no dock icon.
16+
- **Event-Driven** — Monitors app switches via `NSWorkspace` notifications. No polling, no CPU waste.
17+
- **Violation Alerts** — A floating panel appears briefly when a blocked app or URL is caught.
18+
- **Persistent** — Your block lists survive app restarts (stored in UserDefaults).
19+
20+
## Supported Browsers
21+
22+
| Browser | App Block | Tab Block |
23+
|---------|-----------|-----------|
24+
| Safari | Yes | Yes |
25+
| Google Chrome | Yes | Yes |
26+
| Microsoft Edge | Yes | Yes |
27+
| Brave | Yes | Yes |
28+
| Arc | Yes | Yes |
29+
| Dia | Yes | Yes |
30+
| Vivaldi | Yes | Yes |
31+
| Opera | Yes | Yes |
32+
| Firefox | Yes | No (no AppleScript support) |
33+
34+
## Installation
35+
36+
### Download
37+
38+
Download the latest DMG from [Releases](https://github.com/devsemih/appjail/releases), open it, and drag **appjail** to your Applications folder.
39+
40+
### Build from Source
41+
42+
```bash
43+
git clone https://github.com/devsemih/appjail.git
44+
cd appjail
45+
xcodebuild -project appjail.xcodeproj -scheme appjail -configuration Release
46+
```
47+
48+
Requires **Xcode 26.2+** and **macOS 26.0+**.
49+
50+
## Permissions
51+
52+
AppJail needs two permissions on first launch:
53+
54+
| Permission | Why |
55+
|---|---|
56+
| **Accessibility** | To monitor which app is frontmost and terminate blocked apps |
57+
| **Automation** | To read browser URLs and close tabs via AppleScript |
58+
59+
The onboarding screen guides you through granting both. You can manage them later in **System Settings → Privacy & Security**.
60+
61+
## How It Works
62+
63+
1. AppJail observes `NSWorkspace.didActivateApplicationNotification` to detect app switches.
64+
2. When a blocked app comes to the foreground, it calls `terminate()` on the process.
65+
3. When a registered browser activates and URL keywords exist, it waits 300ms for the page to load, reads the active tab URL via AppleScript, and closes the tab if a keyword matches.
66+
67+
No background polling. No network requests. Everything runs locally.
68+
69+
## Architecture
70+
71+
```
72+
Models/ Data models and persistence (BlockList, AppInfo)
73+
Services/ Core logic (MonitoringEngine, AppScanner, BrowserRegistry, AppleScript)
74+
Views/ SwiftUI views (Dashboard, Apps tab, Browsers tab, Onboarding)
75+
```
76+
77+
## License
78+
79+
MIT

appjail.xcodeproj/project.pbxproj

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -392,13 +392,16 @@
392392
buildSettings = {
393393
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
394394
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
395+
CODE_SIGN_ENTITLEMENTS = appjail/appjail.entitlements;
395396
CODE_SIGN_STYLE = Automatic;
396397
COMBINE_HIDPI_IMAGES = YES;
397398
CURRENT_PROJECT_VERSION = 1;
398-
ENABLE_APP_SANDBOX = YES;
399+
ENABLE_APP_SANDBOX = NO;
400+
ENABLE_HARDENED_RUNTIME = YES;
399401
ENABLE_PREVIEWS = YES;
400-
ENABLE_USER_SELECTED_FILES = readonly;
401402
GENERATE_INFOPLIST_FILE = YES;
403+
INFOPLIST_KEY_LSUIElement = YES;
404+
INFOPLIST_KEY_NSAppleEventsUsageDescription = "AppJail needs automation access to monitor and manage browser tabs.";
402405
INFOPLIST_KEY_NSHumanReadableCopyright = "";
403406
LD_RUNPATH_SEARCH_PATHS = (
404407
"$(inherited)",
@@ -422,13 +425,16 @@
422425
buildSettings = {
423426
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
424427
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
428+
CODE_SIGN_ENTITLEMENTS = appjail/appjail.entitlements;
425429
CODE_SIGN_STYLE = Automatic;
426430
COMBINE_HIDPI_IMAGES = YES;
427431
CURRENT_PROJECT_VERSION = 1;
428-
ENABLE_APP_SANDBOX = YES;
432+
ENABLE_APP_SANDBOX = NO;
433+
ENABLE_HARDENED_RUNTIME = YES;
429434
ENABLE_PREVIEWS = YES;
430-
ENABLE_USER_SELECTED_FILES = readonly;
431435
GENERATE_INFOPLIST_FILE = YES;
436+
INFOPLIST_KEY_LSUIElement = YES;
437+
INFOPLIST_KEY_NSAppleEventsUsageDescription = "AppJail needs automation access to monitor and manage browser tabs.";
432438
INFOPLIST_KEY_NSHumanReadableCopyright = "";
433439
LD_RUNPATH_SEARCH_PATHS = (
434440
"$(inherited)",

appjail/Models/AppInfo.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import AppKit
2+
3+
struct AppInfo: Identifiable, Hashable {
4+
let id: String
5+
let name: String
6+
let icon: NSImage
7+
let bundleURL: URL
8+
let isBrowser: Bool
9+
10+
func hash(into hasher: inout Hasher) {
11+
hasher.combine(id)
12+
}
13+
14+
static func == (lhs: AppInfo, rhs: AppInfo) -> Bool {
15+
lhs.id == rhs.id
16+
}
17+
}

appjail/Models/BlockList.swift

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import Foundation
2+
import Combine
3+
4+
class BlockList: ObservableObject {
5+
private static let blockedAppsKey = "blockedAppBundleIDs"
6+
private static let urlKeywordsKey = "urlKeywords"
7+
8+
@Published var blockedAppBundleIDs: Set<String> {
9+
didSet { save() }
10+
}
11+
12+
@Published var urlKeywords: [String] {
13+
didSet { save() }
14+
}
15+
16+
init() {
17+
let savedApps = UserDefaults.standard.stringArray(forKey: Self.blockedAppsKey) ?? []
18+
self.blockedAppBundleIDs = Set(savedApps)
19+
self.urlKeywords = UserDefaults.standard.stringArray(forKey: Self.urlKeywordsKey) ?? []
20+
}
21+
22+
func isBlocked(_ bundleID: String) -> Bool {
23+
blockedAppBundleIDs.contains(bundleID)
24+
}
25+
26+
func toggleBlock(_ bundleID: String) {
27+
if blockedAppBundleIDs.contains(bundleID) {
28+
blockedAppBundleIDs.remove(bundleID)
29+
} else {
30+
blockedAppBundleIDs.insert(bundleID)
31+
}
32+
}
33+
34+
func addKeyword(_ keyword: String) {
35+
let trimmed = keyword.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
36+
guard !trimmed.isEmpty, !urlKeywords.contains(trimmed) else { return }
37+
urlKeywords.append(trimmed)
38+
}
39+
40+
func removeKeyword(_ keyword: String) {
41+
urlKeywords.removeAll { $0 == keyword }
42+
}
43+
44+
func urlMatchesKeywords(_ url: String) -> String? {
45+
let lower = url.lowercased()
46+
return urlKeywords.first { lower.contains($0) }
47+
}
48+
49+
private func save() {
50+
UserDefaults.standard.set(Array(blockedAppBundleIDs), forKey: Self.blockedAppsKey)
51+
UserDefaults.standard.set(urlKeywords, forKey: Self.urlKeywordsKey)
52+
}
53+
}

appjail/Services/AppScanner.swift

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import AppKit
2+
3+
enum AppScanner {
4+
static func scanInstalledApps() -> [AppInfo] {
5+
let directories = ["/Applications", "/System/Applications"]
6+
let fileManager = FileManager.default
7+
let ownBundleID = Bundle.main.bundleIdentifier ?? "com.appjail"
8+
9+
var apps: [AppInfo] = []
10+
var seenBundleIDs = Set<String>()
11+
12+
for directory in directories {
13+
guard let urls = try? fileManager.contentsOfDirectory(
14+
at: URL(fileURLWithPath: directory),
15+
includingPropertiesForKeys: nil,
16+
options: [.skipsHiddenFiles]
17+
) else { continue }
18+
19+
for url in urls where url.pathExtension == "app" {
20+
guard let bundle = Bundle(url: url),
21+
let bundleID = bundle.bundleIdentifier,
22+
bundleID != ownBundleID,
23+
!seenBundleIDs.contains(bundleID)
24+
else { continue }
25+
26+
seenBundleIDs.insert(bundleID)
27+
28+
let name = bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
29+
?? bundle.object(forInfoDictionaryKey: "CFBundleName") as? String
30+
?? url.deletingPathExtension().lastPathComponent
31+
32+
let icon = NSWorkspace.shared.icon(forFile: url.path)
33+
icon.size = NSSize(width: 32, height: 32)
34+
35+
let isBrowser = BrowserRegistry.isBrowser(bundleID)
36+
37+
apps.append(AppInfo(
38+
id: bundleID,
39+
name: name,
40+
icon: icon,
41+
bundleURL: url,
42+
isBrowser: isBrowser
43+
))
44+
}
45+
}
46+
47+
return apps.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
48+
}
49+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import Foundation
2+
3+
enum AppleScriptRunner {
4+
nonisolated static func execute(_ source: String) -> String? {
5+
let script = NSAppleScript(source: source)
6+
var error: NSDictionary?
7+
let result = script?.executeAndReturnError(&error)
8+
if let error {
9+
print("AppleScript error: \(error)")
10+
return nil
11+
}
12+
return result?.stringValue
13+
}
14+
15+
@discardableResult
16+
nonisolated static func executeIgnoringResult(_ source: String) -> Bool {
17+
let script = NSAppleScript(source: source)
18+
var error: NSDictionary?
19+
script?.executeAndReturnError(&error)
20+
return error == nil
21+
}
22+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
protocol BrowserProtocol: Sendable {
2+
var browserName: String { get }
3+
var bundleIdentifier: String { get }
4+
nonisolated func getActiveURL() -> String?
5+
nonisolated func closeActiveTab() -> Bool
6+
}

0 commit comments

Comments
 (0)