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 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:
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pacatui",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "description": "A simple tui app for task, timer and invoicing for projects.",
5
5
  "module": "src/index.tsx",
6
6
  "type": "module",
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: // Export Database
888
+ case 4: // Menu Bar
889
+ handleToggleMenuBar();
890
+ break;
891
+ case 5: // Export Database
864
892
  handleExportDatabase();
865
893
  break;
866
- case 5: // Import Database
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
@@ -631,6 +631,7 @@ export const settings = {
631
631
  stripeApiKey: all.stripeApiKey ?? "",
632
632
  timezone: all.timezone ?? "auto",
633
633
  theme: all.theme ?? "catppuccin-mocha",
634
+ menuBar: all.menuBar ?? "disabled",
634
635
  };
635
636
  },
636
637
  };
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) {
@@ -4,10 +4,16 @@ import { homedir } from "os";
4
4
 
5
5
  const DB_PATH = join(homedir(), ".paca", "paca.db");
6
6
 
7
- function openDb(): Database {
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 = openDb();
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 = openDb();
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 = openDb();
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 = openDb();
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 = ?`,
@@ -1,143 +1,201 @@
1
- import { existsSync, writeFileSync, unlinkSync, chmodSync } from "fs";
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 { getPluginScript } from "./plugin.ts";
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 ACTION_PATH = join(PACA_DIR, ACTION_FILENAME);
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 resolveBunPath(): string {
14
+ function hasSwiftCompiler(): boolean {
15
15
  try {
16
- return execSync("which bun", { encoding: "utf-8" }).trim();
16
+ execSync("which swiftc", { encoding: "utf-8", stdio: "pipe" });
17
+ return true;
17
18
  } catch {
18
- throw new Error("Could not find bun in PATH. Is Bun installed?");
19
+ return false;
19
20
  }
20
21
  }
21
22
 
22
- function findSwiftBarPluginDir(): string | null {
23
- // Try reading from SwiftBar defaults
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
- const dir = execSync(
26
- "defaults read com.ameba.SwiftBar PluginDirectory 2>/dev/null",
27
- { encoding: "utf-8" },
28
- ).trim();
29
- if (dir && existsSync(dir)) return dir;
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
- // ignore
42
+ return "";
32
43
  }
44
+ }
33
45
 
34
- // Check common locations
35
- const candidates = [
36
- join(homedir(), "Library", "Application Support", "SwiftBar", "Plugins"),
37
- join(homedir(), ".swiftbar"),
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
- for (const dir of candidates) {
41
- if (existsSync(dir)) return dir;
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
- return null;
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 isSwiftBarInstalled(): boolean {
48
- return (
49
- existsSync("/Applications/SwiftBar.app") ||
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 formatElapsed(startTimeStr: string): string {
55
- const startMs = new Date(startTimeStr + "Z").getTime();
56
- const elapsedMs = Date.now() - startMs;
57
- const totalSec = Math.floor(elapsedMs / 1000);
58
- const h = Math.floor(totalSec / 3600);
59
- const m = Math.floor((totalSec % 3600) / 60);
60
- const s = totalSec % 60;
61
- return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
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
- async function install() {
65
- if (!isSwiftBarInstalled()) {
66
- console.log("SwiftBar is not installed.");
67
- console.log("");
68
- console.log("Install it with:");
69
- console.log(" brew install --cask swiftbar");
70
- console.log("");
71
- console.log("Then run this command again:");
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
- const pluginDir = findSwiftBarPluginDir();
77
- if (!pluginDir) {
78
- console.log("Could not find SwiftBar plugin directory.");
79
- console.log("");
80
- console.log("Make sure SwiftBar has been launched at least once,");
81
- console.log("or set a plugin directory in SwiftBar preferences.");
82
- process.exit(1);
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
- const bunPath = resolveBunPath();
86
- const pluginPath = join(pluginDir, PLUGIN_FILENAME);
126
+ if (!hasSwiftCompiler()) {
127
+ return "Xcode Command Line Tools required. Install with: xcode-select --install";
128
+ }
87
129
 
88
- // Write plugin script
89
- writeFileSync(pluginPath, getPluginScript(bunPath), "utf-8");
90
- chmodSync(pluginPath, 0o755);
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
- // Write action script
93
- writeFileSync(ACTION_PATH, getActionScript(bunPath), "utf-8");
94
- chmodSync(ACTION_PATH, 0o755);
136
+ const launched = launchHelper();
137
+ if (!launched) return "Failed to launch menu bar helper.";
95
138
 
96
- console.log("Paca menu bar plugin installed!");
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
- async function uninstall() {
106
- let removed = false;
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 plugin
109
- const pluginDir = findSwiftBarPluginDir();
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
- // Remove action script
120
- if (existsSync(ACTION_PATH)) {
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
- if (removed) {
127
- console.log("");
128
- console.log("Paca menu bar plugin uninstalled.");
129
- } else {
130
- console.log("No menu bar plugin files found to remove.");
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 status() {
135
- const DB_PATH = join(PACA_DIR, "paca.db");
136
- if (!existsSync(DB_PATH)) {
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 projects = getProjects();
150
- if (projects.length > 0) {
207
+ const allProjects = getProjects();
208
+ if (allProjects.length > 0) {
151
209
  console.log("");
152
210
  console.log("Projects:");
153
- for (const p of projects) {
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 install Install SwiftBar plugin");
171
- console.log(" paca menubar uninstall Remove SwiftBar plugin");
172
- console.log(" paca menubar status Show current timer status");
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
- if (!isSwiftBarInstalled()) {
176
- console.log("SwiftBar is not installed. Install it with:");
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
- const subcommand = args[0];
197
-
198
- switch (subcommand) {
199
- case "install":
200
- await install();
229
+ switch (args[0]) {
230
+ case "enable":
231
+ await cliEnable();
201
232
  break;
202
- case "uninstall":
203
- await uninstall();
233
+ case "disable":
234
+ await cliDisable();
204
235
  break;
205
236
  case "status":
206
- status();
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
@@ -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
- }
@@ -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
- }