xgem-cli 2.0.0-alpha → 2.0.0-alpha.10

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.
package/lib/doctor.sh CHANGED
@@ -26,7 +26,10 @@ flutter_supports_flag() {
26
26
  flutter_config_spm_enabled() {
27
27
  has_cmd flutter || { echo "unknown (flutter not found)"; return; }
28
28
  local line
29
- line=$(flutter config 2>/dev/null | grep -i "swift-package-manager")
29
+ # Anchored to "enable-swift-package-manager:" (the current-value line)
30
+ # so this doesn't instead match the "--[no-]enable-swift-package-manager"
31
+ # flag-syntax help line that `flutter config` also prints.
32
+ line=$(flutter config 2>/dev/null | grep -im1 -E '^[[:space:]]*enable-swift-package-manager:')
30
33
  if [ -z "$line" ]; then
31
34
  echo "unknown (not reported by this Flutter version)"
32
35
  else
@@ -86,7 +89,7 @@ doctor_print_ios() {
86
89
  echo "Deployment target [$cfg]: ${t:-unknown}"
87
90
  done
88
91
 
89
- local required
92
+ local required generated
90
93
  required=$(ios_required_spm_deployment_target "$project_dir" 2>/dev/null)
91
94
  if [ -n "$required" ]; then
92
95
  echo "Required by resolved SwiftPM plugins: $required"
@@ -94,6 +97,14 @@ doctor_print_ios() {
94
97
  echo "Required by resolved SwiftPM plugins: unable to determine (run 'flutter pub get' first, or no SPM plugins declare an explicit floor)"
95
98
  fi
96
99
 
100
+ if [ "$uses_spm" = "yes" ]; then
101
+ generated=$(ios_generated_package_target "$project_dir" 2>/dev/null)
102
+ echo "FlutterGeneratedPluginSwiftPackage declares: ${generated:-unknown}"
103
+ if [ -n "$required" ] && [ -n "$generated" ] && _ios_ver_lt "$generated" "$required"; then
104
+ log_warn "This is stale — it declares iOS $generated but plugins need $required. This can happen even when your app's own deployment target is already high enough; 'xgem run flutter build' will detect and fix this."
105
+ fi
106
+ fi
107
+
97
108
  if [ "$uses_spm" = "yes" ]; then
98
109
  ios_known_issue_check
99
110
  fi
package/lib/flutter.sh CHANGED
@@ -66,14 +66,19 @@ _flutter_build_ios() {
66
66
 
67
67
  require_cmd xcodebuild "iOS builds require Xcode's command-line tools."
68
68
 
69
+ # Must run pub get BEFORE reconciling: the required-deployment-target
70
+ # check reads .dart_tool/package_config.json, and that file only
71
+ # reflects the plugin versions actually in use after a fresh pub get.
72
+ # Reconciling against a stale package_config.json can silently pass a
73
+ # check that the real, just-resolved dependency graph would fail.
74
+ log_info "Regenerating dependencies..."
75
+ flutter pub get
76
+
69
77
  log_info "Reconciling iOS deployment target against resolved SwiftPM plugins..."
70
78
  if ! ios_reconcile_deployment_target "."; then
71
79
  die "iOS deployment target could not be reconciled; aborting before build."
72
80
  fi
73
81
 
74
- log_info "Regenerating dependencies..."
75
- flutter pub get
76
-
77
82
  if ios_project_uses_pods "."; then
78
83
  log_info "CocoaPods detected - installing pods..."
79
84
  ( cd ios || exit 1; pod install )
@@ -100,7 +105,7 @@ _flutter_build_ios() {
100
105
  mkdir -p "$archive_dir"
101
106
  archive_path="$archive_dir/Runner $archive_time.xcarchive"
102
107
 
103
- if ! xcodebuild -workspace ios/Runner.xcworkspace \
108
+ local -a archive_cmd=(xcodebuild -workspace ios/Runner.xcworkspace \
104
109
  -scheme Runner \
105
110
  -sdk iphoneos \
106
111
  -configuration Release \
@@ -108,10 +113,35 @@ _flutter_build_ios() {
108
113
  -archivePath "$archive_path" \
109
114
  "BUILD_NUMBER=$build_number" \
110
115
  "MARKETING_VERSION=$build_name" \
111
- -allowProvisioningUpdates > "$log_file" 2>&1; then
112
- log_error "Archive FAILED"
113
- cat "$log_file"
114
- die "xcodebuild archive failed" "$?"
116
+ -allowProvisioningUpdates)
117
+
118
+ if ! "${archive_cmd[@]}" > "$log_file" 2>&1; then
119
+ # Static pre-checks can miss cases the real SwiftPM resolution
120
+ # catches (different plugin manifest layouts, symlinked package
121
+ # dirs, etc.) — Xcode's own error is authoritative, so parse it
122
+ # directly and retry once before giving up.
123
+ local retry_required
124
+ retry_required=$(ios_parse_required_from_build_log "$log_file")
125
+ if [ -n "$retry_required" ]; then
126
+ log_warn "Archive failed on a SwiftPM platform-version mismatch Xcode reports directly (requires iOS $retry_required). Retrying with that applied..."
127
+ ios_pbxproj_patch_deployment_target "$retry_required" "."
128
+ ios_regenerate_generated_package "."
129
+ local regenerated
130
+ regenerated=$(ios_generated_package_target ".")
131
+ if [ -z "$regenerated" ] || _ios_ver_lt "$regenerated" "$retry_required"; then
132
+ ios_patch_generated_package_target "$retry_required" "."
133
+ fi
134
+
135
+ if ! "${archive_cmd[@]}" > "$log_file" 2>&1; then
136
+ log_error "Archive FAILED again after retry"
137
+ cat "$log_file"
138
+ die "xcodebuild archive failed" "$?"
139
+ fi
140
+ else
141
+ log_error "Archive FAILED"
142
+ cat "$log_file"
143
+ die "xcodebuild archive failed" "$?"
144
+ fi
115
145
  fi
116
146
  log_success "Archive created at: $archive_path"
117
147
 
@@ -159,20 +189,21 @@ cmd_flutter_build() {
159
189
  echo ""
160
190
  echo "Select Target Platform:"
161
191
  echo "1) APK (Android)"
162
- echo "2) iOS"
163
- echo "3) macOS"
164
- echo "4) Windows"
165
- echo "5) Linux"
192
+ echo "2) App Bundle (Android, .aab — required for Play Store uploads)"
193
+ echo "3) iOS"
194
+ echo "4) macOS"
195
+ echo "5) Windows"
196
+ echo "6) Linux"
166
197
  local platform_choice
167
- read -r -p "Choose [1-5]: " platform_choice
168
- if ! [[ "$platform_choice" =~ ^[1-5]$ ]]; then
198
+ read -r -p "Choose [1-6]: " platform_choice
199
+ if ! [[ "$platform_choice" =~ ^[1-6]$ ]]; then
169
200
  die "Invalid platform choice '$platform_choice'."
170
201
  fi
171
202
 
172
203
  local mode="--release"
173
204
  [[ "$is_release" == "n" || "$is_release" == "N" ]] && mode="--debug"
174
205
 
175
- if [ "$platform_choice" -eq 2 ]; then
206
+ if [ "$platform_choice" -eq 3 ]; then
176
207
  _flutter_build_ios "$build_name" "$build_number" "$mode"
177
208
  return
178
209
  fi
@@ -180,9 +211,10 @@ cmd_flutter_build() {
180
211
  log_info "Initializing Flutter Build ($mode) for version $build_name+$build_number..."
181
212
  case $platform_choice in
182
213
  1) flutter build apk "$mode" "--build-name=$build_name" "--build-number=$build_number" ;;
183
- 3) flutter build macos "$mode" "--build-name=$build_name" "--build-number=$build_number" ;;
184
- 4) flutter build windows "$mode" "--build-name=$build_name" "--build-number=$build_number" ;;
185
- 5) flutter build linux "$mode" "--build-name=$build_name" "--build-number=$build_number" ;;
214
+ 2) flutter build appbundle "$mode" "--build-name=$build_name" "--build-number=$build_number" ;;
215
+ 4) flutter build macos "$mode" "--build-name=$build_name" "--build-number=$build_number" ;;
216
+ 5) flutter build windows "$mode" "--build-name=$build_name" "--build-number=$build_number" ;;
217
+ 6) flutter build linux "$mode" "--build-name=$build_name" "--build-number=$build_number" ;;
186
218
  esac
187
219
  }
