cc-caffeine 0.2.0

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/src/session.js ADDED
@@ -0,0 +1,227 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+ const lockfile = require('proper-lockfile');
5
+
6
+ const CONFIG_DIR = path.join(os.homedir(), '.claude', 'plugins', 'cc-caffeine');
7
+ const SESSIONS_FILE = path.join(CONFIG_DIR, 'sessions.json');
8
+ const SESSION_TIMEOUT = 15 * 60 * 1000; // 15 minutes
9
+ const MAX_RETRIES = 10;
10
+
11
+ const initSessionsFile = async () => {
12
+ if (!fs.existsSync(SESSIONS_FILE)) {
13
+ const initialData = {
14
+ sessions: {},
15
+ last_updated: new Date().toISOString()
16
+ };
17
+ fs.writeFileSync(SESSIONS_FILE, JSON.stringify(initialData, null, 2));
18
+ }
19
+ };
20
+
21
+ const readSessionsWithLock = async (retryCount = 0) => {
22
+ try {
23
+ await initSessionsFile();
24
+
25
+ const release = await lockfile.lock(SESSIONS_FILE, {
26
+ retries: MAX_RETRIES,
27
+ stale: 30000 // 30 seconds
28
+ });
29
+
30
+ try {
31
+ const data = fs.readFileSync(SESSIONS_FILE, 'utf8');
32
+ return JSON.parse(data);
33
+ } finally {
34
+ await release();
35
+ }
36
+ } catch (error) {
37
+ if (retryCount < MAX_RETRIES) {
38
+ console.warn(`Retry ${retryCount + 1}/${MAX_RETRIES} for readSessionsWithLock`);
39
+ await new Promise(resolve => setTimeout(resolve, 100 * Math.pow(2, retryCount)));
40
+ return readSessionsWithLock(retryCount + 1);
41
+ }
42
+ throw error;
43
+ }
44
+ };
45
+
46
+ // Note: Individual write operations should use addSession/removeSession for atomicity
47
+
48
+ const addSessionWithLock = async sessionId => {
49
+ await initSessionsFile();
50
+
51
+ const release = await lockfile.lock(SESSIONS_FILE, {
52
+ retries: MAX_RETRIES,
53
+ stale: 30000 // 30 seconds
54
+ });
55
+
56
+ try {
57
+ // Read data while holding the lock
58
+ const data = JSON.parse(fs.readFileSync(SESSIONS_FILE, 'utf8'));
59
+ const now = new Date().toISOString();
60
+
61
+ // Clean up expired sessions first
62
+ const nowDate = new Date();
63
+ let removedCount = 0;
64
+
65
+ for (const [existingSessionId, sessionData] of Object.entries(data.sessions)) {
66
+ const lastActivity = new Date(sessionData.last_activity);
67
+ const timeDiff = nowDate - lastActivity;
68
+
69
+ if (timeDiff >= SESSION_TIMEOUT) {
70
+ delete data.sessions[existingSessionId];
71
+ removedCount++;
72
+ }
73
+ }
74
+
75
+ // Add or update the session
76
+ if (data.sessions[sessionId]) {
77
+ // Update existing session's last_activity only
78
+ data.sessions[sessionId].last_activity = now;
79
+ } else {
80
+ // Create new session
81
+ data.sessions[sessionId] = {
82
+ created_at: now,
83
+ last_activity: now,
84
+ project_dir: process.env.CLAUDE_PROJECT_DIR
85
+ };
86
+ }
87
+
88
+ // Write updated data while still holding the lock
89
+ data.last_updated = now;
90
+ fs.writeFileSync(SESSIONS_FILE, JSON.stringify(data, null, 2));
91
+
92
+ const isNewSession =
93
+ !data.sessions[sessionId] ||
94
+ (data.sessions[sessionId].created_at === now &&
95
+ data.sessions[sessionId].last_activity === now);
96
+ const action = isNewSession ? 'added' : 'updated';
97
+
98
+ // console.error(`Cleaned up ${removedCount} expired sessions and ${action} session: ${sessionId}`);
99
+ return { id: sessionId, cleaned_sessions: removedCount, action };
100
+ } finally {
101
+ await release();
102
+ }
103
+ };
104
+
105
+ const removeSessionWithLock = async sessionId => {
106
+ await initSessionsFile();
107
+
108
+ const release = await lockfile.lock(SESSIONS_FILE, {
109
+ retries: MAX_RETRIES,
110
+ stale: 30000 // 30 seconds
111
+ });
112
+
113
+ try {
114
+ // Read data while holding the lock
115
+ const data = JSON.parse(fs.readFileSync(SESSIONS_FILE, 'utf8'));
116
+ const now = new Date().toISOString();
117
+ let changes = 0;
118
+
119
+ // Clean up expired sessions first
120
+ const nowDate = new Date();
121
+ let cleanedCount = 0;
122
+
123
+ for (const [existingSessionId, sessionData] of Object.entries(data.sessions)) {
124
+ const lastActivity = new Date(sessionData.last_activity);
125
+ const timeDiff = nowDate - lastActivity;
126
+
127
+ if (timeDiff >= SESSION_TIMEOUT) {
128
+ delete data.sessions[existingSessionId];
129
+ cleanedCount++;
130
+ }
131
+ }
132
+
133
+ // Remove the specific session if it exists
134
+ if (data.sessions[sessionId]) {
135
+ delete data.sessions[sessionId];
136
+ changes = 1;
137
+ }
138
+
139
+ // Write updated data while still holding the lock
140
+ if (changes > 0 || cleanedCount > 0) {
141
+ data.last_updated = now;
142
+ fs.writeFileSync(SESSIONS_FILE, JSON.stringify(data, null, 2));
143
+ }
144
+
145
+ if (cleanedCount > 0) {
146
+ // console.error(`Cleaned up ${cleanedCount} expired sessions`);
147
+ }
148
+
149
+ return { changes, cleaned_sessions: cleanedCount };
150
+ } finally {
151
+ await release();
152
+ }
153
+ };
154
+
155
+ const getActiveSessionsWithLock = async () => {
156
+ await initSessionsFile();
157
+
158
+ const release = await lockfile.lock(SESSIONS_FILE, {
159
+ retries: MAX_RETRIES,
160
+ stale: 30000 // 30 seconds
161
+ });
162
+
163
+ try {
164
+ const data = JSON.parse(fs.readFileSync(SESSIONS_FILE, 'utf8'));
165
+ const now = new Date();
166
+ const activeSessions = [];
167
+
168
+ for (const [sessionId, sessionData] of Object.entries(data.sessions)) {
169
+ const lastActivity = new Date(sessionData.last_activity);
170
+ const timeDiff = now - lastActivity;
171
+
172
+ if (timeDiff < SESSION_TIMEOUT) {
173
+ activeSessions.push({
174
+ id: sessionId,
175
+ ...sessionData
176
+ });
177
+ }
178
+ }
179
+
180
+ return activeSessions;
181
+ } finally {
182
+ await release();
183
+ }
184
+ };
185
+
186
+ const cleanupExpiredSessionsWithLock = async () => {
187
+ await initSessionsFile();
188
+
189
+ const release = await lockfile.lock(SESSIONS_FILE, {
190
+ retries: MAX_RETRIES,
191
+ stale: 30000 // 30 seconds
192
+ });
193
+
194
+ try {
195
+ const data = JSON.parse(fs.readFileSync(SESSIONS_FILE, 'utf8'));
196
+ const now = new Date();
197
+ let removedCount = 0;
198
+
199
+ for (const [sessionId, sessionData] of Object.entries(data.sessions)) {
200
+ const lastActivity = new Date(sessionData.last_activity);
201
+ const timeDiff = now - lastActivity;
202
+
203
+ if (timeDiff >= SESSION_TIMEOUT) {
204
+ delete data.sessions[sessionId];
205
+ removedCount++;
206
+ }
207
+ }
208
+
209
+ if (removedCount > 0) {
210
+ data.last_updated = new Date().toISOString();
211
+ fs.writeFileSync(SESSIONS_FILE, JSON.stringify(data, null, 2));
212
+ }
213
+
214
+ return { changes: removedCount };
215
+ } finally {
216
+ await release();
217
+ }
218
+ };
219
+
220
+ module.exports = {
221
+ initSessionsFile,
222
+ readSessionsWithLock,
223
+ addSessionWithLock,
224
+ removeSessionWithLock,
225
+ getActiveSessionsWithLock,
226
+ cleanupExpiredSessionsWithLock
227
+ };
@@ -0,0 +1,247 @@
1
+ /**
2
+ * System Tray module - Handles all system tray functionality
3
+ */
4
+
5
+ const path = require('path');
6
+
7
+ const { getActiveSessionsWithLock, cleanupExpiredSessionsWithLock } = require('./session');
8
+ const { getElectron } = require('./electron');
9
+ const { removePidFileWithLock } = require('./pid');
10
+ const package = require('../package.json');
11
+
12
+ let trayState = null;
13
+
14
+ /**
15
+ * Create icon for system tray
16
+ */
17
+ const createIcon = isActive => {
18
+ const icon = isActive ? '../assets/icon-coffee-full.png' : '../assets/icon-coffee-empty.png';
19
+ const iconPath = path.join(__dirname, icon);
20
+ const { nativeImage } = getElectron();
21
+ return nativeImage.createFromPath(iconPath);
22
+ };
23
+
24
+ /**
25
+ * Create system tray
26
+ */
27
+ const createSystemTray = () => {
28
+ const { Tray, Menu } = getElectron();
29
+
30
+ if (!Tray) {
31
+ throw new Error('Electron Tray is not available');
32
+ }
33
+
34
+ try {
35
+ const tray = new Tray(createIcon(false));
36
+ tray.setToolTip('CC-Caffeine: Normal');
37
+
38
+ trayState = {
39
+ tray,
40
+ isCaffeinated: false,
41
+ pollInterval: null,
42
+ powerSaveBlockerId: null
43
+ };
44
+
45
+ if (!Menu) {
46
+ throw new Error('Electron Menu is not available');
47
+ }
48
+
49
+ const contextMenu = Menu.buildFromTemplate([
50
+ {
51
+ label: `Version: ${package.version}`,
52
+ enabled: false
53
+ },
54
+ {
55
+ label: 'Github',
56
+ click: () => {
57
+ getElectron().shell.openExternal('https://github.com/samber/cc-caffeine')
58
+ }
59
+ },
60
+ {
61
+ label: '💖 Sponsor',
62
+ click: () => {
63
+ getElectron().shell.openExternal('https://github.com/sponsors/samber')
64
+ }
65
+ },
66
+ {
67
+ type: 'separator'
68
+ },
69
+ {
70
+ label: 'Exit',
71
+ click: async () => {
72
+ await shutdownServer(trayState);
73
+ process.exit(0);
74
+ }
75
+ }
76
+ ]);
77
+
78
+ tray.setContextMenu(contextMenu);
79
+ return trayState;
80
+ } catch (error) {
81
+ console.error('Error creating Electron system tray:', error);
82
+ throw error;
83
+ }
84
+ };
85
+
86
+ /**
87
+ * Get current system tray state
88
+ */
89
+ const getSystemTrayState = () => {
90
+ return trayState;
91
+ };
92
+
93
+ /**
94
+ * Get system tray instance
95
+ */
96
+ const getSystemTray = () => {
97
+ if (!trayState) {
98
+ return createSystemTray();
99
+ }
100
+ return trayState;
101
+ };
102
+
103
+ /**
104
+ * Update tray icon based on caffeine state
105
+ */
106
+ const updateTrayIcon = state => {
107
+ if (!state || !state.tray) {
108
+ return;
109
+ }
110
+
111
+ const icon = createIcon(state.isCaffeinated);
112
+ state.tray.setImage(icon);
113
+ state.tray.setToolTip(`CC-Caffeine: ${state.isCaffeinated ? 'Caffeinated' : 'Normal'}`);
114
+ };
115
+
116
+ /**
117
+ * Enable caffeine (prevent sleep)
118
+ */
119
+ const enableCaffeine = state => {
120
+ if (!state.isCaffeinated) {
121
+ const { powerSaveBlocker } = getElectron();
122
+ state.isCaffeinated = true;
123
+ state.powerSaveBlockerId = powerSaveBlocker.start('prevent-app-suspension');
124
+ }
125
+ };
126
+
127
+ /**
128
+ * Disable caffeine (allow sleep)
129
+ */
130
+ const disableCaffeine = state => {
131
+ if (state.isCaffeinated) {
132
+ const { powerSaveBlocker } = getElectron();
133
+ state.isCaffeinated = false;
134
+ if (state.powerSaveBlockerId !== null) {
135
+ powerSaveBlocker.stop(state.powerSaveBlockerId);
136
+ state.powerSaveBlockerId = null;
137
+ }
138
+ }
139
+ };
140
+
141
+ /**
142
+ * Update caffeine status based on active sessions
143
+ */
144
+ const updateCaffeineStatus = async state => {
145
+ if (!state) {
146
+ return;
147
+ }
148
+
149
+ try {
150
+ await cleanupExpiredSessionsWithLock();
151
+ const activeSessions = await getActiveSessionsWithLock();
152
+ const shouldCaffeinate = activeSessions.length > 0;
153
+
154
+ if (shouldCaffeinate && !state.isCaffeinated) {
155
+ enableCaffeine(state);
156
+ } else if (!shouldCaffeinate && state.isCaffeinated) {
157
+ disableCaffeine(state);
158
+ }
159
+
160
+ updateTrayIcon(state);
161
+ } catch (error) {
162
+ console.error('Error updating caffeine status:', error);
163
+ }
164
+ };
165
+
166
+ /**
167
+ * Start polling for session changes
168
+ */
169
+ const startPolling = (state, interval = 10000) => {
170
+ // Initial check
171
+ updateCaffeineStatus(state);
172
+
173
+ // Set up periodic polling
174
+ state.pollInterval = setInterval(() => {
175
+ updateCaffeineStatus(state);
176
+ }, interval);
177
+ };
178
+
179
+ /**
180
+ * Stop polling
181
+ */
182
+ const stopPolling = state => {
183
+ if (state && state.pollInterval) {
184
+ try {
185
+ clearInterval(state.pollInterval);
186
+ state.pollInterval = null;
187
+ } catch (error) {
188
+ console.error('Error clearing interval:', error.message);
189
+ }
190
+ }
191
+ };
192
+
193
+ /**
194
+ * Shutdown server and clean up resources
195
+ */
196
+ const shutdownServer = async state => {
197
+ console.error('Shutting down caffeine server...');
198
+
199
+ if (!state) {
200
+ console.error('No state provided, exiting...');
201
+ return;
202
+ }
203
+
204
+ // Stop polling
205
+ stopPolling(state);
206
+
207
+ // Always disable caffeine before shutting down
208
+ try {
209
+ disableCaffeine(state);
210
+ } catch (error) {
211
+ console.error('Error disabling caffeine:', error.message);
212
+ }
213
+
214
+ // Clean up Electron system tray
215
+ try {
216
+ if (state.tray) {
217
+ state.tray.destroy();
218
+ state.tray = null;
219
+ }
220
+ } catch (error) {
221
+ console.error('Error destroying Electron system tray:', error.message);
222
+ }
223
+
224
+ // Remove PID file
225
+ try {
226
+ await removePidFileWithLock();
227
+ } catch (error) {
228
+ console.error('Error removing PID file:', error.message);
229
+ }
230
+
231
+ // Reset global state
232
+ trayState = null;
233
+ };
234
+
235
+ module.exports = {
236
+ createIcon,
237
+ createSystemTray,
238
+ getSystemTray,
239
+ getSystemTrayState,
240
+ updateTrayIcon,
241
+ enableCaffeine,
242
+ disableCaffeine,
243
+ updateCaffeineStatus,
244
+ startPolling,
245
+ stopPolling,
246
+ shutdownServer
247
+ };