rechrome 1.44.0 → 1.46.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
@@ -151,10 +151,12 @@ comment for some .env loaders. rech quotes it when it saves one.
151
151
 
152
152
  ### 4. Share with another machine
153
153
 
154
- On the host (the machine with Chrome), make an invite:
154
+ On the host (the machine with Chrome), make an invite. `rech share` has no default transport:
155
+ in a terminal, `rech share` asks which profile and which listeners to share over; in a script,
156
+ name them:
155
157
 
156
158
  ```bash
157
- rech share new you@example.com --name alice
159
+ rech share you@example.com --webrtc --name alice
158
160
  # Share "alice": you@example.com
159
161
  # Invite for one device (unused, it expires …; secret: send it privately):
160
162
  # https://rechrome.pages.dev/#rech1_7fQxC9tZp2mVb8wLrN4hdK3kT9aQ
@@ -168,8 +170,7 @@ rech connect 'https://rechrome.pages.dev/#rech1_…'
168
170
  rech open https://example.com
169
171
  ```
170
172
 
171
- By default the two machines meet over **WebRTC**: neither needs an open port, a VPN or a
172
- domain. A small signal server introduces them, and then traffic flows peer to peer, end-to-end
173
+ Over **WebRTC** the two machines need no open port, VPN or domain. A small signal server introduces them, and then traffic flows peer to peer, end-to-end
173
174
  encrypted with keys the signal server never sees. The first `rech` command after connecting starts
174
175
  a small local bridge that keeps the connection open between commands.
175
176
 
@@ -180,10 +181,12 @@ expire after 24 hours, and WebRTC shares after 30 days unless renewed.
180
181
 
181
182
  | On the host | |
182
183
  |---|---|
183
- | `rech share new <profile..> [--name n]` | Invite link (`--via webrtc` by default) |
184
- | `--via lan` · `--via tailscale` · `--via <IP or listener>` | Direct HTTP instead of (or as well as) WebRTC |
184
+ | `rech share [<profile..>] [--name n]` | Invite link; in a terminal, with no transport flag, pick profile and listeners from a list |
185
+ | `--webrtc` · `--webrtc=<signal-url>` | WebRTC via the built-in `webrtc-default` listener (rechrome.pages.dev: best effort, for trying it out; no speed or uptime promise) · via your own signal server (its listener is made if needed) |
186
+ | `--lan` · `--tailscale` · `--via <listener,…>` | Direct HTTP on this network · your tailnet · existing listeners (`rech listener ls`); combine several for one link each |
185
187
  | `--devices 3` · `--approve` | Let 3 devices redeem it · make each new device wait for `rech share approve` |
186
- | `--expires 7d` · `--invite-ttl 1h` · `--signal <url>` | Lifetimes · your [own signal server](docs/signal-selfhost.md) |
188
+ | `--expires 7d` · `--invite-ttl 1h` | Lifetimes |
189
+ | `rech listener add <name> --webrtc=<signal-url> [--turn <url>]… [--turn-env VAR]…` | A WebRTC listener on your [own signal server](docs/signal-selfhost.md), with optional STUN/TURN |
187
190
  | `rech share ls` · `rech share devices <share> [--rm <device>]` | Shares, devices, expiry · drop one device |
188
191
  | `rech share allow\|deny <share> <profile..>` | Change the profiles; the link and devices stay |
189
192
  | `rech share rotate <share>` | A fresh invite; the old one stops working |
