twokey 1.0.11 → 1.0.13

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
@@ -12,9 +12,15 @@ The core idea matches the video workflow:
12
12
  ## What Works Now
13
13
 
14
14
  - Global hold hotkey on X11 for voice capture.
15
+ - Wayland compositor detection (GNOME/KDE/Sway/Hyprland) with compositor-specific runtime capability messaging.
16
+ - Wayland automation integration paths:
17
+ - text insertion via `wtype` or `ydotool`
18
+ - selected-text read via `wtype` + `wl-paste` when available
15
19
  - Double-tap hotkey to cycle modes.
16
20
  - Single-tap hotkey to open file-context picker.
17
21
  - Voice-triggered toolchains for multi-step desktop actions.
22
+ - Visual toolchain editor in settings with dry-run and manual execution controls.
23
+ - Toolchain safety guardrails (dangerous shell pattern blocking).
18
24
  - Four modes:
19
25
  - Conversation
20
26
  - Edit Text
@@ -43,6 +49,14 @@ The core idea matches the video workflow:
43
49
  - Tray icon and settings window.
44
50
  - GitHub release check from settings.
45
51
  - In-app AppImage update download and launch.
52
+ - Optional in-app background update download to staged AppImage file.
53
+ - Local Whisper auto-setup on first use:
54
+ - managed `ffmpeg` in `~/.local/share/twokey/bin/`
55
+ - managed Whisper venv in `~/.local/share/twokey/whisper-venv/`
56
+ - Local Whisper tuning in settings:
57
+ - model selection (`tiny` .. `large-v3`)
58
+ - beam size tuning (speed vs quality)
59
+ - Visible install/save status in settings (including install spinner).
46
60
 
47
61
  ## Current Video Parity
48
62
 
@@ -58,14 +72,22 @@ Implemented from video behavior:
58
72
 
59
73
  Still not fully equivalent to the video vision:
60
74
 
61
- - Toolchains are implemented, but no visual workflow builder exists yet.
75
+ - Toolchains have a visual settings editor; richer action templates and safety controls are still pending.
62
76
  - Wayland still has compositor-specific limits for global hold hotkeys and full automation.
63
- - Update install is available for AppImage, but no signed rollback-capable updater pipeline yet.
77
+ - Update install is AppImage-first with channel-aware checks (`stable`/`beta`/`dev`), checksum validation and local rollback fallback.
78
+
79
+ Current workflow action catalog:
80
+
81
+ - `open_url`
82
+ - `open_app`
83
+ - `shell` (with safety blocking for known dangerous patterns)
84
+ - `wait_ms`
85
+ - `check_command`
64
86
 
65
87
  ## Install
66
88
 
67
89
  ```bash
68
- npm install twokey
90
+ npm install -g twokey
69
91
  ```
70
92
 
71
93
  Run:
@@ -78,6 +100,7 @@ Default behavior:
78
100
 
79
101
  - starts native desktop app in background
80
102
  - if no native binary is installed, tries to download latest AppImage release
103
+ - prepares runtime dependencies in user space (best effort, no system package mutation)
81
104
 
82
105
  Useful options:
83
106
 
@@ -86,8 +109,16 @@ twokey --help
86
109
  twokey --cli
87
110
  twokey --once "Erklaere X11 vs Wayland kurz"
88
111
  twokey --desktop
112
+ twokey --prepare-runtime
113
+ twokey --prepare-runtime-only
89
114
  ```
90
115
 
116
+ Updater behavior:
117
+
118
+ - Uses update channel from settings (`stable`, `beta`, `dev`).
119
+ - Verifies AppImage checksum when a `.sha256` asset is available.
120
+ - Restores previous local AppImage if updated binary cannot be started.
121
+
91
122
  ## Development
92
123
 
93
124
  Install dependencies:
@@ -114,6 +145,14 @@ npm run build
114
145
  cd src-tauri && cargo check
115
146
  ```
116
147
 
