Skip to content

Commit 708cca3

Browse files
devsemihclaude
andcommitted
Add focus timer, scheduled blocking, website categories, and redesigned dashboard
- Focus Timer: timed focus sessions (25/45/60/90 min) with strict mode - Scheduled Blocking: recurring schedules by weekday and time range - Website Categories: block entire site categories (Social Media, Shopping, News, Entertainment, Gaming) - Redesigned dashboard with card-based layout replacing old tab interface - Three monitoring triggers: manual, timer-based, and schedule-based - New reusable components (StatusCard, QuickConfigCard, TimerRingView, LetterAvatar, WeekdayPicker) - Updated README for v1.1.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent fe6c98c commit 708cca3

26 files changed

Lines changed: 1508 additions & 171 deletions

README.md

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# AppJail — Block Distracting Apps & Browser Tabs on macOS
22

3-
A free, open-source macOS menu bar app that blocks distracting applications and browser tabs to help you stay focused. No background polling, no network requests, no browser extensions required.
3+
A free, open-source macOS menu bar app that blocks distracting applications and browser tabs to help you stay focused. Focus timer, scheduled blocking, website categories, and more — no browser extensions required.
44

55
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.
66

@@ -18,13 +18,17 @@ AppJail sits in your menu bar and enforces focus by terminating blocked applicat
1818

1919
## Features
2020

21-
- **Block Apps**Toggle any installed application to blocked. When activated, the app is immediately terminated.
21+
- **Block Apps**Select any installed application to block. When activated, the app is immediately terminated.
2222
- **Block Browser Tabs** — Add URL keywords (e.g. `youtube`, `reddit`, `twitter`). Matching tabs are closed automatically — no browser extension needed.
23+
- **Website Categories** — Block entire categories of sites (Social Media, Shopping, News, Entertainment, Gaming) with one tap instead of adding keywords one by one.
24+
- **Focus Timer** — Start timed focus sessions (25, 45, 60, or 90 minutes) that automatically enable blocking. Strict mode prevents you from stopping early.
25+
- **Scheduled Blocking** — Set up recurring block schedules by day of week and time range. Blocking activates and deactivates automatically.
26+
- **Redesigned Dashboard** — A unified card-based interface replaces the old tab layout. Quick-access cards show blocked app, website, and category counts at a glance.
2327
- **Menu Bar Only** — Runs entirely from the menu bar with no dock icon. Minimal, distraction-free interface.
2428
- **Event-Driven** — Monitors app switches via `NSWorkspace` notifications. No polling, no CPU waste.
2529
- **Privacy-First** — No network requests, no telemetry, no tracking. Everything runs locally on your Mac.
2630
- **Violation Alerts** — A floating panel appears briefly when a blocked app or URL is caught.
27-
- **Persistent Block Lists**Your block lists survive app restarts (stored in UserDefaults).
31+
- **Persistent Configuration**All block lists, schedules, and settings survive app restarts.
2832

2933
## Supported Browsers
3034

@@ -84,11 +88,22 @@ Unlike browser extensions or network-level blockers, AppJail blocks both apps an
8488
## Architecture
8589

8690
```
87-
Models/ Data models and persistence (BlockList, AppInfo)
88-
Services/ Core logic (MonitoringEngine, AppScanner, BrowserRegistry, AppleScript)
89-
Views/ SwiftUI views (Dashboard, Apps tab, Browsers tab, Onboarding)
91+
Models/ Data models and persistence (BlockList, BlockSchedule, FocusTimerState, WebsiteCategory)
92+
Services/ Core logic (MonitoringEngine, FocusTimerManager, ScheduleManager, AppScanner, BrowserRegistry)
93+
Views/
94+
Components/ Reusable UI components (StatusCard, QuickConfigCard, TimerRingView, etc.)
95+
Sheets/ Modal sheets (SelectApps, SelectWebsites, WebsiteCategories, FocusTimer, Schedule)
9096
```
9197

98+
## What's New in v1.1.0
99+
100+
- Focus Timer with strict mode and preset durations
101+
- Scheduled blocking with weekday and time range support
102+
- Website category blocking (Social Media, Shopping, News, Entertainment, Gaming)
103+
- Redesigned dashboard with card-based layout and glass effect styling
104+
- Three monitoring triggers: manual, timer-based, and schedule-based
105+
- New reusable components (TimerRingView, StatusCard, QuickConfigCard, LetterAvatar)
106+
92107
## License
93108

94109
MIT

appjail/ContentView.swift

Lines changed: 0 additions & 24 deletions
This file was deleted.

