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 +55 -3
- package/bin/postinstall.js +59 -6
- package/bin/twokey.js +199 -42
- package/package.json +2 -1
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
|
|
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
|
|
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
|
package/bin/postinstall.js
CHANGED
|
@@ -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.
|
|
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 (
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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.
|
|
40
|
+
console.warn(`Runtime setup skipped: ${error.message || String(error)}`);
|
|
45
41
|
}
|
|
46
42
|
process.exit(0);
|
|
47
|
-
}
|
|
48
|
-
|
|
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.
|
|
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",
|