diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..af5e99f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,55 @@ +name: CI + +on: + pull_request: + push: + branches: + - master + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test-and-package: + runs-on: macos-26 + timeout-minutes: 30 + + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer + + - name: Run focused tests + run: ./scripts/test.sh + + - name: Build universal app + env: + ARCHES: "arm64 x86_64" + run: ./scripts/build-app.sh release + + - name: Verify packaged app + run: | + set -euo pipefail + + APP_PATH="$PWD/.build/app/Open in Code.app" + EXECUTABLE_PATH="$APP_PATH/Contents/MacOS/Open in Code" + + test -d "$APP_PATH" + test -f "$APP_PATH/Contents/Resources/Assets.car" + test -f "$APP_PATH/Contents/Resources/vscode.icns" + plutil -lint "$APP_PATH/Contents/Info.plist" + + ARCHITECTURES=$(lipo -archs "$EXECUTABLE_PATH") + echo "Architectures: $ARCHITECTURES" + [[ "$ARCHITECTURES" == *arm64* ]] + [[ "$ARCHITECTURES" == *x86_64* ]] + + codesign --verify --deep --strict --verbose=2 "$APP_PATH" + ENTITLEMENTS=$(codesign -d --entitlements :- "$APP_PATH" 2>/dev/null) + grep -F 'com.apple.security.automation.apple-events' <<< "$ENTITLEMENTS" >/dev/null diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9cb6d16..8e66862 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -177,33 +177,26 @@ jobs: - name: Build and sign release app id: app env: - VERSION: ${{ steps.version.outputs.version }} - BUNDLE_VERSION: ${{ steps.version.outputs.bundle_version }} + MARKETING_VERSION: ${{ steps.version.outputs.bundle_version }} + BUILD_NUMBER: ${{ github.run_number }} + ARCHES: "arm64 x86_64" + OPEN_IN_CODE_SIGNING: unsigned SIGNING_IDENTITY: ${{ steps.signing.outputs.identity }} SIGNING_MODE: ${{ steps.signing.outputs.mode }} run: | set -euo pipefail - DERIVED_DATA="$RUNNER_TEMP/OpenInCodeDerivedData" - xcodebuild \ - -project "Open in Code.xcodeproj" \ - -scheme "Open in Code" \ - -configuration Release \ - -destination "generic/platform=macOS" \ - -derivedDataPath "$DERIVED_DATA" \ - ARCHS="arm64 x86_64" \ - ONLY_ACTIVE_ARCH=NO \ - CODE_SIGNING_ALLOWED=NO \ - CODE_SIGNING_REQUIRED=NO \ - clean build - - APP_PATH="$DERIVED_DATA/Build/Products/Release/Open in Code.app" + ./scripts/build-app.sh release + + APP_PATH="$PWD/.build/app/Open in Code.app" PLIST_PATH="$APP_PATH/Contents/Info.plist" EXECUTABLE_PATH="$APP_PATH/Contents/MacOS/Open in Code" test -d "$APP_PATH" - /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $BUNDLE_VERSION" "$PLIST_PATH" - /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $GITHUB_RUN_NUMBER" "$PLIST_PATH" + test -f "$APP_PATH/Contents/Resources/Assets.car" + test -f "$APP_PATH/Contents/Resources/vscode.icns" + test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$PLIST_PATH")" = "$MARKETING_VERSION" + test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$PLIST_PATH")" = "$BUILD_NUMBER" ARCHITECTURES=$(lipo -archs "$EXECUTABLE_PATH") echo "Architectures: $ARCHITECTURES" diff --git a/.gitignore b/.gitignore index 6cf1eee..3454df8 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ ## Build generated build/ +.build/ DerivedData/ ## Various settings diff --git a/AGENTS.md b/AGENTS.md index d335f34..14f2567 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,32 +2,32 @@ ## Project at a glance -OpenInCode is a small macOS Finder toolbar utility written in Objective-C. It reads the selected Finder item, or the front Finder window when nothing is selected, and opens the corresponding folder in Visual Studio Code. +OpenInCode is a small macOS Finder toolbar utility written in Swift. It reads the selected Finder item, or the front Finder window when nothing is selected, and opens the corresponding folder in Visual Studio Code. - Deployment target: macOS 12 or newer. -- Build system: `Open in Code.xcodeproj`; Xcode 26 or newer is required for the current Icon Composer asset. -- Memory model: manual reference counting; ARC is disabled. -- Dependencies: Apple frameworks only; there is no package manager or dependency-install step. +- Build system: Swift Package Manager via `Package.swift`; Xcode 26 or newer is required only for `actool` to compile the current Icon Composer asset when packaging the app. +- Swift language mode: Swift 6. +- Dependencies: Apple frameworks only; SwiftPM resolves no external packages. ## Repository map -- `main.m`: Finder automation, user-facing errors, application lookup, and launch flow. -- `OpenInCodeCore.h` / `OpenInCodeCore.m`: testable editor-priority and Finder-path logic. -- `Tests/OpenInCodeCoreTests.m`: standalone Foundation test executable used by the test script. -- `scripts/test.sh`: canonical focused test command; also validates Homebrew cask rendering. +- `Package.swift`: SwiftPM executable and test target definitions. +- `Sources/OpenInCode/OpenInCodeApplication.swift`: Finder automation, user-facing errors, application lookup, and launch flow. +- `Sources/OpenInCode/OpenInCodeCore.swift`: testable editor-priority and Finder-path logic. +- `Tests/OpenInCodeTests/OpenInCodeCoreTests.swift`: XCTest coverage for editor priority and Finder paths. +- `scripts/test.sh`: canonical focused test command; runs `swift test` and validates Homebrew cask rendering. +- `scripts/build-app.sh`: builds thin SwiftPM executables, creates a universal binary when requested, compiles the icon, assembles the app bundle, and optionally ad-hoc signs it. - `scripts/render-homebrew-cask.sh`: release cask template generator. - `Info.plist` and `Open in Code.entitlements`: app metadata and Finder Apple Events permission. -- `Open in Code.xcodeproj`: target membership, build settings, signing, and the build rule that generates `Finder.h` from Finder's scripting definition. - `.github/workflows/release.yml`: tag-driven universal build, signing, notarization, GitHub release, and Homebrew cask update. ## Working rules 1. Check `git status --short` before editing and preserve unrelated user changes. -2. Inspect only the files relevant to the task; do not search generated `build/` or `DerivedData/` content. +2. Inspect only the files relevant to the task; do not search generated `.build/`, `build/`, or `DerivedData/` content. 3. Keep changes focused. Avoid adding dependencies, new abstractions, or project files unless the task requires them. -4. Put deterministic, UI-independent behavior in `OpenInCodeCore.*` and cover it in `Tests/OpenInCodeCoreTests.m`. -5. When adding or removing source or resource files, keep the Xcode project references and target membership in sync. -6. Do not add a generated `Finder.h` to the repository; Xcode creates it during the build. +4. Put deterministic, UI-independent behavior in `Sources/OpenInCode/OpenInCodeCore.swift` and cover it in `Tests/OpenInCodeTests/OpenInCodeCoreTests.swift`. +5. Keep SwiftPM target definitions, source layout, and packaging inputs in sync when adding or removing source or resource files. ## Behavioral invariants @@ -40,10 +40,10 @@ Preserve these unless the requested change explicitly replaces them: - Present actionable errors for Finder permission failures and missing or failed VS Code launches. - Keep compatibility with macOS 12; guard any newer API before use. -## Objective-C conventions +## Swift conventions -- Match the existing Objective-C style and naming (`OIC` prefix for shared symbols). -- ARC is disabled. Balance ownership with `retain`, `release`, `autorelease`, or `copy`, and keep `dealloc` implementations correct. +- Match the existing Swift style and naming (`OIC` prefix for shared symbols). +- Keep the application compatible with Swift 6 language mode and macOS 12. - Treat compiler warnings as errors in testable core code. - Prefer small functions with explicit failure handling over silent fallback behavior. @@ -58,15 +58,10 @@ Run the smallest relevant checks: ./scripts/test.sh ``` -- Application code, plist, entitlements, icon, or Xcode project changes: run the focused tests, then an unsigned Release build: +- Application code, package manifest, plist, entitlements, icon, or packaging changes: run the focused tests, then assemble an unsigned Release app: ```sh - xcodebuild \ - -project "Open in Code.xcodeproj" \ - -scheme "Open in Code" \ - -configuration Release \ - CODE_SIGNING_ALLOWED=NO \ - clean build + OPEN_IN_CODE_SIGNING=unsigned ./scripts/build-app.sh release ``` Do not create tags, publish releases, notarize artifacts, update the Homebrew tap, or alter signing identities, team IDs, bundle identifiers, or release secrets unless explicitly requested. diff --git a/Info.plist b/Info.plist index 1ad0840..b941174 100755 --- a/Info.plist +++ b/Info.plist @@ -9,7 +9,7 @@ CFBundleIconFile vscode CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) + com.sertacozercan.openincode CFBundleInfoDictionaryVersion 6.0 CFBundleName @@ -32,5 +32,7 @@ Open in Code uses Finder to determine which folder or selected item to open in Visual Studio Code. NSPrincipalClass NSApplication + CFBundleIconName + vscode diff --git a/Open in Code.xcodeproj/project.pbxproj b/Open in Code.xcodeproj/project.pbxproj deleted file mode 100755 index 58cd4f8..0000000 --- a/Open in Code.xcodeproj/project.pbxproj +++ /dev/null @@ -1,358 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 54; - objects = { - -/* Begin PBXBuildFile section */ - 69C8AA89106EE02F00C4DE0D /* Finder.app in Sources */ = {isa = PBXBuildFile; fileRef = 69C8AA70106E8F5800C4DE0D /* Finder.app */; }; - 8D11072B0486CEB800E47090 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 089C165CFE840E0CC02AAC07 /* InfoPlist.strings */; }; - 8D11072D0486CEB800E47090 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 29B97316FDCFA39411CA2CEA /* main.m */; settings = {ATTRIBUTES = (); }; }; - 8D11072F0486CEB800E47090 /* Cocoa.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1058C7A1FEA54F0111CA2CBB /* Cocoa.framework */; }; - C82F4C8C1D31B01E0084C3F5 /* ScriptingBridge.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 69C8A98D106E818900C4DE0D /* ScriptingBridge.framework */; }; - C89E34F12FBA29F900F208FE /* vscode.icon in Resources */ = {isa = PBXBuildFile; fileRef = C89E34F02FBA29F900F208FE /* vscode.icon */; }; - C89E34F62FBA29F900F208FE /* OpenInCodeCore.m in Sources */ = {isa = PBXBuildFile; fileRef = C89E34F32FBA29F900F208FE /* OpenInCodeCore.m */; }; -/* End PBXBuildFile section */ - -/* Begin PBXBuildRule section */ - 69C8AA6F106E8F0C00C4DE0D /* PBXBuildRule */ = { - isa = PBXBuildRule; - compilerSpec = com.apple.compilers.proxy.script; - filePatterns = "*.app"; - fileType = pattern.proxy; - inputFiles = ( - ); - isEditable = 1; - outputFiles = ( - "$(DERIVED_FILES_DIR)/$(INPUT_FILE_BASE).h", - ); - runOncePerArchitecture = 0; - script = "sdef \"$INPUT_FILE_PATH\" | sdp -fh -o \"$DERIVED_FILES_DIR\" --basename \"$INPUT_FILE_BASE\" --bundleid `defaults read \"$INPUT_FILE_PATH/Contents/Info\" CFBundleIdentifier`"; - }; -/* End PBXBuildRule section */ - -/* Begin PBXCopyFilesBuildPhase section */ - 694495D00B869BB500A19631 /* CopyFiles */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 7; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - -/* Begin PBXFileReference section */ - 089C165DFE840E0CC02AAC07 /* English */ = {isa = PBXFileReference; fileEncoding = 10; lastKnownFileType = text.plist.strings; name = English; path = English.lproj/InfoPlist.strings; sourceTree = ""; }; - 1058C7A1FEA54F0111CA2CBB /* Cocoa.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Cocoa.framework; path = /System/Library/Frameworks/Cocoa.framework; sourceTree = ""; }; - 13E42FB307B3F0F600E4EEF1 /* CoreData.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreData.framework; path = /System/Library/Frameworks/CoreData.framework; sourceTree = ""; }; - 29B97316FDCFA39411CA2CEA /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = ""; }; - 29B97324FDCFA39411CA2CEA /* AppKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AppKit.framework; path = /System/Library/Frameworks/AppKit.framework; sourceTree = ""; }; - 29B97325FDCFA39411CA2CEA /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = /System/Library/Frameworks/Foundation.framework; sourceTree = ""; }; - 32CA4F630368D1EE00C91783 /* Open in Code_Prefix.pch */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "Open in Code_Prefix.pch"; sourceTree = ""; }; - 69C8A98D106E818900C4DE0D /* ScriptingBridge.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ScriptingBridge.framework; path = /System/Library/Frameworks/ScriptingBridge.framework; sourceTree = ""; }; - 69C8AA70106E8F5800C4DE0D /* Finder.app */ = {isa = PBXFileReference; lastKnownFileType = wrapper.application; name = Finder.app; path = /System/Library/CoreServices/Finder.app; sourceTree = ""; }; - 8D1107310486CEB800E47090 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; - 8D1107320486CEB800E47090 /* Open in Code.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Open in Code.app"; sourceTree = BUILT_PRODUCTS_DIR; }; - C89E34F02FBA29F900F208FE /* vscode.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = vscode.icon; sourceTree = ""; }; - C89E34F22FBA29F900F208FE /* OpenInCodeCore.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OpenInCodeCore.h; sourceTree = ""; }; - C89E34F32FBA29F900F208FE /* OpenInCodeCore.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OpenInCodeCore.m; sourceTree = ""; }; - C89E34F52FBA29F900F208FE /* Open in Code.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Open in Code.entitlements"; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 8D11072E0486CEB800E47090 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 8D11072F0486CEB800E47090 /* Cocoa.framework in Frameworks */, - C82F4C8C1D31B01E0084C3F5 /* ScriptingBridge.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 1058C7A0FEA54F0111CA2CBB /* Linked Frameworks */ = { - isa = PBXGroup; - children = ( - 1058C7A1FEA54F0111CA2CBB /* Cocoa.framework */, - 69C8A98D106E818900C4DE0D /* ScriptingBridge.framework */, - ); - name = "Linked Frameworks"; - sourceTree = ""; - }; - 1058C7A2FEA54F0111CA2CBB /* Other Frameworks */ = { - isa = PBXGroup; - children = ( - 29B97324FDCFA39411CA2CEA /* AppKit.framework */, - 13E42FB307B3F0F600E4EEF1 /* CoreData.framework */, - 29B97325FDCFA39411CA2CEA /* Foundation.framework */, - ); - name = "Other Frameworks"; - sourceTree = ""; - }; - 19C28FACFE9D520D11CA2CBB /* Products */ = { - isa = PBXGroup; - children = ( - 8D1107320486CEB800E47090 /* Open in Code.app */, - ); - name = Products; - sourceTree = ""; - }; - 29B97314FDCFA39411CA2CEA /* Open In Code */ = { - isa = PBXGroup; - children = ( - 69C8AA70106E8F5800C4DE0D /* Finder.app */, - 29B97315FDCFA39411CA2CEA /* Other Sources */, - 29B97317FDCFA39411CA2CEA /* Resources */, - 29B97323FDCFA39411CA2CEA /* Frameworks */, - 19C28FACFE9D520D11CA2CBB /* Products */, - ); - name = "Open In Code"; - sourceTree = ""; - }; - 29B97315FDCFA39411CA2CEA /* Other Sources */ = { - isa = PBXGroup; - children = ( - 32CA4F630368D1EE00C91783 /* Open in Code_Prefix.pch */, - 29B97316FDCFA39411CA2CEA /* main.m */, - C89E34F22FBA29F900F208FE /* OpenInCodeCore.h */, - C89E34F32FBA29F900F208FE /* OpenInCodeCore.m */, - C89E34F52FBA29F900F208FE /* Open in Code.entitlements */, - ); - name = "Other Sources"; - sourceTree = ""; - }; - 29B97317FDCFA39411CA2CEA /* Resources */ = { - isa = PBXGroup; - children = ( - 8D1107310486CEB800E47090 /* Info.plist */, - 089C165CFE840E0CC02AAC07 /* InfoPlist.strings */, - C89E34F02FBA29F900F208FE /* vscode.icon */, - ); - name = Resources; - sourceTree = ""; - }; - 29B97323FDCFA39411CA2CEA /* Frameworks */ = { - isa = PBXGroup; - children = ( - 1058C7A0FEA54F0111CA2CBB /* Linked Frameworks */, - 1058C7A2FEA54F0111CA2CBB /* Other Frameworks */, - ); - name = Frameworks; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 8D1107260486CEB800E47090 /* Open in Code */ = { - isa = PBXNativeTarget; - buildConfigurationList = C01FCF4A08A954540054247B /* Build configuration list for PBXNativeTarget "Open in Code" */; - buildPhases = ( - 8D1107290486CEB800E47090 /* Resources */, - 8D11072C0486CEB800E47090 /* Sources */, - 8D11072E0486CEB800E47090 /* Frameworks */, - 694495D00B869BB500A19631 /* CopyFiles */, - ); - buildRules = ( - 69C8AA6F106E8F0C00C4DE0D /* PBXBuildRule */, - ); - dependencies = ( - ); - name = "Open in Code"; - productInstallPath = /Applications; - productName = "Open In Code"; - productReference = 8D1107320486CEB800E47090 /* Open in Code.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 29B97313FDCFA39411CA2CEA /* Project object */ = { - isa = PBXProject; - attributes = { - LastUpgradeCheck = 2700; - }; - buildConfigurationList = C01FCF4E08A954540054247B /* Build configuration list for PBXProject "Open in Code" */; - compatibilityVersion = "Xcode 3.2"; - developmentRegion = English; - hasScannedForEncodings = 1; - knownRegions = ( - English, - en, - ); - mainGroup = 29B97314FDCFA39411CA2CEA /* Open In Code */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 8D1107260486CEB800E47090 /* Open in Code */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 8D1107290486CEB800E47090 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 8D11072B0486CEB800E47090 /* InfoPlist.strings in Resources */, - C89E34F12FBA29F900F208FE /* vscode.icon in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 8D11072C0486CEB800E47090 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 69C8AA89106EE02F00C4DE0D /* Finder.app in Sources */, - 8D11072D0486CEB800E47090 /* main.m in Sources */, - C89E34F62FBA29F900F208FE /* OpenInCodeCore.m in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXVariantGroup section */ - 089C165CFE840E0CC02AAC07 /* InfoPlist.strings */ = { - isa = PBXVariantGroup; - children = ( - 089C165DFE840E0CC02AAC07 /* English */, - ); - name = InfoPlist.strings; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - -/* Begin XCBuildConfiguration section */ - C01FCF4B08A954540054247B /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = vscode; - CLANG_ENABLE_OBJC_ARC = NO; - CODE_SIGN_ENTITLEMENTS = "Open in Code.entitlements"; - CODE_SIGN_IDENTITY = "-"; - CODE_SIGN_STYLE = Manual; - COMBINE_HIDPI_IMAGES = YES; - COPY_PHASE_STRIP = NO; - "COPY_PHASE_STRIP[sdk=*]" = NO; - ENABLE_HARDENED_RUNTIME = YES; - GCC_DYNAMIC_NO_PIC = NO; - GCC_ENABLE_OBJC_EXCEPTIONS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - INFOPLIST_FILE = Info.plist; - INSTALL_PATH = /Applications; - MACOSX_DEPLOYMENT_TARGET = 12.0; - PRODUCT_BUNDLE_IDENTIFIER = com.sertacozercan.openincode; - PRODUCT_NAME = "Open in Code"; - WRAPPER_EXTENSION = app; - }; - name = Debug; - }; - C01FCF4C08A954540054247B /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = vscode; - CLANG_ENABLE_OBJC_ARC = NO; - CODE_SIGN_ENTITLEMENTS = "Open in Code.entitlements"; - CODE_SIGN_IDENTITY = "Developer ID Application"; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; - CODE_SIGN_STYLE = Manual; - COMBINE_HIDPI_IMAGES = YES; - DEVELOPMENT_TEAM = VURRGRYW45; - "DEVELOPMENT_TEAM[sdk=macosx*]" = 57QNR9B89Q; - ENABLE_HARDENED_RUNTIME = YES; - GCC_ENABLE_OBJC_EXCEPTIONS = YES; - GCC_GENERATE_DEBUGGING_SYMBOLS = NO; - INFOPLIST_FILE = Info.plist; - INSTALL_PATH = /Applications; - MACOSX_DEPLOYMENT_TARGET = 12.0; - PRODUCT_BUNDLE_IDENTIFIER = com.sertacozercan.openincode; - PRODUCT_NAME = "Open in Code"; - WRAPPER_EXTENSION = app; - }; - name = Release; - }; - C01FCF4F08A954540054247B /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - INSTALL_PATH = ""; - MACOSX_DEPLOYMENT_TARGET = 12.0; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = macosx; - }; - name = Debug; - }; - C01FCF5008A954540054247B /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - INSTALL_PATH = ""; - MACOSX_DEPLOYMENT_TARGET = 12.0; - SDKROOT = macosx; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - C01FCF4A08A954540054247B /* Build configuration list for PBXNativeTarget "Open in Code" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - C01FCF4B08A954540054247B /* Debug */, - C01FCF4C08A954540054247B /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - C01FCF4E08A954540054247B /* Build configuration list for PBXProject "Open in Code" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - C01FCF4F08A954540054247B /* Debug */, - C01FCF5008A954540054247B /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = 29B97313FDCFA39411CA2CEA /* Project object */; -} diff --git a/Open in Code.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Open in Code.xcodeproj/project.xcworkspace/contents.xcworkspacedata deleted file mode 100755 index 919434a..0000000 --- a/Open in Code.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/Open in Code.xcodeproj/xcshareddata/xcschemes/Open in Code.xcscheme b/Open in Code.xcodeproj/xcshareddata/xcschemes/Open in Code.xcscheme deleted file mode 100644 index cfed17d..0000000 --- a/Open in Code.xcodeproj/xcshareddata/xcschemes/Open in Code.xcscheme +++ /dev/null @@ -1,80 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Open in Code_Prefix.pch b/Open in Code_Prefix.pch deleted file mode 100755 index aabef47..0000000 --- a/Open in Code_Prefix.pch +++ /dev/null @@ -1,3 +0,0 @@ -#ifdef __OBJC__ - #import -#endif diff --git a/OpenInCodeCore.h b/OpenInCodeCore.h deleted file mode 100644 index f3489e4..0000000 --- a/OpenInCodeCore.h +++ /dev/null @@ -1,7 +0,0 @@ -#import - -extern NSString * const OICVSCodeBundleIdentifier; -extern NSString * const OICVSCodeInsidersBundleIdentifier; - -NSArray *OICPreferredVSCodeBundleIdentifiers(void); -NSString *OICPathForFinderURL(NSURL *url); diff --git a/OpenInCodeCore.m b/OpenInCodeCore.m deleted file mode 100644 index 1fcf838..0000000 --- a/OpenInCodeCore.m +++ /dev/null @@ -1,58 +0,0 @@ -#import "OpenInCodeCore.h" - -NSString * const OICVSCodeBundleIdentifier = @"com.microsoft.VSCode"; -NSString * const OICVSCodeInsidersBundleIdentifier = @"com.microsoft.VSCodeInsiders"; - -NSArray *OICPreferredVSCodeBundleIdentifiers(void) -{ - return @[OICVSCodeBundleIdentifier, OICVSCodeInsidersBundleIdentifier]; -} - -NSString *OICPathForFinderURL(NSURL *url) -{ - if (url == nil || ![url isFileURL]) { - return nil; - } - - NSNumber *isAliasFile = nil; - NSNumber *isSymbolicLink = nil; - [url getResourceValue:&isAliasFile forKey:NSURLIsAliasFileKey error:nil]; - [url getResourceValue:&isSymbolicLink forKey:NSURLIsSymbolicLinkKey error:nil]; - if ([isAliasFile boolValue] && ![isSymbolicLink boolValue]) { - NSData *bookmark = [NSURL bookmarkDataWithContentsOfURL:url error:nil]; - if (bookmark == nil) { - return nil; - } - - NSURL *resolvedURL = [NSURL URLByResolvingBookmarkData:bookmark - options:NSURLBookmarkResolutionWithoutUI - relativeToURL:nil - bookmarkDataIsStale:nil - error:nil]; - if (resolvedURL == nil) { - return nil; - } - url = resolvedURL; - } - - NSString *path = [[url path] stringByExpandingTildeInPath]; - if ([path length] == 0) { - return nil; - } - - BOOL isDirectory = NO; - if (![[NSFileManager defaultManager] fileExistsAtPath:path isDirectory:&isDirectory]) { - return nil; - } - - NSURL *packageCheckURL = [url URLByResolvingSymlinksInPath]; - NSNumber *isPackageValue = nil; - [packageCheckURL getResourceValue:&isPackageValue forKey:NSURLIsPackageKey error:nil]; - BOOL isPackage = [isPackageValue boolValue]; - - if (!isDirectory || isPackage) { - path = [path stringByDeletingLastPathComponent]; - } - - return [path length] > 0 ? path : nil; -} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..f54fe6e --- /dev/null +++ b/Package.swift @@ -0,0 +1,32 @@ +// swift-tools-version: 6.2 + +import PackageDescription + +let package = Package( + name: "OpenInCode", + platforms: [ + .macOS(.v12), + ], + products: [ + .executable( + name: "OpenInCode", + targets: ["OpenInCode"] + ), + ], + targets: [ + .executableTarget( + name: "OpenInCode", + swiftSettings: [ + .swiftLanguageMode(.v6), + ] + ), + .testTarget( + name: "OpenInCodeTests", + dependencies: ["OpenInCode"], + swiftSettings: [ + .swiftLanguageMode(.v6), + ] + ), + ], + swiftLanguageModes: [.v6] +) diff --git a/README.md b/README.md index c759834..ed78288 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ OpenInCode prefers stable Visual Studio Code and falls back to Visual Studio Cod - macOS 12 or newer - Visual Studio Code or Visual Studio Code Insiders -- Xcode 26 or newer to build the current Icon Composer asset +- Swift 6.2 or newer +- Xcode 26 or newer for the `actool` step that compiles the current Icon Composer asset ## Install @@ -23,38 +24,35 @@ Release archives are also available from the [GitHub Releases](https://github.co To build from source: 1. Clone this repository. -2. Open `Open in Code.xcodeproj` in Xcode. -3. Build the Debug configuration for local use, or select a Developer ID Application certificate before archiving Release. -4. Copy `Open in Code.app` to `/Applications`. -5. Hold Command and drag the app from `/Applications` to a Finder toolbar. -6. Click the toolbar icon while viewing a folder or selecting a file. +2. Run `./scripts/build-app.sh release`. The script uses SwiftPM and creates `.build/app/Open in Code.app`. +3. Copy `Open in Code.app` to `/Applications`. +4. Hold Command and drag the app from `/Applications` to a Finder toolbar. +5. Click the toolbar icon while viewing a folder or selecting a file. The first use asks for permission to control Finder. If permission was denied, enable **Open in Code → Finder** here: - macOS 13 or newer: **System Settings → Privacy & Security → Automation** - macOS 12: **System Preferences → Security & Privacy → Privacy → Automation** -Release archives are configured for hardened runtime and the project publisher team. Fork maintainers should override `DEVELOPMENT_TEAM` with their own team and provide a Developer ID Application certificate. Notarize public release artifacts before distribution. +The local packaging script applies an ad-hoc signature by default so Finder automation entitlements are available during development. Public release artifacts should be signed with a Developer ID Application certificate and notarized before distribution. ## Development -Run the focused path and editor-selection tests: +Build the SwiftPM executable and run the focused path and editor-selection tests: ```sh +swift build ./scripts/test.sh ``` -Run a clean unsigned compiler verification: +Assemble an unsigned Release app bundle for verification: ```sh -xcodebuild \ - -project "Open in Code.xcodeproj" \ - -scheme "Open in Code" \ - -configuration Release \ - CODE_SIGNING_ALLOWED=NO \ - clean build +OPEN_IN_CODE_SIGNING=unsigned ./scripts/build-app.sh release ``` +Set `ARCHES="arm64 x86_64"` when a universal app is required. + ## Publishing a release Pushing a `v*` tag runs `.github/workflows/release.yml`. The workflow builds a universal app, signs it, creates a GitHub release, and updates `Casks/open-in-code.rb` in [`sozercan/homebrew-repo`](https://github.com/sozercan/homebrew-repo). diff --git a/Sources/OpenInCode/OpenInCodeApplication.swift b/Sources/OpenInCode/OpenInCodeApplication.swift new file mode 100644 index 0000000..a830bfb --- /dev/null +++ b/Sources/OpenInCode/OpenInCodeApplication.swift @@ -0,0 +1,137 @@ +import AppKit +import Darwin +import Foundation + +private let finderAutomationPermissionMessage = "Open the Automation privacy settings and allow Open in Code to control Finder, then try again." + +private let finderLocationScript = """ +tell application "Finder" + if (count of selection) > 0 then + set targetItem to item 1 of selection + else if (count of Finder windows) > 0 then + set targetItem to target of front Finder window + else + return "" + end if + + return URL of targetItem +end tell +""" + +@MainActor +private func showErrorAlert(message: String, informativeText: String) { + _ = NSApplication.shared + NSApp.setActivationPolicy(.accessory) + NSApp.activate(ignoringOtherApps: true) + + let alert = NSAlert() + alert.alertStyle = .critical + alert.messageText = message + alert.informativeText = informativeText + alert.addButton(withTitle: "OK") + alert.runModal() +} + +@MainActor +private func pathToFrontFinderLocation() -> (path: String?, errorMessage: String?) { + guard let script = NSAppleScript(source: finderLocationScript) else { + return (nil, finderAutomationPermissionMessage) + } + + var errorInfo: NSDictionary? + let result = script.executeAndReturnError(&errorInfo) + if errorInfo != nil { + return (nil, finderAutomationPermissionMessage) + } + + guard let targetURLString = result.stringValue, !targetURLString.isEmpty else { + return (nil, "Open a Finder window for a local folder and try again.") + } + + guard let targetURL = URL(string: targetURLString), + let path = OICPathForFinderURL(targetURL) else { + return (nil, "The selected Finder item does not have an accessible local path.") + } + + return (path, nil) +} + +@MainActor +private func openPathInPreferredVSCode(_ path: String) async -> (opened: Bool, errorMessage: String?) { + guard !path.isEmpty else { + return (false, "No folder path was available to open.") + } + + let folderURL = URL(fileURLWithPath: path, isDirectory: true) + let workspace = NSWorkspace.shared + + for bundleIdentifier in OICPreferredVSCodeBundleIdentifiers() { + guard let applicationURL = workspace.urlForApplication(withBundleIdentifier: bundleIdentifier) else { + continue + } + + let configuration = NSWorkspace.OpenConfiguration() + configuration.activates = true + configuration.promptsUserIfNeeded = true + configuration.allowsRunningApplicationSubstitution = false + + let result: (opened: Bool, errorMessage: String?) = await withCheckedContinuation { continuation in + workspace.open( + [folderURL], + withApplicationAt: applicationURL, + configuration: configuration + ) { application, error in + continuation.resume( + returning: ( + application != nil && error == nil, + error?.localizedDescription + ) + ) + } + } + + if result.opened { + return (true, nil) + } + + return ( + false, + result.errorMessage?.isEmpty == false + ? result.errorMessage + : "Visual Studio Code could not open the selected folder." + ) + } + + return (false, "Install Visual Studio Code or Visual Studio Code Insiders, then try again.") +} + +@MainActor +private func run() async -> Int32 { + let finderResult = pathToFrontFinderLocation() + guard let path = finderResult.path else { + showErrorAlert( + message: "Couldn’t read the Finder location", + informativeText: finderResult.errorMessage ?? "Open a Finder window and try again." + ) + return 1 + } + + let launchResult = await openPathInPreferredVSCode(path) + guard launchResult.opened else { + showErrorAlert( + message: "Couldn’t open Visual Studio Code", + informativeText: launchResult.errorMessage ?? "The application could not be launched." + ) + return 2 + } + + return 0 +} + +@main +private enum OpenInCodeApplication { + @MainActor + static func main() async { + exit(await run()) + } +} diff --git a/Sources/OpenInCode/OpenInCodeCore.swift b/Sources/OpenInCode/OpenInCodeCore.swift new file mode 100644 index 0000000..94cf85e --- /dev/null +++ b/Sources/OpenInCode/OpenInCodeCore.swift @@ -0,0 +1,81 @@ +import Foundation + +let OICVSCodeBundleIdentifier = "com.microsoft.VSCode" +let OICVSCodeInsidersBundleIdentifier = "com.microsoft.VSCodeInsiders" + +func OICPreferredVSCodeBundleIdentifiers() -> [String] { + [OICVSCodeBundleIdentifier, OICVSCodeInsidersBundleIdentifier] +} + +func OICFilePathURLForURL(_ url: URL) -> URL? { + guard url.isFileURL else { + return nil + } + + let foundationURL = url as NSURL + if foundationURL.isFileReferenceURL() { + return foundationURL.filePathURL + } + return url +} + +func OICFileSystemPathForURL(_ url: URL) -> String { + var path: String + if #available(macOS 13.0, *) { + path = url.path(percentEncoded: false) + } else { + path = url.path + } + + while path.count > 1 && path.last == "/" { + path.removeLast() + } + return path +} + +func OICPathForFinderURL(_ finderURL: URL?) -> String? { + guard let finderURL, var url = OICFilePathURLForURL(finderURL) else { + return nil + } + + let resourceValues = try? url.resourceValues(forKeys: [.isAliasFileKey, .isSymbolicLinkKey]) + if resourceValues?.isAliasFile == true && resourceValues?.isSymbolicLink != true { + guard let bookmarkData = try? URL.bookmarkData(withContentsOf: url) else { + return nil + } + + var isStale = false + guard let resolvedURL = try? URL( + resolvingBookmarkData: bookmarkData, + options: .withoutUI, + relativeTo: nil, + bookmarkDataIsStale: &isStale + ) else { + return nil + } + guard let filePathURL = OICFilePathURLForURL(resolvedURL) else { + return nil + } + url = filePathURL + } + + var path = (OICFileSystemPathForURL(url) as NSString).expandingTildeInPath + guard !path.isEmpty else { + return nil + } + + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) else { + return nil + } + + let packageCheckURL = url.resolvingSymlinksInPath() + let packageValues = try? packageCheckURL.resourceValues(forKeys: [.isPackageKey]) + let isPackage = packageValues?.isPackage == true + + if !isDirectory.boolValue || isPackage { + path = (path as NSString).deletingLastPathComponent + } + + return path.isEmpty ? nil : path +} diff --git a/Tests/OpenInCodeCoreTests.m b/Tests/OpenInCodeCoreTests.m deleted file mode 100644 index 4ec5161..0000000 --- a/Tests/OpenInCodeCoreTests.m +++ /dev/null @@ -1,85 +0,0 @@ -#import -#include -#import "OpenInCodeCore.h" - -static void assertTrue(BOOL condition, NSString *message) -{ - if (!condition) { - NSLog(@"FAIL: %@", message); - exit(1); - } -} - -int main(void) -{ - @autoreleasepool { - NSArray *bundleIdentifiers = OICPreferredVSCodeBundleIdentifiers(); - assertTrue([bundleIdentifiers count] == 2, @"expected two VS Code channels"); - assertTrue([bundleIdentifiers[0] isEqualToString:OICVSCodeBundleIdentifier], @"stable VS Code must be preferred"); - assertTrue([bundleIdentifiers[1] isEqualToString:OICVSCodeInsidersBundleIdentifier], @"Insiders must be the fallback"); - - assertTrue(OICPathForFinderURL(nil) == nil, @"nil URL must return nil"); - assertTrue(OICPathForFinderURL([NSURL URLWithString:@"https://example.com"]) == nil, @"non-file URL must return nil"); - - NSString *temporaryRoot = [NSTemporaryDirectory() stringByAppendingPathComponent:[[NSUUID UUID] UUIDString]]; - NSString *folderPath = [temporaryRoot stringByAppendingPathComponent:@"Project"]; - NSString *filePath = [folderPath stringByAppendingPathComponent:@"README.md"]; - NSString *packagePath = [temporaryRoot stringByAppendingPathComponent:@"Example.app"]; - NSString *directorySymlinkPath = [temporaryRoot stringByAppendingPathComponent:@"LinkedProject"]; - NSURL *folderAliasURL = [NSURL fileURLWithPath:[temporaryRoot stringByAppendingPathComponent:@"Project Folder.alias"]]; - NSURL *fileAliasURL = [NSURL fileURLWithPath:[temporaryRoot stringByAppendingPathComponent:@"Project File.alias"]]; - NSString *deletedTargetPath = [temporaryRoot stringByAppendingPathComponent:@"Deleted Target.txt"]; - NSURL *brokenAliasURL = [NSURL fileURLWithPath:[temporaryRoot stringByAppendingPathComponent:@"Broken Target.alias"]]; - - NSFileManager *fileManager = [NSFileManager defaultManager]; - assertTrue([fileManager createDirectoryAtPath:folderPath withIntermediateDirectories:YES attributes:nil error:nil], @"create test folder"); - assertTrue([@"test" writeToFile:filePath atomically:YES encoding:NSUTF8StringEncoding error:nil], @"create test file"); - assertTrue([@"delete me" writeToFile:deletedTargetPath atomically:YES encoding:NSUTF8StringEncoding error:nil], @"create deleted alias target"); - assertTrue([fileManager createDirectoryAtPath:packagePath withIntermediateDirectories:YES attributes:nil error:nil], @"create test package"); - assertTrue([fileManager createSymbolicLinkAtPath:directorySymlinkPath withDestinationPath:folderPath error:nil], @"create directory symlink"); - - NSData *folderBookmark = [[NSURL fileURLWithPath:folderPath isDirectory:YES] - bookmarkDataWithOptions:NSURLBookmarkCreationSuitableForBookmarkFile - includingResourceValuesForKeys:nil - relativeToURL:nil - error:nil]; - NSData *fileBookmark = [[NSURL fileURLWithPath:filePath] - bookmarkDataWithOptions:NSURLBookmarkCreationSuitableForBookmarkFile - includingResourceValuesForKeys:nil - relativeToURL:nil - error:nil]; - NSData *deletedTargetBookmark = [[NSURL fileURLWithPath:deletedTargetPath] - bookmarkDataWithOptions:NSURLBookmarkCreationSuitableForBookmarkFile - includingResourceValuesForKeys:nil - relativeToURL:nil - error:nil]; - assertTrue(folderBookmark != nil, @"create folder bookmark data"); - assertTrue(fileBookmark != nil, @"create file bookmark data"); - assertTrue(deletedTargetBookmark != nil, @"create deleted-target bookmark data"); - assertTrue([NSURL writeBookmarkData:folderBookmark toURL:folderAliasURL options:0 error:nil], @"create folder alias"); - assertTrue([NSURL writeBookmarkData:fileBookmark toURL:fileAliasURL options:0 error:nil], @"create file alias"); - assertTrue([NSURL writeBookmarkData:deletedTargetBookmark toURL:brokenAliasURL options:0 error:nil], @"create broken alias"); - assertTrue([fileManager removeItemAtPath:deletedTargetPath error:nil], @"delete alias target"); - - NSURL *resolvedFolderAliasURL = [NSURL URLByResolvingAliasFileAtURL:folderAliasURL options:NSURLBookmarkResolutionWithoutUI error:nil]; - NSURL *resolvedFileAliasURL = [NSURL URLByResolvingAliasFileAtURL:fileAliasURL options:NSURLBookmarkResolutionWithoutUI error:nil]; - assertTrue(resolvedFolderAliasURL != nil, @"resolve folder alias"); - assertTrue(resolvedFileAliasURL != nil, @"resolve file alias"); - NSString *resolvedFolderAliasPath = [resolvedFolderAliasURL path]; - NSString *resolvedFileAliasParentPath = [[resolvedFileAliasURL path] stringByDeletingLastPathComponent]; - - assertTrue([OICPathForFinderURL([NSURL fileURLWithPath:folderPath]) isEqualToString:folderPath], @"folder should open itself"); - assertTrue([OICPathForFinderURL([NSURL fileURLWithPath:filePath]) isEqualToString:folderPath], @"file should open its parent"); - assertTrue([OICPathForFinderURL([NSURL fileURLWithPath:packagePath]) isEqualToString:temporaryRoot], @"Finder package should open its parent"); - assertTrue([OICPathForFinderURL([NSURL fileURLWithPath:directorySymlinkPath]) isEqualToString:directorySymlinkPath], @"directory symlink should open the linked directory"); - assertTrue([OICPathForFinderURL(folderAliasURL) isEqualToString:resolvedFolderAliasPath], @"folder alias should resolve to its target"); - assertTrue([OICPathForFinderURL(fileAliasURL) isEqualToString:resolvedFileAliasParentPath], @"file alias should resolve to its target parent"); - assertTrue(OICPathForFinderURL(brokenAliasURL) == nil, @"broken alias must return nil"); - assertTrue(OICPathForFinderURL([NSURL fileURLWithPath:[temporaryRoot stringByAppendingPathComponent:@"missing"]]) == nil, @"missing item must return nil"); - - assertTrue([fileManager removeItemAtPath:temporaryRoot error:nil], @"remove test files"); - NSLog(@"OpenInCodeCoreTests: PASS"); - } - - return 0; -} diff --git a/Tests/OpenInCodeTests/OpenInCodeCoreTests.swift b/Tests/OpenInCodeTests/OpenInCodeCoreTests.swift new file mode 100644 index 0000000..edbc966 --- /dev/null +++ b/Tests/OpenInCodeTests/OpenInCodeCoreTests.swift @@ -0,0 +1,108 @@ +import Foundation +import XCTest +@testable import OpenInCode + +final class OpenInCodeCoreTests: XCTestCase { + func testPreferredVSCodeBundleIdentifiers() { + let bundleIdentifiers = OICPreferredVSCodeBundleIdentifiers() + + XCTAssertEqual(bundleIdentifiers.count, 2) + XCTAssertEqual(bundleIdentifiers[0], OICVSCodeBundleIdentifier) + XCTAssertEqual(bundleIdentifiers[1], OICVSCodeInsidersBundleIdentifier) + } + + func testFinderPathRejectsUnavailableURLs() { + XCTAssertNil(OICPathForFinderURL(nil)) + XCTAssertNil(OICPathForFinderURL(URL(string: "https://example.com"))) + } + + func testFinderPathResolution() throws { + let temporaryRoot = (NSTemporaryDirectory() as NSString) + .appendingPathComponent(UUID().uuidString) + let folderPath = (temporaryRoot as NSString).appendingPathComponent("Project") + let filePath = (folderPath as NSString).appendingPathComponent("README.md") + let packagePath = (temporaryRoot as NSString).appendingPathComponent("Example.app") + let directorySymlinkPath = (temporaryRoot as NSString).appendingPathComponent("LinkedProject") + let folderAliasURL = URL( + fileURLWithPath: (temporaryRoot as NSString).appendingPathComponent("Project Folder.alias") + ) + let fileAliasURL = URL( + fileURLWithPath: (temporaryRoot as NSString).appendingPathComponent("Project File.alias") + ) + let deletedTargetPath = (temporaryRoot as NSString).appendingPathComponent("Deleted Target.txt") + let brokenAliasURL = URL( + fileURLWithPath: (temporaryRoot as NSString).appendingPathComponent("Broken Target.alias") + ) + + let fileManager = FileManager.default + defer { + try? fileManager.removeItem(atPath: temporaryRoot) + } + + try fileManager.createDirectory( + atPath: folderPath, + withIntermediateDirectories: true, + attributes: nil + ) + try "test".write(toFile: filePath, atomically: true, encoding: .utf8) + try "delete me".write(toFile: deletedTargetPath, atomically: true, encoding: .utf8) + try fileManager.createDirectory( + atPath: packagePath, + withIntermediateDirectories: true, + attributes: nil + ) + try fileManager.createSymbolicLink( + atPath: directorySymlinkPath, + withDestinationPath: folderPath + ) + + let folderBookmark = try URL(fileURLWithPath: folderPath, isDirectory: true).bookmarkData( + options: .suitableForBookmarkFile, + includingResourceValuesForKeys: nil, + relativeTo: nil + ) + let fileBookmark = try URL(fileURLWithPath: filePath).bookmarkData( + options: .suitableForBookmarkFile, + includingResourceValuesForKeys: nil, + relativeTo: nil + ) + let deletedTargetBookmark = try URL(fileURLWithPath: deletedTargetPath).bookmarkData( + options: .suitableForBookmarkFile, + includingResourceValuesForKeys: nil, + relativeTo: nil + ) + try URL.writeBookmarkData(folderBookmark, to: folderAliasURL) + try URL.writeBookmarkData(fileBookmark, to: fileAliasURL) + try URL.writeBookmarkData(deletedTargetBookmark, to: brokenAliasURL) + try fileManager.removeItem(atPath: deletedTargetPath) + + let resolvedFolderAliasURL = try URL( + resolvingAliasFileAt: folderAliasURL, + options: .withoutUI + ) + let resolvedFileAliasURL = try URL( + resolvingAliasFileAt: fileAliasURL, + options: .withoutUI + ) + let resolvedFolderAliasPath = OICFileSystemPathForURL(resolvedFolderAliasURL) + let resolvedFileAliasParentPath = ( + OICFileSystemPathForURL(resolvedFileAliasURL) as NSString + ).deletingLastPathComponent + + XCTAssertEqual(OICPathForFinderURL(URL(fileURLWithPath: folderPath)), folderPath) + XCTAssertEqual(OICPathForFinderURL(URL(fileURLWithPath: filePath)), folderPath) + XCTAssertEqual(OICPathForFinderURL(URL(fileURLWithPath: packagePath)), temporaryRoot) + XCTAssertEqual( + OICPathForFinderURL(URL(fileURLWithPath: directorySymlinkPath)), + directorySymlinkPath + ) + XCTAssertEqual(OICPathForFinderURL(folderAliasURL), resolvedFolderAliasPath) + XCTAssertEqual(OICPathForFinderURL(fileAliasURL), resolvedFileAliasParentPath) + XCTAssertNil(OICPathForFinderURL(brokenAliasURL)) + XCTAssertNil( + OICPathForFinderURL( + URL(fileURLWithPath: (temporaryRoot as NSString).appendingPathComponent("missing")) + ) + ) + } +} diff --git a/main.m b/main.m deleted file mode 100755 index 8f7da68..0000000 --- a/main.m +++ /dev/null @@ -1,182 +0,0 @@ -// -// main.m -// Open in Code -// -// Created by Sertac Ozercan on 7/9/2016. -// Copyright Sertac Ozercan 2016. All rights reserved. -// - -#import -#import -#import "Finder.h" -#import "OpenInCodeCore.h" - -static NSString * const FinderAutomationPermissionMessage = @"Open the Automation privacy settings and allow Open in Code to control Finder, then try again."; - -@interface OICScriptingBridgeErrorHandler : NSObject { - NSError *_lastError; -} - -@property(nonatomic, retain) NSError *lastError; - -@end - -@implementation OICScriptingBridgeErrorHandler - -@synthesize lastError = _lastError; - -- (id)eventDidFail:(const AppleEvent *)event withError:(NSError *)error -{ - (void)event; - [self setLastError:error]; - return nil; -} - -- (void)dealloc -{ - [_lastError release]; - [super dealloc]; -} - -@end - -static void showErrorAlert(NSString *message, NSString *informativeText) -{ - [NSApplication sharedApplication]; - [NSApp setActivationPolicy:NSApplicationActivationPolicyAccessory]; - [NSApp activateIgnoringOtherApps:YES]; - - NSAlert *alert = [[[NSAlert alloc] init] autorelease]; - [alert setAlertStyle:NSAlertStyleCritical]; - [alert setMessageText:message]; - [alert setInformativeText:informativeText]; - [alert addButtonWithTitle:@"OK"]; - [alert runModal]; -} - -static NSString *pathToFrontFinderLocation(NSString **errorMessage) -{ - @try { - FinderApplication *finder = [SBApplication applicationWithBundleIdentifier:@"com.apple.Finder"]; - OICScriptingBridgeErrorHandler *errorHandler = [[[OICScriptingBridgeErrorHandler alloc] init] autorelease]; - [finder setDelegate:errorHandler]; - - FinderItem *target = [(NSArray *)[[finder selection] get] firstObject]; - - if (target == nil) { - target = [[[[finder FinderWindows] firstObject] target] get]; - } - - NSString *targetURLString = [target URL]; - if ([targetURLString length] == 0) { - if (errorMessage != NULL) { - if ([errorHandler lastError] != nil) { - *errorMessage = FinderAutomationPermissionMessage; - } else { - *errorMessage = @"Open a Finder window for a local folder and try again."; - } - } - return nil; - } - - NSString *path = OICPathForFinderURL([NSURL URLWithString:targetURLString]); - if ([path length] == 0 && errorMessage != NULL) { - *errorMessage = @"The selected Finder item does not have an accessible local path."; - } - - return path; - } - @catch (NSException *exception) { - if (errorMessage != NULL) { - *errorMessage = FinderAutomationPermissionMessage; - } - return nil; - } -} - -static BOOL openPathInPreferredVSCode(NSString *path, NSString **errorMessage) -{ - if ([path length] == 0) { - if (errorMessage != NULL) { - *errorMessage = @"No folder path was available to open."; - } - return NO; - } - - NSURL *folderURL = [NSURL fileURLWithPath:path isDirectory:YES]; - NSWorkspace *workspace = [NSWorkspace sharedWorkspace]; - for (NSString *bundleIdentifier in OICPreferredVSCodeBundleIdentifiers()) { - NSURL *applicationURL = [workspace URLForApplicationWithBundleIdentifier:bundleIdentifier]; - if (applicationURL == nil) { - continue; - } - - NSWorkspaceOpenConfiguration *configuration = [NSWorkspaceOpenConfiguration configuration]; - [configuration setActivates:YES]; - [configuration setPromptsUserIfNeeded:YES]; - [configuration setAllowsRunningApplicationSubstitution:NO]; - - dispatch_semaphore_t completion = dispatch_semaphore_create(0); - __block BOOL opened = NO; - __block NSString *attemptError = nil; - - [workspace openURLs:@[folderURL] - withApplicationAtURL:applicationURL - configuration:configuration - completionHandler:^(NSRunningApplication *application, NSError *error) { - opened = application != nil && error == nil; - if (error != nil) { - attemptError = [[error localizedDescription] copy]; - } - dispatch_semaphore_signal(completion); - }]; - - dispatch_semaphore_wait(completion, DISPATCH_TIME_FOREVER); -#if !OS_OBJECT_USE_OBJC - dispatch_release(completion); -#endif - - if (opened) { - [attemptError release]; - return YES; - } - - if (errorMessage != NULL) { - if ([attemptError length] > 0) { - *errorMessage = [attemptError autorelease]; - attemptError = nil; - } else { - *errorMessage = @"Visual Studio Code could not open the selected folder."; - } - } - - [attemptError release]; - return NO; - } - - if (errorMessage != NULL) { - *errorMessage = @"Install Visual Studio Code or Visual Studio Code Insiders, then try again."; - } - return NO; -} - -int main(int argc, char *argv[]) -{ - @autoreleasepool { - NSString *finderError = nil; - NSString *path = pathToFrontFinderLocation(&finderError); - - if ([path length] == 0) { - showErrorAlert(@"Couldn’t read the Finder location", finderError ?: @"Open a Finder window and try again."); - return 1; - } - - NSString *launchError = nil; - if (!openPathInPreferredVSCode(path, &launchError)) { - showErrorAlert(@"Couldn’t open Visual Studio Code", launchError ?: @"The application could not be launched."); - return 2; - } - - return 0; - } -} diff --git a/scripts/build-app.sh b/scripts/build-app.sh new file mode 100755 index 0000000..38b52be --- /dev/null +++ b/scripts/build-app.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/.." && pwd)" +configuration="${1:-release}" +case "$configuration" in + debug|release) ;; + *) + echo "Usage: $0 [debug|release]" >&2 + exit 2 + ;; +esac + +app_name="Open in Code" +product_name="OpenInCode" +bundle_id="com.sertacozercan.openincode" +build_root="$repo_root/.build/app" +app_bundle="$build_root/$app_name.app" +signing_mode="${OPEN_IN_CODE_SIGNING:-adhoc}" +marketing_version="${MARKETING_VERSION:-$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$repo_root/Info.plist")}" +build_number="${BUILD_NUMBER:-$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$repo_root/Info.plist")}" + +architectures=() +if [[ -n "${ARCHES:-}" ]]; then + IFS=' ' read -r -a architectures <<< "$ARCHES" +else + architectures=("$(uname -m)") +fi + +verify_architectures() { + local binary="$1" + local actual + actual="$(lipo -archs "$binary")" + + for architecture in "${architectures[@]}"; do + if [[ "$actual" != *"$architecture"* ]]; then + echo "ERROR: $binary is missing $architecture (contains: $actual)" >&2 + exit 1 + fi + done +} + +build_architecture() { + local architecture="$1" + local scratch_path="$build_root/swiftpm/$architecture" + local binary_directory + local source_binary + local staged_directory="$build_root/arch-products/$architecture" + + echo " → Building $product_name for $architecture" + swift build \ + --package-path "$repo_root" \ + --scratch-path "$scratch_path" \ + --configuration "$configuration" \ + --arch "$architecture" \ + --product "$product_name" + + binary_directory="$(swift build \ + --package-path "$repo_root" \ + --scratch-path "$scratch_path" \ + --configuration "$configuration" \ + --arch "$architecture" \ + --show-bin-path)" + source_binary="$binary_directory/$product_name" + + if [[ ! -f "$source_binary" ]]; then + echo "ERROR: SwiftPM did not produce $source_binary" >&2 + exit 1 + fi + + mkdir -p "$staged_directory" + cp "$source_binary" "$staged_directory/$product_name" + chmod +x "$staged_directory/$product_name" +} + +install_executable() { + local destination="$1" + local binaries=() + local architecture + + for architecture in "${architectures[@]}"; do + binaries+=("$build_root/arch-products/$architecture/$product_name") + done + + if [[ ${#binaries[@]} -eq 1 ]]; then + cp "${binaries[0]}" "$destination" + else + lipo -create "${binaries[@]}" -output "$destination" + fi + chmod +x "$destination" + verify_architectures "$destination" +} + +compile_app_icon() { + local resources_directory="$1" + local partial_info_plist="$build_root/AppIconPartialInfo.plist" + + xcrun actool "$repo_root/vscode.icon" \ + --compile "$resources_directory" \ + --notices --warnings --errors \ + --output-partial-info-plist "$partial_info_plist" \ + --app-icon vscode \ + --enable-on-demand-resources NO \ + --development-region English \ + --target-device mac \ + --minimum-deployment-target 12.0 \ + --platform macosx + + [[ -f "$resources_directory/Assets.car" ]] || { + echo "ERROR: actool did not produce Assets.car" >&2 + exit 1 + } + [[ -f "$resources_directory/vscode.icns" ]] || { + echo "ERROR: actool did not produce vscode.icns" >&2 + exit 1 + } +} + +echo "Building $app_name ($configuration) for ${architectures[*]}" +rm -rf "$build_root" +mkdir -p "$build_root" + +for architecture in "${architectures[@]}"; do + build_architecture "$architecture" +done + +mkdir -p \ + "$app_bundle/Contents/MacOS" \ + "$app_bundle/Contents/Resources/English.lproj" + +install_executable "$app_bundle/Contents/MacOS/$app_name" +compile_app_icon "$app_bundle/Contents/Resources" +cp "$repo_root/English.lproj/InfoPlist.strings" \ + "$app_bundle/Contents/Resources/English.lproj/InfoPlist.strings" +cp "$repo_root/Info.plist" "$app_bundle/Contents/Info.plist" +echo -n "APPL????" > "$app_bundle/Contents/PkgInfo" + +/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier $bundle_id" "$app_bundle/Contents/Info.plist" +/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $marketing_version" "$app_bundle/Contents/Info.plist" +/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $build_number" "$app_bundle/Contents/Info.plist" +plutil -lint "$app_bundle/Contents/Info.plist" >/dev/null + +xattr -cr "$app_bundle" 2>/dev/null || true +find "$app_bundle" -name '._*' -delete 2>/dev/null || true + +case "$signing_mode" in + unsigned|none) + echo "Leaving app unsigned" + ;; + adhoc) + echo "Applying ad-hoc signature" + codesign --force --options runtime --timestamp=none \ + --entitlements "$repo_root/Open in Code.entitlements" \ + --sign - "$app_bundle" + codesign --verify --deep --strict --verbose=2 "$app_bundle" + ;; + *) + echo "ERROR: OPEN_IN_CODE_SIGNING must be 'adhoc', 'unsigned', or 'none'" >&2 + exit 2 + ;; +esac + +echo "Built $app_bundle" +echo "Architectures: $(lipo -archs "$app_bundle/Contents/MacOS/$app_name")" +echo "Version: $marketing_version ($build_number)" diff --git a/scripts/test.sh b/scripts/test.sh index 37d3e1c..e888342 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -5,16 +5,10 @@ repo_root="$(cd "$(dirname "$0")/.." && pwd)" build_dir="$(mktemp -d "${TMPDIR:-/tmp}/OpenInCodeTests.XXXXXX")" trap 'rm -rf "$build_dir"' EXIT -xcrun clang \ - -fno-objc-arc \ - -Wall \ - -Wextra \ - -Werror \ - -framework Foundation \ - -I"$repo_root" \ - "$repo_root/OpenInCodeCore.m" \ - "$repo_root/Tests/OpenInCodeCoreTests.m" \ - -o "$build_dir/OpenInCodeCoreTests" +swift test \ + --package-path "$repo_root" \ + --scratch-path "$build_dir/swiftpm" \ + -Xswiftc -warnings-as-errors cask_path="$build_dir/open-in-code.rb" "$repo_root/scripts/render-homebrew-cask.sh" \ @@ -24,5 +18,3 @@ cask_path="$build_dir/open-in-code.rb" ruby -c "$cask_path" >/dev/null grep -q 'version "1.2.3"' "$cask_path" grep -q 'app "Open in Code.app"' "$cask_path" - -"$build_dir/OpenInCodeCoreTests"