Skip to content

Commit 75f8c05

Browse files
Merge pull request #1 from TUTORVUE-GROUP-LTD/master
Fix image parser
2 parents 163a3ab + 460fd63 commit 75f8c05

20 files changed

Lines changed: 413 additions & 38 deletions

Examples/README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,16 @@ The UI-Kit of the example project in Figma:
1212

1313
### Example iOS project
1414

15-
There are 2 example iOS projects in `Example` and `ExampleSwiftUI` directories which demostrates how to use figma-export with UIKit and SwiftUI.
15+
There are 2 example iOS projects in `Example` and `ExampleSwiftUI` directories which demonstrates how to use figma-export with UIKit and SwiftUI.
1616

1717
<img src="../images/figma.png" />
1818

1919
**How to setup iOS project**
2020
1. Open `Example/fastlane/.env` file.
2121
2. Change FIGMA_PERSONAL_TOKEN to your personal Figma token.
2222
3. Go to `Example` folder.
23-
4. Run the following command in Termanal to install cocoapods and fastlane: `bundle install`
24-
5. Run the following command in Termanal to install figma-export: `bundle exec pod install`
23+
4. Run the following command in Terminal to install CocoaPods and Fastlane: `bundle install`
24+
5. Run the following command in Terminal to install figma-export: `bundle exec pod install`
2525

2626
**How to export resources from figma**
2727
* To export colors run: `bundle exec fastlane export_colors`
@@ -39,7 +39,7 @@ There is an example Android Studio project in `AndroidExample` directory which d
3939

4040
### Example Android Jetpack Compose project
4141

42-
There is an example Android Studio project in `AndroidComposeExample` directory which demostrates how to use `figma-export` configured for Jetpack Compose.
42+
There is an example Android Studio project in `AndroidComposeExample` directory which demonstrates how to use `figma-export` configured for Jetpack Compose.
4343

4444
You can find the generated code for compose in the package `com.redmadrobot.androidcomposeexample.ui.figmaexport`
4545

FigmaExport.podspec

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
Pod::Spec.new do |spec|
22
spec.name = "FigmaExport"
3-
spec.version = "0.38.2"
3+
spec.version = "0.38.4"
44
spec.summary = "Command line utility to export colors, typography, icons and images from Figma to Xcode / Android Studio project."
55
spec.homepage = "https://github.com/RedMadRobot/figma-export"
66
spec.license = { type: "MIT", file: "LICENSE" }
77
spec.author = { "Daniil Subbotin" => "mail@subdan.ru" }
88
spec.source = { http: "#{spec.homepage}/releases/download/#{spec.version}/figma-export.zip" }
99
spec.preserve_paths = '*'
1010
spec.platform = :ios
11-
spec.ios.deployment_target = '15.0'
11+
spec.ios.deployment_target = '15.2'
1212
end