188
220
 
@@ -205,3 +237,114 @@ flutter_run_native_command() {
205
237
  *) die "No native flutter command for '$script'." ;;
206
238
  esac
207
239
  }
240
+
241
+ # create_flutter_project — used by lib/create.sh's create_project_wizard.
242
+ # `flutter create` needs a target dir; "." scaffolds into the current one.
243
+ create_flutter_project() {
244
+ require_cmd flutter "Install Flutter: https://docs.flutter.dev/get-started/install"
245
+ local project_dir
246
+ project_dir=$(_prompt_project_location "Flutter")
247
+ [ "$project_dir" != "." ] && XGEM_CREATED_NEW_FOLDER="$project_dir"
248
+ if [ "$project_dir" = "." ]; then
249
+ flutter create . || die "flutter create failed."
250
+ else
251
+ flutter create "$project_dir" || die "flutter create failed."
252
+ cd "$project_dir" || die "Could not enter $project_dir"
253
+ fi
254
+ _flutter_offer_run_on_device
255
+ }
256
+
257
+ # _flutter_select_device -> echoes the chosen device id, or nothing if
258
+ # none available/selected. Uses `flutter devices --machine` (JSON) parsed
259
+ # with python3 — already a soft dependency elsewhere in lib/ios.sh for the
260
+ # same reason (no jq assumed present).
261
+ _flutter_select_device() {
262
+ require_cmd python3 "Needed to parse 'flutter devices --machine' output."
263
+ local devices_json_file
264
+ devices_json_file=$(mktemp)
265
+ flutter devices --machine > "$devices_json_file" 2>/dev/null
266
+
267
+ local -a ids=() labels=()
268
+ while IFS='|' read -r id label; do
269
+ [ -n "$id" ] || continue
270
+ ids+=("$id")
271
+ labels+=("$label")
272
+ done < <(python3 - "$devices_json_file" <<'PYEOF'
273
+ import json, sys
274
+
275
+ try:
276
+ with open(sys.argv[1]) as f:
277
+ devices = json.load(f)
278
+ except Exception:
279
+ devices = []
280
+
281
+ for d in devices:
282
+ platform = d.get("platform") or d.get("targetPlatform") or ""
283
+ name = d.get("name", "unknown")
284
+ device_id = d.get("id", "")
285
+ print(f"{device_id}|{name} ({platform})")
286
+ PYEOF
287
+ )
288
+ rm -f "$devices_json_file"
289
+
290
+ if [ ${#ids[@]} -eq 0 ]; then
291
+ log_warn "No running devices/simulators found."
292
+ _flutter_offer_launch_emulator
293
+ return 1
294
+ fi
295
+
296
+ echo "Available devices:" >&2
297
+ local i
298
+ for i in "${!ids[@]}"; do
299
+ echo "$((i + 1))) ${labels[$i]}" >&2
300
+ done
301
+
302
+ local choice
303
+ read -r -p "Select a device [1-${#ids[@]}]: " choice
304
+ if ! [[ "$choice" =~ ^[0-9]+$ ]] || [ "$choice" -lt 1 ] || [ "$choice" -gt "${#ids[@]}" ]; then
305
+ log_warn "Invalid selection."
306
+ return 1
307
+ fi
308
+ echo "${ids[$((choice - 1))]}"
309
+ }
310
+
311
+ # No running devices — check for available-but-not-booted emulators and
312
+ # offer to launch one, matching the "check available simulators... then
313
+ # launch it" behavior the user asked for.
314
+ _flutter_offer_launch_emulator() {
315
+ local emulators_output
316
+ emulators_output=$(flutter emulators 2>/dev/null)
317
+ [ -n "$emulators_output" ] || return 1
318
+
319
+ echo "$emulators_output"
320
+ local launch_choice
321
+ read -r -p "Launch one of these emulators? Enter its id, or leave blank to skip: " launch_choice
322
+ [ -n "$launch_choice" ] || return 1
323
+
324
+ log_info "Launching emulator '$launch_choice'..."
325
+ flutter emulators --launch "$launch_choice"
326
+ log_info "Waiting for it to boot..."
327
+ sleep 5
328
+ }
329
+
330
+ # Only picks the device and records the launch command in
331
+ # XGEM_POST_INIT_LAUNCH_CMD — does NOT run `flutter run` itself. That's a
332
+ # long-running blocking process; running it here (before xgem's own
333
+ # template injection / .gitignore setup) means a Ctrl-C on it would kill
334
+ # this whole xgem process before that setup ever runs, same class of bug
335
+ # as create-vite's own --immediate flag caused for React. Device selection
336
+ # itself is just a quick menu prompt, so it's safe to do now.
337
+ _flutter_offer_run_on_device() {
338
+ local run_choice
339
+ read -r -p "Run the app now on a device/simulator? (Y/n): " run_choice
340
+ run_choice=${run_choice:-y}
341
+ [[ "$run_choice" == "y" || "$run_choice" == "Y" ]] || return 0
342
+
343
+ local device_id
344
+ device_id=$(_flutter_select_device)
345
+ if [ -n "$device_id" ]; then
346
+ XGEM_POST_INIT_LAUNCH_CMD=(flutter run -d "$device_id")
347
+ else
348
+ log_warn "No device selected — run 'flutter run' manually once one is available."
349
+ fi
350
+ }
package/lib/git.sh CHANGED
@@ -24,9 +24,13 @@ cmd_git_cmt() {
24
24
  die "Invalid choice. Operation aborted."
25
25
  fi
26
26
 
27
- log_info "Committing changes..."
28
- if ! git commit -m "$commit_msg"; then
29
- die "Commit failed. Aborting before pull/push."
27
+ if git diff --cached --quiet; then
28
+ log_warn "Nothing staged to commit checking whether there's anything already committed to sync."
29
+ else
30
+ log_info "Committing changes..."
31
+ if ! git commit -m "$commit_msg"; then
32
+ die "Commit failed. Aborting before pull/push."
33
+ fi
30
34
  fi
31
35
 
32
36
  local current_branch remote_name
@@ -38,14 +42,52 @@ cmd_git_cmt() {
38
42
  fi
39
43
 
40
44
  if [ -z "$remote_name" ]; then
41
- log_warn "Local commit dropped cleanly, but no remote is configured — sync was skipped."
45
+ log_warn "No remote configured — sync was skipped."
46
+ return 0
47
+ fi
48
+
49
+ # A pull needs something to pull FROM. If this branch has never been
50
+ # pushed before, there's no remote ref to pull, and `git pull --rebase`
51
+ # fails with "couldn't find remote ref <branch>" — which is not a
52
+ # connection problem or a conflict, just a brand-new branch. Check for
53
+ # that specific case first so it gets its own accurate message instead
54
+ # of being lumped in with real connectivity failures.
55
+ git ls-remote --exit-code --heads "$remote_name" "$current_branch" >/dev/null 2>&1
56
+ local ls_remote_status=$?
57
+
58
+ if [ "$ls_remote_status" -eq 2 ]; then
59
+ log_info "'$current_branch' doesn't exist on '$remote_name' yet — pushing to create it..."
60
+ if git push -u "$remote_name" "$current_branch"; then
61
+ log_success "Git workflow complete! Branch created and pushed."
62
+ else
63
+ die "Push operation failed."
64
+ fi
65
+ return 0
66
+ elif [ "$ls_remote_status" -ne 0 ]; then
67
+ log_error "Could not reach '$remote_name' to check for '$current_branch' — this looks like a real connection problem."
68
+ log_warn "Your commit is safe locally. Re-run 'xgem git cmt' once connectivity is restored, or push manually: git push $remote_name $current_branch"
69
+ exit 1
70
+ fi
71
+
72
+ local ahead_count
73
+ ahead_count=$(git rev-list --count "$remote_name/$current_branch..$current_branch" 2>/dev/null)
74
+ if [ "$ahead_count" = "0" ]; then
75
+ log_success "Already up to date with '$remote_name/$current_branch' — nothing to push."
42
76
  return 0
43
77
  fi
44
78
 
45
79
  log_info "Pulling updates from remote '$remote_name' on '$current_branch' via rebase..."
46
80
  if ! git pull --rebase "$remote_name" "$current_branch"; then
47
- log_error "MERGE CONFLICT DETECTED!"
48
- log_warn "Execution paused. Resolve conflicts to proceed."
81
+ local git_dir
82
+ git_dir=$(git rev-parse --git-dir 2>/dev/null)
83
+ if [ -d "$git_dir/rebase-merge" ] || [ -d "$git_dir/rebase-apply" ]; then
84
+ log_error "MERGE CONFLICT DETECTED!"
85
+ log_warn "Execution paused. Resolve conflicts to proceed."
86
+ else
87
+ log_error "Could not sync with '$remote_name' — this looks like a connection problem, not a merge conflict (see the git error above)."
88
+ log_warn "Your commit is safe locally. Re-run 'xgem git cmt' once connectivity is restored, or push manually: git push $remote_name $current_branch"
89
+ exit 1
90
+ fi
49
91
  local open_editor
50
92
  read -r -p "Do you want to open VS Code to resolve this now? (y/n): " open_editor
51
93
  [[ "$open_editor" == "y" || "$open_editor" == "Y" ]] && code .
@@ -144,13 +186,69 @@ cmd_git_rm_branch() {
144
186
  fi
145
187
  }
146
188
 
147
- # xgem git <cmt|init|rm-remote|rm-branch> ...
189
+ # cmd_git_branch: lists local branches, lets the user pick one (or create a
190
+ # new one), checks it out, and remembers it as this repo's default so
191
+ # there's a quick way back to it later. `xgem git cmt` always operates on
192
+ # whatever's actually checked out (that's the only thing that's correct for
193
+ # a commit), so this command's job is the checkout + remembering, not
194
+ # changing how cmt picks its branch.
195
+ cmd_git_branch() {
196
+ local -a branches=()
197
+ while IFS= read -r line; do
198
+ [ -n "$line" ] && branches+=("$line")
199
+ done < <(git branch --format='%(refname:short)' 2>/dev/null)
200
+
201
+ [ ${#branches[@]} -gt 0 ] || die "No local branches found."
202
+
203
+ local current_branch
204
+ current_branch=$(git branch --show-current 2>/dev/null)
205
+
206
+ echo "Available branches:"
207
+ local i=1 b
208
+ for b in "${branches[@]}"; do
209
+ if [ "$b" = "$current_branch" ]; then
210
+ echo "$i) $b (current)"
211
+ else
212
+ echo "$i) $b"
213
+ fi
214
+ i=$((i + 1))
215
+ done
216
+ local create_option=$i
217
+ echo "$create_option) Create new branch"
218
+
219
+ local choice
220
+ read -r -p "Select [1-$create_option]: " choice
221
+
222
+ if [ "$choice" = "$create_option" ]; then
223
+ local new_branch
224
+ read -r -p "Enter new branch name: " new_branch
225
+ [ -n "$new_branch" ] || die "Branch name cannot be empty."
226
+ git checkout -b "$new_branch" || die "Could not create branch '$new_branch'."
227
+ git config --local xgem.default-branch "$new_branch"
228
+ log_success "Created and switched to '$new_branch', set as default for this repo."
229
+ return 0
230
+ fi
231
+
232
+ if ! [[ "$choice" =~ ^[0-9]+$ ]] || [ "$choice" -lt 1 ] || [ "$choice" -gt ${#branches[@]} ]; then
233
+ die "Invalid selection."
234
+ fi
235
+
236
+ local selected="${branches[$((choice - 1))]}"
237
+ if [ "$selected" != "$current_branch" ]; then
238
+ git checkout "$selected" || die "Could not switch to branch '$selected'."
239
+ fi
240
+ git config --local xgem.default-branch "$selected"
241
+ log_success "Switched to '$selected' and set as default for this repo."
242
+ }
243
+
244
+ # xgem git <cmt|init|rm-remote|rm-branch|branch> ...
148
245
  cmd_git() {
149
246
  case "$1" in
150
247
  cmt) cmd_git_cmt "$2" ;;
151
248
  init) cmd_git_init ;;
152
249
  rm-remote) cmd_git_rm_remote ;;
153
250
  rm-branch) cmd_git_rm_branch ;;
154
- *) die "Unknown git subcommand '$1'. Usage: xgem git <cmt|init|rm-remote|rm-branch>" ;;
251
+ branch) cmd_git_branch ;;
252
+ *) die "Unknown git subcommand '$1'. Usage: xgem git <cmt|init|rm-remote|rm-branch|branch>" ;;
155
253
  esac
156
254
  }
package/lib/ios.sh CHANGED
@@ -60,8 +60,13 @@ ios_deployment_target_for_config() {
60
60
 
61
61
  if has_cmd xcodebuild; then
62
62
  local value
63
+ # Anchored: newer Xcode also emits DEPLOYMENT_TARGET_SETTING_NAME =
64
+ # IPHONEOS_DEPLOYMENT_TARGET (a meta-setting whose *value* is that
65
+ # string), which an unanchored grep matches before the real
66
+ # "IPHONEOS_DEPLOYMENT_TARGET = 15.6" line — anchoring to the key
67
+ # position avoids grabbing that decoy.
63
68
  value=$(xcodebuild -showBuildSettings "${xcb_args[@]}" -configuration "$config" 2>/dev/null \
64
- | grep -m1 'IPHONEOS_DEPLOYMENT_TARGET' | awk '{print $NF}')
69
+ | grep -m1 -E '^[[:space:]]*IPHONEOS_DEPLOYMENT_TARGET[[:space:]]*=' | awk '{print $NF}')
65
70
  [ -n "$value" ] && { echo "$value"; return 0; }
66
71
  fi
67
72
 
@@ -80,15 +85,36 @@ ios_pbxproj_current_max_target() {
80
85
  }
81
86
 
82
87
  # ios_required_spm_deployment_target [project_dir]
83
- # Reads .dart_tool/package_config.json (written by `flutter pub get`) to
84
- # find every resolved package's own ios/Package.swift, and returns the
85
- # highest .iOS(.vNN) platform floor declared across them. This replaces
86
- # hardcoding a guessed value (e.g. "15.0") with an actual measurement of
87
- # what the resolved dependency graph requires.
88
+ # Returns the highest .iOS(.vNN) platform floor declared across every
89
+ # resolved SwiftPM plugin, instead of hardcoding a guessed value.
90
+ #
91
+ # Primary source: ios/Flutter/ephemeral/Packages/.packages/<name>-<version>/,
92
+ # which `flutter pub get` itself populates with each SPM-enabled plugin as
93
+ # part of dependency resolution — this is the actual, already-resolved
94
+ # location Xcode/SPM uses, not a path we have to reconstruct. Scanning it
95
+ # directly sidesteps having to guess each plugin's on-disk manifest layout
96
+ # (which varies — not every plugin puts Package.swift at <root>/ios/).
97
+ #
98
+ # Falls back to parsing .dart_tool/package_config.json's rootUri entries
99
+ # (assuming the common <root>/ios/Package.swift convention) only if that
100
+ # ephemeral directory doesn't exist yet — e.g. before any pub get has run.
88
101
  ios_required_spm_deployment_target() {
89
102
  local project_dir=${1:-.}
90
- local pkg_config="$project_dir/.dart_tool/package_config.json"
103
+ local packages_dir="$project_dir/ios/Flutter/ephemeral/Packages/.packages"
104
+
105
+ if [ -d "$packages_dir" ]; then
106
+ # -L: per-plugin dirs here are frequently symlinks into pub-cache;
107
+ # without -L, find silently skips descending into them.
108
+ find -L "$packages_dir" -maxdepth 4 -name "Package.swift" -print0 2>/dev/null \
109
+ | xargs -0 grep -ohE '\.iOS\(\.v[0-9_]+\)|\.iOS\("[0-9.]+"\)' 2>/dev/null \
110
+ | grep -oE '[0-9]+(\.[0-9]+)?(_[0-9]+)?' | tr '_' '.' \
111
+ | sort -g | tail -1
112
+ return 0
113
+ fi
114
+
115
+ log_debug "No resolved SwiftPM packages directory at $packages_dir yet; falling back to package_config.json."
91
116
 
117
+ local pkg_config="$project_dir/.dart_tool/package_config.json"
92
118
  [ -f "$pkg_config" ] || { log_debug "No .dart_tool/package_config.json — run flutter pub get first."; return 1; }
93
119
  has_cmd python3 || { log_debug "python3 not found; cannot parse package_config.json."; return 1; }
94
120
 
@@ -118,9 +144,12 @@ for pkg in data.get("packages", []):
118
144
  except OSError:
119
145
  continue
120
146
 
121
- for m in re.finditer(r"\.iOS\(\.v(\d+(?:_\d+)?)\)", content):
147
+ # Both SPM syntaxes: enum .iOS(.v15) and string .iOS("15.0") (the form
148
+ # Flutter's own generator uses).
149
+ for m in re.finditer(r'\.iOS\(\.v(\d+(?:_\d+)?)\)|\.iOS\("(\d+(?:\.\d+)?)"\)', content):
150
+ raw = m.group(1) or m.group(2)
122
151
  try:
123
- fv = float(m.group(1).replace("_", "."))
152
+ fv = float(raw.replace("_", "."))
124
153
  except ValueError:
125
154
  continue
126
155
  max_target = max(max_target, fv)
@@ -164,21 +193,40 @@ ios_generated_package_swift_path() {
164
193
  echo "$guess"
165
194
  return 0
166
195
  fi
167
- find "$project_dir/ios" -maxdepth 6 -type f -name "Package.swift" \
196
+ # -L: plugin package directories under ephemeral/Packages/.packages are
197
+ # frequently symlinks into the pub-cache; a plain find silently skips
198
+ # symlinked directories instead of descending into them.
199
+ find -L "$project_dir/ios" -maxdepth 6 -type f -name "Package.swift" \
168
200
  -path "*FlutterGeneratedPluginSwiftPackage*" 2>/dev/null | head -1
169
201
  }
170
202
 
203
+ # SPM's SupportedPlatform.iOS accepts two syntaxes: the enum form .iOS(.v15)
204
+ # for the fixed known-version cases, and a string form .iOS("15.0") for
205
+ # arbitrary versions — Flutter's own code generator uses the *string* form
206
+ # for FlutterGeneratedPluginSwiftPackage, not the enum form. Matching only
207
+ # the enum form (as an earlier version of this function did) means the
208
+ # patch step silently never matches anything on a real generated package:
209
+ # sed finds no match, writes back an identical file, and "succeeds" while
210
+ # doing nothing.
211
+ _ios_extract_platform_version() {
212
+ grep -oE '\.iOS\(\.v[0-9_]+\)|\.iOS\("[0-9.]+"\)' | head -1 \
213
+ | grep -oE '[0-9]+(\.[0-9]+)?(_[0-9]+)?' | tr '_' '.'
214
+ }
215
+
171
216
  ios_generated_package_target() {
172
217
  local project_dir=${1:-.}
173
218
  local pkg
174
219
  pkg=$(ios_generated_package_swift_path "$project_dir")
175
220
  [ -n "$pkg" ] && [ -f "$pkg" ] || return 1
176
- grep -oE '\.iOS\(\.v[0-9_]+\)' "$pkg" | head -1 | grep -oE '[0-9_]+' | tr '_' '.'
221
+ _ios_extract_platform_version < "$pkg"
177
222
  }
178
223
 
179
224
  # Last-resort workaround for the still-open upstream desync bug: directly
180
225
  # patch the generated package's platform declaration. Always logged loudly
181
226
  # — this is a documented workaround for a known bug, never a silent hack.
227
+ # Writes the string form (.iOS("15.0")) since that's what Flutter's own
228
+ # generator uses in this file — matching its existing convention rather
229
+ # than introducing a different syntax.
182
230
  ios_patch_generated_package_target() {
183
231
  local new_target=$1
184
232
  local project_dir=${2:-.}
@@ -190,10 +238,9 @@ ios_patch_generated_package_target() {
190
238
  fi
191
239
 
192
240
  log_warn "Working around known Flutter SPM bug (flutter/flutter#186804): the regenerated package still doesn't match your deployment target, so patching it directly."
193
- local token=${new_target//./_}
194
241
  local tmp
195
242
  tmp=$(mktemp)
196
- sed -E "s/\.iOS\(\.v[0-9_]+\)/.iOS(.v${token})/g" "$pkg" > "$tmp"
243
+ sed -E "s/\.iOS\(\.v[0-9_]+\)/.iOS(\"${new_target}\")/g; s/\.iOS\(\"[0-9.]+\"\)/.iOS(\"${new_target}\")/g" "$pkg" > "$tmp"
197
244
  mv "$tmp" "$pkg"
198
245
  log_success "Patched generated package platform to iOS $new_target."
199
246
  }
@@ -205,14 +252,39 @@ ios_regenerate_generated_package() {
205
252
  ( cd "$project_dir" && flutter pub get )
206
253
  }
207
254
 
255
+ # ios_parse_required_from_build_log <log_file>
256
+ # Statically scanning plugin manifests to predict the required deployment
257
+ # target has proven unreliable in practice (plugin authors structure SPM
258
+ # manifests differently, package dirs can be symlinks, etc.). Xcode itself
259
+ # always computes this correctly and states it plainly in its own error:
260
+ # "requires minimum platform version X for the iOS platform". Parsing that
261
+ # directly is the authoritative fallback when the static pre-check misses
262
+ # something.
263
+ ios_parse_required_from_build_log() {
264
+ local log_file=$1
265
+ grep -oE "requires minimum platform version [0-9]+(\.[0-9]+)?" "$log_file" 2>/dev/null \
266
+ | grep -oE '[0-9]+(\.[0-9]+)?' \
267
+ | sort -g | tail -1
268
+ }
269
+
208
270
  ios_known_issue_check() {
209
- log_warn "Known upstream issue (flutter/flutter#186804, #189422, #162072): FlutterGeneratedPluginSwiftPackage's deployment target can desync from your app's IPHONEOS_DEPLOYMENT_TARGET. Currently open — xgem detects and works around it automatically below."
271
+ log_warn "Known upstream issue (flutter/flutter#186804, #189422, #162072): FlutterGeneratedPluginSwiftPackage's deployment target can desync from your app's IPHONEOS_DEPLOYMENT_TARGET — independently, even when the app's own target is already high enough. Currently open — xgem detects and works around it automatically below."
272
+ }
273
+
274
+ # _ios_ver_lt a b -> true if a < b, numeric comparison (not string equality,
275
+ # since "15" and "15.0" are the same version but different strings).
276
+ _ios_ver_lt() {
277
+ awk -v a="$1" -v b="$2" 'BEGIN{exit !(a<b)}'
210
278
  }
211
279
 
212
280
  # ios_reconcile_deployment_target [project_dir]
213
- # The core "self-healing" step: detect a required-vs-current mismatch, plan
214
- # the fix, confirm (unless --yes/--dry-run), apply, regenerate, verify, and
215
- # fall back to the documented workaround if the upstream bug is still biting.
281
+ # The core "self-healing" step. Checks TWO independent things against the
282
+ # required version the app's own pbxproj target, AND
283
+ # FlutterGeneratedPluginSwiftPackage's own declared platform because the
284
+ # upstream bug is exactly that these two can desync from each other. A
285
+ # project whose pbxproj is already at (say) 15.6 can still have a generated
286
+ # package stuck at 13.0; checking only the pbxproj target (as an earlier
287
+ # version of this function did) misses that case entirely.
216
288
  # Returns 0 if no action was needed or the fix succeeded; 1 if it couldn't
217
289
  # reconcile and the caller should stop before attempting a build.
218
290
  ios_reconcile_deployment_target() {
@@ -220,46 +292,53 @@ ios_reconcile_deployment_target() {
220
292
 
221
293
  ios_project_uses_spm "$project_dir" || { log_debug "Project does not use SwiftPM; skipping deployment-target reconciliation."; return 0; }
222
294
 
223
- local required current
295
+ local required
224
296
  required=$(ios_required_spm_deployment_target "$project_dir")
225
- current=$(ios_deployment_target_for_config "Release" "$project_dir")
226
-
227
297
  if [ -z "$required" ]; then
228
298
  log_debug "Could not determine a required deployment target from resolved SPM plugins; skipping reconciliation."
229
299
  return 0
230
300
  fi
231
- if [ -z "$current" ]; then
232
- log_warn "Could not determine the project's current deployment target; proceeding without reconciliation."
301
+
302
+ ios_known_issue_check
303
+
304
+ local current generated
305
+ current=$(ios_deployment_target_for_config "Release" "$project_dir")
306
+ generated=$(ios_generated_package_target "$project_dir")
307
+
308
+ local pbxproj_needs_bump=0 generated_needs_fix=0
309
+ [ -n "$current" ] && _ios_ver_lt "$current" "$required" && pbxproj_needs_bump=1
310
+ if [ -z "$generated" ] || _ios_ver_lt "$generated" "$required"; then
311
+ generated_needs_fix=1
312
+ fi
313
+
314
+ if [ "$pbxproj_needs_bump" = 0 ] && [ "$generated_needs_fix" = 0 ]; then
315
+ log_debug "Deployment target ($current) and generated package ($generated) already satisfy SwiftPM requirement ($required)."
233
316
  return 0
234
317
  fi
235
318
 
236
- ios_known_issue_check
319
+ log_warn "Resolved SwiftPM plugins require iOS $required."
320
+ [ "$pbxproj_needs_bump" = 1 ] && echo " - project.pbxproj currently targets iOS $current"
321
+ [ "$generated_needs_fix" = 1 ] && echo " - FlutterGeneratedPluginSwiftPackage currently declares iOS ${generated:-unknown}"
322
+ echo "Plan:"
323
+ [ "$pbxproj_needs_bump" = 1 ] && echo " 1. Set IPHONEOS_DEPLOYMENT_TARGET = $required across every build configuration in project.pbxproj"
324
+ echo " 2. Clear ios/Flutter/ephemeral and re-run 'flutter pub get' to regenerate FlutterGeneratedPluginSwiftPackage"
325
+ echo " 3. Verify the regenerated package declares iOS $required; if it still doesn't (known upstream bug), patch it directly"
326
+
327
+ if ! confirm "Apply this fix?"; then
328
+ log_warn "Skipped. The build will likely fail SwiftPM resolution until this is reconciled manually."
329
+ return 1
330
+ fi
331
+
332
+ [ "$pbxproj_needs_bump" = 1 ] && ios_pbxproj_patch_deployment_target "$required" "$project_dir"
333
+ ios_regenerate_generated_package "$project_dir"
237
334
 
238
- if awk -v a="$required" -v b="$current" 'BEGIN{exit !(a>b)}'; then
239
- log_warn "Resolved SwiftPM plugins require iOS $required, but the project targets iOS $current."
240
- echo "Plan:"
241
- echo " 1. Set IPHONEOS_DEPLOYMENT_TARGET = $required across every build configuration in project.pbxproj"
242
- echo " 2. Clear ios/Flutter/ephemeral and re-run 'flutter pub get' to regenerate FlutterGeneratedPluginSwiftPackage"
243
- echo " 3. Verify the regenerated package declares iOS $required; if it still doesn't (known upstream bug), patch it directly"
244
-
245
- if ! confirm "Apply this fix?"; then
246
- log_warn "Skipped. The build will likely fail SwiftPM resolution until the deployment target is reconciled manually."
247
- return 1
248
- fi
249
-
250
- ios_pbxproj_patch_deployment_target "$required" "$project_dir"
251
- ios_regenerate_generated_package "$project_dir"
252
-
253
- local regenerated
254
- regenerated=$(ios_generated_package_target "$project_dir")
255
- if [ "$regenerated" = "$required" ]; then
256
- log_success "Generated package now correctly declares iOS $regenerated."
257
- else
258
- log_warn "Generated package declares '${regenerated:-unknown}' after regeneration, not $required."
259
- ios_patch_generated_package_target "$required" "$project_dir"
260
- fi
335
+ local regenerated
336
+ regenerated=$(ios_generated_package_target "$project_dir")
337
+ if [ -n "$regenerated" ] && ! _ios_ver_lt "$regenerated" "$required"; then
338
+ log_success "Generated package now correctly declares iOS $regenerated."
261
339
  else
262
- log_debug "Deployment target ($current) already satisfies SwiftPM requirement ($required)."
340
+ log_warn "Generated package declares '${regenerated:-unknown}' after regeneration, still below $required."
341
+ ios_patch_generated_package_target "$required" "$project_dir"
263
342
  fi
264
343
 
265
344
  return 0