#!/usr/bin/env python3 """Generate Bugger.xcodeproj from source tree.""" import hashlib import os from pathlib import Path ROOT = Path(__file__).resolve().parent.parent PROJECT_NAME = "Bugger" BUNDLE_ID = "com.xorbitlab.bugger" SOURCE_ROOT = ROOT / "Sources" RESOURCE_ROOT = ROOT / "Resources" def uuid(seed: str) -> str: digest = hashlib.md5(seed.encode()).hexdigest().upper() return digest[:24] def collect_swift_files() -> list[Path]: return sorted(SOURCE_ROOT.rglob("*.swift")) def collect_resources() -> list[Path]: resources = [] for path in sorted(RESOURCE_ROOT.rglob("*")): if path.is_file(): resources.append(path) return resources def pbxproj() -> str: swift_files = collect_swift_files() resources = collect_resources() project_id = uuid("project") target_id = uuid("target") sources_phase_id = uuid("sources-phase") resources_phase_id = uuid("resources-phase") frameworks_phase_id = uuid("frameworks-phase") product_ref_id = uuid("product-ref") project_config_list_id = uuid("project-config-list") target_config_list_id = uuid("target-config-list") debug_config_id = uuid("debug-config") release_config_id = uuid("release-config") target_debug_config_id = uuid("target-debug-config") target_release_config_id = uuid("target-release-config") main_group_id = uuid("main-group") products_group_id = uuid("products-group") sources_group_id = uuid("sources-group") resources_group_id = uuid("resources-group") file_refs: dict[Path, str] = {} build_files: list[tuple[str, str, str]] = [] for path in swift_files: ref_id = uuid(f"file-{path}") build_id = uuid(f"build-{path}") file_refs[path] = ref_id build_files.append((build_id, ref_id, "Sources")) resource_refs: dict[Path, str] = {} for path in resources: ref_id = uuid(f"res-{path}") build_id = uuid(f"res-build-{path}") resource_refs[path] = ref_id build_files.append((build_id, ref_id, "Resources")) def group_for(path: Path, parent: str) -> str: rel = path.relative_to(ROOT) parts = rel.parts[:-1] group_ids = [main_group_id] current = "" for part in parts: current = f"{current}/{part}" group_ids.append(uuid(f"group-{current}")) return group_ids[-1] groups: dict[str, list[str]] = {} child_groups: dict[str, set[str]] = {} def add_to_group(group_id: str, child_id: str): groups.setdefault(group_id, []).append(child_id) add_to_group(main_group_id, sources_group_id) add_to_group(main_group_id, resources_group_id) add_to_group(main_group_id, products_group_id) add_to_group(products_group_id, product_ref_id) def ensure_group_chain(rel_parts: tuple[str, ...], root_group: str): current = root_group built = [] for part in rel_parts: built.append(part) gid = uuid(f"group-{'/'.join(built)}") if gid not in groups and gid != current: add_to_group(current, gid) current = gid return current for path in swift_files: rel = path.relative_to(SOURCE_ROOT) parent = ensure_group_chain(rel.parts[:-1], sources_group_id) add_to_group(parent, file_refs[path]) for path in resources: rel = path.relative_to(RESOURCE_ROOT) if len(rel.parts) > 1: parent = ensure_group_chain(rel.parts[:-1], resources_group_id) else: parent = resources_group_id add_to_group(parent, resource_refs[path]) lines: list[str] = [] lines.append("// !$*UTF8*$!") lines.append("{") lines.append("\tarchiveVersion = 1;") lines.append("\tclasses = {};") lines.append("\tobjectVersion = 56;") lines.append("\tobjects = {") lines.append(f"\n/* Begin PBXBuildFile section */") for build_id, ref_id, kind in build_files: if kind == "Sources": lines.append(f"\t\t{build_id} /* {Path(ref_id).name} in Sources */ = {{isa = PBXBuildFile; fileRef = {ref_id}; }};") else: lines.append(f"\t\t{build_id} /* {ref_id} in Resources */ = {{isa = PBXBuildFile; fileRef = {ref_id}; }};") lines.append("/* End PBXBuildFile section */\n") lines.append("/* Begin PBXFileReference section */") lines.append(f"\t\t{product_ref_id} /* {PROJECT_NAME}.app */ = {{isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = {PROJECT_NAME}.app; sourceTree = BUILT_PRODUCTS_DIR; }};") for path, ref_id in file_refs.items(): rel = path.relative_to(ROOT) lines.append(f"\t\t{ref_id} /* {path.name} */ = {{isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = {path.name}; sourceTree = \"\"; }};") for path, ref_id in resource_refs.items(): rel = path.relative_to(ROOT) suffix = path.suffix.lower() if suffix == ".plist": file_type = "text.plist.xml" elif suffix == ".entitlements": file_type = "text.plist.entitlements" elif path.name == "Contents.json": file_type = "text.json" else: file_type = "folder.assetcatalog" if path.parent.suffix == ".xcassets" and path.name == "Contents.json" and path.parent.parent.name == "Assets.xcassets" else "text.json" if path.name == "Contents.json" and "Assets.xcassets" in str(path): if path.parent == RESOURCE_ROOT / "Assets.xcassets": continue if path.parent.name.endswith(".appiconset"): file_type = "text.json" elif path.parent == RESOURCE_ROOT / "Assets.xcassets": file_type = "folder.assetcatalog" # asset catalog handled as single folder reference below if "Assets.xcassets" in str(path) and path != RESOURCE_ROOT / "Assets.xcassets": continue if path == RESOURCE_ROOT / "Assets.xcassets": lines.append(f"\t\t{ref_id} /* Assets.xcassets */ = {{isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = \"\"; }};") continue lines.append(f"\t\t{ref_id} /* {path.name} */ = {{isa = PBXFileReference; lastKnownFileType = {file_type}; path = {path.name}; sourceTree = \"\"; }};") lines.append("/* End PBXFileReference section */\n") # Simpler approach: rewrite resource refs cleanly resource_file_refs = [] asset_ref = uuid("asset-catalog") info_ref = uuid("info-plist") ent_ref = uuid("entitlements") resource_file_refs = [ (RESOURCE_ROOT / "Assets.xcassets", asset_ref, "folder.assetcatalog", "Assets.xcassets"), (RESOURCE_ROOT / "Info.plist", info_ref, "text.plist.xml", "Info.plist"), (RESOURCE_ROOT / "Bugger.entitlements", ent_ref, "text.plist.entitlements", "Bugger.entitlements"), ] lines = [] lines.append("// !$*UTF8*$!") lines.append("{") lines.append("\tarchiveVersion = 1;") lines.append("\tclasses = {};") lines.append("\tobjectVersion = 56;") lines.append("\tobjects = {") lines.append("\n/* Begin PBXBuildFile section */") swift_build_entries = [] for path in swift_files: ref_id = uuid(f"file-{path}") build_id = uuid(f"build-{path}") swift_build_entries.append((build_id, ref_id, path.name)) lines.append(f"\t\t{build_id} /* {path.name} in Sources */ = {{isa = PBXBuildFile; fileRef = {ref_id}; }};") asset_build_id = uuid("asset-build") lines.append(f"\t\t{asset_build_id} /* Assets.xcassets in Resources */ = {{isa = PBXBuildFile; fileRef = {asset_ref}; }};") lines.append("/* End PBXBuildFile section */\n") lines.append("/* Begin PBXFileReference section */") lines.append(f"\t\t{product_ref_id} /* {PROJECT_NAME}.app */ = {{isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = {PROJECT_NAME}.app; sourceTree = BUILT_PRODUCTS_DIR; }};") swift_ref_entries = [] for path in swift_files: ref_id = uuid(f"file-{path}") swift_ref_entries.append((ref_id, path)) lines.append(f"\t\t{ref_id} /* {path.name} */ = {{isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = {path.name}; sourceTree = \"\"; }};") lines.append(f"\t\t{asset_ref} /* Assets.xcassets */ = {{isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = \"\"; }};") lines.append(f"\t\t{info_ref} /* Info.plist */ = {{isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = \"\"; }};") lines.append(f"\t\t{ent_ref} /* Bugger.entitlements */ = {{isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Bugger.entitlements; sourceTree = \"\"; }};") lines.append("/* End PBXFileReference section */\n") def render_groups(): group_defs = [] def walk(dir_path: Path, group_name: str, group_id: str, parent_set: list[str]): children = [] for child in sorted(dir_path.iterdir()): if child.is_dir() and child.name != ".DS_Store": child_id = uuid(f"group-{child}") children.append(child_id) walk(child, child.name, child_id, []) elif child.suffix == ".swift": ref_id = uuid(f"file-{child}") children.append(ref_id) if dir_path == RESOURCE_ROOT: children = [asset_ref, info_ref, ent_ref] if dir_path == ROOT: children = [sources_group_id, resources_group_id, products_group_id] if dir_path == SOURCE_ROOT: children = [] for child in sorted(dir_path.iterdir()): if child.is_dir(): children.append(uuid(f"group-{child}")) elif child.suffix == ".swift": children.append(uuid(f"file-{child}")) child_lines = "\n".join([f"\t\t\t\t{id_}," for id_ in children]) path_line = f"path = {group_name};" if group_name not in ("", PROJECT_NAME) else "" if group_id == main_group_id: group_defs.append( f"\t\t{group_id} = {{\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n{child_lines}\n\t\t\t);\n\t\t\tsourceTree = \"\";\n\t\t}};" ) elif group_id == products_group_id: group_defs.append( f"\t\t{group_id} = {{\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t{product_ref_id},\n\t\t\t);\n\t\t\tname = Products;\n\t\t\tsourceTree = \"\";\n\t\t}};" ) elif group_id == sources_group_id: group_defs.append( f"\t\t{group_id} = {{\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n{child_lines}\n\t\t\t);\n\t\t\tpath = Sources;\n\t\t\tsourceTree = \"\";\n\t\t}};" ) elif group_id == resources_group_id: group_defs.append( f"\t\t{group_id} = {{\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t{asset_ref},\n\t\t\t\t{info_ref},\n\t\t\t\t{ent_ref},\n\t\t\t);\n\t\t\tpath = Resources;\n\t\t\tsourceTree = \"\";\n\t\t}};" ) else: rel = dir_path.relative_to(SOURCE_ROOT) if str(dir_path).startswith(str(SOURCE_ROOT)) else dir_path.name group_defs.append( f"\t\t{group_id} = {{\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n{child_lines}\n\t\t\t);\n\t\t\tpath = {dir_path.name};\n\t\t\tsourceTree = \"\";\n\t\t}};" ) walk(ROOT, PROJECT_NAME, main_group_id, []) for child in sorted(SOURCE_ROOT.iterdir()): if child.is_dir(): walk(child, child.name, uuid(f"group-{child}"), []) return "\n".join(group_defs) lines.append("/* Begin PBXFrameworksBuildPhase section */") lines.append(f"\t\t{frameworks_phase_id} /* Frameworks */ = {{") lines.append("\t\t\tisa = PBXFrameworksBuildPhase;") lines.append("\t\t\tbuildActionMask = 2147483647;") lines.append("\t\t\tfiles = (") lines.append("\t\t\t);") lines.append("\t\t\trunOnlyForDeploymentPostprocessing = 0;") lines.append("\t\t};") lines.append("/* End PBXFrameworksBuildPhase section */\n") lines.append("/* Begin PBXGroup section */") lines.append(render_groups()) lines.append("/* End PBXGroup section */\n") lines.append("/* Begin PBXNativeTarget section */") lines.append(f"\t\t{target_id} /* {PROJECT_NAME} */ = {{") lines.append("\t\t\tisa = PBXNativeTarget;") lines.append(f"\t\t\tbuildConfigurationList = {target_config_list_id} /* Build configuration list for PBXNativeTarget \"{PROJECT_NAME}\" */;") lines.append("\t\t\tbuildPhases = (") lines.append(f"\t\t\t\t{sources_phase_id} /* Sources */,") lines.append(f"\t\t\t\t{frameworks_phase_id} /* Frameworks */,") lines.append(f"\t\t\t\t{resources_phase_id} /* Resources */,") lines.append("\t\t\t);") lines.append("\t\t\tbuildRules = (") lines.append("\t\t\t);") lines.append("\t\t\tdependencies = (") lines.append("\t\t\t);") lines.append(f"\t\t\tname = {PROJECT_NAME};") lines.append(f"\t\t\tproductName = {PROJECT_NAME};") lines.append(f"\t\t\tproductReference = {product_ref_id} /* {PROJECT_NAME}.app */;") lines.append("\t\t\tproductType = \"com.apple.product-type.application\";") lines.append("\t\t};") lines.append("/* End PBXNativeTarget section */\n") lines.append("/* Begin PBXProject section */") lines.append(f"\t\t{project_id} /* Project object */ = {{") lines.append("\t\t\tisa = PBXProject;") lines.append("\t\t\tattributes = {") lines.append("\t\t\t\tBuildIndependentTargetsInParallel = 1;") lines.append("\t\t\t\tLastSwiftUpdateCheck = 1500;") lines.append("\t\t\t\tLastUpgradeCheck = 1500;") lines.append("\t\t\t\tTargetAttributes = {") lines.append(f"\t\t\t\t\t{target_id} = {{") lines.append("\t\t\t\t\t\tCreatedOnToolsVersion = 15.0;") lines.append("\t\t\t\t\t};") lines.append("\t\t\t\t};") lines.append("\t\t\t};") lines.append(f"\t\t\tbuildConfigurationList = {project_config_list_id} /* Build configuration list for PBXProject \"{PROJECT_NAME}\" */;") lines.append("\t\t\tcompatibilityVersion = \"Xcode 14.0\";") lines.append("\t\t\tdevelopmentRegion = en;") lines.append("\t\t\thasScannedForEncodings = 0;") lines.append("\t\t\tknownRegions = (") lines.append("\t\t\t\ten,") lines.append("\t\t\t\tBase,") lines.append("\t\t\t);") lines.append(f"\t\t\tmainGroup = {main_group_id};") lines.append(f"\t\t\tproductRefGroup = {products_group_id} /* Products */;") lines.append("\t\t\tprojectDirPath = \"\";") lines.append("\t\t\tprojectRoot = \"\";") lines.append("\t\t\ttargets = (") lines.append(f"\t\t\t\t{target_id} /* {PROJECT_NAME} */,") lines.append("\t\t\t);") lines.append("\t\t};") lines.append("/* End PBXProject section */\n") lines.append("/* Begin PBXResourcesBuildPhase section */") lines.append(f"\t\t{resources_phase_id} /* Resources */ = {{") lines.append("\t\t\tisa = PBXResourcesBuildPhase;") lines.append("\t\t\tbuildActionMask = 2147483647;") lines.append("\t\t\tfiles = (") lines.append(f"\t\t\t\t{asset_build_id} /* Assets.xcassets in Resources */,") lines.append("\t\t\t);") lines.append("\t\t\trunOnlyForDeploymentPostprocessing = 0;") lines.append("\t\t};") lines.append("/* End PBXResourcesBuildPhase section */\n") lines.append("/* Begin PBXSourcesBuildPhase section */") lines.append(f"\t\t{sources_phase_id} /* Sources */ = {{") lines.append("\t\t\tisa = PBXSourcesBuildPhase;") lines.append("\t\t\tbuildActionMask = 2147483647;") lines.append("\t\t\tfiles = (") for build_id, _, name in swift_build_entries: lines.append(f"\t\t\t\t{build_id} /* {name} in Sources */,") lines.append("\t\t\t);") lines.append("\t\t\trunOnlyForDeploymentPostprocessing = 0;") lines.append("\t\t};") lines.append("/* End PBXSourcesBuildPhase section */\n") common_settings = [ ("ASSETCATALOG_COMPILER_APPICON_NAME", "AppIcon"), ("CODE_SIGN_ENTITLEMENTS", "Resources/Bugger.entitlements"), ("CODE_SIGN_STYLE", "Automatic"), ("COMBINE_HIDPI_IMAGES", "YES"), ("CURRENT_PROJECT_VERSION", "1"), ("DEVELOPMENT_TEAM", ""), ("ENABLE_HARDENED_RUNTIME", "YES"), ("FEISHU_APP_ID", ""), ("FEISHU_APP_SECRET", ""), ("FEISHU_BASE_DOMAIN", "xorbitlab.feishu.cn"), ("GENERATE_INFOPLIST_FILE", "NO"), ("INFOPLIST_FILE", "Resources/Info.plist"), ("LD_RUNPATH_SEARCH_PATHS", "$(inherited) @executable_path/../Frameworks"), ("MACOSX_DEPLOYMENT_TARGET", "14.0"), ("MARKETING_VERSION", "1.0"), ("PRODUCT_BUNDLE_IDENTIFIER", BUNDLE_ID), ("PRODUCT_NAME", "$(TARGET_NAME)"), ("SWIFT_EMIT_LOC_STRINGS", "YES"), ("SWIFT_VERSION", "5.0"), ] def config_block(config_id: str, name: str, settings: list[tuple[str, str]]) -> str: setting_lines = "\n".join([f"\t\t\t\t{key} = {value};" for key, value in settings]) return ( f"\t\t{config_id} /* {name} */ = {{\n" f"\t\t\tisa = XCBuildConfiguration;\n" f"\t\t\tbuildSettings = {{\n{setting_lines}\n" f"\t\t\t}};\n" f"\t\t\tname = {name};\n" f"\t\t}};" ) lines.append("/* Begin XCBuildConfiguration section */") lines.append(config_block(debug_config_id, "Debug", [("ALWAYS_SEARCH_USER_PATHS", "NO"), ("CLANG_ENABLE_MODULES", "YES"), ("COPY_PHASE_STRIP", "NO"), ("DEBUG_INFORMATION_FORMAT", "dwarf"), ("GCC_DYNAMIC_NO_PIC", "NO"), ("GCC_OPTIMIZATION_LEVEL", "0"), ("ONLY_ACTIVE_ARCH", "YES"), ("SDKROOT", "macosx"), ("SWIFT_ACTIVE_COMPILATION_CONDITIONS", "DEBUG"), ("SWIFT_OPTIMIZATION_LEVEL", "-Onone")])) lines.append(config_block(release_config_id, "Release", [("ALWAYS_SEARCH_USER_PATHS", "NO"), ("CLANG_ENABLE_MODULES", "YES"), ("COPY_PHASE_STRIP", "NO"), ("DEBUG_INFORMATION_FORMAT", "dwarf-with-dsym"), ("SDKROOT", "macosx"), ("SWIFT_COMPILATION_MODE", "wholemodule")])) lines.append(config_block(target_debug_config_id, "Debug", common_settings)) lines.append(config_block(target_release_config_id, "Release", common_settings)) lines.append("/* End XCBuildConfiguration section */\n") lines.append("/* Begin XCConfigurationList section */") lines.append(f"\t\t{project_config_list_id} /* Build configuration list for PBXProject \"{PROJECT_NAME}\" */ = {{") lines.append("\t\t\tisa = XCConfigurationList;") lines.append("\t\t\tbuildConfigurations = (") lines.append(f"\t\t\t\t{debug_config_id} /* Debug */,") lines.append(f"\t\t\t\t{release_config_id} /* Release */,") lines.append("\t\t\t);") lines.append("\t\t\tdefaultConfigurationIsVisible = 0;") lines.append("\t\t\tdefaultConfigurationName = Release;") lines.append("\t\t};") lines.append(f"\t\t{target_config_list_id} /* Build configuration list for PBXNativeTarget \"{PROJECT_NAME}\" */ = {{") lines.append("\t\t\tisa = XCConfigurationList;") lines.append("\t\t\tbuildConfigurations = (") lines.append(f"\t\t\t\t{target_debug_config_id} /* Debug */,") lines.append(f"\t\t\t\t{target_release_config_id} /* Release */,") lines.append("\t\t\t);") lines.append("\t\t\tdefaultConfigurationIsVisible = 0;") lines.append("\t\t\tdefaultConfigurationName = Release;") lines.append("\t\t};") lines.append("/* End XCConfigurationList section */") lines.append("\t};") lines.append(f"\trootObject = {project_id} /* Project object */;") lines.append("}") return "\n".join(lines) def main(): project_dir = ROOT / f"{PROJECT_NAME}.xcodeproj" project_dir.mkdir(exist_ok=True) (project_dir / "project.pbxproj").write_text(pbxproj()) scheme_dir = project_dir / "xcshareddata" / "xcschemes" scheme_dir.mkdir(parents=True, exist_ok=True) (scheme_dir / f"{PROJECT_NAME}.xcscheme").write_text(f""" """) print(f"Generated {project_dir}") if __name__ == "__main__": main()