diff --git a/README.md b/README.md index de67b3e..4959acc 100644 --- a/README.md +++ b/README.md @@ -458,6 +458,78 @@ list.breakpoints?.push({ const outputXml = breakpoints.build(list); ``` +## XCUserData Support + +Access and manipulate user data directories (`xcuserdata`) which contain per-user schemes, breakpoints, and scheme management. Unlike shared data, user data is per-developer and typically not checked into version control. + +### High-level API + +```ts +import { XcodeProject, XCUserData } from "@bacons/xcode"; + +// Get user data from a project +const project = XcodeProject.open("/path/to/project.pbxproj"); + +// Get all users +const allUserData = project.getAllUserData(); +for (const userData of allUserData) { + console.log(`User: ${userData.userName}`); + console.log(`Schemes: ${userData.getSchemes().length}`); +} + +// Get data for a specific user +const myUserData = project.getUserData("johnsmith"); + +// Access user schemes +const schemes = myUserData.getSchemes(); +const debugScheme = myUserData.getScheme("MyApp-Debug"); + +// Access user breakpoints +if (myUserData.breakpoints) { + console.log(myUserData.breakpoints.breakpoints?.length); +} + +// Access scheme management (order, visibility) +if (myUserData.schemeManagement) { + console.log(myUserData.schemeManagement.SchemeUserState); +} + +// Modify and save +myUserData.breakpoints = { + uuid: "new-uuid", + type: "1", + version: "2.0", + breakpoints: [], +}; +myUserData.save(); +``` + +### Standalone Usage + +```ts +import { XCUserData } from "@bacons/xcode"; + +// Open existing user data +const userData = XCUserData.open( + "/path/to/Project.xcodeproj/xcuserdata/username.xcuserdatad" +); +console.log(userData.userName); // "username" + +// Create new user data +const newUserData = XCUserData.create("newuser"); +newUserData.schemeManagement = { + SchemeUserState: { + "App.xcscheme": { orderHint: 0 }, + }, +}; +newUserData.save("/path/to/Project.xcodeproj/xcuserdata/newuser.xcuserdatad"); + +// Discover all users in a project +const users = XCUserData.discoverUsers( + "/path/to/Project.xcodeproj/xcuserdata" +); +``` + ### Workspace Settings API Parse and build workspace settings files (`WorkspaceSettings.xcsettings`): @@ -613,7 +685,7 @@ We support the following types: `Object`, `Array`, `Data`, `String`. Notably, we - [ ] Create robust xcode projects from scratch. - [ ] Skills. - [ ] Import from other tools. -- [ ] **XCUserData**: (`xcuserdata/.xcuserdatad/`) Per-user schemes, breakpoints, UI state. +- [x] **XCUserData**: (`xcuserdata/.xcuserdatad/`) Per-user schemes, breakpoints, UI state. - [x] **IDEWorkspaceChecks**: (`xcshareddata/IDEWorkspaceChecks.plist`) Workspace check state storage (e.g., 32-bit deprecation warning). - [x] **Swift Package Manager**: Add remote and local SPM dependencies with automatic wiring. diff --git a/src/api/XCUserData.ts b/src/api/XCUserData.ts new file mode 100644 index 0000000..7836069 --- /dev/null +++ b/src/api/XCUserData.ts @@ -0,0 +1,319 @@ +/** + * High-level API for Xcode user data directories (xcuserdata). + * + * Provides unified access to per-user schemes, breakpoints, and scheme management + * stored in xcuserdata directories of .xcodeproj or .xcworkspace bundles. + * + * Unlike shared data (xcshareddata), user data is per-developer and typically + * not checked into version control. + */ +import { + readFileSync, + writeFileSync, + existsSync, + mkdirSync, + readdirSync, +} from "fs"; +import path from "path"; + +import * as breakpoints from "../breakpoints"; +import * as scheme from "../scheme"; +import type { XCBreakpointList } from "../breakpoints/types"; +import type { XCSchemeManagement } from "../scheme/types"; +import { XCScheme } from "./XCScheme"; + +/** + * High-level class for working with Xcode user data directories. + * + * User data includes: + * - Schemes (xcschemes/*.xcscheme) + * - Scheme management (xcschemes/xcschememanagement.plist) + * - Breakpoints (xcdebugger/Breakpoints_v2.xcbkptlist) + * + * User data is stored in: xcuserdata/{username}.xcuserdatad/ + */ +export class XCUserData { + /** Path to the xcuserdatad directory (may be undefined for new instances) */ + filePath?: string; + + /** The username this data belongs to */ + userName: string; + + /** Cached breakpoints data */ + private _breakpoints?: XCBreakpointList; + private _breakpointsLoaded = false; + + /** Cached scheme management data */ + private _schemeManagement?: XCSchemeManagement; + private _schemeManagementLoaded = false; + + private constructor(userName: string, filePath?: string) { + this.userName = userName; + this.filePath = filePath; + } + + /** + * Open an existing user data directory. + * + * @param userDataPath Path to the .xcuserdatad directory + */ + static open(userDataPath: string): XCUserData { + if (!existsSync(userDataPath)) { + throw new Error(`User data directory does not exist: ${userDataPath}`); + } + + // Extract username from directory name (e.g., "username.xcuserdatad" -> "username") + const dirName = path.basename(userDataPath); + if (!dirName.endsWith(".xcuserdatad")) { + throw new Error( + `Invalid user data directory name: ${dirName}. Expected *.xcuserdatad` + ); + } + const userName = dirName.replace(/\.xcuserdatad$/, ""); + + return new XCUserData(userName, userDataPath); + } + + /** + * Create a new XCUserData instance for a user. + * + * @param userName The username this data belongs to + */ + static create(userName: string): XCUserData { + return new XCUserData(userName); + } + + /** + * Discover all user data directories in an xcuserdata folder. + * + * @param xcuserdataPath Path to the xcuserdata directory (e.g., Project.xcodeproj/xcuserdata) + * @returns Array of XCUserData instances for each user + */ + static discoverUsers(xcuserdataPath: string): XCUserData[] { + if (!existsSync(xcuserdataPath)) { + return []; + } + + const entries = readdirSync(xcuserdataPath); + return entries + .filter((entry) => entry.endsWith(".xcuserdatad")) + .map((entry) => XCUserData.open(path.join(xcuserdataPath, entry))); + } + + /** + * Get the directory name for this user's data. + */ + getDirName(): string { + return `${this.userName}.xcuserdatad`; + } + + // ============================================================================ + // Schemes + // ============================================================================ + + /** + * Get the path to the xcschemes directory. + */ + getSchemesDir(): string | undefined { + if (!this.filePath) return undefined; + return path.join(this.filePath, "xcschemes"); + } + + /** + * Get all user schemes. + */ + getSchemes(): XCScheme[] { + const schemesDir = this.getSchemesDir(); + if (!schemesDir || !existsSync(schemesDir)) { + return []; + } + + const files = readdirSync(schemesDir); + return files + .filter((f) => f.endsWith(".xcscheme")) + .map((f) => XCScheme.open(path.join(schemesDir, f))); + } + + /** + * Get a scheme by name. + */ + getScheme(name: string): XCScheme | null { + const schemesDir = this.getSchemesDir(); + if (!schemesDir) return null; + + const schemePath = path.join(schemesDir, `${name}.xcscheme`); + if (!existsSync(schemePath)) return null; + + return XCScheme.open(schemePath); + } + + /** + * Save a scheme to the schemes directory. + */ + saveScheme(xcscheme: XCScheme): void { + if (!this.filePath) { + throw new Error("Cannot save scheme: no file path set for XCUserData"); + } + + const schemesDir = path.join(this.filePath, "xcschemes"); + if (!existsSync(schemesDir)) { + mkdirSync(schemesDir, { recursive: true }); + } + + const schemePath = path.join(schemesDir, `${xcscheme.name}.xcscheme`); + xcscheme.save(schemePath); + } + + /** + * Get or load scheme management data. + */ + get schemeManagement(): XCSchemeManagement | undefined { + if (this._schemeManagementLoaded) { + return this._schemeManagement; + } + + this._schemeManagementLoaded = true; + + if (!this.filePath) return undefined; + + const managementPath = path.join( + this.filePath, + "xcschemes", + "xcschememanagement.plist" + ); + + if (!existsSync(managementPath)) { + return undefined; + } + + const plistContent = readFileSync(managementPath, "utf-8"); + this._schemeManagement = scheme.parseManagement(plistContent); + return this._schemeManagement; + } + + /** + * Set scheme management data. + */ + set schemeManagement(value: XCSchemeManagement | undefined) { + this._schemeManagement = value; + this._schemeManagementLoaded = true; + } + + /** + * Save scheme management to disk. + */ + saveSchemeManagement(): void { + if (!this.filePath) { + throw new Error( + "Cannot save scheme management: no file path set for XCUserData" + ); + } + + const schemesDir = path.join(this.filePath, "xcschemes"); + if (!existsSync(schemesDir)) { + mkdirSync(schemesDir, { recursive: true }); + } + + const managementPath = path.join(schemesDir, "xcschememanagement.plist"); + + if (this._schemeManagement) { + const plistContent = scheme.buildManagement(this._schemeManagement); + writeFileSync(managementPath, plistContent, "utf-8"); + } + } + + // ============================================================================ + // Breakpoints + // ============================================================================ + + /** + * Get the path to the breakpoints file. + */ + getBreakpointsPath(): string | undefined { + if (!this.filePath) return undefined; + return path.join(this.filePath, "xcdebugger", "Breakpoints_v2.xcbkptlist"); + } + + /** + * Get or load breakpoints data. + */ + get breakpoints(): XCBreakpointList | undefined { + if (this._breakpointsLoaded) { + return this._breakpoints; + } + + this._breakpointsLoaded = true; + + const breakpointsPath = this.getBreakpointsPath(); + if (!breakpointsPath || !existsSync(breakpointsPath)) { + return undefined; + } + + const xml = readFileSync(breakpointsPath, "utf-8"); + this._breakpoints = breakpoints.parse(xml); + return this._breakpoints; + } + + /** + * Set breakpoints data. + */ + set breakpoints(value: XCBreakpointList | undefined) { + this._breakpoints = value; + this._breakpointsLoaded = true; + } + + /** + * Save breakpoints to disk. + */ + saveBreakpoints(): void { + if (!this.filePath) { + throw new Error( + "Cannot save breakpoints: no file path set for XCUserData" + ); + } + + const debuggerDir = path.join(this.filePath, "xcdebugger"); + if (!existsSync(debuggerDir)) { + mkdirSync(debuggerDir, { recursive: true }); + } + + const breakpointsPath = path.join(debuggerDir, "Breakpoints_v2.xcbkptlist"); + + if (this._breakpoints) { + const xml = breakpoints.build(this._breakpoints); + writeFileSync(breakpointsPath, xml, "utf-8"); + } + } + + // ============================================================================ + // Save All + // ============================================================================ + + /** + * Save all modified data to disk. + * + * @param dirPath Optional path to save to. If not provided, uses this.filePath. + */ + save(dirPath?: string): void { + const targetPath = dirPath ?? this.filePath; + if (!targetPath) { + throw new Error( + "No file path specified. Either provide a path or set this.filePath." + ); + } + + this.filePath = targetPath; + + if (!existsSync(targetPath)) { + mkdirSync(targetPath, { recursive: true }); + } + + if (this._breakpointsLoaded && this._breakpoints) { + this.saveBreakpoints(); + } + + if (this._schemeManagementLoaded && this._schemeManagement) { + this.saveSchemeManagement(); + } + } +} diff --git a/src/api/XcodeProject.ts b/src/api/XcodeProject.ts index 745f019..3e2708b 100644 --- a/src/api/XcodeProject.ts +++ b/src/api/XcodeProject.ts @@ -5,6 +5,7 @@ import crypto from "crypto"; import { XCScheme, createBuildableReference } from "./XCScheme"; import { XCSharedData } from "./XCSharedData"; +import { XCUserData } from "./XCUserData"; import { parse } from "../json"; import * as json from "../json/types"; @@ -550,6 +551,47 @@ export class XcodeProject extends Map { return sharedData; } + // ============================================================================ + // User Data Methods + // ============================================================================ + + /** + * Get the path to the xcuserdata directory. + */ + getUserDataDir(): string { + const projectDir = path.dirname(this.filePath); + return path.join(projectDir, "xcuserdata"); + } + + /** + * Get all user data directories in this project. + * + * @returns Array of XCUserData instances for each user + */ + getAllUserData(): XCUserData[] { + return XCUserData.discoverUsers(this.getUserDataDir()); + } + + /** + * Get user data for a specific user. + * + * @param userName The username to get data for + * @returns XCUserData for the specified user (creates new if doesn't exist) + */ + getUserData(userName: string): XCUserData { + const userDataDir = this.getUserDataDir(); + const userPath = path.join(userDataDir, `${userName}.xcuserdatad`); + + if (existsSync(userPath)) { + return XCUserData.open(userPath); + } + + // Create a new instance with the path set + const userData = XCUserData.create(userName); + userData.filePath = userPath; + return userData; + } + toJSON(): json.XcodeProject { const json: json.XcodeProject = { archiveVersion: this.archiveVersion, diff --git a/src/api/__tests__/XCUserData.test.ts b/src/api/__tests__/XCUserData.test.ts new file mode 100644 index 0000000..69099c0 --- /dev/null +++ b/src/api/__tests__/XCUserData.test.ts @@ -0,0 +1,409 @@ +import { existsSync, mkdirSync, writeFileSync, rmSync, readFileSync } from "fs"; +import path from "path"; +import tempy from "tempy"; + +import { XCUserData } from "../XCUserData"; +import { XCScheme } from "../XCScheme"; + +describe("XCUserData", () => { + let tempDir: string; + let userDataDir: string; + + beforeEach(() => { + tempDir = tempy.directory(); + userDataDir = path.join(tempDir, "testuser.xcuserdatad"); + mkdirSync(userDataDir, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(tempDir)) { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + describe("open", () => { + it("opens an existing xcuserdatad directory", () => { + const userData = XCUserData.open(userDataDir); + + expect(userData).toBeDefined(); + expect(userData.filePath).toBe(userDataDir); + expect(userData.userName).toBe("testuser"); + }); + + it("extracts username from directory name", () => { + const otherDir = path.join(tempDir, "john.doe.xcuserdatad"); + mkdirSync(otherDir, { recursive: true }); + + const userData = XCUserData.open(otherDir); + + expect(userData.userName).toBe("john.doe"); + }); + + it("throws when directory does not exist", () => { + expect(() => { + XCUserData.open("/nonexistent/path"); + }).toThrow("User data directory does not exist"); + }); + + it("throws when directory name is invalid", () => { + const invalidDir = path.join(tempDir, "invalid-dir"); + mkdirSync(invalidDir, { recursive: true }); + + expect(() => { + XCUserData.open(invalidDir); + }).toThrow("Invalid user data directory name"); + }); + }); + + describe("create", () => { + it("creates a new XCUserData instance", () => { + const userData = XCUserData.create("newuser"); + + expect(userData).toBeDefined(); + expect(userData.filePath).toBeUndefined(); + expect(userData.userName).toBe("newuser"); + }); + + it("returns correct directory name", () => { + const userData = XCUserData.create("myuser"); + + expect(userData.getDirName()).toBe("myuser.xcuserdatad"); + }); + }); + + describe("discoverUsers", () => { + it("returns empty array when xcuserdata does not exist", () => { + const users = XCUserData.discoverUsers("/nonexistent/xcuserdata"); + + expect(users).toEqual([]); + }); + + it("discovers all user data directories", () => { + const xcuserdata = path.join(tempDir, "xcuserdata"); + mkdirSync(xcuserdata, { recursive: true }); + + mkdirSync(path.join(xcuserdata, "user1.xcuserdatad")); + mkdirSync(path.join(xcuserdata, "user2.xcuserdatad")); + mkdirSync(path.join(xcuserdata, "user3.xcuserdatad")); + mkdirSync(path.join(xcuserdata, "not-userdata")); // Should be ignored + + const users = XCUserData.discoverUsers(xcuserdata); + + expect(users).toHaveLength(3); + expect(users.map((u) => u.userName).sort()).toEqual([ + "user1", + "user2", + "user3", + ]); + }); + }); + + describe("schemes", () => { + it("returns empty array when no schemes exist", () => { + const userData = XCUserData.open(userDataDir); + + expect(userData.getSchemes()).toEqual([]); + }); + + it("returns schemes when they exist", () => { + const schemesDir = path.join(userDataDir, "xcschemes"); + mkdirSync(schemesDir, { recursive: true }); + + const schemeXml = ` + + + + +`; + writeFileSync(path.join(schemesDir, "UserScheme.xcscheme"), schemeXml); + + const userData = XCUserData.open(userDataDir); + const schemes = userData.getSchemes(); + + expect(schemes).toHaveLength(1); + expect(schemes[0].name).toBe("UserScheme"); + }); + + it("gets a scheme by name", () => { + const schemesDir = path.join(userDataDir, "xcschemes"); + mkdirSync(schemesDir, { recursive: true }); + + const schemeXml = ` + + + + +`; + writeFileSync(path.join(schemesDir, "MyUserScheme.xcscheme"), schemeXml); + + const userData = XCUserData.open(userDataDir); + const scheme = userData.getScheme("MyUserScheme"); + + expect(scheme).not.toBeNull(); + expect(scheme!.name).toBe("MyUserScheme"); + }); + + it("returns null for non-existent scheme", () => { + const userData = XCUserData.open(userDataDir); + + expect(userData.getScheme("NonExistent")).toBeNull(); + }); + + it("saves a scheme", () => { + const userData = XCUserData.open(userDataDir); + const scheme = XCScheme.create("NewUserScheme"); + + userData.saveScheme(scheme); + + const schemePath = path.join( + userDataDir, + "xcschemes", + "NewUserScheme.xcscheme" + ); + expect(existsSync(schemePath)).toBe(true); + }); + }); + + describe("schemeManagement", () => { + it("returns undefined when no management file exists", () => { + const userData = XCUserData.open(userDataDir); + + expect(userData.schemeManagement).toBeUndefined(); + }); + + it("loads scheme management when file exists", () => { + const schemesDir = path.join(userDataDir, "xcschemes"); + mkdirSync(schemesDir, { recursive: true }); + + const managementPlist = ` + + + + SchemeUserState + + App.xcscheme + + orderHint + 0 + + + +`; + writeFileSync( + path.join(schemesDir, "xcschememanagement.plist"), + managementPlist + ); + + const userData = XCUserData.open(userDataDir); + + expect(userData.schemeManagement).toBeDefined(); + expect(userData.schemeManagement!.SchemeUserState).toBeDefined(); + }); + + it("saves scheme management", () => { + const userData = XCUserData.open(userDataDir); + userData.schemeManagement = { + SchemeUserState: { + "Test.xcscheme": { + orderHint: 1, + }, + }, + }; + + userData.saveSchemeManagement(); + + const managementPath = path.join( + userDataDir, + "xcschemes", + "xcschememanagement.plist" + ); + expect(existsSync(managementPath)).toBe(true); + + const content = readFileSync(managementPath, "utf-8"); + expect(content).toContain("SchemeUserState"); + expect(content).toContain("Test.xcscheme"); + }); + }); + + describe("breakpoints", () => { + it("returns undefined when no breakpoints file exists", () => { + const userData = XCUserData.open(userDataDir); + + expect(userData.breakpoints).toBeUndefined(); + }); + + it("loads breakpoints when file exists", () => { + const debuggerDir = path.join(userDataDir, "xcdebugger"); + mkdirSync(debuggerDir, { recursive: true }); + + const breakpointsXml = ` + + + + +`; + writeFileSync( + path.join(debuggerDir, "Breakpoints_v2.xcbkptlist"), + breakpointsXml + ); + + const userData = XCUserData.open(userDataDir); + + expect(userData.breakpoints).toBeDefined(); + expect(userData.breakpoints!.uuid).toBe("USER-BP-UUID"); + }); + + it("saves breakpoints", () => { + const userData = XCUserData.open(userDataDir); + userData.breakpoints = { + uuid: "NEW-USER-BP-UUID", + type: "1", + version: "2.0", + breakpoints: [], + }; + + userData.saveBreakpoints(); + + const breakpointsPath = path.join( + userDataDir, + "xcdebugger", + "Breakpoints_v2.xcbkptlist" + ); + expect(existsSync(breakpointsPath)).toBe(true); + + const content = readFileSync(breakpointsPath, "utf-8"); + expect(content).toContain('uuid = "NEW-USER-BP-UUID"'); + }); + }); + + describe("save", () => { + it("saves all modified data", () => { + const userData = XCUserData.create("savetest"); + userData.filePath = userDataDir; + + userData.breakpoints = { + uuid: "SAVE-TEST-UUID", + type: "1", + version: "2.0", + breakpoints: [], + }; + + userData.schemeManagement = { + SchemeUserState: { + "SaveTest.xcscheme": { + orderHint: 0, + }, + }, + }; + + userData.save(); + + expect( + existsSync( + path.join(userDataDir, "xcdebugger", "Breakpoints_v2.xcbkptlist") + ) + ).toBe(true); + expect( + existsSync( + path.join(userDataDir, "xcschemes", "xcschememanagement.plist") + ) + ).toBe(true); + }); + + it("saves to a new path when provided", () => { + const newDir = path.join(tempDir, "newuser.xcuserdatad"); + + const userData = XCUserData.create("newuser"); + userData.breakpoints = { + uuid: "NEW-PATH-TEST-UUID", + type: "1", + version: "2.0", + breakpoints: [], + }; + + userData.save(newDir); + + expect(userData.filePath).toBe(newDir); + expect( + existsSync(path.join(newDir, "xcdebugger", "Breakpoints_v2.xcbkptlist")) + ).toBe(true); + }); + + it("throws when no path is set", () => { + const userData = XCUserData.create("nopath"); + userData.breakpoints = { + uuid: "TEST", + type: "1", + version: "2.0", + breakpoints: [], + }; + + expect(() => { + userData.save(); + }).toThrow("No file path specified"); + }); + }); + + describe("lazy loading", () => { + it("only loads breakpoints once", () => { + const debuggerDir = path.join(userDataDir, "xcdebugger"); + mkdirSync(debuggerDir, { recursive: true }); + + const breakpointsXml = ` + + + + +`; + writeFileSync( + path.join(debuggerDir, "Breakpoints_v2.xcbkptlist"), + breakpointsXml + ); + + const userData = XCUserData.open(userDataDir); + + // Access twice + const first = userData.breakpoints; + const second = userData.breakpoints; + + // Should be the same object + expect(first).toBe(second); + }); + + it("only loads scheme management once", () => { + const schemesDir = path.join(userDataDir, "xcschemes"); + mkdirSync(schemesDir, { recursive: true }); + + const managementPlist = ` + + + + SchemeUserState + + + +`; + writeFileSync( + path.join(schemesDir, "xcschememanagement.plist"), + managementPlist + ); + + const userData = XCUserData.open(userDataDir); + + // Access twice + const first = userData.schemeManagement; + const second = userData.schemeManagement; + + // Should be the same object + expect(first).toBe(second); + }); + }); +}); diff --git a/src/api/index.ts b/src/api/index.ts index 3311900..8a91d9c 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -35,5 +35,6 @@ export { XCSwiftPackageProductDependency } from "./XCSwiftPackageProductDependen export { XCVersionGroup } from "./XCVersionGroup"; export { XCScheme, createBuildableReference } from "./XCScheme"; export { XCSharedData } from "./XCSharedData"; +export { XCUserData } from "./XCUserData"; export { XCWorkspace } from "./XCWorkspace"; export { IDEWorkspaceChecks } from "./IDEWorkspaceChecks";