From b413573178d3926ea8887dd663c4163890443bb0 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Wed, 29 Apr 2026 00:37:02 +0800 Subject: [PATCH] feat(ios): add Fastlane xuqm_release lane, expand IM SDK Co-Authored-By: Claude Sonnet 4.6 --- README.md | 1 + Sources/XuqmSDK/IM/ImSDK.swift | 29 ++++- scripts/Fastfile | 187 +++++++++++++++++++++++++++++++++ 3 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 scripts/Fastfile diff --git a/README.md b/README.md index 6233849..4201b3f 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,7 @@ public struct ImMessage: Decodable { public let extra: String? public let revoked: Bool public let createdAt: String + public let editedAt: Int64? } ``` diff --git a/Sources/XuqmSDK/IM/ImSDK.swift b/Sources/XuqmSDK/IM/ImSDK.swift index b8c68e9..665af90 100644 --- a/Sources/XuqmSDK/IM/ImSDK.swift +++ b/Sources/XuqmSDK/IM/ImSDK.swift @@ -244,8 +244,13 @@ public final class ImSDK { ) } - public func revokeMessage(messageId: String) { - client?.revoke(messageId: messageId) + public func revokeMessage(messageId: String) async throws -> ImMessage { + let config = XuqmSDK.shared.requireConfig() + return try await ApiClient.shared.request( + path: "/api/im/messages/\(messageId)/revoke", + method: "POST", + queryItems: [URLQueryItem(name: "appId", value: config.appId)] + ) } public func editMessage(messageId: String, content: String) async throws -> ImMessage { @@ -465,6 +470,26 @@ public final class ImSDK { try await ApiClient.shared.request(path: "/api/im/groups/\(groupId)") } + public func listGroupMembers(groupId: String) async throws -> [UserProfile] { + let config = XuqmSDK.shared.requireConfig() + return try await ApiClient.shared.request( + path: "/api/im/groups/\(groupId)/members", + queryItems: [URLQueryItem(name: "appId", value: config.appId)] + ) + } + + public func searchGroupMembers(groupId: String, keyword: String, size: Int = 20) async throws -> [UserProfile] { + let config = XuqmSDK.shared.requireConfig() + return try await ApiClient.shared.request( + path: "/api/im/groups/\(groupId)/members/search", + queryItems: [ + URLQueryItem(name: "appId", value: config.appId), + URLQueryItem(name: "keyword", value: keyword), + URLQueryItem(name: "size", value: String(size)) + ] + ) + } + public func updateGroupInfo(groupId: String, name: String? = nil, announcement: String? = nil) async throws -> ImGroup { try await ApiClient.shared.request( path: "/api/im/groups/\(groupId)", diff --git a/scripts/Fastfile b/scripts/Fastfile new file mode 100644 index 0000000..f1657d4 --- /dev/null +++ b/scripts/Fastfile @@ -0,0 +1,187 @@ +# XuqmGroup Update Service — iOS Fastlane Release Lane +# +# Requires: fastlane (gem install fastlane) — standard iOS platform toolchain +# Usage (from your iOS project root): +# fastlane xuqm_release +# +# Config: add a .env.xuqm file to your project root with: +# XUQM_SERVER_URL=https://update.dev.xuqinmin.com +# XUQM_APP_ID=your-app-id +# XUQM_API_TOKEN=your-api-token +# XUQM_SCHEME=MyApp +# XUQM_WORKSPACE=MyApp.xcworkspace +# XUQM_BUNDLE_ID=com.example.myapp +# XUQM_STORE_TARGETS=APP_STORE # comma-separated, e.g. "APP_STORE" +# XUQM_AUTO_PUBLISH_AFTER_REVIEW=false +# XUQM_WEBHOOK_URL= # optional +# XUQM_SCHEDULED_PUBLISH_AT= # optional, ISO datetime +# # For App Store Connect API (used by upload_to_app_store): +# APP_STORE_CONNECT_API_KEY_ID=XXXXXXXXXX +# APP_STORE_CONNECT_API_ISSUER_ID=xxxx-xxxx-xxxx-xxxx +# APP_STORE_CONNECT_API_KEY_FILEPATH=~/.appstoreconnect/AuthKey_XXXXXXXXXX.p8 + +require 'net/http' +require 'json' +require 'uri' + +default_platform(:ios) + +platform :ios do + + desc "Build, upload to XuqmGroup update service, and submit to App Store" + lane :xuqm_release do + server_url = ENV.fetch("XUQM_SERVER_URL") + app_id = ENV.fetch("XUQM_APP_ID") + api_token = ENV.fetch("XUQM_API_TOKEN") + scheme = ENV.fetch("XUQM_SCHEME") + workspace = ENV.fetch("XUQM_WORKSPACE") + bundle_id = ENV.fetch("XUQM_BUNDLE_ID", "") + store_targets = ENV.fetch("XUQM_STORE_TARGETS", "") + auto_publish = ENV.fetch("XUQM_AUTO_PUBLISH_AFTER_REVIEW", "false") == "true" + webhook_url = ENV.fetch("XUQM_WEBHOOK_URL", "") + scheduled_at = ENV.fetch("XUQM_SCHEDULED_PUBLISH_AT", "") + + # ── 1. Read local version ──────────────────────────────────────────── + version_name = get_version_number(target: scheme) + version_code = get_build_number.to_i + UI.message("Local version: #{version_name} (#{version_code})") + + # ── 2. Check server latest ─────────────────────────────────────────── + server_version_code = xuqm_get_latest_version_code(server_url, app_id, api_token, "IOS") + UI.message("Server latest versionCode: #{server_version_code}") + + if version_code <= server_version_code + UI.user_error!( + "Local versionCode (#{version_code}) must be greater than server (#{server_version_code}). " \ + "Please bump CFBundleVersion before releasing." + ) + end + + # ── 3. Archive & export IPA ────────────────────────────────────────── + archive_path = File.join(Dir.tmpdir, "xuqm_#{scheme}.xcarchive") + ipa_output = File.join(Dir.tmpdir, "xuqm_#{scheme}_ipa") + + build_app( + scheme: scheme, + workspace: workspace, + archive_path: archive_path, + output_directory: ipa_output, + export_method: "app-store", + silent: false, + ) + + ipa_path = Dir.glob("#{ipa_output}/*.ipa").first + UI.user_error!("IPA not found in #{ipa_output}") unless ipa_path + UI.success("IPA: #{ipa_path}") + + # ── 4. Upload to XuqmGroup update service ──────────────────────────── + change_log = prompt(text: "Release notes: ", ci_input: "") + version_id = xuqm_upload_ipa( + server_url: server_url, + app_id: app_id, + api_token: api_token, + ipa_path: ipa_path, + version_name: version_name, + version_code: version_code, + change_log: change_log, + package_name: bundle_id, + store_targets: store_targets.empty? ? [] : store_targets.split(",").map(&:strip), + auto_publish_after_review: auto_publish, + scheduled_publish_at: scheduled_at, + webhook_url: webhook_url, + ) + UI.success("Uploaded, version ID: #{version_id}") + + # ── 5. Submit to App Store via App Store Connect API ───────────────── + if store_targets.include?("APP_STORE") + UI.message("Submitting to App Store Connect...") + + # Use Fastlane's built-in action — handles JWT auth, upload, and submission + # Requires APP_STORE_CONNECT_API_KEY_* env vars + api_key = app_store_connect_api_key( + key_id: ENV.fetch("APP_STORE_CONNECT_API_KEY_ID"), + issuer_id: ENV.fetch("APP_STORE_CONNECT_API_ISSUER_ID"), + key_filepath: ENV.fetch("APP_STORE_CONNECT_API_KEY_FILEPATH"), + ) + + upload_to_app_store( + api_key: api_key, + ipa: ipa_path, + submit_for_review: true, + force: true, + automatic_release: auto_publish, + skip_metadata: true, + skip_screenshots: true, + run_precheck_before_submit: false, + ) + + # Notify update service that we've submitted + xuqm_post( + url: "#{server_url}/api/v1/updates/store/app/#{version_id}/submit", + token: api_token, + body: { storeTypes: ["APP_STORE"] }.to_json, + ) + UI.success("App Store submission done. Review status will update via webhook or manually.") + end + + # ── 6. Trigger server-side store submission for other stores ────────── + other_stores = store_targets.split(",").map(&:strip).reject { |s| s == "APP_STORE" } + unless other_stores.empty? + xuqm_post( + url: "#{server_url}/api/v1/updates/store/app/#{version_id}/execute-submit", + token: api_token, + body: { storeTypes: other_stores }.to_json, + ) + end + + UI.success("Release complete. Version ID: #{version_id}") + UI.message(scheduled_at.empty? ? "Publish manually or wait for auto-publish." : "Will publish at #{scheduled_at}") + end + + # ── Private helpers ────────────────────────────────────────────────────── + + private_lane :xuqm_get_latest_version_code do |options| + uri = URI("#{options[:server_url]}/api/v1/updates/app/list?appId=#{options[:app_id]}&platform=#{options[:platform]}") + req = Net::HTTP::Get.new(uri) + req["Authorization"] = "Bearer #{options[:api_token]}" + resp = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |h| h.request(req) } + data = JSON.parse(resp.body) + codes = (data.dig("data") || []).filter_map { |v| v["versionCode"] } + codes.max || 0 + end + + private_lane :xuqm_upload_ipa do |options| + require 'net/http/post/multipart' + + uri = URI("#{options[:server_url]}/api/v1/updates/app/upload") + request = Net::HTTP::Post::Multipart.new(uri.path, + "appId" => options[:app_id], + "platform" => "IOS", + "versionName" => options[:version_name], + "versionCode" => options[:version_code].to_s, + "changeLog" => options[:change_log].to_s, + "forceUpdate" => "false", + "packageName" => options[:package_name].to_s, + "autoPublishAfterReview" => options[:auto_publish_after_review].to_s, + "storeSubmitTargets" => options[:store_targets].empty? ? "" : options[:store_targets].to_json, + "scheduledPublishAt" => options[:scheduled_publish_at].to_s, + "webhookUrl" => options[:webhook_url].to_s, + "apkFile" => UploadIO.new(File.open(options[:ipa_path]), "application/octet-stream", File.basename(options[:ipa_path])), + ) + request["Authorization"] = "Bearer #{options[:api_token]}" + + resp = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |h| h.request(request) } + data = JSON.parse(resp.body) + data.dig("data", "id") || UI.user_error!("Upload failed: #{resp.body}") + end + + private_lane :xuqm_post do |options| + uri = URI(options[:url]) + req = Net::HTTP::Post.new(uri) + req["Authorization"] = "Bearer #{options[:token]}" + req["Content-Type"] = "application/json" + req.body = options[:body] + Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |h| h.request(req) } + end + +end