Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -663,8 +663,20 @@ public struct CapacitorView: UIViewControllerRepresentable {
public func makeUIViewController(context: Context) -> CAPBridgeViewController { CAPBridgeViewController() }
public func updateUIViewController(_ vc: CAPBridgeViewController, context: Context) {}
}

// SceneDelegate.swift — deliberately empty
class SceneDelegate: UIResponder, UIWindowSceneDelegate {}
```

`SceneDelegate` carries no methods. It exists because `UISceneConfiguration.delegateClass`
takes a type and UIKit instantiates it per scene, so there is no way to hand UIKit
`SceneDelegateProxy.shared` — the app target has to own a concrete class for `AppDelegate`
to name from `application(_:configurationForConnecting:options:)`. It is the install point
for per-scene UIKit APIs SwiftUI does not surface, the first of which is
`preferredWindowingControlStyle(for:)` for the iPadOS 26 window controls. It stays empty of
routing on purpose: a scene delegate that implements `scene(_:openURLContexts:)` takes URL
delivery away from `.onOpenURL` below.

`CapacitorView` lives in the app target, not the runtime. An app that needs its own
`CAPBridgeViewController` subclass (custom `webViewConfiguration(for:)`, `router()`,
`capacitorDidLoad()`) returns that subclass from `makeUIViewController`. This replaces
Expand Down Expand Up @@ -1370,7 +1382,7 @@ rather than fail when a project has been customised beyond recognition.
| Task | Command | What it rewrites |
| --- | --- | --- |
| `migrate.ts` | `cap migrate` | The umbrella migrator. Bumps `@capacitor/*` and known official plugins in `package.json` and installs them; updates the Gradle wrapper, root and app `build.gradle`, `gradle.properties` and `variables.gradle`; removes `jcenter()`; patches `AndroidManifest.xml` and `AppDelegate.swift`; checks the JDK major; prints the breaking-change list. Delegates to the two below. |
| `migrate-swiftui.ts` | (from `cap migrate`) | Converts a storyboard/`SceneDelegate` iOS app to the SwiftUI App-struct layout: rewrites `AppDelegate.swift` into a `@UIApplicationDelegateAdaptor`, writes `App.swift` and `CapacitorView.swift`, repoints `Info.plist` at the SwiftUI scene setup, and registers the new files with the Xcode App target via `addSwiftFileToAppTarget` (`cli/src/util/xcode.ts`). Detects partial state and skips rather than half-applying. |
| `migrate-swiftui.ts` | (from `cap migrate`) | Converts a storyboard iOS app to the SwiftUI App-struct layout: rewrites `AppDelegate.swift` into a `@UIApplicationDelegateAdaptor` (keeping or adding `configurationForConnecting`), writes `App.swift`, `CapacitorView.swift` and `SceneDelegate.swift`, repoints `Info.plist` at the SwiftUI scene setup, and registers the new files with the Xcode App target via `addSwiftFileToAppTarget` (`cli/src/util/xcode.ts`). A stock 8.x `SceneDelegate.swift` is replaced with the 9.0 one; a customised one is kept with a warning. Detects partial state and skips rather than half-applying. |
| `migrate-edge-to-edge.ts` | (from `cap migrate`) | Adds `EdgeToEdge.enable(this)` / `enableEdgeToEdge()` plus the needed imports to the app's `MainActivity` (Java or Kotlin). Skips when `plugins.SystemBars.insetsHandling` is `'disable'` or the activity already opts in; warns with manual instructions if it cannot patch the file. |
| `migrate-spm.ts` | `cap spm-migration-assistant` | Moves an iOS app from CocoaPods to SwiftPM: extracts the `CapApp-SPM` package directory from the bundled template and deletes `Podfile`, `Podfile.lock` and `App.xcworkspace`. |

Expand Down
374 changes: 316 additions & 58 deletions cli/src/tasks/migrate-swiftui.ts

Large diffs are not rendered by default.

143 changes: 141 additions & 2 deletions cli/src/util/xcode.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { writeFileSync } from 'fs-extra';
import { basename } from 'path';
import { project as loadXcodeProject } from 'xcode';
import type { XcodeProject } from 'xcode';
import type { PbxprojSection, XcodeProject } from 'xcode';

const COMMENT_SUFFIX = '_comment';

/**
* Register a Swift source file with the first native target of an Xcode project.
Expand Down Expand Up @@ -39,10 +42,146 @@ export function addSwiftFileToAppTarget(
return { added: true };
}

/**
* Unregister a Swift source file from an Xcode project.
*
* Clears the file's PBXBuildFile, its entry in every Sources build phase, its listing in
* any PBXGroup, and its PBXFileReference. Matching is by resolved file reference UUID, so
* nothing else that happens to share the name is touched. Idempotent: a second call is a
* no-op and leaves the file untouched.
*
* @param pbxprojPath Absolute path to project.pbxproj
* @param fileName Basename of the Swift file to unregister (e.g. 'SceneDelegate.swift')
*/
export function removeSwiftFileFromAppTarget(pbxprojPath: string, fileName: string): { removed: boolean } {
const project = loadXcodeProject(pbxprojPath);
project.parseSync();

const objects = project.hash.project.objects;
const fileRefUuids = findSwiftFileUuidsByFileName(objects.PBXFileReference, fileName);
if (fileRefUuids.length === 0) {
return { removed: false };
}

const buildFileUuids = removeFromBuildFileSection(objects.PBXBuildFile, fileRefUuids);
removeFromBuildPhaseFiles(objects.PBXSourcesBuildPhase, buildFileUuids);
removeFromGroupChildren(objects.PBXGroup, fileRefUuids);
for (const uuid of fileRefUuids) {
deleteWithComment(objects.PBXFileReference, uuid);
}

writeFileSync(pbxprojPath, project.writeSync(), 'utf-8');
return { removed: true };
}

