autosparkle 0.0.8 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d68b72cb179b558388027771604619562da6a0293a8901b3aadf9c39fbd39df7
4
- data.tar.gz: eaa1e667baba2cd385a63de1d5102ca950b37ecc154b5c8256e6087947d8e973
3
+ metadata.gz: 5ecc21f98e6511792838c080397ff61a8f35abb44621e2ac8b43165b6a893d0f
4
+ data.tar.gz: '0239c58273e8b1d7a54d91cbea17a7036af6cec889eafbf77d5b14e8ea182372'
5
5
  SHA512:
6
- metadata.gz: 787e99f1fd204d1a9234d22109fc92a2572d09381c8277ffd83e85423ee9fbc286547c700e449876ac6ea3a2b7bf4912901856a5ba4fbd5875e9431ff2e3c540
7
- data.tar.gz: 23c2358c76555744a099b67441e032b422f6e5a068f62edda8fae42b74531296e053fe4302bc883ccfa3770911e038238054654d23fbfab7c09482d2aeb4b73c
6
+ metadata.gz: d8ef56bb0a64677667ed1e8ab43f704f2c113a6bf0d97c8ddbf7090a42159a8681417945a7b3339b73b66c5dffe308cdda571a874e655351136fa67174a2817a
7
+ data.tar.gz: 756e49be1e6843e182f46606c96450042c7dfaac4b3d398d6066340456bb202571300e2352e040156cb0ad52af7e4f68cdfb31bbbcac56a3b10ee79d82c79d9c
data/README.md CHANGED
@@ -1,4 +1,6 @@
1
1
  # autosparkle
2
+ ![GitHub Release](https://img.shields.io/github/v/release/hadiidbouk/autosparkle?style=flat&label=version)
3
+ ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/hadiidbouk/autosparkle/ci.yml)
2
4
 
3
5
  ![autosparkle main image](resources/readme-main-image.jpg)
4
6
 
@@ -59,13 +61,50 @@ autosparkle is a Ruby command line tool that automates the delivery of your macO
59
61
  ```bash
60
62
  git clone https://github.com/hadiidbouk/autosparkle.git
61
63
  ```
