ruby_everywhere 0.1.7 → 0.1.8
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.
- checksums.yaml +4 -4
- data/lib/everywhere/blake2b.rb +116 -0
- data/lib/everywhere/cli.rb +4 -0
- data/lib/everywhere/commands/publish.rb +267 -0
- data/lib/everywhere/commands/updates_keygen.rb +69 -0
- data/lib/everywhere/config.rb +38 -1
- data/lib/everywhere/javascript/bridge.js +75 -0
- data/lib/everywhere/minisign.rb +205 -0
- data/lib/everywhere/s3.rb +232 -0
- data/lib/everywhere/update_manifest.rb +115 -0
- data/lib/everywhere/version.rb +2 -2
- data/support/shell/src-tauri/Cargo.lock +123 -1
- data/support/shell/src-tauri/Cargo.toml +7 -1
- data/support/shell/src-tauri/src/main.rs +16 -0
- data/support/shell/src-tauri/src/updater.rs +650 -0
- data/support/shell/src-tauri/tauri.conf.json +1 -1
- metadata +23 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b3a05ca6c42372f257d9fdcbd1ff0e7b2becbceb64e533e671ab5b9b222f7fff
|
|
4
|
+
data.tar.gz: 6905e9cc0e07e0cf221d0a9219b9ddcdbe43ddfee67d844d5d7d2824f92291c6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 755a57a26107f6f6f95d0ef30b14b9f83d67d172ac4aba0fbff629326e4db013b6aa094d16b558b9e84b147d8faff6e93a524df53b0976e5282e156026a87cd1
|
|
7
|
+
data.tar.gz: 1ab5013257d9d4716c8ea27a23ada758474be29dae9cb34d204925dde1cd82fe3046faf10e92cd18242507c6cb7c911bdc3bea9d2dd358507389a4d3584dcf18
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Everywhere
|
|
4
|
+
# Pure-Ruby BLAKE2b (RFC 7693), unkeyed, variable digest length.
|
|
5
|
+
#
|
|
6
|
+
# OpenSSL only exposes the fixed BLAKE2b512 digest, but minisign's secret-key
|
|
7
|
+
# checksum is BLAKE2b-256 — and a 2b-256 digest is NOT a truncation of 2b-512
|
|
8
|
+
# (the output length is part of the parameter block). This implementation
|
|
9
|
+
# covers any length; hot paths (hashing a whole artifact) should still prefer
|
|
10
|
+
# `Blake2b.digest64`, which uses OpenSSL when available and only falls back
|
|
11
|
+
# here.
|
|
12
|
+
module Blake2b
|
|
13
|
+
MASK = 0xFFFFFFFFFFFFFFFF
|
|
14
|
+
|
|
15
|
+
IV = [
|
|
16
|
+
0x6a09e667f3bcc908, 0xbb67ae8584caa73b, 0x3c6ef372fe94f82b, 0xa54ff53a5f1d36f1,
|
|
17
|
+
0x510e527fade682d1, 0x9b05688c2b3e6c1f, 0x1f83d9abfb41bd6b, 0x5be0cd19137e2179
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
SIGMA = [
|
|
21
|
+
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
|
22
|
+
[14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3],
|
|
23
|
+
[11, 8, 12, 0, 5, 2, 15, 13, 10, 14, 3, 6, 7, 1, 9, 4],
|
|
24
|
+
[7, 9, 3, 1, 13, 12, 11, 14, 2, 6, 5, 10, 4, 0, 15, 8],
|
|
25
|
+
[9, 0, 5, 7, 2, 4, 10, 15, 14, 1, 11, 12, 6, 8, 3, 13],
|
|
26
|
+
[2, 12, 6, 10, 0, 11, 8, 3, 4, 13, 7, 5, 15, 14, 1, 9],
|
|
27
|
+
[12, 5, 1, 15, 14, 13, 4, 10, 0, 7, 6, 3, 9, 2, 8, 11],
|
|
28
|
+
[13, 11, 7, 14, 12, 1, 3, 9, 5, 0, 15, 4, 8, 6, 2, 10],
|
|
29
|
+
[6, 15, 14, 9, 11, 3, 0, 8, 12, 2, 13, 7, 1, 4, 10, 5],
|
|
30
|
+
[10, 2, 8, 4, 7, 6, 1, 5, 15, 11, 9, 14, 3, 12, 13, 0]
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
def digest(data, outlen)
|
|
36
|
+
raise ArgumentError, "digest length must be 1..64" unless (1..64).cover?(outlen)
|
|
37
|
+
|
|
38
|
+
data = data.to_s.b
|
|
39
|
+
h = IV.dup
|
|
40
|
+
h[0] ^= 0x01010000 ^ outlen # param block word 0: digest_length, fanout=1, depth=1
|
|
41
|
+
|
|
42
|
+
if data.empty?
|
|
43
|
+
compress(h, ("\0" * 128).b, 0, true)
|
|
44
|
+
else
|
|
45
|
+
blocks = (data.bytesize + 127) / 128
|
|
46
|
+
blocks.times do |i|
|
|
47
|
+
block = data.byteslice(i * 128, 128)
|
|
48
|
+
if i == blocks - 1
|
|
49
|
+
compress(h, block.ljust(128, "\0"), data.bytesize, true)
|
|
50
|
+
else
|
|
51
|
+
compress(h, block, (i + 1) * 128, false)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
h.pack("Q<8").byteslice(0, outlen)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def hexdigest(data, outlen) = digest(data, outlen).unpack1("H*")
|
|
60
|
+
|
|
61
|
+
# 64-byte BLAKE2b of a file — minisign's prehash. Streams through OpenSSL's
|
|
62
|
+
# BLAKE2b512 when the linked OpenSSL provides it (it matches RFC 7693 with
|
|
63
|
+
# outlen 64), else falls back to the pure-Ruby path.
|
|
64
|
+
def digest64_file(path)
|
|
65
|
+
require "openssl"
|
|
66
|
+
md = begin
|
|
67
|
+
OpenSSL::Digest.new("BLAKE2b512")
|
|
68
|
+
rescue StandardError
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
return digest(File.binread(path), 64) unless md
|
|
72
|
+
|
|
73
|
+
File.open(path, "rb") do |io|
|
|
74
|
+
while (chunk = io.read(1 << 20))
|
|
75
|
+
md << chunk
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
md.digest
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def compress(h, block, t, last)
|
|
82
|
+
m = block.unpack("Q<16")
|
|
83
|
+
v = h + IV
|
|
84
|
+
v[12] ^= t & MASK
|
|
85
|
+
v[13] ^= (t >> 64) & MASK
|
|
86
|
+
v[14] ^= MASK if last
|
|
87
|
+
|
|
88
|
+
12.times do |round|
|
|
89
|
+
s = SIGMA[round % 10]
|
|
90
|
+
mix(v, 0, 4, 8, 12, m[s[0]], m[s[1]])
|
|
91
|
+
mix(v, 1, 5, 9, 13, m[s[2]], m[s[3]])
|
|
92
|
+
mix(v, 2, 6, 10, 14, m[s[4]], m[s[5]])
|
|
93
|
+
mix(v, 3, 7, 11, 15, m[s[6]], m[s[7]])
|
|
94
|
+
mix(v, 0, 5, 10, 15, m[s[8]], m[s[9]])
|
|
95
|
+
mix(v, 1, 6, 11, 12, m[s[10]], m[s[11]])
|
|
96
|
+
mix(v, 2, 7, 8, 13, m[s[12]], m[s[13]])
|
|
97
|
+
mix(v, 3, 4, 9, 14, m[s[14]], m[s[15]])
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
8.times { |i| h[i] ^= v[i] ^ v[i + 8] }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def mix(v, a, b, c, d, x, y)
|
|
104
|
+
v[a] = (v[a] + v[b] + x) & MASK
|
|
105
|
+
v[d] = rotr(v[d] ^ v[a], 32)
|
|
106
|
+
v[c] = (v[c] + v[d]) & MASK
|
|
107
|
+
v[b] = rotr(v[b] ^ v[c], 24)
|
|
108
|
+
v[a] = (v[a] + v[b] + y) & MASK
|
|
109
|
+
v[d] = rotr(v[d] ^ v[a], 16)
|
|
110
|
+
v[c] = (v[c] + v[d]) & MASK
|
|
111
|
+
v[b] = rotr(v[b] ^ v[c], 63)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def rotr(x, n) = ((x >> n) | ((x << (64 - n)) & MASK)) & MASK
|
|
115
|
+
end
|
|
116
|
+
end
|
data/lib/everywhere/cli.rb
CHANGED
|
@@ -10,6 +10,8 @@ require_relative "commands/icon"
|
|
|
10
10
|
require_relative "commands/doctor"
|
|
11
11
|
require_relative "commands/install"
|
|
12
12
|
require_relative "commands/release"
|
|
13
|
+
require_relative "commands/publish"
|
|
14
|
+
require_relative "commands/updates_keygen"
|
|
13
15
|
require_relative "commands/shell_dir"
|
|
14
16
|
require_relative "commands/platform/login"
|
|
15
17
|
require_relative "commands/platform/logout"
|
|
@@ -39,6 +41,8 @@ module Everywhere
|
|
|
39
41
|
register "clean", Commands::Clean
|
|
40
42
|
register "icon", Commands::Icon
|
|
41
43
|
register "release", Commands::Release
|
|
44
|
+
register "publish", Commands::Publish
|
|
45
|
+
register "updates keygen", Commands::UpdatesKeygen
|
|
42
46
|
register "shell-dir", Commands::ShellDir
|
|
43
47
|
|
|
44
48
|
register "platform login", Commands::PlatformLogin
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/cli"
|
|
4
|
+
require "json"
|
|
5
|
+
require "digest"
|
|
6
|
+
require_relative "../minisign"
|
|
7
|
+
require_relative "../s3"
|
|
8
|
+
require_relative "../update_manifest"
|
|
9
|
+
require_relative "../paths"
|
|
10
|
+
require_relative "../ui"
|
|
11
|
+
|
|
12
|
+
module Everywhere
|
|
13
|
+
module Commands
|
|
14
|
+
# Publish a released build to the app's update bucket — the free,
|
|
15
|
+
# self-hosted half of auto-updates. Reads the receipt `every release`
|
|
16
|
+
# wrote, signs the artifact, uploads it plus the manifests to any
|
|
17
|
+
# S3-compatible bucket, and cuts latest.json over LAST so shipped apps
|
|
18
|
+
# only ever see a complete release.
|
|
19
|
+
#
|
|
20
|
+
# Bucket creds come from the standard AWS env vars; endpoint/bucket/etc.
|
|
21
|
+
# from everywhere.yml's updates.s3 section. See UpdateManifest for the
|
|
22
|
+
# layout/JSON contract.
|
|
23
|
+
class Publish < Dry::CLI::Command
|
|
24
|
+
desc "Publish a released build to your update bucket (self-hosted auto-updates)"
|
|
25
|
+
|
|
26
|
+
option :root, default: ".", desc: "App root directory"
|
|
27
|
+
option :receipt, default: nil, desc: "Build receipt (default: dist/release.json)"
|
|
28
|
+
option :channel, default: nil, desc: "Release channel (default: updates.channel, then stable)"
|
|
29
|
+
option :notes, default: nil, desc: "Release notes shown to users"
|
|
30
|
+
option :notes_file, default: nil, desc: "Read release notes from a file"
|
|
31
|
+
option :key, default: nil, desc: "Minisign secret key path (default: ~/.rubyeverywhere/keys/<bundle_id>.key)"
|
|
32
|
+
option :rollback_to, default: nil, desc: "Re-point latest.json at an already-published version"
|
|
33
|
+
option :latest_alias, type: :boolean, default: true, desc: "Maintain the App-latest.zip download alias"
|
|
34
|
+
option :force, type: :boolean, default: false, desc: "Overwrite a differing already-published version"
|
|
35
|
+
|
|
36
|
+
def call(root: ".", receipt: nil, channel: nil, notes: nil, notes_file: nil, key: nil,
|
|
37
|
+
rollback_to: nil, latest_alias: true, force: false, **)
|
|
38
|
+
@root = File.expand_path(root)
|
|
39
|
+
@config = Everywhere::Config.load(@root)
|
|
40
|
+
@channel = channel || @config.updates_channel
|
|
41
|
+
preflight!
|
|
42
|
+
|
|
43
|
+
if rollback_to
|
|
44
|
+
rollback!(rollback_to, latest_alias: latest_alias)
|
|
45
|
+
else
|
|
46
|
+
publish!(receipt_path(receipt), notes: read_notes(notes, notes_file),
|
|
47
|
+
key: key, latest_alias: latest_alias, force: force)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# ---- publish -------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
def publish!(receipt_path, notes:, key:, latest_alias:, force:)
|
|
56
|
+
receipt = load_receipt(receipt_path)
|
|
57
|
+
artifact_path = verify_artifact!(receipt, receipt_path)
|
|
58
|
+
os, arch = receipt.dig("target", "os"), receipt.dig("target", "arch")
|
|
59
|
+
version = receipt.dig("app", "version")
|
|
60
|
+
filename = versioned_filename(receipt)
|
|
61
|
+
loc = { channel: @channel, os: os, arch: arch, prefix: prefix }
|
|
62
|
+
|
|
63
|
+
UI.phase("Signing #{filename}")
|
|
64
|
+
signature = sign!(artifact_path, key)
|
|
65
|
+
UI.ok("signed and self-verified against updates.public_key")
|
|
66
|
+
|
|
67
|
+
entry = UpdateManifest.entry(
|
|
68
|
+
receipt: receipt, channel: @channel, notes: notes, signature: signature,
|
|
69
|
+
url: "#{@config.updates_url}/#{@channel}/#{os}/#{arch}/#{filename}"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
UI.phase("Publishing #{version} to #{@channel}/#{os}/#{arch}")
|
|
73
|
+
upload_artifact!(artifact_path, filename, signature, loc, force: force)
|
|
74
|
+
maintain_alias!(filename, version, loc) if latest_alias
|
|
75
|
+
index = update_index!(entry, loc)
|
|
76
|
+
|
|
77
|
+
# The cutover: everything the manifest points at is durable before
|
|
78
|
+
# latest.json flips. A check mid-publish sees old-complete or
|
|
79
|
+
# new-complete, never a torn release.
|
|
80
|
+
s3.put_object(UpdateManifest.latest_key(**loc), UpdateManifest.to_json(entry),
|
|
81
|
+
content_type: "application/json", cache_control: UpdateManifest::MUTABLE_CACHE)
|
|
82
|
+
UI.ok("latest.json → #{version}")
|
|
83
|
+
|
|
84
|
+
summarize(entry, index, loc)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def upload_artifact!(path, filename, signature, loc, force:)
|
|
88
|
+
key = UpdateManifest.artifact_key(filename, **loc)
|
|
89
|
+
size = File.size(path)
|
|
90
|
+
|
|
91
|
+
if (head = s3.head_object(key))
|
|
92
|
+
if head[:size] == size && !force
|
|
93
|
+
UI.step("#{filename} already published (same size) — skipping upload")
|
|
94
|
+
s3.put_object("#{key}.minisig", signature, content_type: "text/plain",
|
|
95
|
+
cache_control: UpdateManifest::IMMUTABLE_CACHE)
|
|
96
|
+
return
|
|
97
|
+
elsif !force
|
|
98
|
+
UI.die!("#{key} already exists with different content (#{head[:size]} vs #{size} bytes) — " \
|
|
99
|
+
"bump app.version, or pass --force if you really mean to replace a published artifact")
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
UI.step("uploading #{filename} #{UI.dim("(#{(size / 1024.0 / 1024.0).round(1)}MB)")}")
|
|
104
|
+
File.open(path, "rb") do |io|
|
|
105
|
+
s3.put_object(key, io, size: size, content_type: content_type(filename),
|
|
106
|
+
cache_control: UpdateManifest::IMMUTABLE_CACHE)
|
|
107
|
+
end
|
|
108
|
+
s3.put_object("#{key}.minisig", signature, content_type: "text/plain",
|
|
109
|
+
cache_control: UpdateManifest::IMMUTABLE_CACHE)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def maintain_alias!(filename, version, loc)
|
|
113
|
+
aliased = UpdateManifest.latest_alias(filename, version)
|
|
114
|
+
return unless aliased
|
|
115
|
+
|
|
116
|
+
s3.copy_object(UpdateManifest.artifact_key(filename, **loc),
|
|
117
|
+
UpdateManifest.artifact_key(aliased, **loc),
|
|
118
|
+
content_type: content_type(filename), cache_control: UpdateManifest::MUTABLE_CACHE)
|
|
119
|
+
UI.substep("#{aliased} alias refreshed")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def update_index!(entry, loc)
|
|
123
|
+
index = UpdateManifest.index_append(s3.get_object(UpdateManifest.index_key(**loc)), entry)
|
|
124
|
+
s3.put_object(UpdateManifest.index_key(**loc), index,
|
|
125
|
+
content_type: "application/json", cache_control: UpdateManifest::MUTABLE_CACHE)
|
|
126
|
+
index
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# ---- rollback ------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
def rollback!(version, latest_alias:)
|
|
132
|
+
# os/arch come from the receipt on publish; on rollback there may be no
|
|
133
|
+
# dist/, so take the target from build.targets (or the default).
|
|
134
|
+
target = Array(@config.targets).first || "macos-arm64"
|
|
135
|
+
os, arch = target.split("-", 2)
|
|
136
|
+
loc = { channel: @channel, os: os, arch: arch, prefix: prefix }
|
|
137
|
+
|
|
138
|
+
UI.phase("Rolling back #{@channel}/#{os}/#{arch} to #{version}")
|
|
139
|
+
entry = UpdateManifest.index_find(s3.get_object(UpdateManifest.index_key(**loc)), version)
|
|
140
|
+
UI.die!("version #{version} was never published to #{@channel}/#{os}/#{arch} (not in index.json)") unless entry
|
|
141
|
+
|
|
142
|
+
filename = File.basename(entry.fetch("url"))
|
|
143
|
+
key = UpdateManifest.artifact_key(filename, **loc)
|
|
144
|
+
UI.die!("#{key} is gone from the bucket — can't roll back to it") unless s3.head_object(key)
|
|
145
|
+
|
|
146
|
+
s3.put_object(UpdateManifest.latest_key(**loc), UpdateManifest.to_json(entry),
|
|
147
|
+
content_type: "application/json", cache_control: UpdateManifest::MUTABLE_CACHE)
|
|
148
|
+
maintain_alias!(filename, version, loc) if latest_alias
|
|
149
|
+
|
|
150
|
+
UI.ok("latest.json → #{version}")
|
|
151
|
+
UI.warn("apps still on newer #{entry["app"] ? entry.dig("app", "name") : "builds"} won't downgrade — " \
|
|
152
|
+
"ship a higher-versioned release to move everyone off the bad build")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# ---- pieces --------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
def preflight!
|
|
158
|
+
UI.die!("no updates.url in everywhere.yml — add an updates: section (see `every updates keygen`)") unless @config.updates_url
|
|
159
|
+
UI.die!("no updates.public_key in everywhere.yml — run `every updates keygen`") unless @config.updates_public_key
|
|
160
|
+
UI.die!("no updates.s3.bucket in everywhere.yml") if s3_config["bucket"].to_s.empty?
|
|
161
|
+
UI.die!("missing bucket credentials — set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY") if
|
|
162
|
+
ENV["AWS_ACCESS_KEY_ID"].to_s.empty? || ENV["AWS_SECRET_ACCESS_KEY"].to_s.empty?
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def receipt_path(given)
|
|
166
|
+
path = given ? File.expand_path(given, @root) : File.join(@root, "dist", "release.json")
|
|
167
|
+
UI.die!("no receipt at #{UI.short_path(path)} — run `every release` first") unless File.exist?(path)
|
|
168
|
+
path
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def load_receipt(path)
|
|
172
|
+
JSON.parse(File.read(path))
|
|
173
|
+
rescue JSON::ParserError => e
|
|
174
|
+
UI.die!("#{UI.short_path(path)} isn't valid JSON (#{e.message})")
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def verify_artifact!(receipt, receipt_path)
|
|
178
|
+
filename = receipt.dig("artifact", "filename") or UI.die!("receipt has no artifact record — re-run `every release`")
|
|
179
|
+
path = File.join(File.dirname(receipt_path), filename)
|
|
180
|
+
UI.die!("artifact #{UI.short_path(path)} not found next to the receipt") unless File.exist?(path)
|
|
181
|
+
|
|
182
|
+
expected = receipt.dig("artifact", "checksum").to_s.delete_prefix("sha256:")
|
|
183
|
+
actual = Digest::SHA256.file(path).hexdigest
|
|
184
|
+
UI.die!("#{filename} doesn't match its receipt (stale dist/? re-run `every release`)") unless actual == expected
|
|
185
|
+
path
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# "My-App.zip" + version -> "My-App-1.2.0.zip": the immutable bucket name.
|
|
189
|
+
def versioned_filename(receipt)
|
|
190
|
+
filename = receipt.dig("artifact", "filename")
|
|
191
|
+
version = receipt.dig("app", "version")
|
|
192
|
+
base = File.basename(filename, ".*")
|
|
193
|
+
ext = File.extname(filename)
|
|
194
|
+
base.end_with?("-#{version}") ? filename : "#{base}-#{version}#{ext}"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def sign!(path, key_option)
|
|
198
|
+
secret_key = signing_key(key_option)
|
|
199
|
+
signature = Minisign.sign_file(path, secret_key: secret_key, passphrase: passphrase)
|
|
200
|
+
begin
|
|
201
|
+
File.write("#{path}.minisig", signature)
|
|
202
|
+
Minisign.verify_file(path, signature: signature, public_key: @config.updates_public_key)
|
|
203
|
+
rescue Minisign::Error
|
|
204
|
+
UI.die!("signature doesn't verify against updates.public_key in everywhere.yml — " \
|
|
205
|
+
"the key you're signing with isn't the one shipped apps trust")
|
|
206
|
+
end
|
|
207
|
+
signature
|
|
208
|
+
rescue Minisign::PassphraseError
|
|
209
|
+
UI.die!("wrong or missing key passphrase — set EVERYWHERE_SIGNING_KEY_PASSPHRASE")
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def signing_key(key_option)
|
|
213
|
+
if key_option
|
|
214
|
+
UI.die!("no key file at #{key_option}") unless File.exist?(key_option)
|
|
215
|
+
return File.read(key_option)
|
|
216
|
+
end
|
|
217
|
+
if (path = ENV["EVERYWHERE_SIGNING_KEY_FILE"])
|
|
218
|
+
UI.die!("EVERYWHERE_SIGNING_KEY_FILE points at #{path}, which doesn't exist") unless File.exist?(path)
|
|
219
|
+
return File.read(path)
|
|
220
|
+
end
|
|
221
|
+
return ENV["EVERYWHERE_SIGNING_KEY"] if ENV["EVERYWHERE_SIGNING_KEY"]
|
|
222
|
+
|
|
223
|
+
default = File.join(Paths.cache_dir, "keys", "#{@config.bundle_id}.key")
|
|
224
|
+
UI.die!("no signing key found — run `every updates keygen` (looked for #{UI.short_path(default)})") unless File.exist?(default)
|
|
225
|
+
File.read(default)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def passphrase = ENV["EVERYWHERE_SIGNING_KEY_PASSPHRASE"] || ""
|
|
229
|
+
|
|
230
|
+
def s3_config = @config.updates_s3
|
|
231
|
+
|
|
232
|
+
def prefix = s3_config["prefix"]
|
|
233
|
+
|
|
234
|
+
def s3
|
|
235
|
+
@s3 ||= Everywhere::S3.new(
|
|
236
|
+
bucket: s3_config["bucket"],
|
|
237
|
+
region: s3_config["region"] || "auto",
|
|
238
|
+
endpoint: s3_config["endpoint"],
|
|
239
|
+
force_path_style: s3_config["force_path_style"],
|
|
240
|
+
access_key_id: ENV.fetch("AWS_ACCESS_KEY_ID"),
|
|
241
|
+
secret_access_key: ENV.fetch("AWS_SECRET_ACCESS_KEY"),
|
|
242
|
+
session_token: ENV["AWS_SESSION_TOKEN"]
|
|
243
|
+
)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def content_type(filename)
|
|
247
|
+
filename.end_with?(".zip") ? "application/zip" : "application/octet-stream"
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def read_notes(notes, notes_file)
|
|
251
|
+
return notes if notes
|
|
252
|
+
return File.read(File.expand_path(notes_file, @root)).strip if notes_file
|
|
253
|
+
|
|
254
|
+
nil
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def summarize(entry, index, loc)
|
|
258
|
+
entries = JSON.parse(index)["entries"].length
|
|
259
|
+
UI.phase("Published 🎉")
|
|
260
|
+
UI.success("#{entry["app"]["name"]} #{entry["version"]} is live on the #{UI.bold(@channel)} channel")
|
|
261
|
+
UI.substep("feed #{UI.dim("#{@config.updates_url}/#{UpdateManifest.latest_key(**loc.merge(prefix: nil))}")}")
|
|
262
|
+
UI.substep("#{entries} version#{"s" if entries != 1} in the index · shipped apps pick this up within " \
|
|
263
|
+
"#{@config.updates_interval / 60} minutes")
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/cli"
|
|
4
|
+
require_relative "../minisign"
|
|
5
|
+
require_relative "../paths"
|
|
6
|
+
require_relative "../ui"
|
|
7
|
+
|
|
8
|
+
module Everywhere
|
|
9
|
+
module Commands
|
|
10
|
+
# Mint the minisign-compatible keypair that signs this app's updates.
|
|
11
|
+
# Secret key lands in ~/.rubyeverywhere/keys/<bundle_id>.key (never in the
|
|
12
|
+
# app repo); the public key goes into everywhere.yml's updates.public_key,
|
|
13
|
+
# where the build bakes it into the shell for verification.
|
|
14
|
+
class UpdatesKeygen < Dry::CLI::Command
|
|
15
|
+
desc "Generate an update-signing keypair (minisign-compatible)"
|
|
16
|
+
|
|
17
|
+
option :root, default: ".", desc: "App root directory"
|
|
18
|
+
option :force, type: :boolean, default: false, desc: "Overwrite an existing keypair"
|
|
19
|
+
option :passphrase, type: :boolean, default: false, desc: "Encrypt the secret key with a passphrase (prompted)"
|
|
20
|
+
|
|
21
|
+
def call(root: ".", force: false, passphrase: false, **)
|
|
22
|
+
config = Everywhere::Config.load(File.expand_path(root))
|
|
23
|
+
bundle_id = config.bundle_id
|
|
24
|
+
keys_dir = File.join(Paths.cache_dir, "keys")
|
|
25
|
+
sk_path = File.join(keys_dir, "#{bundle_id}.key")
|
|
26
|
+
pub_path = File.join(keys_dir, "#{bundle_id}.pub")
|
|
27
|
+
|
|
28
|
+
if File.exist?(sk_path) && !force
|
|
29
|
+
UI.die!("a signing key for #{bundle_id} already exists at #{UI.short_path(sk_path)} — " \
|
|
30
|
+
"pass --force to overwrite it (shipped apps signed with the old key will REFUSE updates signed with a new one)")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
UI.phase("Generating update signing key for #{bundle_id}")
|
|
34
|
+
pair = Minisign.keygen(passphrase: passphrase ? prompt_passphrase : "")
|
|
35
|
+
|
|
36
|
+
require "fileutils"
|
|
37
|
+
FileUtils.mkdir_p(keys_dir, mode: 0o700)
|
|
38
|
+
File.write(sk_path, pair.secret_key, perm: 0o600)
|
|
39
|
+
File.write(pub_path, pair.public_key, perm: 0o644)
|
|
40
|
+
|
|
41
|
+
UI.ok("secret key #{UI.short_path(sk_path)} #{UI.dim("(key id #{pair.key_id_hex})")}")
|
|
42
|
+
UI.ok("public key #{UI.short_path(pub_path)}")
|
|
43
|
+
|
|
44
|
+
UI.phase("Add the public key to config/everywhere.yml")
|
|
45
|
+
puts <<~YAML
|
|
46
|
+
|
|
47
|
+
updates:
|
|
48
|
+
url: https://your-bucket-or-cdn.example.com/#{config.name.downcase.gsub(/[^a-z0-9]+/, "-")}
|
|
49
|
+
public_key: "#{Minisign.public_key_base64(pair.public_key)}"
|
|
50
|
+
|
|
51
|
+
YAML
|
|
52
|
+
UI.substep("back up the secret key somewhere safe — losing it strands every shipped app on its current version")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def prompt_passphrase
|
|
58
|
+
require "io/console"
|
|
59
|
+
UI.die!("--passphrase needs an interactive terminal") unless $stdin.tty?
|
|
60
|
+
|
|
61
|
+
pass = $stdin.getpass("Passphrase: ")
|
|
62
|
+
confirm = $stdin.getpass("Passphrase (again): ")
|
|
63
|
+
UI.die!("passphrases didn't match") unless pass == confirm
|
|
64
|
+
UI.die!("empty passphrase — rerun without --passphrase for an unencrypted key") if pass.empty?
|
|
65
|
+
pass
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
data/lib/everywhere/config.rb
CHANGED
|
@@ -109,6 +109,42 @@ module Everywhere
|
|
|
109
109
|
@data.dig("remote", "url")&.chomp("/")
|
|
110
110
|
end
|
|
111
111
|
|
|
112
|
+
# The `updates:` section — self-hosted auto-updates. Two halves:
|
|
113
|
+
# a client half (url/channel/public_key/auto/interval) that ships to the
|
|
114
|
+
# shell, and a publish-side `s3:` half that NEVER leaves the dev machine.
|
|
115
|
+
# `channel` here is a *release channel* (stable/beta) — not the receipt's
|
|
116
|
+
# `distribution_channel` (direct vs app stores).
|
|
117
|
+
def updates = @data.fetch("updates", nil) || {}
|
|
118
|
+
|
|
119
|
+
def updates_s3 = updates.fetch("s3", nil) || {}
|
|
120
|
+
|
|
121
|
+
def updates_channel = updates["channel"] || "stable"
|
|
122
|
+
|
|
123
|
+
def updates_url = updates["url"]&.chomp("/")
|
|
124
|
+
|
|
125
|
+
def updates_public_key = updates["public_key"]
|
|
126
|
+
|
|
127
|
+
# off — never check; check — check + notify (default); download — also
|
|
128
|
+
# fetch/verify in the background; install — silent auto-update.
|
|
129
|
+
def updates_auto = updates["auto"] || "check"
|
|
130
|
+
|
|
131
|
+
def updates_interval = [(updates["interval"] || 21_600).to_i, 300].max
|
|
132
|
+
|
|
133
|
+
# The client subset for the shell; nil (and thus absent from
|
|
134
|
+
# everywhere.json) until both url and public_key are configured — an
|
|
135
|
+
# unsigned update feed is not a thing.
|
|
136
|
+
def updates_shell_hash
|
|
137
|
+
return nil unless updates_url && updates_public_key
|
|
138
|
+
|
|
139
|
+
{
|
|
140
|
+
"url" => updates_url,
|
|
141
|
+
"channel" => updates_channel,
|
|
142
|
+
"public_key" => updates_public_key,
|
|
143
|
+
"auto" => updates_auto,
|
|
144
|
+
"interval" => updates_interval
|
|
145
|
+
}
|
|
146
|
+
end
|
|
147
|
+
|
|
112
148
|
def tint_color
|
|
113
149
|
normalize_color(appearance["tint_color"])
|
|
114
150
|
end
|
|
@@ -172,7 +208,8 @@ module Everywhere
|
|
|
172
208
|
"tint_color" => tint_color,
|
|
173
209
|
"background_color" => background_color,
|
|
174
210
|
"menu" => (menu unless menu.empty?),
|
|
175
|
-
"tray" => (tray unless tray.empty?)
|
|
211
|
+
"tray" => (tray unless tray.empty?),
|
|
212
|
+
"updates" => updates_shell_hash
|
|
176
213
|
}.compact
|
|
177
214
|
end
|
|
178
215
|
|
|
@@ -10,10 +10,20 @@
|
|
|
10
10
|
// Everywhere.platform // "desktop" | "mobile" | "browser"
|
|
11
11
|
// Everywhere.os // "macos" | "windows" | "linux" | "ios" | "android" | "chromeos" | "unknown"
|
|
12
12
|
// Everywhere.native // true inside a RubyEverywhere app
|
|
13
|
+
// Everywhere.version // app version (everywhere.yml app.version); null in a plain browser tab
|
|
13
14
|
// Everywhere.notify({ title, body }) // native / web Notification / console
|
|
14
15
|
// Everywhere.confirm("Sure?") // Promise<boolean>, native dialog when possible
|
|
15
16
|
// Everywhere.on("menu", handler) // shell events; returns unsubscribe fn
|
|
16
17
|
// Everywhere.visit("/settings") // Turbo.visit with location fallback
|
|
18
|
+
//
|
|
19
|
+
// Everywhere.updates.supported // true when the shell has an update feed
|
|
20
|
+
// Everywhere.updates.check() // Promise<{available, version?, notes?, notesHtml?}>
|
|
21
|
+
// Everywhere.updates.install() // download/verify/swap/relaunch
|
|
22
|
+
// Everywhere.updates.on("available" | "none" | "progress" | "ready" | "error", handler)
|
|
23
|
+
//
|
|
24
|
+
// notes is the markdown source; notesHtml is the same notes pre-rendered to
|
|
25
|
+
// HTML (from the signed update feed — your own content), ready for a
|
|
26
|
+
// changelog modal: el.innerHTML = notesHtml.
|
|
17
27
|
|
|
18
28
|
const config =
|
|
19
29
|
(typeof window !== "undefined" && window.__EVERYWHERE_CONFIG__) || {}
|
|
@@ -74,6 +84,32 @@ const desktop = {
|
|
|
74
84
|
|
|
75
85
|
clipboardRead() {
|
|
76
86
|
return window.__TAURI__.clipboardManager.readText()
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
// Ask the shell to check its update feed; resolve off whichever update
|
|
90
|
+
// event answers first. The shell always replies to an explicit check
|
|
91
|
+
// (available / none / error), the timeout is just a safety net.
|
|
92
|
+
updatesCheck() {
|
|
93
|
+
return new Promise((resolve, reject) => {
|
|
94
|
+
const offs = []
|
|
95
|
+
let timer = null
|
|
96
|
+
const settle = (fn) => (payload) => {
|
|
97
|
+
offs.forEach((off) => off())
|
|
98
|
+
clearTimeout(timer)
|
|
99
|
+
fn(payload)
|
|
100
|
+
}
|
|
101
|
+
offs.push(this.on("update-available", settle((p) =>
|
|
102
|
+
resolve({ available: true, version: p.version,
|
|
103
|
+
notes: p.notes ?? null, notesHtml: p.notes_html ?? null }))))
|
|
104
|
+
offs.push(this.on("update-none", settle(() => resolve({ available: false }))))
|
|
105
|
+
offs.push(this.on("update-error", settle((p) => reject(new Error(p.message)))))
|
|
106
|
+
timer = setTimeout(settle(() => resolve({ available: false, timedOut: true })), 30000)
|
|
107
|
+
window.__TAURI__.event.emit("everywhere:update-check", {})
|
|
108
|
+
})
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
updatesInstall() {
|
|
112
|
+
return window.__TAURI__.event.emit("everywhere:update-install", {})
|
|
77
113
|
}
|
|
78
114
|
}
|
|
79
115
|
|
|
@@ -114,6 +150,16 @@ const browser = {
|
|
|
114
150
|
try { return await navigator.clipboard.readText() } catch (_) { return null }
|
|
115
151
|
}
|
|
116
152
|
return null
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
// Auto-updates only exist inside the shell; the browser tab is always "current".
|
|
156
|
+
updatesCheck() {
|
|
157
|
+
return Promise.resolve({ available: false, unsupported: true })
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
updatesInstall() {
|
|
161
|
+
console.log("[everywhere] updates unavailable in the browser")
|
|
162
|
+
return Promise.resolve()
|
|
117
163
|
}
|
|
118
164
|
}
|
|
119
165
|
|
|
@@ -140,6 +186,11 @@ export const Everywhere = {
|
|
|
140
186
|
platform,
|
|
141
187
|
os,
|
|
142
188
|
|
|
189
|
+
// The running app's version (everywhere.yml app.version, injected by the
|
|
190
|
+
// shell) — e.g. an About page or footer badge. null in a plain browser tab,
|
|
191
|
+
// where the page came from a server, not a versioned bundle.
|
|
192
|
+
version: config.version || null,
|
|
193
|
+
|
|
143
194
|
get native() {
|
|
144
195
|
return platform !== "browser"
|
|
145
196
|
},
|
|
@@ -168,6 +219,30 @@ export const Everywhere = {
|
|
|
168
219
|
read() {
|
|
169
220
|
return adapter.clipboardRead()
|
|
170
221
|
}
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
updates: {
|
|
225
|
+
// True only when the shell actually has a feed to check (everywhere.yml
|
|
226
|
+
// updates: url + public_key made it into the shipped config).
|
|
227
|
+
get supported() {
|
|
228
|
+
return platform === "desktop" && !!config.updates
|
|
229
|
+
},
|
|
230
|
+
version: config.version || null,
|
|
231
|
+
channel: (config.updates && config.updates.channel) || null,
|
|
232
|
+
|
|
233
|
+
check() {
|
|
234
|
+
return adapter.updatesCheck()
|
|
235
|
+
},
|
|
236
|
+
|
|
237
|
+
// Fire-and-forget: progress arrives via on("progress"), completion as
|
|
238
|
+
// "ready", failures as "error"; on success the shell restarts the app.
|
|
239
|
+
install() {
|
|
240
|
+
return adapter.updatesInstall()
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
on(event, handler) {
|
|
244
|
+
return adapter.on(`update-${event}`, handler)
|
|
245
|
+
}
|
|
171
246
|
}
|
|
172
247
|
}
|
|
173
248
|
|