@@ -201,9 +204,12 @@ navigation, tabs, snapshots, screenshots and basic interactions, but not `eval`/
201
204
  filesystem commands (see [Remote access](#remote-access)).
202
205
 
203
206
  **WebRTC needs** `node-datachannel`, installed on first use into `~/.rechrome/webrtc`. When a
204
- direct path can't be found (some corporate networks, symmetric NAT), give the host TURN: set
205
- `CF_TURN_KEY_ID` and `CF_TURN_API_TOKEN` (a [Cloudflare TURN](https://developers.cloudflare.com/realtime/turn/)
206
- key; its free tier is 1,000 GB/month) in the daemon's environment, or share `--via tailscale`.
207
+ direct path can't be found (some corporate networks, symmetric NAT), give the host TURN: add it to
208
+ a WebRTC listener as one URL, `rech listener add home --webrtc=https://signal.example.com/ --turn-env TEAM_TURN`
209
+ with `TEAM_TURN=turns://user:pass@turn.example.com:5349` (user and password percent-encoded;
210
+ `rech listener ls` shows the username, never the password). Or set `CF_TURN_KEY_ID` and
211
+ `CF_TURN_API_TOKEN` (a [Cloudflare TURN](https://developers.cloudflare.com/realtime/turn/) key; its
212
+ free tier is 1,000 GB/month) in the daemon's environment, or share `--tailscale`.
207
213
 
208
214
  **Keep links out of git.** `rech status`, `rech connect` and `rech share --save` warn when the file
209
215
  holding `RECHROME_URL` is committed, staged, not git-ignored, or readable by other users. Links
@@ -398,6 +404,23 @@ cp .env.example .env.local
398
404
  >
399
405
  > Shell-set `PLAYWRIGHT_MCP_*` variables take priority over `.env.local`, so you can always override per-command without editing files.
400
406
 
407
+ ### Downloading files
408
+
409
+ A file the page downloads (a link, an export button) goes to **your** machine. Any type
410
+ works. It lands in `<project>/.rechrome/output/` with the command's other files. The host
411
+ saves it in a private folder, hands it to the client once, and then deletes it. A download
412
+ that is never fetched is deleted after 10 minutes. Each download is logged on the host
413
+ with its name, size and sha256.
414
+
415
+ Keeping a copy on the host is opt-in, per listener:
416
+
417
+ ```bash
418
+ rech listener set <listener> --keep-downloads # --no-keep-downloads: back to client-only
419
+ ```
420
+
421
+ A download that finishes after its command has returned is reported, and handed over,
422
+ by the next command on that tab.
423
+
401
424
  ### Uploading files
402
425
 
403
426
  `rech upload <file>` puts a local image into the page: a file picker, an
package/listeners.js CHANGED
@@ -21,11 +21,123 @@ Types: ${UPLOAD_TYPES.join(", ")}, up to ${MAX_UPLOAD_BYTES / 1024 / 1024} MiB.
21
21
  // listeners.ts
22
22
  import { isIP } from "net";
23
23
  import { createHash, randomBytes } from "crypto";
24
+ import { homedir as homedir2 } from "os";
25
+ import { join as join3 } from "path";
26
+ import { mkdir, readFile, rename, writeFile } from "fs/promises";
27
+
28
+ // shares.ts
24
29
  import { homedir } from "os";
25
30
  import { join as join2 } from "path";
26
- import { mkdir, readFile, rename, writeFile } from "fs/promises";
31
+
32
+ // secret.ts
33
+ var B58 = "[1-9A-HJ-NP-Za-km-z]";
34
+ var BODY_LEN = 22;
35
+ var SUM_LEN = 6;
36
+ var TOKEN_PATTERN = new RegExp(`\\brech1_${B58}{${BODY_LEN + SUM_LEN}}\\b`, "g");
37
+ var EXACT = new RegExp(`^rech1_(${B58}{${BODY_LEN}})(${B58}{${SUM_LEN}})$`);
38
+ var CRC_TABLE = (() => {
39
+ const t = new Uint32Array(256);
40
+ for (let i = 0;i < 256; i++) {
41
+ let c = i;
42
+ for (let k = 0;k < 8; k++)
43
+ c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
44
+ t[i] = c >>> 0;
45
+ }
46
+ return t;
47
+ })();
48
+
49
+ // shares.ts
50
+ var DEFAULT_SIGNAL = "https://rechrome.pages.dev/";
51
+ var DEFAULT_RTC = "webrtc-default";
52
+ var SHARES_FILE = join2(homedir(), ".rechrome", "shares.json");
53
+ function normalizeSignal(value) {
54
+ let url;
55
+ try {
56
+ url = new URL(value);
57
+ } catch {
58
+ throw new Error(`Signal server must be an http(s) URL, e.g. https://signal.example.com; got "${value}"`);
59
+ }
60
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash)
61
+ throw new Error("Signal server must be a plain http(s) URL, without credentials, query or fragment");
62
+ if (!url.pathname.endsWith("/"))
63
+ url.pathname += "/";
64
+ return url.toString();
65
+ }
66
+ var bearerCache = new Map;
67
+
68
+ // listeners.ts
69
+ var LEGACY_RTC = "webrtc";
70
+ var DEFAULT_RTC_NOTE = "best effort, for trying it out: no speed or uptime promise. Host your own: rech listener add <name> --webrtc=<url>";
71
+ var defaultRtcListener = () => ({ name: DEFAULT_RTC, kind: "webrtc", signal: DEFAULT_SIGNAL, builtin: true });
72
+ function rtcListeners(config) {
73
+ return [...config?.rtcListeners ?? [], defaultRtcListener()];
74
+ }
75
+ function shareRtcListeners(share, config) {
76
+ const all = rtcListeners(config);
77
+ const out = [];
78
+ for (const via of share.via) {
79
+ if (via === LEGACY_RTC)
80
+ out.push({ name: LEGACY_RTC, kind: "webrtc", signal: normalizeSignal(share.signal ?? DEFAULT_SIGNAL), builtin: true });
81
+ else {
82
+ const l = all.find((x) => x.name === via);
83
+ if (l)
84
+ out.push(l);
85
+ }
86
+ }
87
+ return out;
88
+ }
89
+ function parseIceUrl(value) {
90
+ const raw = value.trim();
91
+ const m = /^(stuns?|turns?):(?:\/\/)?(?:([^:@/\s]*)(?::([^@/\s]*))?@)?([^/?#@\s]+)(\?[^#]*)?$/i.exec(raw);
92
+ if (!m)
93
+ throw new Error("A STUN/TURN server is a URL like turns://user:pass@turn.example.com:5349 or stun:stun.example.com:3478");
94
+ const [, scheme, user, pass, hostPort, query = ""] = m;
95
+ const kind = scheme.toLowerCase();
96
+ if (query && !/^\?transport=(udp|tcp)$/i.test(query))
97
+ throw new Error("A STUN/TURN URL may only carry ?transport=udp or ?transport=tcp");
98
+ const urls = `${kind}:${hostPort}${query}`;
99
+ if (user === undefined)
100
+ return { urls };
101
+ if (kind.startsWith("stun"))
102
+ throw new Error("A STUN server takes no username or password");
103
+ let username, credential;
104
+ try {
105
+ username = decodeURIComponent(user);
106
+ credential = decodeURIComponent(pass ?? "");
107
+ } catch {
108
+ throw new Error("The TURN username or password has a bad %-escape");
109
+ }
110
+ if (!username || !credential)
111
+ throw new Error("A TURN URL with credentials needs both: turns://user:pass@host:port");
112
+ return { urls, username, credential };
113
+ }
114
+ function iceLabel(s) {
115
+ const url = [s.urls].flat()[0] ?? "";
116
+ if (!s.username)
117
+ return url;
118
+ const [scheme, ...rest] = url.split(":");
119
+ return `${scheme}://${encodeURIComponent(s.username)}:\u2026@${rest.join(":")}`;
120
+ }
121
+ function validateRtcListeners(value, socketNames) {
122
+ if (value === undefined)
123
+ return;
124
+ if (!Array.isArray(value))
125
+ throw new Error("listeners.json rtcListeners must be a list");
126
+ const names = new Set;
127
+ for (const l of value) {
128
+ if (!l || l.kind !== "webrtc" || typeof l.name !== "string" || !/^[a-zA-Z0-9_-]{1,40}$/.test(l.name) || typeof l.signal !== "string")
129
+ throw new Error("Each WebRTC listener needs a name and a signal server URL");
130
+ if (l.name === DEFAULT_RTC || l.name === LEGACY_RTC || names.has(l.name) || socketNames.has(l.name))
131
+ throw new Error(`Listener names must be unique ("${DEFAULT_RTC}" and "${LEGACY_RTC}" are built in)`);
132
+ l.signal = normalizeSignal(l.signal);
133
+ if (l.ice !== undefined && (!Array.isArray(l.ice) || l.ice.some((s) => !s || typeof s.urls !== "string" && !Array.isArray(s.urls) || s.username !== undefined && typeof s.username !== "string" || s.credential !== undefined && typeof s.credential !== "string")))
134
+ throw new Error(`WebRTC listener "${l.name}": ice must be a list of {urls, username?, credential?}`);
135
+ delete l.builtin;
136
+ names.add(l.name);
137
+ }
138
+ }
27
139
  var MAX_RETIRED_PORTS = 32;
28
- var LISTENERS_FILE = join2(homedir(), ".rechrome", "listeners.json");
140
+ var LISTENERS_FILE = join3(homedir2(), ".rechrome", "listeners.json");
29
141
  var isLoopback = (host) => host === "::1" || /^127\./.test(host);
30
142
  var listenerAddress = (l) => `${l.host.includes(":") ? `[${l.host}]` : l.host}:${l.port}`;
31
143
  var profileOutputPrefix = (profile) => `profiles/${createHash("sha256").update(profile).digest("hex")}/`;
@@ -80,6 +192,8 @@ function validateListeners(value) {
80
192
  throw new Error("readSecrets must be true or false");
81
193
  if (l.readCookies !== undefined && typeof l.readCookies !== "boolean")
82
194
  throw new Error("readCookies must be true or false");
195
+ if (l.keepDownloads !== undefined && typeof l.keepDownloads !== "boolean")
196
+ throw new Error("keepDownloads must be true or false");
83
197
  const address = listenerAddress(l);
84
198
  if (names.has(l.name) || addresses.has(address) || keys.has(l.key))
85
199
  throw new Error("Listener names, addresses, and credentials must be unique");
@@ -87,6 +201,7 @@ function validateListeners(value) {
87
201
  addresses.add(address);
88
202
  keys.add(l.key);
89
203
  }
204
+ validateRtcListeners(config.rtcListeners, names);
90
205
  if (config.retiredPorts !== undefined && (!Array.isArray(config.retiredPorts) || config.retiredPorts.some((p) => !Number.isInteger(p) || p < 1 || p > 65535)))
91
206
  throw new Error("listeners.json retiredPorts must be a list of ports");
92
207
  return config;
@@ -116,7 +231,7 @@ async function writeListeners(config) {
116
231
  config.retiredPorts = retired;
117
232
  else
118
233
  delete config.retiredPorts;
119
- await mkdir(join2(homedir(), ".rechrome"), { recursive: true });
234
+ await mkdir(join3(homedir2(), ".rechrome"), { recursive: true });
120
235
  const temp = `${LISTENERS_FILE}.${randomBytes(6).toString("hex")}.tmp`;
121
236
  await writeFile(temp, JSON.stringify(config, null, 2) + `
122
237
  `, { mode: 384 });
@@ -241,24 +356,32 @@ function planProfileRemoval(key, registry, listeners, managedRoot) {
241
356
  return { keys, listeners: edits, ...managed ? { dataDir: entry.userDataDir } : {} };
242
357
  }
243
358
  export {
359
+ DEFAULT_RTC,
360
+ DEFAULT_RTC_NOTE,
361
+ LEGACY_RTC,
244
362
  LISTENERS_FILE,
245
363
  allowProfiles,
246
364
  authorizeProfileRequest,
247
365
  canReadProfileFile,
248
366
  canonicalProfileKeys,
367
+ defaultRtcListener,
249
368
  denyProfiles,
369
+ iceLabel,
250
370
  isLoopback,
251
371
  listenerAddress,
252
372
  normalizePrefix,
253
373
  normalizePublicUrl,
374
+ parseIceUrl,
254
375
  planProfileRemoval,
255
376
  profileOutputPrefix,
256
377
  readListeners,
257
378
  resolveAllowedProfile,
258
379
  retirePorts,
259
380
  rotateListenerKey,
381
+ rtcListeners,
260
382
  serviceUrl,
261
383
  setPublicUrl,
384
+ shareRtcListeners,
262
385
  validateListeners,
263
386
  writeListeners
264
387
  };
package/listeners.ts CHANGED
@@ -4,12 +4,89 @@ import { createHash, randomBytes } from "crypto";
4
4
  import { homedir } from "os";
5
5
  import { join } from "path";
6
6
  import { mkdir, readFile, rename, writeFile } from "fs/promises";
7
+ import { DEFAULT_RTC, DEFAULT_SIGNAL, normalizeSignal } from "./shares.ts";
7
8
 
8
9
  // publicUrl: where a reverse proxy (Tailscale Serve, Caddy, nginx, ...) exposes this listener.
9
- export type Listener = { name: string; host: string; port: number; key: string; profiles: string[] | "*"; prefix?: string; publicUrl?: string; uploads?: UploadPolicy | false; readSecrets?: boolean; readCookies?: boolean };
10
+ export type Listener = { name: string; host: string; port: number; key: string; profiles: string[] | "*"; prefix?: string; publicUrl?: string; uploads?: UploadPolicy | false; readSecrets?: boolean; readCookies?: boolean; keepDownloads?: boolean };
10
11
  // retiredPorts: ports of removed listeners, newest last. New listeners skip them so a link
11
12
  // saved for a removed listener gets "connection refused", not some other listener's answer.
12
- export type ListenerConfig = { version: 1; listeners: Listener[]; retiredPorts?: number[] };
13
+ // rtcListeners: WebRTC transports (a signal server, plus optional STUN/TURN). They live under
14
+ // their own key so a daemon from before they existed reads the file unchanged and ignores them.
15
+ export type ListenerConfig = { version: 1; listeners: Listener[]; retiredPorts?: number[]; rtcListeners?: RtcListener[] };
16
+ export type IceServer = { urls: string | string[]; username?: string; credential?: string };
17
+ export type RtcListener = { name: string; kind: "webrtc"; signal: string; ice?: IceServer[]; builtin?: true };
18
+
19
+ // The built-in WebRTC listener: the public signal server, for trying sharing out. Never written
20
+ // to listeners.json; a share names it in `via`. The legacy `via: ["webrtc"]` (+ share.signal)
21
+ // maps to a virtual listener named "webrtc".
22
+ export { DEFAULT_RTC };
23
+ export const LEGACY_RTC = "webrtc";
24
+ export const DEFAULT_RTC_NOTE = "best effort, for trying it out: no speed or uptime promise. Host your own: rech listener add <name> --webrtc=<url>";
25
+ export const defaultRtcListener = (): RtcListener => ({ name: DEFAULT_RTC, kind: "webrtc", signal: DEFAULT_SIGNAL, builtin: true });
26
+
27
+ /** Every WebRTC listener a share can name: the saved ones plus the built-in default. */
28
+ export function rtcListeners(config: ListenerConfig | null): RtcListener[] {
29
+ return [...(config?.rtcListeners ?? []), defaultRtcListener()];
30
+ }
31
+
32
+ /** The WebRTC listeners a share is offered on, in `via` order. */
33
+ export function shareRtcListeners(share: { via: string[]; signal?: string }, config: ListenerConfig | null): RtcListener[] {
34
+ const all = rtcListeners(config);
35
+ const out: RtcListener[] = [];
36
+ for (const via of share.via) {
37
+ if (via === LEGACY_RTC) out.push({ name: LEGACY_RTC, kind: "webrtc", signal: normalizeSignal(share.signal ?? DEFAULT_SIGNAL), builtin: true });
38
+ else { const l = all.find(x => x.name === via); if (l) out.push(l); }
39
+ }
40
+ return out;
41
+ }
42
+
43
+ /**
44
+ * One STUN/TURN server from a URL: `turn[s]://user:pass@host:port?transport=tcp` (user and
45
+ * password percent-encoded) becomes {urls: "turn[s]:host:port?transport=tcp", username, credential}.
46
+ * `stun:host:port` and `turn:host:port` without credentials are accepted as they are.
47
+ */
48
+ export function parseIceUrl(value: string): IceServer {
49
+ const raw = value.trim();
50
+ const m = /^(stuns?|turns?):(?:\/\/)?(?:([^:@/\s]*)(?::([^@/\s]*))?@)?([^/?#@\s]+)(\?[^#]*)?$/i.exec(raw);
51
+ if (!m) throw new Error("A STUN/TURN server is a URL like turns://user:pass@turn.example.com:5349 or stun:stun.example.com:3478");
52
+ const [, scheme, user, pass, hostPort, query = ""] = m;
53
+ const kind = scheme!.toLowerCase();
54
+ if (query && !/^\?transport=(udp|tcp)$/i.test(query)) throw new Error("A STUN/TURN URL may only carry ?transport=udp or ?transport=tcp");
55
+ const urls = `${kind}:${hostPort}${query}`;
56
+ if (user === undefined) return { urls };
57
+ if (kind.startsWith("stun")) throw new Error("A STUN server takes no username or password");
58
+ let username: string, credential: string;
59
+ try { username = decodeURIComponent(user); credential = decodeURIComponent(pass ?? ""); }
60
+ catch { throw new Error("The TURN username or password has a bad %-escape"); }
61
+ if (!username || !credential) throw new Error("A TURN URL with credentials needs both: turns://user:pass@host:port");
62
+ return { urls, username, credential };
63
+ }
64
+
65
+ /** How a STUN/TURN server is shown: the username, never the password. */
66
+ export function iceLabel(s: IceServer): string {
67
+ const url = [s.urls].flat()[0] ?? "";
68
+ if (!s.username) return url;
69
+ const [scheme, ...rest] = url.split(":");
70
+ return `${scheme}://${encodeURIComponent(s.username)}:…@${rest.join(":")}`;
71
+ }
72
+
73
+ function validateRtcListeners(value: unknown, socketNames: Set<string>): void {
74
+ if (value === undefined) return;
75
+ if (!Array.isArray(value)) throw new Error("listeners.json rtcListeners must be a list");
76
+ const names = new Set<string>();
77
+ for (const l of value as RtcListener[]) {
78
+ if (!l || l.kind !== "webrtc" || typeof l.name !== "string" || !/^[a-zA-Z0-9_-]{1,40}$/.test(l.name) || typeof l.signal !== "string")
79
+ throw new Error("Each WebRTC listener needs a name and a signal server URL");
80
+ if (l.name === DEFAULT_RTC || l.name === LEGACY_RTC || names.has(l.name) || socketNames.has(l.name))
81
+ throw new Error(`Listener names must be unique ("${DEFAULT_RTC}" and "${LEGACY_RTC}" are built in)`);
82
+ l.signal = normalizeSignal(l.signal);
83
+ if (l.ice !== undefined && (!Array.isArray(l.ice) || l.ice.some(s => !s || (typeof s.urls !== "string" && !Array.isArray(s.urls))
84
+ || (s.username !== undefined && typeof s.username !== "string") || (s.credential !== undefined && typeof s.credential !== "string"))))
85
+ throw new Error(`WebRTC listener "${l.name}": ice must be a list of {urls, username?, credential?}`);
86
+ delete l.builtin;
87
+ names.add(l.name);
88
+ }
89
+ }
13
90
  const MAX_RETIRED_PORTS = 32;
14
91
  export const LISTENERS_FILE = join(homedir(), ".rechrome", "listeners.json");
15
92
  export const isLoopback = (host: string) => host === "::1" || /^127\./.test(host);
@@ -63,10 +140,12 @@ export function validateListeners(value: unknown): ListenerConfig {
63
140
  if (l.uploads !== undefined && l.uploads !== false) validateUploadPolicy(l.uploads);
64
141
  if (l.readSecrets !== undefined && typeof l.readSecrets !== "boolean") throw new Error("readSecrets must be true or false");
65
142
  if (l.readCookies !== undefined && typeof l.readCookies !== "boolean") throw new Error("readCookies must be true or false");
143
+ if (l.keepDownloads !== undefined && typeof l.keepDownloads !== "boolean") throw new Error("keepDownloads must be true or false");
66
144
  const address = listenerAddress(l);
67
145
  if (names.has(l.name) || addresses.has(address) || keys.has(l.key)) throw new Error("Listener names, addresses, and credentials must be unique");
68
146
  names.add(l.name); addresses.add(address); keys.add(l.key);
69
147
  }
148
+ validateRtcListeners(config.rtcListeners, names);
70
149
  if (config.retiredPorts !== undefined && (!Array.isArray(config.retiredPorts) || config.retiredPorts.some(p => !Number.isInteger(p) || p < 1 || p > 65535)))
71
150
  throw new Error("listeners.json retiredPorts must be a list of ports");
72
151
  return config;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rechrome",
3
- "version": "1.44.0",
3
+ "version": "1.46.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/rechrome.git"