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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e49675a00a9bbeda3f2e8e9df0dd625535de48ed2cf1dbe2efd6ce2bd6c6975d
4
- data.tar.gz: 6ee2c5cf2063f6099473e51b8bd1b60a93c738c80136b605807dfd6d89095e2e
3
+ metadata.gz: b3a05ca6c42372f257d9fdcbd1ff0e7b2becbceb64e533e671ab5b9b222f7fff
4
+ data.tar.gz: 6905e9cc0e07e0cf221d0a9219b9ddcdbe43ddfee67d844d5d7d2824f92291c6
5
5
  SHA512:
6
- metadata.gz: 24efd93b6713e1d50497367a1c532e784284ff18003b5204adc5f99bf2ad9cc0d34c8ef1815539957a1b06e24ff7d64880966a83a43ea7cf40aa357d526456ca
7
- data.tar.gz: e9820167a09013b4721c584738b2b480f6054dfcbb9fb2417d83ec07158e6359c05ba364a8d8e905b5e5b1a044f97e938917852ac5c4e68f368eda84198fa421
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
@@ -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
@@ -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