swarmwire-cli 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,6 +10,12 @@ swarmwire send ./holiday-photos # prints a link, keeps serving until you
10
10
  swarmwire get "<link>" # on the other machine: downloads it, resumable
11
11
  ```
12
12
 
13
+ Generate QR codes for easy use with the [swarmwire android app](https://github.com/tibocub/swarmwire-mobile)
14
+ ```
15
+ swarmwire send ./holiday-photos --qr # also prints a QR code to flash from the android app
16
+ swarmwire get --qr # displays a QR code that can be flashed to send a file from the android app
17
+ ```
18
+
13
19
  Anyone who knows what a file contains can find the people sharing it, whatever each calls it: two
14
20
  people sending the same file are one swarm. Pass `--private` to `send` for a link that carries a secret, so that only people
15
21
  holding the link can find you.
@@ -18,7 +24,7 @@ Both commands keep a two-line status current. Receiving looks like this:
18
24
 
19
25
  ```
20
26
  [=========> ] 45% [2/3] TOTAL: [154.2/344.0] MB
21
- UP: 1.1 MB/s DOWN: 8.2 MB/s Seeders: 1 Peers: 0
27
+ UP: 1.1 MB/s DOWN: 8.2 MB/s Seeders: 1 Peers: 0
22
28
  ```
23
29
 
24
30
  When the download is done the bar is replaced by `[Done] you are now seeding.` and `get` keeps
@@ -35,10 +41,15 @@ are decimal: 1 MB is 1,000,000 bytes.
35
41
 
36
42
  | | |
37
43
  |---|---|
38
- | `send <path> [--private]` | share a file or folder; prints a link |
44
+ | `send <path> [--private] [--qr]` | share a file or folder; prints a link (`--qr`: also draws it as a QR code) |
45
+ | `get --qr [output] [--exit]` | draw a QR code and wait: a phone scans it and hands over a link (its own file, or one forwarded from another device) |
39
46
  | `get <link> [output] [--exit]` | fetch by link, then keep seeding it (`--exit`: leave instead); the output defaults to the name in the link |
40
47
  | `--bootstrap host:port` | use a private DHT instead of the public one (for tests) |
41
48
 
49
+ `get --qr` listens on a fresh one-shot key on the DHT, shown in the QR code and printed below it as
50
+ `swarmwire-recv://<key>`. It takes the first valid link anyone sends it, then downloads as usual.
51
+ The QR codes go to stderr, so `send`'s stdout is still just the link.
52
+
42
53
  Needs Node 20 or later. Files are shared where they lie: nothing is copied or written next to them.
43
54
 
44
55
  ## Development
package/bin/swarmwire.js CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * swarmwire send <path> [--private] [--bootstrap host:port]
3
+ * swarmwire send <path> [--private] [--qr] [--bootstrap host:port]
4
4
  * swarmwire get <link> [output] [--bootstrap host:port]
5
+ * swarmwire get --qr [output] [--bootstrap host:port]
5
6
  *
6
7
  * Deliberately two commands. Sending prints a link and keeps serving the file where it is;
7
8
  * getting takes that link and nothing else.
@@ -12,15 +13,19 @@ import { stat } from 'fs/promises'
12
13
  import Hyperswarm from 'hyperswarm'
13
14
  import DHT from 'hyperdht'
14
15
  import b4a from 'b4a'
16
+ import { renderQr } from '../lib/qr.js'
17
+ import { receive } from '../lib/rendezvous.js'
15
18
  import { Swarmwire, parseLink, groupLength, DEFAULT_GROUP_LOG } from 'swarmwire-lib'
16
19
 