62
- 2. Call the `autosparkle.rb` script from the repository, you need to specify your Xcode project path and your environment file. Here's how you can do it:
63
- ```bash
64
- ruby autosparkle.rb --project-path <<YOUR XCODE PROJECT>> --env <<YOUR ENV NAME>>
65
- ```
64
+ 2. Execute the Ruby script `autosparkle.rb`, check the [Usage](#usage) section below.
65
+
66
+ ## Usage
67
+
68
+ There are two ways to utilize autosparkle. The first option is to automate the entire process by using the `automate` command. Alternatively, you can select any of the following commands (`export`, `package`, `distribute`) and build your own solution around them.
69
+
70
+
71
+ All of these commands require you to provide the environment file or its name. If you use the `automate` command or the `export` command, which require a project/workspace path, you can simply pass the environment name to the `--env` option. autosparkle will then search for a file named `.env.autosparkle.<<YOUR_ENV_NAME>>` in your project directory (where your Xcode project/workspace is located). For example, if you pass `--env local`, autosparkle will search for `.env.autosparkle.local`.
72
+
73
+ It is **recommended** to use the `automate` command for several reasons:
74
+
75
+ 1. It automatically handles the app versioning, ensuring consistency between the Info.plist and the `appcast.xml`.
76
+
77
+ 2. The custom keychain is created once for all the signing steps.
78
+
79
+ 3. All the generated files are conveniently located in one place at `~/Library/Developer/autosparkle/build`. When using the commands separately, each command will override any existing files in the build directory.
80
+
66
81
 
67
- You can also pass your workspace path using `--workspace-path` and specify the environment path using `--env-file`.
68
82
 
83
+ You can explore the functionality of each command by using the `--help` flag. For instance:
84
+ ```bash
85
+ autosparkle distribute --help
86
+ ```
87
+
88
+ Output:
89
+ ```bash
90
+ distribute
91
+
92
+ Usage: autosparkle distribute [options]
93
+
94
+ Distribute your package to the specified storage and update the appcast.xml file
95
+
96
+ Options:
97
+ --dmg-path PATH Path of the DMG file to be distributed
98
+ --app-display-name NAME Name of the app inside the DMG without the .app extension
99
+ --marketing-version VERSION Marketing version of the app
100
+ --current-project-version VERSION Current project version of the app
101
+ --minimum-macos-version VERSION Minimum macOS version required to run the app, defaults to 14.0
102
+ ```
103
+
104
+
105
+ > **Note:**
106
+ >
107
+ > When using the **distribute** command, autosparkle will automatically calculate the new marketing version and the current project version from the uploaded appcast file (if it exists). If you want to override these values, make sure to pass them using the `--marketing-version` and `--current-project-version` options. Additionally, make sure to set them in your app's Info.plist before exporting the app.
69
108
 
70
109
  ## Environment
71
110
  autosparkle utilizes the [dotenv](https://github.com/bkeepers/dotenv) gem to manage environment variables.
@@ -89,7 +128,7 @@ Below are the list of environment variables used by autosparkle:
89
128
  | APP_SPECIFIC_PASSWORD | The App specific password that will be used in the notarize step | Yes | |
90
129
  | DEVELOPER_ID_APPLICATION_BASE64 | The Developer ID Application base64 certificate | Yes | |
91
130
  | DEVELOPER_ID_APPLICATION_PASSWORD | The Developer ID Application base64 certificate password | Yes | |
92
- | DMG_BACKGROUND_IMAGE | The DMG background image path that should exist in the project folder | Yes | |
131
+ | DMG_BACKGROUND_IMAGE | The path to the DMG background image. Make sure to use an image with the same width and height as the window to ensure a proper fit. | No | [View it here](lib/autosparkle/resources/default-dmg-background.png) |
93
132
  | DMG_WINDOW_WIDTH | The DMG "Drag to Applications" window width | Yes | |
94
133
  | DMG_WINDOW_HEIGHT | The DMG "Drag to Applications" window height | Yes | |
95
134
  | DMG_ICON_SIZE | The icon size of the app and the Applications folder in the window | Yes | |
@@ -7,6 +7,13 @@ require_relative '../helpers/puts_helpers'
7
7
  require_relative '../storages/aws_s3_storage'
8
8
  require_relative 'variables/default_environment_variables'
9
9
 
10
+ module Command
11
+ EXPORT = 0
12
+ PACKAGE = 1
13
+ DISTRIBUTE = 2
14
+ AUTOMATE = 3
15
+ end
16
+
10
17
  # Environment module to load the environment variables
11
18
  # It contains the app state
12
19
  module Env
@@ -17,27 +24,69 @@ module Env
17
24
  class << self
18
25
  attr_accessor :variables, :storage, :verbose_enabled
19
26
 
20
- def initialize(options)
21
- project_path = options[:project_path]
22
- workspace_path = options[:workspace_path]
23
- project_directory_path = File.dirname(workspace_path || project_path)
27
+ def initialize(options, command)
28
+ project_path = options.project_path
29
+ workspace_path = options.workspace_path
24
30
 
25
- load_environment(project_directory_path, options)
31
+ project_directory_path = File.dirname(workspace_path || project_path) if workspace_path || project_path
32
+ ENV['PROJECT_DIRECTORY_PATH'] = project_directory_path
26
33
 
27
- @variables = DefaultEnvironmentVariables.new(project_directory_path)
28
- @storage = retrieve_storage
34
+ load_environment(project_directory_path, options.env)
29
35
 
30
- retrieve_variables_from_xcode(project_path, workspace_path)
31
- set_up_app_versions(project_path, workspace_path)
36
+ @variables = DefaultEnvironmentVariables.new
32
37
 
33
- puts_if_verbose "Running the script with the #{options[:env]} environment...\n"
38
+ case command
39
+ when Command::EXPORT
40
+ initialize_export(options)
41
+ when Command::PACKAGE
42
+ initialize_package(options)
43
+ when Command::DISTRIBUTE
44
+ initialize_distribute(options)
45
+ when Command::AUTOMATE
46
+ initialize_automate(options)
47
+ end
48
+
49
+ puts_if_verbose "Running the script with the #{options.env} environment...\n"
34
50
  end
35
51
  end
36
52
 
37
- def self.load_environment(project_directory_path, options)
38
- env_file_path = options[:env_file] || File.join(project_directory_path, ".env.autosparkle.#{options[:env]}")
39
- raise "#{env_file_path} does not exist." unless File.exist?(env_file_path)
53
+ def self.initialize_export(options)
54
+ retrieve_variables_from_xcode(options.project_path, options.workspace_path)
55
+ end
56
+
57
+ def self.initialize_package(options)
58
+ ENV['APP_DISPLAY_NAME'] = options.app_display_name
59
+ end
60
+
61
+ def self.initialize_distribute(options)
62
+ ENV['APP_DISPLAY_NAME'] = options.app_display_name
63
+ ENV['MINIMUM_MACOS_VERSION'] = options.minimum_macos_version
64
+
65
+ @storage = retrieve_storage
66
+ retreive_versions_variables_form_appcast(options)
67
+ end
68
+
69
+ def self.initialize_automate(options)
70
+ retrieve_variables_from_xcode(options.project_path, options.workspace_path)
71
+
72
+ @storage = retrieve_storage
73
+ retreive_versions_variables_form_appcast(options)
74
+ end
75
+
76
+ def self.load_environment(project_directory_path, env)
77
+ env_is_file = File.file?(env)
78
+ if !project_directory_path && !env_is_file
79
+ raise 'Cannot find the project directory path to load the environment variables'
80
+ end
81
+
82
+ env_file_path = if env_is_file
83
+ env
84
+ else
85
+ File.join(project_directory_path, ".env.autosparkle.#{env}")
86
+ end
40
87
 
88
+ ENV['ENV_FILE_PATH'] = env_file_path
89
+ puts_if_verbose "Loading the environment variables from #{env_file_path}..."
41
90
  Dotenv.load(env_file_path)
42
91
  end
43
92
 
@@ -61,13 +110,10 @@ module Env
61
110
  ENV['APP_DISPLAY_NAME'] = Xcodeproj.get_app_display_name(project_path, workspace_path)
62
111
  end
63
112
 
64
- def self.set_up_app_versions(project_path, workspace_path)
113
+ def self.retreive_versions_variables_form_appcast(options)
65
114
  marketing_version, current_project_version = AppcastXML.retreive_versions(@storage.deployed_appcast_xml)
66
115
 
67
- ENV['MARKETING_VERSION'] = marketing_version
68
- ENV['CURRENT_PROJECT_VERSION'] = current_project_version
69
-
70
- puts_if_verbose 'Updating the project versions from the environment variables...'
71
- Xcodeproj.update_project_version(project_path, workspace_path)
116
+ ENV['MARKETING_VERSION'] = options.marketing_version || marketing_version
117
+ ENV['CURRENT_PROJECT_VERSION'] = options.current_project_version || current_project_version
72
118
  end
73
119
  end
@@ -6,8 +6,9 @@ require_relative 'base_environment_variables'
6
6
 
7
7
  # A class to load the default environment variables
8
8
  class DefaultEnvironmentVariables < BaseEnvironmentVariables
9
- def initialize(project_directory_path)
9
+ def initialize
10
10
  super({
11
+ env_file_path: 'ENV_FILE_PATH',
11
12
  project_directory_path: 'PROJECT_DIRECTORY_PATH',
12
13
  scheme: 'SCHEME',
13
14
  configuration: 'CONFIGURATION',
@@ -29,7 +30,5 @@ class DefaultEnvironmentVariables < BaseEnvironmentVariables
29
30
  dmg_window_width: 'DMG_WINDOW_WIDTH',
30
31
  dmg_window_height: 'DMG_WINDOW_HEIGHT'
31
32
  })
32
-
33
- ENV['PROJECT_DIRECTORY_PATH'] = project_directory_path
34
33
  end
35
34
  end
@@ -4,4 +4,7 @@
4
4
  module Constants
5
5
  NOTARIZE_KEYCHAIN_PROFILE = 'autosparkle.keychain.notarize.profile'
6
6
  AUTOSPARKLE_BUILD_DIRECTORY_PATH = '~/Library/Developer/autosparkle/build'
7
+ KEYCHAIN_NAME = 'temporary.autosparkle.keychain'
8
+ KEYCHAIN_PATH = "~/Library/Keychains/#{KEYCHAIN_NAME}-db"
9
+ KEYCHAIN_PASSWORD = 'autosparkle'
7
10
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'fileutils'
4
+ require 'xcodeproj'
4
5
  require_relative 'build_directory_helpers'
5
6
  require_relative 'puts_helpers'
6
7
  require_relative '../environment/environment'
@@ -9,9 +10,10 @@ require_relative '../environment/environment'
9
10
  module DMG
10
11
  def self.create(app_path)
11
12
  volume_name = Env.variables.app_display_name
12
- background_image_path = "#{Env.variables.project_directory_path}/#{Env.variables.dmg_background_image}"
13
13
 
14
- dmg_path = create_blank_dmg(app_path, background_image_path, volume_name)
14
+ background_image_path = dmg_background_image_path
15
+ volume_size = calculate_volume_size(app_path, background_image_path)
16
+ dmg_path = create_blank_dmg(volume_size, volume_name)
15
17
  mount(dmg_path, volume_name)
16
18
  copy_app_and_set_symbolic_link(app_path, volume_name)
17
19
  copy_background_image(background_image_path, volume_name)
@@ -20,16 +22,32 @@ module DMG
20
22
  create_read_only_dmg(dmg_path)
21
23
  end
22
24
 
23
- def self.create_blank_dmg(app_path, background_image_path, volume_name)
25
+ def self.dmg_background_image_path
26
+ background_image_path = Env.variables.dmg_background_image
27
+ final_background_image_path = if background_image_path.start_with?('~')
28
+ File.expand_path(background_image_path)
29
+ elsif background_image_path.start_with?('/')
30
+ background_image_path
31
+ else
32
+ File.expand_path(background_image_path, Env.variables.env_file_path)
33
+ end
34
+
35
+ raise 'DMG background image not found' unless !background_image_path || File.exist?(final_background_image_path)
36
+
37
+ default_dmg_background_path = File.join(File.dirname(__dir__), 'resources', 'default-dmg-background.png')
38
+ final_background_image_path ||= default_dmg_background_path
39
+ final_background_image_path
40
+ end
41
+
42
+ def self.create_blank_dmg(volume_size, volume_name)
24
43
  puts_if_verbose 'Creating a blank DMG...'
25
- volume_size = volume_size(app_path, background_image_path)
26
44
  uuid = `uuidgen`.strip
27
45
  dmg_path = BuildDirectory.new_path("#{Env.variables.app_display_name}-#{uuid}.dmg")
28
46
  execute_command("hdiutil create -size #{volume_size}m -fs HFS+ -volname '#{volume_name}' -ov '#{dmg_path}'")
29
47
  dmg_path
30
48
  end
31
49
 
32
- def self.volume_size(app_path, background_image_path)
50
+ def self.calculate_volume_size(app_path, background_image_path)
33
51
  app_size_kb = `du -sk "#{app_path}"`.split("\t").first.to_i
34
52
  background_size_kb = File.size(background_image_path) / 1024
35
53
  buffer_size_kb = 20 * 1024
@@ -51,7 +69,10 @@ module DMG
51
69
  def self.copy_background_image(background_image_path, volume_name)
52
70
  puts_if_verbose 'Copying the background image to the DMG...'
53
71
  FileUtils.mkdir_p("/Volumes/#{volume_name}/.background")
54
- FileUtils.cp(background_image_path, "/Volumes/#{volume_name}/.background/")
72
+
73
+ background_image_extension = File.extname(background_image_path)
74
+ FileUtils.cp(background_image_path,
75
+ "/Volumes/#{volume_name}/.background/dmg-background#{background_image_extension}")
55
76
  end
56
77
 
57
78
  def self.customize_dmg_appearence(volume_name)
@@ -78,7 +99,7 @@ module DMG
78
99
  set the bounds of container window to {0, 0, #{window_width}, #{window_height}}
79
100
  set arrangement of icon view options of container window to not arranged
80
101
  set icon size of icon view options of container window to #{Env.variables.dmg_icon_size}
81
- set background picture of icon view options of container window to file ".background:#{Env.variables.dmg_background_image}"
102
+ set background picture of icon view options of container window to file ".background:dmg-background.png"
82
103
  set position of item "#{Env.variables.app_display_name}.app" of container window to {#{app_x_position}, #{item_y_position}}
83
104
  set position of item "Applications" of container window to {#{applications_x_position}, #{item_y_position}}
84
105
  close
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'securerandom'
2
4
  require 'open3'
3
5
  require 'base64'
@@ -12,36 +14,31 @@ require_relative 'puts_helpers'
12
14
  # The temporary keychain will be deleted after the block has been executed.
13
15
  #
14
16
  def with_temporary_keychain
15
- keychain_name = "temporary.autosparkle.keychain-#{SecureRandom.uuid}"
16
- keychain_path = "~/Library/Keychains/#{keychain_name}-db"
17
- password = SecureRandom.hex(16)
18
-
19
17
  original_keychain_list = `security list-keychains`.strip.split("\n").map(&:strip)
20
18
  default_keychain = execute_command('security default-keychain')
21
19
  default_keychain_path = default_keychain.gsub(/"(.*)"/, '\1').strip
22
20
 
23
- delete_temporary_keychain_if_needed(keychain_path)
21
+ delete_temporary_keychain_if_needed
24
22
 
25
23
  begin
26
24
  # Create a temporary keychain
27
- create_temporary_keychain(keychain_path, password, original_keychain_list)
28
- import_certificates_in_temporary_keychain(keychain_path)
25
+ create_temporary_keychain(original_keychain_list)
26
+ import_certificates_in_temporary_keychain
29
27
  execute_command("security set-key-partition-list -S apple-tool:,apple:,codesign: \\
30
- -s -k \"#{password}\" #{keychain_path}")
28
+ -s -k \"#{Constants::KEYCHAIN_PASSWORD}\" #{Constants::KEYCHAIN_PATH}")
31
29
 
32
30
  # Fetch the certificate names and team ids from the temporary keychain
33
- application_cert_name, application_team_id = fetch_application_certificate_info(keychain_path)
34
- store_notarization_credentials(keychain_path, application_team_id)
31
+ application_cert_name, application_team_id = fetch_application_certificate_info
32
+ store_notarization_credentials(application_team_id)
35
33
 
36
34
  keychain_info = {
37
- keychain_path: keychain_path,
38
35
  application_cert_name: application_cert_name,
39
36
  application_team_id: application_team_id
40
37
  }
41
38
  yield(keychain_info) if block_given?
42
39
  ensure
43
40
  puts_if_verbose 'Ensuring cleanup of temporary keychain...'
44
- delete_temporary_keychain_if_needed(keychain_path)
41
+ delete_temporary_keychain_if_needed
45
42
 
46
43
  # Reset the keychain to the default
47
44
  execute_command("security list-keychains -s #{original_keychain_list.join(' ')}")
@@ -51,23 +48,23 @@ end
51
48
 
52
49
  private
53
50
 
54
- def create_temporary_keychain(keychain_path, password, original_keychain_list)
55
- execute_command("security create-keychain -p \"#{password}\" #{keychain_path}")
56
- execute_command("security unlock-keychain -p \"#{password}\" #{keychain_path}")
57
- execute_command("security list-keychains -d user -s #{(original_keychain_list + [keychain_path]).join(' ')}")
58
- execute_command("security default-keychain -s #{keychain_path}")
51
+ def create_temporary_keychain(original_keychain_list)
52
+ execute_command("security create-keychain -p \"#{Constants::KEYCHAIN_PASSWORD}\" #{Constants::KEYCHAIN_PATH}")
53
+ execute_command("security unlock-keychain -p \"#{Constants::KEYCHAIN_PASSWORD}\" #{Constants::KEYCHAIN_PATH}")
54
+ execute_command("security list-keychains -d user -s #{(original_keychain_list + [Constants::KEYCHAIN_PATH]).join(' ')}")
55
+ execute_command("security default-keychain -s #{Constants::KEYCHAIN_PATH}")
59
56
  end
60
57
 
61
- def store_notarization_credentials(keychain_path, application_team_id)
58
+ def store_notarization_credentials(application_team_id)
62
59
  command = "xcrun notarytool store-credentials #{Constants::NOTARIZE_KEYCHAIN_PROFILE} \\
63
- --keychain #{keychain_path} \\
60
+ --keychain #{Constants::KEYCHAIN_PATH} \\
64
61
  --apple-id #{Env.variables.apple_id} \\
65
62
  --team-id #{application_team_id} \\
66
63
  --password #{Env.variables.app_specific_password}"
67
64
  execute_command(command)
68
65
  end
69
66
 
70
- def import_certificates_in_temporary_keychain(keychain_path)
67
+ def import_certificates_in_temporary_keychain
71
68
  developer_id_application_p12 = Base64.decode64(Env.variables.developer_id_application_base64 || '')
72
69
 
73
70
  # Create temporary files for the .p12 certificates
@@ -78,12 +75,11 @@ def import_certificates_in_temporary_keychain(keychain_path)
78
75
  application_cert_file.close
79
76
 
80
77
  # Import the certificates into the temporary keychain
81
- import_file_to_keychain(keychain_path, application_cert_file.path,
82
- Env.variables.developer_id_application_password)
78
+ import_file_to_keychain(application_cert_file.path, Env.variables.developer_id_application_password)
83
79
  end
84
80
 
85
- def import_file_to_keychain(keychain_path, file_path, password)
86
- command = "security import #{file_path} -k #{keychain_path} -P #{password}"
81
+ def import_file_to_keychain(file_path, password)
82
+ command = "security import #{file_path} -k #{Constants::KEYCHAIN_PATH} -P #{password}"
87
83
  command += ' -T /usr/bin/codesign'
88
84
  command += ' -T /usr/bin/security'
89
85
  command += ' -T /usr/bin/productbuild'
@@ -91,17 +87,21 @@ def import_file_to_keychain(keychain_path, file_path, password)
91
87
  execute_command(command)
92
88
  end
93
89
 
94
- def fetch_certificate_info(keychain_path, certificate_type)
95
- command = "security find-certificate -c \"#{certificate_type}\" #{keychain_path} | grep \"labl\" | sed -E 's/^.*\"labl\"<blob>=\"(.*)\".*/\\1/'"
90
+ def fetch_certificate_info(certificate_type)
91
+ command = "security find-certificate -c \"#{certificate_type}\" #{Constants::KEYCHAIN_PATH}"
92
+ command += " | grep \"labl\" | sed -E 's/^.*\"labl\"<blob>=\"(.*)\".*/\\1/'"
96
93
  name = `#{command}`.strip
97
94
  team_id = name[/\(([^)]+)\)$/, 1]
98
95
  [name, team_id]
99
96
  end
100
97
 
101
- def fetch_application_certificate_info(keychain_path)
102
- fetch_certificate_info(keychain_path, 'Developer ID Application')
98
+ def fetch_application_certificate_info
99
+ fetch_certificate_info('Developer ID Application')
103
100
  end
104
101
 
105
- def delete_temporary_keychain_if_needed(keychain_path)
106
- execute_command("security delete-keychain #{keychain_path}") if File.exist?(File.expand_path(keychain_path.to_s))
102
+ def delete_temporary_keychain_if_needed
103
+ return unless File.exist?(File.expand_path(Constants::KEYCHAIN_PATH.to_s))
104
+
105
+ execute_command("security delete-keychain #{Constants::KEYCHAIN_PATH}")
106
+ execute_command("rm -rf #{Constants::KEYCHAIN_PATH}")
107
107
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'colorize'
2
4
  require 'xcodeproj'
3
5
  require 'plist'
@@ -54,7 +56,7 @@ module Xcodeproj
54
56
  end
55
57
  end
56
58
 
57
- raise "Target not found for scheme #{Env.variables.scheme_name}".red unless target
59
+ raise "Target not found for scheme #{Env.variables.scheme}".red unless target
58
60
 
59
61
  [project, target]
60
62
  end
@@ -1,5 +1,8 @@
1
- require 'optparse'
2
- require 'securerandom'
1
+ # frozen_string_literal: true
2
+
3
+ require 'commander/import'
4
+ require 'fileutils'
5
+ require_relative 'metadata'
3
6
  require_relative 'packaging'
4
7
  require_relative 'distribution'
5
8
  require_relative 'helpers/build_directory_helpers'
@@ -7,67 +10,153 @@ require_relative 'helpers/commands_helpers'
7
10
  require_relative 'helpers/keychain_helpers'
8
11
  require_relative 'helpers/xcodeproj_helpers'
9
12
  require_relative 'helpers/puts_helpers'
13
+ require_relative 'helpers/dmg_helpers'
10
14
  require_relative 'environment/environment'
11
15
 
12
- def extract_options
13
- options = {}
16
+ program :name, AutoSparkle::NAME
17
+ program :version, AutoSparkle::VERSION
18
+ program :description, AutoSparkle::DESCRIPTION
19
+ program :help_formatter, :compact
14
20
 
15
- OptionParser.new do |opts|
16
- opts.banner = 'Usage: automate-sparkle.rb [options]'
21
+ default_command :help
22
+ global_option('--env ENVIRONMENT', String, 'Environment to load (aka. local, production) or a path to the env file')
23
+ global_option('--verbose', 'Enable verbose mode') { Env.verbose_enabled = true }
17
24
 
18
- opts.on('--project-path PATH', 'Path to the Xcode project') do |path|
19
- options[:project_path] = path
20
- end
25
+ command :export do |c|
26
+ c.syntax = "#{AutoSparkle::NAME} export [options]"
27
+ c.description = 'Archive and export the macOS app'
28
+ c.option('--project-path PATH', String, 'Path to the Xcode project')
29
+ c.option('--workspace-path PATH', String, 'Path to the Xcode workspace')
30
+ c.option('--skip-sparkle-steps', 'Skip the sparkle config check and signing the framework')
31
+ c.option('--output-dir PATH', String, 'Path to the output directory')
32
+ c.action do |_args, options|
33
+ options.default \
34
+ output_dir: Dir.pwd
21
35
 
22
- opts.on('--workspace-path PATH', 'Path to the Xcode workspace') do |path|
23
- options[:workspace_path] = path
24
- end
36
+ raise 'env name/path is required.' unless options.env
37
+ raise 'Xcode Project/Workspace file is required.' unless options.workspace_path || options.project_path
38
+ raise 'Environment/Environment file is required.' unless options.env || options.env_file
25
39
 
26
- env_description = 'Environment to load (aka. local, production)'
27
- env_description += ' it will look inside the Xcode project/workspace path'
28
- env_description += ' for a file named `.autosparkle.env.<environment>`'
40
+ BuildDirectory.create_build_directory
41
+ Env.initialize(options, Command::EXPORT)
29
42
 
30
- opts.on('--env ENVIRONMENT', env_description) do |env|
31
- options[:env] = env
43
+ unless options.skip_sparkle_steps
44
+ Xcodeproj.check_sparkle_configuration_existence(
45
+ options.project_path,
46
+ options.workspace_path
47
+ )
32
48
  end
33
49
 
34
- opts.on('--env-file PATH', 'Path to the environment file') do |path|
35
- options[:env_file] = path
36
- end
50
+ with_temporary_keychain do |keychain_info|
51
+ archive_and_sign_hash = {
52
+ application_cert_name: keychain_info[:application_cert_name],
53
+ application_team_id: keychain_info[:application_team_id],
54
+ project_path: options.project_path,
55
+ workspace_path: options.workspace_path,
56
+ output_dir: options.output_dir,
57
+ skip_sparkle_steps: options.skip_sparkle_steps
58
+ }
37
59
 
38
- opts.on('-v', 'Enable verbose mode') do |verbose|
39
- Env.verbose_enabled = verbose
40
- end
41
- end.parse!
60
+ exported_app_path = Packaging.archive_and_sign(archive_and_sign_hash)
42
61
 
43
- # Check if both project path and environment are specified
44
- unless (options[:workspace_path] || options[:project_path]) && (options[:env] || options[:env_file])
45
- raise 'Xcode Project/Workspace path and Environment/Environment file are required.'
62
+ puts "Exported the app to #{exported_app_path}"
63
+ end
46
64
  end
65
+ end
47
66
 
48
- options
67
+ command :package do |c|
68
+ c.syntax = "#{AutoSparkle::NAME} package [options]"
69
+ c.description = 'Package the macOS app into a DMG file'
70
+ c.option('--app-path PATH', String, 'Path to the exported app')
71
+ c.option('--output-dir PATH', String, 'Path to the output directory')
72
+ c.action do |_args, options|
73
+ options.default \
74
+ output_dir: Dir.pwd
75
+
76
+ raise 'env name/path is required.' unless options.env
77
+ raise 'App path is required.' unless options.app_path
78
+
79
+ # Add the app-path file name without the extension to options
80
+ app_path = File.expand_path(options.app_path)
81
+ options.app_display_name = File.basename(app_path, '.*')
82
+
83
+ BuildDirectory.create_build_directory
84
+ Env.initialize(options, Command::PACKAGE)
85
+
86
+ with_temporary_keychain do |keychain_info|
87
+ dmg_path = Packaging.create_and_sign_dmg(
88
+ app_path,
89
+ keychain_info[:application_cert_name],
90
+ options.output_dir
91
+ )
92
+
93
+ puts "Packaged the app into a DMG file at #{dmg_path}"
94
+ end
95
+ end
49
96
  end
50
97
 
51
- options = extract_options
52
- Env.initialize(options)
53
-
54
- Xcodeproj.check_sparkle_configuration_existence(options[:project_path], options[:workspace_path])
55
-
56
- BuildDirectory.create_build_directory
57
-
58
- puts "Automating the delivery of #{Env.variables.app_display_name} version #{Env.variables.marketing_version} (#{Env.variables.current_project_version})..."
59
- dmg_path = nil
60
- with_temporary_keychain do |keychain_info|
61
- exported_app_path = Packaging.archive_and_sign(
62
- keychain_info[:application_cert_name],
63
- keychain_info[:application_team_id],
64
- options
65
- )
66
- dmg_path = Packaging.create_and_sign_dmg(
67
- exported_app_path,
68
- keychain_info[:application_cert_name],
69
- keychain_info[:keychain_path]
70
- )
98
+ command :distribute do |c|
99
+ c.syntax = "#{AutoSparkle::NAME} distribute [options]"
100
+ c.description = 'Distribute your package to the specified storage and update the appcast.xml file'
101
+ c.option('--dmg-path PATH', String, 'Path of the DMG file to be distributed')
102
+ c.option('--app-display-name NAME', String, 'Name of the app inside the DMG without the .app extension')
103
+ c.option('--marketing-version VERSION', String, 'Marketing version of the app')
104
+ c.option('--current-project-version VERSION', String, 'Current project version of the app')
105
+ c.option('--minimum-macos-version VERSION', String, 'Minimum macOS version required to run the app, defaults to 14.0')
106
+ c.action do |_args, options|
107
+ raise 'env name/path is required.' unless options.env
108
+ raise 'dmg path is required, please pass it using --dmg-path' unless options.dmg_path
109
+ raise 'No dmg file found at the specified path' unless File.exist?(options.dmg_path)
110
+ raise 'App display name is required.' unless options.app_display_name
111
+
112
+ options.default \
113
+ minimum_macos_version: '14.0'
114
+
115
+ BuildDirectory.create_build_directory
116
+ Env.initialize(options, Command::DISTRIBUTE)
117
+
118
+ Distribution.upload_update(options.dmg_path)
119
+ end
71
120
  end
72
121
 
73
- Distribution.upload_update(dmg_path)
122
+ command :automate do |c|
123
+ c.syntax = "#{AutoSparkle::NAME} automate [options]"
124
+ c.description = 'Automate the export, packaging, and distribution of the macOS app'
125
+ c.option('--project-path PATH', String, 'Path to the Xcode project')
126
+ c.option('--workspace-path PATH', String, 'Path to the Xcode workspace')
127
+ c.action do |_args, options|
128
+ raise 'env name/path is required.' unless options.env
129
+ raise 'Xcode Project/Workspace path is required.' unless options.workspace_path || options.project_path
130
+
131
+ BuildDirectory.create_build_directory
132
+ Env.initialize(options, Command::AUTOMATE)
133
+
134
+ app_version = "#{Env.variables.marketing_version} (#{Env.variables.current_project_version})"
135
+ puts "Automating the delivery of #{Env.variables.app_display_name} version #{app_version} ..."
136
+
137
+ Xcodeproj.update_project_version(options.project_path, options.workspace_path)
138
+
139
+ dmg_path = nil
140
+ with_temporary_keychain do |keychain_info|
141
+ # 1. Export the app
142
+ archive_and_sign_hash = {
143
+ application_cert_name: keychain_info[:application_cert_name],
144
+ application_team_id: keychain_info[:application_team_id],
145
+ project_path: options.project_path,
146
+ workspace_path: options.workspace_path
147
+ }
148
+
149
+ exported_app_path = Packaging.archive_and_sign(archive_and_sign_hash)
150
+
151
+ # 2. Package the app
152
+ dmg_path = Packaging.create_and_sign_dmg(
153
+ exported_app_path,
154
+ keychain_info[:application_cert_name],
155
+ nil
156
+ )
157
+ end
158
+
159
+ # 3. Distribute the app
160
+ Distribution.upload_update(dmg_path)
161
+ end
162
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AutoSparkle
4
+ NAME = 'autosparkle'
5
+ VERSION = '0.1.0'
6
+ SUMMARY = 'Automate macOS app delivery outside the App Store'
7
+ DESCRIPTION = 'Autosparkle is a command-line tool that automates the delivery of macOS apps outside the App Store. It is designed to be used in CI/CD pipelines to automate the process of archiving, signing, notarizing, and distributing macOS apps.'
8
+ end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'Plist'
3
4
  require_relative 'helpers/build_directory_helpers'
4
5
  require_relative 'helpers/constants'
5
6
  require_relative 'helpers/commands_helpers'
@@ -9,43 +10,50 @@ require_relative 'environment/environment'
9
10
 
10
11
  # Packaging module to archive and sign the app
11
12
  module Packaging
12
- def self.archive_and_sign(
13
- application_cert_name,
14
- application_cert_team_id,
15
- options
16
- )
17
-
13
+ def self.archive_and_sign(hash)
18
14
  puts_title 'Archiving and signing the app'
19
15
  app_archive_path = BuildDirectory.new_path("#{Env.variables.app_display_name}.xcarchive")
20
16
  archive_command = 'xcodebuild clean analyze archive'
21
17
  archive_command += " -scheme #{Env.variables.scheme}"
22
18
  archive_command += " -archivePath '#{app_archive_path}'"
23
- archive_command += " CODE_SIGN_IDENTITY='#{application_cert_name}'"
24
- archive_command += " DEVELOPMENT_TEAM='#{application_cert_team_id}'"
19
+ archive_command += " CODE_SIGN_IDENTITY='#{hash[:application_cert_name]}'"
20
+ archive_command += " DEVELOPMENT_TEAM='#{hash[:application_team_id]}'"
25
21
  archive_command += " -configuration #{Env.variables.configuration}"
26
22
  archive_command += " OTHER_CODE_SIGN_FLAGS='--timestamp --options=runtime'"
27
23
 
28
- archive_command += if options[:workspace_path]
29
- " -workspace #{options[:workspace_path]}"
24
+ archive_command += if hash[:workspace_path]
25
+ " -workspace #{hash[:workspace_path]}"
30
26
  else
31
- " -project #{options[:project_path]}"
27
+ " -project #{hash[:project_path]}"
32
28
  end
33
29
 
34
30
  execute_command(archive_command)
35
31
 
36
32
  puts_title 'Exporting the app'
37
- exported_app_path = export_app(app_archive_path, application_cert_name, application_cert_team_id)
38
-
39
- puts_title 'Signing the Sparkle framework'
40
- sign_sparkle_framework(
41
- exported_app_path,
42
- application_cert_name
33
+ exported_app_path = export_app(
34
+ app_archive_path,
35
+ hash[:application_cert_name],
36
+ hash[:application_team_id],
37
+ hash[:output_dir]
43
38
  )
44
39
 
40
+ unless hash[:skip_sparkle_steps]
41
+ puts_title 'Signing the Sparkle framework'
42
+ sign_sparkle_framework(
43
+ exported_app_path,
44
+ hash[:application_cert_name]
45
+ )
46
+ end
47
+
45
48
  exported_app_path
46
49
  end
47
50
 
48
- def self.export_app(app_archive_path, application_cert_name, team_id)
51
+ def self.export_app(
52
+ app_archive_path,
53
+ application_cert_name,
54
+ team_id,
55
+ output_dir
56
+ )
49
57
  puts_if_verbose 'Exporting the app...'
50
58
  export_options = {
51
59
  signingStyle: 'automatic',
@@ -71,43 +79,61 @@ module Packaging
71
79
  export_command += " -exportOptionsPlist \"#{export_options_file.path}\""
72
80
  execute_command(export_command)
73
81
 
74
- "#{export_app_dir_path}/#{Env.variables.app_display_name}.app"
82
+ exported_app_path = "#{export_app_dir_path}/#{Env.variables.app_display_name}.app"
83
+ return exported_app_path unless output_dir
84
+
85
+ FileUtils.cp_r(exported_app_path, output_dir)
86
+ "#{output_dir}/#{Env.variables.app_display_name}.app"
75
87
  end
76
88
 
77
- def self.create_and_sign_dmg(exported_app_path, application_cert_name, keychain_path)
89
+ def self.create_and_sign_dmg(
90
+ exported_app_path,
91
+ application_cert_name,
92
+ output_dir
93
+ )
78
94
  puts_title "Creating #{Env.variables.app_display_name}.dmg"
79
95
  puts_if_verbose 'Creating a DMG for the app...'
80
96
  dmg_path = DMG.create(exported_app_path)
81
97
 
82
98
  # Sign the DMG
83
99
  puts_if_verbose 'Signing the DMG...'
84
- execute_command("codesign --force --sign \"#{application_cert_name}\" --timestamp --options runtime \"#{dmg_path}\"")
100
+ signing_dmg_command = "codesign --force --sign \"#{application_cert_name}\""
101
+ signing_dmg_command += " --timestamp --options runtime \"#{dmg_path}\""
102
+ execute_command(signing_dmg_command)
85
103
 
86
104
  # Notarize the DMG
87
105
  puts_if_verbose 'Notarizing the DMG...'
88
- execute_command("xcrun notarytool submit \"#{dmg_path}\" --keychain-profile \"#{Constants::NOTARIZE_KEYCHAIN_PROFILE}\" --keychain \"#{keychain_path}\" --wait")
106
+ notarize_command = "xcrun notarytool submit \"#{dmg_path}\""
107
+ notarize_command += " --keychain-profile \"#{Constants::NOTARIZE_KEYCHAIN_PROFILE}\""
108
+ notarize_command += " --keychain \"#{Constants::KEYCHAIN_PATH}\""
109
+ notarize_command += ' --wait'
110
+ execute_command(notarize_command)
89
111
 
90
112
  # Staple the notarization ticket
91
113
  puts_if_verbose 'Stapling the notarization ticket...'
92
114
  execute_command("xcrun stapler staple \"#{dmg_path}\"")
93
115
 
94
- dmg_path
116
+ return dmg_path unless output_dir
117
+
118
+ FileUtils.cp(dmg_path, output_dir)
119
+ "#{output_dir}/#{Env.variables.app_display_name}.dmg"
95
120
  end
96
121
 
97
122
  def self.sign_sparkle_framework(exported_app_path, application_cert_name)
98
- sparkle_auto_update_path = "#{exported_app_path}/Contents/Frameworks/Sparkle.framework/AutoUpdate"
123
+ sparkle_framework_path = "#{exported_app_path}/Contents/Frameworks/Sparkle.framework"
124
+
125
+ sparkle_auto_update_path = "#{sparkle_framework_path}/AutoUpdate"
99
126
  codesign('Signing Sparkle AutoUpdate...', application_cert_name, sparkle_auto_update_path)
100
127
 
101
- sparkle_updater_path = "#{exported_app_path}/Contents/Frameworks/Sparkle.framework/Updater.app"
128
+ sparkle_updater_path = "#{sparkle_framework_path}/Updater.app"
102
129
  codesign('Signing Sparkle Updater...', application_cert_name, sparkle_updater_path)
103
130
 
104
- sparkle_installer_xpc_path = "#{exported_app_path}/Contents/Frameworks/Sparkle.framework/XPCServices/Installer.xpc/Contents/MacOS/Installer"
131
+ sparkle_installer_xpc_path = "#{sparkle_framework_path}/XPCServices/Installer.xpc/Contents/MacOS/Installer"
105
132
  codesign('Signing Sparkle Installer XPC Service...', application_cert_name, sparkle_installer_xpc_path)
106
133
 
107
- sparkle_downloader_xpc_path = "#{exported_app_path}/Contents/Frameworks/Sparkle.framework/XPCServices/Downloader.xpc/Contents/MacOS/Downloader"
134
+ sparkle_downloader_xpc_path = "#{sparkle_framework_path}/XPCServices/Downloader.xpc/Contents/MacOS/Downloader"
108
135
  codesign('Signing Sparkle Downloader XPC Service...', application_cert_name, sparkle_downloader_xpc_path)
109
136
 
110
- sparkle_framework_path = "#{exported_app_path}/Contents/Frameworks/Sparkle.framework"
111
137
  codesign('Signing Sparkle framework...', application_cert_name, sparkle_framework_path)
112
138
  end
113
139
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: autosparkle
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.8
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hadi Dbouk
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-07-06 00:00:00.000000000 Z
11
+ date: 2024-07-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: aws-sdk-s3
@@ -38,6 +38,20 @@ dependencies:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
40
  version: 1.1.0
41
+ - !ruby/object:Gem::Dependency
42
+ name: commander
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: 5.0.0
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: 5.0.0
41
55
  - !ruby/object:Gem::Dependency
42
56
  name: dotenv
43
57
  requirement: !ruby/object:Gem::Requirement
@@ -107,7 +121,9 @@ files:
107
121
  - lib/autosparkle/helpers/version_helpers.rb
108
122
  - lib/autosparkle/helpers/xcodeproj_helpers.rb
109
123
  - lib/autosparkle/main.rb
124
+ - lib/autosparkle/metadata.rb
110
125
  - lib/autosparkle/packaging.rb
126
+ - lib/autosparkle/resources/default-dmg-background.png
111
127
  - lib/autosparkle/sparkle/BinaryDelta
112
128
  - lib/autosparkle/sparkle/generate_appcast
113
129
  - lib/autosparkle/sparkle/generate_keys