fastlane-plugin-sync_devices 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a36daeccb8615f994ee412d9f469e416698636b975796152a2b2c8f27d889b43
4
+ data.tar.gz: 29a12e29ba18a01f7958eb98ac1b2c4e97a22a4c1f256f3521cf4f67bed4f529
5
+ SHA512:
6
+ metadata.gz: e3987bece7d43b523a1df6cf1303b39511f310ae2701c0903f385d2e714a7ff61f734f8c21daa3d884de50c118557f0793db7e71337dc748bdaa245684ae335a
7
+ data.tar.gz: a6ed24116431af31c5cd6fe141c9fc365de7be8ebcb88e3631eb1d461e24958c6c98aec0730d500aae4dbc85d53173db414634ffb7b58a8ae834d708ea8b2134
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## master
2
+
3
+ ## 0.1.0
4
+
5
+ * Initial release - [@manicmaniac](https://github.com/manicmaniac)
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2024 Ryosuke Ito <rito.0305@gmail.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,128 @@
1
+ # sync\_devices plugin
2
+
3
+ [![fastlane Plugin Badge](https://rawcdn.githack.com/fastlane/fastlane/master/fastlane/assets/plugin-badge.svg)](https://rubygems.org/gems/fastlane-plugin-sync_devices)
4
+ [![Test](https://github.com/manicmaniac/fastlane-plugin-sync_devices/actions/workflows/test.yml/badge.svg)](https://github.com/manicmaniac/fastlane-plugin-sync_devices/actions/workflows/test.yml)
5
+
6
+ ## Getting Started
7
+
8
+ This project is a [fastlane](https://github.com/fastlane/fastlane) plugin. To get started with `fastlane-plugin-sync_devices`, add it to your project by running:
9
+
10
+ ```bash
11
+ fastlane add_plugin sync_devices
12
+ ```
13
+
14
+ ## About sync\_devices
15
+
16
+ This plugin provides a single action `sync_devices`.
17
+
18
+ `sync_devices` synchronizes your devices with Apple Developer Portal.
19
+
20
+ This plugin works similarly to fastlane official [register\_devices](https://docs.fastlane.tools/actions/register_devices/) plugin, but `sync_devices` can disable, enable and rename devices on Apple Developer Portal while `register_devices` is only capable to create new devices.
21
+
22
+ Since we can only actually _delete_ a device once a year, `sync_devices` does not _delete_ devices but just disables them when they were removed from a devices file. It's safe because you can re-enable devices whenever you want.
23
+
24
+ ## Basic Usage
25
+
26
+ First of all, you need to create your own `devices.tsv` under your project repository. It is a simple tab-separated text file like the following example.
27
+
28
+ ```
29
+ Device ID Device Name Device Platform
30
+ 01234567-89ABCDEF01234567 NAME1 ios
31
+ abcdef0123456789abcdef0123456789abcdef01 NAME2 ios
32
+ 01234567-89AB-CDEF-0123-4567890ABCDE NAME3 mac
33
+ ABCDEF01-2345-6789-ABCD-EF0123456789 NAME4 mac
34
+ ```
35
+
36
+ Then you can run `sync_devices` from command line.
37
+
38
+ Run `sync_devices` in dry-run mode, which does not change remote devices, so that you can see what will be done when it actually runs.
39
+
40
+ ```
41
+ fastlane run sync_devices devices_file:devices.tsv dry_run:true
42
+ ```
43
+
44
+ After carefully checking if the result is the same as expected, run
45
+
46
+ ```
47
+ fastlane run sync_devices devices_file:devices.tsv
48
+ ```
49
+
50
+ You will see the remote devices are synchronized with your devices.tsv.
51
+
52
+
53
+ ## Advanced Usage
54
+
55
+ ### Use Property List file instead of TSV
56
+
57
+ Apple Developer Portal also accepts a devices file in Property List format like this.
58
+
59
+ ```xml
60
+ <?xml version="1.0" encoding="UTF-8"?>
61
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
62
+ <plist version="1.0">
63
+ <dict>
64
+ <key>Device UDIDs</key>
65
+ <array>
66
+ <dict>
67
+ <key>deviceIdentifier</key>
68
+ <string>01234567-89ABCDEF01234567</string>
69
+ <key>deviceName</key>
70
+ <string>NAME1</string>
71
+ <key>devicePlatform</key>
72
+ <string>ios</string>
73
+ </dict>
74
+ </array>
75
+ </dict>
76
+ </plist>
77
+ ```
78
+
79
+ If you want to use Property List format, just pass the file to `sync_devices`.
80
+
81
+ ```
82
+ fastlane run sync_devices devices_file:devices.deviceids
83
+ ```
84
+
85
+ Following Apple's guide, I added `.deviceids` file extension but you can use standard `.xml` or `.plist` as well.
86
+
87
+ ```
88
+ fastlane run sync_devices devices_file:devices.xml
89
+ ```
90
+
91
+ ## Example
92
+
93
+ Check out the [example `Fastfile`](fastlane/Fastfile) to see how to use this plugin. Try it by cloning the repo, running `fastlane install_plugins` and `bundle exec fastlane test`.
94
+
95
+ ## Run tests for this plugin
96
+
97
+ To run both the tests, and code style validation, run
98
+
99
+ ```
100
+ bundle exec rake
101
+ ```
102
+
103
+ To automatically fix many of the styling issues, use
104
+ ```
105
+ bundle exec rake rubocop:autocorrect
106
+ ```
107
+
108
+ You can check other useful tasks by running
109
+
110
+ ```
111
+ bundle exec rake -T
112
+ ```
113
+
114
+ ## Issues and Feedback
115
+
116
+ For any other issues and feedback about this plugin, please submit it to [this repository](https://github.com/manicmaniac/fastlane-plugin-sync_devices).
117
+
118
+ ## Troubleshooting
119
+
120
+ If you have trouble using plugins, check out the [Plugins Troubleshooting](https://docs.fastlane.tools/plugins/plugins-troubleshooting/) guide.
121
+
122
+ ## Using _fastlane_ Plugins
123
+
124
+ For more information about how the `fastlane` plugin system works, check out the [Plugins documentation](https://docs.fastlane.tools/plugins/create-plugin/).
125
+
126
+ ## About _fastlane_
127
+
128
+ _fastlane_ is the easiest way to automate beta deployments and releases for your iOS and Android apps. To learn more, check out [fastlane.tools](https://fastlane.tools).
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'credentials_manager'
4
+ require 'fastlane/action'
5
+ require_relative '../helper/sync_devices_helper'
6
+
7
+ module Fastlane # rubocop:disable Style/Documentation
8
+ # @see https://rubydoc.info/gems/fastlane/FastlaneCore/UI
9
+ UI = FastlaneCore::UI unless Fastlane.const_defined?(:UI)
10
+
11
+ # @see https://rubydoc.info/gems/fastlane/Fastlane/Actions
12
+ module Actions
13
+ # Fastlane action to synchronize remote devices on Apple Developer Portal with local devices file.
14
+ class SyncDevicesAction < Action # rubocop:disable Metrics/ClassLength
15
+ include Fastlane::Helper::SyncDevicesHelper
16
+
17
+ # The main entry point of this plugin.
18
+ #
19
+ # @param params [Hash] options passed to this plugin. See {.available_options} for details.
20
+ # @option params [Boolean] :dry_run
21
+ # @option params [String] :devices_file
22
+ # @option params [String, nil] :api_key_path
23
+ # @option params [Hash, nil] :api_key
24
+ # @option params [String, nil] :team_id
25
+ # @option params [String, nil] :team_name
26
+ # @option params [String, nil] :username
27
+ # @return [void]
28
+ # @see https://rubydoc.info/gems/fastlane/Fastlane%2FAction.run Fastlane::Action.run
29
+ def self.run(params)
30
+ require 'spaceship/connect_api'
31
+
32
+ devices_file = params[:devices_file]
33
+ UI.user_error!('You must pass `devices_file`. Please check the readme.') unless devices_file
34
+ spaceship_login(params)
35
+ new_devices = DevicesFile.load(devices_file)
36
+
37
+ UI.message('Fetching list of currently registered devices...')
38
+ current_devices = Spaceship::ConnectAPI::Device.all
39
+ patch = DevicesPatch.new(current_devices, new_devices)
40
+ patch.apply!(dry_run: params[:dry_run])
41
+
42
+ UI.success('Successfully registered new devices.')
43
+ end
44
+
45
+ # Authenticates the user with AppStore Connect API or Apple Developer Portal.
46
+ #
47
+ # @param (see .run)
48
+ # @return [void]
49
+ def self.spaceship_login(params)
50
+ api_token = Spaceship::ConnectAPI::Token.from(hash: params[:api_key], filepath: params[:api_key_path])
51
+ if api_token
52
+ UI.message('Creating authorization token for App Store Connect API')
53
+ Spaceship::ConnectAPI.token = api_token
54
+ elsif Spaceship::ConnectAPI.token
55
+ UI.message('Using existing authorization token for App Store Connect API')
56
+ else
57
+ UI.message("Login to App Store Connect (#{params[:username]})")
58
+ credentials = CredentialsManager::AccountManager.new(user: params[:username])
59
+ Spaceship::ConnectAPI.login(credentials.user, credentials.password, use_portal: true, use_tunes: false)
60
+ UI.message('Login Successful')
61
+ end
62
+ end
63
+ private_class_method :spaceship_login
64
+
65
+ # Description of this plugin.
66
+ #
67
+ # @return [String]
68
+ # @see https://rubydoc.info/gems/fastlane/Fastlane%2FAction.description Fastlane::Action.description
69
+ def self.description
70
+ 'Synchronize your devices with Apple Developer Portal.'
71
+ end
72
+
73
+ # Authors of this plugin.
74
+ #
75
+ # @return [Array<String>]
76
+ # @see https://rubydoc.info/gems/fastlane/Fastlane%2FAction.authors Fastlane::Action.authors
77
+ def self.authors
78
+ ['Ryosuke Ito']
79
+ end
80
+
81
+ # Detailed description of this plugin.
82
+ #
83
+ # @return [String]
84
+ # @see https://rubydoc.info/gems/fastlane/Fastlane%2FAction.details Fastlane::Action.details
85
+ def self.details
86
+ <<~DETAILS
87
+ This will synchronize iOS/Mac devices with the Apple Developer Portal so that you can include them in your provisioning profiles.
88
+ Unlike `register_devices` action, this action may disable, enable or rename devices.
89
+ Maybe it sounds dangerous but actually it does not delete anything, so you can recover the changes by yourself if needed.
90
+
91
+ The action will connect to the Apple Developer Portal using AppStore Connect API.
92
+ DETAILS
93
+ end
94
+
95
+ # Available options of this plugin.
96
+ #
97
+ # @return [Array<FastlaneCore::ConfigItem>]
98
+ #
99
+ # @see https://rubydoc.info/gems/fastlane/Fastlane%2FAction.available_options Fastlane::Action.available_options
100
+ # @see https://rubydoc.info/gems/fastlane/FastlaneCore/ConfigItem FastlaneCore::ConfigItem
101
+ def self.available_options # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
102
+ [
103
+ FastlaneCore::ConfigItem.new(
104
+ key: :dry_run,
105
+ env_name: 'FL_SYNC_DEVICES_DRY_RUN',
106
+ description: 'Do not modify the registered devices but just print what will be done',
107
+ type: Boolean,
108
+ default_value: false,
109
+ optional: true
110
+ ),
111
+ FastlaneCore::ConfigItem.new(
112
+ key: :devices_file,
113
+ env_name: 'FL_SYNC_DEVICES_FILE',
114
+ description: 'Provide a path to a file with the devices to register. ' \
115
+ 'For the format of the file see the examples',
116
+ optional: true,
117
+ verify_block: proc do |value|
118
+ UI.user_error!("Could not find file '#{value}'") unless File.exist?(value)
119
+ end
120
+ ),
121
+ FastlaneCore::ConfigItem.new(
122
+ key: :api_key_path,
123
+ env_names: %w[FL_SYNC_DEVICES_API_KEY_PATH APP_STORE_CONNECT_API_KEY_PATH],
124
+ description: 'Path to your App Store Connect API Key JSON file ' \
125
+ '(https://docs.fastlane.tools/app-store-connect-api/#using-fastlane-api-key-json-file)',
126
+ optional: true,
127
+ conflicting_options: [:api_key],
128
+ verify_block: proc do |value|
129
+ UI.user_error!("Couldn't find API key JSON file at path '#{value}'") unless File.exist?(value)
130
+ end
131
+ ),
132
+ FastlaneCore::ConfigItem.new(
133
+ key: :api_key,
134
+ env_names: %w[FL_SYNC_DEVICES_API_KEY APP_STORE_CONNECT_API_KEY],
135
+ description: 'Your App Store Connect API Key information ' \
136
+ '(https://docs.fastlane.tools/app-store-connect-api/#using-fastlane-api-key-hash-option)',
137
+ type: Hash,
138
+ default_value: Fastlane::Actions.lane_context[Fastlane::Actions::SharedValues::APP_STORE_CONNECT_API_KEY],
139
+ default_value_dynamic: true,
140
+ optional: true,
141
+ sensitive: true,
142
+ conflicting_options: [:api_key_path]
143
+ ),
144
+ FastlaneCore::ConfigItem.new(
145
+ key: :team_id,
146
+ env_name: 'SYNC_DEVICES_TEAM_ID',
147
+ code_gen_sensitive: true,
148
+ default_value: CredentialsManager::AppfileConfig.try_fetch_value(:team_id),
149
+ default_value_dynamic: true,
150
+ description: "The ID of your Developer Portal team if you're in multiple teams",
151
+ optional: true,
152
+ verify_block: proc do |value|
153
+ ENV['FASTLANE_TEAM_ID'] = value.to_s
154
+ end
155
+ ),
156
+ FastlaneCore::ConfigItem.new(
157
+ key: :team_name,
158
+ env_name: 'SYNC_DEVICES_TEAM_NAME',
159
+ description: "The name of your Developer Portal team if you're in multiple teams",
160
+ optional: true,
161
+ code_gen_sensitive: true,
162
+ default_value: CredentialsManager::AppfileConfig.try_fetch_value(:team_name),
163
+ default_value_dynamic: true,
164
+ verify_block: proc do |value|
165
+ ENV['FASTLANE_TEAM_NAME'] = value.to_s
166
+ end
167
+ ),
168
+ FastlaneCore::ConfigItem.new(
169
+ key: :username,
170
+ env_name: 'DELIVER_USER',
171
+ description: 'Optional: Your Apple ID',
172
+ optional: true,
173
+ default_value: CredentialsManager::AppfileConfig.try_fetch_value(:apple_id),
174
+ default_value_dynamic: true
175
+ )
176
+ ]
177
+ end
178
+
179
+ # Whether if this plugin is supported on the platform.
180
+ #
181
+ # @param _platform [Symbol] unused
182
+ # @return [true]
183
+ # @see https://rubydoc.info/gems/fastlane/Fastlane%2FAction.is_supported%3F Fastlane::Action.is_supported?
184
+ def self.is_supported?(_platform) # rubocop:disable Naming/PredicateName
185
+ true
186
+ end
187
+
188
+ # Example usages of this plugin.
189
+ #
190
+ # @return [Array<String>]
191
+ # @see https://rubydoc.info/gems/fastlane/Fastlane%2FAction.example_code Fastlane::Action.example_code
192
+ def self.example_code
193
+ [
194
+ <<~EX_1,
195
+ # Provide TSV file
196
+ sync_devices(devices_file: '/path/to/devices.txt')
197
+ EX_1
198
+ <<~EX_2,
199
+ # Provide Property List file, with configuring credentials
200
+ sync_devices(
201
+ devices_file: '/path/to/devices.deviceids',
202
+ team_id: 'ABCDEFGHIJ',
203
+ api_key_path: '/path/to/api_key.json'
204
+ )
205
+ EX_2
206
+ <<~EX_3
207
+ # Just check what will occur
208
+ sync_devices(devices_file: '/path/to/devices.txt', dry_run: true)
209
+ EX_3
210
+ ]
211
+ end
212
+
213
+ # Category of this plugin.
214
+ #
215
+ # @return [Symbol]
216
+ # @see https://rubydoc.info/gems/fastlane/Fastlane%2FAction.category Fastlane::Action.category
217
+ def self.category
218
+ :code_signing
219
+ end
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spaceship/connect_api'
4
+
5
+ module Fastlane
6
+ module Helper
7
+ module SyncDevicesHelper
8
+ # Namespace to organize command classes.
9
+ module Command
10
+ # Abstract base class of command object.
11
+ #
12
+ # @attr device [Spaceship::ConnectAPI::Device]
13
+ Base = Struct.new(:device) do
14
+ # Communicate with AppStore Connect and change the remote device.
15
+ # @abstract
16
+ # @return [void]
17
+ def run
18
+ # :nocov:
19
+ raise NotImplementedError
20
+ # :nocov:
21
+ end
22
+
23
+ # Description of this command.
24
+ # @abstract
25
+ # @return [String]
26
+ def description
27
+ # :nocov:
28
+ raise NotImplementedError
29
+ # :nocov:
30
+ end
31
+ end
32
+
33
+ # Command to do nothing.
34
+ class Noop < Base
35
+ # @return (see Base#run)
36
+ def run
37
+ # Does nothing.
38
+ end
39
+
40
+ # @return (see Base#description)
41
+ def description
42
+ "Skipped #{device.name} (#{device.udid})"
43
+ end
44
+ end
45
+
46
+ # Command to disable an existing device.
47
+ # @see https://developer.apple.com/documentation/appstoreconnectapi/modify_a_registered_device
48
+ class Disable < Base
49
+ # @return (see Base#run)
50
+ def run
51
+ Spaceship::ConnectAPI::Device.disable(device.udid)
52
+ end
53
+
54
+ # @return (see Base#description)
55
+ def description
56
+ "Disabled #{device.name} (#{device.udid})"
57
+ end
58
+ end
59
+
60
+ # Command to enable an existing device.
61
+ # @see https://developer.apple.com/documentation/appstoreconnectapi/modify_a_registered_device
62
+ class Enable < Base
63
+ # @return (see Base#run)
64
+ def run
65
+ Spaceship::ConnectAPI::Device.enable(device.udid)
66
+ end
67
+
68
+ # @return (see Base#description)
69
+ def description
70
+ "Enabled #{device.name} (#{device.udid})"
71
+ end
72
+ end
73
+
74
+ # Command to rename an existing device.
75
+ # @see https://developer.apple.com/documentation/appstoreconnectapi/modify_a_registered_device
76
+ class Rename < Base
77
+ # @return [String]
78
+ attr_reader :name
79
+
80
+ # @param device [Spaceship::ConnectAPI::Device]
81
+ # @param name [String]
82
+ def initialize(device, name)
83
+ super(device)
84
+ @name = name
85
+ end
86
+
87
+ # @return (see Base#run)
88
+ def run
89
+ Spaceship::ConnectAPI::Device.rename(device.udid, name)
90
+ end
91
+
92
+ # @return (see Base#description)
93
+ def description
94
+ "Renamed #{device.name} to #{name} (#{device.udid})"
95
+ end
96
+ end
97
+
98
+ # Command to disable and rename an existing device.
99
+ # @see https://developer.apple.com/documentation/appstoreconnectapi/modify_a_registered_device
100
+ class DisableAndRename < Base
101
+ # @return [String]
102
+ attr_reader :name
103
+
104
+ # @param device [Spaceship::ConnectAPI::Device]
105
+ # @param name [String]
106
+ def initialize(device, name)
107
+ super(device)
108
+ @name = name
109
+ end
110
+
111
+ # @return (see Base#run)
112
+ def run
113
+ Spaceship::ConnectAPI::Device.modify(
114
+ device.udid,
115
+ enabled: false,
116
+ new_name: name
117
+ )
118
+ end
119
+
120
+ # @return (see Base#description)
121
+ def description
122
+ "Disabled and renamed #{device.name} to #{name} (#{device.udid})"
123
+ end
124
+ end
125
+
126
+ # Command to enable and rename an existing device.
127
+ # @see https://developer.apple.com/documentation/appstoreconnectapi/modify_a_registered_device
128
+ class EnableAndRename < Base
129
+ # @return [String] name
130
+ attr_reader :name
131
+
132
+ # @param device [Spaceship::ConnectAPI::Device]
133
+ # @param name [String]
134
+ def initialize(device, name)
135
+ super(device)
136
+ @name = name
137
+ end
138
+
139
+ # @return (see Base#run)
140
+ def run
141
+ Spaceship::ConnectAPI::Device.modify(
142
+ device.udid,
143
+ enabled: true,
144
+ new_name: name
145
+ )
146
+ end
147
+
148
+ # @return (see Base#description)
149
+ def description
150
+ "Enabled and renamed #{device.name} to #{name} (#{device.udid})"
151
+ end
152
+ end
153
+
154
+ # Command to register a new device.
155
+ # @see https://developer.apple.com/documentation/appstoreconnectapi/register_a_new_device
156
+ class Create < Base
157
+ # @return (see Base#run)
158
+ def run
159
+ Spaceship::ConnectAPI::Device.create(
160
+ name: device.name,
161
+ platform: device.platform,
162
+ udid: device.udid
163
+ )
164
+ end
165
+
166
+ # @return (see Base#description)
167
+ def description
168
+ "Created #{device.name} (#{device.udid})"
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'command'
4
+
5
+ module Fastlane
6
+ module Helper
7
+ module SyncDevicesHelper
8
+ # Represents differences between 2 devices.
9
+ class DevicePatch
10
+ # @return [Spaceship::ConnectAPI::Device]
11
+ attr_reader :old_device, :new_device
12
+
13
+ # @param old_device [Spaceship::ConnectAPI::Device, nil]
14
+ # @param new_device [Spaceship::ConnectAPI::Device, nil]
15
+ def initialize(old_device, new_device)
16
+ @old_device = old_device
17
+ @new_device = new_device
18
+ end
19
+
20
+ # @return [Boolean]
21
+ def renamed?
22
+ !!old_device && !!new_device && old_device.name != new_device.name
23
+ end
24
+
25
+ # @return [Boolean]
26
+ def enabled?
27
+ !!old_device && !old_device.enabled? && !!new_device&.enabled?
28
+ end
29
+
30
+ # @return [Boolean]
31
+ def disabled?
32
+ !!old_device && old_device.enabled? && !new_device&.enabled?
33
+ end
34
+
35
+ # @return [Boolean]
36
+ def created?
37
+ old_device.nil? && !!new_device&.enabled?
38
+ end
39
+
40
+ # @return [Boolean]
41
+ def platform_changed?
42
+ !!old_device && !!new_device && old_device.platform != new_device.platform
43
+ end
44
+
45
+ # @return [Command::Base]
46
+ # @raise [UnsupportedOperation]
47
+ def command # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
48
+ raise UnsupportedOperation.change_platform(old_device, new_device) if platform_changed?
49
+
50
+ case [renamed?, enabled?, disabled?, created?]
51
+ when [false, false, false, false]
52
+ Command::Noop.new(old_device)
53
+ when [false, false, false, true]
54
+ Command::Create.new(new_device)
55
+ when [false, false, true, false]
56
+ Command::Disable.new(old_device)
57
+ when [false, true, false, false]
58
+ Command::Enable.new(old_device)
59
+ when [true, false, false, false]
60
+ Command::Rename.new(old_device, new_device.name)
61
+ when [true, false, true, false]
62
+ Command::DisableAndRename.new(old_device, new_device.name)
63
+ when [true, true, false, false]
64
+ Command::EnableAndRename.new(old_device, new_device.name)
65
+ else
66
+ # :nocov:
67
+ raise UnsupportedOperation.internal_inconsistency(self, old_device, new_device)
68
+ # :nocov:
69
+ end
70
+ end
71
+ end
72
+
73
+ # Generic error that is raised if no operation is defined in AppStore Connect API for diff of devices.
74
+ class UnsupportedOperation < StandardError
75
+ # @return [Spaceship::ConnectAPI::Device]
76
+ attr_reader :old_device, :new_device
77
+
78
+ # @param message [String]
79
+ # @param old_device [Spaceship::ConnectAPI::Device]
80
+ # @param new_device [Spaceship::ConnectAPI::Device]
81
+ def initialize(message, old_device, new_device)
82
+ super(message)
83
+ @old_device = old_device
84
+ @new_device = new_device
85
+ end
86
+
87
+ # @param old_device [Spaceship::ConnectAPI::Device]
88
+ # @param new_device [Spaceship::ConnectAPI::Device]
89
+ # @return [UnsupportedOperation]
90
+ def self.change_platform(old_device, new_device)
91
+ message = "Cannot change platform of the device '#{new_device.udid}' " \
92
+ "(#{old_device.platform} -> #{new_device.platform})"
93
+ new(message, old_device, new_device)
94
+ end
95
+
96
+ # @param patch [DevicePatch]
97
+ # @param old_device [Spaceship::ConnectAPI::Device]
98
+ # @param new_device [Spaceship::ConnectAPI::Device]
99
+ # @return [UnsupportedOperation]
100
+ def self.internal_inconsistency(patch, old_device, new_device)
101
+ info = {
102
+ renamed?: patch.renamed?,
103
+ enabled?: patch.enabled?,
104
+ disabled?: patch.disabled?,
105
+ created?: patch.created?
106
+ }
107
+ new(
108
+ "Cannot change #{old_device} to #{new_device} because of internal inconsistency. #{info}",
109
+ old_device,
110
+ new_device
111
+ )
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,325 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fastlane
4
+ module Helper
5
+ module SyncDevicesHelper
6
+ # Collection of methods that manipulates TSV or XML devices file.
7
+ #
8
+ # @see https://developer.apple.com/account/resources/downloads/Multiple-Upload-Samples.zip
9
+ module DevicesFile # rubocop:disable Metrics/ModuleLength
10
+ # Loads a devices file and parse it as an array of devices.
11
+ # If the file extension is one of +.deviceids+, +.plist+ and +.xml+, this method delegates to {.load_plist},
12
+ # otherwise to {.load_tsv}.
13
+ #
14
+ # @param path [String] path to the output file
15
+ # @return [Array<Spaceship::ConnectAPI::Device>]
16
+ def self.load(path)
17
+ return load_plist(path) if %w[.deviceids .plist .xml].include?(File.extname(path))
18
+
19
+ load_tsv(path)
20
+ end
21
+
22
+ # @param path [String]
23
+ # @return [Array<Spaceship::ConnectAPI::Device>]
24
+ def self.load_tsv(path)
25
+ require 'csv'
26
+ require 'spaceship/connect_api'
27
+
28
+ table = CSV.read(path, headers: true, col_sep: "\t")
29
+ validate_headers(table.headers, path)
30
+
31
+ devices = table.map.with_index(2) do |row, line_number|
32
+ validate_row(row, path, line_number)
33
+ Spaceship::ConnectAPI::Device.new(
34
+ nil,
35
+ {
36
+ name: row['Device Name'],
37
+ udid: row['Device ID'],
38
+ platform: parse_platform(row['Device Platform'], path),
39
+ status: Spaceship::ConnectAPI::Device::Status::ENABLED
40
+ }
41
+ )
42
+ end
43
+ validate_devices(devices, path)
44
+ devices
45
+ end
46
+
47
+ # @param path [String]
48
+ # @return [Array<Spaceship::ConnectAPI::Device>]
49
+ def self.load_plist(path)
50
+ require 'cfpropertylist'
51
+ require 'spaceship/connect_api'
52
+
53
+ plist = CFPropertyList::List.new(file: path)
54
+ items = CFPropertyList.native_types(plist.value)['Device UDIDs']
55
+ devices = items.map.with_index do |item, index|
56
+ validate_dict_item(item, index, path)
57
+ Spaceship::ConnectAPI::Device.new(
58
+ nil,
59
+ {
60
+ name: item['deviceName'],
61
+ udid: item['deviceIdentifier'],
62
+ platform: parse_platform(item['devicePlatform'], path),
63
+ status: Spaceship::ConnectAPI::Device::Status::ENABLED
64
+ }
65
+ )
66
+ end
67
+ validate_devices(devices, path)
68
+ devices
69
+ end
70
+
71
+ # Dumps devices to devices file specified by path.
72
+ # This method delegates to either of {.dump_tsv} or {.dump_plist} depending on +format+.
73
+ #
74
+ # @param devices [Array<Spaceship::ConnectAPI::Device>] device objects to dump
75
+ # @param path [String] path to the output file
76
+ # @param format [:tsv, :plist] output format
77
+ # @return [void]
78
+ def self.dump(devices, path, format: :tsv)
79
+ case format
80
+ when :tsv
81
+ dump_tsv(devices, path)
82
+ when :plist
83
+ dump_plist(devices, path)
84
+ else
85
+ raise "Unsupported format '#{format}'."
86
+ end
87
+ end
88
+
89
+ # @param devices [Array<Spaceship::ConnectAPI::Device>] device objects to dump
90
+ # @param path [String] path to the output file
91
+ # @return [void]
92
+ def self.dump_tsv(devices, path)
93
+ require 'csv'
94
+
95
+ CSV.open(path, 'w', col_sep: "\t", headers: true, write_headers: true) do |csv|
96
+ csv << HEADERS
97
+ devices.each do |device|
98
+ csv << [device.udid, device.name, device.platform]
99
+ end
100
+ end
101
+ end
102
+
103
+ # @param devices [Array<Spaceship::ConnectAPI::Device>] device objects to dump
104
+ # @param path [String] path to the output file
105
+ # @return [void]
106
+ def self.dump_plist(devices, path)
107
+ require 'cfpropertylist'
108
+
109
+ plist = CFPropertyList::List.new
110
+ plist.value = CFPropertyList.guess(
111
+ {
112
+ 'Device UDIDs' => devices.map do |device|
113
+ {
114
+ deviceIdentifier: device.udid,
115
+ deviceName: device.name,
116
+ devicePlatform: device.platform.downcase
117
+ }
118
+ end
119
+ }
120
+ )
121
+ plist.save(path, CFPropertyList::List::FORMAT_XML)
122
+ end
123
+
124
+ # Maximum length of a device name that is permitted by Apple Developer Portal.
125
+ #
126
+ # @return [Integer]
127
+ MAX_DEVICE_NAME_LENGTH = 50
128
+
129
+ # @param devices [Array<Spaceship::ConnectAPI::Device>] device objects to dump
130
+ # @param path [String]
131
+ # @return [void]
132
+ # @raise [InvalidDevicesFile]
133
+ def self.validate_devices(devices, path) # rubocop:disable Metrics/AbcSize
134
+ seen_udids = []
135
+ devices.each do |device|
136
+ udid = device.udid&.downcase
137
+ unless udid.match(udid_regex_for_platform(device.platform))
138
+ raise InvalidDevicesFile.invalid_udid(device.udid, path)
139
+ end
140
+ raise InvalidDevicesFile.udid_not_unique(device.udid, path) if seen_udids.include?(udid)
141
+
142
+ if device.name.size > MAX_DEVICE_NAME_LENGTH
143
+ raise InvalidDevicesFile.device_name_too_long(device.name, path)
144
+ end
145
+
146
+ seen_udids << udid
147
+ end
148
+ end
149
+ private_class_method :validate_devices
150
+
151
+ # @param platform_string [String]
152
+ # @param path [String]
153
+ # @return [String]
154
+ def self.parse_platform(platform_string, path)
155
+ Spaceship::ConnectAPI::BundleIdPlatform.map(platform_string || 'ios')
156
+ rescue RuntimeError => e
157
+ if e.message.include?('Cannot find a matching platform')
158
+ raise InvalidDevicesFile.unknown_platform(platform_string, path)
159
+ end
160
+
161
+ raise
162
+ end
163
+ private_class_method :parse_platform
164
+
165
+ HEADERS = ['Device ID', 'Device Name', 'Device Platform'].freeze
166
+ private_constant :HEADERS
167
+
168
+ SHORT_HEADERS = HEADERS[0..1].freeze
169
+ private_constant :SHORT_HEADERS
170
+
171
+ # @param [Array<String>] headers
172
+ # @param [String] path
173
+ # @raise [InvalidDevicesFile]
174
+ def self.validate_headers(headers, path)
175
+ raise InvalidDevicesFile.invalid_headers(path, 1) unless [HEADERS, SHORT_HEADERS].include?(headers.compact)
176
+ end
177
+ private_class_method :validate_headers
178
+
179
+ # @param row [CSV::Row]
180
+ # @param path [String]
181
+ # @param line_number [Integer]
182
+ # @return [void]
183
+ # @raise [InvalidDevicesFile]
184
+ def self.validate_row(row, path, line_number)
185
+ case row.fields.compact.size
186
+ when 0, 1
187
+ raise InvalidDevicesFile.columns_too_short(path, line_number)
188
+ when 2, 3
189
+ # Does nothing
190
+ else
191
+ raise InvalidDevicesFile.columns_too_long(path, line_number)
192
+ end
193
+ end
194
+ private_class_method :validate_row
195
+
196
+ REQUIRED_KEYS = %w[deviceName deviceIdentifier].freeze
197
+ private_constant :REQUIRED_KEYS
198
+
199
+ # @param item [Hash<String, String>]
200
+ # @param index [Integer]
201
+ # @param path [String]
202
+ # @return [void]
203
+ # @raise [InvalidDevicesFile]
204
+ def self.validate_dict_item(item, index, path)
205
+ REQUIRED_KEYS.each do |key|
206
+ unless item.key?(key)
207
+ entry = ":Device UDIDs:#{index}:#{key}"
208
+ raise InvalidDevicesFile.missing_key(entry, path)
209
+ end
210
+ end
211
+ end
212
+ private_class_method :validate_dict_item
213
+
214
+ # @param platform [String]
215
+ # @return [Regexp]
216
+ # @raise [TypeError] when platform is not in {Spaceship::ConnectAPI::BundleIdPlatform::ALL}.
217
+ def self.udid_regex_for_platform(platform)
218
+ case platform
219
+ when Spaceship::ConnectAPI::BundleIdPlatform::IOS
220
+ # @see https://www.theiphonewiki.com/wiki/UDID
221
+ /^(?:[0-9]{8}-[0-9a-f]{16}|[0-9a-f]{40})$/
222
+ when Spaceship::ConnectAPI::BundleIdPlatform::MAC_OS
223
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
224
+ else
225
+ # :nocov:
226
+ raise TypeError, "Unknown platform '#{platform}' is not in #{Spaceship::ConnectAPI::BundleIdPlatform::ALL}."
227
+ # :nocov:
228
+ end
229
+ end
230
+ private_class_method :udid_regex_for_platform
231
+ end
232
+
233
+ # Generic error that is raised if device file is not in the valid format.
234
+ class InvalidDevicesFile < StandardError
235
+ # URL of example devices files.
236
+ SAMPLE_FILE_URL = 'https://developer.apple.com/account/resources/downloads/Multiple-Upload-Samples.zip'
237
+
238
+ # @return [String]
239
+ attr_reader :path
240
+ # @return [Integer]
241
+ attr_reader :line_number
242
+ # @return [String, nil]
243
+ attr_reader :entry
244
+
245
+ # @param message [String]
246
+ # @param path [String]
247
+ # @param line_number [String, nil]
248
+ # @param entry [String, nil]
249
+ def initialize(message, path, line_number: nil, entry: nil)
250
+ super(format(message, { location: [path, line_number].join(':'), url: SAMPLE_FILE_URL }))
251
+ @path = path
252
+ @line_number = line_number
253
+ @entry = entry
254
+ end
255
+
256
+ # @param path [String]
257
+ # @param line_number [Integer]
258
+ # @return [InvalidDevicesFile]
259
+ def self.invalid_headers(path, line_number)
260
+ message = 'Invalid header line at %<location>s, please provide a file according to ' \
261
+ 'the Apple Sample UDID file (%<url>s)'
262
+ new(message, path, line_number: line_number)
263
+ end
264
+
265
+ # @param path [String]
266
+ # @param line_number [Integer]
267
+ # @return [InvalidDevicesFile]
268
+ def self.columns_too_short(path, line_number)
269
+ message = 'Invalid device line at %<location>s, ensure you are using tabs (NOT spaces). ' \
270
+ "See Apple's sample/spec here: %<url>s"
271
+ new(message, path, line_number: line_number)
272
+ end
273
+
274
+ # @param path [String]
275
+ # @param line_number [Integer]
276
+ # @return [InvalidDevicesFile]
277
+ def self.columns_too_long(path, line_number)
278
+ message = 'Invalid device line at %<location>s, please provide a file according to ' \
279
+ 'the Apple Sample UDID file (%<url>s)'
280
+ new(message, path, line_number: line_number)
281
+ end
282
+
283
+ # @param entry [String]
284
+ # @param path [String]
285
+ # @return [InvalidDevicesFile]
286
+ def self.missing_key(entry, path)
287
+ message = "Invalid device file at %<location>s, each item must have a required key '#{entry}', " \
288
+ "See Apple's sample/spec here: %<url>s"
289
+ new(message, path, entry: entry)
290
+ end
291
+
292
+ # @param udid [String]
293
+ # @param path [String]
294
+ # @return [InvalidDevicesFile]
295
+ def self.invalid_udid(udid, path)
296
+ new("Invalid UDID '#{udid}' at %<location>s, the UDID is not in the correct format", path)
297
+ end
298
+
299
+ # @param udid [String]
300
+ # @param path [String]
301
+ # @return [InvalidDevicesFile]
302
+ def self.udid_not_unique(udid, path)
303
+ message = "Invalid UDID '#{udid}' at %<location>s, there's another device with the same UDID is defined"
304
+ new(message, path)
305
+ end
306
+
307
+ # @param name [String]
308
+ # @param path [String]
309
+ # @return [InvalidDevicesFile]
310
+ def self.device_name_too_long(name, path)
311
+ message = "Invalid device name '#{name}' at %<location>s, a device name " \
312
+ "must be less than or equal to #{DevicesFile::MAX_DEVICE_NAME_LENGTH} characters long"
313
+ new(message, path)
314
+ end
315
+
316
+ # @param platform [String]
317
+ # @param path [String]
318
+ # @return [InvalidDevicesFile]
319
+ def self.unknown_platform(platform, path)
320
+ new("Unknown platform '#{platform}' at %<location>s", path)
321
+ end
322
+ end
323
+ end
324
+ end
325
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'device_patch'
4
+
5
+ module Fastlane # rubocop:disable Style/Documentation
6
+ UI = FastlaneCore::UI unless Fastlane.const_defined?(:UI)
7
+
8
+ module Helper
9
+ module SyncDevicesHelper
10
+ # Represents a collection of {DevicePatch}.
11
+ class DevicesPatch
12
+ # @return [Spaceship::ConnectAPI::Device]
13
+ attr_reader :old_devices, :new_devices
14
+ # @return [Array<DevicePatch>]
15
+ attr_reader :commands
16
+
17
+ # @param old_devices [Array<Spaceship::ConnectAPI::Device>]
18
+ # @param new_devices [Array<Spaceship::ConnectAPI::Device>]
19
+ def initialize(old_devices, new_devices) # rubocop:disable Metrics/AbcSize
20
+ @old_devices = old_devices
21
+ @new_devices = new_devices
22
+
23
+ old_device_by_udid = old_devices.group_by { |d| d.udid.downcase }.transform_values(&:first)
24
+ new_device_by_udid = new_devices.group_by { |d| d.udid.downcase }.transform_values(&:first)
25
+ @commands = (old_device_by_udid.keys + new_device_by_udid.keys)
26
+ .sort
27
+ .uniq
28
+ .map do |udid|
29
+ old_device = old_device_by_udid[udid]
30
+ new_device = new_device_by_udid[udid]
31
+ DevicePatch.new(old_device, new_device).command
32
+ end
33
+ end
34
+
35
+ # @param dry_run [Boolean]
36
+ # @return [void]
37
+ def apply!(dry_run: false)
38
+ @commands.each do |command|
39
+ if dry_run
40
+ UI.message("(dry-run) #{command.description}")
41
+ else
42
+ command.run
43
+ UI.message(command.description)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fastlane
4
+ # @see https://rubydoc.info/gems/fastlane/Fastlane/Helper Fastlane::Helper
5
+ module Helper
6
+ # Root namespace of +fastlane-plugin-sync_devices+ helpers.
7
+ module SyncDevicesHelper
8
+ end
9
+ end
10
+ end
11
+
12
+ require_relative 'sync_devices_helper/devices_file'
13
+ require_relative 'sync_devices_helper/devices_patch'
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fastlane
4
+ module SyncDevices
5
+ # Gem version of fastlane-plugin-sync_devices.
6
+ VERSION = '0.1.0'
7
+ end
8
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'sync_devices/version'
4
+ require_relative 'sync_devices/actions/sync_devices_action'
5
+
6
+ module Fastlane
7
+ # Root namespace of +fastlane-plugin-sync_devices+ plugin.
8
+ module SyncDevices
9
+ # @return [Array<String>]
10
+ def self.all_classes
11
+ Dir[File.expand_path('**/{actions,helper}/*.rb', __dir__)]
12
+ end
13
+ end
14
+ end
15
+
16
+ Fastlane::SyncDevices.all_classes.each do |current|
17
+ require current
18
+ end
metadata ADDED
@@ -0,0 +1,56 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: fastlane-plugin-sync_devices
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ryosuke Ito
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2024-04-18 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Synchronize your devices with Apple Developer Portal.
14
+ email: rito.0305@gmail.com
15
+ executables: []
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - CHANGELOG.md
20
+ - LICENSE
21
+ - README.md
22
+ - lib/fastlane/plugin/sync_devices.rb
23
+ - lib/fastlane/plugin/sync_devices/actions/sync_devices_action.rb
24
+ - lib/fastlane/plugin/sync_devices/helper/sync_devices_helper.rb
25
+ - lib/fastlane/plugin/sync_devices/helper/sync_devices_helper/command.rb
26
+ - lib/fastlane/plugin/sync_devices/helper/sync_devices_helper/device_patch.rb
27
+ - lib/fastlane/plugin/sync_devices/helper/sync_devices_helper/devices_file.rb
28
+ - lib/fastlane/plugin/sync_devices/helper/sync_devices_helper/devices_patch.rb
29
+ - lib/fastlane/plugin/sync_devices/version.rb
30
+ homepage: https://github.com/manicmaniac/fastlane-plugin-sync_devices
31
+ licenses:
32
+ - MIT
33
+ metadata:
34
+ bug_tracker_uri: https://github.com/manicmaniac/fastlane-plugin-sync_devices/issues
35
+ rubygems_mfa_required: 'true'
36
+ source_code_uri: https://github.com/manicmaniac/fastlane-plugin-sync_devices
37
+ post_install_message:
38
+ rdoc_options: []
39
+ require_paths:
40
+ - lib
41
+ required_ruby_version: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: 2.7.0
46
+ required_rubygems_version: !ruby/object:Gem::Requirement
47
+ requirements:
48
+ - - ">="
49
+ - !ruby/object:Gem::Version
50
+ version: '0'
51
+ requirements: []
52
+ rubygems_version: 3.4.6
53
+ signing_key:
54
+ specification_version: 4
55
+ summary: Synchronize your devices with Apple Developer Portal.
56
+ test_files: []