/**
* Unregister a storyboard from an Xcode project.
*
* A localized storyboard is a PBXVariantGroup whose children are the per-language
* PBXFileReferences, and it is the variant group — not the file reference — that the
* PBXBuildFile and the owning PBXGroup point at. Both levels are removed here, along with
* the Resources build phase entry. Falls back to a bare PBXFileReference for a project
* whose storyboard was moved out of Base.lproj. Idempotent: a second call is a no-op.
*
* @param pbxprojPath Absolute path to project.pbxproj
* @param storyboardName Basename of the storyboard to unregister (e.g. 'Main.storyboard')
*/
export function removeStoryboardFromAppTarget(pbxprojPath: string, storyboardName: string): { removed: boolean } {
const project = loadXcodeProject(pbxprojPath);
project.parseSync();

const objects = project.hash.project.objects;
const variantGroupUuids = findVariantGroupUuidsByName(objects.PBXVariantGroup, storyboardName);
const fileRefUuids = findStoryboardFileRefUuids(objects.PBXFileReference, storyboardName);
const uuids = [...variantGroupUuids, ...fileRefUuids];
if (uuids.length === 0) {
return { removed: false };
}

const buildFileUuids = removeFromBuildFileSection(objects.PBXBuildFile, uuids);
removeFromBuildPhaseFiles(objects.PBXResourcesBuildPhase, buildFileUuids);
removeFromGroupChildren(objects.PBXGroup, uuids);
for (const uuid of variantGroupUuids) {
deleteWithComment(objects.PBXVariantGroup!, uuid);
}
for (const uuid of fileRefUuids) {
deleteWithComment(objects.PBXFileReference, uuid);
}

writeFileSync(pbxprojPath, project.writeSync(), 'utf-8');
return { removed: true };
}

function findSwiftFileUuidsByFileName(section: PbxprojSection, fileName: string): string[] {
return sectionEntries(section)
.filter(
([, fileReference]) =>
unquote(fileReference['lastKnownFileType']) === 'sourcecode.swift' &&
basename(unquote(fileReference['path'])) === fileName,
)
.map(([uuid]) => uuid);
}

function findVariantGroupUuidsByName(section: PbxprojSection | undefined, storyboardName: string): string[] {
if (!section) return [];
return sectionEntries(section)
.filter(([, group]) => unquote(group['name']) === storyboardName)
.map(([uuid]) => uuid);
}

function findStoryboardFileRefUuids(section: PbxprojSection, storyboardName: string): string[] {
return sectionEntries(section)
.filter(([, fileReference]) => basename(unquote(fileReference['path'])) === storyboardName)
.map(([uuid]) => uuid);
}

/** Deletes every PBXBuildFile pointing at one of `fileRefUuids` and returns their UUIDs. */
function removeFromBuildFileSection(section: PbxprojSection, fileRefUuids: string[]): string[] {
const removed: string[] = [];
for (const [uuid, buildFile] of sectionEntries(section)) {
if (!fileRefUuids.includes(buildFile['fileRef'] as string)) continue;
removed.push(uuid);
deleteWithComment(section, uuid);
}
return removed;
}

function removeFromBuildPhaseFiles(section: PbxprojSection | undefined, buildFileUuids: string[]): void {
if (!section) return;
for (const [, buildPhase] of sectionEntries(section)) {
const files = buildPhase['files'] as { value: string }[] | undefined;
if (!Array.isArray(files)) continue;
buildPhase['files'] = files.filter((file) => !buildFileUuids.includes(file.value));
}
}

function removeFromGroupChildren(section: PbxprojSection, uuids: string[]): void {
for (const [, group] of sectionEntries(section)) {
const children = group['children'] as { value: string }[] | undefined;
if (!Array.isArray(children)) continue;
group['children'] = children.filter((child) => !uuids.includes(child.value));
}
}

function sectionEntries(section: PbxprojSection): [string, Record<string, unknown>][] {
return Object.entries(section).filter(
(entry): entry is [string, Record<string, unknown>] =>
!entry[0].endsWith(COMMENT_SUFFIX) && typeof entry[1] !== 'string',
);
}

function deleteWithComment(section: PbxprojSection, uuid: string): void {
delete section[uuid];
delete section[`${uuid}${COMMENT_SUFFIX}`];
}

function unquote(value: unknown): string {
return typeof value === 'string' ? value.replace(/^"(.*)"$/, '$1') : '';
}

// Exported for tests.
export function findGroupUuidByComment(project: XcodeProject, comment: string): string | null {
const groups = project.hash.project.objects.PBXGroup;
const COMMENT_SUFFIX = '_comment';
for (const key of Object.keys(groups)) {
if (!key.endsWith(COMMENT_SUFFIX)) continue;
if (groups[key] === comment) {
Expand Down
Loading
Loading