ruby_everywhere 0.0.1 → 0.1.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.
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/cli"
4
+ require "tmpdir"
5
+ require "fileutils"
6
+ require "shellwords"
7
+ require "base64"
8
+ require "json"
9
+ require "open3"
10
+ require_relative "../../ui"
11
+ require_relative "../../platform/client"
12
+
13
+ module Everywhere
14
+ module Commands
15
+ # `every platform runner` — runs ON a build runner (a GitHub Actions macOS
16
+ # machine, or any Mac). It's the glue between the Runner API and the
17
+ # `every release` signing engine:
18
+ #
19
+ # job spec → mark running → download+extract snapshot → pull JIT creds into
20
+ # a throwaway keychain (+ .p8) → every release (streaming logs) → upload the
21
+ # artifact + release.json → complete.
22
+ #
23
+ # Authenticated with the per-artifact runner token, so it can only touch the
24
+ # one artifact it was dispatched for.
25
+ class PlatformRunner < Dry::CLI::Command
26
+ desc "Run a Platform build on this machine (used by the hosted runner)"
27
+
28
+ option :artifact, required: true, desc: "Artifact public id (art_…)"
29
+ option :token, required: true, desc: "Per-artifact runner token"
30
+ option :url, default: nil, desc: "Platform base URL"
31
+ option :shell_dir, default: nil, desc: "Path to the generic Tauri shell (src-tauri)"
32
+
33
+ def call(artifact:, token:, url: nil, shell_dir: nil, **)
34
+ base = Everywhere::Platform::Client.base_url(url)
35
+ @client = Everywhere::Platform::Client.new(base, token: token)
36
+ @artifact = artifact
37
+
38
+ job = get!("/runner/artifacts/#{artifact}")
39
+ patch!("/runner/artifacts/#{artifact}", status: "running")
40
+ remote_log("runner: picked up #{job.dig("target", "os")}-#{job.dig("target", "arch")} for #{job.dig("app", "name")}")
41
+
42
+ Dir.mktmpdir("every-runner") do |work|
43
+ src = fetch_snapshot(work, job)
44
+ signing_env = pull_credentials(work)
45
+
46
+ status, release_json, zip = run_release(src, job, signing_env, shell_dir)
47
+
48
+ if status == "succeeded" && zip
49
+ complete_success(zip, release_json)
50
+ else
51
+ complete_failure("build failed — see log")
52
+ end
53
+ ensure
54
+ restore_keychains
55
+ end
56
+ rescue Everywhere::Error => e
57
+ complete_failure(e.message)
58
+ UI.die!(e.message)
59
+ end
60
+
61
+ private
62
+
63
+ # ---- pipeline steps ------------------------------------------------------
64
+
65
+ def fetch_snapshot(work, job)
66
+ remote_log("runner: downloading source snapshot")
67
+ tarball = File.join(work, "snapshot.tar.gz")
68
+ @client.download(job["snapshot_url"], tarball)
69
+ src = File.join(work, "src")
70
+ FileUtils.mkdir_p(src)
71
+ UI.die!("failed to extract snapshot") unless system("tar", "-xzf", tarball, "-C", src)
72
+ src
73
+ end
74
+
75
+ def pull_credentials(work)
76
+ remote_log("runner: pulling signing credentials")
77
+ creds = get!("/runner/artifacts/#{@artifact}/credentials")["credentials"] || []
78
+ env = {}
79
+ creds.each do |cred|
80
+ path = File.join(work, cred["filename"])
81
+ File.binwrite(path, Base64.strict_decode64(cred["data_base64"]))
82
+ File.chmod(0o600, path)
83
+ case cred["kind"]
84
+ when "apple_developer_id", "apple_distribution"
85
+ env["APPLE_SIGNING_IDENTITY"] = install_certificate(path, cred["metadata"] || {}, work)
86
+ when "apple_asc_api_key"
87
+ env["NOTARY_KEY"] = path
88
+ env["NOTARY_KEY_ID"] = cred.dig("metadata", "key_id")
89
+ env["NOTARY_ISSUER"] = cred.dig("metadata", "issuer_id")
90
+ end
91
+ end
92
+ env
93
+ end
94
+
95
+ # Import the .p12 into a throwaway keychain and add it to the search list so
96
+ # codesign finds the identity. Returns the identity name for notarize.sh.
97
+ def install_certificate(p12, metadata, work)
98
+ @keychain = File.join(work, "runner.keychain-db")
99
+ password = SecureRandom.hex(12)
100
+ @saved_keychains = `security list-keychains -d user`.scan(/"([^"]+)"/).flatten
101
+
102
+ run_security("create-keychain", "-p", password, @keychain)
103
+ run_security("set-keychain-settings", "-lut", "7200", @keychain)
104
+ run_security("unlock-keychain", "-p", password, @keychain)
105
+ run_security("import", p12, "-k", @keychain, "-P", metadata["password"].to_s, "-T", "/usr/bin/codesign")
106
+ run_security("list-keychains", "-d", "user", "-s", @keychain, *@saved_keychains)
107
+ run_security("set-key-partition-list", "-S", "apple-tool:,apple:,codesign:", "-s", "-k", password, @keychain)
108
+
109
+ # Sign by SHA-1, not name: the name can collide with other identities on
110
+ # the machine, and `-v` (valid-only) would hide ours when the Apple
111
+ # intermediate isn't in THIS keychain — codesign completes the chain from
112
+ # the full search list.
113
+ listing = `security find-identity -p codesigning #{@keychain.shellescape} 2>/dev/null`
114
+ sha = listing[/\b([0-9A-F]{40})\b/, 1]
115
+ UI.die!("no codesigning identity in the delivered .p12") unless sha
116
+ remote_log("runner: signing identity #{listing[/"(.+)"/, 1]} (#{sha[0, 10]}…)")
117
+ sha
118
+ end
119
+
120
+ def run_release(src, job, signing_env, shell_dir)
121
+ remote_log("runner: every release (press → sign → notarize → staple)")
122
+ cmd = [RbConfig.ruby, "-I", lib_dir, every_exe, "release",
123
+ "--root", src,
124
+ "--target", "#{job.dig("target", "os")}-#{job.dig("target", "arch")}",
125
+ "--channel", job.dig("target", "distribution_channel").to_s]
126
+ cmd += ["--shell-dir", shell_dir] if shell_dir
127
+
128
+ success = stream(signing_env, cmd)
129
+
130
+ dist = File.join(src, "dist")
131
+ release_json = JSON.parse(File.read(File.join(dist, "release.json"))) rescue nil
132
+ zip = Dir[File.join(dist, "*.zip")].max_by { |f| File.mtime(f) }
133
+ [success ? "succeeded" : "failed", release_json, zip]
134
+ end
135
+
136
+ def complete_success(zip, release_json)
137
+ remote_log("runner: uploading artifact (#{(File.size(zip) / 1024.0 / 1024.0).round(1)}MB)")
138
+ signed_id = upload_artifact(zip)
139
+ post!("/runner/artifacts/#{@artifact}/complete",
140
+ status: "succeeded", file_signed_id: signed_id, release_json: release_json)
141
+ UI.success("build succeeded — #{File.basename(zip)}")
142
+ end
143
+
144
+ def complete_failure(message)
145
+ return if @completed
146
+
147
+ @client.post("/runner/artifacts/#{@artifact}/complete", status: "failed", error: message)
148
+ @completed = true
149
+ end
150
+
151
+ def upload_artifact(zip)
152
+ require_relative "../../platform/snapshot"
153
+ reservation = post!("/runner/artifacts/#{@artifact}/upload",
154
+ filename: File.basename(zip), byte_size: File.size(zip),
155
+ checksum: Everywhere::Platform::Snapshot.md5_base64(zip), content_type: "application/zip")
156
+ du = reservation["direct_upload"] || {}
157
+ res = @client.put_file(du["url"], zip, headers: du["headers"] || {})
158
+ UI.die!("artifact upload failed (HTTP #{res.code})") unless res.code.to_i.between?(200, 299)
159
+ reservation["signed_id"]
160
+ end
161
+
162
+ # ---- log streaming -------------------------------------------------------
163
+
164
+ # Run cmd, echoing output to this process's stdout AND forwarding it to the
165
+ # Platform log in ~400-byte batches so the web UI tails it live.
166
+ def stream(env, cmd)
167
+ buffer = +""
168
+ status = nil
169
+ Open3.popen2e(env, *cmd) do |stdin, out, wait|
170
+ stdin.close
171
+ out.each_line do |line|
172
+ $stdout.print(line)
173
+ buffer << line
174
+ (flush_log(buffer); buffer = +"") if buffer.bytesize >= 400
175
+ end
176
+ flush_log(buffer) unless buffer.empty?
177
+ status = wait.value
178
+ end
179
+ status&.success?
180
+ end
181
+
182
+ def flush_log(text)
183
+ @client.post("/runner/artifacts/#{@artifact}/logs", text: text)
184
+ rescue Everywhere::Error
185
+ nil # never let a log hiccup kill the build
186
+ end
187
+
188
+ def remote_log(message)
189
+ UI.step(message)
190
+ flush_log("#{message}\n")
191
+ end
192
+
193
+ # ---- helpers -------------------------------------------------------------
194
+
195
+ def get!(path)
196
+ res = @client.get(path)
197
+ UI.die!("runner API #{path} failed (HTTP #{res.status})") unless res.ok?
198
+ res.body
199
+ end
200
+
201
+ def post!(path, body)
202
+ res = @client.post(path, body)
203
+ UI.die!("runner API #{path} failed (HTTP #{res.status})") unless res.ok?
204
+ res.body
205
+ end
206
+
207
+ def patch!(path, body)
208
+ @client.patch(path, body)
209
+ end
210
+
211
+ def run_security(*args)
212
+ system("security", *args, out: File::NULL, err: File::NULL)
213
+ end
214
+
215
+ def restore_keychains
216
+ run_security("list-keychains", "-d", "user", "-s", *@saved_keychains) if @saved_keychains
217
+ run_security("delete-keychain", @keychain) if @keychain && File.exist?(@keychain)
218
+ end
219
+
220
+ def lib_dir = File.expand_path("../../..", __dir__)
221
+ def every_exe = File.expand_path("../../../../exe/every", __dir__)
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/cli"
4
+ require_relative "../shellout"
5
+ require_relative "../receipt"
6
+ require_relative "build"
7
+
8
+ module Everywhere
9
+ module Commands
10
+ # Produce a signed, notarized, stapled, distributable artifact + release.json.
11
+ #
12
+ # `every release` wraps the existing signing engine
13
+ # (cli/support/macos/notarize.sh) rather than reimplementing
14
+ # codesign/notarytool, and emits the machine-readable receipt every build
15
+ # surface (local, CI, Platform) must produce. See
16
+ # platform/docs/build-engine.md §4, §7, §9.
17
+ #
18
+ # Today only the macos builder is live; the target is parameterized so
19
+ # ios/android/windows/linux slot in as builders later.
20
+ class Release < Dry::CLI::Command
21
+ NOTARIZE = File.expand_path("../../../support/macos/notarize.sh", __dir__)
22
+
23
+ desc "Sign, notarize, staple, and emit release.json for a distributable build"
24
+
25
+ option :root, default: ".", desc: "App root directory"
26
+ option :target, default: "macos-arm64", desc: "Build target (os-arch)"
27
+ option :channel, default: "direct", desc: "Distribution channel (direct | mac_app_store | ...)"
28
+ option :ruby, default: nil, desc: "Ruby version to package (default: build.ruby or 4.0.6)"
29
+ option :entry, default: "native_boot.rb", desc: "Entry point script relative to root"
30
+ option :app_name, default: nil, desc: "Bundle name (default from config)"
31
+ option :shell_dir, default: nil, desc: "Path to the shell's src-tauri directory"
32
+ option :env_file, default: nil, desc: "dotenv file with APPLE_* signing secrets (default: .env.release)"
33
+ option :skip_build, type: :boolean, default: false, desc: "Notarize the existing .app instead of rebuilding"
34
+
35
+ def call(root: ".", target: "macos-arm64", channel: "direct", ruby: nil, entry: "native_boot.rb",
36
+ app_name: nil, shell_dir: nil, env_file: nil, skip_build: false, **)
37
+ root_path = File.expand_path(root)
38
+ config = Everywhere::Config.load(root_path)
39
+ app_name ||= config.name
40
+ ruby ||= config.build_ruby || "4.0.6"
41
+
42
+ preflight!(target)
43
+
44
+ framework = config.remote? ? nil : Framework.detect(root_path)
45
+ dist_dir = File.expand_path("dist", root_path)
46
+
47
+ # 1. Produce (or reuse) the signed-for-dev .app via the build path. The
48
+ # notarize step below re-signs it inside-out with the Developer ID.
49
+ unless skip_build
50
+ UI.step("building #{UI.bold(app_name)} for #{UI.cyan(target)}")
51
+ Build.new.call(root: root, ruby: ruby, entry: entry, app_name: app_name, shell_dir: shell_dir, **)
52
+ end
53
+
54
+ app_dir = Dir[File.join(dist_dir, "*.app")].max_by { |d| File.mtime(d) }
55
+ UI.die!("no .app found in #{dist_dir} — run without --skip-build first") unless app_dir
56
+
57
+ # 2. Sign (Developer ID + hardened runtime) → notarize → staple → verify.
58
+ env_file ||= discover_env_file(root_path)
59
+ env = env_file ? { "ENV_FILE" => env_file } : {}
60
+ UI.step("signing + notarizing #{File.basename(app_dir)}#{env_file ? " #{UI.dim("(#{rel(env_file, root_path)})")}" : ""}")
61
+ UI.warn("no signing env found — set APPLE_* or pass --env-file (notarize.sh will fail without it)") unless env_file || ENV["APPLE_SIGNING_IDENTITY"]
62
+ log = "#{app_dir}.notarize.log"
63
+ Shellout.run_logged!(env, ["/bin/bash", NOTARIZE, app_dir], log: log)
64
+
65
+ # 3. Gather signing facts from the bundle itself, then emit the receipt.
66
+ artifact = "#{app_dir.sub(/\.app\z/, "")}.zip" # notarize.sh re-zips the stapled app
67
+ UI.die!("expected notarized zip #{artifact} not found") unless File.exist?(artifact)
68
+
69
+ signing = gather_signing(app_dir, log)
70
+ receipt = Receipt.new(root: root_path, config: config, framework: framework, ruby: ruby,
71
+ target: target, channel: channel, shell_dir: resolve_shell_dir(shell_dir))
72
+ out = File.join(dist_dir, "release.json")
73
+ receipt.write(out, artifact: artifact, signing: signing)
74
+
75
+ summarize(out, artifact, signing, root_path)
76
+ end
77
+
78
+ private
79
+
80
+ def preflight!(target)
81
+ os = target.split("-", 2).first
82
+ UI.die!("only the macos target is implemented (got #{target}); other builders are stubs") unless os == "macos"
83
+ UI.die!("`every release` runs on macOS only") unless RUBY_PLATFORM.include?("darwin")
84
+ UI.die!("signing engine missing at #{NOTARIZE}") unless File.exist?(NOTARIZE)
85
+ end
86
+
87
+ # A .env.release next to the app, then at the repo root above it.
88
+ def discover_env_file(root_path)
89
+ [ENV["ENV_FILE"], File.join(root_path, ".env.release"), File.join(root_path, "..", ".env.release")]
90
+ .compact.find { |p| File.exist?(p) }
91
+ end
92
+
93
+ def resolve_shell_dir(given)
94
+ return File.expand_path(given) if given
95
+
96
+ ["shell/src-tauri", "../shell/src-tauri", "src-tauri"]
97
+ .map { |c| File.expand_path(c) }
98
+ .find { |c| File.exist?(File.join(c, "tauri.conf.json")) }
99
+ end
100
+
101
+ # Read the truth back off the bundle rather than trusting the script's log:
102
+ # a valid staple proves notarization; codesign/spctl prove signed/accepted.
103
+ def gather_signing(app_dir, log)
104
+ stapled = probe("xcrun", "stapler", "validate", app_dir)
105
+ {
106
+ "signed" => probe("codesign", "--verify", "--deep", "--strict", app_dir),
107
+ "signed_by" => team_id(app_dir),
108
+ "verified" => probe("spctl", "--assess", "--type", "execute", app_dir),
109
+ "platform" => {
110
+ "notarized" => stapled,
111
+ "stapled" => stapled,
112
+ "notarization_id" => notarization_id(log)
113
+ }.compact
114
+ }
115
+ end
116
+
117
+ def probe(*cmd) = Shellout.capture(*cmd).last&.success? || false
118
+
119
+ def team_id(app_dir)
120
+ out, status = Shellout.capture("codesign", "-dvv", app_dir)
121
+ (status&.success? && out[/TeamIdentifier=(\w+)/, 1]) || ENV["APPLE_TEAM_ID"]
122
+ end
123
+
124
+ def notarization_id(log)
125
+ File.exist?(log) ? File.read(log)[/\bid:\s*([0-9a-fA-F-]{36})/, 1] : nil
126
+ end
127
+
128
+ def summarize(out, artifact, signing, root_path)
129
+ UI.success("release ready")
130
+ UI.step("artifact: #{rel(artifact, root_path)} (#{(File.size(artifact) / 1024.0 / 1024.0).round}MB)")
131
+ UI.step("signed by #{signing["signed_by"] || "?"} · " \
132
+ "notarized #{yn(signing.dig("platform", "notarized"))} · " \
133
+ "stapled #{yn(signing.dig("platform", "stapled"))} · " \
134
+ "gatekeeper #{yn(signing["verified"])}")
135
+ UI.step("receipt: #{rel(out, root_path)}")
136
+ end
137
+
138
+ def yn(bool) = bool ? UI.green("yes") : UI.red("no")
139
+
140
+ def rel(path, root_path) = path.sub("#{root_path}/", "").sub("#{Dir.pwd}/", "")
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Everywhere
6
+ # Loads config/everywhere.yml:
7
+ #
8
+ # app:
9
+ # name: My Really Awesome App
10
+ # entry_path: /dashboard # initial url on app launch
11
+ # appearance:
12
+ # tint_color: "#CC342D" # a hex string...
13
+ # background_color: # ...or split by system theme
14
+ # light: "#FAF9F7"
15
+ # dark: "#1C1B1A"
16
+ #
17
+ # Colors always normalize to { "light" => ..., "dark" => ... }.
18
+ class Config
19
+ FILE = File.join("config", "everywhere.yml")
20
+
21
+ def self.load(root = Dir.pwd)
22
+ path = File.join(root, FILE)
23
+ data = File.exist?(path) ? YAML.safe_load_file(path, aliases: true) || {} : {}
24
+ new(data, root)
25
+ end
26
+
27
+ attr_reader :root
28
+
29
+ def initialize(data, root)
30
+ @data = data
31
+ @root = root
32
+ end
33
+
34
+ def name
35
+ app["name"] || File.basename(File.expand_path(root)).split(/[-_]/).map(&:capitalize).join(" ")
36
+ end
37
+
38
+ # Reverse-DNS identifier: macOS bundle id, and the name of the per-user
39
+ # app-data directory on every platform. Set it explicitly and never change
40
+ # it — renaming moves users' data.
41
+ def bundle_id
42
+ app["bundle_id"] || "com.rubyeverywhere.#{name.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "")}"
43
+ end
44
+
45
+ # App icon source: a PNG (ideally square, 1024px+). Explicit `app.icon`
46
+ # path relative to the app root, or icon.png at the root by convention.
47
+ def icon
48
+ if (explicit = app["icon"])
49
+ File.expand_path(explicit, root)
50
+ else
51
+ default = File.join(root, "icon.png")
52
+ File.exist?(default) ? default : nil
53
+ end
54
+ end
55
+
56
+ # Optional custom splash page (HTML file, path relative to the app root).
57
+ # Shown while the packaged server boots; the shell injects
58
+ # window.__EVERYWHERE_CONFIG__ so it can use the app's name and colors.
59
+ def splash
60
+ path = app["splash"]
61
+ File.expand_path(path, root) if path
62
+ end
63
+
64
+ def entry_path
65
+ path = app["entry_path"] || "/"
66
+ path.start_with?("/") ? path : "/#{path}"
67
+ end
68
+
69
+ # Marketing/display version of the app (CFBundleShortVersionString, and the
70
+ # `version` recorded in the build receipt). Defaults conservatively.
71
+ def version
72
+ app["version"] || "0.1.0"
73
+ end
74
+
75
+ # The `build:` section — durable build knobs that were once `every build`
76
+ # flags (ruby, targets, permissions). CLI flags still override per-run.
77
+ # See platform/docs/build-engine.md §2.
78
+ def build = @data.fetch("build", nil) || {}
79
+
80
+ # Ruby version to package. nil here means "let the CLI decide its default".
81
+ def build_ruby = build["ruby"]
82
+
83
+ # OS-integration permissions the app declares. Recorded in the receipt and
84
+ # (later) used to generate the shell's capabilities.
85
+ def permissions = Array(build["permissions"])
86
+
87
+ # Build targets (os-arch strings) — the CI matrix, expressed once.
88
+ def targets = Array(build["targets"])
89
+
90
+ # "local" — the app is tebako-pressed and runs on-device (default)
91
+ # "remote" — thin shell around an already-deployed app: no press, no sidecar
92
+ def mode
93
+ app["mode"] || (remote_url ? "remote" : "local")
94
+ end
95
+
96
+ def remote? = mode == "remote"
97
+
98
+ def remote_url
99
+ @data.dig("remote", "url")&.chomp("/")
100
+ end
101
+
102
+ def tint_color
103
+ normalize_color(appearance["tint_color"])
104
+ end
105
+
106
+ def background_color
107
+ normalize_color(appearance["background_color"])
108
+ end
109
+
110
+ # Custom native menu items (macOS app menu). Each entry: label + path
111
+ # (+ optional accelerator), or "separator". Clicks arrive in the page as
112
+ # "everywhere:menu" events and navigate via Turbo.
113
+ def menu
114
+ entries = @data["menu"]
115
+ return [] unless entries.is_a?(Array)
116
+
117
+ entries.filter_map do |item|
118
+ if item == "separator" || (item.is_a?(Hash) && item["separator"])
119
+ { "separator" => true }
120
+ elsif item.is_a?(Hash) && item["label"] && item["path"]
121
+ { "label" => item["label"], "path" => item["path"],
122
+ "accelerator" => item["accelerator"] }.compact
123
+ end
124
+ end
125
+ end
126
+
127
+ # System tray: same entry shape as `menu:` plus `action: quit | show`.
128
+ #
129
+ # tray:
130
+ # - label: "Open My App"
131
+ # action: show
132
+ # - label: "New Note"
133
+ # path: /notes/new
134
+ # - separator
135
+ # - label: Quit
136
+ # action: quit
137
+ def tray
138
+ entries = @data["tray"]
139
+ return [] unless entries.is_a?(Array)
140
+
141
+ entries.filter_map do |item|
142
+ if item == "separator" || (item.is_a?(Hash) && item["separator"])
143
+ { "separator" => true }
144
+ elsif item.is_a?(Hash) && item["label"] && (item["path"] || item["action"])
145
+ { "label" => item["label"], "path" => item["path"],
146
+ "action" => item["action"] }.compact
147
+ end
148
+ end
149
+ end
150
+
151
+ # The subset the shell needs, shipped as JSON (env var in dev,
152
+ # Resources/everywhere.json inside a bundled .app).
153
+ def to_shell_hash
154
+ {
155
+ "name" => name,
156
+ "bundle_id" => bundle_id,
157
+ "mode" => mode,
158
+ "remote_url" => remote_url,
159
+ "entry_path" => entry_path,
160
+ "tint_color" => tint_color,
161
+ "background_color" => background_color,
162
+ "menu" => (menu unless menu.empty?),
163
+ "tray" => (tray unless tray.empty?)
164
+ }.compact
165
+ end
166
+
167
+ def to_shell_json
168
+ require "json"
169
+ JSON.generate(to_shell_hash)
170
+ end
171
+
172
+ private
173
+
174
+ def app = @data.fetch("app", nil) || {}
175
+ def appearance = @data.fetch("appearance", nil) || {}
176
+
177
+ def normalize_color(value)
178
+ case value
179
+ when String
180
+ { "light" => value, "dark" => value }
181
+ when Hash
182
+ light = value["light"] || value["dark"]
183
+ dark = value["dark"] || value["light"]
184
+ { "light" => light, "dark" => dark } if light
185
+ end
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Everywhere
4
+ # Framework-neutral database preparation for the packaged runtime: create the
5
+ # database if missing and bring it up to the latest migration, so a freshly
6
+ # installed desktop app has a working schema on first launch.
7
+ #
8
+ # Rather than hardcode one stack, prepare! adapts to whatever ORM the app has
9
+ # already loaded (it runs *after* the app boots):
10
+ #
11
+ # * ActiveRecord — Rails uses the multi-db-aware prepare_all; a plain
12
+ # ActiveRecord app (e.g. Sinatra + sinatra-activerecord) runs its
13
+ # migrations in-process.
14
+ # * Sequel — runs Sequel migrations against the app's database handle.
15
+ #
16
+ # Apps that need something else register a hook and own the whole step:
17
+ #
18
+ # Everywhere.configure { |c| c.database { MyMigrator.run! } }
19
+ module Database
20
+ MIGRATION_DIRS = %w[db/migrate db/migrations config/db/migrate].freeze
21
+
22
+ module_function
23
+
24
+ def prepare!(root)
25
+ if (hook = Everywhere.config.database_hook)
26
+ return hook.call
27
+ end
28
+
29
+ if defined?(::ActiveRecord::Base)
30
+ prepare_active_record(root)
31
+ elsif defined?(::Sequel)
32
+ prepare_sequel(root)
33
+ else
34
+ log "no ActiveRecord/Sequel detected — skipping database prepare"
35
+ end
36
+ rescue StandardError => e
37
+ warn "[everywhere] database prepare failed: #{e.class}: #{e.message}"
38
+ raise
39
+ end
40
+
41
+ # Rails: prepare_all handles the primary/cache/queue/cable databases.
42
+ # Plain ActiveRecord: connect (sqlite creates the file on demand) and run
43
+ # any pending migrations found on disk.
44
+ def prepare_active_record(root)
45
+ if rails_app?
46
+ ::ActiveRecord::Tasks::DatabaseTasks.prepare_all
47
+ return
48
+ end
49
+
50
+ paths = migration_paths(root)
51
+ return log("no migrations found — skipping") if paths.empty?
52
+
53
+ # Opening the connection creates the sqlite file if it doesn't exist yet.
54
+ ::ActiveRecord::Base.connection
55
+ context = ::ActiveRecord::MigrationContext.new(paths)
56
+ unless context.needs_migration?
57
+ return log("database up to date")
58
+ end
59
+
60
+ log "migrating database"
61
+ context.migrate
62
+ end
63
+
64
+ def prepare_sequel(root)
65
+ db = Everywhere.config.sequel_db
66
+ db ||= ::Object.const_get(:DB) if ::Object.const_defined?(:DB)
67
+ return log("no Sequel database handle (set Everywhere.config.sequel_db)") unless db
68
+
69
+ paths = migration_paths(root)
70
+ return log("no migrations found — skipping") if paths.empty?
71
+
72
+ ::Sequel.extension :migration
73
+ paths.each { |dir| ::Sequel::Migrator.run(db, dir) }
74
+ end
75
+
76
+ def migration_paths(root)
77
+ MIGRATION_DIRS.map { |d| File.join(root, d) }.select { |p| Dir.exist?(p) }
78
+ end
79
+
80
+ def rails_app?
81
+ defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application &&
82
+ defined?(::ActiveRecord::Tasks::DatabaseTasks)
83
+ end
84
+
85
+ def log(message)
86
+ puts "[everywhere] #{message}"
87
+ end
88
+ end
89
+ end