148
+ Runtime smoke tests:
149
+
150
+ ```bash
151
+ cargo test --manifest-path src-tauri/Cargo.toml runtime_e2e -- --nocapture
152
+ TWOKEY_E2E_SESSION=x11 cargo test --manifest-path src-tauri/Cargo.toml runtime_e2e -- --nocapture
153
+ TWOKEY_E2E_SESSION=wayland cargo test --manifest-path src-tauri/Cargo.toml runtime_e2e -- --nocapture
154
+ ```
155
+
117
156
  ## Hotkey Behavior
118
157
 
119
158
  Default hotkey can be changed in settings. Examples:
@@ -146,6 +185,8 @@ Configured in settings (`sttProvider`):
146
185
 
147
186
  `external-command` needs `TWOKEY_STT_COMMAND` with `{audio}` placeholder.
148
187
 
188
+ For `local-whisper`, TwoKey attempts runtime setup in user space and checks required binaries before transcription.
189
+
149
190
  Example:
150
191
 
151
192
  ```bash
@@ -171,6 +212,11 @@ Optional runtime tools:
171
212
  - PDF extraction: `poppler-utils` (`pdftotext`)
172
213
  - TTS backends: `spd-say` or `espeak-ng`/`espeak`
173
214
 
215
+ Notes:
216
+
217
+ - `ffmpeg` is auto-provisioned to `~/.local/share/twokey/bin/ffmpeg` if missing.
218
+ - Local Whisper CLI is auto-provisioned to `~/.local/share/twokey/whisper-venv/bin/whisper` if missing.
219
+
174
220
  ## Data Paths
175
221
 
176
222
  - Config: `~/.config/twokey-ai/`
@@ -178,11 +224,17 @@ Optional runtime tools:
178
224
  - Cache: `~/.cache/twokey-ai/`
179
225
  - History DB: `~/.local/share/twokey-ai/history.db`
180
226
  - Toolchains: `~/.config/twokey-ai/toolchains.json`
227
+ - Managed runtime bin: `~/.local/share/twokey/bin/`
228
+ - Managed Whisper venv: `~/.local/share/twokey/whisper-venv/`
181
229
 
182
230
  ## Repo
183
231
 
184
232
  https://github.com/meinzeug/twokey
185
233
 
234
+ ## Additional Docs
235
+
236
+ - Wayland matrix: `docs/WAYLAND_COMPATIBILITY_MATRIX.md`
237
+
186
238
  ## License
187
239
 
188
240
  MIT
@@ -16,10 +16,6 @@ const isRoot = typeof process.getuid === "function" && process.getuid() === 0;
16
16
  const sudoUser = process.env.SUDO_USER;
17
17
 
18
18
  if (isRoot && sudoUser && sudoUser !== "root") {
19
- if (isDesktopRunningForUser(sudoUser)) {
20
- process.exit(0);
21
- }
22
-
23
19
  const uid = resolveUid(sudoUser);
24
20
  const homeDir = resolveHomeDir(sudoUser);
25
21
  const env = { ...process.env };
@@ -32,9 +28,24 @@ if (isRoot && sudoUser && sudoUser !== "root") {
32
28
  env.DBUS_SESSION_BUS_ADDRESS = `unix:path=${env.XDG_RUNTIME_DIR}/bus`;
33
29
  }
34
30
 
31
+ const runtimePrep = spawn(
32
+ "sudo",
33
+ ["-u", sudoUser, "-H", process.execPath, cliPath, "--prepare-runtime-only", "--quiet"],
34
+ {
35
+ stdio: "ignore",
36
+ shell: false,
37
+ env,
38
+ },
39
+ );
40
+ runtimePrep.on("error", () => undefined);
41
+
42
+ if (isDesktopRunningForUser(sudoUser)) {
43
+ process.exit(0);
44
+ }
45
+
35
46
  const delegated = spawn(
36
47
  "sudo",
37
- ["-u", sudoUser, "-H", process.execPath, cliPath, "--desktop", "--enable-autostart"],
48
+ ["-u", sudoUser, "-H", process.execPath, cliPath, "--prepare-runtime", "--desktop", "--enable-autostart"],
38
49
  {
39
50
  stdio: "ignore",
40
51
  shell: false,
@@ -45,11 +56,22 @@ if (isRoot && sudoUser && sudoUser !== "root") {
45
56
  delegated.on("error", () => process.exit(0));
46
57
  delegated.on("close", () => process.exit(0));
47
58
  } else {
59
+ if (isRoot) {
60
+ ensureSystemTtsBackend();
61
+ }
62
+
63
+ const runtimePrep = spawn(process.execPath, [cliPath, "--prepare-runtime-only", "--quiet"], {
64
+ stdio: "ignore",
65
+ shell: false,
66
+ });
67
+
68
+ runtimePrep.on("error", () => undefined);
69
+
48
70
  if (isDesktopRunningForUser(process.env.USER || "")) {
49
71
  process.exit(0);
50
72
  }
51
73
 
52
- const child = spawn(process.execPath, [cliPath, "--desktop", "--enable-autostart"], {
74
+ const child = spawn(process.execPath, [cliPath, "--prepare-runtime", "--desktop", "--enable-autostart"], {
53
75
  stdio: "ignore",
54
76
  shell: false,
55
77
  });
@@ -85,3 +107,34 @@ function isDesktopRunningForUser(username) {
85
107
  const out = spawnSync("pgrep", ["-u", username, "-f", "twokey-ai(\\.AppImage)?"], { encoding: "utf8" });
86
108
  return out.status === 0;
87
109
  }
110
+
111
+ function ensureSystemTtsBackend() {
112
+ if (hasAnyTtsBackend()) {
113
+ return;
114
+ }
115
+
116
+ if (!commandExists("apt-get")) {
117
+ return;
118
+ }
119
+
120
+ try {
121
+ const result = spawnSync("apt-get", ["install", "-y", "espeak-ng"], {
122
+ stdio: "ignore",
123
+ shell: false,
124
+ });
125
+ if (result.status !== 0) {
126
+ return;
127
+ }
128
+ } catch {
129
+ // best effort
130
+ }
131
+ }
132
+
133
+ function hasAnyTtsBackend() {
134
+ return commandExists("spd-say") || commandExists("espeak-ng") || commandExists("espeak");
135
+ }
136
+
137
+ function commandExists(name) {
138
+ const out = spawnSync("sh", ["-c", `command -v ${name} >/dev/null 2>&1`], { encoding: "utf8" });
139
+ return out.status === 0;
140
+ }
package/bin/twokey.js CHANGED
@@ -6,16 +6,21 @@ import os from "node:os";
6
6
  import path from "node:path";
7
7
  import readline from "node:readline";
8
8
 
9
- const VERSION = process.env.npm_package_version || "1.0.11";
9
+ const VERSION = process.env.npm_package_version || "1.0.12";
10
10
  const DEFAULT_MODEL = process.env.TWOKEY_OLLAMA_MODEL || "qwen2.5:3b";
11
11
  const DEFAULT_OLLAMA_URL = process.env.TWOKEY_OLLAMA_URL || "http://127.0.0.1:11434";
12
12
  const LATEST_RELEASE_API = "https://api.github.com/repos/meinzeug/twokey/releases/latest";
13
13
  const APPIMAGE_DIR = path.join(os.homedir(), ".local", "share", "twokey", "bin");
14
14
  const APPIMAGE_PATH = path.join(APPIMAGE_DIR, "twokey-ai.AppImage");
15
15
  const APPIMAGE_META_PATH = path.join(APPIMAGE_DIR, "twokey-ai.meta.json");
16
+ const MANAGED_FFMPEG_PATH = path.join(APPIMAGE_DIR, "ffmpeg");
17
+ const WHISPER_VENV_DIR = path.join(os.homedir(), ".local", "share", "twokey", "whisper-venv");
18
+ const MANAGED_WHISPER_PATH = path.join(WHISPER_VENV_DIR, "bin", "whisper");
16
19
 
17
20
  const args = process.argv.slice(2);
18
21
  const QUIET = args.includes("--quiet");
22
+ const PREPARE_RUNTIME = args.includes("--prepare-runtime");
23
+ const PREPARE_RUNTIME_ONLY = args.includes("--prepare-runtime-only");
19
24
 
20
25
  if (args.includes("--help") || args.includes("-h")) {
21
26
  printHelp();
@@ -27,50 +32,16 @@ if (args.includes("--version") || args.includes("-v")) {
27
32
  process.exit(0);
28
33
  }
29
34
 
30
- if (args.includes("--desktop")) {
31
- launchDesktopApp().then(async (startedCommand) => {
32
- if (startedCommand) {
33
- if (args.includes("--enable-autostart")) {
34
- try {
35
- await ensureUserService(startedCommand);
36
- } catch (error) {
37
- if (!QUIET) {
38
- const message = error instanceof Error ? error.message : String(error);
39
- console.warn(`Autostart setup skipped: ${message}`);
40
- }
41
- }
42
- }
35
+ if (PREPARE_RUNTIME_ONLY) {
36
+ ensureRuntimeDependencies()
37
+ .then(() => process.exit(0))
38
+ .catch((error) => {
43
39
  if (!QUIET) {
44
- console.log("TwoKey desktop app started in background.");
40
+ console.warn(`Runtime setup skipped: ${error.message || String(error)}`);
45
41
  }
46
42
  process.exit(0);
47
- }
48
- if (!QUIET) {
49
- console.error("No native desktop binary found in PATH.");
50
- console.error("Install the .deb/.AppImage release and ensure 'twokey-ai' is available in PATH.");
51
- }
52
- process.exit(1);
53
- });
54
- }
55
-
56
- const onceIndex = args.findIndex((value) => value === "--once");
57
- if (onceIndex >= 0) {
58
- const prompt = args.slice(onceIndex + 1).join(" ").trim();
59
- if (!prompt) {
60
- console.error("Missing prompt after --once");
61
- process.exit(1);
62
- }
63
-
64
- runSinglePrompt(prompt).catch((error) => {
65
- console.error(error.message || String(error));
66
- process.exit(1);
67
- });
68
- } else if (args.includes("--cli")) {
69
- startRepl().catch((error) => {
70
- console.error(error.message || String(error));
71
- process.exit(1);
72
- });
73
- } else {
43
+ });
44
+ } else if (args.includes("--desktop")) {
74
45
  launchDesktopApp().then(async (startedCommand) => {
75
46
  if (startedCommand) {
76
47
  if (args.includes("--enable-autostart")) {
@@ -96,6 +67,51 @@ if (onceIndex >= 0) {
96
67
  }
97
68
  process.exit(1);
98
69
  });
70
+ } else {
71
+ const onceIndex = args.findIndex((value) => value === "--once");
72
+ if (onceIndex >= 0) {
73
+ const prompt = args.slice(onceIndex + 1).join(" ").trim();
74
+ if (!prompt) {
75
+ console.error("Missing prompt after --once");
76
+ process.exit(1);
77
+ }
78
+
79
+ runSinglePrompt(prompt).catch((error) => {
80
+ console.error(error.message || String(error));
81
+ process.exit(1);
82
+ });
83
+ } else if (args.includes("--cli")) {
84
+ startRepl().catch((error) => {
85
+ console.error(error.message || String(error));
86
+ process.exit(1);
87
+ });
88
+ } else {
89
+ launchDesktopApp().then(async (startedCommand) => {
90
+ if (startedCommand) {
91
+ if (args.includes("--enable-autostart")) {
92
+ try {
93
+ await ensureUserService(startedCommand);
94
+ } catch (error) {
95
+ if (!QUIET) {
96
+ const message = error instanceof Error ? error.message : String(error);
97
+ console.warn(`Autostart setup skipped: ${message}`);
98
+ }
99
+ }
100
+ }
101
+ if (!QUIET) {
102
+ console.log("TwoKey desktop app started in background.");
103
+ }
104
+ process.exit(0);
105
+ }
106
+
107
+ if (!QUIET) {
108
+ console.error("Could not start desktop app.");
109
+ console.error("Tried system binaries and auto-download from GitHub Releases.");
110
+ console.error("Use 'twokey --cli' to run terminal mode.");
111
+ }
112
+ process.exit(1);
113
+ });
114
+ }
99
115
  }
100
116
 
101
117
  async function runSinglePrompt(prompt) {
@@ -191,6 +207,17 @@ async function askOllama(prompt) {
191
207
  }
192
208
 
193
209
  async function launchDesktopApp() {
210
+ if (PREPARE_RUNTIME) {
211
+ try {
212
+ await ensureRuntimeDependencies();
213
+ } catch (error) {
214
+ if (!QUIET) {
215
+ const message = error instanceof Error ? error.message : String(error);
216
+ console.warn(`Runtime dependencies could not be fully prepared: ${message}`);
217
+ }
218
+ }
219
+ }
220
+
194
221
  let appImageReady = false;
195
222
  try {
196
223
  appImageReady = await ensureLocalAppImage();
@@ -330,6 +357,7 @@ function spawnDetached(command) {
330
357
  detached: true,
331
358
  stdio: "ignore",
332
359
  shell: false,
360
+ env: withManagedBinPath(process.env),
333
361
  });
334
362
 
335
363
  child.once("spawn", () => {
@@ -343,6 +371,132 @@ function spawnDetached(command) {
343
371
  });
344
372
  }
345
373
 
374
+ async function ensureRuntimeDependencies() {
375
+ await fs.promises.mkdir(APPIMAGE_DIR, { recursive: true });
376
+ await ensureManagedFfmpeg();
377
+ await ensureManagedWhisperCli();
378
+ await ensureManagedTtsBackend();
379
+ }
380
+
381
+ async function ensureManagedFfmpeg() {
382
+ if (await hasExecutable(MANAGED_FFMPEG_PATH)) {
383
+ return;
384
+ }
385
+
386
+ let sourcePath = "";
387
+ try {
388
+ const module = await import("@ffmpeg-installer/ffmpeg");
389
+ sourcePath =
390
+ module?.default?.path
391
+ || module?.path
392
+ || module?.default?.default?.path
393
+ || "";
394
+ } catch {
395
+ sourcePath = "";
396
+ }
397
+
398
+ if (!sourcePath) {
399
+ throw new Error("@ffmpeg-installer/ffmpeg konnte nicht geladen werden");
400
+ }
401
+
402
+ await fs.promises.copyFile(sourcePath, MANAGED_FFMPEG_PATH);
403
+ await fs.promises.chmod(MANAGED_FFMPEG_PATH, 0o755);
404
+ }
405
+
406
+ async function ensureManagedWhisperCli() {
407
+ if (await hasExecutable(MANAGED_WHISPER_PATH)) {
408
+ return;
409
+ }
410
+
411
+ if (!(await commandExists("python3"))) {
412
+ throw new Error("python3 fehlt, Whisper-CLI konnte nicht automatisch installiert werden");
413
+ }
414
+
415
+ await fs.promises.mkdir(path.dirname(WHISPER_VENV_DIR), { recursive: true });
416
+
417
+ const venvPython = path.join(WHISPER_VENV_DIR, "bin", "python");
418
+ if (!(await hasExecutable(venvPython))) {
419
+ await runCommand("python3", ["-m", "venv", WHISPER_VENV_DIR]);
420
+ }
421
+
422
+ await runCommand(venvPython, ["-m", "pip", "install", "--upgrade", "pip", "setuptools", "wheel"]);
423
+ await runCommand(venvPython, ["-m", "pip", "install", "--upgrade", "openai-whisper"]);
424
+
425
+ if (!(await hasExecutable(MANAGED_WHISPER_PATH))) {
426
+ throw new Error("Whisper wurde installiert, aber das CLI-Binary fehlt");
427
+ }
428
+ }
429
+
430
+ async function ensureManagedTtsBackend() {
431
+ if (await commandExists("spd-say") || await commandExists("espeak-ng") || await commandExists("espeak")) {
432
+ return;
433
+ }
434
+
435
+ if (!isRoot()) {
436
+ return;
437
+ }
438
+
439
+ if (await commandExists("apt-get")) {
440
+ try {
441
+ await runCommand("apt-get", ["install", "-y", "espeak-ng"]);
442
+ } catch {
443
+ // best effort only
444
+ }
445
+ }
446
+ }
447
+
448
+ async function commandExists(name) {
449
+ return new Promise((resolve) => {
450
+ const child = spawn("sh", ["-c", `command -v ${name} >/dev/null 2>&1`], {
451
+ stdio: "ignore",
452
+ shell: false,
453
+ });
454
+ child.on("error", () => resolve(false));
455
+ child.on("close", (code) => resolve(code === 0));
456
+ });
457
+ }
458
+
459
+ function isRoot() {
460
+ return typeof process.getuid === "function" && process.getuid() === 0;
461
+ }
462
+
463
+ async function runCommand(program, commandArgs) {
464
+ return new Promise((resolve, reject) => {
465
+ const child = spawn(program, commandArgs, {
466
+ shell: false,
467
+ stdio: QUIET ? "ignore" : "pipe",
468
+ env: withManagedBinPath(process.env),
469
+ });
470
+
471
+ let stderr = "";
472
+ if (child.stderr) {
473
+ child.stderr.on("data", (chunk) => {
474
+ stderr += String(chunk);
475
+ });
476
+ }
477
+
478
+ child.on("error", (error) => reject(error));
479
+ child.on("close", (code) => {
480
+ if (code === 0) {
481
+ resolve();
482
+ return;
483
+ }
484
+ reject(new Error(stderr.trim() || `${program} failed with code ${code}`));
485
+ });
486
+ });
487
+ }
488
+
489
+ function withManagedBinPath(baseEnv) {
490
+ const env = { ...baseEnv };
491
+ const currentPath = String(env.PATH || "");
492
+ const parts = currentPath.split(":").filter(Boolean);
493
+ if (!parts.includes(APPIMAGE_DIR)) {
494
+ parts.unshift(APPIMAGE_DIR);
495
+ }
496
+ env.PATH = parts.join(":");
497
+ return env;
498
+ }
499
+
346
500
  function printHelp() {
347
501
  console.log("twokey <command/options>");
348
502
  console.log("");
@@ -352,6 +506,8 @@ function printHelp() {
352
506
  console.log(" --cli Start interactive terminal mode");
353
507
  console.log(" --once <prompt> Send one prompt to Ollama and print response");
354
508
  console.log(" --desktop Start native desktop app in background");
509
+ console.log(" --prepare-runtime Install/check local runtime dependencies");
510
+ console.log(" --prepare-runtime-only Only install/check dependencies and exit");
355
511
  console.log("");
356
512
  console.log("Without options, twokey starts the native desktop app in background.");
357
513
  console.log("If no desktop binary is installed, twokey tries to download an AppImage from latest GitHub release.");
@@ -369,6 +525,7 @@ async function ensureUserService(command) {
369
525
  "After=graphical-session.target",
370
526
  "",
371
527
  "[Service]",
528
+ `Environment=PATH=${path.join(os.homedir(), ".local", "share", "twokey", "bin")}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`,
372
529
  `ExecStart=/bin/sh -lc ${shellEscape(command)}`,
373
530
  "Restart=on-failure",
374
531
  "RestartSec=3",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "twokey",
3
- "version": "1.0.11",
3
+ "version": "1.0.13",
4
4
  "description": "Linux-first desktop AI assistant built with Tauri, React, and TypeScript.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -48,6 +48,7 @@
48
48
  "tauri:build": "GTK_MODULES='' LIBGL_ALWAYS_SOFTWARE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 tauri build"
49
49
  },
50
50
  "dependencies": {
51
+ "@ffmpeg-installer/ffmpeg": "^1.1.0",
51
52
  "@tauri-apps/api": "^2.5.0",
52
53
  "lucide-react": "^0.468.0",
53
54
  "react": "^18.3.1",