copyhub-cli 1.0.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/.env.example +24 -0
- package/README.md +122 -0
- package/package.json +39 -0
- package/src/cli.js +337 -0
- package/src/clipboard-watcher.js +45 -0
- package/src/config.js +180 -0
- package/src/daemon-state.js +51 -0
- package/src/electron-launcher.js +47 -0
- package/src/oauth.js +579 -0
- package/src/paths.js +9 -0
- package/src/platform.js +17 -0
- package/src/sheet-api-errors.js +50 -0
- package/src/sheet-daily.js +11 -0
- package/src/sheets.js +106 -0
- package/src/start-daemon-logic.js +114 -0
- package/src/stop-process.js +29 -0
- package/src/storage.js +59 -0
- package/src/tokens.js +24 -0
- package/ui/main.mjs +331 -0
- package/ui/preload.cjs +12 -0
- package/ui/renderer/index.html +224 -0
package/src/sheets.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { google } from 'googleapis';
|
|
2
|
+
import { getAuthorizedClient } from './oauth.js';
|
|
3
|
+
import { loadSheetSyncTarget } from './config.js';
|
|
4
|
+
import { dailySheetTabName } from './sheet-daily.js';
|
|
5
|
+
import { formatGoogleSheetUserMessage } from './sheet-api-errors.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {string} tabName
|
|
9
|
+
*/
|
|
10
|
+
function a1RangeForTab(tabName) {
|
|
11
|
+
const escaped = /[^A-Za-z0-9_]/.test(tabName)
|
|
12
|
+
? `'${tabName.replace(/'/g, "''")}'`
|
|
13
|
+
: tabName;
|
|
14
|
+
return `${escaped}!A:B`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {import('googleapis').sheets_v4.Sheets} sheets
|
|
19
|
+
* @param {string} spreadsheetId
|
|
20
|
+
* @param {string} tabName
|
|
21
|
+
* @returns {Promise<boolean>} true if a new tab was created (header row written)
|
|
22
|
+
*/
|
|
23
|
+
async function ensureDailySheetExists(sheets, spreadsheetId, tabName) {
|
|
24
|
+
const { data } = await sheets.spreadsheets.get({
|
|
25
|
+
spreadsheetId,
|
|
26
|
+
fields: 'sheets.properties(title)',
|
|
27
|
+
});
|
|
28
|
+
const exists = data.sheets?.some((s) => s.properties?.title === tabName);
|
|
29
|
+
if (exists) return false;
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
await sheets.spreadsheets.batchUpdate({
|
|
33
|
+
spreadsheetId,
|
|
34
|
+
requestBody: {
|
|
35
|
+
requests: [
|
|
36
|
+
{
|
|
37
|
+
addSheet: {
|
|
38
|
+
properties: {
|
|
39
|
+
title: tabName,
|
|
40
|
+
gridProperties: { rowCount: 5000, columnCount: 4 },
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
} catch (e) {
|
|
48
|
+
const err = /** @type {Error & { code?: number }} */ (e);
|
|
49
|
+
const msg = `${err.message || ''} ${err.code || ''}`;
|
|
50
|
+
if (msg.includes('already exists') || err.code === 400) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
throw e;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const headerRange = a1RangeForTab(tabName).replace('!A:B', '!A1:B1');
|
|
57
|
+
await sheets.spreadsheets.values.update({
|
|
58
|
+
spreadsheetId,
|
|
59
|
+
range: headerRange,
|
|
60
|
+
valueInputOption: 'RAW',
|
|
61
|
+
requestBody: {
|
|
62
|
+
values: [['Time', 'Content']],
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Append one row to the **daily** tab (creates a new tab each day if missing).
|
|
70
|
+
* Column A = ISO timestamp, column B = content (RAW).
|
|
71
|
+
* @param {string} clipboardText
|
|
72
|
+
*/
|
|
73
|
+
export async function appendClipboardToSheet(clipboardText) {
|
|
74
|
+
try {
|
|
75
|
+
const target = await loadSheetSyncTarget();
|
|
76
|
+
if (!target) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
'No Spreadsheet ID. Run copyhub login (setup page) or copyhub config ... --sheet-id <ID>.',
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const auth = await getAuthorizedClient();
|
|
83
|
+
if (!auth.credentials.refresh_token && !auth.credentials.access_token) {
|
|
84
|
+
throw new Error('Not signed in. Run: copyhub login');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const sheets = google.sheets({ version: 'v4', auth });
|
|
88
|
+
const tabName = dailySheetTabName();
|
|
89
|
+
await ensureDailySheetExists(sheets, target.spreadsheetId, tabName);
|
|
90
|
+
|
|
91
|
+
const range = a1RangeForTab(tabName);
|
|
92
|
+
const ts = new Date().toISOString();
|
|
93
|
+
|
|
94
|
+
await sheets.spreadsheets.values.append({
|
|
95
|
+
spreadsheetId: target.spreadsheetId,
|
|
96
|
+
range,
|
|
97
|
+
valueInputOption: 'RAW',
|
|
98
|
+
insertDataOption: 'INSERT_ROWS',
|
|
99
|
+
requestBody: {
|
|
100
|
+
values: [[ts, clipboardText]],
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
} catch (e) {
|
|
104
|
+
throw new Error(formatGoogleSheetUserMessage(e));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { appendHistory } from './storage.js';
|
|
2
|
+
import { startClipboardWatcher } from './clipboard-watcher.js';
|
|
3
|
+
import { appendClipboardToSheet } from './sheets.js';
|
|
4
|
+
import { loadTokens } from './tokens.js';
|
|
5
|
+
import { loadSheetSyncTarget } from './config.js';
|
|
6
|
+
import { DIR } from './paths.js';
|
|
7
|
+
import { logLinuxClipboardHint } from './platform.js';
|
|
8
|
+
import { dailySheetTabName } from './sheet-daily.js';
|
|
9
|
+
import { spawnCopyhubOverlay } from './electron-launcher.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {{ useSheet: boolean, skipOverlay: boolean }} StartDaemonOptions
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Run clipboard watcher (+ optional overlay); logs via io (defaults to console).
|
|
17
|
+
* @param {StartDaemonOptions} opts
|
|
18
|
+
* @param {{ log: typeof console.log, warn: typeof console.warn, error: typeof console.error }} io
|
|
19
|
+
*/
|
|
20
|
+
export async function runCopyhubDaemon(opts, io = console) {
|
|
21
|
+
const useSheet = opts.useSheet;
|
|
22
|
+
const skipOverlay = opts.skipOverlay;
|
|
23
|
+
|
|
24
|
+
let tokens = await loadTokens();
|
|
25
|
+
const sheetTarget = await loadSheetSyncTarget();
|
|
26
|
+
|
|
27
|
+
if (useSheet && !sheetTarget) {
|
|
28
|
+
io.warn(
|
|
29
|
+
'No Spreadsheet ID in ~/.copyhub/config.json — run copyhub login or copyhub config ... --sheet-id <ID>',
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
if (useSheet && sheetTarget && !tokens?.refresh_token && !tokens?.access_token) {
|
|
33
|
+
io.warn('No OAuth token — local history only. Run copyhub login.');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
io.log('CopyHub daemon running.');
|
|
37
|
+
io.log('Data directory:', DIR);
|
|
38
|
+
if (sheetTarget) {
|
|
39
|
+
io.log(`Sheet: daily tab "${dailySheetTabName()}".`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** @type {import('node:child_process').ChildProcess | null} */
|
|
43
|
+
let overlayProc = null;
|
|
44
|
+
let overlayStoppingWithCli = false;
|
|
45
|
+
|
|
46
|
+
if (!skipOverlay) {
|
|
47
|
+
try {
|
|
48
|
+
overlayProc = spawnCopyhubOverlay({
|
|
49
|
+
stdio: 'ignore',
|
|
50
|
+
envExtra: { COPYHUB_SPAWNED_BY_START: '1' },
|
|
51
|
+
});
|
|
52
|
+
overlayProc.on('error', (err) => {
|
|
53
|
+
io.warn('Could not start Electron overlay:', err.message);
|
|
54
|
+
overlayProc = null;
|
|
55
|
+
});
|
|
56
|
+
overlayProc.on('exit', (code, sig) => {
|
|
57
|
+
if (!overlayStoppingWithCli && code != null && code !== 0) {
|
|
58
|
+
io.warn(
|
|
59
|
+
`Electron overlay exited (code ${code}). Run copyhub overlay or restart copyhub start.`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
overlayProc = null;
|
|
63
|
+
});
|
|
64
|
+
io.log('Started history overlay (Electron).');
|
|
65
|
+
} catch (e) {
|
|
66
|
+
io.warn('Could not start overlay:', /** @type {Error} */ (e).message);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
logLinuxClipboardHint();
|
|
71
|
+
|
|
72
|
+
let lastSheetLogKey = '';
|
|
73
|
+
let lastSheetLogAt = 0;
|
|
74
|
+
|
|
75
|
+
const watcher = startClipboardWatcher(async (text) => {
|
|
76
|
+
let synced = false;
|
|
77
|
+
if (useSheet && sheetTarget && (tokens?.refresh_token || tokens?.access_token)) {
|
|
78
|
+
try {
|
|
79
|
+
await appendClipboardToSheet(text);
|
|
80
|
+
synced = true;
|
|
81
|
+
lastSheetLogKey = '';
|
|
82
|
+
io.log(`[${new Date().toISOString()}] Appended row to Google Sheet (daily tab).`);
|
|
83
|
+
} catch (e) {
|
|
84
|
+
const msg = /** @type {Error} */ (e).message;
|
|
85
|
+
const now = Date.now();
|
|
86
|
+
const key = msg.slice(0, 160);
|
|
87
|
+
if (key !== lastSheetLogKey || now - lastSheetLogAt > 120_000) {
|
|
88
|
+
lastSheetLogKey = key;
|
|
89
|
+
lastSheetLogAt = now;
|
|
90
|
+
io.error(`[${new Date().toISOString()}] Google Sheet error:`, msg);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
await appendHistory({ text, syncedToSheet: synced });
|
|
95
|
+
const oneLine = text.replace(/\r?\n/g, '\\n').slice(0, 120);
|
|
96
|
+
io.log(
|
|
97
|
+
`[${new Date().toISOString()}] Saved (${text.length} chars): ${oneLine}${text.length > 120 ? '…' : ''}`,
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
stopSync() {
|
|
103
|
+
watcher.stop();
|
|
104
|
+
if (overlayProc && !overlayProc.killed) {
|
|
105
|
+
overlayStoppingWithCli = true;
|
|
106
|
+
try {
|
|
107
|
+
overlayProc.kill('SIGTERM');
|
|
108
|
+
} catch {
|
|
109
|
+
/* ignore */
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Stop process and (Windows) entire child tree (Electron overlay).
|
|
5
|
+
* @param {number} pid
|
|
6
|
+
*/
|
|
7
|
+
export function killDaemonTree(pid) {
|
|
8
|
+
if (process.platform === 'win32') {
|
|
9
|
+
try {
|
|
10
|
+
execSync(`taskkill /PID ${pid} /T /F`, { stdio: 'ignore' });
|
|
11
|
+
} catch {
|
|
12
|
+
try {
|
|
13
|
+
process.kill(pid, 'SIGKILL');
|
|
14
|
+
} catch {
|
|
15
|
+
/* ignore */
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
process.kill(pid, 'SIGTERM');
|
|
22
|
+
} catch {
|
|
23
|
+
try {
|
|
24
|
+
process.kill(pid, 'SIGKILL');
|
|
25
|
+
} catch {
|
|
26
|
+
/* ignore */
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/storage.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { mkdir, appendFile, readFile } from 'node:fs/promises';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { DIR, HISTORY_PATH } from './paths.js';
|
|
4
|
+
|
|
5
|
+
export async function ensureDir() {
|
|
6
|
+
const opts = { recursive: true };
|
|
7
|
+
if (process.platform !== 'win32') {
|
|
8
|
+
Object.assign(opts, { mode: 0o700 });
|
|
9
|
+
}
|
|
10
|
+
await mkdir(DIR, opts);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {{ text: string, syncedToSheet?: boolean }} entry
|
|
15
|
+
*/
|
|
16
|
+
export async function appendHistory(entry) {
|
|
17
|
+
await ensureDir();
|
|
18
|
+
const line = JSON.stringify({
|
|
19
|
+
ts: new Date().toISOString(),
|
|
20
|
+
text: entry.text,
|
|
21
|
+
syncedToSheet: Boolean(entry.syncedToSheet),
|
|
22
|
+
}) + '\n';
|
|
23
|
+
await appendFile(HISTORY_PATH, line, 'utf8');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function readRecentHistory(maxLines = 50) {
|
|
27
|
+
if (!existsSync(HISTORY_PATH)) return [];
|
|
28
|
+
const raw = await readFile(HISTORY_PATH, 'utf8');
|
|
29
|
+
const lines = raw.trim().split('\n').filter(Boolean);
|
|
30
|
+
return lines.slice(-maxLines).map((l) => {
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(l);
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}).filter(Boolean);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Newest items first (for Electron overlay).
|
|
41
|
+
* @param {number} [maxLines]
|
|
42
|
+
* @returns {Array<{ ts?: string, text?: string, syncedToSheet?: boolean }>}
|
|
43
|
+
*/
|
|
44
|
+
export function readRecentHistorySync(maxLines = 200) {
|
|
45
|
+
if (!existsSync(HISTORY_PATH)) return [];
|
|
46
|
+
const raw = readFileSync(HISTORY_PATH, 'utf8');
|
|
47
|
+
const lines = raw.trim().split('\n').filter(Boolean);
|
|
48
|
+
const slice = lines.slice(-maxLines);
|
|
49
|
+
/** @type {Array<{ ts?: string, text?: string, syncedToSheet?: boolean }>} */
|
|
50
|
+
const out = [];
|
|
51
|
+
for (const l of slice) {
|
|
52
|
+
try {
|
|
53
|
+
out.push(JSON.parse(l));
|
|
54
|
+
} catch {
|
|
55
|
+
/* skip */
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return out.reverse();
|
|
59
|
+
}
|
package/src/tokens.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { TOKENS_PATH } from './paths.js';
|
|
4
|
+
import { ensureDir } from './storage.js';
|
|
5
|
+
|
|
6
|
+
/** @typedef {{ access_token?: string, refresh_token?: string, scope?: string, token_type?: string, expiry_date?: number }} TokenSet */
|
|
7
|
+
|
|
8
|
+
/** @returns {Promise<TokenSet | null>} */
|
|
9
|
+
export async function loadTokens() {
|
|
10
|
+
if (!existsSync(TOKENS_PATH)) return null;
|
|
11
|
+
const raw = await readFile(TOKENS_PATH, 'utf8');
|
|
12
|
+
return JSON.parse(raw);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** @param {TokenSet} tokens */
|
|
16
|
+
export async function saveTokens(tokens) {
|
|
17
|
+
await ensureDir();
|
|
18
|
+
await writeFile(TOKENS_PATH, JSON.stringify(tokens, null, 2), 'utf8');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function clearTokens() {
|
|
22
|
+
if (!existsSync(TOKENS_PATH)) return;
|
|
23
|
+
await writeFile(TOKENS_PATH, '{}', 'utf8');
|
|
24
|
+
}
|
package/ui/main.mjs
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import {
|
|
5
|
+
app,
|
|
6
|
+
BrowserWindow,
|
|
7
|
+
globalShortcut,
|
|
8
|
+
clipboard,
|
|
9
|
+
ipcMain,
|
|
10
|
+
screen,
|
|
11
|
+
Tray,
|
|
12
|
+
Menu,
|
|
13
|
+
nativeImage,
|
|
14
|
+
} from 'electron';
|
|
15
|
+
import { readRecentHistorySync } from '../src/storage.js';
|
|
16
|
+
import { loadOverlayAcceleratorFromConfigSync } from '../src/config.js';
|
|
17
|
+
|
|
18
|
+
const gotLock = app.requestSingleInstanceLock();
|
|
19
|
+
if (!gotLock) {
|
|
20
|
+
app.quit();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
|
|
25
|
+
/** Set to 1 so the window does not hide on blur (only Esc / pick row to copy). */
|
|
26
|
+
const STICKY_NO_BLUR = process.env.COPYHUB_OVERLAY_STICKY === '1';
|
|
27
|
+
|
|
28
|
+
/** Electron Accelerator: use `Control`, not `Ctrl`; `CommandOrControl` = Ctrl (Win) / Cmd (Mac). */
|
|
29
|
+
function normalizeAccelerator(raw) {
|
|
30
|
+
if (!raw || typeof raw !== 'string') return '';
|
|
31
|
+
let s = raw.trim();
|
|
32
|
+
s = s.replace(/\bCtrl\b/gi, 'Control');
|
|
33
|
+
s = s.replace(/\bCmd\b/gi, 'Command');
|
|
34
|
+
s = s.replace(/\s*\+\s*/g, '+');
|
|
35
|
+
return s;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const DEFAULT_ACCEL = 'CommandOrControl+Shift+H';
|
|
39
|
+
const HIDE_ON_START = process.env.COPYHUB_OVERLAY_HIDE_ON_START === '1';
|
|
40
|
+
|
|
41
|
+
/** Overlay width (~70% of 460px baseline). */
|
|
42
|
+
const OVERLAY_WIDTH = Math.round(460 * 0.7);
|
|
43
|
+
|
|
44
|
+
/** For UI / IPC: registered shortcut and raw value from .env */
|
|
45
|
+
let overlayHotkeyMeta = {
|
|
46
|
+
accelerator: '',
|
|
47
|
+
usedFallback: false,
|
|
48
|
+
requestedRaw: '',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
let win = null;
|
|
52
|
+
let tray = null;
|
|
53
|
+
/** Avoid hiding immediately after show (WM quirks). */
|
|
54
|
+
let blurHideEnabled = false;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Stay above other apps: screen-saver level (highest in Electron), moveTop, all workspaces.
|
|
58
|
+
* @param {BrowserWindow} w
|
|
59
|
+
*/
|
|
60
|
+
function applyAlwaysOnTopStack(w) {
|
|
61
|
+
if (!w || w.isDestroyed()) return;
|
|
62
|
+
try {
|
|
63
|
+
w.setAlwaysOnTop(true, 'screen-saver');
|
|
64
|
+
} catch {
|
|
65
|
+
try {
|
|
66
|
+
w.setAlwaysOnTop(true, 'floating');
|
|
67
|
+
} catch {
|
|
68
|
+
w.setAlwaysOnTop(true);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
w.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
|
73
|
+
} catch {
|
|
74
|
+
/* unsupported on some Linux builds */
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
if (typeof w.moveTop === 'function') {
|
|
78
|
+
w.moveTop();
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
/* ignore */
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function createWindow() {
|
|
86
|
+
win = new BrowserWindow({
|
|
87
|
+
width: OVERLAY_WIDTH,
|
|
88
|
+
height: 540,
|
|
89
|
+
alwaysOnTop: true,
|
|
90
|
+
show: false,
|
|
91
|
+
/** Frameless: no title bar + menu (Windows/macOS). */
|
|
92
|
+
frame: false,
|
|
93
|
+
roundedCorners: true,
|
|
94
|
+
/** Show on taskbar for visibility (COPYHUB_OVERLAY_SKIP_TASKBAR=1 hides from taskbar). */
|
|
95
|
+
skipTaskbar: process.env.COPYHUB_OVERLAY_SKIP_TASKBAR === '1',
|
|
96
|
+
title: 'CopyHub',
|
|
97
|
+
backgroundColor: '#ffffff',
|
|
98
|
+
webPreferences: {
|
|
99
|
+
preload: path.join(__dirname, 'preload.cjs'),
|
|
100
|
+
contextIsolation: true,
|
|
101
|
+
nodeIntegration: false,
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
win.loadFile(path.join(__dirname, 'renderer', 'index.html'));
|
|
106
|
+
|
|
107
|
+
win.on('show', () => {
|
|
108
|
+
applyAlwaysOnTopStack(win);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
if (!STICKY_NO_BLUR) {
|
|
112
|
+
win.on('blur', () => {
|
|
113
|
+
if (!blurHideEnabled) return;
|
|
114
|
+
if (win && !win.webContents.isDevToolsOpened()) {
|
|
115
|
+
win.hide();
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
win.on('close', (e) => {
|
|
121
|
+
e.preventDefault();
|
|
122
|
+
win?.hide();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
win.webContents.on('before-input-event', (_event, input) => {
|
|
126
|
+
if (input.type === 'keyDown' && input.key === 'Escape') {
|
|
127
|
+
win?.hide();
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
win.once('ready-to-show', () => {
|
|
132
|
+
if (HIDE_ON_START) {
|
|
133
|
+
blurHideEnabled = true;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
placeWindowAtCursor(win);
|
|
137
|
+
win.show();
|
|
138
|
+
applyAlwaysOnTopStack(win);
|
|
139
|
+
win.focus();
|
|
140
|
+
win.webContents.send('overlay:open');
|
|
141
|
+
setTimeout(() => {
|
|
142
|
+
applyAlwaysOnTopStack(win);
|
|
143
|
+
blurHideEnabled = true;
|
|
144
|
+
}, 800);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Position window near cursor (display containing pointer).
|
|
150
|
+
* @param {BrowserWindow} w
|
|
151
|
+
*/
|
|
152
|
+
function placeWindowAtCursor(w) {
|
|
153
|
+
if (!w || w.isDestroyed()) return;
|
|
154
|
+
const point = screen.getCursorScreenPoint();
|
|
155
|
+
const display = screen.getDisplayNearestPoint(point);
|
|
156
|
+
const { workArea } = display;
|
|
157
|
+
const { width, height } = w.getBounds();
|
|
158
|
+
const margin = 10;
|
|
159
|
+
let x = Math.round(point.x - width / 2);
|
|
160
|
+
let y = Math.round(point.y - 40);
|
|
161
|
+
x = Math.max(
|
|
162
|
+
workArea.x + margin,
|
|
163
|
+
Math.min(x, workArea.x + workArea.width - width - margin),
|
|
164
|
+
);
|
|
165
|
+
y = Math.max(
|
|
166
|
+
workArea.y + margin,
|
|
167
|
+
Math.min(y, workArea.y + workArea.height - height - margin),
|
|
168
|
+
);
|
|
169
|
+
w.setPosition(x, y);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function toggleOverlay() {
|
|
173
|
+
if (!win) return;
|
|
174
|
+
if (win.isVisible()) {
|
|
175
|
+
win.hide();
|
|
176
|
+
} else {
|
|
177
|
+
blurHideEnabled = false;
|
|
178
|
+
placeWindowAtCursor(win);
|
|
179
|
+
win.show();
|
|
180
|
+
applyAlwaysOnTopStack(win);
|
|
181
|
+
win.focus();
|
|
182
|
+
win.webContents.send('overlay:open');
|
|
183
|
+
setTimeout(() => {
|
|
184
|
+
applyAlwaysOnTopStack(win);
|
|
185
|
+
blurHideEnabled = true;
|
|
186
|
+
}, 800);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Register global shortcut: try .env (normalized) then default CommandOrControl+Shift+H.
|
|
192
|
+
* @returns {{ accelerator: string, usedFallback: boolean }}
|
|
193
|
+
*/
|
|
194
|
+
function registerHotkeys() {
|
|
195
|
+
const raw =
|
|
196
|
+
process.env.COPYHUB_OVERLAY_ACCELERATOR?.trim() ||
|
|
197
|
+
loadOverlayAcceleratorFromConfigSync();
|
|
198
|
+
const candidates = [];
|
|
199
|
+
if (raw) {
|
|
200
|
+
const n = normalizeAccelerator(raw);
|
|
201
|
+
if (n) candidates.push(n);
|
|
202
|
+
}
|
|
203
|
+
candidates.push(DEFAULT_ACCEL);
|
|
204
|
+
|
|
205
|
+
let usedFallback = false;
|
|
206
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
207
|
+
const acc = candidates[i];
|
|
208
|
+
try {
|
|
209
|
+
if (globalShortcut.register(acc, () => toggleOverlay())) {
|
|
210
|
+
if (i > 0) usedFallback = true;
|
|
211
|
+
return { accelerator: acc, usedFallback };
|
|
212
|
+
}
|
|
213
|
+
} catch (e) {
|
|
214
|
+
console.warn('Invalid accelerator:', acc, /** @type {Error} */ (e).message);
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
globalShortcut.unregister(acc);
|
|
218
|
+
} catch {
|
|
219
|
+
/* ignore */
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return { accelerator: '', usedFallback: false };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function registerIpc() {
|
|
226
|
+
ipcMain.handle('overlay:meta', () => ({
|
|
227
|
+
...overlayHotkeyMeta,
|
|
228
|
+
platform: process.platform,
|
|
229
|
+
defaultAccelerator: DEFAULT_ACCEL,
|
|
230
|
+
sticky: STICKY_NO_BLUR,
|
|
231
|
+
}));
|
|
232
|
+
|
|
233
|
+
ipcMain.handle('history:get', () => {
|
|
234
|
+
try {
|
|
235
|
+
return {
|
|
236
|
+
items: readRecentHistorySync(200).map((row) => ({
|
|
237
|
+
ts: row.ts || '',
|
|
238
|
+
text: typeof row.text === 'string' ? row.text : '',
|
|
239
|
+
synced: Boolean(row.syncedToSheet || row.syncedToGmail),
|
|
240
|
+
})),
|
|
241
|
+
};
|
|
242
|
+
} catch (e) {
|
|
243
|
+
return { error: /** @type {Error} */ (e).message, items: [] };
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
ipcMain.handle('history:copy', (_e, text) => {
|
|
248
|
+
if (typeof text === 'string') {
|
|
249
|
+
clipboard.writeText(text);
|
|
250
|
+
}
|
|
251
|
+
win?.hide();
|
|
252
|
+
return true;
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function registerTray() {
|
|
257
|
+
const icon = nativeImage.createFromDataURL(
|
|
258
|
+
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhn1IGOMJoAmBGOSMDEwMmABWDWHJjBCSpBKGBSDjBAAAeoRBIEs/x0AAAAASUVORK5CYII=',
|
|
259
|
+
);
|
|
260
|
+
tray = new Tray(icon);
|
|
261
|
+
tray.setToolTip('CopyHub overlay');
|
|
262
|
+
const accLabel = overlayHotkeyMeta.accelerator
|
|
263
|
+
? `Shortcut: ${overlayHotkeyMeta.accelerator}`
|
|
264
|
+
: 'Shortcut: (see terminal)';
|
|
265
|
+
tray.setContextMenu(
|
|
266
|
+
Menu.buildFromTemplate([
|
|
267
|
+
{ label: accLabel, enabled: false },
|
|
268
|
+
{ label: 'Open history (always on top)', click: () => toggleOverlay() },
|
|
269
|
+
{ type: 'separator' },
|
|
270
|
+
{ label: 'Quit', click: () => app.quit() },
|
|
271
|
+
]),
|
|
272
|
+
);
|
|
273
|
+
tray.on('click', () => toggleOverlay());
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (gotLock) {
|
|
277
|
+
app.on('second-instance', () => {
|
|
278
|
+
toggleOverlay();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
app.whenReady().then(() => {
|
|
282
|
+
Menu.setApplicationMenu(null);
|
|
283
|
+
createWindow();
|
|
284
|
+
registerIpc();
|
|
285
|
+
|
|
286
|
+
const { accelerator, usedFallback } = registerHotkeys();
|
|
287
|
+
overlayHotkeyMeta = {
|
|
288
|
+
accelerator,
|
|
289
|
+
usedFallback,
|
|
290
|
+
requestedRaw:
|
|
291
|
+
process.env.COPYHUB_OVERLAY_ACCELERATOR?.trim() ||
|
|
292
|
+
loadOverlayAcceleratorFromConfigSync() ||
|
|
293
|
+
'',
|
|
294
|
+
};
|
|
295
|
+
if (accelerator) {
|
|
296
|
+
console.log('CopyHub overlay — shortcut in use:', accelerator);
|
|
297
|
+
console.log('Windows tip: Ctrl+Shift+H (CommandOrControl+Shift+H).');
|
|
298
|
+
if (usedFallback) {
|
|
299
|
+
console.warn(
|
|
300
|
+
'COPYHUB_OVERLAY_ACCELERATOR could not be registered. Using default CommandOrControl+Shift+H.',
|
|
301
|
+
);
|
|
302
|
+
console.warn('Leave COPYHUB_OVERLAY_ACCELERATOR unset in .env to always use Ctrl+Shift+H.');
|
|
303
|
+
}
|
|
304
|
+
} else {
|
|
305
|
+
console.error(
|
|
306
|
+
'Could not register a global shortcut. Open history from the tray or taskbar icon.',
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
console.log(
|
|
311
|
+
STICKY_NO_BLUR
|
|
312
|
+
? 'COPYHUB_OVERLAY_STICKY=1 — window does not close on outside click (Esc / row pick only).'
|
|
313
|
+
: 'Overlay: opens near cursor; click outside the window to close. Esc closes too. COPYHUB_OVERLAY_STICKY=1 keeps it open on blur.',
|
|
314
|
+
);
|
|
315
|
+
console.log(
|
|
316
|
+
HIDE_ON_START
|
|
317
|
+
? 'COPYHUB_OVERLAY_HIDE_ON_START=1 — window opens only via shortcut / tray.'
|
|
318
|
+
: 'Window shows on startup; check taskbar or tray if you do not see it.',
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
try {
|
|
322
|
+
registerTray();
|
|
323
|
+
} catch (e) {
|
|
324
|
+
console.warn('Could not create system tray icon:', /** @type {Error} */ (e).message);
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
app.on('will-quit', () => {
|
|
329
|
+
globalShortcut.unregisterAll();
|
|
330
|
+
});
|
|
331
|
+
}
|
package/ui/preload.cjs
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const { contextBridge, ipcRenderer } = require('electron');
|
|
2
|
+
|
|
3
|
+
contextBridge.exposeInMainWorld('copyhub', {
|
|
4
|
+
getMeta: () => ipcRenderer.invoke('overlay:meta'),
|
|
5
|
+
getHistory: () => ipcRenderer.invoke('history:get'),
|
|
6
|
+
copyPick: (text) => ipcRenderer.invoke('history:copy', text),
|
|
7
|
+
onOpen: (fn) => {
|
|
8
|
+
ipcRenderer.on('overlay:open', (_e) => {
|
|
9
|
+
fn();
|
|
10
|
+
});
|
|
11
|
+
},
|
|
12
|
+
});
|