17
20
  const USAGE = `swarmwire - content-addressed file transfer, peer to peer
18
21
 
19
22
  swarmwire send <path> [options] share a file or folder; prints a link
20
23
  swarmwire get <link> [output] fetch a file or folder by link
24
+ swarmwire get --qr [output] show a QR code; a phone scans it and hands over a link
21
25
 
22
26
  Options:
23
27
  --private only people holding the link can find you (default: anyone who knows the hash can)
28
+ --qr send: also show the link as a QR code. get: wait for a link from a scanner
24
29
  --exit get: leave when the download is done instead of seeding it
25
30
  --bootstrap <h:p> use a private DHT instead of the public one
26
31
  --help
@@ -32,14 +37,14 @@ let shown = ''
32
37
  let drawn = false
33
38
  let lastPlain = ''
34
39
 
35
- const FLAGS = { private: false, exit: false, help: false, bootstrap: true } // true: takes a value
40
+ const FLAGS = { private: false, exit: false, qr: false, help: false, bootstrap: true } // true: takes a value
36
41
 
37
42
  async function main () {
38
43
  const { command, args, flags } = parseArgs(process.argv.slice(2))
39
44
 
40
45
  if (flags.help || !command) return print(USAGE)
41
46
  if (command === 'send') return send(args[0], flags)
42
- if (command === 'get') return get(args[0], args[1], flags)
47
+ if (command === 'get') return flags.qr ? get(null, args[0], flags) : get(args[0], args[1], flags)
43
48
 
44
49
  throw new Error(`unknown command: ${command}\n\n${USAGE}`)
45
50
  }
@@ -59,6 +64,7 @@ async function send (path, flags) {
59
64
  : await node.serve(path, { secret })
60
65
 
61
66
  print(link)
67
+ if (flags.qr) warn(renderQr(link, { color: process.stderr.isTTY }))
62
68
  warn(
63
69
  folder
64
70
  ? `serving ${path} (${files} files, ${bytes(size)})${flags.private ? ' privately' : ''}`
@@ -77,12 +83,18 @@ async function send (path, flags) {
77
83
  }
78
84
 
79
85
  async function get (link, output, flags) {
80
- if (!link) throw new Error('get needs a link\n\n' + USAGE)
86
+ if (!link && !flags.qr) throw new Error('get needs a link\n\n' + USAGE)
87
+
88
+ const swarm = createSwarm(flags)
89
+
90
+ if (!link) {
91
+ link = await linkFromScanner(swarm)
92
+ if (!link) return swarm.destroy() // interrupted while waiting
93
+ }
81
94
 
82
95
  const parsed = parseLink(link)
83
96
  const out = output ?? safeName(parsed.name) ?? parsed.root.slice(0, 16)
84
97
 
85
- const swarm = createSwarm(flags)
86
98
  const node = new Swarmwire({ swarm })
87
99
 
88
100
  warn(`fetching ${parsed.root.slice(0, 16)}... into ${out}`)
@@ -133,6 +145,19 @@ async function get (link, output, flags) {
133
145
  await swarm.destroy()
134
146
  }
135
147
 
148
+ /** Show a QR code for a one-shot meeting point and wait for a scanner to hand us a link. */
149
+ async function linkFromScanner (swarm) {
150
+ const meeting = await receive(swarm.dht)
151
+
152
+ warn(renderQr(meeting.url, { color: process.stderr.isTTY }))
153
+ warn(`${meeting.url}\nscan this with the swarmwire app and pick something to send; ctrl-c to cancel`)
154
+
155
+ const link = await Promise.race([meeting.link, interrupted().then(() => null)])
156
+ await meeting.close()
157
+
158
+ return link
159
+ }
160
+
136
161
  /** Resolves on ctrl-c or a polite kill, so serving can end cleanly. */
137
162
  function interrupted () {
138
163
  return new Promise((resolve) => {
package/lib/qr.js ADDED
@@ -0,0 +1,25 @@
1
+ import { encode } from 'uqr'
2
+
3
+ const QUIET = 2 // modules of white around the code, as the spec asks
4
+
5
+ /**
6
+ * A QR code as terminal text, two modules per character row. Black on white is set
7
+ * explicitly when `color` is true: on a dark theme the default colors would invert the code.
8
+ */
9
+ export function renderQr (text, { color = true } = {}) {
10
+ const { data, size } = encode(text, { ecc: 'L', border: 0 })
11
+ const dark = (x, y) => x >= 0 && y >= 0 && x < size && y < size && data[y][x]
12
+
13
+ const rows = []
14
+ for (let y = -QUIET; y < size + QUIET; y += 2) {
15
+ let row = ''
16
+ for (let x = -QUIET; x < size + QUIET; x++) {
17
+ const top = dark(x, y)
18
+ const bottom = dark(x, y + 1)
19
+ row += top && bottom ? '█' : top ? '▀' : bottom ? '▄' : ' '
20
+ }
21
+ rows.push(color ? `\x1b[30;47m${row}\x1b[0m` : row)
22
+ }
23
+
24
+ return rows.join('\n')
25
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * A one-shot meeting point for handing a link to a machine that cannot be reached by one.
3
+ *
4
+ * `receive` listens on a fresh DHT keypair and shows its public key (as a QR code); whoever
5
+ * scans it calls `deliver` with a link, the listener checks it is one and answers. No topic,
6
+ * no discovery: the key is the address, and it is good for exactly one link.
7
+ */
8
+
9
+ import b4a from 'b4a'
10
+ import { parseLink } from 'swarmwire-lib'
11
+
12
+ export const SCHEME = 'swarmwire-recv://'
13
+ const MAX_LINK = 4096 // a link is ~150 bytes; this only bounds what a stranger can make us buffer
14
+
15
+ /**
16
+ * @param {import('hyperdht')} dht
17
+ * @returns {Promise<{url: string, link: Promise<string>, close: () => Promise<void>}>}
18
+ */
19
+ export async function receive (dht) {
20
+ const keyPair = dht.constructor.keyPair()
21
+ let resolve
22
+ const link = new Promise((r) => { resolve = r })
23
+ let done = false
24
+
25
+ const server = dht.createServer((socket) => {
26
+ let buffer = ''
27
+ socket.setEncoding('utf8')
28
+ socket.on('error', () => {}) // a scanner that walks away is not our problem
29
+ socket.on('data', (chunk) => {
30
+ buffer += chunk
31
+ if (buffer.length > MAX_LINK) return socket.destroy()
32
+
33
+ const end = buffer.indexOf('\n')
34
+ if (end < 0) return
35
+
36
+ const candidate = buffer.slice(0, end).trim()
37
+ buffer = ''
38
+
39
+ try {
40
+ if (done) throw new Error('already received')
41
+ parseLink(candidate)
42
+ } catch {
43
+ return socket.end('invalid\n')
44
+ }
45
+
46
+ done = true
47
+ socket.end('ok\n')
48
+ resolve(candidate)
49
+ })
50
+ })
51
+
52
+ await server.listen(keyPair)
53
+
54
+ return {
55
+ url: SCHEME + b4a.toString(keyPair.publicKey, 'hex'),
56
+ link,
57
+ close: () => server.close()
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Hand `link` to the listener at `url`. Resolves once it has accepted it.
63
+ *
64
+ * @param {import('hyperdht')} dht
65
+ * @param {string} url
66
+ * @param {string} link
67
+ */
68
+ export async function deliver (dht, url, link) {
69
+ if (!url.startsWith(SCHEME)) throw new Error(`not a swarmwire receive address: ${url}`)
70
+ const publicKey = b4a.from(url.slice(SCHEME.length), 'hex')
71
+ if (publicKey.length !== 32) throw new Error(`not a swarmwire receive address: ${url}`)
72
+
73
+ const socket = dht.connect(publicKey)
74
+ socket.setEncoding('utf8')
75
+
76
+ const answer = new Promise((resolve, reject) => {
77
+ let reply = ''
78
+ socket.on('data', (chunk) => { reply += chunk })
79
+ socket.on('error', reject)
80
+ socket.on('end', () => { socket.end(); resolve(reply.trim()) }) // the listener half-closes after its answer
81
+ socket.on('close', () => resolve(reply.trim()))
82
+ })
83
+
84
+ socket.write(link + '\n')
85
+ const reply = await answer
86
+
87
+ if (reply !== 'ok') throw new Error(`receiver refused the link${reply ? `: ${reply}` : ''}`)
88
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "swarmwire-cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Send a file or folder to someone with a link. Peer to peer, from the terminal.",
5
5
  "keywords": [
6
6
  "cli",
@@ -23,7 +23,8 @@
23
23
  "swarmwire": "bin/swarmwire.js"
24
24
  },
25
25
  "files": [
26
- "bin/"
26
+ "bin/",
27
+ "lib/"
27
28
  ],
28
29
  "engines": {
29
30
  "node": ">=20"
@@ -35,7 +36,8 @@
35
36
  "b4a": "^1.9.0",
36
37
  "hyperdht": "^6.0.0",
37
38
  "hyperswarm": "^4.17.2",
38
- "swarmwire-lib": "^0.4.0"
39
+ "swarmwire-lib": "^0.4.1",
40
+ "uqr": "^0.1.3"
39
41
  },
40
42
  "devDependencies": {
41
43
  "@hyperswarm/testnet": "^3.1.4",