ruby_everywhere 0.6.0 → 0.7.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.
- checksums.yaml +4 -4
- data/exe/every +12 -1
- data/exe/rbe +12 -1
- data/lib/everywhere/agents_guide.rb +3 -1
- data/lib/everywhere/blake2b.rb +17 -1
- data/lib/everywhere/boot.rb +21 -4
- data/lib/everywhere/builders/android.rb +144 -30
- data/lib/everywhere/builders/desktop.rb +25 -17
- data/lib/everywhere/builders/ios.rb +244 -9
- data/lib/everywhere/cli.rb +2 -0
- data/lib/everywhere/commands/build.rb +113 -58
- data/lib/everywhere/commands/clean.rb +8 -3
- data/lib/everywhere/commands/dev.rb +85 -11
- data/lib/everywhere/commands/doctor.rb +138 -21
- data/lib/everywhere/commands/install.rb +48 -8
- data/lib/everywhere/commands/platform/build.rb +136 -27
- data/lib/everywhere/commands/platform/login.rb +16 -2
- data/lib/everywhere/commands/platform/runner.rb +113 -23
- data/lib/everywhere/commands/preview.rb +359 -0
- data/lib/everywhere/commands/publish.rb +20 -2
- data/lib/everywhere/commands/release.rb +145 -28
- data/lib/everywhere/commands/shell_dir.rb +2 -2
- data/lib/everywhere/config.rb +88 -3
- data/lib/everywhere/console.rb +2 -1
- data/lib/everywhere/engine.rb +24 -0
- data/lib/everywhere/framework.rb +21 -4
- data/lib/everywhere/ignore.rb +3 -1
- data/lib/everywhere/jump.rb +121 -0
- data/lib/everywhere/mobile_config_endpoint.rb +47 -0
- data/lib/everywhere/mobile_configs_controller.rb +46 -0
- data/lib/everywhere/paths.rb +13 -6
- data/lib/everywhere/platform/client.rb +27 -6
- data/lib/everywhere/platform/credentials.rb +18 -8
- data/lib/everywhere/platform/snapshot.rb +10 -0
- data/lib/everywhere/receipt.rb +55 -10
- data/lib/everywhere/shellout.rb +19 -0
- data/lib/everywhere/simulator.rb +12 -1
- data/lib/everywhere/version.rb +1 -1
- data/support/mobile/android/app/build.gradle.kts +29 -1
- data/support/mobile/ios/App/EverywhereConfig.swift +17 -5
- data/support/mobile/ios/App/SceneDelegate.swift +75 -8
- data/support/mobile/ios/App.xcodeproj/project.pbxproj +2 -2
- data/support/mobile/ios/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +0 -1
- data/support/mobile/ios/README.md +13 -6
- metadata +17 -1
|
@@ -15,30 +15,61 @@ module Everywhere
|
|
|
15
15
|
# Xcode template, stamp it (xcconfig + everywhere.json + AppIcon; never
|
|
16
16
|
# the pbxproj), and hand it to xcodebuild.
|
|
17
17
|
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
18
|
+
# Two products come out of the same stamped project: the Simulator .app
|
|
19
|
+
# (ad-hoc signed, no certs) and, with device: true, a signed .ipa via
|
|
20
|
+
# xcodebuild archive + -exportArchive — see platform/docs/build-engine.md §5.
|
|
21
21
|
class Ios
|
|
22
22
|
# Fixed by the template contract: the target/product is always "App"
|
|
23
23
|
# (App.app), so build paths never depend on per-app values.
|
|
24
24
|
SCHEME = "App"
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
# Device signing arrives through the environment, never everywhere.yml:
|
|
27
|
+
# the platform runner pulls the certificate and profile just-in-time and
|
|
28
|
+
# nothing lands in the app repo.
|
|
29
|
+
REQUIRED_SIGNING_ENV = {
|
|
30
|
+
"EVERY_IOS_TEAM_ID" => "the 10-character Apple Developer team id",
|
|
31
|
+
"EVERY_IOS_PROVISIONING_PROFILE" => "the provisioning profile NAME (not a path or UUID)"
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
EXPORT_METHODS = %w[app-store-connect app-store ad-hoc development release-testing enterprise].freeze
|
|
35
|
+
DEFAULT_EXPORT_METHOD = "app-store-connect"
|
|
36
|
+
|
|
37
|
+
# Distribution channels whose artifact has to reach App Store Connect: the
|
|
38
|
+
# upload happens here, on the build host, because only this machine has the
|
|
39
|
+
# archive and Xcode.
|
|
40
|
+
STORE_CHANNELS = %w[testflight app_store].freeze
|
|
41
|
+
|
|
42
|
+
# The App Store Connect API key, under the names the platform runner
|
|
43
|
+
# already exports for notarytool, plus explicit aliases for local runs.
|
|
44
|
+
ASC_ENV = {
|
|
45
|
+
key_path: %w[NOTARY_KEY EVERY_ASC_KEY],
|
|
46
|
+
key_id: %w[NOTARY_KEY_ID EVERY_ASC_KEY_ID],
|
|
47
|
+
issuer_id: %w[NOTARY_ISSUER EVERY_ASC_ISSUER_ID]
|
|
48
|
+
}.freeze
|
|
49
|
+
|
|
50
|
+
def initialize(config:, root:, target:, template_dir: nil, channel: "direct")
|
|
27
51
|
@config = config
|
|
28
52
|
@root = root
|
|
29
53
|
@target = target
|
|
30
54
|
@template_dir = template_dir
|
|
55
|
+
@channel = channel.to_s
|
|
31
56
|
end
|
|
32
57
|
|
|
33
58
|
# Build the Simulator .app and copy it into dist_dir. `dev` builds Debug
|
|
34
59
|
# and relaxes the remote-mode requirement (the dev URL arrives at launch
|
|
35
60
|
# via SIMCTL_CHILD_EVERYWHERE_DEV_URL, not baked into the bundle).
|
|
36
|
-
#
|
|
37
|
-
|
|
38
|
-
|
|
61
|
+
# `device` archives and exports a signed dist/<Name>.ipa instead.
|
|
62
|
+
# Returns the path of the built product.
|
|
63
|
+
def build!(dist_dir:, dev: false, device: false)
|
|
64
|
+
preflight!(dev: dev, device: device)
|
|
39
65
|
template = @template_dir ? File.expand_path(@template_dir) : Paths.ios_dir!
|
|
40
66
|
work = stage(template)
|
|
41
67
|
stamp!(work)
|
|
68
|
+
if device
|
|
69
|
+
archive!(work, dist_dir)
|
|
70
|
+
return export!(work, dist_dir)
|
|
71
|
+
end
|
|
72
|
+
|
|
42
73
|
configuration = dev ? "Debug" : "Release"
|
|
43
74
|
xcodebuild!(work, configuration, dist_dir)
|
|
44
75
|
collect(configuration, dist_dir)
|
|
@@ -49,18 +80,70 @@ module Everywhere
|
|
|
49
80
|
def name = @config.name(target: @target)
|
|
50
81
|
def bundle_id = @config.bundle_id(target: @target)
|
|
51
82
|
|
|
52
|
-
def preflight!(dev:)
|
|
83
|
+
def preflight!(dev:, device:)
|
|
53
84
|
unless dev || @config.remote?
|
|
54
85
|
UI.die!("iOS apps are remote mode only for now — set remote.url in config/everywhere.yml " \
|
|
55
86
|
"(the shell wraps your deployed app; nothing is packaged locally)")
|
|
56
87
|
end
|
|
57
88
|
UI.die!("iOS builds need macOS with Xcode installed") unless RUBY_PLATFORM.include?("darwin")
|
|
89
|
+
return device_preflight! if device
|
|
58
90
|
return if Shellout.run?("xcrun", "--sdk", "iphonesimulator", "--show-sdk-path", quiet: true)
|
|
59
91
|
|
|
60
92
|
UI.die!("no iOS Simulator SDK — install full Xcode (not just the Command Line Tools) " \
|
|
61
93
|
"and run: xcode-select --switch /Applications/Xcode.app")
|
|
62
94
|
end
|
|
63
95
|
|
|
96
|
+
# Signing is checked before anything is staged: xcodebuild only notices a
|
|
97
|
+
# missing team or profile minutes in, at the codesign step, and says so in
|
|
98
|
+
# its own vocabulary.
|
|
99
|
+
def device_preflight!
|
|
100
|
+
unless Shellout.run?("xcrun", "--sdk", "iphoneos", "--show-sdk-path", quiet: true)
|
|
101
|
+
UI.die!("no iOS device SDK — install full Xcode (not just the Command Line Tools) " \
|
|
102
|
+
"and run: xcode-select --switch /Applications/Xcode.app")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
missing = REQUIRED_SIGNING_ENV.reject { |name, _| signing_env(name) }
|
|
106
|
+
unless missing.empty?
|
|
107
|
+
UI.die!("device builds need signing credentials in the environment:\n" \
|
|
108
|
+
"#{missing.map { |name, hint| " #{name} — #{hint}" }.join("\n")}")
|
|
109
|
+
end
|
|
110
|
+
unless EXPORT_METHODS.include?(export_method)
|
|
111
|
+
UI.die!("EVERY_IOS_EXPORT_METHOD=#{export_method} isn't an Xcode export method — " \
|
|
112
|
+
"use one of: #{EXPORT_METHODS.join(", ")}")
|
|
113
|
+
end
|
|
114
|
+
asc_preflight! if store_channel?
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# A store channel that can't reach App Store Connect has to fail now: the
|
|
118
|
+
# archive it would otherwise produce takes minutes and is unusable.
|
|
119
|
+
def asc_preflight!
|
|
120
|
+
missing = ASC_ENV.reject { |field, _names| asc_env(field) }
|
|
121
|
+
.map { |_field, names| names.first }
|
|
122
|
+
unless missing.empty?
|
|
123
|
+
UI.die!("--channel #{@channel} uploads to App Store Connect, which needs an API key " \
|
|
124
|
+
"in the environment — missing: #{missing.join(", ")}")
|
|
125
|
+
end
|
|
126
|
+
return if export_method == DEFAULT_EXPORT_METHOD
|
|
127
|
+
|
|
128
|
+
UI.die!("--channel #{@channel} uploads to App Store Connect, which only accepts " \
|
|
129
|
+
"#{DEFAULT_EXPORT_METHOD} builds — unset EVERY_IOS_EXPORT_METHOD=#{export_method}")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def signing_env(name)
|
|
133
|
+
value = ENV[name].to_s.strip
|
|
134
|
+
value.empty? ? nil : value
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def asc_env(field) = ASC_ENV.fetch(field).filter_map { |name| signing_env(name) }.first
|
|
138
|
+
|
|
139
|
+
def store_channel? = STORE_CHANNELS.include?(@channel)
|
|
140
|
+
def upload_to_asc? = store_channel? && ASC_ENV.each_key.all? { |field| asc_env(field) }
|
|
141
|
+
|
|
142
|
+
def team_id = signing_env("EVERY_IOS_TEAM_ID")
|
|
143
|
+
def provisioning_profile = signing_env("EVERY_IOS_PROVISIONING_PROFILE")
|
|
144
|
+
def signing_identity = signing_env("APPLE_SIGNING_IDENTITY")
|
|
145
|
+
def export_method = signing_env("EVERY_IOS_EXPORT_METHOD") || DEFAULT_EXPORT_METHOD
|
|
146
|
+
|
|
64
147
|
# Copy the template into the app's persistent work dir. Persistent (not
|
|
65
148
|
# a tmpdir) so the project path is stable — Xcode's incremental state
|
|
66
149
|
# stays valid and users can open the stamped project directly.
|
|
@@ -360,7 +443,7 @@ module Everywhere
|
|
|
360
443
|
MARKETING_VERSION = #{@config.version(target: @target)}
|
|
361
444
|
CURRENT_PROJECT_VERSION = 1
|
|
362
445
|
INFOPLIST_KEY_CFBundleDisplayName = #{name}
|
|
363
|
-
DEVELOPMENT_TEAM
|
|
446
|
+
DEVELOPMENT_TEAM =#{" #{team_id}" if team_id}
|
|
364
447
|
#{entitlements}
|
|
365
448
|
XCCONFIG
|
|
366
449
|
end
|
|
@@ -415,6 +498,158 @@ module Everywhere
|
|
|
415
498
|
log: log, filter: LogFilter.for(:xcodebuild))
|
|
416
499
|
end
|
|
417
500
|
|
|
501
|
+
# Archive for a real device. The signing settings are passed as
|
|
502
|
+
# command-line build settings because those override the project file —
|
|
503
|
+
# the template ships CODE_SIGN_STYLE = Automatic and the stamp-only
|
|
504
|
+
# contract forbids editing its pbxproj. CODE_SIGN_IDENTITY is left to
|
|
505
|
+
# xcodebuild ("Apple Distribution") unless the runner names one.
|
|
506
|
+
def archive!(work, dist_dir)
|
|
507
|
+
UI.step("archiving iOS app #{UI.dim("(xcodebuild archive, team #{team_id})")}")
|
|
508
|
+
FileUtils.mkdir_p(dist_dir)
|
|
509
|
+
FileUtils.rm_rf(archive_path(work))
|
|
510
|
+
Shellout.run_logged!({},
|
|
511
|
+
["xcodebuild", "archive",
|
|
512
|
+
"-project", File.join(work, "App.xcodeproj"),
|
|
513
|
+
"-scheme", SCHEME,
|
|
514
|
+
"-configuration", "Release",
|
|
515
|
+
"-destination", "generic/platform=iOS",
|
|
516
|
+
"-archivePath", archive_path(work),
|
|
517
|
+
"-derivedDataPath", Paths.ios_derived_data_dir,
|
|
518
|
+
"-clonedSourcePackagesDirPath", Paths.ios_packages_dir,
|
|
519
|
+
"CODE_SIGN_STYLE=Manual",
|
|
520
|
+
"DEVELOPMENT_TEAM=#{team_id}",
|
|
521
|
+
"PROVISIONING_PROFILE_SPECIFIER=#{provisioning_profile}",
|
|
522
|
+
*("CODE_SIGN_IDENTITY=#{signing_identity}" if signing_identity)],
|
|
523
|
+
log: File.join(dist_dir, "ios-archive.log"), filter: LogFilter.for(:xcodebuild))
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
# Two passes over the same archive: the local .ipa (the artifact everything
|
|
527
|
+
# downstream expects) and, for a store channel, the upload to App Store
|
|
528
|
+
# Connect. Xcode won't do both from one -exportArchive — `destination` is
|
|
529
|
+
# either export or upload — and the .ipa must exist either way.
|
|
530
|
+
def export!(work, dist_dir)
|
|
531
|
+
FileUtils.mkdir_p(dist_dir)
|
|
532
|
+
# A receipt left by a previous run would otherwise read as proof that
|
|
533
|
+
# THIS build reached App Store Connect.
|
|
534
|
+
FileUtils.rm_f(upload_receipt_path(dist_dir))
|
|
535
|
+
ipa = export_local!(work, dist_dir)
|
|
536
|
+
upload_to_asc!(work, dist_dir) if upload_to_asc?
|
|
537
|
+
ipa
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
def export_local!(work, dist_dir)
|
|
541
|
+
export = File.join(work, "build", "export")
|
|
542
|
+
FileUtils.rm_rf(export)
|
|
543
|
+
UI.step("exporting .ipa #{UI.dim("(#{export_method}, profile #{provisioning_profile})")}")
|
|
544
|
+
Shellout.run_logged!({},
|
|
545
|
+
["xcodebuild", "-exportArchive",
|
|
546
|
+
"-archivePath", archive_path(work),
|
|
547
|
+
"-exportOptionsPlist", write_export_options(work),
|
|
548
|
+
"-exportPath", export],
|
|
549
|
+
log: File.join(dist_dir, "ios-export.log"), filter: LogFilter.for(:xcodebuild))
|
|
550
|
+
collect_ipa(export, dist_dir)
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
# -allowProvisioningUpdates is what lets the auth-key trio stand in for a
|
|
554
|
+
# signed-in Xcode account; `destination: upload` in the plist is what makes
|
|
555
|
+
# this an upload rather than a second export.
|
|
556
|
+
def upload_to_asc!(work, dist_dir)
|
|
557
|
+
upload = File.join(work, "build", "upload")
|
|
558
|
+
FileUtils.rm_rf(upload)
|
|
559
|
+
UI.step("uploading to App Store Connect #{UI.dim("(#{@channel}, key #{asc_env(:key_id)})")}")
|
|
560
|
+
Shellout.run_logged!({},
|
|
561
|
+
["xcodebuild", "-exportArchive",
|
|
562
|
+
"-archivePath", archive_path(work),
|
|
563
|
+
"-exportOptionsPlist", write_export_options(work, destination: "upload"),
|
|
564
|
+
"-exportPath", upload,
|
|
565
|
+
"-allowProvisioningUpdates",
|
|
566
|
+
"-authenticationKeyPath", asc_env(:key_path),
|
|
567
|
+
"-authenticationKeyID", asc_env(:key_id),
|
|
568
|
+
"-authenticationKeyIssuerID", asc_env(:issuer_id)],
|
|
569
|
+
log: File.join(dist_dir, "ios-upload.log"), filter: LogFilter.for(:xcodebuild))
|
|
570
|
+
write_upload_receipt(work, dist_dir)
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
def upload_receipt_path(dist_dir) = File.join(dist_dir, "ios-upload.json")
|
|
574
|
+
|
|
575
|
+
# The contract `every release` folds into the receipt's signing bag. Written
|
|
576
|
+
# only after the upload pass returned — run_logged! dies on failure — so the
|
|
577
|
+
# file existing IS the proof that App Store Connect accepted the build.
|
|
578
|
+
def write_upload_receipt(work, dist_dir)
|
|
579
|
+
require "json"
|
|
580
|
+
require "time"
|
|
581
|
+
File.write(upload_receipt_path(dist_dir), "#{JSON.pretty_generate(
|
|
582
|
+
"uploaded" => true,
|
|
583
|
+
"destination" => "app-store-connect",
|
|
584
|
+
"method" => export_method,
|
|
585
|
+
"channel" => @channel,
|
|
586
|
+
"build_number" => archive_build_number(work),
|
|
587
|
+
"uploaded_at" => Time.now.utc.iso8601
|
|
588
|
+
)}\n")
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
# What ASC will show as the build: the archive's own CFBundleVersion, which
|
|
592
|
+
# the upload pass is pinned to by manageAppVersionAndBuildNumber = false.
|
|
593
|
+
def archive_build_number(work)
|
|
594
|
+
out, status = Shellout.capture("plutil", "-extract", "ApplicationProperties.CFBundleVersion",
|
|
595
|
+
"raw", "-o", "-", File.join(archive_path(work), "Info.plist"))
|
|
596
|
+
status&.success? ? out.strip : nil
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
def archive_path(work) = File.join(work, "build", "App.xcarchive")
|
|
600
|
+
|
|
601
|
+
# `destination` picks the pass: "export" writes an .ipa, "upload" hands the
|
|
602
|
+
# archive to App Store Connect. manageAppVersionAndBuildNumber is
|
|
603
|
+
# LOAD-BEARING on the upload pass — left at its default Xcode silently bumps
|
|
604
|
+
# CFBundleVersion, and the uploaded build stops matching the stored .ipa.
|
|
605
|
+
# testFlightInternalTestingOnly is deliberately never emitted: it would
|
|
606
|
+
# permanently bar the build from external TestFlight and the App Store.
|
|
607
|
+
def write_export_options(work, destination: "export")
|
|
608
|
+
upload = destination == "upload"
|
|
609
|
+
path = File.join(work, "build", upload ? "ExportOptions-upload.plist" : "ExportOptions.plist")
|
|
610
|
+
managed = "\t<key>manageAppVersionAndBuildNumber</key>\n\t<false/>\n" if upload
|
|
611
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
612
|
+
File.write(path, <<~PLIST)
|
|
613
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
614
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
615
|
+
<plist version="1.0">
|
|
616
|
+
<dict>
|
|
617
|
+
\t<key>method</key>
|
|
618
|
+
\t<string>#{export_method}</string>
|
|
619
|
+
\t<key>teamID</key>
|
|
620
|
+
\t<string>#{team_id}</string>
|
|
621
|
+
\t<key>signingStyle</key>
|
|
622
|
+
\t<string>manual</string>
|
|
623
|
+
\t<key>provisioningProfiles</key>
|
|
624
|
+
\t<dict>
|
|
625
|
+
\t\t<key>#{bundle_id}</key>
|
|
626
|
+
\t\t<string>#{xml_escape(provisioning_profile)}</string>
|
|
627
|
+
\t</dict>
|
|
628
|
+
\t<key>destination</key>
|
|
629
|
+
\t<string>#{destination}</string>
|
|
630
|
+
#{managed}\t<key>uploadSymbols</key>
|
|
631
|
+
\t<true/>
|
|
632
|
+
</dict>
|
|
633
|
+
</plist>
|
|
634
|
+
PLIST
|
|
635
|
+
path
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
# Profile names are free-form and come from outside the CLI, so this is
|
|
639
|
+
# the one interpolated plist value that can't be trusted as XML.
|
|
640
|
+
def xml_escape(value) = value.to_s.gsub("&", "&").gsub("<", "<").gsub(">", ">")
|
|
641
|
+
|
|
642
|
+
def collect_ipa(export, dist_dir)
|
|
643
|
+
built = File.join(export, "App.ipa")
|
|
644
|
+
built = Dir.glob(File.join(export, "*.ipa")).min unless File.file?(built)
|
|
645
|
+
UI.die!("xcodebuild reported success but no .ipa in #{UI.short_path(export)}") unless built && File.file?(built)
|
|
646
|
+
|
|
647
|
+
dest = File.join(dist_dir, "#{name}.ipa")
|
|
648
|
+
FileUtils.rm_f(dest)
|
|
649
|
+
FileUtils.cp(built, dest)
|
|
650
|
+
dest
|
|
651
|
+
end
|
|
652
|
+
|
|
418
653
|
# Copy the product out of DerivedData. The ".simulator.app" suffix keeps
|
|
419
654
|
# it visibly distinct from the desktop dist/<Name>.app (and out of
|
|
420
655
|
# release.rb's dist/*.app glob).
|
data/lib/everywhere/cli.rb
CHANGED
|
@@ -6,6 +6,7 @@ require_relative "framework"
|
|
|
6
6
|
require_relative "commands/build"
|
|
7
7
|
require_relative "commands/clean"
|
|
8
8
|
require_relative "commands/dev"
|
|
9
|
+
require_relative "commands/preview"
|
|
9
10
|
require_relative "commands/logs"
|
|
10
11
|
require_relative "commands/icon"
|
|
11
12
|
require_relative "commands/doctor"
|
|
@@ -38,6 +39,7 @@ module Everywhere
|
|
|
38
39
|
register "doctor", Commands::Doctor
|
|
39
40
|
register "install", Commands::Install
|
|
40
41
|
register "dev", Commands::Dev
|
|
42
|
+
register "preview", Commands::Preview
|
|
41
43
|
register "logs", Commands::Logs
|
|
42
44
|
register "build", Commands::Build
|
|
43
45
|
register "clean", Commands::Clean
|
|
@@ -18,16 +18,16 @@ module Everywhere
|
|
|
18
18
|
module Commands
|
|
19
19
|
# Press the app into a single-file binary and make it runnable on this Mac.
|
|
20
20
|
#
|
|
21
|
-
# Encodes the
|
|
22
|
-
# 1.
|
|
23
|
-
# 2. BUNDLE_FORCE_RUBY_PLATFORM so native gems compile from source
|
|
21
|
+
# Encodes the macOS 26 workarounds discovered in the Phase 0 spike:
|
|
22
|
+
# 1. BUNDLE_FORCE_RUBY_PLATFORM so native gems compile from source
|
|
24
23
|
# (tebako's strip corrupts precompiled gems' signatures -> SIGKILL)
|
|
25
|
-
#
|
|
26
|
-
#
|
|
24
|
+
# 2. Homebrew bison on PATH + LG_VADDR for tebako itself
|
|
25
|
+
# 3. Post-press ad-hoc re-sign (macOS 26 rejects the pressed signature)
|
|
27
26
|
class Build < Dry::CLI::Command
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
# Derived from the host, not fixed: on an Intel Mac the pressed binary is
|
|
28
|
+
# x86_64, and a hardcoded arm64 would put that lie in the bundle metadata,
|
|
29
|
+
# the release receipt and every platform override keyed off the target.
|
|
30
|
+
DEFAULT_TARGET = RUBY_PLATFORM.match?(/arm64|aarch64/) ? "macos-arm64" : "macos-x86_64"
|
|
31
31
|
IOS_TARGET = "ios-arm64"
|
|
32
32
|
# The APK/AAB is universal, so the arch half is nominal — it exists only
|
|
33
33
|
# so every target reads as os-arch.
|
|
@@ -37,6 +37,8 @@ module Everywhere
|
|
|
37
37
|
# mobile platform is one entry rather than a third copy of resolve_target.
|
|
38
38
|
SHORTHAND_TARGETS = { ios: IOS_TARGET, android: ANDROID_TARGET }.freeze
|
|
39
39
|
|
|
40
|
+
ANDROID_FORMATS = %w[apk aab].freeze
|
|
41
|
+
|
|
40
42
|
desc "Package the app natively — desktop (tebako press + shell + .app), iOS (--ios) or Android (--android)"
|
|
41
43
|
|
|
42
44
|
option :root, default: ".", desc: "App root directory"
|
|
@@ -46,12 +48,17 @@ module Everywhere
|
|
|
46
48
|
option :app_name, default: nil, desc: "Bundle name (default: capitalized app dir)"
|
|
47
49
|
option :target, default: nil, desc: "Build target (os-arch) — selects any platforms: overrides, default: \"#{DEFAULT_TARGET}\""
|
|
48
50
|
option :shell_dir, default: nil, desc: "Path to the shell's src-tauri directory"
|
|
51
|
+
option :template_dir, default: nil, desc: "Path to the mobile shell template (iOS/Android)"
|
|
52
|
+
option :format, default: nil, desc: "Android artifact format (#{ANDROID_FORMATS.join(" | ")}), default: apk"
|
|
53
|
+
option :channel, default: "direct", desc: "Distribution channel (direct | testflight | app_store | ...)"
|
|
49
54
|
option :ios, type: :boolean, default: false, desc: "Build the iOS shell (shorthand for --target #{IOS_TARGET})"
|
|
50
55
|
option :android, type: :boolean, default: false, desc: "Build the Android shell (shorthand for --target #{ANDROID_TARGET})"
|
|
56
|
+
option :device, type: :boolean, default: false, desc: "iOS: archive and export a device .ipa instead of a simulator .app"
|
|
51
57
|
option :skip_press, type: :boolean, default: false, desc: "Reuse the existing pressed binary, only rebuild the .app"
|
|
52
58
|
|
|
53
59
|
def call(root: ".", output: nil, ruby: "4.0.6", entry: "native_boot.rb",
|
|
54
|
-
app_name: nil, target: nil, shell_dir: nil,
|
|
60
|
+
app_name: nil, target: nil, shell_dir: nil, template_dir: nil, format: nil,
|
|
61
|
+
channel: "direct", ios: false, android: false, device: false, skip_press: false, **)
|
|
55
62
|
target = resolve_target(target, ios: ios, android: android)
|
|
56
63
|
root_path = File.expand_path(root)
|
|
57
64
|
config = Everywhere::Config.load(root_path)
|
|
@@ -61,11 +68,20 @@ module Everywhere
|
|
|
61
68
|
# shells, each built by its own builder. Anything else has no builder yet.
|
|
62
69
|
case Everywhere::Config.os_of(target)
|
|
63
70
|
when "ios", "android"
|
|
64
|
-
|
|
65
|
-
|
|
71
|
+
# --output names a file for the pressed desktop binary, but a mobile
|
|
72
|
+
# build's product is whatever the platform toolchain emits — so here it
|
|
73
|
+
# names the directory those products land in.
|
|
74
|
+
dist_dir = output ? File.expand_path(output) : File.expand_path("dist", root_path)
|
|
75
|
+
app = mobile_builder(target).new(config: config, root: root_path, target: target,
|
|
76
|
+
template_dir: template_dir, **channel_option(target, channel))
|
|
77
|
+
.build!(dist_dir: dist_dir, **mobile_options(target, device: device, format: format))
|
|
66
78
|
UI.success("built #{rel(app, root_path)}")
|
|
67
79
|
return
|
|
68
|
-
when "macos" # the rest of this method
|
|
80
|
+
when "macos" # ...which is the rest of this method
|
|
81
|
+
# Every step of it ends in codesign and an .app bundle, so a Linux
|
|
82
|
+
# host can only fail — the question is whether it fails here or
|
|
83
|
+
# thirty minutes in, at the first codesign.
|
|
84
|
+
UI.die!("desktop builds are macOS-only for now") unless RUBY_PLATFORM.include?("darwin")
|
|
69
85
|
else
|
|
70
86
|
UI.die!("no builder for target #{target} — macos-*, ios-* and android-* are supported")
|
|
71
87
|
end
|
|
@@ -88,7 +104,7 @@ module Everywhere
|
|
|
88
104
|
# required locally, nothing to press — just shell + config in a bundle.
|
|
89
105
|
if config.remote?
|
|
90
106
|
UI.step("#{UI.bold("remote")} mode → #{UI.cyan(config.remote_url)} #{UI.dim("(no local server packaged)")}")
|
|
91
|
-
|
|
107
|
+
bundle_app(nil, app_name, shell_dir, config, target: target, dist_dir: File.expand_path("dist", root_path))
|
|
92
108
|
return
|
|
93
109
|
end
|
|
94
110
|
|
|
@@ -114,7 +130,7 @@ module Everywhere
|
|
|
114
130
|
press!(framework, entry, ruby, output_path)
|
|
115
131
|
end
|
|
116
132
|
|
|
117
|
-
bundle_app(output_path, app_name, shell_dir, config, target: target)
|
|
133
|
+
bundle_app(output_path, app_name, shell_dir, config, target: target)
|
|
118
134
|
|
|
119
135
|
UI.success("built #{rel(output_path, root_path)} (#{(File.size(output_path) / 1024.0 / 1024.0).round}MB)")
|
|
120
136
|
end
|
|
@@ -128,6 +144,34 @@ module Everywhere
|
|
|
128
144
|
Everywhere::Config.os_of(target) == "android" ? Builders::Android : Builders::Ios
|
|
129
145
|
end
|
|
130
146
|
|
|
147
|
+
# Only iOS distributes from the build host — the store upload rides along
|
|
148
|
+
# with the archive that's already on this machine. Android's Play upload
|
|
149
|
+
# happens platform-side, so its builder has no channel to know about.
|
|
150
|
+
def channel_option(target, channel)
|
|
151
|
+
Everywhere::Config.os_of(target) == "android" ? {} : { channel: channel }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Each mobile builder takes the one flag that only makes sense for it, so
|
|
155
|
+
# `--format aab` on an iOS build is a mistake we name rather than drop.
|
|
156
|
+
def mobile_options(target, device:, format:)
|
|
157
|
+
if Everywhere::Config.os_of(target) == "android"
|
|
158
|
+
UI.die!("--device is an iOS flag — Android always builds an installable artifact") if device
|
|
159
|
+
{ format: android_format(format) }
|
|
160
|
+
else
|
|
161
|
+
UI.die!("--format is an Android flag (#{ANDROID_FORMATS.join("/")}) — iOS builds a .app or .ipa") if format
|
|
162
|
+
{ device: device }
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def android_format(format)
|
|
167
|
+
return :apk if format.nil?
|
|
168
|
+
|
|
169
|
+
unless ANDROID_FORMATS.include?(format.to_s)
|
|
170
|
+
UI.die!("--format must be one of #{ANDROID_FORMATS.join(", ")} (got #{format})")
|
|
171
|
+
end
|
|
172
|
+
format.to_s.to_sym
|
|
173
|
+
end
|
|
174
|
+
|
|
131
175
|
# --ios/--android are shorthand for their target, and disagreeing with an
|
|
132
176
|
# explicit --target is a mistake worth naming rather than silently
|
|
133
177
|
# resolving one way: both spellings were deliberate, so we can't know
|
|
@@ -173,6 +217,7 @@ module Everywhere
|
|
|
173
217
|
end
|
|
174
218
|
|
|
175
219
|
def press!(framework, entry, ruby, output_path)
|
|
220
|
+
bison_prefix! # fail here, not forty minutes deep in tebako's log
|
|
176
221
|
ensure_gems!(framework) if framework.precompile?
|
|
177
222
|
|
|
178
223
|
UI.step("precompiling assets")
|
|
@@ -184,21 +229,24 @@ module Everywhere
|
|
|
184
229
|
# into the new binary. dist/ is ours — clear it.
|
|
185
230
|
FileUtils.rm_rf(out_dir) if out_dir.start_with?(framework.root + File::SEPARATOR)
|
|
186
231
|
FileUtils.mkdir_p(out_dir)
|
|
187
|
-
# Press into a staging dir OUTSIDE the app root for the same reason
|
|
188
|
-
# (tebako prints a big warning about exactly this).
|
|
189
|
-
staging = File.join(Dir.mktmpdir("everywhere-press"), File.basename(output_path))
|
|
190
232
|
log = "#{output_path}.build.log"
|
|
191
233
|
|
|
192
234
|
UI.step("rbe-tebako press #{UI.dim("(Ruby #{ruby} — grab a coffee on first run)")}")
|
|
193
235
|
UI.detail("full build log: #{UI.short_path(log)}")
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
236
|
+
# Press into a staging dir OUTSIDE the app root for the same reason
|
|
237
|
+
# (tebako prints a big warning about exactly this). Block form: the
|
|
238
|
+
# staging copy is a 100MB+ binary, and a failed press never reaches the mv.
|
|
239
|
+
Dir.mktmpdir("everywhere-press") do |staging_dir|
|
|
240
|
+
staging = File.join(staging_dir, File.basename(output_path))
|
|
241
|
+
Shellout.run_logged!(press_env, ["rbe-tebako", "press",
|
|
242
|
+
"--root=#{framework.root}", "--entry-point=#{entry}",
|
|
243
|
+
"--Ruby=#{ruby}", "--output=#{staging}"], log: log, filter: LogFilter.for(:tebako))
|
|
244
|
+
|
|
245
|
+
# Tebako's final strip step only warns (and still exits 0) if it can't
|
|
246
|
+
# write the output, so verify the artifact instead of trusting exit codes.
|
|
247
|
+
UI.die!("tebako reported success but no binary was created") unless File.exist?(staging)
|
|
248
|
+
FileUtils.mv(staging, output_path)
|
|
249
|
+
end
|
|
202
250
|
|
|
203
251
|
UI.step("re-signing for macOS")
|
|
204
252
|
Shellout.run!({}, "codesign", "--remove-signature", output_path)
|
|
@@ -215,11 +263,16 @@ module Everywhere
|
|
|
215
263
|
# Assemble dist/<App Name>.app: the release shell binary + the pressed
|
|
216
264
|
# Rails binary (as "app-server") + Info.plist + icon. An .app bundle is
|
|
217
265
|
# just a directory layout, so we build it by hand — no tauri-cli needed.
|
|
218
|
-
def bundle_app(server_binary, app_name, shell_dir, config, target:
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
#
|
|
222
|
-
#
|
|
266
|
+
def bundle_app(server_binary, app_name, shell_dir, config, target: DEFAULT_TARGET, dist_dir: nil)
|
|
267
|
+
Shellout.ensure_tool!("cargo", "the desktop shell is built with Rust — install it from https://rustup.rs")
|
|
268
|
+
|
|
269
|
+
# cargo always runs in a STAGED copy of the shell under
|
|
270
|
+
# ~/.rubyeverywhere, never in the gem (or in whatever --shell-dir
|
|
271
|
+
# points at): the icon swap below and cargo's own Cargo.lock would
|
|
272
|
+
# otherwise write into the installed gem — Errno::EACCES on a
|
|
273
|
+
# system-wide install, and one app's branding in the next app's build.
|
|
274
|
+
# An app with native.desktop additionally gets its Rust stamped in and a
|
|
275
|
+
# private target dir; the rest of this method doesn't need to know which.
|
|
223
276
|
prepared = Builders::Desktop.new(config: config, root: config.root, template_dir: shell_dir).prepare!
|
|
224
277
|
shell_dir = prepared.dir
|
|
225
278
|
dist_dir ||= File.dirname(server_binary)
|
|
@@ -249,21 +302,15 @@ module Everywhere
|
|
|
249
302
|
|
|
250
303
|
# Tauri bakes icons/icon.png into the shell binary and sets it as the
|
|
251
304
|
# Dock icon at runtime (stomping the bundle's icns after launch), so the
|
|
252
|
-
# shell must be COMPILED with the app's icon. Swap
|
|
253
|
-
#
|
|
254
|
-
#
|
|
255
|
-
#
|
|
256
|
-
# The restore only matters on the fast path, where shell_dir is the
|
|
257
|
-
# INSTALLED GEM and every app shares it — leaving one app's icon there
|
|
258
|
-
# would brand the next app's build. A stamped work dir belongs to this
|
|
259
|
-
# app alone, so its icon can stay: restoring would only force another
|
|
260
|
-
# re-embed on the next build.
|
|
305
|
+
# shell must be COMPILED with the app's icon. Swap the shaped master into
|
|
306
|
+
# the staged copy and touch build.rs so tauri-build re-embeds. Nothing to
|
|
307
|
+
# restore: staging re-seeds the copy from the template on every build, so
|
|
308
|
+
# the placeholder comes back by itself when app.icon goes away.
|
|
261
309
|
shell_icon = File.join(shell_dir, "icons", "icon.png")
|
|
262
|
-
placeholder_backup = "#{shell_icon}.placeholder"
|
|
263
310
|
if icon_source
|
|
264
|
-
FileUtils.cp(shell_icon, placeholder_backup) unless File.exist?(placeholder_backup)
|
|
265
311
|
# The shaped master is already RGBA; fall back to raw RGBA conversion
|
|
266
|
-
# if shaping failed
|
|
312
|
+
# if shaping failed. ensure_rgba refuses before writing, so a failure
|
|
313
|
+
# leaves the staged placeholder untouched.
|
|
267
314
|
dock_ok = if macos_icon == icon_source
|
|
268
315
|
Everywhere::PNG.ensure_rgba(icon_source, shell_icon)
|
|
269
316
|
else
|
|
@@ -272,21 +319,13 @@ module Everywhere
|
|
|
272
319
|
end
|
|
273
320
|
unless dock_ok
|
|
274
321
|
UI.warn "couldn't convert #{File.basename(icon_source)} to RGBA (unusual PNG flavor) — Dock icon will use the placeholder after launch"
|
|
275
|
-
FileUtils.cp(placeholder_backup, shell_icon)
|
|
276
322
|
end
|
|
277
323
|
FileUtils.touch(File.join(shell_dir, "build.rs"))
|
|
278
324
|
end
|
|
279
325
|
|
|
280
326
|
UI.step("building shell #{UI.dim(prepared.stamped? ? "(cargo release, per-app)" : "(cargo release)")}")
|
|
281
327
|
target_dir = prepared.target_dir
|
|
282
|
-
|
|
283
|
-
Shellout.run!({ "CARGO_TARGET_DIR" => target_dir }, "cargo", "build", "--release", chdir: shell_dir)
|
|
284
|
-
ensure
|
|
285
|
-
if !prepared.stamped? && icon_source && File.exist?(placeholder_backup)
|
|
286
|
-
FileUtils.mv(placeholder_backup, shell_icon)
|
|
287
|
-
FileUtils.touch(File.join(shell_dir, "build.rs"))
|
|
288
|
-
end
|
|
289
|
-
end
|
|
328
|
+
Shellout.run!({ "CARGO_TARGET_DIR" => target_dir }, "cargo", "build", "--release", chdir: shell_dir)
|
|
290
329
|
shell_bin = Dir[File.join(target_dir, "release/*")]
|
|
291
330
|
.find { |f| File.file?(f) && File.executable?(f) && !f.end_with?(".d") }
|
|
292
331
|
UI.die!("couldn't find release shell binary in #{target_dir}/release") unless shell_bin
|
|
@@ -312,7 +351,7 @@ module Everywhere
|
|
|
312
351
|
UI.warn "splash #{splash} not found — using the default splash"
|
|
313
352
|
end
|
|
314
353
|
end
|
|
315
|
-
icns = make_icns(macos_icon ||
|
|
354
|
+
icns = make_icns(macos_icon || shell_icon, resources)
|
|
316
355
|
File.write(File.join(app_dir, "Contents", "Info.plist"),
|
|
317
356
|
info_plist(app_name, exe_name, icns, config.bundle_id(target: target), config.version(target: target)))
|
|
318
357
|
|
|
@@ -404,14 +443,30 @@ module Everywhere
|
|
|
404
443
|
end
|
|
405
444
|
|
|
406
445
|
def press_env
|
|
407
|
-
|
|
446
|
+
{
|
|
408
447
|
"BUNDLE_FORCE_RUBY_PLATFORM" => "true",
|
|
409
|
-
"LG_VADDR" => "39"
|
|
448
|
+
"LG_VADDR" => "39",
|
|
449
|
+
"PATH" => "#{bison_prefix!}/bin:#{ENV.fetch("PATH")}"
|
|
410
450
|
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# tebako builds Ruby from source and macOS still ships bison 2.3 (2006),
|
|
454
|
+
# which fails deep inside that build with no hint of the cause. Homebrew's
|
|
455
|
+
# is the only one that works, so a missing one is a hard stop rather than
|
|
456
|
+
# a silently dropped PATH entry.
|
|
457
|
+
def bison_prefix!
|
|
458
|
+
@bison_prefix ||= brew_bison_prefix ||
|
|
459
|
+
UI.die!("tebako needs Homebrew bison — brew install bison")
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def brew_bison_prefix
|
|
463
|
+
return nil unless Shellout.tool?("brew")
|
|
464
|
+
|
|
465
|
+
prefix, status = Shellout.capture("brew", "--prefix", "bison")
|
|
466
|
+
prefix = prefix.to_s.strip
|
|
467
|
+
# `brew --prefix <formula>` answers with the opt path whether or not the
|
|
468
|
+
# formula is installed, so the bin dir has to be checked too.
|
|
469
|
+
prefix if status&.success? && !prefix.empty? && File.directory?(File.join(prefix, "bin"))
|
|
415
470
|
end
|
|
416
471
|
|
|
417
472
|
end
|
|
@@ -6,9 +6,9 @@ require "fileutils"
|
|
|
6
6
|
module Everywhere
|
|
7
7
|
module Commands
|
|
8
8
|
# Wipes the shared build caches under ~/.rubyeverywhere (or
|
|
9
|
-
# $RUBYEVERYWHERE_HOME): the cargo shell cache, the
|
|
10
|
-
#
|
|
11
|
-
# projects. Safe to run any time: everything here is derived output, so the
|
|
9
|
+
# $RUBYEVERYWHERE_HOME): the cargo shell cache, the staged desktop shells
|
|
10
|
+
# and their dev staging dirs, the iOS DerivedData / SPM / stamped-project
|
|
11
|
+
# dirs, and the Android Gradle home / icon font / stamped projects. Safe to run any time: everything here is derived output, so the
|
|
12
12
|
# next `every dev`/`every build` rebuilds it — cold, and for Android that
|
|
13
13
|
# includes re-downloading Gradle and the Material icon font.
|
|
14
14
|
class Clean < Dry::CLI::Command
|
|
@@ -16,6 +16,11 @@ module Everywhere
|
|
|
16
16
|
|
|
17
17
|
def call(**)
|
|
18
18
|
caches = [Everywhere::Paths.cargo_target_dir,
|
|
19
|
+
File.join(Everywhere::Paths.cache_dir, "desktop"),
|
|
20
|
+
File.join(Everywhere::Paths.cache_dir, "desktop-target"),
|
|
21
|
+
File.join(Everywhere::Paths.cache_dir, "desktop-app"),
|
|
22
|
+
File.join(Everywhere::Paths.cache_dir, "desktop-icon"),
|
|
23
|
+
File.join(Everywhere::Paths.cache_dir, "desktop-assets"),
|
|
19
24
|
Everywhere::Paths.ios_derived_data_dir,
|
|
20
25
|
Everywhere::Paths.ios_packages_dir,
|
|
21
26
|
File.join(Everywhere::Paths.cache_dir, "ios"),
|