diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorAssetsLibrary.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorAssetsLibrary.kt index 9aea8503..208aa859 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorAssetsLibrary.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorAssetsLibrary.kt @@ -44,6 +44,9 @@ class EditorAssetsLibrary( try { connection.requestMethod = "GET" + val defaultUserAgent = System.getProperty("http.agent") ?: "" + connection.setRequestProperty("User-Agent", "$defaultUserAgent GutenbergKit/${GutenbergKitVersion.VERSION}") + // Set headers from configuration if (configuration.authHeader.isNotEmpty()) { connection.setRequestProperty("Authorization", configuration.authHeader) @@ -102,6 +105,10 @@ class EditorAssetsLibrary( val connection = URL(httpURL).openConnection() as HttpURLConnection try { connection.requestMethod = "GET" + + val defaultUserAgent = System.getProperty("http.agent") ?: "" + connection.setRequestProperty("User-Agent", "$defaultUserAgent GutenbergKit/${GutenbergKitVersion.VERSION}") + connection.connectTimeout = 30000 connection.readTimeout = 30000 @@ -149,7 +156,7 @@ class EditorAssetsLibrary( */ fun cacheAssetInBackground(url: String) { if (!shouldCacheUrl(url)) return - + scope.launch { try { cacheAsset(url) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergKitVersion.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergKitVersion.kt new file mode 100644 index 00000000..ad69da25 --- /dev/null +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergKitVersion.kt @@ -0,0 +1,14 @@ +// This file is auto-generated by bin/generate-version.js +// Do not edit manually - changes will be overwritten + +package org.wordpress.gutenberg + +/** + * GutenbergKit version information. + */ +object GutenbergKitVersion { + /** + * The current version of GutenbergKit. + */ + const val VERSION = "0.11.1" +} diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index 6ea3abea..572b484a 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -125,6 +125,11 @@ class GutenbergView : WebView { this.settings.javaScriptCanOpenWindowsAutomatically = true this.settings.javaScriptEnabled = true this.settings.domStorageEnabled = true + + // Set custom user agent + val defaultUserAgent = this.settings.userAgentString + this.settings.userAgentString = "$defaultUserAgent GutenbergKit/${GutenbergKitVersion.VERSION}" + this.addJavascriptInterface(this, "editorDelegate") this.visibility = View.GONE diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt index ede20c98..b070e14a 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt @@ -147,4 +147,20 @@ class GutenbergViewTest { assertEquals("File path callback should be null after reset", null, gutenbergView.filePathCallback) } + + @Test + fun `initializeWebView sets custom user agent with GutenbergKit identifier`() { + // Given + val gutenbergView = GutenbergView(RuntimeEnvironment.getApplication()) + + // When + gutenbergView.initializeWebView() + + // Then + val userAgent = gutenbergView.settings.userAgentString + assertTrue("User agent should contain GutenbergKit identifier", + userAgent.contains("GutenbergKit/")) + assertTrue("User agent should contain version number", + userAgent.contains("GutenbergKit/${GutenbergKitVersion.VERSION}")) + } } diff --git a/bin/generate-version.js b/bin/generate-version.js new file mode 100755 index 00000000..c50ef18f --- /dev/null +++ b/bin/generate-version.js @@ -0,0 +1,74 @@ +#!/usr/bin/env node + +/** + * Generate version files for iOS and Android from package.json + * This ensures a single source of truth for the version number + */ + +/** + * External dependencies + */ +import { readFileSync, writeFileSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +/** + * Internal dependencies + */ +import { info } from '../src/utils/logger.js'; + +const __filename = fileURLToPath( import.meta.url ); +const __dirname = dirname( __filename ); +const rootDir = join( __dirname, '..' ); + +// Read version from package.json +const packageJson = JSON.parse( + readFileSync( join( rootDir, 'package.json' ), 'utf8' ) +); +const version = packageJson.version; + +info( `Generating version files for version ${ version }` ); + +// Generate iOS version file +const iosVersionContent = `// This file is auto-generated by bin/generate-version.js +// Do not edit manually - changes will be overwritten + +/// GutenbergKit version information. +public enum GutenbergKitVersion { + /// The current version of GutenbergKit. + public static let version = "${ version }" +} +`; + +const iosVersionPath = join( + rootDir, + 'ios/Sources/GutenbergKit/Sources/GutenbergKitVersion.swift' +); +writeFileSync( iosVersionPath, iosVersionContent, 'utf8' ); +info( `Generated ${ iosVersionPath }` ); + +// Generate Android version file +const androidVersionContent = `// This file is auto-generated by bin/generate-version.js +// Do not edit manually - changes will be overwritten + +package org.wordpress.gutenberg + +/** + * GutenbergKit version information. + */ +object GutenbergKitVersion { + /** + * The current version of GutenbergKit. + */ + const val VERSION = "${ version }" +} +`; + +const androidVersionPath = join( + rootDir, + 'android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergKitVersion.kt' +); +writeFileSync( androidVersionPath, androidVersionContent, 'utf8' ); +info( `Generated ${ androidVersionPath }` ); + +info( 'Version files generated successfully' ); diff --git a/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift b/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift index dec766db..431a0382 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift @@ -41,6 +41,18 @@ public actor EditorHTTPClient: EditorHTTPClientProtocol { case unknown(response: Data, statusCode: Int) } + /// The base user agent string identifying the platform. + private static let baseUserAgent: String = { + let version = ProcessInfo.processInfo.operatingSystemVersion + #if os(iOS) + return "iOS/\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)" + #elseif os(macOS) + return "macOS/\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)" + #else + return "Darwin" + #endif + }() + private let urlSession: URLSessionProtocol private let authHeader: String private let delegate: EditorHTTPClientDelegate? @@ -99,6 +111,7 @@ public actor EditorHTTPClient: EditorHTTPClientProtocol { private func configureRequest(_ request: URLRequest) -> URLRequest { var mutableRequest = request mutableRequest.addValue(self.authHeader, forHTTPHeaderField: "Authorization") + mutableRequest.addValue("\(Self.baseUserAgent) GutenbergKit/\(GutenbergKitVersion.version)", forHTTPHeaderField: "User-Agent") if let requestTimeout { mutableRequest.timeoutInterval = requestTimeout diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index f684546a..8018b4c7 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -175,6 +175,8 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro self.bundleProvider.bind(to: config) + config.applicationNameForUserAgent = "GutenbergKit/\(GutenbergKitVersion.version)" + self.webView = GBWebView(frame: .zero, configuration: config) self.webView.scrollView.keyboardDismissMode = .interactive diff --git a/ios/Sources/GutenbergKit/Sources/GutenbergKitVersion.swift b/ios/Sources/GutenbergKit/Sources/GutenbergKitVersion.swift new file mode 100644 index 00000000..e07cb3fe --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/GutenbergKitVersion.swift @@ -0,0 +1,8 @@ +// This file is auto-generated by bin/generate-version.js +// Do not edit manually - changes will be overwritten + +/// GutenbergKit version information. +public enum GutenbergKitVersion { + /// The current version of GutenbergKit. + public static let version = "0.11.1" +} diff --git a/ios/Sources/GutenbergKit/Sources/Views/HTMLPreview/HTMLWebViewRenderer.swift b/ios/Sources/GutenbergKit/Sources/Views/HTMLPreview/HTMLWebViewRenderer.swift index 991b12a3..d5e15615 100644 --- a/ios/Sources/GutenbergKit/Sources/Views/HTMLPreview/HTMLWebViewRenderer.swift +++ b/ios/Sources/GutenbergKit/Sources/Views/HTMLPreview/HTMLWebViewRenderer.swift @@ -90,6 +90,7 @@ final class HTMLWebViewRenderer { init() { let config = WKWebViewConfiguration() config.suppressesIncrementalRendering = true + config.applicationNameForUserAgent = "GutenbergKit/\(GutenbergKitVersion.version)" // Create web view with small initial frame for off-screen rendering // Frame will be adjusted per render based on viewport width and content height diff --git a/ios/Tests/GutenbergKitTests/EditorHTTPClientTests.swift b/ios/Tests/GutenbergKitTests/EditorHTTPClientTests.swift index dbe2d91b..ba8edafb 100644 --- a/ios/Tests/GutenbergKitTests/EditorHTTPClientTests.swift +++ b/ios/Tests/GutenbergKitTests/EditorHTTPClientTests.swift @@ -335,6 +335,63 @@ struct EditorHTTPClientTests { #expect(lastCall.request.value(forHTTPHeaderField: "Authorization") == authHeader) #expect(lastCall.request.httpShouldHandleCookies == false) } + + // MARK: - User-Agent Header Tests + + @Test("perform() sets User-Agent header with GutenbergKit identifier") + func performSetsUserAgentHeader() async throws { + let spySession = SpyURLSession() + let client = EditorHTTPClient( + urlSession: spySession, + authHeader: "Bearer token" + ) + + let request = URLRequest(url: URL(string: "https://example.com/wp-json/wp/v2/posts")!) + _ = try await client.perform(request) + + let capturedRequest = try #require(spySession.lastCapturedRequest) + let userAgent = try #require(capturedRequest.value(forHTTPHeaderField: "User-Agent")) + #expect(userAgent.contains("GutenbergKit/")) + #expect(userAgent.contains(GutenbergKitVersion.version)) + } + + @Test("download() sets User-Agent header with GutenbergKit identifier") + func downloadSetsUserAgentHeader() async throws { + let spySession = SpyURLSession() + let client = EditorHTTPClient( + urlSession: spySession, + authHeader: "Bearer token" + ) + + let request = URLRequest(url: URL(string: "https://example.com/wp-content/file.js")!) + _ = try await client.download(request) + + let capturedRequest = try #require(spySession.lastCapturedRequest) + let userAgent = try #require(capturedRequest.value(forHTTPHeaderField: "User-Agent")) + #expect(userAgent.contains("GutenbergKit/")) + #expect(userAgent.contains(GutenbergKitVersion.version)) + } + + @Test("User-Agent header includes platform identifier") + func userAgentIncludesPlatformIdentifier() async throws { + let spySession = SpyURLSession() + let client = EditorHTTPClient( + urlSession: spySession, + authHeader: "Bearer token" + ) + + let request = URLRequest(url: URL(string: "https://example.com/wp-json/wp/v2/posts")!) + _ = try await client.perform(request) + + let capturedRequest = try #require(spySession.lastCapturedRequest) + let userAgent = try #require(capturedRequest.value(forHTTPHeaderField: "User-Agent")) + + #if os(iOS) + #expect(userAgent.contains("iOS/")) + #elseif os(macOS) + #expect(userAgent.contains("macOS/")) + #endif + } } fileprivate extension EditorResponseData { diff --git a/ios/Tests/GutenbergKitTests/GBWebViewTests.swift b/ios/Tests/GutenbergKitTests/GBWebViewTests.swift new file mode 100644 index 00000000..bb5185b2 --- /dev/null +++ b/ios/Tests/GutenbergKitTests/GBWebViewTests.swift @@ -0,0 +1,19 @@ +import Testing +import WebKit +@testable import GutenbergKit + +struct GBWebViewTests { + + @MainActor + func testApplicationNameForUserAgent() async throws { + let result = try await GBWebView().evaluateJavaScript("navigator.userAgent") + let string = try #require(result as? String) + + #expect(string.hasSuffix("GutenbergKit/\(GutenbergKitVersion.version)")) + } + + func testVersionConstantExists() { + #expect(!GutenbergKitVersion.version.isEmpty, "Version constant should not be empty") + #expect(GutenbergKitVersion.version.contains("."), "Version should be in semantic versioning format") + } +} diff --git a/package.json b/package.json index afd82ff0..4ee09849 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,10 @@ "dev:tools": "react-devtools", "build": "vite --emptyOutDir build", "format": "prettier --write .", + "generate-version": "node bin/generate-version.js", "lint:js": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", "lint:js:fix": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0 --fix", - "postinstall": "patch-package && npm run prep-translations", + "postinstall": "patch-package && npm run prep-translations && npm run generate-version", "prep-translations": "node bin/prep-translations.js", "preview": "vite preview --host", "test:unit": "vitest run",