fastlane-plugin-sync_devices 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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: []