buninu 0.2.3 → 0.2.4
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/CHANGELOG.md +104 -0
- package/README.md +50 -7
- package/apps/cmdlist +21 -0
- package/apps/jsmdcui/CHANGELOG.md +14 -0
- package/apps/jsmdcui/package.json +1 -1
- package/apps/jsmdcui/src/cui/rpc.mjs +32 -3
- package/apps/native-bridge/native-bridge.js +154 -0
- package/apps/native-bridge/package.json +8 -0
- package/apps/tts/tts.js +214 -0
- package/apps/xclip/xclip.js +121 -0
- package/apps/xdg-open/xdg-open.js +79 -0
- package/bin/native-bridge.bat +3 -0
- package/bin/rz.bat +3 -0
- package/bin/rz.sh +5 -0
- package/bin/showimg.bat +3 -0
- package/bin/showimg.sh +5 -0
- package/bin/sz.bat +3 -0
- package/bin/sz.sh +5 -0
- package/bin/tts.bat +3 -0
- package/bin/xclip.bat +3 -0
- package/bin/xdg-open.bat +3 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,109 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.4 - 2026-08-19
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Add the `xdg-open` command (`apps/xdg-open`), which opens a file or URL
|
|
8
|
+
with whatever the platform considers its default handler: native-bridge's
|
|
9
|
+
`xdgOpen` on Android (the host resolves a path under Buninu's home and
|
|
10
|
+
hands it to another app through a read-only `content://` provider, since
|
|
11
|
+
that directory is otherwise private to the APK), `termux-open` when
|
|
12
|
+
running under plain Termux with no native bridge, `open` on macOS, `start`
|
|
13
|
+
on Windows, and the real system `xdg-open` on Linux. That last one skips
|
|
14
|
+
Buninu's own `bin` while searching `PATH`, since this script is itself
|
|
15
|
+
registered as `xdg-open` and would otherwise find and run itself.
|
|
16
|
+
- Add `showimg`, a `bin/`-only shorthand for `jsgotty --viu` (no `apps/`
|
|
17
|
+
subfolder, since it has no logic of its own beyond forwarding args).
|
|
18
|
+
- Add `rz`/`sz`, `bin/`-only companion scripts wrapping the existing
|
|
19
|
+
`apps/jsgotty/rz.js`/`sz.js` -- `rz` uploads a file over ZMODEM, `sz`
|
|
20
|
+
downloads one -- over the same terminal connection jsgotty renders in a
|
|
21
|
+
browser or WebView.
|
|
22
|
+
|
|
23
|
+
- Add `apps/native-bridge`, a Bun module (`toast`, `clipboardRead`/`getcb`,
|
|
24
|
+
`clipboardWrite`/`setcb`, and the raw `call(func, args, envp)` it and the
|
|
25
|
+
CLI both sit on top of) that reaches minapk's Android native bridge over
|
|
26
|
+
`PKG_BRIDGE_SOCK` -- a Bun unix socket, filesystem-path or Linux
|
|
27
|
+
abstract-namespace depending on how that env var is encoded -- using
|
|
28
|
+
jsmdcui's `rpc.mjs` `switchBackend`. Only meaningful inside an APK built by
|
|
29
|
+
minapk with the native bridge wired in; elsewhere `available()` returns
|
|
30
|
+
`false` and calls throw a clear error instead of doing nothing silently.
|
|
31
|
+
Every call times out after 5s by default, so a stuck or unresponsive host
|
|
32
|
+
can never hang the caller. `bun apps/native-bridge/native-bridge.js [func]
|
|
33
|
+
[args...]` runs it directly; a bare invocation defaults `func` to
|
|
34
|
+
`_discover` and lists what the host implements.
|
|
35
|
+
- Add the `xclip` command (`apps/xclip`), ported from the DroidScript-era
|
|
36
|
+
`../tmpk/bin/xclip.sh` to use native-bridge instead of `dsapi.pipe`.
|
|
37
|
+
`-selection primary` (the default) stays local-file-only, matching real X11
|
|
38
|
+
semantics where the primary selection is never the same thing as the
|
|
39
|
+
clipboard; `-selection clipboard`/`-clip` bridges to the real Android
|
|
40
|
+
clipboard through native-bridge. jsmdcui's own clipboard backend detection
|
|
41
|
+
already shells out to `xclip` on Linux-like platforms (`isLinuxLike()`
|
|
42
|
+
counts `process.platform === "android"`), so jsmdcui's middle-click paste,
|
|
43
|
+
selection auto-sync, and `PastePrimary` command all work inside an APK with
|
|
44
|
+
no further wiring once `xclip` is on `PATH`.
|
|
45
|
+
- Add `speak`/`ttsStatus`/`tts` to native-bridge and the `tts` command
|
|
46
|
+
(`apps/tts`) built on them. Android's TextToSpeech completion signal is an
|
|
47
|
+
asynchronous callback with no way to push it to the far side over this
|
|
48
|
+
protocol, so `speak(text, speed, pitch, flush)` never blocks -- it returns
|
|
49
|
+
a handle immediately, and `ttsStatus(handle)`/`tts(handle)` (`"speaking"`,
|
|
50
|
+
`"done"`, `"error"`, or `"unknown"` once a terminal state has already been
|
|
51
|
+
consumed) is polled to find out when it finishes. `pitch`/`speed` both
|
|
52
|
+
default to `1.0` (normal), the same convention as `termux-tts-speak`'s
|
|
53
|
+
`-p`/`-r`; native-bridge passes them straight to `TextToSpeech.setPitch()`/
|
|
54
|
+
`setSpeechRate()` with no unit conversion. `tts <text>` wraps the polling
|
|
55
|
+
loop for the common case, blocking the calling process until speech
|
|
56
|
+
finishes -- the same as any other CLI tool that waits for the thing it
|
|
57
|
+
started, and, like real TTS tooling (espeak-ng has no timeout concept at
|
|
58
|
+
all; Windows SAPI's `WaitUntilDone` documents `-1`/infinite as the default),
|
|
59
|
+
waits as long as it takes with no wall-clock cap by default. Pass
|
|
60
|
+
`--timeout <ms>` to opt into a bounded wait instead, or `-a`/`--async` to
|
|
61
|
+
not wait at all. `--pitch`/`--speed` override the environment; without
|
|
62
|
+
them, `tts` reads `$TTS_PITCH`/`$TTS_SPEED` itself (falling back to `1` for
|
|
63
|
+
either that is unset), so jsmdcui's own `TTS_PITCH`/`TTS_SPEED` convention
|
|
64
|
+
(which it sets on `Bun.env` before spawning a TTS command, inherited here
|
|
65
|
+
like any other child process env var) is honored automatically -- jsmdcui's
|
|
66
|
+
own `detectTtsCmd()` already falls back to a bare `Bun.which("tts")` after
|
|
67
|
+
termux-tts-speak/espeak-ng/espeak, which this `tts` now satisfies, but that
|
|
68
|
+
particular fallback branch passes no `-p`/`-r`-equivalent flags at all, so
|
|
69
|
+
reading the env vars directly here is the only way `tts`'s pitch/speed
|
|
70
|
+
actually reaches Android's TTS engine through that path.
|
|
71
|
+
- Give `xclip` and `tts` a real fallback on non-Android platforms too, since
|
|
72
|
+
Buninu is cross-OS, not Android-only. `xclip`'s `-selection clipboard`
|
|
73
|
+
register now also tries jsmdcui's own `ClipboardManager`
|
|
74
|
+
(`src/platform/clipboard.js`, imported directly rather than reimplemented)
|
|
75
|
+
on win32/darwin, then `wl-copy`/`wl-paste` on Linux; `tts` gains a
|
|
76
|
+
`detectTtsCmd()` copied verbatim from jsmdcui's `src/index.js` (same
|
|
77
|
+
pitch/speed/lang math and command construction, including the win32
|
|
78
|
+
PowerShell/SAPI SSML branch, copied rather than hand-retyped since that
|
|
79
|
+
quoting is easy to silently break and this had no Windows machine to
|
|
80
|
+
verify it on) covering termux-tts-speak/say/PowerShell/espeak-ng/espeak.
|
|
81
|
+
Both deliberately skip the one path that would reintroduce the exact
|
|
82
|
+
self-reference risk they otherwise avoid: `ClipboardManager`'s Linux-like
|
|
83
|
+
branch searches PATH for a binary named `xclip`, and jsmdcui's own
|
|
84
|
+
`detectTtsCmd()` ends with a `Bun.which("tts")` fallback -- since these two
|
|
85
|
+
commands are themselves registered as Buninu's `xclip`/`tts`, ahead of
|
|
86
|
+
anything else of the same name on PATH, using either the way jsmdcui does
|
|
87
|
+
from inside xclip.js/tts.js would just be each script invoking itself.
|
|
88
|
+
- Add `bin/*.bat` launchers for the new commands (`native-bridge`, `xclip`,
|
|
89
|
+
`tts`, `showimg`, `rz`, `sz`, `xdg-open`). POSIX platforms get their
|
|
90
|
+
`bin/<name>` entries as symlinks rebuilt by `bin/init.js` on startup,
|
|
91
|
+
which Windows cannot use, so each command needs its own `.bat` there.
|
|
92
|
+
|
|
93
|
+
### Changed
|
|
94
|
+
|
|
95
|
+
- Update the bundled jsmdcui to 0.18.1, which adds `switchBackend()` to
|
|
96
|
+
`src/cui/rpc.mjs` so an RPC caller can point at something other than the
|
|
97
|
+
default `rpc` endpoint -- specifically a unix socket, which is what
|
|
98
|
+
`native-bridge` dials. Also picks up 0.18.1's fix for decoding a
|
|
99
|
+
percent-encoded socket path, needed because `new URL("unix:" + sock)`
|
|
100
|
+
turns a leading NUL (a Linux abstract-namespace socket) into `%00`.
|
|
101
|
+
- `native-bridge`'s CLI now prints `_discover` one function per line instead
|
|
102
|
+
of as a single line of JSON, which makes the list greppable. The value
|
|
103
|
+
itself is untouched -- rpc.mjs dispatches every call against that object,
|
|
104
|
+
so it stays exactly what the host returned; only the CLI's own display of
|
|
105
|
+
it changed.
|
|
106
|
+
|
|
3
107
|
## 0.2.3 - 2026-08-18
|
|
4
108
|
|
|
5
109
|
### Added
|
package/README.md
CHANGED
|
@@ -118,13 +118,20 @@ validated source list is `apps/cmdlist`; see [Add a command](#add-a-command)
|
|
|
118
118
|
for how it works):
|
|
119
119
|
|
|
120
120
|
```text
|
|
121
|
-
glow
|
|
122
|
-
jmi
|
|
123
|
-
jsgotty
|
|
124
|
-
jsmdcui
|
|
125
|
-
musl-la
|
|
126
|
-
buninu-help
|
|
127
|
-
bunx
|
|
121
|
+
glow Render files with jsmdcui syntax highlighting
|
|
122
|
+
jmi Open files in the js micro editor
|
|
123
|
+
jsgotty Run a browser-accessible terminal
|
|
124
|
+
jsmdcui Edit and run interactive Markdown applications
|
|
125
|
+
musl-la Launch AArch64 ELF programs with the bundled musl loader
|
|
126
|
+
buninu-help Render README.md with glow, then show icon.png with jsgotty --viu
|
|
127
|
+
bunx Globally install a package with bun, then exec its matching binary
|
|
128
|
+
native-bridge Call the Android host app (toast, clipboard, speak) over PKG_BRIDGE_SOCK
|
|
129
|
+
xclip X11-style clipboard tool; -selection clipboard/-clip bridges to the system clipboard
|
|
130
|
+
tts Speak text and wait for it to finish (-a to not wait)
|
|
131
|
+
showimg Shorthand for jsgotty --viu
|
|
132
|
+
rz Upload a file over ZMODEM
|
|
133
|
+
sz Download a file over ZMODEM
|
|
134
|
+
xdg-open Open a file or URL with the platform's default handler
|
|
128
135
|
```
|
|
129
136
|
|
|
130
137
|
`buninu-help` renders README.md with jsmdcui's `--cat` mode and then shows
|
|
@@ -137,6 +144,40 @@ matching binary. On Android, the underlying `bun i -g` currently needs
|
|
|
137
144
|
upstream — without it, install is killed by SIGSYS (Android's seccomp policy
|
|
138
145
|
rejects a syscall bin-linking uses), so `bunx` can't install anything there yet.
|
|
139
146
|
|
|
147
|
+
`native-bridge [func] [args...]` calls into the Android host app that
|
|
148
|
+
[minapk](https://www.npmjs.com/package/@drxiaozhi/minapk) built the running
|
|
149
|
+
APK with, over an abstract-namespace unix socket. A bare `native-bridge`
|
|
150
|
+
lists what the host implements. Every call
|
|
151
|
+
times out after 5 seconds instead of hanging; outside such an APK, every call
|
|
152
|
+
fails with a clear error instead of doing nothing silently. `import { toast,
|
|
153
|
+
clipboardRead, clipboardWrite, speak, ttsStatus, call, available } from
|
|
154
|
+
"apps/native-bridge/native-bridge.js"` gives the same functions as a library,
|
|
155
|
+
for use from a `js back` block.
|
|
156
|
+
|
|
157
|
+
`xclip [-o] [-selection primary|clipboard] [-clip]` is a small X11-`xclip`-
|
|
158
|
+
compatible clipboard tool. `-selection primary` (the default) never touches
|
|
159
|
+
the system clipboard, matching real X11 semantics. `-selection
|
|
160
|
+
clipboard`/`-clip` reaches the real system clipboard on Android, macOS,
|
|
161
|
+
Windows, and Linux/Wayland. jsmdcui picks this up automatically once it's on
|
|
162
|
+
`PATH`, so its middle-click paste and copy/paste commands just work.
|
|
163
|
+
|
|
164
|
+
`tts <text> [-f|--flush] [-a|--async] [--timeout <ms>] [--pitch <n>]
|
|
165
|
+
[--speed <n>]` speaks text and, by default, blocks until it finishes -- no
|
|
166
|
+
timeout unless you pass `--timeout`. Works on Android, macOS, Windows, and
|
|
167
|
+
Linux (via espeak-ng/espeak). `--pitch`/`--speed` fall back to
|
|
168
|
+
`$TTS_PITCH`/`$TTS_SPEED` when not given explicitly, so jsmdcui's own
|
|
169
|
+
pitch/speed setting is honored automatically.
|
|
170
|
+
|
|
171
|
+
`rz [target-dir]` (upload into `target-dir`, default: cwd) and `sz <file>
|
|
172
|
+
[more files...]` (download one or more files) are thin `bin/`-only wrappers
|
|
173
|
+
around `apps/jsgotty/rz.js`/`sz.js`, transferring files over ZMODEM through
|
|
174
|
+
the same terminal connection jsgotty already renders in a browser or WebView.
|
|
175
|
+
|
|
176
|
+
`xdg-open <file-or-url>` hands a file or URL to whatever the platform treats
|
|
177
|
+
as its default handler: the host app through native-bridge on Android (or
|
|
178
|
+
`termux-open` under plain Termux), `open` on macOS, `start` on Windows, and
|
|
179
|
+
the real system `xdg-open` on Linux.
|
|
180
|
+
|
|
140
181
|
## Export
|
|
141
182
|
|
|
142
183
|
Export the current Buninu installation as a gzip-compressed tar archive:
|
|
@@ -184,6 +225,8 @@ paths obtained from Android APIs:
|
|
|
184
225
|
|
|
185
226
|
- `PKG_DDIR`: `/storage/emulated/<user-id>/Android/data/<package>`.
|
|
186
227
|
- `PKG_MDIR`: `/storage/emulated/<user-id>/Android/media/<package>`.
|
|
228
|
+
- `PKG_BRIDGE_SOCK`: the `native-bridge` unix socket, when the host app
|
|
229
|
+
wires one in; see `native-bridge` above.
|
|
187
230
|
|
|
188
231
|
## Add a command
|
|
189
232
|
|
package/apps/cmdlist
CHANGED
|
@@ -20,3 +20,24 @@ buninu-help
|
|
|
20
20
|
|
|
21
21
|
### bunx — Globally install a package with bun, then exec its matching binary
|
|
22
22
|
bunx
|
|
23
|
+
|
|
24
|
+
### native-bridge — Call the Android host app (toast, clipboard) over PKG_BRIDGE_SOCK; bare invocation lists what the host implements
|
|
25
|
+
native-bridge
|
|
26
|
+
|
|
27
|
+
### xclip — X11-style clipboard tool; -selection clipboard/-clip bridges to the real Android clipboard via native-bridge
|
|
28
|
+
xclip
|
|
29
|
+
|
|
30
|
+
### tts — Speak text through the Android host and wait for it to finish (-a to not wait)
|
|
31
|
+
tts
|
|
32
|
+
|
|
33
|
+
### showimg — Shorthand for jsgotty --viu
|
|
34
|
+
showimg
|
|
35
|
+
|
|
36
|
+
### rz — Upload a file over ZMODEM (bun apps/jsgotty/rz.js)
|
|
37
|
+
rz
|
|
38
|
+
|
|
39
|
+
### sz — Download a file over ZMODEM (bun apps/jsgotty/sz.js)
|
|
40
|
+
sz
|
|
41
|
+
|
|
42
|
+
### xdg-open — Open a file or URL with the platform's default handler (native bridge on Android, open/start/xdg-open elsewhere)
|
|
43
|
+
xdg-open
|
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
All notable user-visible changes to jsmdcui are documented here.
|
|
4
4
|
|
|
5
|
+
## [0.18.0] - 2026-08-19
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Add `switchBackend(endpoint)` to `rpc.mjs`, repointing every `rpc.*` call
|
|
10
|
+
(including `_discover`) at a different endpoint instead of the default
|
|
11
|
+
`"rpc"`. Returns the previous endpoint so a caller can restore it.
|
|
12
|
+
- Accept a Bun unix socket as that endpoint: pass `"unix:/path/to/sock"` (or
|
|
13
|
+
`"UNIX:/path/to/sock"`) to `switchBackend`, and every `rpc.*` fetch is
|
|
14
|
+
issued over that socket via Bun's `unix` fetch option instead of as an
|
|
15
|
+
http path. Dispatch is unaffected, since routing is by function name in
|
|
16
|
+
the request body rather than by HTTP path. Only meaningful when `rpc.mjs`
|
|
17
|
+
runs under Bun (the `unix` fetch option has no effect in a browser).
|
|
18
|
+
|
|
5
19
|
## [0.17.3] - 2026-08-12
|
|
6
20
|
|
|
7
21
|
### Fixed
|
|
@@ -7,6 +7,35 @@ const crlf="\r\n"
|
|
|
7
7
|
|
|
8
8
|
let apilist = new Map()
|
|
9
9
|
|
|
10
|
+
// Endpoint every frontend rpc fetch posts to. Returns the previous one.
|
|
11
|
+
let rpcBackend = "rpc"
|
|
12
|
+
|
|
13
|
+
export function switchBackend( endpoint )
|
|
14
|
+
{
|
|
15
|
+
const previous = rpcBackend ;
|
|
16
|
+
|
|
17
|
+
rpcBackend = ( endpoint || "rpc" ) + "" ;
|
|
18
|
+
|
|
19
|
+
return previous ;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// rpcBackend starting with "unix"/"UNIX" (e.g. "unix:/path/to/sock") names a
|
|
23
|
+
// Bun unix socket instead of an http path. fetch() rejects the unix: scheme
|
|
24
|
+
// outright, so the request goes to a fixed dummy URL and the real path comes
|
|
25
|
+
// from fetch's `unix` option instead; routing here is by func name in the
|
|
26
|
+
// JSON body, not by HTTP path, so the dummy URL costs nothing.
|
|
27
|
+
const rpcFetchUrl = () =>
|
|
28
|
+
( rpcBackend.slice(0,4) === "unix" || rpcBackend.slice(0,4) === "UNIX" )
|
|
29
|
+
? "http://unix/rpc" : rpcBackend ;
|
|
30
|
+
|
|
31
|
+
// decodeURIComponent undoes new URL()'s percent-encoding of the path, which
|
|
32
|
+
// otherwise turns a leading NUL byte (a Linux abstract-namespace socket name,
|
|
33
|
+
// e.g. "unix:\0name") into the three literal characters "%00" instead of the
|
|
34
|
+
// single byte 0x00 Bun's `unix` option needs to actually reach it.
|
|
35
|
+
const rpcFetchOpt = () =>
|
|
36
|
+
( rpcBackend.slice(0,4) === "unix" || rpcBackend.slice(0,4) === "UNIX" )
|
|
37
|
+
? { unix: decodeURIComponent(new URL(rpcBackend).pathname) } : {} ;
|
|
38
|
+
|
|
10
39
|
export const jss = JSON.stringify
|
|
11
40
|
|
|
12
41
|
const webMdcuiIdSource = String.raw`[_\p{L}\p{N}][_\p{L}\p{M}\p{N}:-]*`;
|
|
@@ -309,7 +338,7 @@ function webComponentMarkdownHtml(markdown)
|
|
|
309
338
|
const source = String(markdown ?? "");
|
|
310
339
|
if (typeof Bun !== "undefined" && Bun?.markdown?.html)
|
|
311
340
|
return renderWebComponentMarkdownOnServer(source);
|
|
312
|
-
return fetch(
|
|
341
|
+
return fetch(rpcFetchUrl(), { ...rpcFetchOpt(),
|
|
313
342
|
method: "POST",
|
|
314
343
|
headers: { "content-type": "application/json" },
|
|
315
344
|
body: JSON.stringify(["_mdcui_render_markdown", [source]]),
|
|
@@ -1389,7 +1418,7 @@ export const rpcraw = async (func,argv,envp)=>{
|
|
|
1389
1418
|
const apilistMod = await FrontendDiscoverApi()
|
|
1390
1419
|
|
|
1391
1420
|
if(apilistMod[func])
|
|
1392
|
-
return await fetch(
|
|
1421
|
+
return await fetch(rpcFetchUrl(), { ...rpcFetchOpt(),
|
|
1393
1422
|
method: "POST",
|
|
1394
1423
|
body: JSON.stringify([
|
|
1395
1424
|
func,argv,envp
|
|
@@ -1463,7 +1492,7 @@ export function getfuncparams( func )
|
|
|
1463
1492
|
export async function FrontendDiscoverApi()
|
|
1464
1493
|
{
|
|
1465
1494
|
if(!apilist.get(0))
|
|
1466
|
-
apilist.set(0, await fetch(
|
|
1495
|
+
apilist.set(0, await fetch(rpcFetchUrl(), { ...rpcFetchOpt(),
|
|
1467
1496
|
method: "POST",
|
|
1468
1497
|
body: JSON.stringify(["_discover"])
|
|
1469
1498
|
}).then(r=>r.json()).catch(e=>e) ) ;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Called from a jsmdcui `js back` block (a Bun process), never from `js front`:
|
|
2
|
+
// the `unix` fetch option native-bridge relies on only works under Bun, not
|
|
3
|
+
// inside the Android WebView that renders front code.
|
|
4
|
+
// rpc.mjs's rpc.* Proxy exists so a caller can dial an RPC by a name it only
|
|
5
|
+
// knows at runtime; every call here uses a fixed, known-in-advance name, so
|
|
6
|
+
// there is nothing that sugar buys us. Going straight to rpcraw also gets us
|
|
7
|
+
// a real envp slot -- rpc.foo(...) always sends envp as {} (it is fed by the
|
|
8
|
+
// Proxy's own internal `this`, not anything a caller supplies).
|
|
9
|
+
import { rpcraw, switchBackend } from "../jsmdcui/src/cui/rpc.mjs"
|
|
10
|
+
|
|
11
|
+
// PKG_BRIDGE_SOCK carries whatever rpc.mjs's rpcFetchOpt() needs after
|
|
12
|
+
// decodeURIComponent(new URL("unix:"+sock).pathname): percent-encoded
|
|
13
|
+
// (e.g. "%00native-bridge") for a Linux abstract-namespace socket -- an env
|
|
14
|
+
// var cannot carry a literal NUL byte, but it can carry the three ASCII
|
|
15
|
+
// characters "%00" -- or a plain path for a real filesystem socket, which
|
|
16
|
+
// has nothing that needs encoding and passes through unchanged. Either way,
|
|
17
|
+
// native-bridge.js does not decide which; it forwards the value as-is.
|
|
18
|
+
const sock = process.env.PKG_BRIDGE_SOCK
|
|
19
|
+
|
|
20
|
+
if (sock) switchBackend(`unix:${sock}`)
|
|
21
|
+
|
|
22
|
+
// rpc.mjs never throws for a call the far side does not implement; it
|
|
23
|
+
// resolves to a string starting with "Unknown func" instead (evalBack/rpcraw
|
|
24
|
+
// in rpc.mjs both use this exact prefix). Default here is the same silent
|
|
25
|
+
// behavior; call nothrow(false) to make native-bridge throw in that case
|
|
26
|
+
// instead, so a missing PKG_BRIDGE_SOCK or an unimplemented host method fails
|
|
27
|
+
// loudly rather than being mistaken for a successful result. Any value other
|
|
28
|
+
// than an explicit `false` (including calling nothrow() with no argument)
|
|
29
|
+
// restores the default silent behavior.
|
|
30
|
+
let noThrowUnknownFunc = true
|
|
31
|
+
|
|
32
|
+
export function nothrow(bool)
|
|
33
|
+
{
|
|
34
|
+
noThrowUnknownFunc = bool !== false
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// fetch() never times out on its own: if the host never answers (main thread
|
|
38
|
+
// wedged, bridge crashed mid-request, anything), the await below would hang
|
|
39
|
+
// forever without this race. The abandoned rpcraw() call keeps running in
|
|
40
|
+
// the background after the race settles -- Bun's event loop treats a
|
|
41
|
+
// pending fetch as a reason to stay alive, so it is not enough for this
|
|
42
|
+
// function to merely return; whatever ultimately calls call() must still
|
|
43
|
+
// process.exit() once it is done, or the process itself will not exit even
|
|
44
|
+
// though every await has resolved.
|
|
45
|
+
const DEFAULT_TIMEOUT_MS = 5000
|
|
46
|
+
|
|
47
|
+
export async function call(name, args = [], envp, timeoutMs = DEFAULT_TIMEOUT_MS)
|
|
48
|
+
{
|
|
49
|
+
if (!sock)
|
|
50
|
+
throw new Error(`native-bridge: PKG_BRIDGE_SOCK is not set (not running inside minapk's APK)`)
|
|
51
|
+
|
|
52
|
+
const result = await Promise.race([
|
|
53
|
+
rpcraw(name, args, envp),
|
|
54
|
+
new Promise((_, reject) =>
|
|
55
|
+
setTimeout(() => reject(new Error(
|
|
56
|
+
`native-bridge: "${name}" timed out after ${timeoutMs}ms`)), timeoutMs)),
|
|
57
|
+
])
|
|
58
|
+
|
|
59
|
+
if (!noThrowUnknownFunc && typeof result === "string" && result.startsWith("Unknown func"))
|
|
60
|
+
throw new Error(`native-bridge: host does not implement "${name}"`)
|
|
61
|
+
|
|
62
|
+
return result
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const available = () => !!sock
|
|
66
|
+
|
|
67
|
+
// clipboardRead/clipboardWrite are long enough to want a short alias; toast
|
|
68
|
+
// already is one. The Java dispatcher accepts either spelling over the wire
|
|
69
|
+
// and lists both in its _discover response, so calling the alias directly
|
|
70
|
+
// (e.g. from the CLI below, or another rpcraw caller) works too, not just
|
|
71
|
+
// through these.
|
|
72
|
+
export const toast = (text, long = false) => call("toast", [text, long])
|
|
73
|
+
|
|
74
|
+
export const clipboardRead = () => call("clipboardRead")
|
|
75
|
+
export const getcb = clipboardRead
|
|
76
|
+
|
|
77
|
+
export const clipboardWrite = (text) => call("clipboardWrite", [text])
|
|
78
|
+
export const setcb = clipboardWrite
|
|
79
|
+
|
|
80
|
+
// TTS has no push/callback path over this protocol -- speak() only waits for
|
|
81
|
+
// the host to accept the utterance, not for it to finish speaking, and
|
|
82
|
+
// returns a handle immediately. Poll ttsStatus(handle) yourself ("speaking",
|
|
83
|
+
// "done", "error", or "unknown" once a terminal state has already been
|
|
84
|
+
// consumed) until it leaves "speaking"; apps/tts wraps that loop for the
|
|
85
|
+
// common case instead of every caller writing its own.
|
|
86
|
+
// Only text is required. 1.0 is normal for both speed and pitch, same
|
|
87
|
+
// convention as termux-tts-speak's -p/-r; native-bridge does not assume any
|
|
88
|
+
// particular caller's own default (jsmdcui's TTS_PITCH/TTS_SPEED, for
|
|
89
|
+
// instance, default to 1/1.5 -- that is jsmdcui's own choice, read
|
|
90
|
+
// TTS_PITCH/TTS_SPEED yourself if you want to honor it).
|
|
91
|
+
export const speak = (text, speed = 1, pitch = 1, flush = false) =>
|
|
92
|
+
call("speak", [text, speed, pitch, flush])
|
|
93
|
+
|
|
94
|
+
export const ttsStatus = (handle) => call("ttsStatus", [handle])
|
|
95
|
+
export const tts = ttsStatus
|
|
96
|
+
|
|
97
|
+
// target is either a URL (any scheme:// prefix, opened via ACTION_VIEW same
|
|
98
|
+
// as a WebView link tap) or a path under Buninu's home -- served back out
|
|
99
|
+
// through a read-only content:// provider, since the home directory is
|
|
100
|
+
// otherwise private to this app and a raw file:// Uri would be rejected by
|
|
101
|
+
// the receiving app on modern Android.
|
|
102
|
+
export const xdgOpen = (target) => call("xdgOpen", [target])
|
|
103
|
+
|
|
104
|
+
// `bun native-bridge.js <func> [args...]`, dispatched straight to call(func,
|
|
105
|
+
// args) -- not restricted to toast/clipboardRead/clipboardWrite, exactly like
|
|
106
|
+
// calling rpcraw(func, args) would be. This is a raw RPC, equivalent to a
|
|
107
|
+
// native function call on the host side: the result can be any JSON value
|
|
108
|
+
// (string, number, boolean, null, array, object), not just a plain object,
|
|
109
|
+
// so it is printed as-is rather than assumed to have any particular shape.
|
|
110
|
+
// Each arg is JSON.parse'd when it parses cleanly (so `42`, `true`, `[1,2]`,
|
|
111
|
+
// `{"a":1}` arrive as their real types) and kept as a plain string otherwise
|
|
112
|
+
// (so `hi` stays "hi" rather than failing to parse as JSON). No func given at
|
|
113
|
+
// all defaults to `_discover`, so a bare `bun native-bridge.js` lists what
|
|
114
|
+
// the far side implements instead of erroring.
|
|
115
|
+
export async function main()
|
|
116
|
+
{
|
|
117
|
+
const [name = "_discover", ...rawArgs] = process.argv.slice(2)
|
|
118
|
+
|
|
119
|
+
const args = rawArgs.map(arg => {
|
|
120
|
+
try { return JSON.parse(arg) } catch { return arg }
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
try
|
|
124
|
+
{
|
|
125
|
+
const result = await call(name, args)
|
|
126
|
+
|
|
127
|
+
// Display-only: _discover's real value (an object, relied on as-is by
|
|
128
|
+
// rpc.mjs's own dispatch gate -- see FrontendDiscoverApi/rpcraw) is
|
|
129
|
+
// never touched. This only changes what gets printed here, one
|
|
130
|
+
// function per line instead of JSON.stringify's single-line output, so
|
|
131
|
+
// the list is easy to grep.
|
|
132
|
+
if (name === "_discover" && result && typeof result === "object")
|
|
133
|
+
{
|
|
134
|
+
const lines = Object.entries(result).map(([k, v]) => `"${k}":${JSON.stringify(v)}`)
|
|
135
|
+
console.log("{\n" + lines.join(",\n") + "\n}")
|
|
136
|
+
}
|
|
137
|
+
else
|
|
138
|
+
{
|
|
139
|
+
console.log(JSON.stringify(result))
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
catch (e)
|
|
143
|
+
{
|
|
144
|
+
console.error(e.message)
|
|
145
|
+
process.exit(1)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Explicit exit even on success: see the note above call()'s timeout race
|
|
149
|
+
// -- an abandoned rpcraw() from a prior timeout can keep the process alive
|
|
150
|
+
// on its own otherwise.
|
|
151
|
+
process.exit(0)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (import.meta.main) main()
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "native-bridge",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Call the Android host app (toast, clipboard, ...) from a Buninu Bun back script, over the unix socket minapk exposes as PKG_BRIDGE_SOCK",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./native-bridge.js",
|
|
7
|
+
"license": "MIT"
|
|
8
|
+
}
|
package/apps/tts/tts.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
// Wraps native-bridge's raw speak()/ttsStatus() -- which never block, since
|
|
4
|
+
// TextToSpeech's completion signal is a callback the far side has no way to
|
|
5
|
+
// push to us over this protocol -- in the polling loop most callers actually
|
|
6
|
+
// want. Invoked as a subprocess this necessarily blocks the caller until
|
|
7
|
+
// speech finishes (or --async is given): that is expected, the same as any
|
|
8
|
+
// other CLI tool that waits for the thing it started.
|
|
9
|
+
import { speak, ttsStatus, available } from "../native-bridge/native-bridge.js"
|
|
10
|
+
|
|
11
|
+
// Buninu is cross-OS, not just Android: when native-bridge isn't available,
|
|
12
|
+
// fall back to a locally-spawned platform TTS command instead of just
|
|
13
|
+
// failing. This block is copied verbatim from jsmdcui's own
|
|
14
|
+
// src/index.js detectTtsCmd() (same pitch/speed/lang math, same command
|
|
15
|
+
// construction) rather than reimplemented, since the PowerShell quoting in
|
|
16
|
+
// the win32 branch is exactly the kind of thing that is easy to silently
|
|
17
|
+
// break by hand-retyping and hard to verify without a real Windows machine.
|
|
18
|
+
//
|
|
19
|
+
// One deliberate omission from the original: it ends with an
|
|
20
|
+
// `if (Bun.which("tts")) return {cmd:['tts'],via:'arg'}` fallback. Calling
|
|
21
|
+
// that from inside this exact script (also registered as Buninu's "tts"
|
|
22
|
+
// command, ahead of anything else named "tts" on PATH) would just be this
|
|
23
|
+
// script invoking itself -- the same self-reference risk xclip.js avoids
|
|
24
|
+
// around ClipboardManager's own "xclip" search, so it is left out here.
|
|
25
|
+
function detectTtsCmd(speed, pitch) {
|
|
26
|
+
const platform = process.platform;
|
|
27
|
+
|
|
28
|
+
const lang = process.env.TTS_LANG || 'zh-TW'
|
|
29
|
+
process.env.TTS_LANG = lang ;
|
|
30
|
+
|
|
31
|
+
if (platform === "android") {
|
|
32
|
+
if (Bun.which("termux-tts-speak"))
|
|
33
|
+
return { cmd: ["termux-tts-speak", "-p", String(pitch), "-r", String(speed)], via: "arg" };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (platform === "darwin") {
|
|
37
|
+
// say -r <wpm>; pitch via [[pbas n]] embedded TTS command (0-127, 48 = normal)
|
|
38
|
+
const rate = Math.round(175 * speed);
|
|
39
|
+
const pitchN = Math.max(0, Math.min(127, Math.round(48 * pitch)));
|
|
40
|
+
return {
|
|
41
|
+
cmd: ["say", "-r", String(rate)],
|
|
42
|
+
via: "arg",
|
|
43
|
+
textTransform: pitchN !== 48 ? (t) => `[[pbas ${pitchN}]] ${t}` : null,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (platform === "win32") {
|
|
48
|
+
// Rate property: -10 to 10 (0 = normal); pitch via SSML <prosody>
|
|
49
|
+
const rate = Math.max(-10, Math.min(10, Math.round((speed - 1) * 10)));
|
|
50
|
+
const pitchPct = Math.round((pitch - 1) * 100);
|
|
51
|
+
const pitchAttr = (pitchPct >= 0 ? "+" : "") + pitchPct + "%";
|
|
52
|
+
for (const shell of ["pwsh.exe", "powershell.exe"]) {
|
|
53
|
+
if (Bun.which(shell)) {
|
|
54
|
+
const psCmd =
|
|
55
|
+
"Add-Type -AssemblyName System.Speech; " +
|
|
56
|
+
`$s = New-Object System.Speech.Synthesis.SpeechSynthesizer; $s.Rate = ${rate}; ` +
|
|
57
|
+
`$t = [Console]::In.ReadToEnd(); ` +
|
|
58
|
+
`$x = [System.Security.SecurityElement]::Escape($t); ` +
|
|
59
|
+
`$s.SpeakSsml('<speak xml:lang="${lang}" version="1.0" xmlns="http://www.w3.org/2001/10/synthesis"><prosody pitch="${pitchAttr}">' + $x + '</prosody></speak>')`;
|
|
60
|
+
return { cmd: [shell, "-NoProfile", "-Command", psCmd], via: "stdin" };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Linux / Android fallback: espeak-ng / espeak
|
|
66
|
+
// Speed: -s <wpm> (175 = normal), Pitch: -p <n> (0-99, 50 = normal)
|
|
67
|
+
for (const bin of ["espeak-ng", "espeak"]) {
|
|
68
|
+
if (Bun.which(bin)) {
|
|
69
|
+
const spd = Math.round(175 * speed);
|
|
70
|
+
const pit = Math.max(0, Math.min(99, Math.round(50 * pitch)));
|
|
71
|
+
return { cmd: [bin, '-s', spd, '-p', pit], via: "arg" };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Also copied verbatim (as the spawn side of the same jsmdcui code path,
|
|
79
|
+
// runTts) other than operating on the whole text at once instead of
|
|
80
|
+
// per-sentence, since a CLI invocation has no buffer/cursor to highlight
|
|
81
|
+
// against sentence by sentence the way the jsmdcui TUI does.
|
|
82
|
+
async function speakViaPlatformCommand(text, cmd) {
|
|
83
|
+
const spawnOpts = { stdout: "ignore", stderr: "ignore", env: process.env };
|
|
84
|
+
spawnOpts.stdin = cmd.via === "stdin" ? new Blob([text]) : "ignore";
|
|
85
|
+
|
|
86
|
+
if (cmd.via === "arg") {
|
|
87
|
+
text = (text + "")
|
|
88
|
+
.replace(/^-+/, "")
|
|
89
|
+
.replaceAll("`", "")
|
|
90
|
+
.replaceAll("$", "");
|
|
91
|
+
if (cmd.textTransform) text = cmd.textTransform(text);
|
|
92
|
+
}
|
|
93
|
+
const args = cmd.via === "arg" ? [...cmd.cmd, text] : cmd.cmd;
|
|
94
|
+
const proc = Bun.spawn(args, spawnOpts);
|
|
95
|
+
return await proc.exited;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Not too short: each tick is a full unix-socket round trip, and TTS
|
|
99
|
+
// utterances run several seconds at minimum, so polling faster just burns
|
|
100
|
+
// cycles without getting the answer any sooner.
|
|
101
|
+
const POLL_MS = 500
|
|
102
|
+
|
|
103
|
+
// No wall-clock cap by default. Real TTS tooling doesn't invent one: espeak-ng
|
|
104
|
+
// has no timeout concept at all (its man page documents speed/pitch/gaps but
|
|
105
|
+
// nothing about how long it may block), and Windows SAPI's WaitUntilDone
|
|
106
|
+
// treats -1 ("wait forever") as the documented default -- a fixed cap here
|
|
107
|
+
// would just mean this tool gives up early on long text, which has nothing
|
|
108
|
+
// to do with the host actually being broken. The loop below still can't hang
|
|
109
|
+
// on a genuinely dead host: each poll has call()'s own 5s timeout (throws,
|
|
110
|
+
// caught below), and the loop condition itself already exits the moment
|
|
111
|
+
// status leaves "speaking" -- "unknown"/"error" are the real failure signal,
|
|
112
|
+
// not a timer. Pass --timeout to opt into a bounded wait instead.
|
|
113
|
+
const argv = process.argv.slice(2)
|
|
114
|
+
|
|
115
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
116
|
+
console.log(`
|
|
117
|
+
Usage: tts <text>
|
|
118
|
+
tts "hello world"
|
|
119
|
+
tts -f "interrupt and say this instead"
|
|
120
|
+
tts -a "say this, don't wait for it to finish"
|
|
121
|
+
tts --timeout 30000 "give up after 30s if still speaking"
|
|
122
|
+
tts --pitch 1.2 --speed 0.8 "slower and higher-pitched"
|
|
123
|
+
|
|
124
|
+
-f, --flush interrupt whatever is currently speaking
|
|
125
|
+
-a, --async print the handle and exit immediately instead of polling
|
|
126
|
+
--timeout <ms> give up and exit non-zero after this long (default: none,
|
|
127
|
+
wait as long as it takes -- matches espeak-ng/SAPI)
|
|
128
|
+
--pitch <n> 1.0 is normal (default: $TTS_PITCH, or 1 if unset)
|
|
129
|
+
--speed <n> 1.0 is normal (default: $TTS_SPEED, or 1 if unset)
|
|
130
|
+
`)
|
|
131
|
+
process.exit(0)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const flush = argv.includes("-f") || argv.includes("--flush")
|
|
135
|
+
const isAsync = argv.includes("-a") || argv.includes("--async")
|
|
136
|
+
|
|
137
|
+
const optValue = (flag, envFallback) => {
|
|
138
|
+
const i = argv.indexOf(flag)
|
|
139
|
+
const raw = i !== -1 ? argv[i + 1] : process.env[envFallback]
|
|
140
|
+
const n = Number(raw)
|
|
141
|
+
return Number.isFinite(n) ? n : 1
|
|
142
|
+
}
|
|
143
|
+
const timeoutIdx = argv.indexOf("--timeout")
|
|
144
|
+
const maxWaitMs = timeoutIdx !== -1 ? Number(argv[timeoutIdx + 1]) : null
|
|
145
|
+
const pitch = optValue("--pitch", "TTS_PITCH")
|
|
146
|
+
const speed = optValue("--speed", "TTS_SPEED")
|
|
147
|
+
|
|
148
|
+
const valueFlags = new Set(["--timeout", "--pitch", "--speed"])
|
|
149
|
+
const text = argv.find((a, i) => !a.startsWith("-") && !valueFlags.has(argv[i - 1])) ?? ""
|
|
150
|
+
|
|
151
|
+
if (!available()) {
|
|
152
|
+
// No Android native bridge -- fall back to a local platform TTS command
|
|
153
|
+
// (see detectTtsCmd() above). flush/polling/handles are all native-bridge
|
|
154
|
+
// concepts specific to Android's async TextToSpeech callback; there is no
|
|
155
|
+
// equivalent "interrupt what's currently speaking" here, so -f/--flush is
|
|
156
|
+
// silently a no-op on this path.
|
|
157
|
+
const cmd = detectTtsCmd(speed, pitch)
|
|
158
|
+
if (!cmd) {
|
|
159
|
+
console.error("no TTS backend found: no native bridge, and no termux-tts-speak/say/PowerShell/espeak-ng/espeak on PATH")
|
|
160
|
+
process.exit(1)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (isAsync) {
|
|
164
|
+
speakViaPlatformCommand(text, cmd)
|
|
165
|
+
process.exit(0)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const code = await speakViaPlatformCommand(text, cmd)
|
|
169
|
+
process.exit(code === 0 ? 0 : 1)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let handle
|
|
173
|
+
try {
|
|
174
|
+
handle = await speak(text, speed, pitch, flush)
|
|
175
|
+
} catch (e) {
|
|
176
|
+
console.error(`speak failed: ${e.message}`)
|
|
177
|
+
process.exit(1)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (isAsync) {
|
|
181
|
+
console.log(handle)
|
|
182
|
+
process.exit(0)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const start = Date.now()
|
|
186
|
+
let status = "speaking"
|
|
187
|
+
|
|
188
|
+
while (status === "speaking") {
|
|
189
|
+
if (maxWaitMs != null && Date.now() - start > maxWaitMs) {
|
|
190
|
+
console.error(`tts: gave up waiting for "${handle}" after ${maxWaitMs}ms`)
|
|
191
|
+
process.exit(1)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
await new Promise(resolve => setTimeout(resolve, POLL_MS))
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
status = await ttsStatus(handle)
|
|
198
|
+
} catch (e) {
|
|
199
|
+
console.error(`ttsStatus failed: ${e.message}`)
|
|
200
|
+
process.exit(1)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (status === "error") {
|
|
205
|
+
console.error(`tts: "${handle}" reported an error`)
|
|
206
|
+
process.exit(1)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (status === "unknown") {
|
|
210
|
+
console.error(`tts: "${handle}" went unknown before finishing`)
|
|
211
|
+
process.exit(1)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
process.exit(0)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
// Ported from ../tmpk/bin/xclip.sh (the DroidScript-era xclip). That version
|
|
4
|
+
// bridged to native only through DroidScript's dsapi.pipe; this one uses
|
|
5
|
+
// native-bridge instead. The core semantics are unchanged: "primary" is
|
|
6
|
+
// local-file-only (matches X11's mouse-selection clipboard, which xclip also
|
|
7
|
+
// keeps separate from the real clipboard), and only "-selection clipboard"
|
|
8
|
+
// (or its "-clip"/"-clipboard" shorthand) touches the real system clipboard.
|
|
9
|
+
import { getcb, setcb, available } from "../native-bridge/native-bridge.js"
|
|
10
|
+
import { ClipboardManager } from "../jsmdcui/src/platform/clipboard.js"
|
|
11
|
+
|
|
12
|
+
// Buninu is cross-OS, not just Android, so the "clipboard" register falls
|
|
13
|
+
// back through: native-bridge (Android) -> jsmdcui's own ClipboardManager
|
|
14
|
+
// (win32/darwin only, see below) -> wl-copy/wl-paste (Linux, Wayland).
|
|
15
|
+
//
|
|
16
|
+
// ClipboardManager is deliberately NOT used on Linux-like platforms
|
|
17
|
+
// (including Android): its own backend detection searches PATH for a binary
|
|
18
|
+
// literally named "xclip" there, and this script is itself registered as
|
|
19
|
+
// Buninu's "xclip" command, ahead of any real system xclip on PATH -- using
|
|
20
|
+
// it here would make this script shell out to itself. Its win32/darwin
|
|
21
|
+
// branches never search for "xclip" at all, so those two are safe.
|
|
22
|
+
let desktopClipboard = null
|
|
23
|
+
function getDesktopClipboard() {
|
|
24
|
+
if (process.platform !== "win32" && process.platform !== "darwin") return null
|
|
25
|
+
if (!desktopClipboard) desktopClipboard = new ClipboardManager()
|
|
26
|
+
return desktopClipboard
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const wlAvailable = () => !!(Bun.which("wl-copy") && Bun.which("wl-paste"))
|
|
30
|
+
const wlRead = () => {
|
|
31
|
+
const proc = Bun.spawnSync(["wl-paste", "--no-newline"])
|
|
32
|
+
return proc.success ? new TextDecoder().decode(proc.stdout) : null
|
|
33
|
+
}
|
|
34
|
+
const wlWrite = (text) => Bun.spawnSync(["wl-copy"], { stdin: Buffer.from(text) }).success
|
|
35
|
+
|
|
36
|
+
const argv = process.argv.slice(2)
|
|
37
|
+
|
|
38
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
39
|
+
console.log(`
|
|
40
|
+
Usage: Paste/Copy
|
|
41
|
+
xclip -o [-selection clipboard]
|
|
42
|
+
xclip -o [-clip]
|
|
43
|
+
echo hello | xclip [-selection clipboard]
|
|
44
|
+
echo hello | xclip [-clip]
|
|
45
|
+
|
|
46
|
+
Uses primary by default(mouse selection)
|
|
47
|
+
`)
|
|
48
|
+
process.exit(0)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const selMatch = argv.join(" ").match(/-selection\s+(\S+)/)
|
|
52
|
+
const cb = selMatch ? selMatch[1]
|
|
53
|
+
: argv.some(a => a.includes("-clip")) ? "clipboard"
|
|
54
|
+
: "primary"
|
|
55
|
+
|
|
56
|
+
process.stderr.write(`\x1b[35mClipboard 剪貼板📋 : ${cb} \n\x1b[0m`)
|
|
57
|
+
|
|
58
|
+
const cbf = `${process.env.HOME}/.xclip.${cb}`
|
|
59
|
+
const useNative = cb !== "primary"
|
|
60
|
+
const desktop = useNative ? getDesktopClipboard() : null
|
|
61
|
+
|
|
62
|
+
if (useNative && !available() && !desktop && !wlAvailable())
|
|
63
|
+
process.stderr.write(`no system clipboard found (no native bridge, no ClipboardManager backend, no wl-copy/wl-paste)\nNo native clipboard now!\n`)
|
|
64
|
+
|
|
65
|
+
const isPaste = argv.some(a => a.toLowerCase() === "-o")
|
|
66
|
+
|
|
67
|
+
if (isPaste) {
|
|
68
|
+
process.stderr.write(`\x1b[33mPaste 貼上 📋 \n\x1b[0m`)
|
|
69
|
+
|
|
70
|
+
if (useNative) {
|
|
71
|
+
if (available()) {
|
|
72
|
+
try {
|
|
73
|
+
await Bun.write(cbf, (await getcb()) ?? "")
|
|
74
|
+
} catch (e) {
|
|
75
|
+
process.stderr.write(`getcb failed: ${e.message}\n`)
|
|
76
|
+
}
|
|
77
|
+
} else if (desktop) {
|
|
78
|
+
try {
|
|
79
|
+
await Bun.write(cbf, desktop.read("clipboard") ?? "")
|
|
80
|
+
} catch (e) {
|
|
81
|
+
process.stderr.write(`clipboard read failed: ${e.message}\n`)
|
|
82
|
+
}
|
|
83
|
+
} else if (wlAvailable()) {
|
|
84
|
+
const text = wlRead()
|
|
85
|
+
if (text != null) await Bun.write(cbf, text)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const file = Bun.file(cbf)
|
|
90
|
+
if (await file.exists()) process.stdout.write(new Uint8Array(await file.arrayBuffer()))
|
|
91
|
+
} else {
|
|
92
|
+
process.stderr.write(`\x1b[33mCopy 複製 ✂️ \n\x1b[0m`)
|
|
93
|
+
|
|
94
|
+
const input = await Bun.stdin.arrayBuffer()
|
|
95
|
+
await Bun.write(cbf, input)
|
|
96
|
+
|
|
97
|
+
if (useNative) {
|
|
98
|
+
if (available()) {
|
|
99
|
+
try {
|
|
100
|
+
await setcb(new TextDecoder().decode(input))
|
|
101
|
+
} catch (e) {
|
|
102
|
+
process.stderr.write(`setcb failed: ${e.message}\n`)
|
|
103
|
+
}
|
|
104
|
+
} else if (desktop) {
|
|
105
|
+
try {
|
|
106
|
+
desktop.write(new TextDecoder().decode(input), "clipboard")
|
|
107
|
+
} catch (e) {
|
|
108
|
+
process.stderr.write(`clipboard write failed: ${e.message}\n`)
|
|
109
|
+
}
|
|
110
|
+
} else if (wlAvailable()) {
|
|
111
|
+
wlWrite(new TextDecoder().decode(input))
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Explicit exit: call()'s own timeout race means every await above is
|
|
117
|
+
// guaranteed to settle, but a getcb()/setcb() that hit that timeout leaves
|
|
118
|
+
// an abandoned fetch running, which keeps Bun's event loop alive on its own.
|
|
119
|
+
// Without this, xclip could still hang after printing everything it needs
|
|
120
|
+
// to -- the shell would just never get its prompt back.
|
|
121
|
+
process.exit(0)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
// Android goes through native-bridge's xdgOpen (MainActivity.java validates
|
|
4
|
+
// the path stays under Buninu's home and serves it back out through a
|
|
5
|
+
// read-only content:// provider; a bare URL just goes to ACTION_VIEW). The
|
|
6
|
+
// other platforms each have their own "open with whatever the OS thinks is
|
|
7
|
+
// the default handler" mechanism -- macOS/Windows are real OS
|
|
8
|
+
// commands/APIs, but Linux's is conventionally *also* a command named
|
|
9
|
+
// xdg-open, so this script has to be careful not to find itself on PATH.
|
|
10
|
+
import { xdgOpen as bridgeOpen, available } from "../native-bridge/native-bridge.js"
|
|
11
|
+
const fs = require("fs")
|
|
12
|
+
|
|
13
|
+
// Buninu's own bin/ is prepended to PATH ahead of any real system binary of
|
|
14
|
+
// the same name (same concern as xclip.js/tts.js), and this script is
|
|
15
|
+
// itself registered as Buninu's "xdg-open" command -- a naive PATH search
|
|
16
|
+
// would just find this file. Skip Buninu's own bin dir and keep looking
|
|
17
|
+
// further down PATH for wherever a real xdg-open actually lives.
|
|
18
|
+
function findSystemXdgOpen() {
|
|
19
|
+
const ownBinDir = process.env.BUNINU_HOME ? `${process.env.BUNINU_HOME}/bin` : null
|
|
20
|
+
for (const dir of (process.env.PATH || "").split(":").filter(Boolean)) {
|
|
21
|
+
if (dir === ownBinDir) continue
|
|
22
|
+
const candidate = `${dir}/xdg-open`
|
|
23
|
+
try {
|
|
24
|
+
fs.accessSync(candidate, fs.constants.X_OK)
|
|
25
|
+
return candidate
|
|
26
|
+
} catch {}
|
|
27
|
+
}
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function runCommand(args) {
|
|
32
|
+
const proc = Bun.spawn(args, { stdout: "ignore", stderr: "ignore" })
|
|
33
|
+
return (await proc.exited) === 0
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const argv = process.argv.slice(2)
|
|
37
|
+
|
|
38
|
+
if (argv.length === 0 || argv.includes("-h") || argv.includes("--help")) {
|
|
39
|
+
console.log("usage: xdg-open <file-or-url>")
|
|
40
|
+
process.exit(argv.length === 0 ? 1 : 0)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const target = argv[0]
|
|
44
|
+
let ok = false
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
if (process.platform === "android") {
|
|
48
|
+
if (available()) {
|
|
49
|
+
ok = await bridgeOpen(target)
|
|
50
|
+
} else if (Bun.which("termux-open")) {
|
|
51
|
+
// Plain Termux (no minapk) still reports platform "android" but has
|
|
52
|
+
// no PKG_BRIDGE_SOCK -- termux-open (Termux:API) does the same
|
|
53
|
+
// ACTION_VIEW-based open, same fallback relationship as tts.js's
|
|
54
|
+
// termux-tts-speak branch when native-bridge isn't available.
|
|
55
|
+
ok = await runCommand(["termux-open", target])
|
|
56
|
+
} else {
|
|
57
|
+
throw new Error("no native bridge and no termux-open (install termux-api, or run inside minapk's APK)")
|
|
58
|
+
}
|
|
59
|
+
} else if (process.platform === "darwin") {
|
|
60
|
+
ok = await runCommand(["open", target])
|
|
61
|
+
} else if (process.platform === "win32") {
|
|
62
|
+
// start is a cmd builtin, not a real executable, and needs an empty ""
|
|
63
|
+
// title argument first or it treats a quoted target as the title.
|
|
64
|
+
ok = await runCommand(["cmd", "/c", "start", "", target])
|
|
65
|
+
} else {
|
|
66
|
+
const real = findSystemXdgOpen()
|
|
67
|
+
if (!real) throw new Error("no system xdg-open found on PATH")
|
|
68
|
+
ok = await runCommand([real, target])
|
|
69
|
+
}
|
|
70
|
+
} catch (e) {
|
|
71
|
+
process.stderr.write(`xdg-open: ${e.message}\n`)
|
|
72
|
+
process.exit(1)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Explicit exit: bridgeOpen() goes through native-bridge's call(), whose own
|
|
76
|
+
// timeout race leaves an abandoned fetch running the event loop on a
|
|
77
|
+
// timeout -- without this, xdg-open could hang after printing everything it
|
|
78
|
+
// needs to.
|
|
79
|
+
process.exit(ok ? 0 : 1)
|
package/bin/rz.bat
ADDED
package/bin/rz.sh
ADDED
package/bin/showimg.bat
ADDED
package/bin/showimg.sh
ADDED
package/bin/sz.bat
ADDED
package/bin/sz.sh
ADDED
package/bin/tts.bat
ADDED
package/bin/xclip.bat
ADDED
package/bin/xdg-open.bat
ADDED
package/package.json
CHANGED