twokey 1.0.3 → 1.0.5
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 +124 -146
- package/bin/postinstall.js +21 -0
- package/bin/twokey.js +181 -36
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -1,210 +1,188 @@
|
|
|
1
1
|
# TwoKey Linux AI Assistant
|
|
2
2
|
|
|
3
|
-
TwoKey is a Linux-first desktop assistant
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
3
|
+
TwoKey is a Linux-first desktop AI assistant with a small floating pill overlay.
|
|
4
|
+
The core idea matches the video workflow:
|
|
5
|
+
|
|
6
|
+
- hold a global hotkey, speak, release
|
|
7
|
+
- audio is recorded and transcribed
|
|
8
|
+
- transcript is sent to the selected AI provider
|
|
9
|
+
- result is used directly in your current desktop workflow
|
|
10
|
+
- optional TTS reads the answer out loud
|
|
11
|
+
|
|
12
|
+
## What Works Now
|
|
13
|
+
|
|
14
|
+
- Global hold hotkey on X11 for voice capture.
|
|
15
|
+
- Double-tap hotkey to cycle modes.
|
|
16
|
+
- Single-tap hotkey to open file-context picker.
|
|
17
|
+
- Voice-triggered toolchains for multi-step desktop actions.
|
|
18
|
+
- Four modes:
|
|
19
|
+
- Conversation
|
|
20
|
+
- Edit Text
|
|
21
|
+
- Dictation
|
|
22
|
+
- Feedback
|
|
23
|
+
- Conversation mode:
|
|
24
|
+
- transcript -> provider answer
|
|
25
|
+
- optional TTS playback
|
|
26
|
+
- Edit mode:
|
|
27
|
+
- read selected text
|
|
28
|
+
- apply spoken transform via AI
|
|
29
|
+
- replace selected text directly
|
|
30
|
+
- Dictation mode:
|
|
31
|
+
- insert transcript at cursor
|
|
32
|
+
- Feedback mode:
|
|
33
|
+
- local feedback persistence in history DB
|
|
34
|
+
- File context:
|
|
35
|
+
- TXT/MD/JSON/CSV/PDF content context
|
|
36
|
+
- image context routing to vision-capable providers
|
|
37
|
+
- Provider routing:
|
|
38
|
+
- local Ollama
|
|
39
|
+
- OpenAI-compatible
|
|
40
|
+
- OpenRouter-compatible
|
|
41
|
+
- secure API key storage via local keyring
|
|
42
|
+
- Local audit/history persistence in SQLite.
|
|
43
|
+
- Tray icon and settings window.
|
|
44
|
+
- GitHub release check from settings.
|
|
45
|
+
- In-app AppImage update download and launch.
|
|
46
|
+
|
|
47
|
+
## Current Video Parity
|
|
48
|
+
|
|
49
|
+
Implemented from video behavior:
|
|
50
|
+
|
|
51
|
+
- Minimal floating pill UI.
|
|
52
|
+
- Hold-to-talk workflow.
|
|
53
|
+
- Mode switch via double tap.
|
|
54
|
+
- Text workflow without browser/chat-tab context switch.
|
|
55
|
+
- File attach + ask flow.
|
|
56
|
+
- Hybrid local/online providers.
|
|
57
|
+
- Optional TTS answer playback.
|
|
58
|
+
|
|
59
|
+
Still not fully equivalent to the video vision:
|
|
60
|
+
|
|
61
|
+
- Toolchains are implemented, but no visual workflow builder exists yet.
|
|
62
|
+
- 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.
|
|
64
|
+
|
|
65
|
+
## Install
|
|
20
66
|
|
|
21
67
|
```bash
|
|
22
68
|
npm install twokey
|
|
23
69
|
```
|
|
24
70
|
|
|
25
|
-
|
|
71
|
+
Run:
|
|
26
72
|
|
|
27
73
|
```bash
|
|
28
74
|
twokey
|
|
29
75
|
```
|
|
30
76
|
|
|
31
|
-
Default behavior:
|
|
32
|
-
If no desktop binary is installed yet, `twokey` attempts to download an AppImage from the latest GitHub release into `~/.local/share/twokey/bin/` and starts it.
|
|
77
|
+
Default behavior:
|
|
33
78
|
|
|
34
|
-
|
|
79
|
+
- starts native desktop app in background
|
|
80
|
+
- if no native binary is installed, tries to download latest AppImage release
|
|
81
|
+
|
|
82
|
+
Useful options:
|
|
35
83
|
|
|
36
84
|
```bash
|
|
37
85
|
twokey --help
|
|
38
86
|
twokey --cli
|
|
39
|
-
twokey --once "Erklaere
|
|
87
|
+
twokey --once "Erklaere X11 vs Wayland kurz"
|
|
40
88
|
twokey --desktop
|
|
41
89
|
```
|
|
42
90
|
|
|
43
|
-
##
|
|
44
|
-
|
|
45
|
-
```ts
|
|
46
|
-
import { getPackageInfo } from "twokey";
|
|
47
|
-
|
|
48
|
-
const info = getPackageInfo();
|
|
49
|
-
console.log(info.name);
|
|
50
|
-
console.log(info.runtimeStatus.waylandGlobalHotkeys);
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
The package exports runtime status metadata. Current status includes planned/limited areas such as Wayland global hotkeys, TTS, tray menu, SQLite history/audit, and online provider execution until secure API-key storage is implemented.
|
|
91
|
+
## Development
|
|
54
92
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
- Linux desktop
|
|
58
|
-
- Node.js 20+
|
|
59
|
-
- npm
|
|
60
|
-
- Rust toolchain with `cargo` for running the Tauri app
|
|
61
|
-
- Tauri Linux system dependencies, for example on Ubuntu/Debian:
|
|
62
|
-
|
|
63
|
-
```bash
|
|
64
|
-
sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
This workspace currently has Node/npm available. `cargo` is required before the native app can be started.
|
|
68
|
-
|
|
69
|
-
## Start Development
|
|
70
|
-
|
|
71
|
-
Install JavaScript dependencies:
|
|
93
|
+
Install dependencies:
|
|
72
94
|
|
|
73
95
|
```bash
|
|
74
96
|
npm install
|
|
75
97
|
```
|
|
76
98
|
|
|
77
|
-
Run
|
|
78
|
-
|
|
79
|
-
```bash
|
|
80
|
-
npm run dev
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
Run the Linux desktop app:
|
|
99
|
+
Run desktop dev stack:
|
|
84
100
|
|
|
85
101
|
```bash
|
|
86
102
|
npm run tauri:dev
|
|
87
103
|
```
|
|
88
104
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
- Hold `Ctrl+Space`: start recording after a short hold delay.
|
|
92
|
-
- Release `Ctrl+Space`: stop recording and save a WAV file under `~/.cache/twokey-ai/recordings/`.
|
|
93
|
-
- Double tap `Ctrl+Space`: cycle to the next mode.
|
|
94
|
-
- Press `Escape`: cancel an active recording.
|
|
95
|
-
|
|
96
|
-
On Wayland, generic global hold-hotkeys are reported as unavailable instead of failing silently.
|
|
97
|
-
|
|
98
|
-
Phase 3 STT behavior:
|
|
99
|
-
|
|
100
|
-
- Default STT provider is a deterministic mock transcriber.
|
|
101
|
-
- To test a real local or custom STT command, set `TWOKEY_STT_COMMAND`.
|
|
102
|
-
- The command must print the transcript to stdout and include `{audio}` as placeholder.
|
|
105
|
+
Important:
|
|
103
106
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
```bash
|
|
107
|
-
TWOKEY_STT_COMMAND='whisper-cli -f {audio} --language de --no-timestamps' npm run tauri:dev
|
|
108
|
-
```
|
|
107
|
+
- `target/debug/twokey-ai` alone in dev mode will fail if Vite is not running.
|
|
108
|
+
- For development, use `npm run tauri:dev` so frontend + Tauri run together.
|
|
109
109
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
- Ollama runs as a systemd service on `127.0.0.1:11434`.
|
|
113
|
-
- Default model: `qwen2.5:3b`.
|
|
114
|
-
- Conversation mode sends finished transcripts to Ollama and displays the answer in the overlay.
|
|
115
|
-
- Override model or endpoint with:
|
|
110
|
+
Build:
|
|
116
111
|
|
|
117
112
|
```bash
|
|
118
|
-
|
|
113
|
+
npm run build
|
|
114
|
+
cd src-tauri && cargo check
|
|
119
115
|
```
|
|
120
116
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
- In dictation mode, a finished transcript is pasted at the active cursor position.
|
|
124
|
-
- X11 uses `xclip` or `xsel` plus `xdotool`.
|
|
125
|
-
- Clipboard content is read before insertion and restored afterward when possible.
|
|
126
|
-
- Wayland insertion is deliberately reported as unsupported for now.
|
|
117
|
+
## Hotkey Behavior
|
|
127
118
|
|
|
128
|
-
|
|
119
|
+
Default hotkey can be changed in settings. Examples:
|
|
129
120
|
|
|
130
|
-
-
|
|
131
|
-
-
|
|
132
|
-
-
|
|
133
|
-
- The selected text is replaced only after pressing `Ersetzen`.
|
|
121
|
+
- `Ctrl+Space`
|
|
122
|
+
- `Ctrl+Super`
|
|
123
|
+
- mouse-button combinations except left/right mouse button in recorder UI
|
|
134
124
|
|
|
135
|
-
|
|
125
|
+
Runtime semantics on X11:
|
|
136
126
|
|
|
137
|
-
-
|
|
138
|
-
-
|
|
139
|
-
-
|
|
127
|
+
- hold hotkey: start recording
|
|
128
|
+
- release hotkey: stop recording -> transcribe -> mode action
|
|
129
|
+
- short single tap: open file picker
|
|
130
|
+
- short double tap: switch mode
|
|
131
|
+
- `Escape` while recording: cancel
|
|
140
132
|
|
|
141
|
-
|
|
133
|
+
Wayland fallback:
|
|
142
134
|
|
|
143
|
-
-
|
|
144
|
-
-
|
|
145
|
-
- OpenAI-compatible and OpenRouter-compatible providers are visible as planned online providers, but disabled until API-key storage and routing are implemented.
|
|
135
|
+
- if global hotkeys are blocked, manual start/stop recording is available from the menu
|
|
136
|
+
- this keeps the voice pipeline usable on restricted desktops
|
|
146
137
|
|
|
147
|
-
|
|
138
|
+
## STT Providers
|
|
148
139
|
|
|
149
|
-
|
|
150
|
-
- `txt`, `md`, `markdown`, `json`, and `csv` files are read directly.
|
|
151
|
-
- PDFs are extracted with `pdftotext` from `poppler-utils`.
|
|
152
|
-
- Images are registered as context metadata for future vision providers.
|
|
153
|
-
- Extracted text is cached under `~/.cache/twokey-ai/file-contexts/`.
|
|
140
|
+
Configured in settings (`sttProvider`):
|
|
154
141
|
|
|
155
|
-
|
|
142
|
+
- `mock`
|
|
143
|
+
- `local-whisper`
|
|
144
|
+
- `external-command`
|
|
145
|
+
- `openai-compatible`
|
|
156
146
|
|
|
157
|
-
-
|
|
158
|
-
- Settings autostart writes `~/.config/autostart/twokey-ai.desktop`.
|
|
159
|
-
- Generated bundles live under `src-tauri/target/release/bundle/`.
|
|
147
|
+
`external-command` needs `TWOKEY_STT_COMMAND` with `{audio}` placeholder.
|
|
160
148
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
- Settings can check GitHub Releases for a newer version.
|
|
164
|
-
- The app only reports availability; it does not auto-download or auto-install.
|
|
165
|
-
|
|
166
|
-
Current stabilization status:
|
|
167
|
-
|
|
168
|
-
- AppImage and `.deb` builds complete successfully.
|
|
169
|
-
- Ollama runs locally as a systemd service with `qwen2.5:3b`.
|
|
170
|
-
- Production dependency audit reports no vulnerabilities.
|
|
171
|
-
- Wayland limitations, TTS, tray menu, SQLite history, and online provider execution remain future hardening work.
|
|
172
|
-
|
|
173
|
-
Build the frontend:
|
|
149
|
+
Example:
|
|
174
150
|
|
|
175
151
|
```bash
|
|
176
|
-
npm run
|
|
152
|
+
TWOKEY_STT_COMMAND='whisper-cli -f {audio} -l de -nt' npm run tauri:dev
|
|
177
153
|
```
|
|
178
154
|
|
|
179
|
-
|
|
155
|
+
## Linux Requirements
|
|
180
156
|
|
|
181
|
-
|
|
182
|
-
npm
|
|
183
|
-
|
|
157
|
+
- Node.js 20+
|
|
158
|
+
- npm
|
|
159
|
+
- Rust + cargo
|
|
160
|
+
- Linux desktop dependencies for Tauri
|
|
184
161
|
|
|
185
|
-
|
|
162
|
+
Ubuntu/Debian example:
|
|
186
163
|
|
|
187
|
-
```
|
|
188
|
-
|
|
189
|
-
src/ React frontend
|
|
190
|
-
src-tauri/ Rust/Tauri desktop shell
|
|
164
|
+
```bash
|
|
165
|
+
sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev
|
|
191
166
|
```
|
|
192
167
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
GitHub: https://github.com/meinzeug/twokey
|
|
168
|
+
Optional runtime tools:
|
|
196
169
|
|
|
197
|
-
|
|
170
|
+
- X11 automation: `xdotool`, `xclip` or `xsel`
|
|
171
|
+
- PDF extraction: `poppler-utils` (`pdftotext`)
|
|
172
|
+
- TTS backends: `spd-say` or `espeak-ng`/`espeak`
|
|
198
173
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
## Linux Notes
|
|
202
|
-
|
|
203
|
-
TwoKey is designed around Linux conventions:
|
|
174
|
+
## Data Paths
|
|
204
175
|
|
|
205
176
|
- Config: `~/.config/twokey-ai/`
|
|
206
177
|
- Data: `~/.local/share/twokey-ai/`
|
|
207
178
|
- Cache: `~/.cache/twokey-ai/`
|
|
208
|
-
-
|
|
179
|
+
- History DB: `~/.local/share/twokey-ai/history.db`
|
|
180
|
+
- Toolchains: `~/.config/twokey-ai/toolchains.json`
|
|
181
|
+
|
|
182
|
+
## Repo
|
|
183
|
+
|
|
184
|
+
https://github.com/meinzeug/twokey
|
|
185
|
+
|
|
186
|
+
## License
|
|
209
187
|
|
|
210
|
-
|
|
188
|
+
MIT
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
if (process.env.CI === "true" || process.env.TWOKEY_SKIP_POSTINSTALL === "1") {
|
|
8
|
+
process.exit(0);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
12
|
+
const __dirname = path.dirname(__filename);
|
|
13
|
+
const cliPath = path.join(__dirname, "twokey.js");
|
|
14
|
+
|
|
15
|
+
const child = spawn(process.execPath, [cliPath, "--desktop", "--enable-autostart", "--quiet"], {
|
|
16
|
+
stdio: "ignore",
|
|
17
|
+
shell: false,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
child.on("error", () => process.exit(0));
|
|
21
|
+
child.on("close", () => process.exit(0));
|
package/bin/twokey.js
CHANGED
|
@@ -6,14 +6,16 @@ import os from "node:os";
|
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import readline from "node:readline";
|
|
8
8
|
|
|
9
|
-
const VERSION = "1.0.
|
|
9
|
+
const VERSION = process.env.npm_package_version || "1.0.5";
|
|
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
|
+
const APPIMAGE_META_PATH = path.join(APPIMAGE_DIR, "twokey-ai.meta.json");
|
|
15
16
|
|
|
16
17
|
const args = process.argv.slice(2);
|
|
18
|
+
const QUIET = args.includes("--quiet");
|
|
17
19
|
|
|
18
20
|
if (args.includes("--help") || args.includes("-h")) {
|
|
19
21
|
printHelp();
|
|
@@ -26,13 +28,27 @@ if (args.includes("--version") || args.includes("-v")) {
|
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
if (args.includes("--desktop")) {
|
|
29
|
-
launchDesktopApp().then((
|
|
30
|
-
if (
|
|
31
|
-
|
|
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
|
+
}
|
|
43
|
+
if (!QUIET) {
|
|
44
|
+
console.log("TwoKey desktop app started in background.");
|
|
45
|
+
}
|
|
32
46
|
process.exit(0);
|
|
33
47
|
}
|
|
34
|
-
|
|
35
|
-
|
|
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
|
+
}
|
|
36
52
|
process.exit(1);
|
|
37
53
|
});
|
|
38
54
|
}
|
|
@@ -55,15 +71,29 @@ if (onceIndex >= 0) {
|
|
|
55
71
|
process.exit(1);
|
|
56
72
|
});
|
|
57
73
|
} else {
|
|
58
|
-
launchDesktopApp().then((
|
|
59
|
-
if (
|
|
60
|
-
|
|
74
|
+
launchDesktopApp().then(async (startedCommand) => {
|
|
75
|
+
if (startedCommand) {
|
|
76
|
+
if (args.includes("--enable-autostart")) {
|
|
77
|
+
try {
|
|
78
|
+
await ensureUserService(startedCommand);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (!QUIET) {
|
|
81
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
82
|
+
console.warn(`Autostart setup skipped: ${message}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (!QUIET) {
|
|
87
|
+
console.log("TwoKey desktop app started in background.");
|
|
88
|
+
}
|
|
61
89
|
process.exit(0);
|
|
62
90
|
}
|
|
63
91
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
92
|
+
if (!QUIET) {
|
|
93
|
+
console.error("Could not start desktop app.");
|
|
94
|
+
console.error("Tried system binaries and auto-download from GitHub Releases.");
|
|
95
|
+
console.error("Use 'twokey --cli' to run terminal mode.");
|
|
96
|
+
}
|
|
67
97
|
process.exit(1);
|
|
68
98
|
});
|
|
69
99
|
}
|
|
@@ -161,46 +191,45 @@ async function askOllama(prompt) {
|
|
|
161
191
|
}
|
|
162
192
|
|
|
163
193
|
async function launchDesktopApp() {
|
|
194
|
+
let appImageReady = false;
|
|
195
|
+
try {
|
|
196
|
+
appImageReady = await ensureLocalAppImage();
|
|
197
|
+
} catch {
|
|
198
|
+
appImageReady = false;
|
|
199
|
+
}
|
|
200
|
+
|
|
164
201
|
const candidates = [];
|
|
165
202
|
if (process.env.TWOKEY_DESKTOP_CMD) {
|
|
166
203
|
candidates.push(process.env.TWOKEY_DESKTOP_CMD);
|
|
167
204
|
}
|
|
168
|
-
|
|
205
|
+
if (appImageReady) {
|
|
206
|
+
candidates.push(APPIMAGE_PATH);
|
|
207
|
+
}
|
|
208
|
+
candidates.push("twokey-ai", "twokey-desktop");
|
|
169
209
|
|
|
170
210
|
for (const command of candidates) {
|
|
171
211
|
const started = await spawnDetached(command);
|
|
172
212
|
if (started) {
|
|
173
|
-
return
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
try {
|
|
178
|
-
const downloaded = await ensureLocalAppImage();
|
|
179
|
-
if (downloaded) {
|
|
180
|
-
return spawnDetached(APPIMAGE_PATH);
|
|
213
|
+
return command;
|
|
181
214
|
}
|
|
182
|
-
} catch {
|
|
183
|
-
return false;
|
|
184
215
|
}
|
|
185
216
|
|
|
186
|
-
return
|
|
217
|
+
return null;
|
|
187
218
|
}
|
|
188
219
|
|
|
189
220
|
async function ensureLocalAppImage() {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
221
|
+
await fs.promises.mkdir(APPIMAGE_DIR, { recursive: true });
|
|
222
|
+
|
|
223
|
+
const latestAsset = await resolveLatestAppImageAsset();
|
|
224
|
+
if (!latestAsset) {
|
|
225
|
+
return hasExecutable(APPIMAGE_PATH);
|
|
195
226
|
}
|
|
196
227
|
|
|
197
|
-
await
|
|
198
|
-
|
|
199
|
-
if (!assetUrl) {
|
|
200
|
-
return false;
|
|
228
|
+
if (await isCurrentAppImage(latestAsset)) {
|
|
229
|
+
return true;
|
|
201
230
|
}
|
|
202
231
|
|
|
203
|
-
const response = await fetch(
|
|
232
|
+
const response = await fetch(latestAsset.url, {
|
|
204
233
|
headers: {
|
|
205
234
|
"User-Agent": "twokey-cli",
|
|
206
235
|
Accept: "application/octet-stream",
|
|
@@ -215,10 +244,19 @@ async function ensureLocalAppImage() {
|
|
|
215
244
|
await fs.promises.writeFile(APPIMAGE_PATH, data, { mode: 0o755 });
|
|
216
245
|
|
|
217
246
|
await fs.promises.chmod(APPIMAGE_PATH, 0o755);
|
|
247
|
+
const meta = {
|
|
248
|
+
releaseTag: latestAsset.releaseTag,
|
|
249
|
+
assetName: latestAsset.name,
|
|
250
|
+
assetId: latestAsset.id,
|
|
251
|
+
assetSize: latestAsset.size,
|
|
252
|
+
assetUpdatedAt: latestAsset.updatedAt,
|
|
253
|
+
downloadedAt: new Date().toISOString(),
|
|
254
|
+
};
|
|
255
|
+
await fs.promises.writeFile(APPIMAGE_META_PATH, `${JSON.stringify(meta, null, 2)}\n`, "utf8");
|
|
218
256
|
return true;
|
|
219
257
|
}
|
|
220
258
|
|
|
221
|
-
async function
|
|
259
|
+
async function resolveLatestAppImageAsset() {
|
|
222
260
|
const response = await fetch(LATEST_RELEASE_API, {
|
|
223
261
|
headers: {
|
|
224
262
|
"User-Agent": "twokey-cli",
|
|
@@ -236,7 +274,54 @@ async function resolveLatestAppImageUrl() {
|
|
|
236
274
|
(asset) => typeof asset?.name === "string" && asset.name.endsWith(".AppImage") && asset.name.includes("amd64"),
|
|
237
275
|
) || assets.find((asset) => typeof asset?.name === "string" && asset.name.endsWith(".AppImage"));
|
|
238
276
|
|
|
239
|
-
|
|
277
|
+
if (!appImage?.browser_download_url) {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
releaseTag: typeof payload?.tag_name === "string" ? payload.tag_name : "unknown",
|
|
283
|
+
id: Number.isFinite(appImage.id) ? appImage.id : 0,
|
|
284
|
+
name: appImage.name,
|
|
285
|
+
size: Number.isFinite(appImage.size) ? appImage.size : 0,
|
|
286
|
+
updatedAt: typeof appImage.updated_at === "string" ? appImage.updated_at : "",
|
|
287
|
+
url: appImage.browser_download_url,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function isCurrentAppImage(latestAsset) {
|
|
292
|
+
if (!(await hasExecutable(APPIMAGE_PATH))) {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const [meta, stats] = await Promise.all([readAppImageMeta(), fs.promises.stat(APPIMAGE_PATH)]);
|
|
297
|
+
if (!meta) {
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return (
|
|
302
|
+
meta.releaseTag === latestAsset.releaseTag
|
|
303
|
+
&& meta.assetId === latestAsset.id
|
|
304
|
+
&& meta.assetUpdatedAt === latestAsset.updatedAt
|
|
305
|
+
&& Number(meta.assetSize) === Number(stats.size)
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function readAppImageMeta() {
|
|
310
|
+
try {
|
|
311
|
+
const content = await fs.promises.readFile(APPIMAGE_META_PATH, "utf8");
|
|
312
|
+
return JSON.parse(content);
|
|
313
|
+
} catch {
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function hasExecutable(filePath) {
|
|
319
|
+
try {
|
|
320
|
+
await fs.promises.access(filePath, fs.constants.X_OK);
|
|
321
|
+
return true;
|
|
322
|
+
} catch {
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
240
325
|
}
|
|
241
326
|
|
|
242
327
|
function spawnDetached(command) {
|
|
@@ -271,3 +356,63 @@ function printHelp() {
|
|
|
271
356
|
console.log("Without options, twokey starts the native desktop app in background.");
|
|
272
357
|
console.log("If no desktop binary is installed, twokey tries to download an AppImage from latest GitHub release.");
|
|
273
358
|
}
|
|
359
|
+
|
|
360
|
+
async function ensureUserService(command) {
|
|
361
|
+
const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
|
362
|
+
const systemdDir = path.join(configHome, "systemd", "user");
|
|
363
|
+
const servicePath = path.join(systemdDir, "twokey.service");
|
|
364
|
+
await fs.promises.mkdir(systemdDir, { recursive: true });
|
|
365
|
+
|
|
366
|
+
const content = [
|
|
367
|
+
"[Unit]",
|
|
368
|
+
"Description=TwoKey Desktop Assistant",
|
|
369
|
+
"After=graphical-session.target",
|
|
370
|
+
"",
|
|
371
|
+
"[Service]",
|
|
372
|
+
`ExecStart=/bin/sh -lc ${shellEscape(command)}`,
|
|
373
|
+
"Restart=on-failure",
|
|
374
|
+
"RestartSec=3",
|
|
375
|
+
"",
|
|
376
|
+
"[Install]",
|
|
377
|
+
"WantedBy=default.target",
|
|
378
|
+
"",
|
|
379
|
+
].join("\n");
|
|
380
|
+
|
|
381
|
+
await fs.promises.writeFile(servicePath, content, "utf8");
|
|
382
|
+
|
|
383
|
+
await runSystemctlUser(["daemon-reload"]);
|
|
384
|
+
await runSystemctlUser(["enable", "--now", "twokey.service"]);
|
|
385
|
+
|
|
386
|
+
if (!QUIET) {
|
|
387
|
+
console.log("TwoKey systemd user service enabled: twokey.service");
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async function runSystemctlUser(argsList) {
|
|
392
|
+
return new Promise((resolve, reject) => {
|
|
393
|
+
const child = spawn("systemctl", ["--user", ...argsList], {
|
|
394
|
+
stdio: QUIET ? "ignore" : "pipe",
|
|
395
|
+
shell: false,
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
let stderr = "";
|
|
399
|
+
if (child.stderr) {
|
|
400
|
+
child.stderr.on("data", (chunk) => {
|
|
401
|
+
stderr += String(chunk);
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
child.on("error", (error) => reject(error));
|
|
406
|
+
child.on("close", (code) => {
|
|
407
|
+
if (code === 0) {
|
|
408
|
+
resolve();
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
reject(new Error(stderr.trim() || `systemctl --user failed with code ${code}`));
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function shellEscape(value) {
|
|
417
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
418
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "twokey",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.5",
|
|
4
4
|
"description": "Linux-first desktop AI assistant built with Tauri, React, and TypeScript.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -42,9 +42,10 @@
|
|
|
42
42
|
"preview": "vite preview --host 127.0.0.1",
|
|
43
43
|
"lint": "npm run typecheck",
|
|
44
44
|
"test": "npm run typecheck",
|
|
45
|
+
"postinstall": "node ./bin/postinstall.js",
|
|
45
46
|
"prepublishOnly": "npm run build && npm pack --dry-run",
|
|
46
|
-
"tauri:dev": "tauri dev",
|
|
47
|
-
"tauri:build": "tauri build"
|
|
47
|
+
"tauri:dev": "GTK_MODULES='' LIBGL_ALWAYS_SOFTWARE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 tauri dev",
|
|
48
|
+
"tauri:build": "GTK_MODULES='' LIBGL_ALWAYS_SOFTWARE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 tauri build"
|
|
48
49
|
},
|
|
49
50
|
"dependencies": {
|
|
50
51
|
"@tauri-apps/api": "^2.5.0",
|