appjail/Models/BlockList.swift

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import Combine
44
class BlockList: ObservableObject {
55
private static let blockedAppsKey = "blockedAppBundleIDs"
66
private static let urlKeywordsKey = "urlKeywords"
7+
private static let enabledCategoryIDsKey = "enabledCategoryIDs"
8+
private static let schedulesKey = "schedules"
79

810
@Published var blockedAppBundleIDs: Set<String> {
911
didSet { save() }
@@ -13,10 +15,39 @@ class BlockList: ObservableObject {
1315
didSet { save() }
1416
}
1517

18+
@Published var enabledCategoryIDs: Set<String> {
19+
didSet { save() }
20+
}
21+
22+
@Published var schedules: [BlockSchedule] {
23+
didSet { save() }
24+
}
25+
26+
var effectiveKeywords: [String] {
27+
var combined = Set(urlKeywords)
28+
for category in WebsiteCategory.predefined where enabledCategoryIDs.contains(category.id) {
29+
combined.formUnion(category.keywords)
30+
}
31+
return Array(combined)
32+
}
33+
1634
init() {
1735
let savedApps = UserDefaults.standard.stringArray(forKey: Self.blockedAppsKey) ?? []
1836
self.blockedAppBundleIDs = Set(savedApps)
1937
self.urlKeywords = UserDefaults.standard.stringArray(forKey: Self.urlKeywordsKey) ?? []
38+
39+
if let catData = UserDefaults.standard.stringArray(forKey: Self.enabledCategoryIDsKey) {
40+
self.enabledCategoryIDs = Set(catData)
41+
} else {
42+
self.enabledCategoryIDs = []
43+
}
44+
45+
if let scheduleData = UserDefaults.standard.data(forKey: Self.schedulesKey),
46+
let decoded = try? JSONDecoder().decode([BlockSchedule].self, from: scheduleData) {
47+
self.schedules = decoded
48+
} else {
49+
self.schedules = []
50+
}
2051
}
2152

2253
func isBlocked(_ bundleID: String) -> Bool {
@@ -43,11 +74,37 @@ class BlockList: ObservableObject {
4374

4475
func urlMatchesKeywords(_ url: String) -> String? {
4576
let lower = url.lowercased()
46-
return urlKeywords.first { lower.contains($0) }
77+
return effectiveKeywords.first { lower.contains($0) }
78+
}
79+
80+
func toggleCategory(_ categoryID: String) {
81+
if enabledCategoryIDs.contains(categoryID) {
82+
enabledCategoryIDs.remove(categoryID)
83+
} else {
84+
enabledCategoryIDs.insert(categoryID)
85+
}
86+
}
87+
88+
func addSchedule(_ schedule: BlockSchedule) {
89+
schedules.append(schedule)
90+
}
91+
92+
func removeSchedule(_ id: UUID) {
93+
schedules.removeAll { $0.id == id }
94+
}
95+
96+
func updateSchedule(_ schedule: BlockSchedule) {
97+
if let index = schedules.firstIndex(where: { $0.id == schedule.id }) {
98+
schedules[index] = schedule
99+
}
47100
}
48101

49102
private func save() {
50103
UserDefaults.standard.set(Array(blockedAppBundleIDs), forKey: Self.blockedAppsKey)
51104
UserDefaults.standard.set(urlKeywords, forKey: Self.urlKeywordsKey)
105+
UserDefaults.standard.set(Array(enabledCategoryIDs), forKey: Self.enabledCategoryIDsKey)
106+
if let scheduleData = try? JSONEncoder().encode(schedules) {
107+
UserDefaults.standard.set(scheduleData, forKey: Self.schedulesKey)
108+
}
52109
}
53110
}

appjail/Models/BlockSchedule.swift

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import Foundation
2+
3+
struct BlockSchedule: Codable, Identifiable {
4+
var id: UUID = UUID()
5+
var name: String
6+
var startTime: DateComponents
7+
var endTime: DateComponents
8+
var weekdays: Set<Int>
9+
var isEnabled: Bool = true
10+
11+
func isActiveNow() -> Bool {
12+
guard isEnabled else { return false }
13+
let calendar = Calendar.current
14+
let now = Date()
15+
let currentWeekday = calendar.component(.weekday, from: now)
16+
guard weekdays.contains(currentWeekday) else { return false }
17+
18+
let currentHour = calendar.component(.hour, from: now)
19+
let currentMinute = calendar.component(.minute, from: now)
20+
let currentTotal = currentHour * 60 + currentMinute
21+
22+
let startTotal = (startTime.hour ?? 0) * 60 + (startTime.minute ?? 0)
23+
let endTotal = (endTime.hour ?? 0) * 60 + (endTime.minute ?? 0)
24+
25+
if startTotal <= endTotal {
26+
return currentTotal >= startTotal && currentTotal < endTotal
27+
} else {
28+
return currentTotal >= startTotal || currentTotal < endTotal
29+
}
30+
}
31+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import Foundation
2+
3+
struct FocusTimerState: Codable {
4+
enum Status: String, Codable {
5+
case idle, running, paused
6+
}
7+
8+
var durationSeconds: Int = 0
9+
var remainingSeconds: Int = 0
10+
var status: Status = .idle
11+
var startedAt: Date?
12+
var strictMode: Bool = false
13+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import Foundation
2+
3+
struct WebsiteCategory: Identifiable {
4+
let id: String
5+
let name: String
6+
let systemImage: String
7+
let keywords: [String]
8+
var isEnabled: Bool
9+
10+
static let predefined: [WebsiteCategory] = [
11+
WebsiteCategory(
12+
id: "social_media",
13+
name: "Social Media",
14+
systemImage: "bubble.left.and.bubble.right.fill",
15+
keywords: ["facebook.com", "twitter.com", "x.com", "instagram.com", "tiktok.com", "snapchat.com", "reddit.com", "linkedin.com"],
16+
isEnabled: false
17+
),
18+
WebsiteCategory(
19+
id: "shopping",
20+
name: "Shopping",
21+
systemImage: "cart.fill",
22+
keywords: ["amazon.com", "ebay.com", "etsy.com", "walmart.com", "target.com", "aliexpress.com", "shopify.com", "wish.com"],
23+
isEnabled: false
24+
),
25+
WebsiteCategory(
26+
id: "news",
27+
name: "News",
28+
systemImage: "newspaper.fill",
29+
keywords: ["cnn.com", "bbc.com", "foxnews.com", "nytimes.com", "reuters.com", "theguardian.com", "huffpost.com", "news.google.com"],
30+
isEnabled: false
31+
),
32+
WebsiteCategory(
33+
id: "entertainment",
34+
name: "Entertainment",
35+
systemImage: "film.fill",
36+
keywords: ["youtube.com", "netflix.com", "hulu.com", "twitch.tv", "disneyplus.com", "spotify.com", "soundcloud.com", "dailymotion.com"],
37+
isEnabled: false
38+
),
39+
WebsiteCategory(
40+
id: "gaming",
41+
name: "Gaming",
42+
systemImage: "gamecontroller.fill",
43+
keywords: ["steampowered.com", "epicgames.com", "roblox.com", "miniclip.com", "itch.io", "kongregate.com", "poki.com", "coolmathgames.com"],
44+
isEnabled: false
45+
),
46+
]
47+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import Foundation
2+
import Combine
3+
import UserNotifications
4+
5+
class FocusTimerManager: ObservableObject {
6+
@Published var state = FocusTimerState()
7+
8+
private var timer: Timer?
9+
10+
var progress: Double {
11+
guard state.durationSeconds > 0 else { return 0 }
12+
return 1.0 - Double(state.remainingSeconds) / Double(state.durationSeconds)
13+
}
14+
15+
var formattedRemaining: String {
16+
let minutes = state.remainingSeconds / 60
17+
let seconds = state.remainingSeconds % 60
18+
return String(format: "%02d:%02d", minutes, seconds)
19+
}
20+
21+
var isRunning: Bool { state.status == .running }
22+
var isPaused: Bool { state.status == .paused }
23+
var isIdle: Bool { state.status == .idle }
24+
25+
func setDuration(minutes: Int) {
26+
let seconds = minutes * 60
27+
state.durationSeconds = seconds
28+
state.remainingSeconds = seconds
29+
}
30+
31+
func start() {
32+
guard state.durationSeconds > 0 else { return }
33+
state.status = .running
34+
state.startedAt = Date()
35+
state.remainingSeconds = state.durationSeconds
36+
startTimer()
37+
}
38+
39+
func pause() {
40+
guard state.status == .running else { return }
41+
state.status = .paused
42+
stopTimer()
43+
}
44+
45+
func resume() {
46+
guard state.status == .paused else { return }
47+
state.status = .running
48+
state.startedAt = Date().addingTimeInterval(-Double(state.durationSeconds - state.remainingSeconds))
49+
startTimer()
50+
}
51+
52+
func stop() {
53+
state.status = .idle
54+
state.remainingSeconds = state.durationSeconds
55+
state.startedAt = nil
56+
stopTimer()
57+
}
58+
59+
private func startTimer() {
60+
stopTimer()
61+
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
62+
self?.tick()
63+
}
64+
}
65+
66+
private func stopTimer() {
67+
timer?.invalidate()
68+
timer = nil
69+
}
70+
71+
private func tick() {
72+
guard state.status == .running, let startedAt = state.startedAt else { return }
73+
74+
let elapsed = Int(Date().timeIntervalSince(startedAt))
75+
let remaining = max(0, state.durationSeconds - elapsed)
76+
state.remainingSeconds = remaining
77+
78+
if remaining <= 0 {
79+
complete()
80+
}
81+
}
82+
83+
private func complete() {
84+
state.status = .idle
85+
state.remainingSeconds = 0
86+
stopTimer()
87+
sendCompletionNotification()
88+
NotificationCenter.default.post(name: .focusTimerCompleted, object: nil)
89+
}
90+
91+
private func sendCompletionNotification() {
92+
let content = UNMutableNotificationContent()
93+
content.title = "Focus Session Complete"
94+
content.body = "Great work! Your focus session has ended."
95+
content.sound = .default
96+
97+
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
98+
UNUserNotificationCenter.current().add(request)
99+
}
100+
}
101+
102+
extension Notification.Name {
103+
static let focusTimerCompleted = Notification.Name("focusTimerCompleted")
104+
}

0 commit comments

Comments
 (0)