pacatui 0.1.14 → 0.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -0
- package/assets/paca-mascot.png +0 -0
- package/package.json +1 -1
- package/src/App.tsx +43 -2
- package/src/components/SettingsView.tsx +8 -0
- package/src/db.ts +1 -0
- package/src/index.tsx +15 -0
- package/src/menubar/db-lite.ts +15 -7
- package/src/menubar/index.ts +172 -141
- package/src/menubar/swift-source.ts +269 -0
- package/src/types.ts +1 -0
- package/src/menubar/action.ts +0 -80
- package/src/menubar/plugin.ts +0 -88
package/README.md
CHANGED
|
@@ -16,6 +16,7 @@ A simple TUI app for task, timer and invoicing for projects.
|
|
|
16
16
|
- **Stripe Invoicing** - Create draft invoices directly from time entries
|
|
17
17
|
- **Invoice Management** - View and manage all your Stripe invoices
|
|
18
18
|
- **Dashboard** - Overview of projects, tasks, and time stats
|
|
19
|
+
- **Menu Bar** - Native macOS menu bar companion for quick timer control
|
|
19
20
|
- **Offline-first** - All data stored locally in SQLite
|
|
20
21
|
- **Vim-style Navigation** - Keyboard-driven interface
|
|
21
22
|
|
|
@@ -120,6 +121,7 @@ Access settings by pressing `5`:
|
|
|
120
121
|
- **Business Name** - Your business name for invoices
|
|
121
122
|
- **Stripe API Key** - Enable invoicing features
|
|
122
123
|
- **Timezone** - Set display timezone (or auto-detect)
|
|
124
|
+
- **Menu Bar** - Toggle the macOS menu bar companion
|
|
123
125
|
- **Export/Import** - Backup and restore your data
|
|
124
126
|
|
|
125
127
|
### Data Location
|
|
@@ -127,6 +129,27 @@ Access settings by pressing `5`:
|
|
|
127
129
|
- Database: `~/.paca/paca.db`
|
|
128
130
|
- Backups: `~/.paca/backups/`
|
|
129
131
|
|
|
132
|
+
## Menu Bar (macOS)
|
|
133
|
+
|
|
134
|
+
Paca includes a native macOS menu bar companion that shows your running timer and lets you start/stop timers without opening the TUI.
|
|
135
|
+
|
|
136
|
+
### Enable via Settings
|
|
137
|
+
|
|
138
|
+
1. Press `5` to open Settings
|
|
139
|
+
2. Select **Menu Bar** and press `Enter` to toggle it on
|
|
140
|
+
|
|
141
|
+
### Enable via CLI
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
paca menubar enable # Compile & launch
|
|
145
|
+
paca menubar disable # Stop & remove
|
|
146
|
+
paca menubar status # Show current status
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The first time you enable it, Paca compiles a small native Swift helper binary (requires Xcode Command Line Tools). The paca mascot icon appears in your menu bar with live timer status.
|
|
150
|
+
|
|
151
|
+
**Requires**: Xcode Command Line Tools (`xcode-select --install`)
|
|
152
|
+
|
|
130
153
|
## Stripe Integration
|
|
131
154
|
|
|
132
155
|
To enable invoicing:
|
package/assets/paca-mascot.png
CHANGED
|
Binary file
|
package/package.json
CHANGED
package/src/App.tsx
CHANGED
|
@@ -109,6 +109,7 @@ export function App() {
|
|
|
109
109
|
stripeApiKey: "",
|
|
110
110
|
timezone: "auto",
|
|
111
111
|
theme: "catppuccin-mocha",
|
|
112
|
+
menuBar: "disabled",
|
|
112
113
|
});
|
|
113
114
|
|
|
114
115
|
// Timesheet State
|
|
@@ -364,6 +365,30 @@ export function App() {
|
|
|
364
365
|
loadCustomers();
|
|
365
366
|
}, []);
|
|
366
367
|
|
|
368
|
+
// Poll for external timer changes (e.g. from menu bar)
|
|
369
|
+
// Uses bun:sqlite directly to bypass Prisma's connection cache
|
|
370
|
+
useEffect(() => {
|
|
371
|
+
const poll = setInterval(async () => {
|
|
372
|
+
const { getRunningTimer: getRunningTimerLite } = await import("./menubar/db-lite.ts");
|
|
373
|
+
const row = getRunningTimerLite();
|
|
374
|
+
if (row) {
|
|
375
|
+
setRunningTimer({
|
|
376
|
+
id: row.id,
|
|
377
|
+
startTime: new Date(row.startTime.includes("T") ? row.startTime : row.startTime + "Z"),
|
|
378
|
+
project: {
|
|
379
|
+
id: row.projectId,
|
|
380
|
+
name: row.projectName,
|
|
381
|
+
color: row.projectColor,
|
|
382
|
+
hourlyRate: row.projectHourlyRate,
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
} else {
|
|
386
|
+
setRunningTimer(null);
|
|
387
|
+
}
|
|
388
|
+
}, 5000);
|
|
389
|
+
return () => clearInterval(poll);
|
|
390
|
+
}, []);
|
|
391
|
+
|
|
367
392
|
// Reload tasks when project selection changes
|
|
368
393
|
useEffect(() => {
|
|
369
394
|
loadTasks();
|
|
@@ -860,10 +885,13 @@ export function App() {
|
|
|
860
885
|
case 3: // Timezone
|
|
861
886
|
setInputMode("edit_timezone");
|
|
862
887
|
break;
|
|
863
|
-
case 4: //
|
|
888
|
+
case 4: // Menu Bar
|
|
889
|
+
handleToggleMenuBar();
|
|
890
|
+
break;
|
|
891
|
+
case 5: // Export Database
|
|
864
892
|
handleExportDatabase();
|
|
865
893
|
break;
|
|
866
|
-
case
|
|
894
|
+
case 6: // Import Database
|
|
867
895
|
setConfirmMessage("Import will replace all data. Continue?");
|
|
868
896
|
setConfirmAction(() => () => handleImportDatabase());
|
|
869
897
|
break;
|
|
@@ -1177,6 +1205,18 @@ export function App() {
|
|
|
1177
1205
|
showMessage(`Theme: ${theme.displayName}`);
|
|
1178
1206
|
};
|
|
1179
1207
|
|
|
1208
|
+
const handleToggleMenuBar = async () => {
|
|
1209
|
+
const newValue = appSettings.menuBar === "enabled" ? "disabled" : "enabled";
|
|
1210
|
+
await settings.set("menuBar", newValue);
|
|
1211
|
+
setAppSettings((prev) => ({ ...prev, menuBar: newValue }));
|
|
1212
|
+
|
|
1213
|
+
const { enableMenuBar, disableMenuBar } = await import("./menubar/index.ts");
|
|
1214
|
+
const msg = newValue === "enabled"
|
|
1215
|
+
? await enableMenuBar()
|
|
1216
|
+
: await disableMenuBar();
|
|
1217
|
+
showMessage(msg);
|
|
1218
|
+
};
|
|
1219
|
+
|
|
1180
1220
|
// Modal handlers
|
|
1181
1221
|
const handleCreateProject = async (name: string, rate: number | null) => {
|
|
1182
1222
|
await projects.create({ name, hourlyRate: rate });
|
|
@@ -1437,6 +1477,7 @@ export function App() {
|
|
|
1437
1477
|
onEditStripeKey={() => setInputMode("edit_stripe_key")}
|
|
1438
1478
|
onEditTimezone={() => setInputMode("edit_timezone")}
|
|
1439
1479
|
onSelectTheme={() => setInputMode("select_theme")}
|
|
1480
|
+
onToggleMenuBar={handleToggleMenuBar}
|
|
1440
1481
|
onExportDatabase={handleExportDatabase}
|
|
1441
1482
|
onImportDatabase={() => {
|
|
1442
1483
|
setConfirmMessage("Import will replace all data. Continue?");
|
|
@@ -9,6 +9,7 @@ interface SettingsViewProps {
|
|
|
9
9
|
onEditStripeKey: () => void;
|
|
10
10
|
onEditTimezone: () => void;
|
|
11
11
|
onSelectTheme: () => void;
|
|
12
|
+
onToggleMenuBar: () => void;
|
|
12
13
|
onExportDatabase: () => void;
|
|
13
14
|
onImportDatabase: () => void;
|
|
14
15
|
}
|
|
@@ -18,6 +19,7 @@ const SETTINGS_ITEMS = [
|
|
|
18
19
|
{ key: "stripeApiKey", label: "Stripe API Key", type: "secret" },
|
|
19
20
|
{ key: "theme", label: "Theme", type: "select" },
|
|
20
21
|
{ key: "timezone", label: "Timezone", type: "text" },
|
|
22
|
+
{ key: "menuBar", label: "Menu Bar", type: "toggle" },
|
|
21
23
|
{ key: "exportDatabase", label: "Export Database", type: "action" },
|
|
22
24
|
{ key: "importDatabase", label: "Import Database", type: "action" },
|
|
23
25
|
] as const;
|
|
@@ -30,6 +32,7 @@ export function SettingsView({
|
|
|
30
32
|
onEditStripeKey,
|
|
31
33
|
onEditTimezone,
|
|
32
34
|
onSelectTheme,
|
|
35
|
+
onToggleMenuBar,
|
|
33
36
|
onExportDatabase,
|
|
34
37
|
onImportDatabase,
|
|
35
38
|
}: SettingsViewProps) {
|
|
@@ -63,6 +66,8 @@ export function SettingsView({
|
|
|
63
66
|
return maskSecret(settings.stripeApiKey);
|
|
64
67
|
case "timezone":
|
|
65
68
|
return formatTimezone(settings.timezone);
|
|
69
|
+
case "menuBar":
|
|
70
|
+
return settings.menuBar === "enabled" ? "Enabled" : "Disabled";
|
|
66
71
|
case "exportDatabase":
|
|
67
72
|
return "Export to file...";
|
|
68
73
|
case "importDatabase":
|
|
@@ -82,6 +87,8 @@ export function SettingsView({
|
|
|
82
87
|
return onEditStripeKey;
|
|
83
88
|
case "timezone":
|
|
84
89
|
return onEditTimezone;
|
|
90
|
+
case "menuBar":
|
|
91
|
+
return onToggleMenuBar;
|
|
85
92
|
case "exportDatabase":
|
|
86
93
|
return onExportDatabase;
|
|
87
94
|
case "importDatabase":
|
|
@@ -94,6 +101,7 @@ export function SettingsView({
|
|
|
94
101
|
const getActionLabel = (type: string) => {
|
|
95
102
|
if (type === "action") return "run";
|
|
96
103
|
if (type === "select") return "select";
|
|
104
|
+
if (type === "toggle") return "toggle";
|
|
97
105
|
return "edit";
|
|
98
106
|
};
|
|
99
107
|
|
package/src/db.ts
CHANGED
package/src/index.tsx
CHANGED
|
@@ -185,6 +185,21 @@ async function main() {
|
|
|
185
185
|
// Clean up tasks that have been done for more than 3 days
|
|
186
186
|
await cleanupOldCompletedTasks(3);
|
|
187
187
|
|
|
188
|
+
// Auto-launch menu bar helper if enabled
|
|
189
|
+
try {
|
|
190
|
+
const db2 = new Database(DB_PATH, { readonly: true });
|
|
191
|
+
const menuBarSetting = db2
|
|
192
|
+
.query("SELECT value FROM Setting WHERE key = 'menuBar'")
|
|
193
|
+
.get() as { value: string } | null;
|
|
194
|
+
db2.close();
|
|
195
|
+
if (menuBarSetting?.value === "enabled") {
|
|
196
|
+
const { ensureMenuBarRunning } = await import("./menubar/index.ts");
|
|
197
|
+
ensureMenuBarRunning();
|
|
198
|
+
}
|
|
199
|
+
} catch {
|
|
200
|
+
// Non-critical — skip if menu bar can't be launched
|
|
201
|
+
}
|
|
202
|
+
|
|
188
203
|
// Handle cleanup on exit - must stop renderer to restore terminal state
|
|
189
204
|
const cleanup = async () => {
|
|
190
205
|
if (renderer) {
|
package/src/menubar/db-lite.ts
CHANGED
|
@@ -4,10 +4,16 @@ import { homedir } from "os";
|
|
|
4
4
|
|
|
5
5
|
const DB_PATH = join(homedir(), ".paca", "paca.db");
|
|
6
6
|
|
|
7
|
-
function
|
|
7
|
+
function openReadonly(): Database {
|
|
8
|
+
const db = new Database(DB_PATH, { readonly: true });
|
|
9
|
+
db.exec("PRAGMA busy_timeout=5000");
|
|
10
|
+
return db;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function openReadWrite(): Database {
|
|
8
14
|
const db = new Database(DB_PATH);
|
|
9
|
-
db.exec("PRAGMA journal_mode=WAL");
|
|
10
15
|
db.exec("PRAGMA busy_timeout=5000");
|
|
16
|
+
db.exec("PRAGMA journal_mode=WAL");
|
|
11
17
|
return db;
|
|
12
18
|
}
|
|
13
19
|
|
|
@@ -17,6 +23,7 @@ export interface RunningTimerRow {
|
|
|
17
23
|
projectId: string;
|
|
18
24
|
projectName: string;
|
|
19
25
|
projectColor: string;
|
|
26
|
+
projectHourlyRate: number | null;
|
|
20
27
|
}
|
|
21
28
|
|
|
22
29
|
export interface ProjectRow {
|
|
@@ -26,12 +33,13 @@ export interface ProjectRow {
|
|
|
26
33
|
}
|
|
27
34
|
|
|
28
35
|
export function getRunningTimer(): RunningTimerRow | null {
|
|
29
|
-
const db =
|
|
36
|
+
const db = openReadonly();
|
|
30
37
|
try {
|
|
31
38
|
const row = db
|
|
32
39
|
.query<RunningTimerRow, []>(
|
|
33
40
|
`SELECT te.id, te.startTime, te.projectId,
|
|
34
|
-
p.name AS projectName, p.color AS projectColor
|
|
41
|
+
p.name AS projectName, p.color AS projectColor,
|
|
42
|
+
p.hourlyRate AS projectHourlyRate
|
|
35
43
|
FROM TimeEntry te
|
|
36
44
|
JOIN Project p ON p.id = te.projectId
|
|
37
45
|
WHERE te.endTime IS NULL
|
|
@@ -45,7 +53,7 @@ export function getRunningTimer(): RunningTimerRow | null {
|
|
|
45
53
|
}
|
|
46
54
|
|
|
47
55
|
export function getProjects(): ProjectRow[] {
|
|
48
|
-
const db =
|
|
56
|
+
const db = openReadonly();
|
|
49
57
|
try {
|
|
50
58
|
return db
|
|
51
59
|
.query<ProjectRow, []>(
|
|
@@ -58,7 +66,7 @@ export function getProjects(): ProjectRow[] {
|
|
|
58
66
|
}
|
|
59
67
|
|
|
60
68
|
export function startTimer(projectId: string): void {
|
|
61
|
-
const db =
|
|
69
|
+
const db = openReadWrite();
|
|
62
70
|
try {
|
|
63
71
|
// Stop any running timer first
|
|
64
72
|
const running = db
|
|
@@ -84,7 +92,7 @@ export function startTimer(projectId: string): void {
|
|
|
84
92
|
}
|
|
85
93
|
|
|
86
94
|
export function stopTimer(entryId: string, description?: string): void {
|
|
87
|
-
const db =
|
|
95
|
+
const db = openReadWrite();
|
|
88
96
|
try {
|
|
89
97
|
db.query(
|
|
90
98
|
`UPDATE TimeEntry SET endTime = datetime('now'), description = ?, updatedAt = datetime('now') WHERE id = ?`,
|
package/src/menubar/index.ts
CHANGED
|
@@ -1,143 +1,201 @@
|
|
|
1
|
-
import { existsSync, writeFileSync, unlinkSync,
|
|
2
|
-
import { join } from "path";
|
|
1
|
+
import { existsSync, writeFileSync, unlinkSync, readFileSync } from "fs";
|
|
2
|
+
import { join, dirname } from "path";
|
|
3
3
|
import { homedir } from "os";
|
|
4
|
-
import { execSync } from "child_process";
|
|
5
|
-
import {
|
|
6
|
-
import { getActionScript } from "./action.ts";
|
|
4
|
+
import { execSync, spawn } from "child_process";
|
|
5
|
+
import { getSwiftSource } from "./swift-source.ts";
|
|
7
6
|
import { getRunningTimer, getProjects } from "./db-lite.ts";
|
|
8
7
|
|
|
9
|
-
const PLUGIN_FILENAME = "paca-timer.3s.ts";
|
|
10
|
-
const ACTION_FILENAME = "menubar-action.ts";
|
|
11
8
|
const PACA_DIR = join(homedir(), ".paca");
|
|
12
|
-
const
|
|
9
|
+
const BINARY_PATH = join(PACA_DIR, "paca-menubar");
|
|
10
|
+
const SWIFT_PATH = join(PACA_DIR, "paca-menubar.swift");
|
|
11
|
+
const PID_PATH = join(PACA_DIR, "menubar.pid");
|
|
12
|
+
const ICON_PATH = join(PACA_DIR, "paca-icon.png");
|
|
13
13
|
|
|
14
|
-
function
|
|
14
|
+
function hasSwiftCompiler(): boolean {
|
|
15
15
|
try {
|
|
16
|
-
|
|
16
|
+
execSync("which swiftc", { encoding: "utf-8", stdio: "pipe" });
|
|
17
|
+
return true;
|
|
17
18
|
} catch {
|
|
18
|
-
|
|
19
|
+
return false;
|
|
19
20
|
}
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
function
|
|
23
|
-
//
|
|
23
|
+
function prepareMascotIcon(): string {
|
|
24
|
+
// Find the mascot image relative to source directory
|
|
25
|
+
const srcDir = dirname(dirname(import.meta.dir));
|
|
26
|
+
const mascotPath = join(srcDir, "assets", "paca-mascot.png");
|
|
27
|
+
|
|
28
|
+
if (!existsSync(mascotPath)) {
|
|
29
|
+
return "";
|
|
30
|
+
}
|
|
31
|
+
|
|
24
32
|
try {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
{
|
|
28
|
-
|
|
29
|
-
|
|
33
|
+
// Resize to 36x36 (18pt @2x retina) using sips (built into macOS)
|
|
34
|
+
execSync(
|
|
35
|
+
`sips --resampleHeight 36 ${JSON.stringify(mascotPath)} --out ${JSON.stringify(ICON_PATH)} 2>/dev/null`,
|
|
36
|
+
{ encoding: "utf-8", stdio: "pipe" },
|
|
37
|
+
);
|
|
38
|
+
const iconData = readFileSync(ICON_PATH);
|
|
39
|
+
unlinkSync(ICON_PATH);
|
|
40
|
+
return iconData.toString("base64");
|
|
30
41
|
} catch {
|
|
31
|
-
|
|
42
|
+
return "";
|
|
32
43
|
}
|
|
44
|
+
}
|
|
33
45
|
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
46
|
+
function compileBinary(): boolean {
|
|
47
|
+
const iconBase64 = prepareMascotIcon();
|
|
48
|
+
writeFileSync(SWIFT_PATH, getSwiftSource(iconBase64), "utf-8");
|
|
49
|
+
try {
|
|
50
|
+
execSync(
|
|
51
|
+
`swiftc -O -o ${JSON.stringify(BINARY_PATH)} ${JSON.stringify(SWIFT_PATH)} -framework Cocoa -lsqlite3 2>&1`,
|
|
52
|
+
{ encoding: "utf-8", timeout: 60_000 },
|
|
53
|
+
);
|
|
54
|
+
unlinkSync(SWIFT_PATH);
|
|
55
|
+
return true;
|
|
56
|
+
} catch (error: any) {
|
|
57
|
+
console.error("Failed to compile menu bar helper:");
|
|
58
|
+
console.error(error.stdout || error.message);
|
|
59
|
+
if (existsSync(SWIFT_PATH)) unlinkSync(SWIFT_PATH);
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
39
63
|
|
|
40
|
-
|
|
41
|
-
|
|
64
|
+
function readPid(): number | null {
|
|
65
|
+
try {
|
|
66
|
+
const pid = parseInt(readFileSync(PID_PATH, "utf-8").trim(), 10);
|
|
67
|
+
return isNaN(pid) ? null : pid;
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
42
70
|
}
|
|
71
|
+
}
|
|
43
72
|
|
|
44
|
-
|
|
73
|
+
function isProcessRunning(pid: number): boolean {
|
|
74
|
+
try {
|
|
75
|
+
process.kill(pid, 0);
|
|
76
|
+
return true;
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
45
80
|
}
|
|
46
81
|
|
|
47
|
-
function
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
existsSync(join(homedir(), "Applications", "SwiftBar.app"))
|
|
51
|
-
);
|
|
82
|
+
export function isMenuBarRunning(): boolean {
|
|
83
|
+
const pid = readPid();
|
|
84
|
+
return pid !== null && isProcessRunning(pid);
|
|
52
85
|
}
|
|
53
86
|
|
|
54
|
-
function
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
87
|
+
function launchHelper(): boolean {
|
|
88
|
+
if (!existsSync(BINARY_PATH)) return false;
|
|
89
|
+
|
|
90
|
+
const child = spawn(BINARY_PATH, [], {
|
|
91
|
+
detached: true,
|
|
92
|
+
stdio: "ignore",
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
if (child.pid) {
|
|
96
|
+
writeFileSync(PID_PATH, String(child.pid), "utf-8");
|
|
97
|
+
child.unref();
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
return false;
|
|
62
101
|
}
|
|
63
102
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
console.log(" paca menubar install");
|
|
73
|
-
process.exit(1);
|
|
103
|
+
function killHelper(): boolean {
|
|
104
|
+
const pid = readPid();
|
|
105
|
+
if (pid === null) return false;
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
process.kill(pid, "SIGTERM");
|
|
109
|
+
} catch {
|
|
110
|
+
// Process already gone
|
|
74
111
|
}
|
|
75
112
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
113
|
+
if (existsSync(PID_PATH)) unlinkSync(PID_PATH);
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Enable the menu bar helper. Called from settings toggle or CLI.
|
|
119
|
+
* Returns a status message string.
|
|
120
|
+
*/
|
|
121
|
+
export async function enableMenuBar(): Promise<string> {
|
|
122
|
+
if (isMenuBarRunning()) {
|
|
123
|
+
return "Menu bar is already running.";
|
|
83
124
|
}
|
|
84
125
|
|
|
85
|
-
|
|
86
|
-
|
|
126
|
+
if (!hasSwiftCompiler()) {
|
|
127
|
+
return "Xcode Command Line Tools required. Install with: xcode-select --install";
|
|
128
|
+
}
|
|
87
129
|
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
130
|
+
// Compile if binary doesn't exist
|
|
131
|
+
if (!existsSync(BINARY_PATH)) {
|
|
132
|
+
const ok = compileBinary();
|
|
133
|
+
if (!ok) return "Failed to compile menu bar helper.";
|
|
134
|
+
}
|
|
91
135
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
chmodSync(ACTION_PATH, 0o755);
|
|
136
|
+
const launched = launchHelper();
|
|
137
|
+
if (!launched) return "Failed to launch menu bar helper.";
|
|
95
138
|
|
|
96
|
-
|
|
97
|
-
console.log("");
|
|
98
|
-
console.log(` Plugin: ${pluginPath}`);
|
|
99
|
-
console.log(` Action: ${ACTION_PATH}`);
|
|
100
|
-
console.log("");
|
|
101
|
-
console.log("The timer should appear in your menu bar shortly.");
|
|
102
|
-
console.log("If not, open SwiftBar and refresh plugins.");
|
|
139
|
+
return "Menu bar enabled.";
|
|
103
140
|
}
|
|
104
141
|
|
|
105
|
-
|
|
106
|
-
|
|
142
|
+
/**
|
|
143
|
+
* Disable the menu bar helper. Called from settings toggle or CLI.
|
|
144
|
+
* Returns a status message string.
|
|
145
|
+
*/
|
|
146
|
+
export async function disableMenuBar(): Promise<string> {
|
|
147
|
+
killHelper();
|
|
107
148
|
|
|
108
|
-
// Remove
|
|
109
|
-
|
|
110
|
-
if (pluginDir) {
|
|
111
|
-
const pluginPath = join(pluginDir, PLUGIN_FILENAME);
|
|
112
|
-
if (existsSync(pluginPath)) {
|
|
113
|
-
unlinkSync(pluginPath);
|
|
114
|
-
console.log(`Removed plugin: ${pluginPath}`);
|
|
115
|
-
removed = true;
|
|
116
|
-
}
|
|
117
|
-
}
|
|
149
|
+
// Remove binary
|
|
150
|
+
if (existsSync(BINARY_PATH)) unlinkSync(BINARY_PATH);
|
|
118
151
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
unlinkSync(ACTION_PATH);
|
|
122
|
-
console.log(`Removed action script: ${ACTION_PATH}`);
|
|
123
|
-
removed = true;
|
|
124
|
-
}
|
|
152
|
+
return "Menu bar disabled.";
|
|
153
|
+
}
|
|
125
154
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
155
|
+
/**
|
|
156
|
+
* Ensure the helper is running if the setting is enabled.
|
|
157
|
+
* Called on paca startup — no-op if already running or not enabled.
|
|
158
|
+
*/
|
|
159
|
+
export function ensureMenuBarRunning(): void {
|
|
160
|
+
if (isMenuBarRunning()) return;
|
|
161
|
+
if (!existsSync(BINARY_PATH)) return;
|
|
162
|
+
launchHelper();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// --- CLI ---
|
|
166
|
+
|
|
167
|
+
function formatElapsed(startTimeStr: string): string {
|
|
168
|
+
const startMs = new Date(startTimeStr + "Z").getTime();
|
|
169
|
+
const elapsedMs = Date.now() - startMs;
|
|
170
|
+
const totalSec = Math.floor(elapsedMs / 1000);
|
|
171
|
+
const h = Math.floor(totalSec / 3600);
|
|
172
|
+
const m = Math.floor((totalSec % 3600) / 60);
|
|
173
|
+
const s = totalSec % 60;
|
|
174
|
+
return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function cliEnable() {
|
|
178
|
+
console.log("Compiling menu bar helper...");
|
|
179
|
+
const msg = await enableMenuBar();
|
|
180
|
+
console.log(msg);
|
|
132
181
|
}
|
|
133
182
|
|
|
134
|
-
function
|
|
135
|
-
const
|
|
136
|
-
|
|
183
|
+
async function cliDisable() {
|
|
184
|
+
const msg = await disableMenuBar();
|
|
185
|
+
console.log(msg);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function cliStatus() {
|
|
189
|
+
const dbPath = join(PACA_DIR, "paca.db");
|
|
190
|
+
if (!existsSync(dbPath)) {
|
|
137
191
|
console.log("No database found. Run paca to initialize.");
|
|
138
192
|
return;
|
|
139
193
|
}
|
|
140
194
|
|
|
195
|
+
console.log(`Menu bar: ${isMenuBarRunning() ? "running" : "not running"}`);
|
|
196
|
+
console.log(`Binary: ${existsSync(BINARY_PATH) ? "compiled" : "not compiled"}`);
|
|
197
|
+
console.log("");
|
|
198
|
+
|
|
141
199
|
const running = getRunningTimer();
|
|
142
200
|
if (running) {
|
|
143
201
|
const elapsed = formatElapsed(running.startTime);
|
|
@@ -146,64 +204,37 @@ function status() {
|
|
|
146
204
|
console.log("No timer running.");
|
|
147
205
|
}
|
|
148
206
|
|
|
149
|
-
const
|
|
150
|
-
if (
|
|
207
|
+
const allProjects = getProjects();
|
|
208
|
+
if (allProjects.length > 0) {
|
|
151
209
|
console.log("");
|
|
152
210
|
console.log("Projects:");
|
|
153
|
-
for (const p of
|
|
211
|
+
for (const p of allProjects) {
|
|
154
212
|
console.log(` - ${p.name}`);
|
|
155
213
|
}
|
|
156
214
|
}
|
|
157
215
|
}
|
|
158
216
|
|
|
159
217
|
function showHelp() {
|
|
160
|
-
const running = getRunningTimer();
|
|
161
|
-
if (running) {
|
|
162
|
-
const elapsed = formatElapsed(running.startTime);
|
|
163
|
-
console.log(`Timer: ${running.projectName} (${elapsed})`);
|
|
164
|
-
} else {
|
|
165
|
-
console.log("No timer running.");
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
console.log("");
|
|
169
218
|
console.log("Menu bar commands:");
|
|
170
|
-
console.log(" paca menubar
|
|
171
|
-
console.log(" paca menubar
|
|
172
|
-
console.log(" paca menubar status Show current
|
|
219
|
+
console.log(" paca menubar enable Compile & launch menu bar helper");
|
|
220
|
+
console.log(" paca menubar disable Stop & remove menu bar helper");
|
|
221
|
+
console.log(" paca menubar status Show current status");
|
|
173
222
|
console.log("");
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
console.log(" brew install --cask swiftbar");
|
|
178
|
-
} else {
|
|
179
|
-
const pluginDir = findSwiftBarPluginDir();
|
|
180
|
-
if (pluginDir) {
|
|
181
|
-
const pluginPath = join(pluginDir, PLUGIN_FILENAME);
|
|
182
|
-
if (existsSync(pluginPath)) {
|
|
183
|
-
console.log("Status: Plugin installed");
|
|
184
|
-
} else {
|
|
185
|
-
console.log("Status: SwiftBar found, plugin not installed");
|
|
186
|
-
console.log(" Run: paca menubar install");
|
|
187
|
-
}
|
|
188
|
-
} else {
|
|
189
|
-
console.log("Status: SwiftBar found, but no plugin directory set");
|
|
190
|
-
console.log(" Launch SwiftBar first, then run: paca menubar install");
|
|
191
|
-
}
|
|
192
|
-
}
|
|
223
|
+
console.log(`Menu bar: ${isMenuBarRunning() ? "running" : "not running"}`);
|
|
224
|
+
console.log("");
|
|
225
|
+
console.log("You can also toggle this from Settings in the TUI.");
|
|
193
226
|
}
|
|
194
227
|
|
|
195
228
|
export async function menubarCommand(args: string[]) {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
case "install":
|
|
200
|
-
await install();
|
|
229
|
+
switch (args[0]) {
|
|
230
|
+
case "enable":
|
|
231
|
+
await cliEnable();
|
|
201
232
|
break;
|
|
202
|
-
case "
|
|
203
|
-
await
|
|
233
|
+
case "disable":
|
|
234
|
+
await cliDisable();
|
|
204
235
|
break;
|
|
205
236
|
case "status":
|
|
206
|
-
|
|
237
|
+
cliStatus();
|
|
207
238
|
break;
|
|
208
239
|
default:
|
|
209
240
|
showHelp();
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native macOS menu bar helper — Swift source code.
|
|
3
|
+
* Compiled with `swiftc` at install time, stored at ~/.paca/paca-menubar.
|
|
4
|
+
*
|
|
5
|
+
* @param iconBase64 - Base64-encoded PNG of the paca mascot (36x36 for retina)
|
|
6
|
+
*/
|
|
7
|
+
export function getSwiftSource(iconBase64: string): string {
|
|
8
|
+
return `
|
|
9
|
+
import Cocoa
|
|
10
|
+
import SQLite3
|
|
11
|
+
|
|
12
|
+
let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
|
|
13
|
+
|
|
14
|
+
let ICON_BASE64 = "${iconBase64}"
|
|
15
|
+
|
|
16
|
+
func loadIcon() -> NSImage? {
|
|
17
|
+
guard let data = Data(base64Encoded: ICON_BASE64) else { return nil }
|
|
18
|
+
guard let image = NSImage(data: data) else { return nil }
|
|
19
|
+
// Set point size to 18x18 (the data is @2x retina)
|
|
20
|
+
image.size = NSSize(width: 18, height: 18)
|
|
21
|
+
return image
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
class AppDelegate: NSObject, NSApplicationDelegate {
|
|
25
|
+
var statusItem: NSStatusItem!
|
|
26
|
+
var pollTimer: Timer?
|
|
27
|
+
let dbPath: String
|
|
28
|
+
var icon: NSImage?
|
|
29
|
+
|
|
30
|
+
override init() {
|
|
31
|
+
let home = FileManager.default.homeDirectoryForCurrentUser
|
|
32
|
+
dbPath = home.appendingPathComponent(".paca/paca.db").path
|
|
33
|
+
super.init()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
37
|
+
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
|
38
|
+
icon = loadIcon()
|
|
39
|
+
statusItem.button?.imagePosition = .imageLeading
|
|
40
|
+
updateMenu()
|
|
41
|
+
pollTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
|
|
42
|
+
DispatchQueue.main.async { self?.updateMenu() }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func updateMenu() {
|
|
47
|
+
var db: OpaquePointer?
|
|
48
|
+
guard sqlite3_open_v2(dbPath, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else {
|
|
49
|
+
setIdleAppearance()
|
|
50
|
+
statusItem.menu = buildFallbackMenu("Database not found")
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
defer { sqlite3_close(db) }
|
|
54
|
+
|
|
55
|
+
sqlite3_busy_timeout(db, 5000)
|
|
56
|
+
|
|
57
|
+
// Check for running timer
|
|
58
|
+
var stmt: OpaquePointer?
|
|
59
|
+
let runningSQL = """
|
|
60
|
+
SELECT te.id, te.startTime, p.name, p.color
|
|
61
|
+
FROM TimeEntry te JOIN Project p ON p.id = te.projectId
|
|
62
|
+
WHERE te.endTime IS NULL LIMIT 1
|
|
63
|
+
"""
|
|
64
|
+
var runningId: String?
|
|
65
|
+
var startTime: String?
|
|
66
|
+
var projectName: String?
|
|
67
|
+
|
|
68
|
+
if sqlite3_prepare_v2(db, runningSQL, -1, &stmt, nil) == SQLITE_OK {
|
|
69
|
+
if sqlite3_step(stmt) == SQLITE_ROW {
|
|
70
|
+
runningId = col(stmt, 0)
|
|
71
|
+
startTime = col(stmt, 1)
|
|
72
|
+
projectName = col(stmt, 2)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
sqlite3_finalize(stmt)
|
|
76
|
+
|
|
77
|
+
// Update title and icon
|
|
78
|
+
if let name = projectName, let start = startTime {
|
|
79
|
+
let elapsed = formatElapsed(start)
|
|
80
|
+
let title = " ▶ \\(name) \\(elapsed)"
|
|
81
|
+
let attrs: [NSAttributedString.Key: Any] = [
|
|
82
|
+
.foregroundColor: NSColor(red: 0.65, green: 0.89, blue: 0.63, alpha: 1.0),
|
|
83
|
+
.font: NSFont.monospacedDigitSystemFont(ofSize: NSFont.systemFontSize, weight: .medium)
|
|
84
|
+
]
|
|
85
|
+
statusItem.button?.attributedTitle = NSAttributedString(string: title, attributes: attrs)
|
|
86
|
+
statusItem.button?.image = icon
|
|
87
|
+
} else {
|
|
88
|
+
setIdleAppearance()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Build menu
|
|
92
|
+
let menu = NSMenu()
|
|
93
|
+
|
|
94
|
+
if let entryId = runningId {
|
|
95
|
+
let stop = NSMenuItem(title: "Stop Timer", action: #selector(stopTimerClicked(_:)), keyEquivalent: "")
|
|
96
|
+
stop.target = self
|
|
97
|
+
stop.representedObject = entryId
|
|
98
|
+
menu.addItem(stop)
|
|
99
|
+
} else {
|
|
100
|
+
// Only show project list when no timer is running
|
|
101
|
+
var pStmt: OpaquePointer?
|
|
102
|
+
let pSQL = "SELECT id, name FROM Project WHERE archived = 0 ORDER BY name ASC"
|
|
103
|
+
var projectItems: [(id: String, name: String)] = []
|
|
104
|
+
|
|
105
|
+
if sqlite3_prepare_v2(db, pSQL, -1, &pStmt, nil) == SQLITE_OK {
|
|
106
|
+
while sqlite3_step(pStmt) == SQLITE_ROW {
|
|
107
|
+
if let id = col(pStmt, 0), let name = col(pStmt, 1) {
|
|
108
|
+
projectItems.append((id: id, name: name))
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
sqlite3_finalize(pStmt)
|
|
113
|
+
|
|
114
|
+
if projectItems.isEmpty {
|
|
115
|
+
let empty = NSMenuItem(title: "No projects — open paca to create one", action: nil, keyEquivalent: "")
|
|
116
|
+
empty.isEnabled = false
|
|
117
|
+
menu.addItem(empty)
|
|
118
|
+
} else {
|
|
119
|
+
let header = NSMenuItem(title: "Start Timer For...", action: nil, keyEquivalent: "")
|
|
120
|
+
header.isEnabled = false
|
|
121
|
+
menu.addItem(header)
|
|
122
|
+
for p in projectItems {
|
|
123
|
+
let item = NSMenuItem(title: " \\(p.name)", action: #selector(startTimerClicked(_:)), keyEquivalent: "")
|
|
124
|
+
item.target = self
|
|
125
|
+
item.representedObject = p.id
|
|
126
|
+
menu.addItem(item)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
menu.addItem(NSMenuItem.separator())
|
|
132
|
+
let quit = NSMenuItem(title: "Quit Paca Menu Bar", action: #selector(quitApp), keyEquivalent: "q")
|
|
133
|
+
quit.target = self
|
|
134
|
+
menu.addItem(quit)
|
|
135
|
+
|
|
136
|
+
statusItem.menu = menu
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
func setIdleAppearance() {
|
|
140
|
+
statusItem.button?.image = icon
|
|
141
|
+
statusItem.button?.title = ""
|
|
142
|
+
statusItem.button?.attributedTitle = NSAttributedString(string: "")
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
func buildFallbackMenu(_ message: String) -> NSMenu {
|
|
146
|
+
let menu = NSMenu()
|
|
147
|
+
let item = NSMenuItem(title: message, action: nil, keyEquivalent: "")
|
|
148
|
+
item.isEnabled = false
|
|
149
|
+
menu.addItem(item)
|
|
150
|
+
menu.addItem(NSMenuItem.separator())
|
|
151
|
+
let quit = NSMenuItem(title: "Quit Paca Menu Bar", action: #selector(quitApp), keyEquivalent: "q")
|
|
152
|
+
quit.target = self
|
|
153
|
+
menu.addItem(quit)
|
|
154
|
+
return menu
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
func col(_ stmt: OpaquePointer?, _ idx: Int32) -> String? {
|
|
158
|
+
guard let cStr = sqlite3_column_text(stmt, idx) else { return nil }
|
|
159
|
+
return String(cString: cStr)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
func parseDate(_ str: String) -> Date? {
|
|
163
|
+
// Try ISO8601DateFormatter first (handles Z, +00:00, etc.)
|
|
164
|
+
let iso = ISO8601DateFormatter()
|
|
165
|
+
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
166
|
+
if let d = iso.date(from: str) { return d }
|
|
167
|
+
|
|
168
|
+
// Without fractional seconds
|
|
169
|
+
iso.formatOptions = [.withInternetDateTime]
|
|
170
|
+
if let d = iso.date(from: str) { return d }
|
|
171
|
+
|
|
172
|
+
// Try SQLite datetime() format
|
|
173
|
+
let sql = DateFormatter()
|
|
174
|
+
sql.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
|
175
|
+
sql.timeZone = TimeZone(identifier: "UTC")
|
|
176
|
+
return sql.date(from: str)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
func formatElapsed(_ startTimeStr: String) -> String {
|
|
180
|
+
guard let start = parseDate(startTimeStr) else { return "0:00:00" }
|
|
181
|
+
|
|
182
|
+
let elapsed = max(0, Int(Date().timeIntervalSince(start)))
|
|
183
|
+
let h = elapsed / 3600
|
|
184
|
+
let m = (elapsed % 3600) / 60
|
|
185
|
+
let s = elapsed % 60
|
|
186
|
+
return String(format: "%d:%02d:%02d", h, m, s)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// MARK: - Actions
|
|
190
|
+
|
|
191
|
+
@objc func stopTimerClicked(_ sender: NSMenuItem) {
|
|
192
|
+
guard let entryId = sender.representedObject as? String else { return }
|
|
193
|
+
|
|
194
|
+
let alert = NSAlert()
|
|
195
|
+
alert.messageText = "What did you work on?"
|
|
196
|
+
alert.addButton(withTitle: "Save")
|
|
197
|
+
alert.addButton(withTitle: "Skip")
|
|
198
|
+
alert.addButton(withTitle: "Cancel")
|
|
199
|
+
|
|
200
|
+
let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 300, height: 24))
|
|
201
|
+
input.placeholderString = "Description (optional)"
|
|
202
|
+
alert.accessoryView = input
|
|
203
|
+
alert.window.initialFirstResponder = input
|
|
204
|
+
|
|
205
|
+
NSApp.activate(ignoringOtherApps: true)
|
|
206
|
+
let response = alert.runModal()
|
|
207
|
+
|
|
208
|
+
if response == .alertThirdButtonReturn { return }
|
|
209
|
+
|
|
210
|
+
let desc = response == .alertFirstButtonReturn ? input.stringValue : nil
|
|
211
|
+
|
|
212
|
+
var db: OpaquePointer?
|
|
213
|
+
guard sqlite3_open(dbPath, &db) == SQLITE_OK else { return }
|
|
214
|
+
defer { sqlite3_close(db) }
|
|
215
|
+
sqlite3_exec(db, "PRAGMA journal_mode=WAL", nil, nil, nil)
|
|
216
|
+
sqlite3_busy_timeout(db, 5000)
|
|
217
|
+
|
|
218
|
+
var stmt: OpaquePointer?
|
|
219
|
+
let sql = "UPDATE TimeEntry SET endTime = datetime('now'), description = ?, updatedAt = datetime('now') WHERE id = ?"
|
|
220
|
+
if sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK {
|
|
221
|
+
if let d = desc, !d.isEmpty {
|
|
222
|
+
sqlite3_bind_text(stmt, 1, (d as NSString).utf8String, -1, SQLITE_TRANSIENT)
|
|
223
|
+
} else {
|
|
224
|
+
sqlite3_bind_null(stmt, 1)
|
|
225
|
+
}
|
|
226
|
+
sqlite3_bind_text(stmt, 2, (entryId as NSString).utf8String, -1, SQLITE_TRANSIENT)
|
|
227
|
+
sqlite3_step(stmt)
|
|
228
|
+
}
|
|
229
|
+
sqlite3_finalize(stmt)
|
|
230
|
+
|
|
231
|
+
updateMenu()
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
@objc func startTimerClicked(_ sender: NSMenuItem) {
|
|
235
|
+
guard let projectId = sender.representedObject as? String else { return }
|
|
236
|
+
|
|
237
|
+
var db: OpaquePointer?
|
|
238
|
+
guard sqlite3_open(dbPath, &db) == SQLITE_OK else { return }
|
|
239
|
+
defer { sqlite3_close(db) }
|
|
240
|
+
sqlite3_exec(db, "PRAGMA journal_mode=WAL", nil, nil, nil)
|
|
241
|
+
sqlite3_busy_timeout(db, 5000)
|
|
242
|
+
|
|
243
|
+
sqlite3_exec(db, "UPDATE TimeEntry SET endTime = datetime('now'), updatedAt = datetime('now') WHERE endTime IS NULL", nil, nil, nil)
|
|
244
|
+
|
|
245
|
+
let id = UUID().uuidString.lowercased()
|
|
246
|
+
var stmt: OpaquePointer?
|
|
247
|
+
let sql = "INSERT INTO TimeEntry (id, projectId, startTime, createdAt, updatedAt) VALUES (?, ?, datetime('now'), datetime('now'), datetime('now'))"
|
|
248
|
+
if sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK {
|
|
249
|
+
sqlite3_bind_text(stmt, 1, (id as NSString).utf8String, -1, SQLITE_TRANSIENT)
|
|
250
|
+
sqlite3_bind_text(stmt, 2, (projectId as NSString).utf8String, -1, SQLITE_TRANSIENT)
|
|
251
|
+
sqlite3_step(stmt)
|
|
252
|
+
}
|
|
253
|
+
sqlite3_finalize(stmt)
|
|
254
|
+
|
|
255
|
+
updateMenu()
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
@objc func quitApp() {
|
|
259
|
+
NSApp.terminate(nil)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
let app = NSApplication.shared
|
|
264
|
+
app.setActivationPolicy(.accessory)
|
|
265
|
+
let delegate = AppDelegate()
|
|
266
|
+
app.delegate = delegate
|
|
267
|
+
app.run()
|
|
268
|
+
`;
|
|
269
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -102,6 +102,7 @@ export interface AppSettings {
|
|
|
102
102
|
stripeApiKey: string;
|
|
103
103
|
timezone: string; // IANA timezone (e.g., "America/New_York") or "auto" for system detection
|
|
104
104
|
theme: string; // Theme name (e.g., "catppuccin-mocha", "neon")
|
|
105
|
+
menuBar: string; // "enabled" or "disabled"
|
|
105
106
|
}
|
|
106
107
|
|
|
107
108
|
// Theme color definitions
|
package/src/menubar/action.ts
DELETED
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
import { join } from "path";
|
|
2
|
-
import { homedir } from "os";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Returns the content of the action handler script.
|
|
6
|
-
* This script is invoked by SwiftBar when the user clicks a menu item.
|
|
7
|
-
*/
|
|
8
|
-
export function getActionScript(bunPath: string): string {
|
|
9
|
-
const dbPath = join(homedir(), ".paca", "paca.db");
|
|
10
|
-
|
|
11
|
-
return `#!${bunPath}
|
|
12
|
-
// Paca SwiftBar Action Handler — auto-generated, do not edit
|
|
13
|
-
|
|
14
|
-
import Database from "bun:sqlite";
|
|
15
|
-
|
|
16
|
-
const DB_PATH = ${JSON.stringify(dbPath)};
|
|
17
|
-
|
|
18
|
-
function openDb() {
|
|
19
|
-
const db = new Database(DB_PATH);
|
|
20
|
-
db.exec("PRAGMA journal_mode=WAL");
|
|
21
|
-
db.exec("PRAGMA busy_timeout=5000");
|
|
22
|
-
return db;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
async function askDescription(): Promise<string> {
|
|
26
|
-
try {
|
|
27
|
-
const proc = Bun.spawnSync([
|
|
28
|
-
"osascript", "-e",
|
|
29
|
-
'display dialog "What did you work on?" default answer "" with title "Paca — Stop Timer" buttons {"Cancel", "Save"} default button "Save"',
|
|
30
|
-
]);
|
|
31
|
-
const output = proc.stdout.toString().trim();
|
|
32
|
-
// osascript returns: "button returned:Save, text returned:my description"
|
|
33
|
-
const match = output.match(/text returned:(.*)/);
|
|
34
|
-
return match?.[1]?.trim() ?? "";
|
|
35
|
-
} catch {
|
|
36
|
-
return "";
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
async function main() {
|
|
41
|
-
const action = process.argv[2];
|
|
42
|
-
const id = process.argv[3];
|
|
43
|
-
|
|
44
|
-
if (!action || !id) {
|
|
45
|
-
console.error("Usage: action.ts <start|stop> <id>");
|
|
46
|
-
process.exit(1);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const db = openDb();
|
|
50
|
-
|
|
51
|
-
try {
|
|
52
|
-
if (action === "stop") {
|
|
53
|
-
const description = await askDescription();
|
|
54
|
-
db.query(
|
|
55
|
-
\`UPDATE TimeEntry SET endTime = datetime('now'), description = ?, updatedAt = datetime('now') WHERE id = ?\`
|
|
56
|
-
).run(description || null, id);
|
|
57
|
-
} else if (action === "start") {
|
|
58
|
-
// Stop any running timer first
|
|
59
|
-
const running = db.query("SELECT id FROM TimeEntry WHERE endTime IS NULL LIMIT 1").get();
|
|
60
|
-
if (running) {
|
|
61
|
-
db.query(
|
|
62
|
-
\`UPDATE TimeEntry SET endTime = datetime('now'), updatedAt = datetime('now') WHERE id = ?\`
|
|
63
|
-
).run(running.id);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// Start new timer
|
|
67
|
-
const newId = crypto.randomUUID();
|
|
68
|
-
db.query(
|
|
69
|
-
\`INSERT INTO TimeEntry (id, projectId, startTime, createdAt, updatedAt)
|
|
70
|
-
VALUES (?, ?, datetime('now'), datetime('now'), datetime('now'))\`
|
|
71
|
-
).run(newId, id);
|
|
72
|
-
}
|
|
73
|
-
} finally {
|
|
74
|
-
db.close();
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
main();
|
|
79
|
-
`;
|
|
80
|
-
}
|
package/src/menubar/plugin.ts
DELETED
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
import { join } from "path";
|
|
2
|
-
import { homedir } from "os";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Returns the content of the SwiftBar plugin script.
|
|
6
|
-
* The script is a self-contained Bun script that reads the Paca DB
|
|
7
|
-
* and outputs SwiftBar-formatted text.
|
|
8
|
-
*/
|
|
9
|
-
export function getPluginScript(bunPath: string): string {
|
|
10
|
-
const dbPath = join(homedir(), ".paca", "paca.db");
|
|
11
|
-
const actionPath = join(homedir(), ".paca", "menubar-action.ts");
|
|
12
|
-
|
|
13
|
-
return `#!${bunPath}
|
|
14
|
-
// Paca SwiftBar Plugin — auto-generated, do not edit
|
|
15
|
-
// Refresh: every 3 seconds (from filename)
|
|
16
|
-
|
|
17
|
-
import Database from "bun:sqlite";
|
|
18
|
-
|
|
19
|
-
const DB_PATH = ${JSON.stringify(dbPath)};
|
|
20
|
-
const ACTION = ${JSON.stringify(actionPath)};
|
|
21
|
-
const BUN = ${JSON.stringify(bunPath)};
|
|
22
|
-
|
|
23
|
-
function main() {
|
|
24
|
-
let db;
|
|
25
|
-
try {
|
|
26
|
-
db = new Database(DB_PATH, { readonly: true });
|
|
27
|
-
db.exec("PRAGMA busy_timeout=5000");
|
|
28
|
-
} catch {
|
|
29
|
-
console.log("⏸ Paca | color=#6c7086");
|
|
30
|
-
console.log("---");
|
|
31
|
-
console.log("Database not found | color=#f38ba8");
|
|
32
|
-
console.log("Run paca to initialize | color=#6c7086");
|
|
33
|
-
return;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
try {
|
|
37
|
-
// Check for running timer
|
|
38
|
-
const running = db
|
|
39
|
-
.query(
|
|
40
|
-
\`SELECT te.id, te.startTime, p.name AS projectName, p.color AS projectColor
|
|
41
|
-
FROM TimeEntry te
|
|
42
|
-
JOIN Project p ON p.id = te.projectId
|
|
43
|
-
WHERE te.endTime IS NULL
|
|
44
|
-
LIMIT 1\`
|
|
45
|
-
)
|
|
46
|
-
.get();
|
|
47
|
-
|
|
48
|
-
if (running) {
|
|
49
|
-
// Calculate elapsed time
|
|
50
|
-
const startMs = new Date(running.startTime + "Z").getTime();
|
|
51
|
-
const elapsedMs = Date.now() - startMs;
|
|
52
|
-
const totalSec = Math.floor(elapsedMs / 1000);
|
|
53
|
-
const h = Math.floor(totalSec / 3600);
|
|
54
|
-
const m = Math.floor((totalSec % 3600) / 60);
|
|
55
|
-
const s = totalSec % 60;
|
|
56
|
-
const elapsed = \`\${h}:\${String(m).padStart(2, "0")}:\${String(s).padStart(2, "0")}\`;
|
|
57
|
-
|
|
58
|
-
console.log(\`▶ \${running.projectName} \${elapsed} | color=#a6e3a1 sfimage=timer\`);
|
|
59
|
-
console.log("---");
|
|
60
|
-
console.log(\`Stop Timer | bash=\${BUN} param1=\${ACTION} param2=stop param3=\${running.id} terminal=false refresh=true\`);
|
|
61
|
-
} else {
|
|
62
|
-
console.log("⏸ Paca | color=#6c7086 sfimage=timer");
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
console.log("---");
|
|
66
|
-
|
|
67
|
-
// List projects
|
|
68
|
-
const projects = db
|
|
69
|
-
.query("SELECT id, name, color FROM Project WHERE archived = 0 ORDER BY name ASC")
|
|
70
|
-
.all();
|
|
71
|
-
|
|
72
|
-
if (projects.length === 0) {
|
|
73
|
-
console.log("No projects yet | color=#6c7086");
|
|
74
|
-
console.log("Open paca to create one | color=#6c7086");
|
|
75
|
-
} else {
|
|
76
|
-
console.log("Start Timer For... | disabled=true color=#6c7086");
|
|
77
|
-
for (const p of projects) {
|
|
78
|
-
console.log(\`\${p.name} | bash=\${BUN} param1=\${ACTION} param2=start param3=\${p.id} terminal=false refresh=true color=\${p.color}\`);
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
} finally {
|
|
82
|
-
db.close();
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
main();
|
|
87
|
-
`;
|
|
88
|
-
}
|