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,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+
5
+ module Everywhere
6
+ # Minimal PNG surgery: Tauri requires RGBA icons, users hand us whatever
7
+ # their design tool exported. Converts 8-bit RGB (color type 2) to RGBA
8
+ # (color type 6); passes RGBA through untouched. No image libraries needed.
9
+ module PNG
10
+ SIGNATURE = "\x89PNG\r\n\x1a\n".b
11
+
12
+ module_function
13
+
14
+ # Writes an RGBA copy of src to dst. Returns true on success, false for
15
+ # PNG flavors we don't handle (palette, 16-bit, interlaced...).
16
+ def ensure_rgba(src, dst)
17
+ data = File.binread(src)
18
+ return false unless data.start_with?(SIGNATURE)
19
+
20
+ chunks = read_chunks(data)
21
+ ihdr = chunks.find { |type, _| type == "IHDR" }&.last or return false
22
+ width, height, depth, color, _comp, _filter, interlace = ihdr.unpack("NNC5")
23
+ return false unless depth == 8 && interlace.zero?
24
+
25
+ if color == 6 # already RGBA
26
+ FileUtils.cp(src, dst)
27
+ return true
28
+ end
29
+ return false unless color == 2 # 8-bit RGB
30
+
31
+ raw = Zlib::Inflate.inflate(chunks.select { |t, _| t == "IDAT" }.map(&:last).join)
32
+ rgba = add_alpha(raw, width, height)
33
+
34
+ out = SIGNATURE.dup
35
+ out << chunk("IHDR", [width, height, 8, 6, 0, 0, 0].pack("NNC5"))
36
+ out << chunk("IDAT", Zlib::Deflate.deflate(rgba))
37
+ out << chunk("IEND", "")
38
+ File.binwrite(dst, out)
39
+ true
40
+ end
41
+
42
+ def read_chunks(data)
43
+ chunks = []
44
+ pos = 8
45
+ while pos < data.bytesize
46
+ length = data[pos, 4].unpack1("N")
47
+ type = data[pos + 4, 4]
48
+ chunks << [type, data[pos + 8, length]]
49
+ pos += 12 + length
50
+ break if type == "IEND"
51
+ end
52
+ chunks
53
+ end
54
+
55
+ # Unfilter RGB scanlines, emit unfiltered (filter 0) RGBA scanlines.
56
+ def add_alpha(raw, width, height)
57
+ bpp = 3
58
+ stride = width * bpp
59
+ prev = "\0".b * stride
60
+ out = +"".b
61
+
62
+ height.times do |y|
63
+ filter = raw.getbyte(y * (stride + 1))
64
+ line = raw.byteslice(y * (stride + 1) + 1, stride).dup
65
+ unfilter!(line, prev, filter, bpp)
66
+
67
+ out << "\0"
68
+ width.times do |x|
69
+ out << line.byteslice(x * bpp, bpp) << "\xFF".b
70
+ end
71
+ prev = line
72
+ end
73
+ out
74
+ end
75
+
76
+ def unfilter!(line, prev, filter, bpp)
77
+ stride = line.bytesize
78
+ case filter
79
+ when 0 # None
80
+ when 1 # Sub
81
+ bpp.upto(stride - 1) { |i| line.setbyte(i, (line.getbyte(i) + line.getbyte(i - bpp)) & 0xFF) }
82
+ when 2 # Up
83
+ 0.upto(stride - 1) { |i| line.setbyte(i, (line.getbyte(i) + prev.getbyte(i)) & 0xFF) }
84
+ when 3 # Average
85
+ 0.upto(stride - 1) do |i|
86
+ left = i >= bpp ? line.getbyte(i - bpp) : 0
87
+ line.setbyte(i, (line.getbyte(i) + ((left + prev.getbyte(i)) >> 1)) & 0xFF)
88
+ end
89
+ when 4 # Paeth
90
+ 0.upto(stride - 1) do |i|
91
+ left = i >= bpp ? line.getbyte(i - bpp) : 0
92
+ up = prev.getbyte(i)
93
+ up_left = i >= bpp ? prev.getbyte(i - bpp) : 0
94
+ p = left + up - up_left
95
+ pa = (p - left).abs
96
+ pb = (p - up).abs
97
+ pc = (p - up_left).abs
98
+ predictor = pa <= pb && pa <= pc ? left : (pb <= pc ? up : up_left)
99
+ line.setbyte(i, (line.getbyte(i) + predictor) & 0xFF)
100
+ end
101
+ else
102
+ raise Error, "unknown PNG filter #{filter}"
103
+ end
104
+ end
105
+
106
+ def chunk(type, payload)
107
+ [payload.bytesize].pack("N") + type + payload + [Zlib.crc32(type + payload)].pack("N")
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+ require_relative "version"
6
+ require_relative "shellout"
7
+ require_relative "ignore"
8
+
9
+ module Everywhere
10
+ # Builds the machine-readable build receipt (release.json) — the same shape
11
+ # emitted by local, CI, and Platform builds. See platform/docs/build-engine.md
12
+ # §7.
13
+ #
14
+ # Two layers:
15
+ # * the *resolved manifest* — the deterministic build request (app,
16
+ # framework, ruby, toolchain versions, source checksums, target,
17
+ # permissions). Everything here is knowable BEFORE the build runs and
18
+ # carries no volatile fields, so the same source + toolchain hashes
19
+ # identically (manifest_checksum).
20
+ # * the *artifact* record — filename, checksum, size, and the generic
21
+ # `signing` block, layered on once a build has produced something.
22
+ class Receipt
23
+ SCHEMA = 1
24
+
25
+ def initialize(root:, config:, framework:, ruby:, target:, channel:, shell_dir: nil)
26
+ @root = File.expand_path(root)
27
+ @config = config
28
+ @framework = framework
29
+ @ruby = ruby
30
+ @target = parse_target(target, channel)
31
+ @shell_dir = shell_dir
32
+ end
33
+
34
+ # The deterministic build request.
35
+ def manifest
36
+ {
37
+ "app" => {
38
+ "name" => @config.name,
39
+ "bundle_id" => @config.bundle_id,
40
+ "version" => @config.version
41
+ },
42
+ "framework" => framework_info,
43
+ "ruby" => @ruby,
44
+ "versions" => versions,
45
+ "source" => {
46
+ "checksum" => source_checksum,
47
+ "lockfile_checksum" => lockfile_checksum
48
+ },
49
+ "target" => @target,
50
+ "permissions" => @config.permissions
51
+ }
52
+ end
53
+
54
+ # sha256 over the canonical (sorted-key) JSON of the manifest. Ties a receipt
55
+ # to the exact request that produced it.
56
+ def manifest_checksum
57
+ "sha256:#{Digest::SHA256.hexdigest(canonical_json(manifest))}"
58
+ end
59
+
60
+ # The full receipt: manifest + artifact record. `signing` is the generic
61
+ # block from the spec — common fields + a platform-specific bag.
62
+ def to_h(artifact:, signing:, build_id: nil, logs_url: nil)
63
+ {
64
+ "schema" => SCHEMA,
65
+ "build_id" => build_id,
66
+ "manifest_checksum" => manifest_checksum
67
+ }.merge(manifest).merge(
68
+ "artifact" => artifact_record(artifact).merge("signing" => signing),
69
+ "logs_url" => logs_url
70
+ )
71
+ end
72
+
73
+ def write(path, **kwargs)
74
+ File.write(path, "#{JSON.pretty_generate(to_h(**kwargs))}\n")
75
+ path
76
+ end
77
+
78
+ private
79
+
80
+ def parse_target(target, channel)
81
+ os, arch = target.to_s.split("-", 2)
82
+ { "os" => os, "arch" => arch, "distribution_channel" => channel }
83
+ end
84
+
85
+ # Remote-mode apps package no local framework/runtime.
86
+ def framework_info
87
+ return { "name" => "remote", "version" => nil } unless @framework
88
+
89
+ { "name" => @framework.name, "version" => lockfile_gem_version(@framework.name) }
90
+ end
91
+
92
+ def artifact_record(path)
93
+ {
94
+ "filename" => File.basename(path),
95
+ "checksum" => "sha256:#{Digest::SHA256.file(path).hexdigest}",
96
+ "size_bytes" => File.size(path)
97
+ }
98
+ end
99
+
100
+ # ---- versions ------------------------------------------------------------
101
+
102
+ def versions
103
+ {
104
+ "cli" => Everywhere::VERSION,
105
+ "bridge" => bridge_version,
106
+ "shell" => shell_version,
107
+ "tebako" => tebako_version
108
+ }
109
+ end
110
+
111
+ def bridge_version
112
+ # Package installs: read the version straight from package.json.
113
+ [
114
+ File.join(@root, "node_modules", "@rubyeverywhere", "bridge", "package.json"),
115
+ File.join(@root, "vendor", "javascript", "@rubyeverywhere", "bridge", "package.json")
116
+ ].each { |p| (v = json_version(p)) and return v }
117
+
118
+ # Single-file vendored install: read a `// version: x.y.z` header marker
119
+ # if `every install` stamped one (best-effort until it does).
120
+ flat = File.join(@root, "vendor", "javascript", "@rubyeverywhere--bridge.js")
121
+ File.exist?(flat) ? File.foreach(flat).first(6).join[/version:\s*([\d.]+)/i, 1] : nil
122
+ end
123
+
124
+ def shell_version
125
+ return nil unless @shell_dir
126
+
127
+ json_version(File.join(@shell_dir, "tauri.conf.json")) ||
128
+ json_version(File.join(@shell_dir, "..", "tauri.conf.json"))
129
+ end
130
+
131
+ # The rbe-tebako fork prints its banner ("...version 0.15.0") on every
132
+ # invocation and exits non-zero for unknown subcommands, so parse the output
133
+ # rather than gate on the exit status.
134
+ def tebako_version
135
+ out, = Shellout.capture("rbe-tebako", "--version")
136
+ out[/version\s+([\d.]+)/i, 1]
137
+ end
138
+
139
+ # ---- checksums -----------------------------------------------------------
140
+
141
+ def lockfile_checksum
142
+ lock = File.join(@root, "Gemfile.lock")
143
+ File.exist?(lock) ? "sha256:#{Digest::SHA256.file(lock).hexdigest}" : nil
144
+ end
145
+
146
+ def lockfile_gem_version(gem_name)
147
+ lock = File.join(@root, "Gemfile.lock")
148
+ return nil unless File.exist?(lock)
149
+
150
+ File.read(lock)[/^\s{4}#{Regexp.escape(gem_name)} \(([^)]+)\)/, 1]
151
+ end
152
+
153
+ # Deterministic digest of the app's source: for every non-ignored file,
154
+ # "relpath\0<sha256>\n", sorted, then hashed. Order-stable across machines.
155
+ # Uses the SAME ignore rules as the (future) snapshot, so the checksum
156
+ # describes exactly what ships.
157
+ def source_checksum
158
+ digest = Digest::SHA256.new
159
+ Ignore.for(@root).files(@root).each do |rel|
160
+ digest << rel << "\0" << Digest::SHA256.file(File.join(@root, rel)).hexdigest << "\n"
161
+ end
162
+ "sha256:#{digest.hexdigest}"
163
+ end
164
+
165
+ # ---- helpers -------------------------------------------------------------
166
+
167
+ def json_version(path)
168
+ return nil unless File.exist?(path)
169
+
170
+ JSON.parse(File.read(path))["version"]
171
+ rescue JSON::ParserError
172
+ nil
173
+ end
174
+
175
+ def canonical_json(obj) = JSON.generate(sort_deep(obj))
176
+
177
+ def sort_deep(obj)
178
+ case obj
179
+ when Hash then obj.sort_by { |k, _| k.to_s }.to_h.transform_values { |v| sort_deep(v) }
180
+ when Array then obj.map { |v| sort_deep(v) }
181
+ else obj
182
+ end
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ui"
4
+
5
+ module Everywhere
6
+ # All external commands (tebako, cargo, the app's own bin/dev) run outside
7
+ # this gem's bundle — they have their own gem environments.
8
+ module Shellout
9
+ module_function
10
+
11
+ def unbundled(&block)
12
+ defined?(Bundler) ? Bundler.with_unbundled_env(&block) : yield
13
+ end
14
+
15
+ def run!(env, *cmd, chdir: Dir.pwd)
16
+ success = unbundled { system(env, *cmd, chdir: chdir) }
17
+ UI.die!("command failed: #{cmd.join(" ")}") unless success
18
+ end
19
+
20
+ def run?(*cmd)
21
+ unbundled { system(*cmd) }
22
+ end
23
+
24
+ # Run a command and capture its combined stdout+stderr. Returns
25
+ # [output_string, Process::Status]. For probing tool output (codesign,
26
+ # stapler, notarytool) rather than driving a build.
27
+ def capture(*cmd)
28
+ require "open3"
29
+ unbundled { Open3.capture2e(*cmd) }
30
+ rescue Errno::ENOENT
31
+ ["", nil]
32
+ end
33
+
34
+ # Run a command streaming to the console while also capturing to a log file.
35
+ def run_logged!(env, cmd, log:)
36
+ require "shellwords"
37
+ line = "#{cmd.shelljoin} 2>&1 | tee #{log.shellescape}"
38
+ success = unbundled { system(env, "/bin/bash", "-o", "pipefail", "-c", line) }
39
+ UI.die!("command failed: #{cmd.join(" ")} (full log: #{log})") unless success
40
+ end
41
+
42
+ def spawn(env, cmd, chdir:)
43
+ unbundled { Process.spawn(env, cmd, chdir: chdir) }
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Everywhere
4
+ # Terminal output helpers. Colors follow the informal standard:
5
+ # on when stdout is a TTY or CLICOLOR_FORCE=1, always off when NO_COLOR is set.
6
+ module UI
7
+ module_function
8
+
9
+ def color?
10
+ return false if ENV["NO_COLOR"]
11
+ return true if ENV["CLICOLOR_FORCE"] == "1"
12
+
13
+ $stdout.tty?
14
+ end
15
+
16
+ def paint(text, *codes)
17
+ color? ? "\e[#{codes.join(";")}m#{text}\e[0m" : text.to_s
18
+ end
19
+
20
+ def bold(t) = paint(t, 1)
21
+ def dim(t) = paint(t, 2)
22
+ def red(t) = paint(t, 31)
23
+ def green(t) = paint(t, 32)
24
+ def yellow(t) = paint(t, 33)
25
+ def cyan(t) = paint(t, 36)
26
+
27
+ def step(msg) = puts("#{cyan("→")} #{msg}")
28
+ def ok(msg) = puts("#{green("✓")} #{msg}")
29
+ def bad(msg) = puts("#{red("✗")} #{msg}")
30
+ def warn(msg) = puts("#{yellow("!")} #{msg}")
31
+ def success(msg) = puts("#{green("✓")} #{bold(msg)}")
32
+
33
+ def die!(msg)
34
+ abort "#{red("✗")} #{red(msg)}"
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Everywhere
4
+ VERSION = "0.1.0"
5
+
6
+ # Version of the vendored @rubyeverywhere/bridge JS this gem ships. Tracks
7
+ # bridge/package.json — bump it whenever lib/everywhere/javascript/bridge.js is
8
+ # refreshed. `every install` stamps it into the vendored file so the build
9
+ # receipt can record which bridge shipped; it versions independently of the CLI.
10
+ BRIDGE_VERSION = "0.1.0"
11
+ end
data/lib/everywhere.rb ADDED
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Runtime entry point: what an app gets from `require "ruby_everywhere"`.
4
+ # The CLI lives in everywhere/cli and is only loaded by the every/rbe executables.
5
+ require_relative "everywhere/version"
6
+ require_relative "everywhere/config"
7
+ require_relative "everywhere/framework"
8
+ require_relative "everywhere/database"
9
+ require_relative "everywhere/boot"
10
+
11
+ module Everywhere
12
+ class Error < StandardError; end
13
+
14
+ # App-facing runtime configuration. Apps register overrides at load time:
15
+ #
16
+ # # config/initializers/everywhere.rb (Rails) or anywhere loaded by config.ru
17
+ # Everywhere.configure do |c|
18
+ # c.database { MyMigrator.run! } # override the default ORM-driven prep
19
+ # end
20
+ #
21
+ # Read back by the boot path (Everywhere::Database, etc.).
22
+ class Configuration
23
+ # A callable that fully owns database preparation. When set, it replaces the
24
+ # ORM auto-detection in Everywhere::Database entirely.
25
+ attr_reader :database_hook
26
+
27
+ # An explicit Sequel database handle for frameworks (e.g. Hanami) where the
28
+ # connection lives inside a container rather than a top-level constant.
29
+ attr_accessor :sequel_db
30
+
31
+ def database(&block)
32
+ @database_hook = block
33
+ end
34
+ end
35
+
36
+ class << self
37
+ def config
38
+ @config ||= Configuration.new
39
+ end
40
+
41
+ def configure
42
+ yield config
43
+ end
44
+
45
+ # Test/reset seam.
46
+ def reset_config!
47
+ @config = Configuration.new
48
+ end
49
+ end
50
+ end
@@ -1,5 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module RubyEverywhere
4
- VERSION = "0.0.1"
5
- end
3
+ # Conventional require path for the ruby_everywhere gem.
4
+ require_relative "everywhere"
@@ -0,0 +1,85 @@
1
+ # RubyEverywhere Platform — reusable build workflow.
2
+ #
3
+ # Lives in the RunnerDispatch build repo (private, RubyEverywhere-owned). The
4
+ # Platform triggers it via workflow_dispatch (see app/models/runner_dispatch.rb);
5
+ # it runs one artifact on an ephemeral, single-use macOS runner and reports back
6
+ # through the Runner API. No customer source lives in this repo — it arrives as
7
+ # the snapshot the runner downloads.
8
+ #
9
+ # The heavy part is provisioning the toolchain (rbe-tebako fork + deps + Xcode
10
+ # shims). Cache it aggressively; a warm runner builds in minutes.
11
+
12
+ name: RubyEverywhere Platform Build
13
+
14
+ on:
15
+ workflow_dispatch:
16
+ inputs:
17
+ artifact_id:
18
+ description: Artifact public id (art_…)
19
+ required: true
20
+ runner_token:
21
+ description: Per-artifact runner token
22
+ required: true
23
+ callback_base_url:
24
+ description: Platform base URL the runner reports back to
25
+ required: true
26
+
27
+ # One in-flight job per artifact.
28
+ concurrency:
29
+ group: build-${{ inputs.artifact_id }}
30
+ cancel-in-progress: false
31
+
32
+ jobs:
33
+ build:
34
+ # macOS/iOS → macos; Android → ubuntu; Windows → windows (routed by the
35
+ # dispatcher today only macOS is live).
36
+ runs-on: macos-14 # Apple Silicon
37
+ timeout-minutes: 45
38
+
39
+ steps:
40
+ - name: Set up Ruby (to run the every CLI itself)
41
+ uses: ruby/setup-ruby@v1
42
+ with:
43
+ ruby-version: "3.4"
44
+
45
+ - name: Provision the RubyEverywhere toolchain
46
+ run: |
47
+ set -euo pipefail
48
+ # The signing engine needs the rbe-tebako fork + build deps. This is the
49
+ # slow step — restore it from cache in a real setup (actions/cache on
50
+ # ~/.tebako, ~/.tebako-shims, brew).
51
+ brew install bison
52
+ gem install ruby_everywhere # provides `every`
53
+ # gem install rbe-tebako # the fork (or build from source)
54
+ # bin/setup-xcode-shims # Xcode 26 CXX shim, rbconfig patch
55
+ every doctor || true # surfaces any missing toolchain bits
56
+
57
+ - name: Install Apple intermediate certificates
58
+ run: |
59
+ set -euo pipefail
60
+ # The delivered .p12 holds only the leaf; codesign needs the Developer
61
+ # ID intermediate to build a complete chain (a clean runner has none).
62
+ for cert in DeveloperIDG2CA AppleWWDRCAG3; do
63
+ curl -fsSL "https://www.apple.com/certificateauthority/$cert.cer" -o "$RUNNER_TEMP/$cert.cer"
64
+ sudo security import "$RUNNER_TEMP/$cert.cer" -k /Library/Keychains/System.keychain || true
65
+ done
66
+
67
+ - name: Provision the generic Tauri shell
68
+ run: |
69
+ set -euo pipefail
70
+ # The shell (src-tauri) is generic — same for every app. Pull the
71
+ # pinned prebuilt shell for this platform.
72
+ echo "SHELL_DIR=$RUNNER_TEMP/shell/src-tauri" >> "$GITHUB_ENV"
73
+ # gh release download --repo rubyeverywhere/shell --pattern 'src-tauri-*' ...
74
+ # OR: git clone --depth 1 https://github.com/rubyeverywhere/shell "$RUNNER_TEMP/shell"
75
+
76
+ - name: Run the build
77
+ run: |
78
+ every platform runner \
79
+ --artifact "${{ inputs.artifact_id }}" \
80
+ --token "${{ inputs.runner_token }}" \
81
+ --url "${{ inputs.callback_base_url }}" \
82
+ --shell-dir "$SHELL_DIR"
83
+ # Signing credentials are pulled JIT from the Platform into a throwaway
84
+ # keychain by the runner itself — they are never GitHub secrets, and the
85
+ # ephemeral runner is destroyed after this job.
@@ -0,0 +1,23 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <!--
6
+ Hardened-runtime entitlements required to notarize a tebako-packed Ruby app.
7
+
8
+ A tebako binary embeds Ruby + all gems in a DwarFS memfs and, at runtime,
9
+ dlopen()s native gem extensions (.bundle) straight out of that memfs. Those
10
+ .bundle files are NOT signed with our Developer ID, so the hardened runtime's
11
+ library validation would reject them — hence disable-library-validation.
12
+
13
+ Ruby 4's YJIT/ZJIT allocate + execute JIT code, which the hardened runtime
14
+ blocks unless we allow JIT and unsigned executable memory.
15
+ -->
16
+ <key>com.apple.security.cs.disable-library-validation</key>
17
+ <true/>
18
+ <key>com.apple.security.cs.allow-jit</key>
19
+ <true/>
20
+ <key>com.apple.security.cs.allow-unsigned-executable-memory</key>
21
+ <true/>
22
+ </dict>
23
+ </plist>
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Sign (Developer ID + hardened runtime), notarize, and staple a .app bundle
4
+ # so it launches without Gatekeeper warnings on other Macs.
5
+ #
6
+ # Secrets never live in this script. Provide them via the environment — the
7
+ # easiest way is to source your release env first:
8
+ #
9
+ # source ~/Projects/atlastools.dev/atlas-workspaces/.env.release
10
+ # cli/support/macos/notarize.sh "example/dist/Example Notes.app"
11
+ #
12
+ # Required env vars (these match Tauri's names, so your existing .env works):
13
+ # APPLE_SIGNING_IDENTITY e.g. "Developer ID Application: Your Name (TEAMID)"
14
+ # APPLE_ID your Apple ID email
15
+ # APPLE_PASSWORD app-specific password (NOT your Apple account password)
16
+ # APPLE_TEAM_ID your 10-char team id
17
+ #
18
+ # Optional:
19
+ # NOTARY_PROFILE if set, uses `--keychain-profile "$NOTARY_PROFILE"`
20
+ # instead of APPLE_ID/APPLE_PASSWORD/APPLE_TEAM_ID.
21
+ # Create it once with:
22
+ # xcrun notarytool store-credentials "$NOTARY_PROFILE" \
23
+ # --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" \
24
+ # --password "$APPLE_PASSWORD"
25
+
26
+ set -euo pipefail
27
+
28
+ APP="${1:?usage: notarize.sh /path/to/App.app}"
29
+ [ -d "$APP" ] || { echo "not a .app bundle: $APP" >&2; exit 1; }
30
+
31
+ # Load secrets from a dotenv-style file when ENV_FILE is set. This parses
32
+ # KEY=VALUE line-by-line and exports each var, so it handles unquoted values
33
+ # containing spaces/parens (e.g. APPLE_SIGNING_IDENTITY=Developer ID
34
+ # Application: Name (TEAM)) that plain `source .env.release` cannot.
35
+ #
36
+ # ENV_FILE=.env.release cli/support/macos/notarize.sh "App.app"
37
+ if [ -n "${ENV_FILE:-}" ]; then
38
+ [ -f "$ENV_FILE" ] || { echo "ENV_FILE not found: $ENV_FILE" >&2; exit 1; }
39
+ while IFS= read -r line || [ -n "$line" ]; do
40
+ line="${line%$'\r'}" # strip trailing CR (CRLF files)
41
+ case "$line" in ''|'#'*) continue ;; esac # skip blank / comment lines
42
+ [ "${line#*=}" = "$line" ] && continue # no '=' on the line -> skip
43
+ key="${line%%=*}"; val="${line#*=}"
44
+ key="${key#export }" # allow "export KEY=..."
45
+ key="${key#"${key%%[![:space:]]*}"}" # trim leading whitespace
46
+ key="${key%"${key##*[![:space:]]}"}" # trim trailing whitespace
47
+ # strip one layer of matching surrounding quotes from the value
48
+ if [ "${val#\"}" != "$val" ] && [ "${val%\"}" != "$val" ]; then val="${val#\"}"; val="${val%\"}"
49
+ elif [ "${val#\'}" != "$val" ] && [ "${val%\'}" != "$val" ]; then val="${val#\'}"; val="${val%\'}"
50
+ fi
51
+ export "$key=$val"
52
+ done < "$ENV_FILE"
53
+ fi
54
+
55
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
56
+ ENTITLEMENTS="$SCRIPT_DIR/entitlements.plist"
57
+
58
+ : "${APPLE_SIGNING_IDENTITY:?set APPLE_SIGNING_IDENTITY (source your .env.release)}"
59
+
60
+ # --- 1. Sign inside-out: nested Mach-O first, then the bundle --------------
61
+ echo "==> signing with: $APPLE_SIGNING_IDENTITY"
62
+ # Sign every executable/dylib under Contents/MacOS individually so nested
63
+ # helpers (app-server, the tebako binary) each carry the entitlements. The
64
+ # bundle sign at the end seals resources and the main executable.
65
+ while IFS= read -r -d '' bin; do
66
+ echo " sign $bin"
67
+ codesign --force --timestamp --options runtime \
68
+ --entitlements "$ENTITLEMENTS" \
69
+ --sign "$APPLE_SIGNING_IDENTITY" "$bin"
70
+ done < <(find "$APP/Contents/MacOS" -type f -print0)
71
+
72
+ echo " sign $APP"
73
+ codesign --force --timestamp --options runtime \
74
+ --entitlements "$ENTITLEMENTS" \
75
+ --sign "$APPLE_SIGNING_IDENTITY" "$APP"
76
+
77
+ echo "==> verifying signature"
78
+ codesign --verify --deep --strict --verbose=2 "$APP"
79
+
80
+ # --- 2. Notarize ----------------------------------------------------------
81
+ ZIP="${APP%.app}.zip"
82
+ echo "==> zipping for notarization: $ZIP"
83
+ rm -f "$ZIP"
84
+ ditto -c -k --keepParent "$APP" "$ZIP"
85
+
86
+ echo "==> submitting to Apple notary service (this can take a few minutes)"
87
+ if [ -n "${NOTARY_KEY:-}" ]; then
88
+ # App Store Connect API key (.p8) — how the Platform runner notarizes with a
89
+ # custodied key. NOTARY_KEY is a path to the .p8.
90
+ : "${NOTARY_KEY_ID:?set NOTARY_KEY_ID with NOTARY_KEY}"
91
+ : "${NOTARY_ISSUER:?set NOTARY_ISSUER with NOTARY_KEY}"
92
+ xcrun notarytool submit "$ZIP" \
93
+ --key "$NOTARY_KEY" --key-id "$NOTARY_KEY_ID" --issuer "$NOTARY_ISSUER" --wait
94
+ elif [ -n "${NOTARY_PROFILE:-}" ]; then
95
+ xcrun notarytool submit "$ZIP" --keychain-profile "$NOTARY_PROFILE" --wait
96
+ else
97
+ # Accept APPLE_APP_SPECIFIC_PASSWORD as an alias for APPLE_PASSWORD.
98
+ APPLE_PASSWORD="${APPLE_PASSWORD:-${APPLE_APP_SPECIFIC_PASSWORD:-}}"
99
+ : "${APPLE_ID:?set APPLE_ID or NOTARY_PROFILE}"
100
+ : "${APPLE_PASSWORD:?set APPLE_PASSWORD (or APPLE_APP_SPECIFIC_PASSWORD) or NOTARY_PROFILE}"
101
+ : "${APPLE_TEAM_ID:?set APPLE_TEAM_ID or NOTARY_PROFILE}"
102
+ xcrun notarytool submit "$ZIP" \
103
+ --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_PASSWORD" \
104
+ --wait
105
+ fi
106
+
107
+ # --- 3. Staple + verify ---------------------------------------------------
108
+ echo "==> stapling ticket"
109
+ xcrun stapler staple "$APP"
110
+ xcrun stapler validate "$APP"
111
+
112
+ echo "==> Gatekeeper assessment"
113
+ spctl -a -vvv "$APP"
114
+
115
+ # --- 4. Re-zip the stapled app for distribution ---------------------------
116
+ echo "==> re-zipping stapled app for distribution"
117
+ rm -f "$ZIP"
118
+ ditto -c -k --keepParent "$APP" "$ZIP"
119
+
120
+ echo ""
121
+ echo "✅ Done. Send this to your friend: $ZIP"
122
+ echo " (Stapled, so it works even offline. Send the .zip, not the raw .app —"
123
+ echo " zipping preserves the signature; AirDrop/Finder-copy of a bare .app can strip it.)"