diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..5d46ce4 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: minsang-alt diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 0000000..e17a9fc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,57 @@ +name: Bug Report +description: Report a bug in ContextSwitcher +labels: ["bug"] +body: + - type: checkboxes + id: checklist + attributes: + label: Before submitting + options: + - label: I have read the [README](https://github.com/minsang-alt/contextSwitcher#readme) + required: true + - label: I have searched existing issues for duplicates + required: true + + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: Describe the steps to reproduce the issue + placeholder: | + 1. Open ContextSwitcher + 2. Create a workspace with... + 3. Switch to... + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Actual result + description: What happened? Include screenshots or screen recordings if possible. + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected result + description: What did you expect to happen? + validations: + required: true + + - type: input + id: version + attributes: + label: ContextSwitcher version + placeholder: "e.g., 1.2.0" + validations: + required: true + + - type: input + id: macos + attributes: + label: macOS version + placeholder: "e.g., macOS 15.2 Sequoia" + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..61e4303 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Questions & Discussions + url: https://github.com/minsang-alt/contextSwitcher/discussions + about: Ask questions and discuss ideas diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml new file mode 100644 index 0000000..629a9d3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -0,0 +1,36 @@ +name: Feature Request +description: Suggest a new feature for ContextSwitcher +labels: ["enhancement"] +body: + - type: checkboxes + id: checklist + attributes: + label: Before submitting + options: + - label: I have read the [README](https://github.com/minsang-alt/contextSwitcher#readme) and checked existing features + required: true + - label: I have searched existing issues and feature requests + required: true + + - type: textarea + id: problem + attributes: + label: Problem + description: What problem does this feature solve? + placeholder: "I'm always frustrated when..." + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Proposed solution + description: Describe the feature you'd like + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Any alternative solutions or workarounds you've tried? diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..fc90573 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,18 @@ +## Summary + + + +## Changes + +- + +## Test Plan + +- [ ] Tested on macOS 14+ +- [ ] Accessibility permission works correctly +- [ ] Workspace switching functions as expected +- [ ] No regressions in existing features + +## Related Issues + + diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..e6b4540 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,19 @@ +name: Build + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + + - name: Build Release + run: swift build -c release 2>&1 + + - name: Build Debug + run: swift build 2>&1 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..0795a52 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,33 @@ +name: PR Title Check + +on: + pull_request: + types: [opened, edited, synchronize] + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: amannn/action-semantic-pull-request@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + types: | + feat + fix + docs + chore + refactor + ci + test + style + perf + scopes: | + workspace + shortcuts + hud + menu-bar + capture + accessibility + ui + requireScope: false diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..0c4ebf7 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,28 @@ +name: Code Quality + +on: + pull_request: + branches: [main] + +jobs: + swiftlint: + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + + - name: Install SwiftLint + run: brew install swiftlint + + - name: Run SwiftLint + run: swiftlint lint --strict --reporter github-actions-logging + + swiftformat: + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + + - name: Install SwiftFormat + run: brew install swiftformat + + - name: Check SwiftFormat + run: swiftformat --lint . diff --git a/.gitignore b/.gitignore index 505a333..ab47cbd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,30 @@ +# Build .build/ +DerivedData/ + +# Swift Package Manager .swiftpm/ + +# Xcode +*.xcodeproj +xcuserdata/ + +# IDE .idea/ +.vscode/ + +# Claude Code .claude/ + +# macOS .DS_Store -*.xcodeproj -xcuserdata/ -DerivedData/ + +# Binary artifacts *.mov *.dmg + +# Fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/ +fastlane/test_output/ diff --git a/.swiftformat b/.swiftformat new file mode 100644 index 0000000..be40858 --- /dev/null +++ b/.swiftformat @@ -0,0 +1,12 @@ +--swiftversion 5.9 +--indent 4 +--maxwidth 150 +--wraparguments before-first +--wrapcollections before-first +--closingparen balanced +--self remove +--stripunusedargs closure-only +--ifdef no-indent +--disable redundantRawValues +--disable blankLinesAtStartOfScope +--exclude .build,.swiftpm,DerivedData diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..0dde6c8 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,37 @@ +disabled_rules: + - trailing_whitespace + - void_return + - nesting + - identifier_name + +opt_in_rules: + - empty_count + - closure_spacing + - contains_over_filter_count + - flatmap_over_map_reduce + - first_where + +excluded: + - .build + - .swiftpm + - DerivedData + +line_length: + warning: 150 + error: 200 + +file_length: + warning: 500 + error: 1000 + +type_body_length: + warning: 300 + error: 500 + +function_body_length: + warning: 60 + error: 100 + +cyclomatic_complexity: + warning: 15 + error: 25 diff --git a/Brewfile b/Brewfile new file mode 100644 index 0000000..84e4a39 --- /dev/null +++ b/Brewfile @@ -0,0 +1,2 @@ +brew "swiftlint" +brew "swiftformat" diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..166b5ff --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,59 @@ +# ContextSwitcher + +## Project Overview +macOS menu bar utility for managing development contexts by hiding/showing app windows. +- **Language:** Swift 6.0 (Swift tools version) +- **Platform:** macOS 14.0+ (Sonoma) +- **Build System:** Swift Package Manager +- **Bundle ID:** com.minsang.ContextSwitcher +- **License:** GPL-3.0 +- **GitHub:** `minsang-alt/contextSwitcher` (camelCase) + +## Architecture +``` +ContextSwitcher/ +├── ContextSwitcherApp.swift # @main entry point (SwiftUI) +├── Models/ # Data models (KeyShortcut, WindowIdentifier, WorkspaceConfiguration) +├── Services/ # Core business logic +│ ├── AccessibilityService # macOS Accessibility API wrapper +│ ├── ShortcutService # Global keyboard shortcuts (CGEvent) +│ ├── WorkspaceSwitchService # Workspace switching logic +│ └── WorkspaceStore # Persistence (JSON-based) +├── Views/ # SwiftUI views +├── Panel/ # Floating HUD panel (AppKit) +├── Utilities/ # Helpers (IntelliJ title parser) +└── Resources/ # Info.plist, AppIcon.icns +``` + +## Build & Run +```bash +./scripts/install.sh # Build + install to /Applications +./scripts/release.sh # Build + DMG + GitHub release +swift build -c release # Release build only +swift build # Debug build only +brew bundle # Install dev dependencies (swiftlint, swiftformat) +``` + +## CI/CD +- `.github/workflows/build.yml` — Build check on push to main + PRs +- `.github/workflows/quality.yml` — SwiftLint + SwiftFormat on PRs +- `.github/workflows/pr.yml` — Conventional Commits PR title validation +- `fastlane/Fastfile` — Release automation (build → bundle → DMG → optional notarization) + +## Code Quality +- **SwiftLint** config: `.swiftlint.yml` (line length 150, file length 500) +- **SwiftFormat** config: `.swiftformat` (4-space indent, max width 150) +- Run before committing: `swiftlint lint && swiftformat --lint .` + +## Conventions +- Commit messages follow Conventional Commits: `feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `ci:` +- PR titles must follow the same convention +- Issue templates enforce structured bug reports and feature requests +- Menu bar app (LSUIElement = true, no dock icon) + +## Important Notes +- Accessibility permission resets after each rebuild (re-toggle in System Settings) +- Window identification: bundleID + windowID (stable across tab switches) +- Browser profiles extracted from window titles (Chrome, Brave, Edge) +- JetBrains IDE project names parsed from window titles +- Homebrew formula at `HomebrewFormula/contextswitcher.rb` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 74c9e78..09d924e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,26 +5,72 @@ Thanks for your interest in contributing! ## Getting Started 1. Fork and clone the repository -2. Build from source: `./scripts/install.sh` -3. Grant Accessibility permission in **System Settings → Privacy & Security → Accessibility** +2. Install dev dependencies: `brew bundle` +3. Build from source: `./scripts/install.sh` +4. Grant Accessibility permission in **System Settings → Privacy & Security → Accessibility** ## Development -- **Language:** Swift 5.9+ -- **Platform:** macOS 14+ +- **Language:** Swift 6.0 +- **Platform:** macOS 14+ (Sonoma) - **Build system:** Swift Package Manager +- **Code style:** Enforced by SwiftLint + SwiftFormat + +### Code Quality + +Before submitting, run: + +```bash +swiftlint lint +swiftformat --lint . +``` + +These checks also run automatically in CI on pull requests. ## Submitting Changes 1. Create a feature branch from `main` 2. Make your changes -3. Test thoroughly on macOS -4. Submit a pull request with a clear description +3. Run lint checks +4. Test thoroughly on macOS +5. Submit a pull request with a clear description + +### Commit & PR Convention + +PR titles must follow [Conventional Commits](https://www.conventionalcommits.org/): + +- `feat:` — New features +- `fix:` — Bug fixes +- `docs:` — Documentation changes +- `chore:` — Maintenance tasks +- `refactor:` — Code refactoring +- `ci:` — CI/CD changes +- `test:` — Test changes +- `style:` — Code style changes +- `perf:` — Performance improvements + +Examples: +- `feat: add workspace reordering via drag and drop` +- `fix: resolve window matching issue with Chrome profiles` ## Reporting Issues -- Use [GitHub Issues](https://github.com/minsang-alt/contextSwitcher/issues) -- Include your macOS version and steps to reproduce +Use [GitHub Issues](https://github.com/minsang-alt/contextSwitcher/issues) with the provided templates: + +- **Bug Report** — Include macOS version, steps to reproduce, and screenshots +- **Feature Request** — Describe the problem and proposed solution + +## Architecture Overview + +``` +ContextSwitcher/ +├── Models/ # Data models +├── Services/ # Core business logic (Accessibility, Shortcuts, Workspace) +├── Views/ # SwiftUI views +├── Panel/ # AppKit floating panel +├── Utilities/ # Helpers +└── Resources/ # Info.plist, AppIcon +``` ## License diff --git a/ContextSwitcher/Models/KeyShortcut.swift b/ContextSwitcher/Models/KeyShortcut.swift index dd38ce1..224a7d3 100644 --- a/ContextSwitcher/Models/KeyShortcut.swift +++ b/ContextSwitcher/Models/KeyShortcut.swift @@ -1,10 +1,10 @@ -import Carbon import AppKit +import Carbon /// 키보드 단축키를 표현하는 모델 struct KeyShortcut: Codable, Equatable, Hashable { let keyCode: UInt16 - let modifiers: UInt32 // Carbon modifier flags + let modifiers: UInt32 // Carbon modifier flags /// 사람이 읽을 수 있는 단축키 문자열 (예: "⌃1", "⌥⇧A") var displayString: String { @@ -25,7 +25,7 @@ struct KeyShortcut: Codable, Equatable, Hashable { if cgFlags.contains(.maskAlternate) { carbonMods |= UInt32(optionKey) } if cgFlags.contains(.maskShift) { carbonMods |= UInt32(shiftKey) } if cgFlags.contains(.maskCommand) { carbonMods |= UInt32(cmdKey) } - self.modifiers = carbonMods + modifiers = carbonMods } /// NSEvent의 modifier flags에서 KeyShortcut 생성 @@ -36,7 +36,7 @@ struct KeyShortcut: Codable, Equatable, Hashable { if nsFlags.contains(.option) { carbonMods |= UInt32(optionKey) } if nsFlags.contains(.shift) { carbonMods |= UInt32(shiftKey) } if nsFlags.contains(.command) { carbonMods |= UInt32(cmdKey) } - self.modifiers = carbonMods + modifiers = carbonMods } /// modifier가 1개 이상 포함되어 있는지 확인 diff --git a/ContextSwitcher/Models/WindowIdentifier.swift b/ContextSwitcher/Models/WindowIdentifier.swift index 0ed5c3a..163e135 100644 --- a/ContextSwitcher/Models/WindowIdentifier.swift +++ b/ContextSwitcher/Models/WindowIdentifier.swift @@ -2,7 +2,9 @@ import Foundation /// 워크스페이스에 포함된 앱+창을 식별하기 위한 패턴 struct WindowIdentifier: Codable, Hashable, Identifiable { - var id: String { windowID ?? "\(bundleIdentifier):\(titlePattern)" } + var id: String { + windowID ?? "\(bundleIdentifier):\(titlePattern)" + } /// 앱의 Bundle ID (예: "com.jetbrains.intellij") let bundleIdentifier: String @@ -33,6 +35,6 @@ struct WindowIdentifier: Codable, Hashable, Identifiable { // 2) 제목 패턴 매칭 if titlePattern.isEmpty { return true } return window.windowTitle.localizedCaseInsensitiveContains(titlePattern) || - window.stableIdentityName.localizedCaseInsensitiveContains(titlePattern) + window.stableIdentityName.localizedCaseInsensitiveContains(titlePattern) } } diff --git a/ContextSwitcher/Models/WorkspaceConfiguration.swift b/ContextSwitcher/Models/WorkspaceConfiguration.swift index 1615449..0036859 100644 --- a/ContextSwitcher/Models/WorkspaceConfiguration.swift +++ b/ContextSwitcher/Models/WorkspaceConfiguration.swift @@ -24,7 +24,7 @@ final class WorkspaceConfiguration: Identifiable, Codable, ObservableObject { self.isActive = isActive self.displayOrder = displayOrder self.shortcut = shortcut - self.createdAt = Date() + createdAt = Date() } // MARK: - Codable (Published 프로퍼티 수동 구현) diff --git a/ContextSwitcher/Panel/FloatingPanel.swift b/ContextSwitcher/Panel/FloatingPanel.swift index 010eccd..a16127d 100644 --- a/ContextSwitcher/Panel/FloatingPanel.swift +++ b/ContextSwitcher/Panel/FloatingPanel.swift @@ -37,7 +37,12 @@ final class FloatingPanel: NSPanel { animationBehavior = .utilityWindow } - // 포커스를 훔치지 않음 - override var canBecomeKey: Bool { false } - override var canBecomeMain: Bool { false } + /// 포커스를 훔치지 않음 + override var canBecomeKey: Bool { + false + } + + override var canBecomeMain: Bool { + false + } } diff --git a/ContextSwitcher/Panel/VisualEffectBackground.swift b/ContextSwitcher/Panel/VisualEffectBackground.swift index 6a87209..8db9117 100644 --- a/ContextSwitcher/Panel/VisualEffectBackground.swift +++ b/ContextSwitcher/Panel/VisualEffectBackground.swift @@ -1,5 +1,5 @@ -import SwiftUI import AppKit +import SwiftUI /// NSVisualEffectView를 SwiftUI에서 사용하기 위한 래퍼 struct VisualEffectBackground: NSViewRepresentable { @@ -30,7 +30,7 @@ struct VisualEffectBackground: NSViewRepresentable { extension View { func hudBackground(cornerRadius: CGFloat = 12) -> some View { - self.background( + background( VisualEffectBackground(material: .hudWindow) .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) ) diff --git a/ContextSwitcher/Services/AccessibilityService.swift b/ContextSwitcher/Services/AccessibilityService.swift index a83b887..7d9bc70 100644 --- a/ContextSwitcher/Services/AccessibilityService.swift +++ b/ContextSwitcher/Services/AccessibilityService.swift @@ -14,18 +14,22 @@ struct DiscoveredWindow: Identifiable { let windowIndex: Int /// bundleIdentifier + PID + 윈도우 인덱스 기반 ID (탭/파일 전환에 영향 안 받음) - var id: String { "\(bundleIdentifier):\(pid):\(windowIndex)" } + var id: String { + "\(bundleIdentifier):\(pid):\(windowIndex)" + } /// 탭/파일 전환에도 안정적인 식별 이름 (저장 및 매칭용) var stableIdentityName: String { // Chrome 계열: "페이지제목 - Chrome - 프로필명" → 프로필명 추출 if bundleIdentifier == "com.google.Chrome" || - bundleIdentifier == "com.google.Chrome.canary" || - bundleIdentifier == "com.brave.Browser" || - bundleIdentifier == "com.microsoft.edgemac" { + bundleIdentifier == "com.google.Chrome.canary" || + bundleIdentifier == "com.brave.Browser" || + bundleIdentifier == "com.microsoft.edgemac" + { if let range = windowTitle.range(of: " - Chrome - ", options: .backwards) ?? - windowTitle.range(of: " - Brave - ", options: .backwards) ?? - windowTitle.range(of: " - Edge - ", options: .backwards) { + windowTitle.range(of: " - Brave - ", options: .backwards) ?? + windowTitle.range(of: " - Edge - ", options: .backwards) + { return String(windowTitle[range.upperBound...]) } } @@ -47,8 +51,9 @@ struct DiscoveredWindow: Identifiable { // "프로필명 · 현재탭제목" 또는 "프로젝트명 · 현재파일" // 현재 컨텍스트 부분 추출 if let range = windowTitle.range(of: " - Chrome - ", options: .backwards) ?? - windowTitle.range(of: " - Brave - ", options: .backwards) ?? - windowTitle.range(of: " - Edge - ", options: .backwards) { + windowTitle.range(of: " - Brave - ", options: .backwards) ?? + windowTitle.range(of: " - Edge - ", options: .backwards) + { let tabTitle = String(windowTitle[.. Unmanaged? { // 이벤트 탭이 비활성화되면 재활성화 if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { - if let userInfo = userInfo { + if let userInfo { let service = Unmanaged.fromOpaque(userInfo).takeUnretainedValue() if let tap = service.eventTap { CGEvent.tapEnable(tap: tap, enable: true) @@ -89,14 +89,14 @@ private func shortcutEventCallback( return Unmanaged.passRetained(event) } - guard type == .keyDown, let userInfo = userInfo else { + guard type == .keyDown, let userInfo else { return Unmanaged.passRetained(event) } let service = Unmanaged.fromOpaque(userInfo).takeUnretainedValue() if service.handleKeyEvent(event) { - return nil // 이벤트 소비 (다른 앱에 전달하지 않음) + return nil // 이벤트 소비 (다른 앱에 전달하지 않음) } return Unmanaged.passRetained(event) } diff --git a/ContextSwitcher/Services/WorkspaceStore.swift b/ContextSwitcher/Services/WorkspaceStore.swift index 609044f..5c2dbe0 100644 --- a/ContextSwitcher/Services/WorkspaceStore.swift +++ b/ContextSwitcher/Services/WorkspaceStore.swift @@ -1,5 +1,5 @@ -import Foundation import Combine +import Foundation /// JSON 파일 기반 워크스페이스 저장소 final class WorkspaceStore: ObservableObject { diff --git a/ContextSwitcher/Views/CaptureView.swift b/ContextSwitcher/Views/CaptureView.swift index c408a76..e137ab3 100644 --- a/ContextSwitcher/Views/CaptureView.swift +++ b/ContextSwitcher/Views/CaptureView.swift @@ -13,7 +13,9 @@ struct CaptureView: View { /// 신규 생성 시 임시로 사용할 워크스페이스 ID @State private var pendingWorkspaceID = UUID() - private var isEditing: Bool { editingWorkspace != nil } + private var isEditing: Bool { + editingWorkspace != nil + } var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -108,7 +110,7 @@ struct CaptureView: View { } label: { HStack(spacing: 6) { Image(systemName: allSelected ? "checkmark.square.fill" : - noneSelected ? "square" : "minus.square.fill") + noneSelected ? "square" : "minus.square.fill") .foregroundColor(noneSelected ? .secondary : .accentColor) Text(group.appName) .font(.system(size: 13, weight: .medium)) @@ -130,7 +132,7 @@ struct CaptureView: View { } label: { HStack(spacing: 6) { Image(systemName: selectedWindowIDs.contains(window.id) ? - "checkmark.square.fill" : "square") + "checkmark.square.fill" : "square") .foregroundColor(selectedWindowIDs.contains(window.id) ? .accentColor : .secondary) Text(window.displayName.isEmpty ? "(제목 없음)" : window.displayName) .font(.system(size: 11)) @@ -228,7 +230,7 @@ struct CaptureView: View { "com.google.Chrome", "com.google.Chrome.canary", "com.brave.Browser", - "com.microsoft.edgemac" + "com.microsoft.edgemac", ] var identifiers: [WindowIdentifier] = [] @@ -239,7 +241,7 @@ struct CaptureView: View { let total = totalCounts[bundleID] ?? 0 let selected = selectedCounts[bundleID] ?? 0 - if processedBundleIDs.contains(bundleID) && selected == total { + if processedBundleIDs.contains(bundleID), selected == total { continue } @@ -247,11 +249,10 @@ struct CaptureView: View { identifiers.append(WindowIdentifier(bundleIdentifier: bundleID, titlePattern: "")) processedBundleIDs.insert(bundleID) } else { - let pattern: String - if chromiumBundleIDs.contains(bundleID) { - pattern = window.windowTitle.isEmpty ? window.stableIdentityName : window.windowTitle + let pattern: String = if chromiumBundleIDs.contains(bundleID) { + window.windowTitle.isEmpty ? window.stableIdentityName : window.windowTitle } else { - pattern = window.stableIdentityName + window.stableIdentityName } if !pattern.isEmpty { diff --git a/ContextSwitcher/Views/ShortcutRecorderView.swift b/ContextSwitcher/Views/ShortcutRecorderView.swift index 38017ac..e12dbcb 100644 --- a/ContextSwitcher/Views/ShortcutRecorderView.swift +++ b/ContextSwitcher/Views/ShortcutRecorderView.swift @@ -1,5 +1,5 @@ -import SwiftUI import AppKit +import SwiftUI /// 키보드 단축키를 녹화하는 SwiftUI 뷰 struct ShortcutRecorderView: View { @@ -28,7 +28,7 @@ struct ShortcutRecorderView: View { .font(.system(size: 11)) .buttonStyle(.plain) .foregroundStyle(.secondary) - } else if let shortcut = shortcut { + } else if let shortcut { Text(shortcut.displayString) .font(.system(size: 12, design: .rounded)) .padding(.horizontal, 8) @@ -65,7 +65,7 @@ struct ShortcutRecorderView: View { // 로컬 이벤트 모니터로 키 입력 캡처 (창이 포커스 상태이므로 local 사용) monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - let hasModifier = !flags.intersection([.control, .option, .shift, .command]).isEmpty + let hasModifier = !flags.isDisjoint(with: [.control, .option, .shift, .command]) // modifier가 없으면 무시 (ESC는 녹화 취소) if event.keyCode == 0x35 { // ESC @@ -78,13 +78,13 @@ struct ShortcutRecorderView: View { let recorded = KeyShortcut(keyCode: event.keyCode, nsFlags: flags) shortcut = recorded stopRecording() - return nil // 이벤트 소비 + return nil // 이벤트 소비 } } private func stopRecording() { isRecording = false - if let monitor = monitor { + if let monitor { NSEvent.removeMonitor(monitor) } monitor = nil diff --git a/HomebrewFormula/contextswitcher.rb b/HomebrewFormula/contextswitcher.rb new file mode 100644 index 0000000..85ce745 --- /dev/null +++ b/HomebrewFormula/contextswitcher.rb @@ -0,0 +1,19 @@ +cask "contextswitcher" do + version "1.2.0" + sha256 :no_check + + url "https://github.com/minsang-alt/contextSwitcher/releases/download/v#{version}/ContextSwitcher-#{version}-arm64.dmg" + name "ContextSwitcher" + desc "macOS menu bar utility for managing development contexts" + homepage "https://github.com/minsang-alt/contextSwitcher" + + depends_on macos: ">= :sonoma" + depends_on arch: :arm64 + + app "ContextSwitcher.app" + + zap trash: [ + "~/Library/Application Support/ContextSwitcher", + "~/Library/Preferences/com.minsang.ContextSwitcher.plist", + ] +end diff --git a/Package.swift b/Package.swift index d281a52..c26bc47 100644 --- a/Package.swift +++ b/Package.swift @@ -8,6 +8,6 @@ let package = Package( .executableTarget( name: "ContextSwitcher", path: "ContextSwitcher" - ) + ), ] ) diff --git a/README.ko.md b/README.ko.md index b95f221..46c861c 100644 --- a/README.ko.md +++ b/README.ko.md @@ -4,11 +4,24 @@

ContextSwitcher

+

+ Build + Release + License + Platform + Swift +

+

English | 한국어

-

앱 윈도우를 숨기거나 표시하여 여러 개발 컨텍스트를 관리하는 macOS 메뉴바 유틸리티입니다.

+

+ 여러 프로젝트를 오가는 개발자를 위한 가벼운 macOS 메뉴바 유틸리티.
+ 윈도우 배치를 워크스페이스로 저장하고 즉시 전환하세요. +

+ +--- ## 데모 @@ -16,26 +29,51 @@ https://github.com/user-attachments/assets/010d90dd-5c32-4f04-9d9f-1386d15954ed ## 주요 기능 -- 현재 윈도우 배치를 이름이 있는 워크스페이스로 저장 -- 메뉴바에서 워크스페이스 간 즉시 전환 -- **글로벌 키보드 단축키**로 어떤 앱에서든 워크스페이스 전환 -- 개별 윈도우를 선택적으로 표시/숨김 (예: 특정 IntelliJ 프로젝트만) -- 빠른 접근을 위한 플로팅 HUD 패널 -- 한 번의 클릭으로 숨긴 모든 앱 복원 +- **워크스페이스 관리** — 현재 윈도우 배치를 이름 있는 워크스페이스로 저장 +- **즉시 전환** — 메뉴바에서 워크스페이스 간 즉시 전환 +- **글로벌 단축키** — 어떤 앱에서든 키보드 단축키로 워크스페이스 전환 +- **윈도우 단위 제어** — 개별 윈도우를 선택적으로 표시/숨김 (예: 특정 IntelliJ 프로젝트) +- **플로팅 HUD** — 빠른 접근을 위한 플로팅 패널 +- **전체 복원** — 한 번의 클릭으로 숨긴 모든 앱 복원 + +## 설계 철학 + +ContextSwitcher는 명확한 원칙을 따릅니다: + +- **성능** — 애니메이션 지연 없는 즉시 전환 +- **단순함** — 컨텍스트 전환 하나만 잘합니다 +- **비침습적** — 기존 워크플로를 방해하지 않습니다 +- **투명함** — 메뉴바에 살며, 방해하지 않습니다 + +### 동작 방식 -## 다운로드 +ContextSwitcher는 macOS Accessibility API를 사용하여 **앱 윈도우를 숨기고 표시**합니다. 워크스페이스로 전환하면, 해당 워크스페이스에 속하지 않는 앱은 숨겨지고 속하는 앱이 전면으로 나옵니다. 가상 데스크톱과는 근본적으로 다릅니다 — 윈도우는 같은 Space에 그대로 있습니다. + +### 왜 타일링이 없나요? + +ContextSwitcher는 의도적으로 윈도우 위치나 타일링을 관리하지 **않습니다**. 그건 전용 도구(Rectangle, Magnet, yabai)가 훨씬 잘합니다. ContextSwitcher는 **어떤 앱이 보이는지**에만 집중하여, 어떤 윈도우 매니저와도 조합할 수 있습니다. + +## 설치 + +### 바이너리 다운로드 | 플랫폼 | 다운로드 | |--------|----------| -| macOS 14+ (Apple Silicon) | [ContextSwitcher-1.1.0-arm64.dmg](https://github.com/minsang-alt/contextSwitcher/releases/latest/download/ContextSwitcher-1.1.0-arm64.dmg) | +| macOS 14+ (Apple Silicon) | [ContextSwitcher-1.2.0-arm64.dmg](https://github.com/minsang-alt/contextSwitcher/releases/latest/download/ContextSwitcher-1.2.0-arm64.dmg) | > DMG를 열고 `ContextSwitcher.app`을 `/Applications`로 드래그하세요. > -> **macOS Gatekeeper 경고 시:** 서명되지 않은 앱이라 경고가 뜰 수 있습니다. 아래 중 하나를 실행하세요. +> **macOS Gatekeeper 경고:** 아직 공증되지 않은 앱이라 경고가 뜰 수 있습니다: > - Finder에서 `ContextSwitcher.app`을 **우클릭 → 열기** -> - 또는 터미널에서: `xattr -cr /Applications/ContextSwitcher.app` +> - 또는: `xattr -cr /Applications/ContextSwitcher.app` -## 소스에서 빌드 +### Homebrew (준비 중) + +```bash +brew install --cask minsang-alt/tap/contextswitcher +``` + +### 소스에서 빌드 ```bash git clone https://github.com/minsang-alt/contextSwitcher.git @@ -43,6 +81,8 @@ cd ContextSwitcher ./scripts/install.sh ``` +**요구사항:** Xcode 15+ 또는 Swift 6.0 툴체인 + ## 설정 실행 후 접근성 권한을 부여하세요: @@ -50,15 +90,62 @@ cd ContextSwitcher 1. **시스템 설정 → 개인정보 보호 및 보안 → 손쉬운 사용** 열기 2. **ContextSwitcher**를 추가하고 토글 ON -> 참고: 접근성 권한은 다시 빌드할 때마다 초기화됩니다. OFF 후 다시 ON 하세요. +> **참고:** 접근성 권한은 다시 빌드할 때마다 초기화됩니다. OFF 후 다시 ON 하세요. ## 사용법 -1. 윈도우를 배치한 후 메뉴바 아이콘 → **+** 클릭하여 워크스페이스 캡처 -2. 이름을 지정하고 포함할 앱/윈도우 선택 -3. 워크스페이스 이름을 클릭하여 컨텍스트 전환 -4. **Show All Apps**를 클릭하여 모든 앱 복원 +1. 프로젝트에 맞게 **윈도우를 배치** +2. 메뉴바 아이콘 → **"+"** 클릭하여 워크스페이스 캡처 +3. **이름을 지정**하고 포함할 앱/윈도우 선택 +4. **워크스페이스 이름**을 클릭하여 컨텍스트 전환 +5. **"Show All Apps"**를 클릭하여 모든 앱 복원 + +### 키보드 단축키 + +워크스페이스에 글로벌 단축키를 할당하여 즉시 전환: + +1. 메뉴바 → 워크스페이스 설정 열기 +2. 단축키 필드를 클릭하고 원하는 키 조합 입력 +3. 어떤 앱에서든 단축키로 즉시 전환 + +### 팁 + +- **JetBrains IDE**: 개별 프로젝트 윈도우를 인식하여, 특정 IntelliJ/WebStorm 프로젝트만 워크스페이스에 포함 가능 +- **브라우저**: Chrome, Brave, Edge 프로필을 개별 인식 +- **조합 사용**: Rectangle/Magnet과 함께 사용하여 완전한 윈도우 관리 가능 + +## 아키텍처 + +``` +ContextSwitcher/ +├── Models/ # 데이터 모델 (KeyShortcut, WindowIdentifier, WorkspaceConfiguration) +├── Services/ # 핵심 로직 (Accessibility, Shortcuts, WorkspaceSwitch, Store) +├── Views/ # SwiftUI 뷰 (MenuBar, WorkspaceList, Capture, HUD, ShortcutRecorder) +├── Panel/ # AppKit 플로팅 패널 (HUD, Capture 컨트롤러) +├── Utilities/ # 헬퍼 (IntelliJ 타이틀 파서) +└── Resources/ # Info.plist, AppIcon +``` + +## 기여 + +[CONTRIBUTING.md](CONTRIBUTING.md)를 참조하세요. + +**빠른 시작:** + +```bash +# 개발 의존성 설치 +brew bundle + +# 빌드 및 설치 +./scripts/install.sh + +# 린트 +swiftlint lint +swiftformat --lint . +``` + +PR 제목은 [Conventional Commits](https://www.conventionalcommits.org/)를 따라야 합니다: `feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `ci:` ## 라이선스 -GPL-3.0. 자세한 내용은 [LICENSE](LICENSE)를 참조하세요. \ No newline at end of file +GPL-3.0. 자세한 내용은 [LICENSE](LICENSE)를 참조하세요. diff --git a/README.md b/README.md index 4569c76..0182241 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,23 @@

ContextSwitcher

+

+ Build + Release + License + Platform + Swift +

+

English | 한국어

-

A macOS menu bar utility for managing multiple development contexts by hiding/showing app windows.

+

+ A lightweight macOS menu bar utility that lets you save, switch, and restore window layouts as named workspaces — designed for developers juggling multiple projects. +

+ +--- ## Demo @@ -16,14 +28,33 @@ https://github.com/user-attachments/assets/010d90dd-5c32-4f04-9d9f-1386d15954ed ## Features -- Save current window layouts as named workspaces -- Switch between workspaces instantly from the menu bar -- **Global keyboard shortcuts** to switch workspaces from any app -- Selectively show/hide individual windows (e.g., specific IntelliJ projects) -- Floating HUD panel for quick access -- Restore all hidden apps with one click +- **Workspace Management** — Save current window layouts as named workspaces +- **Instant Switching** — Switch between workspaces from the menu bar +- **Global Shortcuts** — Keyboard shortcuts to switch workspaces from any app +- **Window-Level Control** — Selectively show/hide individual windows (e.g., specific IntelliJ projects or Chrome profiles) +- **Floating HUD** — Quick-access panel for workspace switching +- **Restore All** — Bring back all hidden apps with one click + +## Design Philosophy + +ContextSwitcher follows a clear set of principles: + +- **Performance** — Instant switching with no animation delays +- **Simplicity** — Does one thing well: context switching +- **Non-Disruptive** — Works with your existing workflow, not against it +- **Invisible** — Lives in the menu bar, stays out of your way + +### How It Works -## Download +ContextSwitcher uses macOS Accessibility APIs to **hide and show application windows**. When you switch to a workspace, apps not belonging to that workspace are hidden, and the workspace's apps are brought forward. This is fundamentally different from virtual desktop approaches — your windows stay on the same Space. + +### Why Not Tiling? + +ContextSwitcher intentionally does **not** manage window positions or tiling. There are excellent dedicated tools for that (Rectangle, Magnet, yabai). ContextSwitcher focuses purely on **which apps are visible**, letting you compose it with any window manager you prefer. + +## Installation + +### Download Binary | Platform | Download | |----------|----------| @@ -31,11 +62,17 @@ https://github.com/user-attachments/assets/010d90dd-5c32-4f04-9d9f-1386d15954ed > After downloading, open the DMG and drag `ContextSwitcher.app` to `/Applications`. > -> **macOS Gatekeeper warning:** Since the app is not signed, macOS may show a warning. Use one of these workarounds: +> **macOS Gatekeeper warning:** Since the app is not yet notarized, macOS may show a warning: > - Right-click `ContextSwitcher.app` in Finder → **Open** -> - Or run in Terminal: `xattr -cr /Applications/ContextSwitcher.app` +> - Or run: `xattr -cr /Applications/ContextSwitcher.app` -## Build from Source +### Homebrew (coming soon) + +```bash +brew install --cask minsang-alt/tap/contextswitcher +``` + +### Build from Source ```bash git clone https://github.com/minsang-alt/contextSwitcher.git @@ -43,6 +80,8 @@ cd ContextSwitcher ./scripts/install.sh ``` +**Requirements:** Xcode 15+ or Swift 6.0 toolchain + ## Setup After launching, grant Accessibility permission: @@ -50,14 +89,61 @@ After launching, grant Accessibility permission: 1. Open **System Settings → Privacy & Security → Accessibility** 2. Add **ContextSwitcher** and toggle it ON -> Note: Accessibility permission resets after each rebuild. Toggle it OFF then ON again. +> **Note:** Accessibility permission resets after each rebuild. Toggle it OFF then ON again. ## Usage -1. Arrange your windows, then click the menu bar icon → **+** to capture a workspace -2. Name it and select which apps/windows to include -3. Click a workspace name to switch contexts -4. Click **Show All Apps** to restore everything +1. **Arrange your windows** for a project +2. Click the menu bar icon → **"+"** to capture a workspace +3. **Name it** and select which apps/windows to include +4. Click a **workspace name** to switch contexts +5. Click **"Show All Apps"** to restore everything + +### Keyboard Shortcuts + +Assign global shortcuts to workspaces for instant switching: + +1. Open menu bar → workspace settings +2. Click the shortcut field and press your desired key combination +3. Use the shortcut from any app to switch instantly + +### Tips + +- **JetBrains IDEs**: ContextSwitcher recognizes individual project windows, so you can include only specific IntelliJ/WebStorm projects in a workspace +- **Browsers**: Chrome, Brave, and Edge profiles are detected separately +- **Composability**: Use ContextSwitcher alongside Rectangle/Magnet for full window management + +## Architecture + +``` +ContextSwitcher/ +├── Models/ # Data models (KeyShortcut, WindowIdentifier, WorkspaceConfiguration) +├── Services/ # Core logic (Accessibility, Shortcuts, WorkspaceSwitch, Store) +├── Views/ # SwiftUI views (MenuBar, WorkspaceList, Capture, HUD, ShortcutRecorder) +├── Panel/ # AppKit floating panel (HUD, Capture controllers) +├── Utilities/ # Helpers (IntelliJ title parser) +└── Resources/ # Info.plist, AppIcon +``` + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. + +**Quick start:** + +```bash +# Install dev dependencies +brew bundle + +# Build and install +./scripts/install.sh + +# Lint +swiftlint lint +swiftformat --lint . +``` + +PR titles must follow [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `ci:` ## License diff --git a/fastlane/Appfile b/fastlane/Appfile new file mode 100644 index 0000000..6540181 --- /dev/null +++ b/fastlane/Appfile @@ -0,0 +1,8 @@ +# App identifier +app_identifier("com.minsang.ContextSwitcher") + +# Apple ID (set via environment variable) +# apple_id(ENV["APPLE_ID"]) + +# Team ID (set via environment variable) +# team_id(ENV["TEAM_ID"]) diff --git a/fastlane/Fastfile b/fastlane/Fastfile new file mode 100644 index 0000000..d6bc0d8 --- /dev/null +++ b/fastlane/Fastfile @@ -0,0 +1,69 @@ +default_platform(:mac) + +platform :mac do + desc "Build release binary" + lane :build do + sh("cd .. && swift build -c release 2>&1") + end + + desc "Create app bundle" + lane :bundle do + build + sh("cd .. && ./scripts/install.sh") + end + + desc "Create DMG for distribution" + lane :dmg do |options| + version = options[:version] || get_version + dmg_name = "ContextSwitcher-#{version}-arm64.dmg" + dmg_path = "/tmp/#{dmg_name}" + + bundle + + sh("rm -f '#{dmg_path}'") + sh("hdiutil create -volname ContextSwitcher -srcfolder /Applications/ContextSwitcher.app -ov -format UDZO '#{dmg_path}'") + + UI.success("DMG created: #{dmg_path}") + dmg_path + end + + desc "Build, notarize, and create release DMG" + lane :release do |options| + version = options[:version] + UI.user_error!("Version required: fastlane release version:x.y.z") unless version + + # Update version in Info.plist + update_version(version: version) + + # Build DMG + dmg_path = dmg(version: version) + + # Notarize (requires Apple Developer ID) + if ENV["NOTARIZE"] == "true" + notarize( + package: dmg_path, + bundle_id: "com.minsang.ContextSwitcher", + username: ENV["APPLE_ID"], + asc_provider: ENV["ASC_PROVIDER"] + ) + UI.success("Notarization complete!") + else + UI.important("Skipping notarization. Set NOTARIZE=true to enable.") + end + + UI.success("Release #{version} ready: #{dmg_path}") + end + + # Helper methods + + def get_version + plist_path = "../ContextSwitcher/Resources/Info.plist" + `/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "#{plist_path}"`.strip + end + + def update_version(version:) + plist_path = "../ContextSwitcher/Resources/Info.plist" + `/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString #{version}" "#{plist_path}"` + UI.success("Version updated to #{version}") + end +end