Package.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,10 @@ let package = Package(
4141
.target(name: "FigmaExportCore"),
4242

4343
// Loads data via Figma REST API
44-
.target(name: "FigmaAPI"),
44+
.target(
45+
name: "FigmaAPI",
46+
dependencies: [.product(name: "Logging", package: "swift-log")]
47+
),
4548

4649
// Exports resources to Xcode project
4750
.target(
@@ -84,6 +87,10 @@ let package = Package(
8487
.testTarget(
8588
name: "AndroidExportTests",
8689
dependencies: ["AndroidExport", .product(name: "CustomDump", package: "swift-custom-dump")]
90+
),
91+
.testTarget(
92+
name: "FigmaAPITests",
93+
dependencies: ["FigmaAPI"]
8794
)
8895
]
8996
)

README.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,6 @@ Additionally, the following Swift file will be created to use colors from the co
8585
static var backgroundVideo: UIColor { return UIColor(named: #function)! }
8686
...
8787
}
88-
8988
```
9089

9190
For SwiftUI the following Swift file will be created to use colors from the code.
@@ -99,7 +98,6 @@ For SwiftUI the following Swift file will be created to use colors from the code
9998
static var backgroundVideo: Color { return Color(#function) }
10099
...
101100
}
102-
103101
```
104102

105103
If you set option `useColorAssets: False` in the configuration file, then will be generated code like this:
@@ -221,6 +219,7 @@ Example of these files:
221219

222220
Colors will be exported to `values/colors.xml` and `values-night/colors.xml` files.
223221
For Jetpack Compose, following code will be generated, if configured:
222+
224223
```kotlin
225224
package com.redmadrobot.androidcomposeexample.ui.figmaexport
226225

@@ -234,6 +233,7 @@ fun Colors.backgroundPrimary(): Color = colorResource(id = R.color.background_pr
234233
```
235234

236235
Icons will be exported to `drawable` directory as vector xml files. For Jetpack Compose, following code will be generated, if configured:
236+
237237
```kotlin
238238
package com.redmadrobot.androidcomposeexample.ui.figmaexport
239239

@@ -260,6 +260,7 @@ Vector images will be exported to `drawable` and `drawable-night` directories as
260260
Raster images will be exported to `drawable-???dpi` and `drawable-night-???dpi` directories as `png` or `webp` files.
261261

262262
Typography will be exported to `values/typography.xml`. For Jetpack Compose, following code will be generated, if configured:
263+
263264
```kotlin
264265
package com.redmadrobot.androidcomposeexample.ui.figmaexport
265266

@@ -290,16 +291,20 @@ object Typography {
290291
[Download](https://github.com/RedMadRobot/figma-export/releases) the latest release and read [Usage](#usage)
291292

292293
### Homebrew
293-
```
294+
295+
```bash
294296
brew install RedMadRobot/formulae/figma-export
295297
```
298+
296299
If you want to export raster images in WebP format install [cwebp](https://developers.google.com/speed/webp/docs/using) command line utility.
297-
```
300+
301+
```bash
298302
brew install webp
299303
```
300304

301305
### CocoaPods + Fastlane
302306
Add the following line to your Podfile:
307+
303308
```ruby
304309
pod 'FigmaExport'
305310
```
@@ -308,6 +313,7 @@ This will download the FigmaExport binaries and dependencies in `Pods/` during y
308313
`pod install` execution and will allow you to invoke it via `Pods/FigmaExport/Release/figma-export` in your Fastfile.
309314

310315
Add the following line to your Fastfile:
316+
311317
```ruby
312318
lane :sync_colors do
313319
Dir.chdir("../") do
@@ -321,6 +327,7 @@ Don't forget to place figma-export.yaml file at the root of the project director
321327
Run `fastlane sync_colors` to run FigmaExport.
322328

323329
## Usage
330+
324331
1. Open `Terminal.app`
325332
2. Go (cd) to the folder with `figma-export` binary file
326333
3. Run `figma-export`
@@ -413,7 +420,8 @@ Example of `figma-export.yaml` file for iOS project — [Examples/Example/figma-
413420
Example of `figma-export.yaml` file for Android project — [Examples/AndroidExample/figma-export.yaml](./Examples/AndroidExample/figma-export.yaml)
414421

415422
Generate `figma-export.yaml` config file using one of the following command:
416-
```
423+
424+
```bash
417425
figma-export init --platform android
418426
figma-export init --platform ios
419427
```

Sources/FigmaAPI/Client.swift

Lines changed: 101 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,135 @@
11
import Foundation
2+
import Logging
23
#if os(Linux)
34
import FoundationNetworking
45
#endif
56

67
public typealias APIResult<Value> = Swift.Result<Value, Error>
78

89
public protocol Client {
9-
10+
1011
func request<T>(_ endpoint: T) throws -> T.Content where T: Endpoint
11-
12+
1213
func request<T>(
1314
_ endpoint: T,
1415
completion: @escaping (APIResult<T.Content>) -> Void ) -> URLSessionTask where T: Endpoint
16+
17+
func requestWithRetry<T>(
18+
_ endpoint: T,
19+
configuration: RetryConfiguration
20+
) throws -> T.Content where T: Endpoint
1521
}
1622

1723
public class BaseClient: Client {
18-
24+
1925
private let baseURL: URL
20-
2126
private let session: URLSession
22-
27+
private let logger = Logger(label: "FigmaAPI.Client")
28+
2329
public init(baseURL: URL, config: URLSessionConfiguration) {
2430
self.baseURL = baseURL
2531
session = URLSession(configuration: config, delegate: nil, delegateQueue: .main)
2632
}
27-
33+
2834
public func request<T>(_ endpoint: T) throws -> T.Content where T: Endpoint {
2935
var outResult: APIResult<T.Content>!
30-
36+
3137
let task = request(endpoint, completion: { result in
3238
outResult = result
3339
})
3440
task.wait()
3541

3642
return try outResult.get()
3743
}
38-
44+
3945
public func request<T>(
4046
_ endpoint: T,
4147
completion: @escaping (APIResult<T.Content>) -> Void ) -> URLSessionTask where T: Endpoint {
42-
48+
4349
let request = endpoint.makeRequest(baseURL: baseURL)
4450
let task = session.dataTask(with: request) { (data: Data?, response: URLResponse?, error: Error?) in
45-
51+
52+
// Handle network errors including timeout
53+
if let urlError = error as? URLError, urlError.code == .timedOut {
54+
completion(.failure(RateLimitError.timeout))
55+
return
56+
}
57+
4658
guard let data = data, error == nil else {
4759
completion(.failure(error!))
4860
return
4961
}
62+
63+
// Check for HTTP 429 rate limiting
64+
if let httpResponse = response as? HTTPURLResponse, httpResponse.isRateLimited {
65+
let retryAfter = httpResponse.extractRetryAfter()
66+
completion(.failure(RateLimitError.rateLimited(retryAfter: retryAfter)))
67+
return
68+
}
69+
5070
let content = APIResult<T.Content>(catching: { () -> T.Content in
5171
return try endpoint.content(from: response, with: data)
5272
})
53-
73+
5474
completion(content)
5575
}
5676
task.resume()
5777
return task
5878
}
5979

80+
public func requestWithRetry<T>(
81+
_ endpoint: T,
82+
configuration: RetryConfiguration = .default
83+
) throws -> T.Content where T: Endpoint {
84+
85+
var lastError: Error?
86+
var currentBackoff = configuration.initialBackoffSeconds
87+
88+
for attempt in 0..<configuration.maxRetries {
89+
do {
90+
let result = try request(endpoint)
91+
// Delay after successful request to prevent hitting rate limits on subsequent calls
92+
if configuration.requestDelaySeconds > 0 {
93+
logger.info("Throttling: waiting \(String(format: "%.1f", configuration.requestDelaySeconds))s before next request")
94+
sleep(forTimeInterval: configuration.requestDelaySeconds)
95+
}
96+
return result
97+
} catch let error as RateLimitError {
98+
switch error {
99+
case .rateLimited(let retryAfter):
100+
// Exit immediately if retry-after exceeds max allowed wait time
101+
if retryAfter > configuration.maxRetryAfterSeconds {
102+
throw RateLimitError.rateLimitExceeded(retryAfter: retryAfter)
103+
}
104+
105+
logger.warning("Rate limited by Figma API. Waiting \(Int(retryAfter))s before retry \(attempt + 1)/\(configuration.maxRetries)")
106+
sleep(forTimeInterval: retryAfter)
107+
lastError = error
108+
109+
case .timeout:
110+
// Exponential backoff for timeout
111+
logger.warning("Request timed out. Waiting \(Int(currentBackoff))s before retry \(attempt + 1)/\(configuration.maxRetries)")
112+
sleep(forTimeInterval: currentBackoff)
113+
currentBackoff *= configuration.backoffMultiplier
114+
lastError = error
115+
116+
case .rateLimitExceeded:
117+
// Do not retry, exit immediately
118+
throw error
119+
}
120+
} catch {
121+
// Other errors: do not retry
122+
throw error
123+
}
124+
}
125+
126+
// All retries exhausted
127+
if let lastError = lastError {
128+
throw lastError
129+
}
130+
throw RateLimitError.timeout
131+
}
132+
60133
}
61134

62135
private extension URLSessionTask {
@@ -71,3 +144,20 @@ private extension URLSessionTask {
71144
}
72145

73146
}
147+
148+
/// Sleeps for the specified interval while keeping the RunLoop responsive.
149+
/// Uses RunLoop when possible to process pending callbacks, with Thread.sleep
150+
/// fallback when RunLoop has no active sources (prevents immediate return).
151+
/// On Linux, uses Thread.sleep directly as RunLoop behavior differs.
152+
private func sleep(forTimeInterval interval: TimeInterval) {
153+
#if os(Linux)
154+
Thread.sleep(forTimeInterval: interval)
155+
#else
156+
let limitDate = Date(timeIntervalSinceNow: interval)
157+
while Date() < limitDate {
158+
if !RunLoop.current.run(mode: .default, before: limitDate) {
159+
Thread.sleep(forTimeInterval: 0.1)
160+
}
161+
}
162+
#endif
163+
}

Sources/FigmaAPI/FigmaClient.swift

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,43 @@
1+
// ABOUTME: FigmaClient is the main API client for Figma REST API
2+
// ABOUTME: Supports automatic retry with exponential backoff for rate limiting
3+
14
import Foundation
25
#if os(Linux)
36
import FoundationNetworking
47
#endif
58

69
final public class FigmaClient: BaseClient {
7-
10+
811
private let baseURL = URL(string: "https://api.figma.com/v1/")!
9-
10-
public init(accessToken: String, timeout: TimeInterval?) {
12+
private let retryConfiguration: RetryConfiguration
13+
14+
public init(
15+
accessToken: String,
16+
timeout: TimeInterval?,
17+
retryConfiguration: RetryConfiguration = .default,
18+
requestDelay: TimeInterval? = nil
19+
) {
20+
// If requestDelay is explicitly provided, override the configuration value
21+
if let requestDelay = requestDelay {
22+
self.retryConfiguration = RetryConfiguration(
23+
maxRetries: retryConfiguration.maxRetries,
24+
maxRetryAfterSeconds: retryConfiguration.maxRetryAfterSeconds,
25+
initialBackoffSeconds: retryConfiguration.initialBackoffSeconds,
26+
backoffMultiplier: retryConfiguration.backoffMultiplier,
27+
requestDelaySeconds: requestDelay
28+
)
29+
} else {
30+
self.retryConfiguration = retryConfiguration
31+
}
1132
let config = URLSessionConfiguration.ephemeral
1233
config.httpAdditionalHeaders = ["X-Figma-Token": accessToken]
1334
config.timeoutIntervalForRequest = timeout ?? 30
1435
super.init(baseURL: baseURL, config: config)
1536
}
1637

38+
/// Convenience method that uses the client's default retry configuration
39+
public func requestWithRetry<T>(_ endpoint: T) throws -> T.Content where T: Endpoint {
40+
return try requestWithRetry(endpoint, configuration: retryConfiguration)
41+
}
42+
1743
}

0 commit comments

Comments
 (0)