```
├── .gitattributes (omitted)
├── .github/
├── ISSUE_TEMPLATE/
├── bug_report.md (100 tokens)
├── config.yml
├── feature_request.md (100 tokens)
├── workflows/
├── update-homebrew.yml (400 tokens)
├── .gitignore (100 tokens)
├── Archive/
├── AI.zip
├── CAGridView_first_working_version.swift.zip
├── Config/
├── OpenMultitouchSupport.xcconfig (100 tokens)
├── LICENSE (omitted)
├── LaunchNext.xcodeproj/
├── project.pbxproj (3.8k tokens)
├── project.xcworkspace/
├── contents.xcworkspacedata
├── xcshareddata/
├── WorkspaceSettings.xcsettings
├── xcuserdata/
├── gzk.xcuserdatad/
├── UserInterfaceState.xcuserstate
├── roversx.xcuserdatad/
├── WorkspaceSettings.xcsettings (100 tokens)
├── xcshareddata/
├── xcschemes/
├── LaunchNext.xcscheme (600 tokens)
├── xcuserdata/
├── gzk.xcuserdatad/
├── xcdebugger/
├── Breakpoints_v2.xcbkptlist (300 tokens)
├── xcschemes/
├── xcschememanagement.plist (100 tokens)
├── roversx.xcuserdatad/
├── xcschemes/
├── xcschememanagement.plist (100 tokens)
├── LaunchNext/
├── Animations.swift (300 tokens)
├── AppCacheManager.swift (2.4k tokens)
├── AppIcon.icon/
├── Assets/
├── 2.searchbar_unspecified_unspecified_automatic.svg (100 tokens)
├── 3.magnifier_unspecified_unspecified_automatic.svg (200 tokens)
├── 4.chiclet6_unspecified_unspecified_automatic.svg (600 tokens)
├── 5.chiclet5_unspecified_unspecified_automatic.svg (600 tokens)
├── 6.chiclet4_unspecified_unspecified_automatic.svg (600 tokens)
├── 7.chiclet3_unspecified_unspecified_automatic.svg (600 tokens)
├── 8.chiclet2_unspecified_unspecified_automatic.svg (600 tokens)
├── 9.chiclet1_unspecified_unspecified_automatic.svg (600 tokens)
├── icon.json (400 tokens)
├── AppInfo.swift (1300 tokens)
├── AppStore.swift (55.8k tokens)
├── Assets.xcassets/
├── AboutBackground.imageset/
├── Contents.json (100 tokens)
├── background_dark.heic
├── background_light.heic
├── AccentColor.colorset/
├── Contents.json
├── AppIcon.appiconset/
├── Contents.json (200 tokens)
├── AppearanceAuto.imageset/
├── Contents.json
├── auto_appearance_mode.heic
├── AppearanceDark.imageset/
├── Contents.json
├── dark_appearance_mode.heic
├── AppearanceLight.imageset/
├── Contents.json
├── light_appearance_mode.heic
├── Contents.json
├── FoundaryColor.colorset/
├── Contents.json (200 tokens)
├── CAFolderGridView.swift (10.6k tokens)
├── CAFolderGridViewRepresentable.swift (1000 tokens)
├── CAGridView+Input.swift (11.9k tokens)
├── CAGridView+Layout.swift (3.6k tokens)
├── CAGridView.swift (5.9k tokens)
├── CAGridViewRepresentable.swift (3.6k tokens)
├── ControllerInputManager.swift (2k tokens)
├── Extensions.swift (200 tokens)
├── FolderInfo.swift (2.2k tokens)
├── FolderView.swift (6.6k tokens)
├── GeometryUtils.swift (800 tokens)
├── Gesture/
├── GestureConfiguration.swift (200 tokens)
├── GestureInputDevice.swift (200 tokens)
├── GestureMonitor.swift (500 tokens)
├── GestureStateMachine.swift (3.3k tokens)
├── GestureTouchProvider.swift (900 tokens)
├── HotCornerMonitor.swift (900 tokens)
├── IconStore.swift (200 tokens)
├── LaunchNextCLI.swift (8.6k tokens)
├── LaunchNextCLIIPC.swift (2.2k tokens)
├── LaunchpadApp.swift (14.3k tokens)
├── LaunchpadItemButton.swift (1800 tokens)
├── LaunchpadView.swift (29.5k tokens)
├── LayoutPresetCatalog.swift (1000 tokens)
├── Localization.swift (85.7k tokens)
├── Markdown/
├── MarkdownRenderModel.swift (200 tokens)
├── ReleaseNotesMarkdownView.swift (2.1k tokens)
├── SimpleMarkdownParser.swift (1800 tokens)
├── NativeLaunchpadImporter.swift (5.6k tokens)
├── PerformanceMode.swift (100 tokens)
├── RightClickMenu.swift (2.8k tokens)
├── Search/
├── FuzzyMatcher.swift (400 tokens)
├── LaunchpadSearchEngine.swift (700 tokens)
├── SearchIndexEntry.swift (200 tokens)
├── SettingsView.swift (51.4k tokens)
├── SoundManager.swift (800 tokens)
├── ThirdParty/
├── OpenMultitouchSupport/
├── Framework/
├── OpenMultitouchSupportXCF/
├── OpenMTEvent.h (100 tokens)
├── OpenMTEvent.m (200 tokens)
├── OpenMTEventInternal.h (100 tokens)
├── OpenMTInternal.h (2k tokens)
├── OpenMTListener.h (100 tokens)
├── OpenMTListener.m (200 tokens)
├── OpenMTListenerInternal.h (100 tokens)
├── OpenMTManager.h (300 tokens)
├── OpenMTManager.m (4.5k tokens)
├── OpenMTManagerInternal.h (100 tokens)
├── OpenMTTouch.h (200 tokens)
├── OpenMTTouch.m (400 tokens)
├── OpenMTTouchInternal.h (200 tokens)
├── OpenMultitouchSupportXCF.h (100 tokens)
├── module.modulemap (100 tokens)
├── Sources/
├── OpenMultitouchSupport/
├── OMSManager.swift (1300 tokens)
├── OMSTouchData.swift (400 tokens)
├── VoiceManager.swift (900 tokens)
├── README.md (1800 tokens)
├── UpdaterScripts/
├── SwiftUpdater/
├── .gitignore
├── LICENSE (200 tokens)
├── Package.swift (100 tokens)
├── README.md (600 tokens)
├── Sources/
├── SwiftUpdater/
├── Arguments.swift (400 tokens)
├── Config.swift (300 tokens)
├── Downloader.swift (400 tokens)
├── Errors.swift (100 tokens)
├── GitHubAPI.swift (300 tokens)
├── Installer.swift (900 tokens)
├── Localization.swift (6.2k tokens)
├── NCursesUI.swift (1900 tokens)
├── SwiftUpdater.swift (3.7k tokens)
├── launchnext_updater.py (10.1k tokens)
├── cs.lproj/
├── Localizable.strings
├── de.lproj/
├── Localizable.strings
├── en.lproj/
├── Localizable.strings
├── es.lproj/
├── Localizable.strings
├── fr.lproj/
├── Localizable.strings
├── hi.lproj/
├── Localizable.strings
├── i18n/
├── README.cs.md (1700 tokens)
├── README.de.md (1800 tokens)
├── README.es.md (1800 tokens)
├── README.fr.md (1900 tokens)
├── README.hi.md (1700 tokens)
├── README.it.md (1800 tokens)
├── README.ja.md (1100 tokens)
├── README.ko.md (1100 tokens)
├── README.ru.md (1800 tokens)
├── README.vi.md (1700 tokens)
├── README.zh-TW.md (1000 tokens)
├── README.zh.md (1000 tokens)
├── it.lproj/
├── Localizable.strings
├── ja.lproj/
├── Localizable.strings
├── ko.lproj/
├── Localizable.strings
├── pt-BR.lproj/
├── Localizable.strings
├── public/
├── banner.webp
├── banner_old.webp
├── setting1.webp
├── setting2.webp
├── setting3.webp
├── ru.lproj/
├── Localizable.strings
├── scripts/
├── release.sh (300 tokens)
├── vi.lproj/
├── Localizable.strings
├── zh-Hans.lproj/
├── Localizable.strings
├── zh-Hant.lproj/
├── Localizable.strings
```
## /.github/ISSUE_TEMPLATE/bug_report.md
---
name: Bug Report
about: Report an issue
title: "[BUG] <title>"
labels: "bug"
---
### Description of the issue
<!-- A clear and concise description of the issue encountered -->
<!-- Please post in English. Requests in other languages will be ignored and closed. -->
### Steps to reproduce this issue
<!--
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
.-->
### Expected behavior
<!-- A clear and concise description of what you expected to happen. -->
### Additional content
<!-- If applicable, add screenshots or screen recordings to help explain your problem. -->
## /.github/ISSUE_TEMPLATE/config.yml
```yml path="/.github/ISSUE_TEMPLATE/config.yml"
blank_issues_enabled: true
```
## /.github/ISSUE_TEMPLATE/feature_request.md
---
name: Feature Request
about: Suggest an idea or improvement
title: "[FR] <title>"
labels: "enhancement"
---
### Feature Request
<!-- A clear and concise description of the problem you are facing. Example: "I’m always frustrated when..." -->
<!-- Please post in English. Requests in other languages will be ignored and closed. -->
### Solution you would like
<!-- A clear and concise description of what you want to happen. -->
### Alternatives you have considered
<!-- A clear and concise description of any alternative solutions or features you've considered. -->
### Additional context
<!-- Add any other context, screenshots, or mockups about the feature request here.-->
## /.github/workflows/update-homebrew.yml
```yml path="/.github/workflows/update-homebrew.yml"
name: Update Homebrew Tap
on:
release:
types:
- published
permissions:
contents: read
jobs:
update-homebrew:
runs-on: macos-latest
steps:
- name: Read checksum from release assets
id: checksum
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.event.release.tag_name }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
VERSION="${TAG#v}"
ZIP_NAME="LaunchNext${VERSION}.zip"
gh release download "${TAG}" \
--repo "${REPO}" \
--pattern "checksums.txt" \
--dir .
SHA256="$(awk -v file="${ZIP_NAME}" '$2 == file { print $1 }' checksums.txt)"
if [[ -z "${SHA256}" ]]; then
echo "Could not find checksum for ${ZIP_NAME} in checksums.txt" >&2
exit 1
fi
echo "version=${VERSION}" >> "${GITHUB_OUTPUT}"
echo "sha256=${SHA256}" >> "${GITHUB_OUTPUT}"
- name: Update tap cask
env:
TAP_TOKEN: ${{ secrets.TAP_TOKEN }}
TAG: ${{ github.event.release.tag_name }}
SHA256: ${{ steps.checksum.outputs.sha256 }}
run: |
set -euo pipefail
if [[ -z "${TAP_TOKEN}" ]]; then
echo "TAP_TOKEN is not configured; skipping Homebrew tap update."
exit 0
fi
curl -X POST \
-H "Authorization: token ${TAP_TOKEN}" \
-H "Accept: application/vnd.github.v3+json" \
https://api.github.com/repos/RoversX/homebrew-tap/actions/workflows/update-formula.yml/dispatches \
-d "{\"ref\":\"main\",\"inputs\":{\"formula\":\"launchnext\",\"tag\":\"${TAG}\",\"repository\":\"RoversX/LaunchNext\",\"sha256\":\"${SHA256}\"}}"
```
## /.gitignore
```gitignore path="/.gitignore"
# Xcode
*.xcuserstate
xcuserdata/
*.xcuserdatad
# Build artifacts
build/
DerivedData/
.build/
dist/
# macOS
.DS_Store
.AppleDouble
.LSOverride
# Swift updater build artifacts
UpdaterScripts/SwiftUpdater/.build/
UpdaterScripts/SwiftUpdater/.build-*/
# Archives
*.xcarchive
# Breakpoints
*.xcbkptlist
# Parallel sources
parallel_version/
# Local AI notes
LaunchNext/AI/TODO.md
# Local docs draft
/docs/
```
## /Archive/AI.zip
Binary file available at https://raw.githubusercontent.com/RoversX/LaunchNext/refs/heads/main/Archive/AI.zip
## /Archive/CAGridView_first_working_version.swift.zip
Binary file available at https://raw.githubusercontent.com/RoversX/LaunchNext/refs/heads/main/Archive/CAGridView_first_working_version.swift.zip
## /Config/OpenMultitouchSupport.xcconfig
```xcconfig path="/Config/OpenMultitouchSupport.xcconfig"
// Experimental gesture support wiring.
// If this feature is removed later, delete this file and remove the
// OpenMultitouchSupport.xcconfig baseConfigurationReference lines from
// LaunchNext.xcodeproj/project.pbxproj.
FRAMEWORK_SEARCH_PATHS = $(inherited) /System/Library/PrivateFrameworks
HEADER_SEARCH_PATHS = $(inherited) $(SRCROOT)/LaunchNext/ThirdParty/OpenMultitouchSupport/Framework
OTHER_LDFLAGS = $(inherited) -framework MultitouchSupport
SWIFT_INCLUDE_PATHS = $(inherited) $(SRCROOT)/LaunchNext/ThirdParty/OpenMultitouchSupport/Framework
```
## /LaunchNext.xcodeproj/project.pbxproj
```pbxproj path="/LaunchNext.xcodeproj/project.pbxproj"
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
A1E2B5C02F4A8A1B00F12345 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = A1E2B5CB2F4A8A1B00F12345 /* Localizable.strings */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
0D1B0B402E4CDE460083EEF9 /* LaunchNext.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LaunchNext.app; sourceTree = BUILT_PRODUCTS_DIR; };
0F7A6B1C3E2D4F5A6B7C8D9E /* OpenMultitouchSupport.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = OpenMultitouchSupport.xcconfig; sourceTree = "<group>"; };
690FA1FB2E88822200948FA6 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = "<group>"; };
690FA1FC2E88822200948FA6 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = "<group>"; };
A1E2B5C12F4A8A1B00F12345 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
A1E2B5C22F4A8A1B00F12345 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; };
A1E2B5CD2F4A8A1B00F12345 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = "<group>"; };
A1E2B5C32F4A8A1B00F12345 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = "<group>"; };
A1E2B5C42F4A8A1B00F12345 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = "<group>"; };
A1E2B5C52F4A8A1B00F12345 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = "<group>"; };
A1E2B5C62F4A8A1B00F12345 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; };
A1E2B5C72F4A8A1B00F12345 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
A1E2B5C82F4A8A1B00F12345 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; };
A1E2B5C92F4A8A1B00F12345 /* hi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hi; path = hi.lproj/Localizable.strings; sourceTree = "<group>"; };
A1E2B5CA2F4A8A1B00F12345 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = "<group>"; };
A1E2B5CC2F4A8A1B00F12345 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
0D1B0B422E4CDE460083EEF9 /* LaunchNext */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = LaunchNext;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
0D1B0B3D2E4CDE460083EEF9 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
0D1B0B372E4CDE460083EEF9 = {
isa = PBXGroup;
children = (
A1E2B5CB2F4A8A1B00F12345 /* Localizable.strings */,
0F7A6B1D3E2D4F5A6B7C8D9E /* Config */,
0D1B0B422E4CDE460083EEF9 /* LaunchNext */,
0D1B0B412E4CDE460083EEF9 /* Products */,
);
sourceTree = "<group>";
};
0D1B0B412E4CDE460083EEF9 /* Products */ = {
isa = PBXGroup;
children = (
0D1B0B402E4CDE460083EEF9 /* LaunchNext.app */,
);
name = Products;
sourceTree = "<group>";
};
0F7A6B1D3E2D4F5A6B7C8D9E /* Config */ = {
isa = PBXGroup;
children = (
0F7A6B1C3E2D4F5A6B7C8D9E /* OpenMultitouchSupport.xcconfig */,
);
path = Config;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
0D1B0B3F2E4CDE460083EEF9 /* LaunchNext */ = {
isa = PBXNativeTarget;
buildConfigurationList = 0D1B0B4B2E4CDE480083EEF9 /* Build configuration list for PBXNativeTarget "LaunchNext" */;
buildPhases = (
0D1B0B3C2E4CDE460083EEF9 /* Sources */,
0D1B0B3D2E4CDE460083EEF9 /* Frameworks */,
0D1B0B3E2E4CDE460083EEF9 /* Resources */,
E1C2D3A42F56B78900ABCDEF /* Run Script (Updater) */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
0D1B0B422E4CDE460083EEF9 /* LaunchNext */,
);
name = LaunchNext;
packageProductDependencies = (
);
productName = LaunchNext;
productReference = 0D1B0B402E4CDE460083EEF9 /* LaunchNext.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
0D1B0B382E4CDE460083EEF9 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2600;
LastUpgradeCheck = 2620;
TargetAttributes = {
0D1B0B3F2E4CDE460083EEF9 = {
CreatedOnToolsVersion = 26.0;
LastSwiftMigration = 2600;
};
};
};
buildConfigurationList = 0D1B0B3B2E4CDE460083EEF9 /* Build configuration list for PBXProject "LaunchNext" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
"zh-Hans",
"zh-Hant",
ja,
ko,
fr,
es,
cs,
de,
ru,
hi,
vi,
it,
"pt-BR",
);
mainGroup = 0D1B0B372E4CDE460083EEF9;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = 0D1B0B412E4CDE460083EEF9 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
0D1B0B3F2E4CDE460083EEF9 /* LaunchNext */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
0D1B0B3E2E4CDE460083EEF9 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A1E2B5C02F4A8A1B00F12345 /* Localizable.strings in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
E1C2D3A42F56B78900ABCDEF /* Run Script (Updater) */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"$(SRCROOT)/UpdaterScripts/SwiftUpdater/Package.swift",
"$(SRCROOT)/UpdaterScripts/SwiftUpdater/Sources/SwiftUpdater/Arguments.swift",
"$(SRCROOT)/UpdaterScripts/SwiftUpdater/Sources/SwiftUpdater/Config.swift",
"$(SRCROOT)/UpdaterScripts/SwiftUpdater/Sources/SwiftUpdater/Downloader.swift",
"$(SRCROOT)/UpdaterScripts/SwiftUpdater/Sources/SwiftUpdater/Errors.swift",
"$(SRCROOT)/UpdaterScripts/SwiftUpdater/Sources/SwiftUpdater/GitHubAPI.swift",
"$(SRCROOT)/UpdaterScripts/SwiftUpdater/Sources/SwiftUpdater/Installer.swift",
"$(SRCROOT)/UpdaterScripts/SwiftUpdater/Sources/SwiftUpdater/Localization.swift",
"$(SRCROOT)/UpdaterScripts/SwiftUpdater/Sources/SwiftUpdater/NCursesUI.swift",
"$(SRCROOT)/UpdaterScripts/SwiftUpdater/Sources/SwiftUpdater/SwiftUpdater.swift",
);
name = "Run Script (Updater)";
outputFileListPaths = (
);
outputPaths = (
"$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/Updater/SwiftUpdater",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/bash;
shellScript = "set -euo pipefail\n\nif [[ \"${ENABLE_PREVIEWS:-NO}\" == \"YES\" ]]; then\n echo 'SwiftUI Preview: skip updater build'\n exit 0\nfi\n\nUPDATER_DIR=\"${SRCROOT}/UpdaterScripts/SwiftUpdater\"\nDEST_DIR=\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Updater\"\nSOURCE_BIN=\"${UPDATER_DIR}/.build/apple/Products/Release/SwiftUpdater\"\nLEGACY_BIN=\"${UPDATER_DIR}/.build/release/SwiftUpdater\"\n\nmkdir -p \"${DEST_DIR}\"\n\nfunction sign_binary {\n /usr/bin/codesign --force --sign - --preserve-metadata=entitlements,requirements \"$1\"\n}\n\nif [[ -f \"${SOURCE_BIN}\" ]]; then\n echo \"Using SwiftUpdater at ${SOURCE_BIN}\"\n cp \"${SOURCE_BIN}\" \"${DEST_DIR}/SwiftUpdater\"\n chmod +x \"${DEST_DIR}/SwiftUpdater\"\n sign_binary \"${DEST_DIR}/SwiftUpdater\"\n exit 0\nfi\n\nif [[ -f \"${LEGACY_BIN}\" ]]; then\n echo \"Using SwiftUpdater at ${LEGACY_BIN}\"\n cp \"${LEGACY_BIN}\" \"${DEST_DIR}/SwiftUpdater\"\n chmod +x \"${DEST_DIR}/SwiftUpdater\"\n sign_binary \"${DEST_DIR}/SwiftUpdater\"\n exit 0\nfi\n\n>&2 echo \"error: SwiftUpdater binary not found. Run 'swift build --configuration release --arch arm64 --arch x86_64 --product SwiftUpdater' in UpdaterScripts/SwiftUpdater\"\nexit 1\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
0D1B0B3C2E4CDE460083EEF9 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXVariantGroup section */
A1E2B5CB2F4A8A1B00F12345 /* Localizable.strings */ = {
isa = PBXVariantGroup;
children = (
A1E2B5C12F4A8A1B00F12345 /* en */,
A1E2B5C22F4A8A1B00F12345 /* zh-Hans */,
A1E2B5CD2F4A8A1B00F12345 /* zh-Hant */,
A1E2B5C32F4A8A1B00F12345 /* ja */,
A1E2B5C42F4A8A1B00F12345 /* ko */,
A1E2B5C52F4A8A1B00F12345 /* fr */,
A1E2B5C62F4A8A1B00F12345 /* es */,
690FA1FC2E88822200948FA6 /* it */,
690FA1FB2E88822200948FA6 /* cs */,
A1E2B5C72F4A8A1B00F12345 /* de */,
A1E2B5C82F4A8A1B00F12345 /* ru */,
A1E2B5C92F4A8A1B00F12345 /* hi */,
A1E2B5CA2F4A8A1B00F12345 /* vi */,
A1E2B5CC2F4A8A1B00F12345 /* pt-BR */,
);
name = Localizable.strings;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
0D1B0B492E4CDE480083EEF9 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 6V3LHNB5K8;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 26.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
0D1B0B4A2E4CDE480083EEF9 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 6V3LHNB5K8;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 26.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = macosx;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
};
name = Release;
};
0D1B0B4C2E4CDE480083EEF9 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 0F7A6B1C3E2D4F5A6B7C8D9E /* OpenMultitouchSupport.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 20260224;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "";
ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleAllowMixedLocalizations = YES;
INFOPLIST_KEY_CFBundleDisplayName = LaunchNext;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
INFOPLIST_KEY_LSUIElement = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 RoversX / CloseX. Licensed under GPL-3.0";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 2.4.1;
PRODUCT_BUNDLE_IDENTIFIER = LaunchNext;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
};
name = Debug;
};
0D1B0B4D2E4CDE480083EEF9 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 0F7A6B1C3E2D4F5A6B7C8D9E /* OpenMultitouchSupport.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 20260224;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "";
ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleAllowMixedLocalizations = YES;
INFOPLIST_KEY_CFBundleDisplayName = LaunchNext;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
INFOPLIST_KEY_LSUIElement = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 RoversX / CloseX. Licensed under GPL-3.0";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 2.4.1;
PRODUCT_BUNDLE_IDENTIFIER = LaunchNext;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
0D1B0B3B2E4CDE460083EEF9 /* Build configuration list for PBXProject "LaunchNext" */ = {
isa = XCConfigurationList;
buildConfigurations = (
0D1B0B492E4CDE480083EEF9 /* Debug */,
0D1B0B4A2E4CDE480083EEF9 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
0D1B0B4B2E4CDE480083EEF9 /* Build configuration list for PBXNativeTarget "LaunchNext" */ = {
isa = XCConfigurationList;
buildConfigurations = (
0D1B0B4C2E4CDE480083EEF9 /* Debug */,
0D1B0B4D2E4CDE480083EEF9 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 0D1B0B382E4CDE460083EEF9 /* Project object */;
}
```
## /LaunchNext.xcodeproj/project.xcworkspace/contents.xcworkspacedata
```xcworkspacedata path="/LaunchNext.xcodeproj/project.xcworkspace/contents.xcworkspacedata"
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
```
## /LaunchNext.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
```xcsettings path="/LaunchNext.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings"
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>
```
## /LaunchNext.xcodeproj/project.xcworkspace/xcuserdata/gzk.xcuserdatad/UserInterfaceState.xcuserstate
Binary file available at https://raw.githubusercontent.com/RoversX/LaunchNext/refs/heads/main/LaunchNext.xcodeproj/project.xcworkspace/xcuserdata/gzk.xcuserdatad/UserInterfaceState.xcuserstate
## /LaunchNext.xcodeproj/project.xcworkspace/xcuserdata/roversx.xcuserdatad/WorkspaceSettings.xcsettings
```xcsettings path="/LaunchNext.xcodeproj/project.xcworkspace/xcuserdata/roversx.xcuserdatad/WorkspaceSettings.xcsettings"
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BuildLocationStyle</key>
<string>UseAppPreferences</string>
<key>CompilationCachingSetting</key>
<string>Default</string>
<key>CustomBuildLocationType</key>
<string>RelativeToDerivedData</string>
<key>DerivedDataLocationStyle</key>
<string>Default</string>
<key>ShowSharedSchemesAutomaticallyEnabled</key>
<true/>
</dict>
</plist>
```
## /LaunchNext.xcodeproj/xcshareddata/xcschemes/LaunchNext.xcscheme
```xcscheme path="/LaunchNext.xcodeproj/xcshareddata/xcschemes/LaunchNext.xcscheme"
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2620"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0D1B0B3F2E4CDE460083EEF9"
BuildableName = "LaunchNext.app"
BlueprintName = "LaunchNext"
ReferencedContainer = "container:LaunchNext.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0D1B0B3F2E4CDE460083EEF9"
BuildableName = "LaunchNext.app"
BlueprintName = "LaunchNext"
ReferencedContainer = "container:LaunchNext.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0D1B0B3F2E4CDE460083EEF9"
BuildableName = "LaunchNext.app"
BlueprintName = "LaunchNext"
ReferencedContainer = "container:LaunchNext.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
```
## /LaunchNext.xcodeproj/xcuserdata/gzk.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist
```xcbkptlist path="/LaunchNext.xcodeproj/xcuserdata/gzk.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist"
<?xml version="1.0" encoding="UTF-8"?>
<Bucket
uuid = "DE3DF139-2D80-49D6-B259-5D1E522B934E"
type = "1"
version = "2.0">
<Breakpoints>
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
<BreakpointContent
uuid = "8B3FD3CF-DBF9-428D-BA1E-E6D6A4D7E152"
shouldBeEnabled = "No"
ignoreCount = "0"
continueAfterRunningActions = "No"
filePath = "LaunchNext/LaunchpadView.swift"
startingColumnNumber = "9223372036854775807"
endingColumnNumber = "9223372036854775807"
startingLineNumber = "175"
endingLineNumber = "175"
landmarkName = "body"
landmarkType = "24">
</BreakpointContent>
</BreakpointProxy>
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
<BreakpointContent
uuid = "A58C9A88-D4D8-4AD1-8D08-4E69E45161C8"
shouldBeEnabled = "No"
ignoreCount = "0"
continueAfterRunningActions = "No"
filePath = "LaunchNext/AppStore.swift"
startingColumnNumber = "9223372036854775807"
endingColumnNumber = "9223372036854775807"
startingLineNumber = "867"
endingLineNumber = "867"
landmarkName = "isInsideAnotherApp(_:)"
landmarkType = "7">
</BreakpointContent>
</BreakpointProxy>
</Breakpoints>
</Bucket>
```
## /LaunchNext.xcodeproj/xcuserdata/gzk.xcuserdatad/xcschemes/xcschememanagement.plist
```plist path="/LaunchNext.xcodeproj/xcuserdata/gzk.xcuserdatad/xcschemes/xcschememanagement.plist"
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>LaunchNext.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>
```
## /LaunchNext.xcodeproj/xcuserdata/roversx.xcuserdatad/xcschemes/xcschememanagement.plist
```plist path="/LaunchNext.xcodeproj/xcuserdata/roversx.xcuserdatad/xcschemes/xcschememanagement.plist"
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>LaunchNext.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>
<dict>
<key>0D1B0B3F2E4CDE460083EEF9</key>
<dict>
<key>primary</key>
<true/>
</dict>
</dict>
</dict>
</plist>
```
## /LaunchNext/Animations.swift
```swift path="/LaunchNext/Animations.swift"
import SwiftUI
enum LNAnimations {
// MARK: - Springs - 优化性能的动画配置
static var springFast: Animation {
guard AnimationPreferences.isEnabled else { return .linear(duration: 0.0001) }
return .spring(response: AnimationPreferences.springResponse, dampingFraction: 0.8)
}
// MARK: - 性能优化的动画
static var dragPreview: Animation {
guard AnimationPreferences.isEnabled else { return .linear(duration: 0.0001) }
return .easeOut(duration: AnimationPreferences.baseDuration)
}
static var gridUpdate: Animation {
guard AnimationPreferences.isEnabled else { return .linear(duration: 0.0001) }
return .easeInOut(duration: AnimationPreferences.baseDuration)
}
// MARK: - Transitions
static var folderOpenTransition: AnyTransition {
if AnimationPreferences.isEnabled {
return AnyTransition.scale(scale: 0.95).combined(with: .opacity)
} else {
return AnyTransition.opacity
}
}
}
private enum AnimationPreferences {
static var isEnabled: Bool {
UserDefaults.standard.object(forKey: "enableAnimations") as? Bool ?? true
}
static var baseDuration: Double {
let stored = UserDefaults.standard.double(forKey: "animationDuration")
let value = stored == 0 ? 0.3 : stored
return max(0.05, min(value, 1.5))
}
static var springResponse: Double {
max(0.15, baseDuration)
}
}
```
## /LaunchNext/AppCacheManager.swift
```swift path="/LaunchNext/AppCacheManager.swift"
import Foundation
import AppKit
import Combine
/// 应用缓存管理器 - 负责缓存应用图标、应用信息和网格布局数据以提高性能
final class AppCacheManager: ObservableObject {
static let shared = AppCacheManager()
// MARK: - 缓存存储
private var iconCache: [String: NSImage] = [:]
private var appInfoCache: [String: AppInfo] = [:]
private var gridLayoutCache: [String: Any] = [:]
private let cacheLock = NSLock()
// MARK: - 缓存配置
private let maxIconCacheSize = 200
private let maxAppInfoCacheSize = 300
private var iconCacheOrder: [String] = [] // 改为可变数组,实现真正的LRU
// MARK: - 缓存状态
@Published var isCacheValid = false
@Published var lastCacheUpdate = Date.distantPast
@Published var cacheSize: Int = 0
// MARK: - 缓存键生成
private let cacheKeyGenerator = CacheKeyGenerator()
private init() {}
private var isLeanMode: Bool {
PerformanceMode.current == .lean
}
// MARK: - 公共接口
/// 生成应用缓存 - 在应用启动或扫描后调用
func generateCache(from apps: [AppInfo],
items: [LaunchpadItem],
itemsPerPage: Int,
columns: Int,
rows: Int) {
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
guard let self = self else { return }
// 清空旧缓存
self.clearAllCaches()
// 收集所有需要缓存的应用,包括文件夹内的应用
var allApps: [AppInfo] = []
allApps.append(contentsOf: apps)
// 从items中提取文件夹内的应用
for item in items {
if case let .folder(folder) = item {
allApps.append(contentsOf: folder.apps)
}
}
// 去重,避免重复缓存同一个应用
var uniqueApps: [AppInfo] = []
var seenPaths = Set<String>()
for app in allApps {
if !seenPaths.contains(app.url.path) {
seenPaths.insert(app.url.path)
uniqueApps.append(app)
}
}
// 缓存应用信息
self.cacheAppInfos(uniqueApps)
// 缓存应用图标
if !self.isLeanMode {
self.cacheAppIcons(uniqueApps)
}
// 缓存网格布局数据
self.cacheGridLayout(items,
itemsPerPage: itemsPerPage,
columns: columns,
rows: rows)
DispatchQueue.main.async {
self.isCacheValid = true
self.lastCacheUpdate = Date()
self.calculateCacheSize()
}
}
}
/// 获取缓存的应用图标
func getCachedIcon(for appPath: String) -> NSImage? {
if isLeanMode {
return nil
}
let key = cacheKeyGenerator.generateIconKey(for: appPath)
cacheLock.lock()
defer { cacheLock.unlock() }
if let icon = iconCache[key] {
if let index = iconCacheOrder.firstIndex(of: key) {
iconCacheOrder.remove(at: index)
iconCacheOrder.append(key)
}
return icon
} else {
return nil
}
}
/// 获取缓存的应用信息
func getCachedAppInfo(for appPath: String) -> AppInfo? {
let key = cacheKeyGenerator.generateAppInfoKey(for: appPath)
return appInfoCache[key]
}
/// 获取缓存的网格布局数据
func getCachedGridLayout(for layoutKey: String) -> Any? {
let key = cacheKeyGenerator.generateGridLayoutKey(for: layoutKey)
return gridLayoutCache[key]
}
/// 预加载应用图标到缓存
func preloadIcons(for appPaths: [String]) {
if isLeanMode {
return
}
DispatchQueue.global(qos: .utility).async { [weak self] in
guard let self = self else { return }
for path in appPaths {
if self.getCachedIcon(for: path) == nil {
let icon = NSWorkspace.shared.icon(forFile: path)
let key = self.cacheKeyGenerator.generateIconKey(for: path)
self.cacheLock.lock()
self.iconCache[key] = icon
self.iconCacheOrder.append(key)
if self.iconCache.count > self.maxIconCacheSize {
if let oldestKey = self.iconCacheOrder.first {
self.iconCache.removeValue(forKey: oldestKey)
self.iconCacheOrder.removeFirst()
}
}
self.cacheLock.unlock()
}
}
DispatchQueue.main.async {
self.calculateCacheSize()
}
}
}
/// 智能预加载:预加载当前页面和相邻页面的图标
func smartPreloadIcons(for items: [LaunchpadItem], currentPage: Int, itemsPerPage: Int) {
if isLeanMode {
return
}
let startIndex = max(0, (currentPage - 1) * itemsPerPage)
let endIndex = min(items.count, (currentPage + 2) * itemsPerPage)
let relevantItems = Array(items[startIndex..<endIndex])
let appPaths = relevantItems.compactMap { item -> String? in
if case let .app(app) = item {
return app.url.path
}
return nil
}
preloadIcons(for: appPaths)
}
/// 清除所有缓存
func clearAllCaches() {
cacheLock.lock()
iconCache.removeAll()
appInfoCache.removeAll()
gridLayoutCache.removeAll()
iconCacheOrder.removeAll()
cacheLock.unlock()
DispatchQueue.main.async {
self.isCacheValid = false
self.cacheSize = 0
}
}
/// 清除过期缓存
func clearExpiredCache() {
let now = Date()
let cacheAgeThreshold: TimeInterval = 24 * 60 * 60 // 24小时
if now.timeIntervalSince(lastCacheUpdate) > cacheAgeThreshold {
clearAllCaches()
}
}
/// 手动刷新缓存
func refreshCache(from apps: [AppInfo],
items: [LaunchpadItem],
itemsPerPage: Int,
columns: Int,
rows: Int) {
// 收集所有需要缓存的应用,包括文件夹内的应用
var allApps: [AppInfo] = []
allApps.append(contentsOf: apps)
// 从items中提取文件夹内的应用
for item in items {
if case let .folder(folder) = item {
allApps.append(contentsOf: folder.apps)
}
}
// 去重,避免重复缓存同一个应用
var uniqueApps: [AppInfo] = []
var seenPaths = Set<String>()
for app in allApps {
if !seenPaths.contains(app.url.path) {
seenPaths.insert(app.url.path)
uniqueApps.append(app)
}
}
generateCache(from: uniqueApps,
items: items,
itemsPerPage: itemsPerPage,
columns: columns,
rows: rows)
}
// MARK: - 私有方法
private func cacheAppInfos(_ apps: [AppInfo]) {
cacheLock.lock()
for app in apps {
let key = cacheKeyGenerator.generateAppInfoKey(for: app.url.path)
appInfoCache[key] = app
}
cacheLock.unlock()
}
private func cacheAppIcons(_ apps: [AppInfo]) {
if isLeanMode {
return
}
cacheLock.lock()
for app in apps {
let key = cacheKeyGenerator.generateIconKey(for: app.url.path)
if let existingIndex = iconCacheOrder.firstIndex(of: key) {
iconCacheOrder.remove(at: existingIndex)
}
iconCache[key] = app.icon
iconCacheOrder.append(key)
if iconCache.count > maxIconCacheSize {
if let oldestKey = iconCacheOrder.first {
iconCache.removeValue(forKey: oldestKey)
iconCacheOrder.removeFirst()
}
}
}
cacheLock.unlock()
}
private func cacheGridLayout(_ items: [LaunchpadItem],
itemsPerPage: Int,
columns: Int,
rows: Int) {
// 缓存网格布局相关的计算数据
let layoutData = GridLayoutCacheData(
totalItems: items.count,
itemsPerPage: itemsPerPage,
columns: columns,
rows: rows,
pageCount: (items.count + max(itemsPerPage, 1) - 1) / max(itemsPerPage, 1)
)
let pageInfo = calculatePageInfo(for: items, itemsPerPage: itemsPerPage)
let key = cacheKeyGenerator.generateGridLayoutKey(for: "main")
let pageKey = cacheKeyGenerator.generateGridLayoutKey(for: "pages")
cacheLock.lock()
gridLayoutCache[key] = layoutData
gridLayoutCache[pageKey] = pageInfo
cacheLock.unlock()
}
/// 计算页面信息
private func calculatePageInfo(for items: [LaunchpadItem], itemsPerPage: Int) -> [PageInfo] {
let sanitizedItemsPerPage = max(itemsPerPage, 1)
let pageCount = (items.count + sanitizedItemsPerPage - 1) / sanitizedItemsPerPage
var pages: [PageInfo] = []
for pageIndex in 0..<pageCount {
let startIndex = pageIndex * sanitizedItemsPerPage
let endIndex = min(startIndex + sanitizedItemsPerPage, items.count)
let pageItems = Array(items[startIndex..<endIndex])
let appCount = pageItems.filter { if case .app = $0 { return true } else { return false } }.count
let folderCount = pageItems.filter { if case .folder = $0 { return true } else { return false } }.count
let emptyCount = pageItems.filter { if case .empty = $0 { return true } else { return false } }.count
let pageInfo = PageInfo(
pageIndex: pageIndex,
startIndex: startIndex,
endIndex: endIndex,
appCount: appCount,
folderCount: folderCount,
emptyCount: emptyCount
)
pages.append(pageInfo)
}
return pages
}
private func calculateCacheSize() {
cacheLock.lock()
let iconSize = iconCache.count
let appInfoSize = appInfoCache.count
let gridLayoutSize = gridLayoutCache.count
cacheLock.unlock()
cacheSize = iconSize + appInfoSize + gridLayoutSize
}
/// 获取性能统计
var performanceStats: PerformanceStats {
return PerformanceStats(cacheSize: cacheSize)
}
}
// MARK: - 缓存键生成器
private struct CacheKeyGenerator {
func generateIconKey(for appPath: String) -> String {
return "icon_\(appPath.hashValue)"
}
func generateAppInfoKey(for appPath: String) -> String {
return "appinfo_\(appPath.hashValue)"
}
func generateGridLayoutKey(for layoutKey: String) -> String {
return "grid_\(layoutKey.hashValue)"
}
}
// MARK: - 网格布局缓存数据结构
private struct GridLayoutCacheData {
let totalItems: Int
let itemsPerPage: Int
let columns: Int
let rows: Int
let pageCount: Int
}
private struct PageInfo {
let pageIndex: Int
let startIndex: Int
let endIndex: Int
let appCount: Int
let folderCount: Int
let emptyCount: Int
}
// MARK: - 缓存统计信息
extension AppCacheManager {
var cacheStatistics: CacheStatistics {
return CacheStatistics(
iconCacheSize: iconCache.count,
appInfoCacheSize: appInfoCache.count,
gridLayoutCacheSize: gridLayoutCache.count,
totalCacheSize: cacheSize,
isCacheValid: isCacheValid,
lastUpdate: lastCacheUpdate
)
}
}
struct CacheStatistics {
let iconCacheSize: Int
let appInfoCacheSize: Int
let gridLayoutCacheSize: Int
let totalCacheSize: Int
let isCacheValid: Bool
let lastUpdate: Date
}
struct PerformanceStats {
let cacheSize: Int
}
```
## /LaunchNext/AppIcon.icon/Assets/2.searchbar_unspecified_unspecified_automatic.svg
```svg path="/LaunchNext/AppIcon.icon/Assets/2.searchbar_unspecified_unspecified_automatic.svg"
<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Apple Native CoreSVG 341-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1024 1024">
<g/>
<g id="searchbar">
<path d="M281.392 192c-32.0685 0-58.6365 1.55676-84.2532 21.2065C174.099 230.867 160.348 258.819 160.348 288s13.7511 57.133 36.7905 74.7935C222.755 382.443 249.323 384 281.392 384h461.216c32.0684 0 58.6365-1.55676 84.2532-21.2065C849.901 345.133 863.652 317.181 863.652 288s-13.751-57.133-36.7905-74.7935C801.245 193.557 774.677 192 742.608 192H281.392z" opacity="0.3" style="opacity:0.3;"/>
</g>
</svg>
```
## /LaunchNext/AppIcon.icon/Assets/3.magnifier_unspecified_unspecified_automatic.svg
```svg path="/LaunchNext/AppIcon.icon/Assets/3.magnifier_unspecified_unspecified_automatic.svg"
<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Apple Native CoreSVG 341-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1024 1024">
<g/>
<g id="magnifier">
<path d="M319.843 330.938l-22.6128-22.7663c5.56607-7.65732 8.83954-17.0698 8.83954-27.2837c0-26.1817-21.2855-47.4568-47.4673-47.4568c-26.1828 0-47.4683 21.2751-47.4683 47.4568c0 26.1932 21.2855 47.4672 47.4683 47.4672c9.73706 0 18.6726-2.99277 26.1222-8.0434l22.8194 22.8675c1.67712 1.67691 3.89865 2.47522 6.23712 2.47522c4.96204 0 8.51089-3.7504 8.51089-8.65381C322.292 334.694 321.446 332.541 319.843 330.938zM258.591 316.233c-19.4927 0-35.3334-15.851-35.3334-35.3448c0-19.4834 15.8407-35.3333 35.3334-35.3333c19.5157 0 35.3449 15.8499 35.3449 35.3333C293.936 300.382 278.107 316.233 258.591 316.233z" fill="#F0F0F0" style="fill:#F0F0F0;"/>
</g>
</svg>
```
## /LaunchNext/AppIcon.icon/Assets/4.chiclet6_unspecified_unspecified_automatic.svg
```svg path="/LaunchNext/AppIcon.icon/Assets/4.chiclet6_unspecified_unspecified_automatic.svg"
<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Apple Native CoreSVG 341-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="SVGID_1_" x1="2203.14" y1="176.474" x2="2363.14" y2="176.474" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0.0000003 1 -1 0.0000003 928.4728394 -1531.1361084)">
<stop offset="0" stop-color="#ffffff" stop-opacity="1"/>
<stop offset="0" stop-color="#c8cac8" stop-opacity="1"/>
<stop offset="1" stop-color="#abadab" stop-opacity="1"/>
</linearGradient>
</defs>
<g/>
<g id="chiclet6">
<path d="M832 773.719c0 2.2254 0.0009766 4.4505-0.0131226 6.6759c-0.0107422 1.87445-0.0324097 3.74847-0.0831299 5.62231c-0.110718 4.08337-0.351013 8.20221-1.07697 12.2403c-0.736328 4.09644-1.93805 7.909-3.83258 11.6309c-1.862 3.65814-4.29517 7.00574-7.19769 9.90826s-6.25018 5.33563-9.90826 7.19769c-3.72192 1.89453-7.53448 3.09625-11.6309 3.83264c-4.03802 0.725891-8.15686 0.966186-12.2402 1.0769c-1.8739 0.0507812-3.74792 0.0723877-5.62238 0.0831909C778.169 832.001 775.944 832 773.719 832h-43.4375c-2.22546 0-4.4505 0.0009155-6.67596-0.0131226c-1.87439-0.0107422-3.74841-0.0324097-5.62231-0.0831909c-4.08337-0.110657-8.20221-0.351013-12.2402-1.07684c-4.09644-0.736389-7.909-1.93817-11.6309-3.83264c-3.65814-1.86206-7.0058-4.29517-9.90833-7.19769s-5.33563-6.25018-7.19769-9.90826c-1.89447-3.72198-3.09625-7.53455-3.83264-11.631c-0.72583-4.03802-0.966186-8.15686-1.07684-12.2402c-0.0508423-1.87384-0.0724487-3.74792-0.0831909-5.62231c-0.0140991-2.22546-0.0131226-4.4505-0.0131226-6.67596L672 730.281c0-2.2254-0.0009155-4.4505 0.0131226-6.6759c0.0108032-1.87439 0.0324097-3.74841 0.0831909-5.62231c0.110718-4.08337 0.351013-8.20221 1.0769-12.2403c0.736389-4.09637 1.93811-7.909 3.83264-11.6309c1.862-3.65808 4.29517-7.00574 7.19769-9.90826c2.90247-2.90253 6.25012-5.33569 9.90826-7.19769c3.72192-1.89453 7.53448-3.09625 11.6309-3.83258c4.03802-0.725952 8.15686-0.966248 12.2402-1.07697c1.8739-0.0507812 3.74792-0.0724487 5.62238-0.0831909c2.2254-0.0140381 4.4505-0.0130615 6.6759-0.0130615L773.719 672c2.22546 0 4.4505-0.0009766 6.67596 0.0131226c1.87439 0.0107422 3.74841 0.0324097 5.62231 0.0831909c4.08337 0.110718 8.20221 0.351013 12.2402 1.0769c4.09644 0.736328 7.909 1.93805 11.6309 3.83264c3.65814 1.862 7.00574 4.29511 9.90826 7.19763s5.33569 6.25018 7.19769 9.90833c1.89459 3.72186 3.09631 7.53448 3.83264 11.6309c0.725891 4.03802 0.966186 8.15686 1.0769 12.2402c0.0507812 1.8739 0.0724487 3.74792 0.0831909 5.62231C832.001 725.831 832 728.056 832 730.281V773.719z" fill="url(#SVGID_1_)" style="fill:url(#SVGID_1_);"/>
</g>
</svg>
```
## /LaunchNext/AppIcon.icon/Assets/5.chiclet5_unspecified_unspecified_automatic.svg
```svg path="/LaunchNext/AppIcon.icon/Assets/5.chiclet5_unspecified_unspecified_automatic.svg"
<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Apple Native CoreSVG 341-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="SVGID_1_" x1="2203.14" y1="416.473" x2="2363.14" y2="416.473" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0.0000003 1 -1 0.0000003 928.4728394 -1531.1361084)">
<stop offset="0" stop-color="#ffffff" stop-opacity="1"/>
<stop offset="0" stop-color="#5dc0ff" stop-opacity="1"/>
<stop offset="1" stop-color="#069eff" stop-opacity="1"/>
</linearGradient>
</defs>
<g/>
<g id="chiclet5">
<path d="M592.001 773.719c0 2.2254 0.0009766 4.4505-0.0130615 6.6759c-0.0107422 1.87445-0.0324707 3.74847-0.0831909 5.62231c-0.110718 4.08337-0.351013 8.20221-1.0769 12.2403c-0.736328 4.09644-1.93811 7.909-3.83264 11.6309c-1.862 3.65814-4.29517 7.00574-7.19769 9.90826s-6.25012 5.33563-9.90826 7.19769c-3.72192 1.89453-7.53448 3.09625-11.6309 3.83264c-4.03802 0.725891-8.15686 0.966186-12.2402 1.0769c-1.8739 0.0507812-3.74792 0.0723877-5.62238 0.0831909C538.17 832.001 535.945 832 533.72 832h-43.4375c-2.22543 0-4.4505 0.0009155-6.67593-0.0131226c-1.87442-0.0107422-3.74847-0.0324097-5.62235-0.0831909c-4.08337-0.110657-8.20218-0.351013-12.2402-1.07684c-4.09644-0.736389-7.909-1.93817-11.6309-3.83264c-3.65814-1.86206-7.0058-4.29517-9.90829-7.19769c-2.90253-2.90253-5.33566-6.25018-7.19769-9.90826c-1.8945-3.72198-3.09625-7.53455-3.83264-11.631c-0.725891-4.03802-0.966217-8.15686-1.0769-12.2402c-0.0507812-1.87384-0.0723877-3.74792-0.0831604-5.62231c-0.0140686-2.22546-0.0131226-4.4505-0.0131226-6.67596l-3.05e-05-43.4375c0-2.2254-0.000946-4.4505 0.0131226-6.6759c0.0107727-1.87439 0.0323792-3.74841 0.0831909-5.62231c0.110657-4.08337 0.351013-8.20221 1.07687-12.2403c0.736389-4.09637 1.93811-7.909 3.83264-11.6309c1.86203-3.65808 4.29517-7.00574 7.19766-9.90826c2.90253-2.90253 6.25015-5.33569 9.90829-7.19769c3.72192-1.89453 7.53448-3.09625 11.6309-3.83258c4.03806-0.725952 8.15689-0.966248 12.2403-1.07697c1.87387-0.0507812 3.74789-0.0724487 5.62231-0.0831909c2.22543-0.0140381 4.4505-0.0130615 6.67593-0.0130615L533.72 672c2.22546 0 4.4505-0.0009766 6.67596 0.0131226c1.87439 0.0107422 3.74841 0.0324097 5.62231 0.0831909c4.08337 0.110718 8.20221 0.351013 12.2402 1.0769c4.09644 0.736328 7.90906 1.93805 11.6309 3.83264c3.65814 1.862 7.0058 4.29511 9.90833 7.19763s5.33563 6.25018 7.19763 9.90833c1.89459 3.72186 3.09631 7.53448 3.83264 11.6309c0.725952 4.03802 0.966248 8.15686 1.07697 12.2402c0.0507202 1.8739 0.0723877 3.74792 0.0831299 5.62231c0.0140991 2.22546 0.0131226 4.4505 0.0131226 6.6759V773.719z" fill="url(#SVGID_1_)" style="fill:url(#SVGID_1_);"/>
</g>
</svg>
```
## /LaunchNext/AppIcon.icon/Assets/6.chiclet4_unspecified_unspecified_automatic.svg
```svg path="/LaunchNext/AppIcon.icon/Assets/6.chiclet4_unspecified_unspecified_automatic.svg"
<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Apple Native CoreSVG 341-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="SVGID_1_" x1="2203.14" y1="656.474" x2="2363.14" y2="656.474" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0.0000003 1 -1 0.0000003 928.4728394 -1531.1361084)">
<stop offset="0" stop-color="#ffffff" stop-opacity="1"/>
<stop offset="0" stop-color="#d599ff" stop-opacity="1"/>
<stop offset="1" stop-color="#be62ff" stop-opacity="1"/>
</linearGradient>
</defs>
<g/>
<g id="chiclet4">
<path d="M352 773.719c0 2.2254 0.0009766 4.4505-0.013092 6.6759c-0.0107422 1.87445-0.0324097 3.74847-0.0831604 5.62231c-0.110718 4.08337-0.351013 8.20221-1.07693 12.2403c-0.736328 4.09644-1.93808 7.909-3.83261 11.6309c-1.86203 3.65814-4.29517 7.00574-7.19769 9.90826s-6.25015 5.33563-9.90826 7.19769c-3.72192 1.89453-7.53452 3.09625-11.6309 3.83264c-4.03806 0.725891-8.15686 0.966186-12.2402 1.0769c-1.87393 0.0507812-3.74796 0.0723877-5.62235 0.0831909C298.169 832.001 295.944 832 293.719 832h-43.4375c-2.22543 0-4.45052 0.0009155-6.67593-0.0131226c-1.87444-0.0107422-3.74846-0.0324097-5.62233-0.0831909c-4.08339-0.110657-8.20221-0.351013-12.2403-1.07684c-4.09644-0.736389-7.90898-1.93817-11.6309-3.83264c-3.65814-1.86206-7.00578-4.29517-9.90829-7.19769c-2.90253-2.90253-5.33565-6.25018-7.19768-9.90826c-1.89452-3.72198-3.09627-7.53455-3.83264-11.631c-0.725891-4.03802-0.966217-8.15686-1.07689-12.2402c-0.0507965-1.87384-0.0724182-3.74792-0.0831909-5.62231c-0.0140686-2.22546-0.0131226-4.4505-0.0131226-6.67596L192 730.281c0-2.2254-0.000946-4.4505 0.0131226-6.6759c0.0107727-1.87439 0.0323792-3.74841 0.0831757-5.62231c0.110672-4.08337 0.351013-8.20221 1.07689-12.2403c0.736374-4.09637 1.93811-7.909 3.83263-11.6309c1.86205-3.65808 4.29517-7.00574 7.19768-9.90826s6.25015-5.33569 9.90829-7.19769c3.72192-1.89453 7.53448-3.09625 11.6309-3.83258c4.03806-0.725952 8.15688-0.966248 12.2402-1.07697c1.87387-0.0507812 3.74791-0.0724487 5.62233-0.0831909c2.22543-0.0140381 4.4505-0.0130615 6.67593-0.0130615L293.719 672c2.2254 0 4.45047-0.0009766 6.6759 0.0131226c1.87439 0.0107422 3.74844 0.0324097 5.62235 0.0831909c4.08337 0.110718 8.20221 0.351013 12.2402 1.0769c4.0964 0.736328 7.90903 1.93805 11.6309 3.83264c3.65811 1.862 7.00577 4.29511 9.90829 7.19763s5.33566 6.25018 7.19769 9.90833c1.89453 3.72186 3.09628 7.53448 3.83261 11.6309c0.725922 4.03802 0.966217 8.15686 1.07693 12.2402c0.0507507 1.8739 0.0724182 3.74792 0.0831604 5.62231c0.0140991 2.22546 0.0131226 4.4505 0.0131226 6.6759V773.719z" fill="url(#SVGID_1_)" style="fill:url(#SVGID_1_);"/>
</g>
</svg>
```
## /LaunchNext/AppIcon.icon/Assets/7.chiclet3_unspecified_unspecified_automatic.svg
```svg path="/LaunchNext/AppIcon.icon/Assets/7.chiclet3_unspecified_unspecified_automatic.svg"
<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Apple Native CoreSVG 341-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="SVGID_1_" x1="1982.79" y1="176.473" x2="2142.79" y2="176.473" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0.0000003 1 -1 0.0000003 928.4728394 -1531.1361084)">
<stop offset="0" stop-color="#ffffff" stop-opacity="1"/>
<stop offset="0" stop-color="#78ebd0" stop-opacity="1"/>
<stop offset="1" stop-color="#30e1b7" stop-opacity="1"/>
</linearGradient>
</defs>
<g/>
<g id="chiclet3">
<path d="M832 553.373c0 2.2254 0.0009766 4.4505-0.0131226 6.6759c-0.0107422 1.87445-0.0324097 3.74847-0.0831299 5.62238c-0.110718 4.08337-0.351013 8.20221-1.07697 12.2402c-0.736328 4.09644-1.93805 7.909-3.83258 11.6309c-1.862 3.65814-4.29517 7.0058-7.19769 9.90826c-2.90253 2.90253-6.25018 5.33569-9.90826 7.19769c-3.72192 1.89453-7.53448 3.09625-11.6309 3.83264c-4.03802 0.725891-8.15686 0.966248-12.2402 1.0769c-1.8739 0.0507812-3.74792 0.0724487-5.62238 0.0831909c-2.2254 0.0140381-4.45044 0.0131226-6.6759 0.0131226h-43.4375c-2.22546 0-4.4505 0.0009766-6.67596-0.0131226c-1.87439-0.0107422-3.74841-0.0323486-5.62231-0.0831299c-4.08337-0.110718-8.20221-0.351013-12.2402-1.0769c-4.09644-0.736389-7.909-1.93811-11.6309-3.83264c-3.65814-1.86206-7.0058-4.29517-9.90833-7.19769s-5.33563-6.25012-7.19769-9.90826c-1.89447-3.72192-3.09625-7.53448-3.83264-11.6309c-0.72583-4.03809-0.966186-8.15686-1.07684-12.2403c-0.0508423-1.87384-0.0724487-3.74786-0.0831909-5.62231c-0.0140991-2.2254-0.0131226-4.4505-0.0131226-6.6759L672 509.936c0-2.22543-0.0009155-4.4505 0.0131226-6.6759c0.0108032-1.87442 0.0324097-3.74844 0.0831909-5.62235c0.110718-4.08337 0.351013-8.20221 1.0769-12.2403c0.736389-4.0964 1.93811-7.909 3.83264-11.6309c1.862-3.65811 4.29517-7.00574 7.19769-9.90826c2.90247-2.90253 6.25012-5.33566 9.90826-7.19769c3.72192-1.89453 7.53448-3.09628 11.6309-3.83261c4.03802-0.725922 8.15686-0.966217 12.2402-1.07693c1.8739-0.0507812 3.74792-0.0724487 5.62238-0.0831909c2.2254-0.0140686 4.4505-0.013092 6.6759-0.013092h43.4375c2.22546-3.05e-05 4.4505-0.0010071 6.67596 0.013092c1.87439 0.0107422 3.74841 0.0324097 5.62231 0.0831604c4.08337 0.110718 8.20221 0.351013 12.2402 1.07693c4.09644 0.736328 7.909 1.93805 11.6309 3.83261c3.65814 1.862 7.00574 4.29514 9.90826 7.19766s5.33569 6.25018 7.19769 9.90829c1.89459 3.72189 3.09631 7.53448 3.83264 11.6309c0.725891 4.03802 0.966186 8.15686 1.0769 12.2402c0.0507812 1.8739 0.0724487 3.74796 0.0831909 5.62235C832.001 505.485 832 507.71 832 509.936V553.373z" fill="url(#SVGID_1_)" style="fill:url(#SVGID_1_);"/>
</g>
</svg>
```
## /LaunchNext/AppIcon.icon/Assets/8.chiclet2_unspecified_unspecified_automatic.svg
```svg path="/LaunchNext/AppIcon.icon/Assets/8.chiclet2_unspecified_unspecified_automatic.svg"
<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Apple Native CoreSVG 341-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="SVGID_1_" x1="1982.79" y1="416.474" x2="2142.79" y2="416.474" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0.0000003 1 -1 0.0000003 928.4728394 -1531.1361084)">
<stop offset="0" stop-color="#ffffff" stop-opacity="1"/>
<stop offset="0" stop-color="#ff70bb" stop-opacity="1"/>
<stop offset="1" stop-color="#ff2396" stop-opacity="1"/>
</linearGradient>
</defs>
<g/>
<g id="chiclet2">
<path d="M592 553.373c0 2.2254 0.0009766 4.4505-0.0131226 6.6759c-0.0107422 1.87445-0.0324097 3.74847-0.0831299 5.62238c-0.110718 4.08337-0.351013 8.20221-1.07697 12.2402c-0.736328 4.09644-1.93805 7.909-3.83258 11.6309c-1.86206 3.65814-4.29517 7.0058-7.19769 9.90826c-2.90253 2.90253-6.25018 5.33569-9.90826 7.19769c-3.72192 1.89453-7.53455 3.09625-11.6309 3.83264c-4.03809 0.725891-8.15686 0.966248-12.2402 1.0769c-1.87396 0.0507812-3.74799 0.0724487-5.62238 0.0831909c-2.2254 0.0140381-4.4505 0.0131226-6.6759 0.0131226h-43.4375c-2.22543 0-4.4505 0.0009766-6.67593-0.0131226c-1.87442-0.0107422-3.74844-0.0323486-5.62231-0.0831299c-4.08337-0.110718-8.20221-0.351013-12.2403-1.0769c-4.09644-0.736389-7.909-1.93811-11.6309-3.83264c-3.65814-1.86206-7.00577-4.29517-9.90829-7.19769s-5.33563-6.25012-7.19769-9.90826c-1.8945-3.72192-3.09625-7.53448-3.83264-11.6309c-0.725861-4.03809-0.966217-8.15686-1.07687-12.2403c-0.0508118-1.87384-0.0724182-3.74786-0.0831909-5.62231c-0.0140686-2.2254-0.0131226-4.4505-0.0131226-6.6759L432 509.936c0-2.22543-0.0009155-4.4505 0.0131226-6.6759c0.0107727-1.87442 0.0324097-3.74844 0.0831909-5.62235c0.110687-4.08337 0.351013-8.20221 1.07687-12.2403c0.736389-4.0964 1.93814-7.909 3.83264-11.6309c1.86203-3.65811 4.29517-7.00574 7.19769-9.90826c2.9025-2.90253 6.25012-5.33566 9.90826-7.19769c3.72195-1.89453 7.53452-3.09628 11.6309-3.83261c4.03806-0.725922 8.15689-0.966217 12.2403-1.07693c1.87387-0.0507812 3.74789-0.0724487 5.62235-0.0831909c2.22543-0.0140686 4.4505-0.013092 6.67593-0.013092h43.4375c2.2254-3.05e-05 4.4505-0.0010071 6.6759 0.013092c1.87439 0.0107422 3.74847 0.0324097 5.62238 0.0831604c4.08337 0.110718 8.20221 0.351013 12.2402 1.07693c4.09637 0.736328 7.909 1.93805 11.6309 3.83261c3.65808 1.862 7.00574 4.29514 9.90826 7.19766s5.33569 6.25018 7.19769 9.90829c1.89453 3.72189 3.09631 7.53448 3.83264 11.6309c0.725891 4.03802 0.966186 8.15686 1.0769 12.2402c0.0507812 1.8739 0.0724487 3.74796 0.0831909 5.62235C592.001 505.485 592 507.71 592 509.936V553.373z" fill="url(#SVGID_1_)" style="fill:url(#SVGID_1_);"/>
</g>
</svg>
```
## /LaunchNext/AppIcon.icon/Assets/9.chiclet1_unspecified_unspecified_automatic.svg
```svg path="/LaunchNext/AppIcon.icon/Assets/9.chiclet1_unspecified_unspecified_automatic.svg"
<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Apple Native CoreSVG 341-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="SVGID_1_" x1="1982.79" y1="656.474" x2="2142.79" y2="656.474" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0.0000003 1 -1 0.0000003 928.4728394 -1531.1361084)">
<stop offset="0" stop-color="#ffffff" stop-opacity="1"/>
<stop offset="0" stop-color="#ffa759" stop-opacity="1"/>
<stop offset="1" stop-color="#ff7700" stop-opacity="1"/>
</linearGradient>
</defs>
<g/>
<g id="chiclet1">
<path d="M352 553.373c0 2.2254 0.0009766 4.4505-0.013092 6.6759c-0.0107422 1.87445-0.0324097 3.74847-0.0831604 5.62238c-0.110718 4.08337-0.351013 8.20221-1.07693 12.2402c-0.736328 4.09644-1.93808 7.909-3.83261 11.6309c-1.86203 3.65814-4.29517 7.0058-7.19769 9.90826c-2.90253 2.90253-6.25015 5.33569-9.90826 7.19769c-3.72192 1.89453-7.53452 3.09625-11.6309 3.83264c-4.03806 0.725891-8.15686 0.966248-12.2402 1.0769c-1.87393 0.0507812-3.74796 0.0724487-5.62235 0.0831909c-2.22543 0.0140381-4.4505 0.0131226-6.67593 0.0131226h-43.4375c-2.22543 0-4.45052 0.0009766-6.67593-0.0131226c-1.87444-0.0107422-3.74846-0.0323486-5.62233-0.0831299c-4.08339-0.110718-8.20221-0.351013-12.2403-1.0769c-4.09644-0.736389-7.90898-1.93811-11.6309-3.83264c-3.65814-1.86206-7.00578-4.29517-9.90829-7.19769c-2.90253-2.90253-5.33565-6.25012-7.19768-9.90826c-1.89452-3.72192-3.09627-7.53448-3.83264-11.6309c-0.725891-4.03809-0.966217-8.15686-1.07689-12.2403c-0.0507965-1.87384-0.0724182-3.74786-0.0831909-5.62231c-0.0140686-2.2254-0.0131226-4.4505-0.0131226-6.6759L192 509.936c0-2.22543-0.000946-4.4505 0.0131226-6.6759c0.0107727-1.87442 0.0323792-3.74844 0.0831757-5.62235c0.110672-4.08337 0.351013-8.20221 1.07689-12.2403c0.736374-4.0964 1.93811-7.909 3.83263-11.6309c1.86205-3.65811 4.29517-7.00574 7.19768-9.90826s6.25015-5.33566 9.90829-7.19769c3.72192-1.89453 7.53448-3.09628 11.6309-3.83261c4.03806-0.725922 8.15688-0.966217 12.2402-1.07693c1.87387-0.0507812 3.74791-0.0724487 5.62233-0.0831909c2.22543-0.0140686 4.4505-0.013092 6.67593-0.013092H293.719c2.2254-3.05e-05 4.45047-0.0010071 6.6759 0.013092c1.87439 0.0107422 3.74844 0.0324097 5.62235 0.0831604c4.08337 0.110718 8.20221 0.351013 12.2402 1.07693c4.0964 0.736328 7.90903 1.93805 11.6309 3.83261c3.65811 1.862 7.00577 4.29514 9.90829 7.19766s5.33566 6.25018 7.19769 9.90829c1.89453 3.72189 3.09628 7.53448 3.83261 11.6309c0.725922 4.03802 0.966217 8.15686 1.07693 12.2402c0.0507507 1.8739 0.0724182 3.74796 0.0831604 5.62235c0.0140991 2.22543 0.0131226 4.45047 0.0131226 6.6759V553.373z" fill="url(#SVGID_1_)" style="fill:url(#SVGID_1_);"/>
</g>
</svg>
```
## /LaunchNext/AppIcon.icon/icon.json
```json path="/LaunchNext/AppIcon.icon/icon.json"
{
"fill-specializations" : [
{
"value" : "automatic"
},
{
"appearance" : "dark",
"value" : "automatic"
}
],
"groups" : [
{
"blur-material" : null,
"layers" : [
{
"image-name" : "9.chiclet1_unspecified_unspecified_automatic.svg",
"name" : "9.chiclet1_unspecified_unspecified_automatic"
},
{
"image-name" : "8.chiclet2_unspecified_unspecified_automatic.svg",
"name" : "8.chiclet2_unspecified_unspecified_automatic"
},
{
"image-name" : "7.chiclet3_unspecified_unspecified_automatic.svg",
"name" : "7.chiclet3_unspecified_unspecified_automatic"
},
{
"image-name" : "6.chiclet4_unspecified_unspecified_automatic.svg",
"name" : "6.chiclet4_unspecified_unspecified_automatic"
},
{
"image-name" : "5.chiclet5_unspecified_unspecified_automatic.svg",
"name" : "5.chiclet5_unspecified_unspecified_automatic"
},
{
"image-name" : "4.chiclet6_unspecified_unspecified_automatic.svg",
"name" : "4.chiclet6_unspecified_unspecified_automatic"
},
{
"image-name" : "3.magnifier_unspecified_unspecified_automatic.svg",
"name" : "3.magnifier_unspecified_unspecified_automatic"
},
{
"image-name" : "2.searchbar_unspecified_unspecified_automatic.svg",
"name" : "2.searchbar_unspecified_unspecified_automatic"
}
],
"lighting" : "individual",
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"specular" : true,
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"squares" : "shared"
}
}
```
## /LaunchNext/AppInfo.swift
```swift path="/LaunchNext/AppInfo.swift"
import Foundation
import AppKit
import CoreServices
struct AppInfo: Identifiable, Equatable, Hashable {
let name: String
let icon: NSImage
let url: URL
static let transparentPlaceholderIcon: NSImage = {
let size = NSSize(width: 1, height: 1)
let image = NSImage(size: size)
image.lockFocus()
NSColor.clear.setFill()
NSBezierPath(rect: NSRect(origin: .zero, size: size)).fill()
image.unlockFocus()
image.isTemplate = false
return image
}()
// 使用应用路径作为稳定唯一标识
var id: String { url.path }
static func == (lhs: AppInfo, rhs: AppInfo) -> Bool {
lhs.url == rhs.url
}
func hash(into hasher: inout Hasher) {
hasher.combine(url.path)
}
// MARK: - 创建 AppInfo
static func from(url: URL, preferredName: String? = nil, customTitle: String? = nil, loadIcon: Bool = true) -> AppInfo {
let fallbackName = normalizeCandidate(url.deletingPathExtension().lastPathComponent)
let bundle = Bundle(url: url)
let localizedName = localizedAppName(for: url,
preferredName: preferredName,
fallbackName: fallbackName,
bundle: bundle)
let englishName = englishAppName(preferredName: preferredName,
fallbackName: fallbackName,
bundle: bundle)
let shouldUseLocalized = shouldUseLocalizedTitles()
let chosenName = shouldUseLocalized ? localizedName : englishName
let icon: NSImage
if loadIcon {
icon = NSWorkspace.shared.icon(forFile: url.path)
} else {
icon = transparentPlaceholderIcon
}
if let override = customTitle.flatMap({ title -> String? in
let normalized = normalizeCandidate(title)
return normalized.isEmpty ? nil : normalized
}) {
return AppInfo(name: override, icon: icon, url: url)
}
return AppInfo(name: chosenName, icon: icon, url: url)
}
// MARK: - 获取本地化应用名
private static func localizedAppName(for url: URL,
preferredName: String?,
fallbackName: String,
bundle: Bundle?) -> String {
var resolvedName: String? = nil
func consider(_ rawValue: String?, source: String) {
guard let rawValue = rawValue else {
return
}
let normalized = normalizeCandidate(rawValue)
if normalized.isEmpty {
return
}
guard resolvedName == nil else { return }
if normalized != fallbackName {
resolvedName = normalized
}
}
if let bundle {
consider(bundlePreferredDisplayName(bundle), source: "BundlePreferredDisplayName")
}
return resolvedName ?? fallbackName
}
private static func englishAppName(preferredName: String?,
fallbackName: String,
bundle: Bundle?) -> String {
var candidates: [String] = []
if let bundle {
let englishLocales = ["en", "en-US", "en-GB"]
for locale in englishLocales {
if let path = bundle.path(forResource: "InfoPlist",
ofType: "strings",
inDirectory: nil,
forLocalization: locale),
let dict = NSDictionary(contentsOfFile: path) as? [String: String] {
for key in ["CFBundleDisplayName", "CFBundleName"] {
if let value = dict[key], !value.isEmpty {
candidates.append(value)
}
}
}
}
for key in ["CFBundleDisplayName", "CFBundleName"] {
if let value = bundle.infoDictionary?[key] as? String, !value.isEmpty {
candidates.append(value)
}
}
}
if let preferredName, !preferredName.isEmpty {
candidates.append(preferredName)
}
for raw in candidates {
let normalized = normalizeCandidate(raw)
if !normalized.isEmpty {
return normalized
}
}
return fallbackName
}
private static func shouldUseLocalizedTitles() -> Bool {
let defaults = UserDefaults.standard
if defaults.object(forKey: "useLocalizedThirdPartyTitles") == nil {
return true
}
return defaults.bool(forKey: "useLocalizedThirdPartyTitles")
}
private static func normalizeCandidate(_ value: String) -> String {
var trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.lowercased().hasSuffix(".app") {
trimmed = String(trimmed.dropLast(4))
}
return trimmed
}
private static func bundlePreferredDisplayName(_ bundle: Bundle) -> String? {
let preferredLocales = Bundle.preferredLocalizations(from: bundle.localizations, forPreferences: userPreferredLanguages())
if let chosen = preferredLocales.first,
let lprojPath = bundle.path(forResource: chosen, ofType: "lproj"),
let localizedBundle = Bundle(path: lprojPath),
let stringsPath = localizedBundle.path(forResource: "InfoPlist", ofType: "strings"),
let dict = NSDictionary(contentsOfFile: stringsPath) as? [String: String] {
if let displayName = dict["CFBundleDisplayName"], !displayName.isEmpty {
return displayName
}
if let bundleName = dict["CFBundleName"], !bundleName.isEmpty {
return bundleName
}
}
if let localizedInfo = bundle.localizedInfoDictionary {
if let displayName = localizedInfo["CFBundleDisplayName"] as? String, !displayName.isEmpty {
return displayName
}
if let bundleName = localizedInfo["CFBundleName"] as? String, !bundleName.isEmpty {
return bundleName
}
}
return nil
}
private static func userPreferredLanguages() -> [String] {
if let languages = UserDefaults.standard.array(forKey: "AppleLanguages") as? [String], !languages.isEmpty {
return languages
}
return Locale.preferredLanguages
}
}
```
## /LaunchNext/Assets.xcassets/AboutBackground.imageset/Contents.json
```json path="/LaunchNext/Assets.xcassets/AboutBackground.imageset/Contents.json"
{
"images" : [
{
"filename" : "background_light.heic",
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "background_dark.heic",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
```
## /LaunchNext/Assets.xcassets/AboutBackground.imageset/background_dark.heic
Binary file available at https://raw.githubusercontent.com/RoversX/LaunchNext/refs/heads/main/LaunchNext/Assets.xcassets/AboutBackground.imageset/background_dark.heic
## /LaunchNext/Assets.xcassets/AboutBackground.imageset/background_light.heic
Binary file available at https://raw.githubusercontent.com/RoversX/LaunchNext/refs/heads/main/LaunchNext/Assets.xcassets/AboutBackground.imageset/background_light.heic
## /LaunchNext/Assets.xcassets/AccentColor.colorset/Contents.json
```json path="/LaunchNext/Assets.xcassets/AccentColor.colorset/Contents.json"
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
```
## /LaunchNext/Assets.xcassets/AppIcon.appiconset/Contents.json
```json path="/LaunchNext/Assets.xcassets/AppIcon.appiconset/Contents.json"
{
"images" : [
{
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
```
## /LaunchNext/Assets.xcassets/AppearanceAuto.imageset/Contents.json
```json path="/LaunchNext/Assets.xcassets/AppearanceAuto.imageset/Contents.json"
{
"images" : [
{
"filename" : "auto_appearance_mode.heic",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
```
## /LaunchNext/Assets.xcassets/AppearanceAuto.imageset/auto_appearance_mode.heic
Binary file available at https://raw.githubusercontent.com/RoversX/LaunchNext/refs/heads/main/LaunchNext/Assets.xcassets/AppearanceAuto.imageset/auto_appearance_mode.heic
## /LaunchNext/Assets.xcassets/AppearanceDark.imageset/Contents.json
```json path="/LaunchNext/Assets.xcassets/AppearanceDark.imageset/Contents.json"
{
"images" : [
{
"filename" : "dark_appearance_mode.heic",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
```
## /LaunchNext/Assets.xcassets/AppearanceDark.imageset/dark_appearance_mode.heic
Binary file available at https://raw.githubusercontent.com/RoversX/LaunchNext/refs/heads/main/LaunchNext/Assets.xcassets/AppearanceDark.imageset/dark_appearance_mode.heic
## /LaunchNext/Assets.xcassets/AppearanceLight.imageset/Contents.json
```json path="/LaunchNext/Assets.xcassets/AppearanceLight.imageset/Contents.json"
{
"images" : [
{
"filename" : "light_appearance_mode.heic",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
```
## /LaunchNext/Assets.xcassets/AppearanceLight.imageset/light_appearance_mode.heic
Binary file available at https://raw.githubusercontent.com/RoversX/LaunchNext/refs/heads/main/LaunchNext/Assets.xcassets/AppearanceLight.imageset/light_appearance_mode.heic
## /LaunchNext/Assets.xcassets/Contents.json
```json path="/LaunchNext/Assets.xcassets/Contents.json"
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
```
## /LaunchNext/Assets.xcassets/FoundaryColor.colorset/Contents.json
```json path="/LaunchNext/Assets.xcassets/FoundaryColor.colorset/Contents.json"
{
"colors" : [
{
"color" : {
"color-space" : "display-p3",
"components" : {
"alpha" : "1.000",
"blue" : "1.000",
"green" : "1.000",
"red" : "1.000"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "light"
}
],
"color" : {
"color-space" : "display-p3",
"components" : {
"alpha" : "1.000",
"blue" : "1.000",
"green" : "1.000",
"red" : "1.000"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "display-p3",
"components" : {
"alpha" : "0.700",
"blue" : "0.400",
"green" : "0.400",
"red" : "0.400"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
```
## /LaunchNext/CAFolderGridView.swift
```swift path="/LaunchNext/CAFolderGridView.swift"
import AppKit
import QuartzCore
final class CAFolderGridView: NSView {
var apps: [AppInfo] = [] {
didSet { rebuildLayers() }
}
var layoutMode: AppStore.FolderLayoutMode = .paged {
didSet {
guard layoutMode != oldValue else { return }
updateLayerClipping()
currentPage = min(currentPage, max(pageCount - 1, 0))
verticalOffset = 0
horizontalOffset = pageOffset(for: currentPage, metrics: makeMetrics())
targetHorizontalOffset = horizontalOffset
updateLayout(animated: false)
}
}
var iconSize: CGFloat = 72 {
didSet {
guard iconSize != oldValue else { return }
clearIconCache()
rebuildLayers()
}
}
var labelFontSize: CGFloat = 12 {
didSet {
guard labelFontSize != oldValue else { return }
updateLabelFonts()
updateLayout(animated: false)
}
}
var labelFontWeight: NSFont.Weight = .medium {
didSet {
guard labelFontWeight != oldValue else { return }
updateLabelFonts()
}
}
var showLabels: Bool = true {
didSet {
guard showLabels != oldValue else { return }
updateLabelVisibility()
updateLayout(animated: false)
}
}
var hoverMagnificationEnabled: Bool = false {
didSet {
guard hoverMagnificationEnabled != oldValue else { return }
if !hoverMagnificationEnabled { updateHoverIndex(nil) }
}
}
var hoverMagnificationScale: CGFloat = 1.2
var activePressEffectEnabled: Bool = false
var activePressScale: CGFloat = 0.92
var animationsEnabled: Bool = true
var animationDuration: Double = 0.3
var isLayoutLocked: Bool = false
var scrollSensitivity: Double = AppStore.defaultScrollSensitivity
var reverseWheelPagingDirection: Bool = false
var verticalHeaderHeight: CGFloat = 0 {
didSet {
guard verticalHeaderHeight != oldValue else { return }
updateLayout(animated: false)
}
}
var showInFinderMenuTitle: String = "Show in Finder"
var copyAppPathMenuTitle: String = "Copy App Path"
var hideAppMenuTitle: String = "Hide application"
var uninstallWithToolMenuTitle: String = "Uninstall with configured tool"
var canUseConfiguredUninstallTool: Bool = false
var contextMenuTargetApp: AppInfo?
var onOpenApp: ((AppInfo) -> Void)?
var onReorderApps: ((Int, Int) -> Void)?
var onDragAppOut: ((AppInfo) -> Void)?
var onShowAppInFinder: ((AppInfo) -> Void)?
var onCopyAppPath: ((AppInfo) -> Void)?
var onHideApp: ((AppInfo) -> Void)?
var onUninstallWithTool: ((AppInfo) -> Void)?
var onClose: (() -> Void)?
var onPageStateChanged: ((Int, Int) -> Void)?
var onVerticalScrollOffsetChanged: ((CGFloat) -> Void)?
private let baseContentInsets = NSEdgeInsets(top: 12, left: 12, bottom: 12, right: 12)
private var contentInsets: NSEdgeInsets {
var insets = baseContentInsets
if layoutMode == .vertical {
insets.top += verticalHeaderHeight
}
return insets
}
private let columnSpacing: CGFloat = 22
private let rowSpacing: CGFloat = 18
private let dragOutInset: CGFloat = -14
private let pageFlipEdgeWidth: CGFloat = 60
private let pageFlipDelay: TimeInterval = 0.4
private var contentLayer = CALayer()
private var displayLink: CADisplayLink?
private var appLayers: [CALayer] = []
private var itemFrames: [CGRect] = []
private var iconCache: [String: CGImage] = [:]
private let iconCacheLock = NSLock()
private var hoverTrackingArea: NSTrackingArea?
private var currentPage = 0
private var horizontalOffset: CGFloat = 0
private var targetHorizontalOffset: CGFloat = 0
private var verticalOffset: CGFloat = 0
private var pageCount: Int {
let metrics = makeMetrics()
return max(1, (apps.count + metrics.itemsPerPage - 1) / metrics.itemsPerPage)
}
private var selectedIndex: Int?
private var hoveredIndex: Int?
private var pressedIndex: Int?
private var dragStartPoint: CGPoint = .zero
private var draggingIndex: Int?
private var draggingApp: AppInfo?
private var draggingLayer: CALayer?
private var isDraggingItem = false
private var dragCurrentPoint: CGPoint = .zero
private var edgeDragTimer: Timer?
private var edgeDragDirection: Int?
private var edgeDragRequiresReentry = false
private var pendingDragUpdateAfterPageAnimation = false
private var isPageScrollDragging = false
private var isPageScrollAnimating = false
private var pageScrollStartOffset: CGFloat = 0
private var pageScrollAccumulatedDelta: CGFloat = 0
private var pageScrollSnapWorkItem: DispatchWorkItem?
private var wheelAccumulatedDelta: CGFloat = 0
private var wheelLastDirection = 0
private var wheelLastFlipAt: Date?
private let wheelFlipCooldown: TimeInterval = 0.15
private var currentHoverIndex: Int?
private var lastReportedPage: Int?
private var lastReportedPageCount: Int?
private var lastReportedVerticalOffset: CGFloat?
var displayedPage: Int { currentPage }
var displayedPageCount: Int { pageCount }
func setDisplayedPage(_ page: Int, animated: Bool) {
navigateToPage(page, animated: animated)
}
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
setup()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setup()
}
deinit {
if let tracking = hoverTrackingArea {
removeTrackingArea(tracking)
}
displayLink?.invalidate()
edgeDragTimer?.invalidate()
pageScrollSnapWorkItem?.cancel()
}
private func setup() {
wantsLayer = true
updateLayerClipping()
contentLayer.masksToBounds = false
layer?.addSublayer(contentLayer)
}
private func updateLayerClipping() {
layer?.masksToBounds = false
}
override var acceptsFirstResponder: Bool { true }
override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
override func layout() {
super.layout()
if layoutMode == .paged, !isPageScrollDragging, !isPageScrollAnimating, !isDraggingItem {
let metrics = makeMetrics()
horizontalOffset = pageOffset(for: currentPage, metrics: metrics)
targetHorizontalOffset = horizontalOffset
}
updateLayout(animated: false)
}
override func updateTrackingAreas() {
super.updateTrackingAreas()
if let tracking = hoverTrackingArea {
removeTrackingArea(tracking)
}
let options: NSTrackingArea.Options = [.mouseMoved, .mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect]
let tracking = NSTrackingArea(rect: bounds, options: options, owner: self, userInfo: nil)
addTrackingArea(tracking)
hoverTrackingArea = tracking
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
window?.acceptsMouseMovedEvents = true
if window != nil {
setupDisplayLinkIfNeeded()
} else {
displayLink?.invalidate()
displayLink = nil
}
DispatchQueue.main.async { [weak self] in
guard let self, let window = self.window else { return }
window.makeFirstResponder(self)
}
}
private func setupDisplayLinkIfNeeded() {
guard displayLink == nil, let window else { return }
displayLink = window.displayLink(target: self, selector: #selector(displayLinkFired(_:)))
displayLink?.preferredFrameRateRange = CAFrameRateRange(minimum: 60, maximum: 120, preferred: 120)
displayLink?.add(to: .main, forMode: .common)
}
@objc private func displayLinkFired(_ link: CADisplayLink) {
guard isPageScrollAnimating else { return }
updatePageScrollAnimation()
}
func updateSelection(_ index: Int?, animated: Bool = true) {
let clamped = index.flatMap { apps.indices.contains($0) ? $0 : nil }
guard selectedIndex != clamped else { return }
let old = selectedIndex
selectedIndex = clamped
if let old { applyScale(at: old, animated: animated) }
if let clamped { applyScale(at: clamped, animated: animated) }
ensureSelectionVisible()
}
func clearIconCache() {
iconCacheLock.lock()
iconCache.removeAll()
iconCacheLock.unlock()
}
private struct Metrics {
var columns: Int
var rows: Int
var itemsPerPage: Int
var cellWidth: CGFloat
var cellHeight: CGFloat
var contentHeight: CGFloat
var totalItemHeight: CGFloat
var labelHeight: CGFloat
var labelTopSpacing: CGFloat
var pageStride: CGFloat
}
private func makeMetrics() -> Metrics {
let width = max(bounds.width, 1)
let height = max(bounds.height, 1)
let availableWidth = max(1, width - contentInsets.left - contentInsets.right)
let availableHeight = max(1, height - contentInsets.top - contentInsets.bottom)
let labelHeight: CGFloat = showLabels ? labelFontSize + 8 : 0
let labelTopSpacing: CGFloat = showLabels ? 6 : 0
let totalItemHeight = iconSize + labelTopSpacing + labelHeight
let minCellWidth = max(iconSize + 18, iconSize * 1.32)
let minCellHeight = max(totalItemHeight + 12, iconSize * 1.28)
let columns = max(1, min(8, Int((availableWidth + columnSpacing) / (minCellWidth + columnSpacing))))
let usableWidth = max(1, availableWidth - CGFloat(columns - 1) * columnSpacing)
let cellWidth = usableWidth / CGFloat(columns)
if layoutMode == .paged {
let rows = max(1, min(5, Int((availableHeight + rowSpacing) / (minCellHeight + rowSpacing))))
let usableHeight = max(1, availableHeight - CGFloat(rows - 1) * rowSpacing)
let cellHeight = usableHeight / CGFloat(rows)
return Metrics(columns: columns,
rows: rows,
itemsPerPage: max(1, columns * rows),
cellWidth: cellWidth,
cellHeight: cellHeight,
contentHeight: height,
totalItemHeight: totalItemHeight,
labelHeight: labelHeight,
labelTopSpacing: labelTopSpacing,
pageStride: width)
}
let rows = max(1, Int(ceil(Double(apps.count) / Double(max(columns, 1)))))
let cellHeight = minCellHeight
let contentHeight = contentInsets.top + contentInsets.bottom + CGFloat(rows) * cellHeight + CGFloat(max(rows - 1, 0)) * rowSpacing
return Metrics(columns: columns,
rows: rows,
itemsPerPage: max(1, columns * max(rows, 1)),
cellWidth: cellWidth,
cellHeight: cellHeight,
contentHeight: max(height, contentHeight),
totalItemHeight: totalItemHeight,
labelHeight: labelHeight,
labelTopSpacing: labelTopSpacing,
pageStride: width)
}
private func rebuildLayers() {
appLayers.forEach { $0.removeFromSuperlayer() }
appLayers = apps.map { makeAppLayer(for: $0) }
appLayers.forEach { contentLayer.addSublayer($0) }
if selectedIndex.map({ !apps.indices.contains($0) }) ?? false {
selectedIndex = apps.indices.first
}
updateLayout(animated: false)
}
private func makeAppLayer(for app: AppInfo) -> CALayer {
let container = CALayer()
container.masksToBounds = false
container.contentsScale = backingScale
let iconLayer = CALayer()
iconLayer.name = "icon"
iconLayer.contentsGravity = .resizeAspect
iconLayer.contentsScale = backingScale
iconLayer.masksToBounds = false
iconLayer.shouldRasterize = true
iconLayer.rasterizationScale = backingScale
container.addSublayer(iconLayer)
let textLayer = CATextLayer()
textLayer.name = "label"
textLayer.contentsScale = backingScale
textLayer.alignmentMode = .center
textLayer.truncationMode = .end
textLayer.isWrapped = false
textLayer.fontSize = labelFontSize
textLayer.font = NSFont.systemFont(ofSize: labelFontSize, weight: labelFontWeight)
textLayer.foregroundColor = currentLabelColor().cgColor
textLayer.string = app.name
textLayer.shouldRasterize = true
textLayer.rasterizationScale = backingScale
textLayer.isHidden = !showLabels
container.addSublayer(textLayer)
let warningLayer = CATextLayer()
warningLayer.name = "missingWarning"
warningLayer.contentsScale = backingScale
warningLayer.alignmentMode = .center
warningLayer.fontSize = max(10, iconSize * 0.13)
warningLayer.font = NSFont.systemFont(ofSize: max(10, iconSize * 0.13), weight: .bold)
warningLayer.foregroundColor = NSColor.white.cgColor
warningLayer.backgroundColor = NSColor.systemOrange.cgColor
warningLayer.cornerRadius = max(7, iconSize * 0.11)
warningLayer.masksToBounds = true
warningLayer.string = "!"
warningLayer.isHidden = FileManager.default.fileExists(atPath: app.url.path)
container.addSublayer(warningLayer)
setIcon(for: iconLayer, app: app)
return container
}
private var backingScale: CGFloat {
window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2
}
private func updateLayout(animated: Bool) {
guard bounds.width > 0, bounds.height > 0 else { return }
let metrics = makeMetrics()
currentPage = min(currentPage, max(pageCount - 1, 0))
if layoutMode == .paged {
targetHorizontalOffset = clampHorizontalOffset(pageOffset(for: currentPage, metrics: metrics), metrics: metrics)
horizontalOffset = clampHorizontalOffset(horizontalOffset, metrics: metrics)
if !isPageScrollDragging, !isPageScrollAnimating, !isDraggingItem {
horizontalOffset = targetHorizontalOffset
}
verticalOffset = 0
} else {
horizontalOffset = 0
targetHorizontalOffset = 0
verticalOffset = clampVerticalOffset(verticalOffset, metrics: metrics)
}
CATransaction.begin()
CATransaction.setDisableActions(true)
CATransaction.setAnimationDuration(0)
CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(name: .easeOut))
if layoutMode == .paged {
let totalWidth = CGFloat(max(pageCount, 1)) * metrics.pageStride
// Keep frame updates out of a transformed coordinate space; otherwise CA can leave the page container a few percent off.
contentLayer.transform = CATransform3DIdentity
contentLayer.frame = CGRect(x: 0, y: 0, width: totalWidth, height: bounds.height)
contentLayer.transform = CATransform3DMakeTranslation(horizontalOffset, 0, 0)
} else {
contentLayer.transform = CATransform3DIdentity
contentLayer.frame = bounds
}
itemFrames = Array(repeating: .zero, count: apps.count)
for (index, layer) in appLayers.enumerated() {
guard index < apps.count else { continue }
let frame = frameForItem(at: index, metrics: metrics)
itemFrames[index] = visibleFrame(frame)
layer.transform = CATransform3DIdentity
layer.frame = frame
layoutSublayers(of: layer, metrics: metrics)
layer.opacity = draggingIndex == index ? 0 : 1
}
CATransaction.commit()
notifyPageStateChanged()
notifyVerticalScrollOffsetChanged()
}
private func frameForItem(at index: Int, metrics: Metrics) -> CGRect {
frameForGridSlot(at: index, metrics: metrics)
}
private func frameForGridSlot(at index: Int, metrics: Metrics) -> CGRect {
let pageIndex: Int
let localIndex: Int
let xOffset: CGFloat
if layoutMode == .paged {
pageIndex = index / metrics.itemsPerPage
localIndex = index % metrics.itemsPerPage
xOffset = CGFloat(pageIndex) * metrics.pageStride
} else {
pageIndex = 0
localIndex = index
xOffset = 0
}
_ = pageIndex
let col = localIndex % metrics.columns
let row = localIndex / metrics.columns
let x = contentInsets.left + xOffset + CGFloat(col) * (metrics.cellWidth + columnSpacing)
let topBasedY = bounds.height - contentInsets.top - CGFloat(row + 1) * metrics.cellHeight - CGFloat(row) * rowSpacing
let y = topBasedY + (metrics.cellHeight - metrics.totalItemHeight) / 2 - (layoutMode == .vertical ? verticalOffset : 0)
return CGRect(x: x, y: y, width: metrics.cellWidth, height: metrics.totalItemHeight)
}
private func visibleFrame(_ frame: CGRect) -> CGRect {
layoutMode == .paged ? frame.offsetBy(dx: horizontalOffset, dy: 0) : frame
}
private func pageOffset(for page: Int, metrics: Metrics) -> CGFloat {
-CGFloat(page) * metrics.pageStride
}
private func clampHorizontalOffset(_ value: CGFloat, metrics: Metrics) -> CGFloat {
let minOffset = -CGFloat(max(pageCount - 1, 0)) * metrics.pageStride
return min(0, max(minOffset, value))
}
private func layoutSublayers(of layer: CALayer, metrics: Metrics) {
layer.transform = CATransform3DIdentity
let iconX = (metrics.cellWidth - iconSize) / 2
let iconY = metrics.labelHeight + metrics.labelTopSpacing
let iconFrame = CGRect(x: iconX, y: iconY, width: iconSize, height: iconSize)
if let iconLayer = layer.sublayers?.first(where: { $0.name == "icon" }) {
iconLayer.transform = CATransform3DIdentity
iconLayer.frame = iconFrame
}
if let textLayer = layer.sublayers?.first(where: { $0.name == "label" }) as? CATextLayer {
textLayer.isHidden = !showLabels
textLayer.frame = CGRect(x: 4, y: 0, width: metrics.cellWidth - 8, height: metrics.labelHeight)
}
if let warningLayer = layer.sublayers?.first(where: { $0.name == "missingWarning" }) as? CATextLayer {
let side = max(14, iconSize * 0.22)
warningLayer.frame = CGRect(x: iconFrame.maxX - side - iconSize * 0.05,
y: iconFrame.maxY - side - iconSize * 0.05,
width: side,
height: side)
warningLayer.cornerRadius = side / 2
}
if let index = appLayers.firstIndex(of: layer) {
applyScale(at: index, animated: false)
}
}
private func setIcon(for layer: CALayer, app: AppInfo) {
let path = app.url.path
let scale = backingScale
let side = iconSize
let expectedKey = iconCacheKey(for: path, scale: scale)
layer.setValue(expectedKey, forKey: "iconCacheKey")
if let cached = cachedIcon(for: path) {
layer.contents = cached
return
}
layer.contents = nil
DispatchQueue.global(qos: .userInitiated).async { [weak self, weak layer] in
guard let self, let layer else { return }
guard layer.value(forKey: "iconCacheKey") as? String == expectedKey else { return }
let icon: NSImage
if FileManager.default.fileExists(atPath: path) {
icon = IconStore.shared.icon(forPath: path)
} else {
icon = MissingAppPlaceholder.defaultIcon
}
guard let cgImage = self.renderIcon(icon, key: expectedKey, side: side, scale: scale) else { return }
DispatchQueue.main.async {
guard layer.value(forKey: "iconCacheKey") as? String == expectedKey else { return }
CATransaction.begin()
CATransaction.setDisableActions(true)
layer.contents = cgImage
CATransaction.commit()
}
}
}
private func cachedIcon(for path: String) -> CGImage? {
let key = iconCacheKey(for: path, scale: backingScale)
iconCacheLock.lock()
defer { iconCacheLock.unlock() }
return iconCache[key]
}
private func iconCacheKey(for path: String, scale: CGFloat) -> String {
"\(path)#\(Int(iconSize.rounded()))#\(Int((scale * 100).rounded()))"
}
private func renderIcon(_ icon: NSImage, key: String, side: CGFloat, scale: CGFloat) -> CGImage? {
iconCacheLock.lock()
if let cached = iconCache[key] {
iconCacheLock.unlock()
return cached
}
iconCacheLock.unlock()
let pixelSide = max(16, Int((side * scale).rounded()))
let image = NSImage(size: NSSize(width: pixelSide, height: pixelSide))
image.lockFocus()
NSGraphicsContext.current?.imageInterpolation = .high
icon.draw(in: NSRect(x: 0, y: 0, width: pixelSide, height: pixelSide))
image.unlockFocus()
guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return nil }
iconCacheLock.lock()
iconCache[key] = cgImage
iconCacheLock.unlock()
return cgImage
}
private func updateLabelFonts() {
for layer in appLayers {
if let text = layer.sublayers?.first(where: { $0.name == "label" }) as? CATextLayer {
text.fontSize = labelFontSize
text.font = NSFont.systemFont(ofSize: labelFontSize, weight: labelFontWeight)
}
}
}
private func updateLabelVisibility() {
for layer in appLayers {
if let text = layer.sublayers?.first(where: { $0.name == "label" }) as? CATextLayer {
text.isHidden = !showLabels
}
}
}
private func updateLabelColors() {
let resolvedColor = currentLabelColor().cgColor
CATransaction.begin()
CATransaction.setDisableActions(true)
for layer in appLayers {
if let text = layer.sublayers?.first(where: { $0.name == "label" }) as? CATextLayer {
text.foregroundColor = resolvedColor
}
}
CATransaction.commit()
}
private func currentLabelColor() -> NSColor {
let match = effectiveAppearance.bestMatch(from: [.darkAqua, .aqua])
return match == .darkAqua ? .white : .black
}
override func viewDidChangeEffectiveAppearance() {
super.viewDidChangeEffectiveAppearance()
updateLabelColors()
}
override func mouseMoved(with event: NSEvent) {
guard hoverMagnificationEnabled, !isDraggingItem else {
updateHoverIndex(nil)
return
}
let point = convert(event.locationInWindow, from: nil)
updateHoverIndex(itemIndex(at: point))
}
override func mouseExited(with event: NSEvent) {
updateHoverIndex(nil)
}
override func mouseDown(with event: NSEvent) {
window?.makeFirstResponder(self)
let point = convert(event.locationInWindow, from: nil)
guard let index = itemIndex(at: point) else { return }
pressedIndex = index
dragStartPoint = point
applyScale(at: index, animated: true)
}
override func mouseDragged(with event: NSEvent) {
let point = convert(event.locationInWindow, from: nil)
if !isDraggingItem, let pressedIndex {
guard !isLayoutLocked else { return }
let distance = hypot(point.x - dragStartPoint.x, point.y - dragStartPoint.y)
if distance > 10, apps.indices.contains(pressedIndex) {
startDragging(at: pressedIndex, point: point)
}
}
if isDraggingItem {
updateDragging(at: point)
}
}
override func mouseUp(with event: NSEvent) {
let point = convert(event.locationInWindow, from: nil)
if isDraggingItem {
finishDragging(at: point)
return
}
if let index = pressedIndex {
pressedIndex = nil
applyScale(at: index, animated: true)
if itemIndex(at: point) == index, apps.indices.contains(index) {
let app = apps[index]
if FileManager.default.fileExists(atPath: app.url.path) {
onOpenApp?(app)
} else {
NSSound.beep()
}
}
}
}
override func scrollWheel(with event: NSEvent) {
if layoutMode == .paged {
handlePagedScroll(event)
} else {
handleVerticalScroll(event)
}
}
override func keyDown(with event: NSEvent) {
if event.keyCode == 53 {
onClose?()
return
}
guard !apps.isEmpty else {
super.keyDown(with: event)
return
}
if selectedIndex == nil { updateSelection(0, animated: true) }
guard let selectedIndex else { return }
switch event.keyCode {
case 36:
let app = apps[selectedIndex]
if FileManager.default.fileExists(atPath: app.url.path) {
onOpenApp?(app)
} else {
NSSound.beep()
}
case 123:
updateSelection(max(0, selectedIndex - 1), animated: true)
case 124:
updateSelection(min(apps.count - 1, selectedIndex + 1), animated: true)
case 125:
updateSelection(min(apps.count - 1, selectedIndex + makeMetrics().columns), animated: true)
case 126:
updateSelection(max(0, selectedIndex - makeMetrics().columns), animated: true)
default:
super.keyDown(with: event)
}
}
private func handlePagedScroll(_ event: NSEvent) {
let deltaX = event.scrollingDeltaX
let deltaY = event.scrollingDeltaY
let dominant = scaledPageDelta(deltaX: deltaX, deltaY: deltaY)
if !event.hasPreciseScrollingDeltas {
if dominant != 0 {
handleWheelPaging(with: dominant)
}
return
}
let phase = event.phase
let momentumPhase = event.momentumPhase
let phaseLessScroll = phase.isEmpty && momentumPhase.isEmpty
let ended = phase.contains(.ended)
|| phase.contains(.cancelled)
|| momentumPhase.contains(.ended)
|| momentumPhase.contains(.cancelled)
if phase.contains(.began) {
beginPageScroll()
}
if (phase.contains(.changed) || phaseLessScroll), dominant != 0 {
if !(isPageScrollAnimating && !isPageScrollDragging) {
if !isPageScrollDragging { beginPageScroll() }
updatePageScroll(by: dominant)
if phaseLessScroll {
schedulePageScrollSnap(velocity: dominant)
}
}
}
if ended {
finishPageScroll(velocity: dominant)
}
}
private func schedulePageScrollSnap(velocity: CGFloat) {
pageScrollSnapWorkItem?.cancel()
let workItem = DispatchWorkItem { [weak self] in
self?.finishPageScroll(velocity: velocity)
}
pageScrollSnapWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12, execute: workItem)
}
private func scaledPageDelta(deltaX: CGFloat, deltaY: CGFloat) -> CGFloat {
let rawDelta = abs(deltaX) > abs(deltaY) ? deltaX : -deltaY
let baseline = max(AppStore.defaultScrollSensitivity, 0.0001)
let sensitivityScale = CGFloat(max(scrollSensitivity, 0.0001) / baseline)
return rawDelta * sensitivityScale
}
private func handleWheelPaging(with scaledDelta: CGFloat) {
let direction = scaledDelta > 0 ? 1 : -1
let effectiveDirection = reverseWheelPagingDirection ? -direction : direction
if wheelLastDirection != direction {
wheelAccumulatedDelta = 0
}
wheelLastDirection = direction
wheelAccumulatedDelta += abs(scaledDelta)
let threshold: CGFloat = 2.0
guard wheelAccumulatedDelta >= threshold else { return }
let now = Date()
if let last = wheelLastFlipAt, now.timeIntervalSince(last) < wheelFlipCooldown {
return
}
let targetPage = effectiveDirection > 0 ? currentPage - 1 : currentPage + 1
wheelLastFlipAt = now
wheelAccumulatedDelta = 0
if targetPage < 0 || targetPage >= pageCount {
animatePageBoundaryNudge(toward: targetPage)
return
}
navigateToPage(targetPage, animated: true)
}
private func beginPageScroll() {
pageScrollSnapWorkItem?.cancel()
isPageScrollAnimating = false
isPageScrollDragging = true
pageScrollStartOffset = horizontalOffset
pageScrollAccumulatedDelta = 0
}
private func updatePageScroll(by delta: CGFloat) {
isPageScrollAnimating = false
pageScrollAccumulatedDelta += delta
let metrics = makeMetrics()
let minOffset = pageOffset(for: max(pageCount - 1, 0), metrics: metrics)
let maxOffset: CGFloat = 0
var newOffset = pageScrollStartOffset + pageScrollAccumulatedDelta
if newOffset > maxOffset {
newOffset = maxOffset + rubberBand(newOffset - maxOffset, limit: bounds.width * 0.2)
} else if newOffset < minOffset {
newOffset = minOffset + rubberBand(newOffset - minOffset, limit: bounds.width * 0.2)
}
horizontalOffset = newOffset
applyHorizontalOffset()
}
private func finishPageScroll(velocity: CGFloat) {
pageScrollSnapWorkItem?.cancel()
guard isPageScrollDragging else {
if !isPageScrollAnimating {
snapToNearestPage(animated: true)
}
return
}
isPageScrollDragging = false
let metrics = makeMetrics()
let pageStride = max(metrics.pageStride, 1)
let threshold = pageStride * 0.15
let velocityThreshold: CGFloat = 30
var targetPage = Int(round(-horizontalOffset / pageStride))
if pageScrollAccumulatedDelta < -threshold || velocity < -velocityThreshold {
targetPage = max(targetPage, currentPage + 1)
} else if pageScrollAccumulatedDelta > threshold || velocity > velocityThreshold {
targetPage = min(targetPage, currentPage - 1)
} else {
targetPage = currentPage
}
pageScrollAccumulatedDelta = 0
navigateToPage(targetPage, animated: true)
}
private func snapToNearestPage(animated: Bool) {
let metrics = makeMetrics()
let pageStride = max(metrics.pageStride, 1)
let nearestPage = Int(round(-horizontalOffset / pageStride))
navigateToPage(nearestPage, animated: animated)
}
private func handleVerticalScroll(_ event: NSEvent) {
let metrics = makeMetrics()
let raw = event.scrollingDeltaY
let baseline = max(AppStore.defaultScrollSensitivity, 0.0001)
let sensitivityScale = CGFloat(max(scrollSensitivity, 0.0001) / baseline)
var delta = (event.hasPreciseScrollingDeltas ? raw : -raw) * sensitivityScale
if reverseWheelPagingDirection {
delta = -delta
}
verticalOffset = clampVerticalOffset(verticalOffset - delta, metrics: metrics)
updateLayout(animated: false)
}
private func navigateToPage(_ page: Int, animated: Bool) {
let target = min(max(0, page), max(pageCount - 1, 0))
let metrics = makeMetrics()
let resolvedOffset = clampHorizontalOffset(pageOffset(for: target, metrics: metrics), metrics: metrics)
if animated, page != target, layoutMode == .paged, abs(horizontalOffset - resolvedOffset) <= 0.5 {
animatePageBoundaryNudge(toward: page)
return
}
currentPage = target
targetHorizontalOffset = resolvedOffset
wheelAccumulatedDelta = 0
wheelLastDirection = 0
let needsAnimation = animated && animationsEnabled && abs(horizontalOffset - targetHorizontalOffset) > 0.5
if needsAnimation {
setupDisplayLinkIfNeeded()
isPageScrollAnimating = true
} else {
isPageScrollAnimating = false
horizontalOffset = targetHorizontalOffset
}
applyHorizontalOffset()
notifyPageStateChanged()
}
private func animatePageBoundaryNudge(toward requestedPage: Int) {
guard layoutMode == .paged, pageCount > 0, !isPageScrollDragging else { return }
let metrics = makeMetrics()
let page = min(max(0, currentPage), max(pageCount - 1, 0))
let baseOffset = clampHorizontalOffset(pageOffset(for: page, metrics: metrics), metrics: metrics)
let direction: CGFloat = requestedPage < 0 ? 1 : -1
let rawNudge = min(max(bounds.width * 0.08, 18), 44)
horizontalOffset = baseOffset + direction * rawNudge
targetHorizontalOffset = baseOffset
currentPage = page
isPageScrollAnimating = animationsEnabled
if animationsEnabled {
setupDisplayLinkIfNeeded()
} else {
horizontalOffset = targetHorizontalOffset
isPageScrollAnimating = false
}
applyHorizontalOffset()
notifyPageStateChanged()
}
private func notifyPageStateChanged() {
guard bounds.width > 0, bounds.height > 0 else { return }
let count = pageCount
let page = min(max(0, currentPage), max(count - 1, 0))
guard lastReportedPage != page || lastReportedPageCount != count else { return }
lastReportedPage = page
lastReportedPageCount = count
onPageStateChanged?(page, count)
}
private func notifyVerticalScrollOffsetChanged() {
let offset = layoutMode == .vertical ? max(0, -verticalOffset) : 0
guard lastReportedVerticalOffset.map({ abs($0 - offset) > 0.5 }) ?? true else { return }
lastReportedVerticalOffset = offset
onVerticalScrollOffsetChanged?(offset)
}
private func updatePageScrollAnimation() {
let metrics = makeMetrics()
targetHorizontalOffset = clampHorizontalOffset(pageOffset(for: currentPage, metrics: metrics), metrics: metrics)
if !animationsEnabled {
horizontalOffset = targetHorizontalOffset
isPageScrollAnimating = false
} else {
let diff = targetHorizontalOffset - horizontalOffset
if abs(diff) > 0.5 {
horizontalOffset += diff * 0.18
} else {
horizontalOffset = targetHorizontalOffset
isPageScrollAnimating = false
}
}
applyHorizontalOffset()
if !isPageScrollAnimating, pendingDragUpdateAfterPageAnimation {
pendingDragUpdateAfterPageAnimation = false
if isDraggingItem {
DispatchQueue.main.async { [weak self] in
guard let self, self.isDraggingItem else { return }
self.updateDragging(at: self.dragCurrentPoint)
}
}
}
}
private func applyHorizontalOffset() {
CATransaction.begin()
CATransaction.setDisableActions(true)
CATransaction.setAnimationDuration(0)
contentLayer.transform = layoutMode == .paged ? CATransform3DMakeTranslation(horizontalOffset, 0, 0) : CATransform3DIdentity
CATransaction.commit()
updateVisibleItemFrames()
}
private func updateVisibleItemFrames() {
guard layoutMode == .paged else { return }
for (index, layer) in appLayers.enumerated() where itemFrames.indices.contains(index) {
itemFrames[index] = visibleFrame(layer.frame)
}
}
private func clampVerticalOffset(_ value: CGFloat, metrics: Metrics) -> CGFloat {
let minOffset = min(0, bounds.height - metrics.contentHeight)
return min(0, max(minOffset, value))
}
private func rubberBand(_ offset: CGFloat, limit: CGFloat) -> CGFloat {
let factor: CGFloat = 0.5
let absOffset = abs(offset)
let scaled = (factor * absOffset * limit) / (absOffset + limit)
return offset >= 0 ? scaled : -scaled
}
private func itemIndex(at point: CGPoint) -> Int? {
for (index, frame) in itemFrames.enumerated() where frame.contains(point) {
return index
}
return nil
}
func contextMenuItemIndex(at point: CGPoint) -> Int? {
itemIndex(at: point)
}
private func gridIndex(at point: CGPoint) -> Int? {
guard bounds.width > 0, bounds.height > 0 else { return nil }
let metrics = makeMetrics()
if layoutMode == .paged {
guard !isPageScrollAnimating else { return nil }
let page = min(max(0, currentPage), max(pageCount - 1, 0))
let localX = point.x - contentInsets.left
let localY = bounds.height - point.y - contentInsets.top
guard localX >= 0, localY >= 0 else { return nil }
let col = Int(localX / (metrics.cellWidth + columnSpacing))
let row = Int(localY / (metrics.cellHeight + rowSpacing))
guard col >= 0, col < metrics.columns, row >= 0, row < metrics.rows else { return nil }
return min(apps.count, page * metrics.itemsPerPage + row * metrics.columns + col)
}
let localX = point.x - contentInsets.left
let localY = bounds.height - point.y - verticalOffset - contentInsets.top
guard localX >= 0, localY >= 0 else { return nil }
let col = Int(localX / (metrics.cellWidth + columnSpacing))
let row = Int(localY / (metrics.cellHeight + rowSpacing))
guard col >= 0, col < metrics.columns, row >= 0 else { return nil }
return min(apps.count, row * metrics.columns + col)
}
private func startDragging(at index: Int, point: CGPoint) {
guard apps.indices.contains(index) else { return }
isDraggingItem = true
draggingIndex = index
draggingApp = apps[index]
currentHoverIndex = nil
pressedIndex = nil
appLayers[index].opacity = 0
draggingLayer = makeDraggingLayer(for: apps[index], at: point)
if let draggingLayer { layer?.addSublayer(draggingLayer) }
}
private func makeDraggingLayer(for app: AppInfo, at point: CGPoint) -> CALayer {
let container = CALayer()
container.frame = CGRect(x: point.x - iconSize / 2, y: point.y - iconSize / 2, width: iconSize, height: iconSize)
container.transform = CATransform3DMakeScale(1.08, 1.08, 1)
container.shadowColor = NSColor.black.cgColor
container.shadowOpacity = 0.18
container.shadowRadius = 10
container.shadowOffset = CGSize(width: 0, height: -3)
let iconLayer = CALayer()
iconLayer.contentsGravity = .resizeAspect
iconLayer.contentsScale = backingScale
iconLayer.frame = container.bounds
if let cached = cachedIcon(for: app.url.path) {
iconLayer.contents = cached
}
container.addSublayer(iconLayer)
setIcon(for: iconLayer, app: app)
return container
}
private func updateDragging(at point: CGPoint) {
dragCurrentPoint = point
CATransaction.begin()
CATransaction.setDisableActions(true)
draggingLayer?.frame = CGRect(x: point.x - iconSize / 2, y: point.y - iconSize / 2, width: iconSize, height: iconSize)
CATransaction.commit()
if point.x < dragOutInset || point.y < dragOutInset || point.x > bounds.width - dragOutInset || point.y > bounds.height - dragOutInset {
if let app = draggingApp {
cancelDragging(restoreSource: false)
onDragAppOut?(app)
}
return
}
if layoutMode == .paged {
let edgeDirection: Int?
if point.x < pageFlipEdgeWidth {
edgeDirection = -1
} else if point.x > bounds.width - pageFlipEdgeWidth {
edgeDirection = 1
} else {
edgeDirection = nil
}
if let edgeDirection {
if !edgeDragRequiresReentry {
startEdgeFlipTimer(direction: edgeDirection)
}
} else {
edgeDragRequiresReentry = false
cancelEdgeFlipTimer()
}
guard !isPageScrollAnimating else { return }
}
let hoverIndex = gridIndex(at: point)
updateReorderPreview(targetIndex: hoverIndex == draggingIndex ? nil : hoverIndex)
}
private func startEdgeFlipTimer(direction: Int) {
guard layoutMode == .paged, !isPageScrollAnimating else { return }
let targetPage = currentPage + direction
guard targetPage >= 0, targetPage < pageCount else {
cancelEdgeFlipTimer()
return
}
if edgeDragDirection == direction, edgeDragTimer != nil { return }
cancelEdgeFlipTimer()
edgeDragDirection = direction
let timer = Timer(timeInterval: pageFlipDelay, repeats: false) { [weak self] _ in
guard let self else { return }
self.edgeDragTimer = nil
self.edgeDragDirection = nil
guard self.isDraggingItem else { return }
let nextPage = self.currentPage + direction
guard nextPage >= 0, nextPage < self.pageCount else { return }
self.resetReorderPreview(animated: false)
self.currentHoverIndex = nil
self.edgeDragRequiresReentry = true
self.pendingDragUpdateAfterPageAnimation = true
self.navigateToPage(nextPage, animated: true)
if !self.isPageScrollAnimating {
self.pendingDragUpdateAfterPageAnimation = false
DispatchQueue.main.async { [weak self] in
guard let self, self.isDraggingItem else { return }
self.updateDragging(at: self.dragCurrentPoint)
}
}
}
RunLoop.main.add(timer, forMode: .common)
edgeDragTimer = timer
}
private func cancelEdgeFlipTimer() {
edgeDragTimer?.invalidate()
edgeDragTimer = nil
edgeDragDirection = nil
}
private func finishDragging(at point: CGPoint) {
guard let source = draggingIndex else {
cancelDragging()
return
}
finishPageAnimationImmediatelyIfNeeded()
let target = currentHoverIndex ?? gridIndex(at: point) ?? source
let clampedTarget = min(max(0, target), apps.count)
let shouldReorder = source != clampedTarget
if shouldReorder {
finishDraggingAfterReorder()
onReorderApps?(source, clampedTarget)
} else {
cancelDragging(restoreSource: true, animated: true)
}
}
private func finishDraggingAfterReorder() {
cancelEdgeFlipTimer()
edgeDragRequiresReentry = false
pendingDragUpdateAfterPageAnimation = false
draggingLayer?.removeFromSuperlayer()
draggingLayer = nil
isDraggingItem = false
currentHoverIndex = nil
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.draggingIndex = nil
self.draggingApp = nil
}
}
private func cancelDragging(restoreSource: Bool = true, animated: Bool = true) {
cancelEdgeFlipTimer()
edgeDragRequiresReentry = false
pendingDragUpdateAfterPageAnimation = false
resetReorderPreview(animated: animated)
if restoreSource, let index = draggingIndex, appLayers.indices.contains(index) {
appLayers[index].opacity = 1
}
draggingLayer?.removeFromSuperlayer()
draggingLayer = nil
draggingIndex = nil
draggingApp = nil
isDraggingItem = false
currentHoverIndex = nil
}
private func finishPageAnimationImmediatelyIfNeeded() {
guard layoutMode == .paged, isPageScrollAnimating else { return }
pageScrollSnapWorkItem?.cancel()
pendingDragUpdateAfterPageAnimation = false
isPageScrollAnimating = false
horizontalOffset = targetHorizontalOffset
applyHorizontalOffset()
}
private func updateReorderPreview(targetIndex: Int?) {
guard let source = draggingIndex, apps.indices.contains(source) else { return }
let clampedTarget = targetIndex.map { min(max(0, $0), apps.count) }
guard currentHoverIndex != clampedTarget else { return }
currentHoverIndex = clampedTarget
let metrics = makeMetrics()
if layoutMode == .paged {
updatePagedReorderPreview(source: source, target: clampedTarget, metrics: metrics)
} else {
updateVerticalReorderPreview(source: source, target: clampedTarget, metrics: metrics)
}
}
private func updatePagedReorderPreview(source: Int, target: Int?, metrics: Metrics) {
let pageStart = currentPage * metrics.itemsPerPage
let pageEnd = min(pageStart + metrics.itemsPerPage, apps.count)
guard pageStart < pageEnd else { return }
let sourceInCurrentPage = source >= pageStart && source < pageEnd
let hoverLocalIndex: Int? = {
guard let target, target >= pageStart, target <= pageEnd else { return nil }
return min(max(0, target - pageStart), max(0, pageEnd - pageStart))
}()
CATransaction.begin()
CATransaction.setAnimationDuration(animationsEnabled ? 0.28 : 0)
CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(controlPoints: 0.25, 1.0, 0.35, 1.0))
for index in apps.indices {
guard appLayers.indices.contains(index) else { continue }
let layer = appLayers[index]
if index == source {
layer.opacity = 0
continue
}
guard index >= pageStart, index < pageEnd else {
layer.opacity = 1
continue
}
let localIndex = index - pageStart
var visualIndex = index
if let hoverLocalIndex {
if sourceInCurrentPage {
let sourceLocalIndex = source - pageStart
if sourceLocalIndex < hoverLocalIndex,
localIndex > sourceLocalIndex,
localIndex <= hoverLocalIndex {
visualIndex = index - 1
} else if sourceLocalIndex > hoverLocalIndex,
localIndex >= hoverLocalIndex,
localIndex < sourceLocalIndex {
visualIndex = index + 1
}
} else if localIndex >= hoverLocalIndex {
visualIndex = index + 1
}
}
let frame = frameForGridSlot(at: visualIndex, metrics: metrics)
layer.transform = CATransform3DIdentity
layer.frame = frame
if itemFrames.indices.contains(index) {
itemFrames[index] = visibleFrame(frame)
}
layoutSublayers(of: layer, metrics: metrics)
layer.opacity = 1
}
CATransaction.commit()
}
private func updateVerticalReorderPreview(source: Int, target: Int?, metrics: Metrics) {
let visualOrder = visualOrderForDrag(source: source, target: target)
CATransaction.begin()
CATransaction.setAnimationDuration(animationsEnabled ? 0.28 : 0)
CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(controlPoints: 0.25, 1.0, 0.35, 1.0))
for index in apps.indices {
guard appLayers.indices.contains(index) else { continue }
let layer = appLayers[index]
if index == source {
layer.opacity = 0
continue
}
guard let visualIndex = visualOrder.firstIndex(of: index) else { continue }
let frame = frameForGridSlot(at: visualIndex, metrics: metrics)
layer.transform = CATransform3DIdentity
layer.frame = frame
if itemFrames.indices.contains(index) {
itemFrames[index] = visibleFrame(frame)
}
layoutSublayers(of: layer, metrics: metrics)
layer.opacity = 1
}
CATransaction.commit()
}
private func visualOrderForDrag(source: Int, target: Int?) -> [Int] {
var order = Array(apps.indices)
guard order.indices.contains(source) else { return order }
let moving = order.remove(at: source)
if let target {
order.insert(moving, at: min(max(0, target), order.count))
} else {
order.insert(moving, at: source)
}
return order
}
private func resetReorderPreview(animated: Bool) {
guard !appLayers.isEmpty else { return }
let metrics = makeMetrics()
CATransaction.begin()
CATransaction.setDisableActions(!animated)
CATransaction.setAnimationDuration(animated && animationsEnabled ? 0.2 : 0)
CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(name: .easeOut))
itemFrames = Array(repeating: .zero, count: apps.count)
for index in apps.indices {
guard appLayers.indices.contains(index) else { continue }
let frame = frameForGridSlot(at: index, metrics: metrics)
itemFrames[index] = visibleFrame(frame)
appLayers[index].transform = CATransform3DIdentity
appLayers[index].frame = frame
layoutSublayers(of: appLayers[index], metrics: metrics)
}
CATransaction.commit()
}
private func ensureSelectionVisible() {
guard let selectedIndex else { return }
if layoutMode == .paged {
let metrics = makeMetrics()
let page = selectedIndex / metrics.itemsPerPage
if page != currentPage {
navigateToPage(page, animated: true)
}
return
}
guard itemFrames.indices.contains(selectedIndex) else { return }
let frame = itemFrames[selectedIndex]
let metrics = makeMetrics()
if frame.minY < contentInsets.bottom {
verticalOffset -= contentInsets.bottom - frame.minY
} else if frame.maxY > bounds.height - contentInsets.top {
verticalOffset += frame.maxY - (bounds.height - contentInsets.top)
}
verticalOffset = clampVerticalOffset(verticalOffset, metrics: metrics)
updateLayout(animated: true)
}
private func updateHoverIndex(_ index: Int?) {
guard hoveredIndex != index else { return }
let old = hoveredIndex
hoveredIndex = index
if let old { applyScale(at: old, animated: true) }
if let index { applyScale(at: index, animated: true) }
}
private func applyScale(at index: Int, animated: Bool) {
guard appLayers.indices.contains(index) else { return }
let layer = appLayers[index]
var iconScale: CGFloat = 1
if selectedIndex == index {
iconScale = 1.16
} else if hoverMagnificationEnabled && hoveredIndex == index {
iconScale = hoverMagnificationScale
}
let pressScale: CGFloat = (activePressEffectEnabled && pressedIndex == index) ? activePressScale : 1
CATransaction.begin()
CATransaction.setDisableActions(!animated)
CATransaction.setAnimationDuration(animated ? 0.12 : 0)
CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(name: .easeOut))
layer.transform = CATransform3DMakeScale(pressScale, pressScale, 1)
if let icon = layer.sublayers?.first(where: { $0.name == "icon" }) {
icon.transform = CATransform3DMakeScale(iconScale, iconScale, 1)
}
CATransaction.commit()
}
}
```
## /LaunchNext/CAFolderGridViewRepresentable.swift
```swift path="/LaunchNext/CAFolderGridViewRepresentable.swift"
import AppKit
import SwiftUI
struct CAFolderGridViewRepresentable: NSViewRepresentable {
@ObservedObject var appStore: AppStore
@Binding var folder: FolderInfo
@Binding var currentPage: Int
@Binding var pageCount: Int
@Binding var verticalScrollOffset: CGFloat
var iconSize: CGFloat
var verticalHeaderHeight: CGFloat
var onClose: () -> Void
var onLaunchApp: (AppInfo) -> Void
func makeNSView(context: Context) -> CAFolderGridView {
let view = CAFolderGridView(frame: .zero)
configure(view)
wireCallbacks(view)
view.apps = folder.apps
return view
}
func updateNSView(_ nsView: CAFolderGridView, context: Context) {
configure(nsView)
if nsView.apps != folder.apps {
nsView.apps = folder.apps
}
if appStore.folderLayoutMode == .paged, nsView.displayedPage != currentPage {
nsView.setDisplayedPage(currentPage, animated: appStore.enableAnimations)
}
}
private func configure(_ view: CAFolderGridView) {
view.layoutMode = appStore.folderLayoutMode
view.iconSize = iconSize
view.labelFontSize = CGFloat(appStore.iconLabelFontSize)
view.labelFontWeight = nsFontWeight(for: appStore.iconLabelFontWeight)
view.showLabels = appStore.showLabels
view.hoverMagnificationEnabled = appStore.enableHoverMagnification
view.hoverMagnificationScale = CGFloat(appStore.hoverMagnificationScale)
view.activePressEffectEnabled = appStore.enableActivePressEffect
view.activePressScale = CGFloat(appStore.activePressScale)
view.animationsEnabled = appStore.enableAnimations
view.animationDuration = appStore.animationDuration
view.isLayoutLocked = appStore.isLayoutLocked
view.scrollSensitivity = appStore.scrollSensitivity
view.reverseWheelPagingDirection = appStore.reverseWheelPagingDirection
view.verticalHeaderHeight = verticalHeaderHeight
view.showInFinderMenuTitle = appStore.localized(.contextMenuShowInFinder)
view.copyAppPathMenuTitle = appStore.localized(.contextMenuCopyAppPath)
view.hideAppMenuTitle = appStore.localized(.hiddenAppsAddButton)
view.uninstallWithToolMenuTitle = appStore.localized(.contextMenuUninstallWithConfiguredTool)
view.canUseConfiguredUninstallTool = appStore.uninstallToolAppURL != nil
}
private func wireCallbacks(_ view: CAFolderGridView) {
let currentPageBinding = $currentPage
let pageCountBinding = $pageCount
let verticalScrollOffsetBinding = $verticalScrollOffset
view.onOpenApp = { app in
DispatchQueue.main.async {
onLaunchApp(app)
}
}
view.onClose = {
DispatchQueue.main.async {
onClose()
}
}
view.onPageStateChanged = { page, count in
DispatchQueue.main.async {
if currentPageBinding.wrappedValue != page {
currentPageBinding.wrappedValue = page
}
if pageCountBinding.wrappedValue != count {
pageCountBinding.wrappedValue = count
}
}
}
view.onVerticalScrollOffsetChanged = { offset in
DispatchQueue.main.async {
if abs(verticalScrollOffsetBinding.wrappedValue - offset) > 0.5 {
verticalScrollOffsetBinding.wrappedValue = offset
}
}
}
view.onReorderApps = { from, to in
DispatchQueue.main.async {
_ = appStore.reorderAppInFolder(folderID: folder.id, from: from, to: to)
}
}
view.onDragAppOut = { app in
DispatchQueue.main.async {
appStore.handoffDraggingApp = app
appStore.handoffDragScreenLocation = NSEvent.mouseLocation
appStore.removeAppFromFolder(app, folder: folder)
withAnimation(LNAnimations.springFast) {
onClose()
}
}
}
view.onShowAppInFinder = { app in
DispatchQueue.main.async {
if !appStore.showAppInFinder(app) { NSSound.beep() }
}
}
view.onCopyAppPath = { app in
DispatchQueue.main.async {
if !appStore.copyAppPath(app) { NSSound.beep() }
}
}
view.onHideApp = { app in
DispatchQueue.main.async {
_ = appStore.hideApp(app)
}
}
view.onUninstallWithTool = { app in
DispatchQueue.main.async {
if !appStore.openConfiguredUninstallTool(for: app) { NSSound.beep() }
}
}
}
private func nsFontWeight(for option: AppStore.IconLabelFontWeightOption) -> NSFont.Weight {
switch option {
case .light: return .light
case .regular: return .regular
case .medium: return .medium
case .semibold: return .semibold
case .bold: return .bold
}
}
}
```
## /LaunchNext/CAGridView+Input.swift
```swift path="/LaunchNext/CAGridView+Input.swift"
import AppKit
import QuartzCore
extension CAGridView {
// MARK: - Input Handling
override var acceptsFirstResponder: Bool { true }
override func becomeFirstResponder() -> Bool {
// print("🎯 [CAGrid] becomeFirstResponder")
return true
}
override func resignFirstResponder() -> Bool {
// print("🎯 [CAGrid] resignFirstResponder")
return true
}
// 确保视图接受第一次鼠标点击就能响应
override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
return true
}
// 确保视图可以接收鼠标事件
override func hitTest(_ point: NSPoint) -> NSView? {
let result = frame.contains(point) ? self : nil
return result
}
override func updateTrackingAreas() {
super.updateTrackingAreas()
if let existing = hoverTrackingArea {
removeTrackingArea(existing)
}
let options: NSTrackingArea.Options = [.mouseMoved, .mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect]
let area = NSTrackingArea(rect: bounds, options: options, owner: self, userInfo: nil)
addTrackingArea(area)
hoverTrackingArea = area
}
override func mouseMoved(with event: NSEvent) {
guard hoverMagnificationEnabled else {
clearHover()
return
}
guard !isDraggingItem && !isPageDragging && !isDragging else {
clearHover()
return
}
let location = convert(event.locationInWindow, from: nil)
if let (item, _) = itemAt(location), case .empty = item {
updateHoverIndex(nil)
} else if let (_, index) = itemAt(location) {
updateHoverIndex(index)
} else {
updateHoverIndex(nil)
}
}
override func mouseExited(with event: NSEvent) {
clearHover()
}
override func viewDidChangeEffectiveAppearance() {
super.viewDidChangeEffectiveAppearance()
updateLabelColors()
updateFolderGlassColors()
}
override func scrollWheel(with event: NSEvent) {
// 当本地 monitor 存在时,避免双重处理
if scrollEventMonitor != nil {
return
}
guard isScrollEnabled else { return }
handleScrollWheel(with: event)
}
func handleScrollWheel(with event: NSEvent) {
// 优先使用水平滑动,如果没有则用垂直滑动(反向)
let deltaX = event.scrollingDeltaX
let deltaY = event.scrollingDeltaY
let delta = abs(deltaX) > abs(deltaY) ? deltaX : -deltaY
let baseline = max(AppStore.defaultScrollSensitivity, 0.0001)
let sensitivityScale = CGFloat(max(scrollSensitivity, 0.0001) / baseline)
let scaledDelta = delta * sensitivityScale
let isPrecise = event.hasPreciseScrollingDeltas
if !isPrecise {
/*
// 旧版滚轮跟手 + 定时器 snap 逻辑(保留注释,便于后续对比)
wheelSnapTimer?.invalidate()
// 累积滚动量
wheelAccumulatedDelta += scaledDelta * 8 // 放大系数,让跟手效果更明显
// 计算临时偏移(带橡皮筋效果)
let pageStride = bounds.width + pageSpacing
let baseOffset = -CGFloat(currentPage) * pageStride
var newOffset = baseOffset + wheelAccumulatedDelta
// 橡皮筋效果:边界阻力
let minOffset = -CGFloat(pageCount - 1) * pageStride
let maxOffset: CGFloat = 0
if newOffset > maxOffset {
let overscroll = newOffset - maxOffset
newOffset = maxOffset + rubberBand(overscroll, limit: bounds.width * 0.15)
} else if newOffset < minOffset {
let overscroll = newOffset - minOffset
newOffset = minOffset + rubberBand(overscroll, limit: bounds.width * 0.15)
}
// 更新显示
scrollOffset = newOffset
CATransaction.begin()
CATransaction.setDisableActions(true)
pageContainerLayer.transform = CATransform3DMakeTranslation(scrollOffset, 0, 0)
CATransaction.commit()
// 设置定时器,停止滚动后决定翻页或弹回
wheelSnapTimer = Timer.scheduledTimer(withTimeInterval: wheelSnapDelay, repeats: false) { [weak self] _ in
guard let self = self else { return }
let threshold = self.bounds.width * 0.15 // 15% 触发翻页
var targetPage = self.currentPage
if self.wheelAccumulatedDelta < -threshold {
targetPage = self.currentPage + 1
} else if self.wheelAccumulatedDelta > threshold {
targetPage = self.currentPage - 1
}
self.wheelAccumulatedDelta = 0
self.navigateToPage(targetPage, animated: true)
}
*/
// 只优化普通滚轮,不改精准设备(触控板 / Magic Mouse)路径
handleWheelPaging(with: scaledDelta)
return
}
// 触控板滑动
switch event.phase {
case .began:
isDragging = true
isScrollAnimating = false
dragStartOffset = scrollOffset
accumulatedDelta = 0
scrollVelocity = 0
case .changed:
accumulatedDelta += scaledDelta
// 计算新的偏移量
var newOffset = dragStartOffset + accumulatedDelta
// 橡皮筋效果:在边界处添加阻力
let pageStride = bounds.width + pageSpacing
let minOffset = -CGFloat(pageCount - 1) * pageStride
let maxOffset: CGFloat = 0
if newOffset > maxOffset {
// 超出左边界
let overscroll = newOffset - maxOffset
newOffset = maxOffset + rubberBand(overscroll, limit: bounds.width * 0.2)
} else if newOffset < minOffset {
// 超出右边界
let overscroll = newOffset - minOffset
newOffset = minOffset + rubberBand(overscroll, limit: bounds.width * 0.2)
}
scrollOffset = newOffset
// 性能优化:使用 CATransaction 批量更新
CATransaction.begin()
CATransaction.setDisableActions(true)
CATransaction.setAnimationDuration(0)
pageContainerLayer.transform = CATransform3DMakeTranslation(scrollOffset, 0, 0)
CATransaction.commit()
case .ended, .cancelled:
isDragging = false
// 根据滑动距离和速度确定目标页面
let velocity = (abs(deltaX) > abs(deltaY) ? deltaX : -deltaY) * sensitivityScale
let threshold = (bounds.width + pageSpacing) * 0.15 // 15% 即可触发翻页
let velocityThreshold: CGFloat = 30
var targetPage = currentPage
// 根据累计滑动方向决定翻页
if accumulatedDelta < -threshold || velocity < -velocityThreshold {
targetPage = currentPage + 1
} else if accumulatedDelta > threshold || velocity > velocityThreshold {
targetPage = currentPage - 1
}
navigateToPage(targetPage)
default:
break
}
}
private func handleWheelPaging(with scaledDelta: CGFloat) {
guard scaledDelta != 0 else { return }
let direction = scaledDelta > 0 ? 1 : -1
let effectiveDirection = reverseWheelPagingDirection ? -direction : direction
if wheelLastDirection != direction {
wheelAccumulatedDelta = 0
}
wheelLastDirection = direction
wheelAccumulatedDelta += abs(scaledDelta)
// 固定阈值,灵敏度变化已反映在 scaledDelta
let threshold: CGFloat = 2.0
guard wheelAccumulatedDelta >= threshold else { return }
let now = Date()
if let last = wheelLastFlipAt, now.timeIntervalSince(last) < wheelFlipCooldown {
return
}
// Keep existing CA direction semantics by default; optional override flips wheel-only paging.
let targetPage = effectiveDirection > 0 ? currentPage - 1 : currentPage + 1
wheelLastFlipAt = now
wheelAccumulatedDelta = 0
navigateToPage(targetPage, animated: true)
}
func rubberBand(_ offset: CGFloat, limit: CGFloat) -> CGFloat {
let factor: CGFloat = 0.5
let absOffset = abs(offset)
let scaled = (factor * absOffset * limit) / (absOffset + limit)
return offset >= 0 ? scaled : -scaled
}
override func mouseDown(with event: NSEvent) {
guard !externalAppDragSessionActive else { return }
// 确保成为第一响应者,这样后续的滚轮事件才能被接收
window?.makeFirstResponder(self)
let location = convert(event.locationInWindow, from: nil)
// print("🖱️ [CAGrid] mouseDown at \(location)")
if let (item, index) = itemAt(location) {
// print("🖱️ [CAGrid] Hit item: \(item.name) at index \(index)")
if event.clickCount == 1 {
// 添加点击效果动画
pressedIndex = index
animatePress(at: index, pressed: true)
dragStartPoint = location
// 启动长按计时器(用于开始拖拽)
// 注意:必须添加到 .common 模式,否则在鼠标追踪期间不会触发
longPressTimer?.invalidate()
let timer = Timer(timeInterval: longPressDuration, repeats: false) { [weak self] _ in
self?.startDragging(item: item, index: index, at: location)
}
RunLoop.main.add(timer, forMode: .common)
longPressTimer = timer
}
} else {
// 点击空白区域 - 开始页面拖拽模式
// print("🖱️ [CAGrid] Hit empty area, starting page drag")
isPageDragging = true
pageDragStartX = location.x
pageDragStartOffset = scrollOffset
dragStartPoint = location
}
}
override func mouseDragged(with event: NSEvent) {
if externalAppDragSessionActive { return }
let location = convert(event.locationInWindow, from: nil)
// 页面拖拽模式
if isPageDragging {
let deltaX = location.x - pageDragStartX
var newOffset = pageDragStartOffset + deltaX
// 橡皮筋效果 - 在边界处添加阻力
let pageStride = bounds.width + pageSpacing
let minOffset = -CGFloat(pageCount - 1) * pageStride
let maxOffset: CGFloat = 0
if newOffset > maxOffset {
let overscroll = newOffset - maxOffset
newOffset = maxOffset + rubberBand(overscroll, limit: bounds.width * 0.3)
} else if newOffset < minOffset {
let overscroll = newOffset - minOffset
newOffset = minOffset + rubberBand(overscroll, limit: bounds.width * 0.3)
}
scrollOffset = newOffset
CATransaction.begin()
CATransaction.setDisableActions(true)
pageContainerLayer.transform = CATransform3DMakeTranslation(scrollOffset, 0, 0)
CATransaction.commit()
return
}
// Check if moved enough distance to start dragging
if !isDraggingItem, let idx = pressedIndex {
if isLayoutLocked { return }
let distance = hypot(location.x - dragStartPoint.x, location.y - dragStartPoint.y)
if distance > 10 {
// 取消长按计时器,立即开始拖拽
longPressTimer?.invalidate()
longPressTimer = nil
if let item = items[safe: idx] {
startDragging(item: item, index: idx, at: location)
}
}
}
// 更新拖拽位置
if isDraggingItem {
let dragDelta = CGPoint(x: location.x - dragCurrentPoint.x,
y: location.y - dragCurrentPoint.y)
if let app = externalDockDragCandidate(),
shouldStartExternalDockDrag(localPoint: location,
windowPoint: event.locationInWindow,
dragDelta: dragDelta) {
startExternalDockDrag(for: app, event: event, at: location)
return
}
updateDragging(at: location)
}
}
override func mouseUp(with event: NSEvent) {
if externalAppDragSessionActive { return }
let location = convert(event.locationInWindow, from: nil)
// 取消长按计时器
longPressTimer?.invalidate()
longPressTimer = nil
// 结束页面拖拽
if isPageDragging {
isPageDragging = false
let totalDrag = location.x - pageDragStartX
let threshold = (bounds.width + pageSpacing) * 0.15 // 15% 即可触发翻页
var targetPage = currentPage
if totalDrag < -threshold {
// 向左拖 -> 下一页
targetPage = min(currentPage + 1, pageCount - 1)
} else if totalDrag > threshold {
// 向右拖 -> 上一页
targetPage = max(currentPage - 1, 0)
}
// 如果没有实际拖动(只是点击),则关闭窗口
if abs(totalDrag) < 5 {
onEmptyAreaClicked?()
return
}
navigateToPage(targetPage, animated: true)
return
}
if isDraggingItem {
// 结束拖拽
endDragging(at: location)
} else if let idx = pressedIndex {
// 恢复点击效果
pressedIndex = nil
animatePress(at: idx, pressed: false)
// 检查是否在同一个 item 上释放
if let (item, index) = itemAt(location), index == idx {
if isBatchSelectionMode {
if case .app(let app) = item {
toggleBatchSelection(forAppPath: app.url.path)
}
} else {
// 延迟一点点再触发,让动画效果更明显
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
self?.onItemClicked?(item, index)
}
}
}
}
}
func animatePress(at index: Int, pressed: Bool) {
_ = pressed
let pageIndex = index / itemsPerPage
let localIndex = index % itemsPerPage
guard pageIndex < iconLayers.count, localIndex < iconLayers[pageIndex].count else { return }
applyScaleForIndex(index, animated: true)
}
// MARK: - Drag and Drop
func startDragging(item: LaunchpadItem, index: Int, at point: CGPoint) {
guard !isLayoutLocked else { return }
if isBatchSelectionMode {
guard case .app(let app) = item else { return }
let dragPath = app.url.path
let orderedBatch = orderedBatchDragPaths(leadingAppPath: dragPath)
guard !orderedBatch.isEmpty else { return }
batchDraggingAppPathsOrdered = orderedBatch
batchHiddenCompanionIndices = orderedBatch
.compactMap { globalIndex(forAppPath: $0) }
.filter { $0 != index }
} else {
// Allow dragging apps and folders in normal mode.
switch item {
case .app, .folder:
break
case .empty, .missingApp:
return
}
batchDraggingAppPathsOrdered.removeAll()
batchHiddenCompanionIndices.removeAll()
}
isDraggingItem = true
draggingIndex = index
draggingItem = item
dragCurrentPoint = point
// 恢复按压效果
if let idx = pressedIndex {
animatePress(at: idx, pressed: false)
pressedIndex = nil
}
// 隐藏原图标
let pageIndex = index / itemsPerPage
let localIndex = index % itemsPerPage
if pageIndex < iconLayers.count, localIndex < iconLayers[pageIndex].count {
iconLayers[pageIndex][localIndex].opacity = 0
}
if !batchHiddenCompanionIndices.isEmpty {
for companionIndex in batchHiddenCompanionIndices {
setOpacity(0, forGlobalIndex: companionIndex)
}
}
// 创建拖拽图层
createDraggingLayer(for: item, at: point)
if isBatchDragging {
pendingHoverIndex = gridPositionAt(point)
applyIconPositionUpdate()
}
// print("🎯 [CAGrid] Started dragging: \(item.name) at index \(index)")
}
func createDraggingLayer(for item: LaunchpadItem, at point: CGPoint) {
let actualIconSize = iconSize
let scale = NSScreen.main?.backingScaleFactor ?? 2.0
// Container for dragging (holds glass + icon for folders)
let container = CALayer()
container.frame = CGRect(x: point.x - actualIconSize / 2, y: point.y - actualIconSize / 2,
width: actualIconSize, height: actualIconSize)
container.transform = CATransform3DMakeScale(1.1, 1.1, 1.0)
container.zPosition = 1000
// For folders, add glass background
if case .folder = item {
let glassSize = actualIconSize * 0.8
let glassOffset = (actualIconSize - glassSize) / 2
let glassLayer = CALayer()
glassLayer.frame = CGRect(x: glassOffset, y: glassOffset, width: glassSize, height: glassSize)
glassLayer.backgroundColor = NSColor.white.withAlphaComponent(0.08).cgColor
glassLayer.borderColor = NSColor.white.withAlphaComponent(0.2).cgColor
glassLayer.borderWidth = 0.5
glassLayer.cornerRadius = glassSize * 0.25
glassLayer.shadowColor = NSColor.black.cgColor
glassLayer.shadowOffset = CGSize(width: 0, height: -1)
glassLayer.shadowRadius = 3
glassLayer.shadowOpacity = 0.15
container.addSublayer(glassLayer)
}
// Icon layer
let iconLayer = CALayer()
iconLayer.frame = CGRect(x: 0, y: 0, width: actualIconSize, height: actualIconSize)
iconLayer.contentsScale = scale
iconLayer.contentsGravity = .resizeAspect
iconLayer.shadowOpacity = 0
// Set icon content with high resolution
if case .app(let app) = item {
let icon = IconStore.shared.icon(forPath: app.url.path)
let renderSize = NSSize(width: actualIconSize * scale, height: actualIconSize * scale)
let renderedImage = NSImage(size: renderSize)
renderedImage.lockFocus()
icon.draw(in: NSRect(origin: .zero, size: renderSize))
renderedImage.unlockFocus()
if let cgImage = renderedImage.cgImage(forProposedRect: nil, context: nil, hints: nil) {
iconLayer.contents = cgImage
}
} else if case .folder(let folder) = item {
let icon = folder.icon(of: actualIconSize, scale: folderPreviewScale)
let renderSize = NSSize(width: actualIconSize * scale, height: actualIconSize * scale)
let renderedImage = NSImage(size: renderSize)
renderedImage.lockFocus()
icon.draw(in: NSRect(origin: .zero, size: renderSize))
renderedImage.unlockFocus()
if let cgImage = renderedImage.cgImage(forProposedRect: nil, context: nil, hints: nil) {
iconLayer.contents = cgImage
}
}
container.addSublayer(iconLayer)
if case .app = item, batchDraggingAppPathsOrdered.count > 1 {
addBatchDragCountBadge(to: container, count: batchDraggingAppPathsOrdered.count)
}
containerLayer.addSublayer(container)
draggingLayer = container
}
func externalDockDragCandidate() -> AppInfo? {
guard !isBatchDragging else { return nil }
guard case .app(let app) = draggingItem else { return nil }
let path = app.url.path
guard !path.isEmpty else { return nil }
guard app.url.pathExtension.lowercased() == "app" else { return nil }
guard FileManager.default.fileExists(atPath: path) else { return nil }
return app
}
func shouldStartExternalDockDrag(localPoint point: CGPoint, windowPoint: CGPoint, dragDelta: CGPoint) -> Bool {
guard let contentView = window?.contentView else { return false }
guard dockDragEnabled else { return false }
switch dockDragSide {
case .disabled:
return false
case .bottom:
let movingTowardDock = dragDelta.y < -1.5
let nearBottomEdge = windowPoint.y <= contentView.bounds.minY + externalAppDragTriggerDistance
let isInsideHorizontalRange =
windowPoint.x >= contentView.bounds.minX - externalAppDragOutset &&
windowPoint.x <= contentView.bounds.maxX + externalAppDragOutset
return movingTowardDock && nearBottomEdge && isInsideHorizontalRange
case .left:
let movingTowardDock = dragDelta.x < -1.5
let nearLeftEdge = windowPoint.x <= contentView.bounds.minX + externalAppDragTriggerDistance
let isInsideVerticalRange =
windowPoint.y >= contentView.bounds.minY - externalAppDragOutset &&
windowPoint.y <= contentView.bounds.maxY + externalAppDragOutset
return movingTowardDock && nearLeftEdge && isInsideVerticalRange
case .right:
let movingTowardDock = dragDelta.x > 1.5
let nearRightEdge = windowPoint.x >= contentView.bounds.maxX - externalAppDragTriggerDistance
let isInsideVerticalRange =
windowPoint.y >= contentView.bounds.minY - externalAppDragOutset &&
windowPoint.y <= contentView.bounds.maxY + externalAppDragOutset
return movingTowardDock && nearRightEdge && isInsideVerticalRange
}
}
func startExternalDockDrag(for app: AppInfo, event: NSEvent, at point: CGPoint) {
guard !externalAppDragSessionActive else { return }
clearDropTargetHighlight()
cancelEdgeDragTimer()
let writer = app.url as NSURL
let draggingItem = NSDraggingItem(pasteboardWriter: writer)
let dragImage = renderedExternalDockDragPreview(for: app)
let frame = CGRect(x: point.x - iconSize / 2,
y: point.y - iconSize / 2,
width: iconSize,
height: iconSize)
draggingItem.setDraggingFrame(frame, contents: dragImage)
cancelDragging()
externalAppDragSessionActive = true
AppDelegate.shared?.beginExternalSystemDragSession()
let session = beginDraggingSession(with: [draggingItem], event: event, source: self)
session.draggingFormation = .none
session.animatesToStartingPositionsOnCancelOrFail = true
}
func renderedExternalDockDragPreview(for app: AppInfo) -> NSImage {
let icon = IconStore.shared.icon(forPath: app.url.path)
let scale = window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2
let renderSize = NSSize(width: iconSize * scale, height: iconSize * scale)
let renderedImage = NSImage(size: renderSize)
renderedImage.lockFocus()
icon.draw(in: NSRect(origin: .zero, size: renderSize),
from: .zero,
operation: .copy,
fraction: 1.0)
renderedImage.unlockFocus()
renderedImage.size = NSSize(width: iconSize, height: iconSize)
return renderedImage
}
func addBatchDragCountBadge(to container: CALayer, count: Int) {
let badgeSize: CGFloat = 22
let badge = CALayer()
badge.name = "batchDragCountBadge"
badge.frame = CGRect(x: container.bounds.width - badgeSize * 0.9,
y: container.bounds.height - badgeSize * 0.95,
width: badgeSize,
height: badgeSize)
badge.cornerRadius = badgeSize * 0.5
badge.backgroundColor = NSColor.systemBlue.cgColor
badge.borderColor = NSColor.white.withAlphaComponent(0.85).cgColor
badge.borderWidth = 1
badge.zPosition = 40
let text = CATextLayer()
text.string = "\(count)"
text.alignmentMode = .center
text.font = NSFont.systemFont(ofSize: 11, weight: .bold)
text.fontSize = 11
text.foregroundColor = NSColor.white.cgColor
text.contentsScale = NSScreen.main?.backingScaleFactor ?? 2
text.frame = CGRect(x: 0, y: 4, width: badgeSize, height: badgeSize - 6)
badge.addSublayer(text)
container.addSublayer(badge)
}
func updateDragging(at point: CGPoint) {
dragCurrentPoint = point
// Update dragging layer position
CATransaction.begin()
CATransaction.setDisableActions(true)
let actualIconSize = iconSize
draggingLayer?.frame = CGRect(x: point.x - actualIconSize / 2, y: point.y - actualIconSize / 2,
width: actualIconSize, height: actualIconSize)
CATransaction.commit()
// Check edge drag for page flip
checkEdgeDrag(at: point)
let dragPage = draggingIndex.map { $0 / itemsPerPage }
let isCrossPage = dragPage != nil && dragPage != currentPage
if isBatchDragging {
if let hoverIndex = gridPositionAt(point), hoverIndex != draggingIndex {
clearDropTargetHighlight()
updateIconPositionsForDrag(hoverIndex: hoverIndex)
} else {
clearDropTargetHighlight()
updateIconPositionsForDrag(hoverIndex: nil)
}
return
}
if let hoverIndex = gridPositionAt(point), hoverIndex != draggingIndex {
if hoverIndex < items.count {
let targetItem = items[hoverIndex]
let inCenterArea = isPointInFolderDropZone(point, targetIndex: hoverIndex)
switch targetItem {
case .folder:
if case .app = draggingItem, inCenterArea {
// Hovering over folder center - highlight for move into folder
highlightDropTarget(at: hoverIndex)
updateIconPositionsForDrag(hoverIndex: isCrossPage ? hoverIndex : nil)
} else {
clearDropTargetHighlight()
updateIconPositionsForDrag(hoverIndex: hoverIndex)
}
case .app:
if case .app = draggingItem, inCenterArea {
// App over app center - create folder
highlightDropTarget(at: hoverIndex)
updateIconPositionsForDrag(hoverIndex: isCrossPage ? hoverIndex : nil)
} else {
clearDropTargetHighlight()
updateIconPositionsForDrag(hoverIndex: hoverIndex)
}
case .missingApp, .empty:
clearDropTargetHighlight()
updateIconPositionsForDrag(hoverIndex: hoverIndex)
}
} else {
clearDropTargetHighlight()
updateIconPositionsForDrag(hoverIndex: hoverIndex)
}
} else {
clearDropTargetHighlight()
updateIconPositionsForDrag(hoverIndex: nil)
}
}
func updateIconPositionsForDrag(hoverIndex: Int?) {
guard draggingIndex != nil else { return }
// Skip if same as pending or current
if hoverIndex == pendingHoverIndex { return }
// Store pending hover index
pendingHoverIndex = hoverIndex
// Cancel previous timer
hoverUpdateTimer?.invalidate()
// Schedule delayed update to prevent jittering during fast movement
hoverUpdateTimer = Timer.scheduledTimer(withTimeInterval: hoverUpdateDelay, repeats: false) { [weak self] _ in
self?.applyIconPositionUpdate()
}
}
func applyIconPositionUpdate() {
guard let dragIndex = draggingIndex else { return }
let hoverIndex = pendingHoverIndex
// Batch mode always recomputes compaction so selected gaps are closed immediately.
if !isBatchDragging, hoverIndex == currentHoverIndex { return }
currentHoverIndex = hoverIndex
// Get current page icons only
let pageIndex = currentPage
guard pageIndex < iconLayers.count else { return }
let pageLayers = iconLayers[pageIndex]
let pageStart = pageIndex * itemsPerPage
ensureOriginalPositionsForCurrentPage(pageLayers: pageLayers, pageStart: pageStart)
if isBatchDragging {
applyBatchCompactedPositions(pageLayers: pageLayers, pageStart: pageStart, dragIndex: dragIndex)
return
}
// Calculate positions with item shifted - smooth spring-like animation
CATransaction.begin()
CATransaction.setAnimationDuration(0.45)
CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(controlPoints: 0.25, 1.0, 0.35, 1.0))
for (localIndex, layer) in pageLayers.enumerated() {
let globalIndex = pageStart + localIndex
// Skip the dragging item's original layer - hide it completely
if globalIndex == dragIndex {
layer.opacity = 0
continue
}
guard let originalPos = originalIconPositions[globalIndex] else { continue }
var targetPos = originalPos
if let hover = hoverIndex {
let hoverLocalIndex = hover - pageStart
let dragLocalIndex = dragIndex - pageStart
let dragInThisPage = dragIndex >= pageStart && dragIndex < pageStart + itemsPerPage
// Only affect items on current page
if hover >= pageStart && hover < pageStart + itemsPerPage {
if dragInThisPage {
if dragLocalIndex < hoverLocalIndex {
// Dragging forward: items between drag and hover shift left
if localIndex > dragLocalIndex && localIndex <= hoverLocalIndex {
if let prevPos = originalIconPositions[pageStart + localIndex - 1] {
targetPos = prevPos
}
}
} else if dragLocalIndex > hoverLocalIndex {
// Dragging backward: items between hover and drag shift right
if localIndex >= hoverLocalIndex && localIndex < dragLocalIndex {
if let nextPos = originalIconPositions[pageStart + localIndex + 1] {
targetPos = nextPos
}
}
}
} else {
// Dragging from another page: create a gap on the hover page by shifting items to the right.
if localIndex >= hoverLocalIndex {
let targetGlobalIndex = pageStart + localIndex + 1
targetPos = gridCenterForGlobalIndex(targetGlobalIndex)
}
}
}
}
layer.position = targetPos
}
CATransaction.commit()
}
func applyBatchCompactedPositions(pageLayers: [CALayer], pageStart: Int, dragIndex: Int) {
let pageEnd = pageStart + pageLayers.count
let removedIndices = Set(batchHiddenCompanionIndices + [dragIndex]).filter { $0 >= pageStart && $0 < pageEnd }
let removedLocals = Set(removedIndices.map { $0 - pageStart })
var nonEmptyLocals: [Int] = []
var emptyLocals: [Int] = []
for localIndex in 0..<pageLayers.count {
guard !removedLocals.contains(localIndex) else { continue }
let globalIndex = pageStart + localIndex
if globalIndex < items.count, case .empty = items[globalIndex] {
emptyLocals.append(localIndex)
} else {
nonEmptyLocals.append(localIndex)
}
}
let compactedLocals = nonEmptyLocals + emptyLocals
CATransaction.begin()
CATransaction.setAnimationDuration(0.25)
CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(controlPoints: 0.25, 0.9, 0.35, 1.0))
for (targetLocalIndex, sourceLocalIndex) in compactedLocals.enumerated() {
let layer = pageLayers[sourceLocalIndex]
let targetGlobalIndex = pageStart + targetLocalIndex
let targetPosition = originalIconPositions[targetGlobalIndex] ?? gridCenterForGlobalIndex(targetGlobalIndex)
layer.position = targetPosition
layer.opacity = 1
}
for removedLocalIndex in removedLocals {
pageLayers[removedLocalIndex].opacity = 0
}
CATransaction.commit()
}
func gridCenterForGlobalIndex(_ globalIndex: Int) -> CGPoint {
guard bounds.width > 0, bounds.height > 0 else { return .zero }
let pageWidth = bounds.width
let pageHeight = bounds.height
let pageStride = pageWidth + pageSpacing
let pageIndex = max(0, globalIndex / itemsPerPage)
let localIndex = max(0, globalIndex % itemsPerPage)
let availableWidth = max(0, pageWidth - contentInsets.left - contentInsets.right)
let availableHeight = max(0, pageHeight - contentInsets.top - contentInsets.bottom)
let totalColumnSpacing = columnSpacing * CGFloat(max(columns - 1, 0))
let totalRowSpacing = rowSpacing * CGFloat(max(rows - 1, 0))
let usableWidth = max(0, availableWidth - totalColumnSpacing)
let usableHeight = max(0, availableHeight - totalRowSpacing)
let cellWidth = usableWidth / CGFloat(max(columns, 1))
let cellHeight = usableHeight / CGFloat(max(rows, 1))
let strideX = cellWidth + columnSpacing
let col = localIndex % columns
let row = localIndex / columns
let cellOriginX = contentInsets.left + CGFloat(col) * strideX
let cellOriginY = pageHeight - contentInsets.top - CGFloat(row + 1) * cellHeight - CGFloat(row) * rowSpacing
let actualIconSize = iconSize
let labelHeight: CGFloat = labelFontSize + 8
let labelTopSpacing: CGFloat = 6
let totalHeight = actualIconSize + labelTopSpacing + labelHeight
let containerX = CGFloat(pageIndex) * pageStride + cellOriginX
let containerY = cellOriginY + (cellHeight - totalHeight) / 2
return CGPoint(x: containerX + cellWidth / 2, y: containerY + totalHeight / 2)
}
func resetIconPositions() {
// Cancel pending update timer
hoverUpdateTimer?.invalidate()
hoverUpdateTimer = nil
pendingHoverIndex = nil
guard !originalIconPositions.isEmpty else {
// Even if we never captured original positions, make sure the hidden drag source is restored
if let dragIndex = draggingIndex {
let pageIndex = dragIndex / itemsPerPage
let localIndex = dragIndex % itemsPerPage
if pageIndex < iconLayers.count, localIndex < iconLayers[pageIndex].count {
iconLayers[pageIndex][localIndex].opacity = 1.0
}
}
currentHoverIndex = nil
return
}
CATransaction.begin()
CATransaction.setAnimationDuration(0.3)
CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(controlPoints: 0.25, 0.1, 0.25, 1.0))
for (pageIndex, pageLayers) in iconLayers.enumerated() {
let pageStart = pageIndex * itemsPerPage
for (localIndex, layer) in pageLayers.enumerated() {
let globalIndex = pageStart + localIndex
if let originalPos = originalIconPositions[globalIndex] {
layer.position = originalPos
}
layer.opacity = 1.0
}
}
CATransaction.commit()
originalIconPositions.removeAll()
currentHoverIndex = nil
}
func updateHoverIndex(_ newIndex: Int?) {
guard hoveredIndex != newIndex else { return }
let old = hoveredIndex
hoveredIndex = newIndex
if let old = old {
applyScaleForIndex(old, animated: true)
}
if let newIndex = newIndex {
applyScaleForIndex(newIndex, animated: true)
}
}
func clearHover() {
updateHoverIndex(nil)
}
func updateLabelFonts() {
CATransaction.begin()
CATransaction.setDisableActions(true)
for pageLayers in iconLayers {
for containerLayer in pageLayers {
if let textLayer = containerLayer.sublayers?.first(where: { $0.name == "label" }) as? CATextLayer {
textLayer.font = NSFont.systemFont(ofSize: labelFontSize, weight: labelFontWeight)
textLayer.fontSize = labelFontSize
}
}
}
CATransaction.commit()
}
func updateLabelVisibility() {
CATransaction.begin()
CATransaction.setDisableActions(true)
for pageLayers in iconLayers {
for containerLayer in pageLayers {
if let textLayer = containerLayer.sublayers?.first(where: { $0.name == "label" }) as? CATextLayer {
textLayer.isHidden = !showLabels
}
}
}
CATransaction.commit()
updateLayout()
}
func updateLabelColors() {
let resolvedColor = currentLabelColor().cgColor
CATransaction.begin()
CATransaction.setDisableActions(true)
for pageLayers in iconLayers {
for containerLayer in pageLayers {
if let textLayer = containerLayer.sublayers?.first(where: { $0.name == "label" }) as? CATextLayer {
textLayer.foregroundColor = resolvedColor
}
}
}
CATransaction.commit()
}
func updateFolderGlassColors() {
let colors = currentFolderGlassStyle()
CATransaction.begin()
CATransaction.setDisableActions(true)
for pageLayers in iconLayers {
for containerLayer in pageLayers {
if let glassLayer = containerLayer.sublayers?.first(where: { $0.name == "glass" }) {
glassLayer.backgroundColor = colors.background.cgColor
glassLayer.borderColor = colors.border.cgColor
glassLayer.shadowOffset = colors.shadowOffset
glassLayer.shadowRadius = colors.shadowRadius
glassLayer.shadowOpacity = colors.shadowOpacity
}
}
}
CATransaction.commit()
}
func currentLabelColor() -> NSColor {
let match = effectiveAppearance.bestMatch(from: [.darkAqua, .aqua])
return match == .darkAqua ? .white : .black
}
func isPointInFolderDropZone(_ point: CGPoint, targetIndex: Int) -> Bool {
guard let center = iconCenter(for: targetIndex) else { return false }
let size = iconSize * folderDropZoneScale
let rect = CGRect(x: center.x - size / 2,
y: center.y - size / 2,
width: size,
height: size)
return rect.contains(point)
}
func iconCenter(for index: Int) -> CGPoint? {
guard bounds.width > 0, bounds.height > 0 else { return nil }
let pageIndex = index / itemsPerPage
let localIndex = index % itemsPerPage
guard pageIndex >= 0 && pageIndex < pageCount else { return nil }
let pageWidth = bounds.width
let pageHeight = bounds.height
let pageStride = pageWidth + pageSpacing
let availableWidth = max(0, pageWidth - contentInsets.left - contentInsets.right)
let availableHeight = max(0, pageHeight - contentInsets.top - contentInsets.bottom)
let totalColumnSpacing = columnSpacing * CGFloat(max(columns - 1, 0))
let totalRowSpacing = rowSpacing * CGFloat(max(rows - 1, 0))
let usableWidth = max(0, availableWidth - totalColumnSpacing)
let usableHeight = max(0, availableHeight - totalRowSpacing)
let cellWidth = usableWidth / CGFloat(max(columns, 1))
let cellHeight = usableHeight / CGFloat(max(rows, 1))
let strideX = cellWidth + columnSpacing
let col = localIndex % columns
let row = localIndex / columns
let cellOriginX = contentInsets.left + CGFloat(col) * strideX
let cellOriginY = pageHeight - contentInsets.top - CGFloat(row + 1) * cellHeight - CGFloat(row) * rowSpacing
let labelHeight: CGFloat = showLabels ? (labelFontSize + 8) : 0
let labelTopSpacing: CGFloat = showLabels ? 6 : 0
let totalHeight = iconSize + labelTopSpacing + labelHeight
let containerX = CGFloat(pageIndex) * pageStride + cellOriginX
let containerY = cellOriginY + (cellHeight - totalHeight) / 2
let iconX = containerX + (cellWidth - iconSize) / 2
let iconY = containerY + labelHeight + labelTopSpacing
let centerX = iconX + iconSize / 2 + scrollOffset
let centerY = iconY + iconSize / 2
return CGPoint(x: centerX, y: centerY)
}
func currentFolderGlassStyle() -> (background: NSColor, border: NSColor, shadowOpacity: Float, shadowRadius: CGFloat, shadowOffset: CGSize) {
let match = effectiveAppearance.bestMatch(from: [.darkAqua, .aqua])
if match == .darkAqua {
return (NSColor.white.withAlphaComponent(0.08),
NSColor.white.withAlphaComponent(0.2),
0.15,
3,
CGSize(width: 0, height: -1))
}
return (NSColor.white.withAlphaComponent(0.7),
NSColor.white.withAlphaComponent(0.75),
0.2,
4,
CGSize(width: 0, height: -1))
}
func applyScaleForIndex(_ index: Int, animated: Bool) {
let pageIndex = index / itemsPerPage
let localIndex = index % itemsPerPage
guard pageIndex < iconLayers.count, localIndex < iconLayers[pageIndex].count else { return }
let containerLayer = iconLayers[pageIndex][localIndex]
let pressScale: CGFloat = (pressedIndex == index && activePressEffectEnabled) ? CGFloat(activePressScale) : 1.0
let selectionScale: CGFloat = 1.2
var iconScale: CGFloat = 1.0
if dropTargetIndex == index {
iconScale = 1.1
} else if selectedIndex == index {
iconScale = selectionScale
} else if hoverMagnificationEnabled, hoveredIndex == index {
iconScale = hoverMagnificationScale
}
CATransaction.begin()
CATransaction.setAnimationDuration(animated ? 0.12 : 0)
CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(name: .easeOut))
containerLayer.transform = CATransform3DMakeScale(pressScale, pressScale, 1.0)
if let iconLayer = containerLayer.sublayers?.first(where: { $0.name == "icon" }) {
iconLayer.transform = CATransform3DMakeScale(iconScale, iconScale, 1.0)
}
if let glassLayer = containerLayer.sublayers?.first(where: { $0.name == "glass" }) {
glassLayer.transform = CATransform3DMakeScale(iconScale, iconScale, 1.0)
}
CATransaction.commit()
}
func updateSelection(_ index: Int?, animated: Bool = true) {
let clampedIndex: Int? = {
guard let index else { return nil }
guard index >= 0, index < items.count else { return nil }
if case .empty = items[index] { return nil }
return index
}()
guard selectedIndex != clampedIndex else { return }
let old = selectedIndex
selectedIndex = clampedIndex
if let old = old { applyScaleForIndex(old, animated: animated) }
if let newIndex = selectedIndex { applyScaleForIndex(newIndex, animated: animated) }
}
func updateExternalDragState(sourceIndex: Int?, hoverIndex: Int?) {
// Avoid interfering with native CA drag.
guard !isDraggingItem else { return }
guard bounds.width > 0, bounds.height > 0 else { return }
if let sourceIndex = sourceIndex {
if !externalDragActive || draggingIndex != sourceIndex {
externalDragActive = true
draggingIndex = sourceIndex
let pageIndex = sourceIndex / itemsPerPage
let localIndex = sourceIndex % itemsPerPage
if pageIndex < iconLayers.count, localIndex < iconLayers[pageIndex].count {
iconLayers[pageIndex][localIndex].opacity = 0
}
}
updateIconPositionsForDrag(hoverIndex: hoverIndex)
} else if externalDragActive {
externalDragActive = false
draggingIndex = nil
resetIconPositions()
}
}
func ensureOriginalPositionsForCurrentPage(pageLayers: [CALayer], pageStart: Int) {
// If we already captured positions for this page, keep them.
var hasAny = false
for localIndex in 0..<pageLayers.count {
let globalIndex = pageStart + localIndex
if originalIconPositions[globalIndex] != nil {
hasAny = true
break
}
}
guard !hasAny else { return }
for (localIndex, layer) in pageLayers.enumerated() {
let globalIndex = pageStart + localIndex
originalIconPositions[globalIndex] = layer.position
}
}
// MARK: - 边缘翻页检测
func checkEdgeDrag(at point: CGPoint) {
let leftEdge = point.x < edgeDragThreshold
let rightEdge = point.x > bounds.width - edgeDragThreshold
if leftEdge && currentPage > 0 {
// 左边缘 - 翻到上一页
startEdgeDragTimer(direction: -1)
} else if rightEdge {
// 右边缘 - 翻到下一页(可能创建新页)
startEdgeDragTimer(direction: 1)
} else {
// 离开边缘区域 - 取消计时器
cancelEdgeDragTimer()
}
}
func startEdgeDragTimer(direction: Int) {
// 如果已有相同方向的计时器,不重复创建
if edgeDragTimer != nil { return }
let timer = Timer(timeInterval: edgeDragDelay, repeats: false) { [weak self] _ in
guard let self = self else { return }
let targetPage = self.currentPage + direction
// 检查是否需要创建新页面
if direction > 0 && targetPage >= self.pageCount {
// 通知创建新页面
self.onRequestNewPage?()
}
self.navigateToPage(targetPage, animated: true)
self.edgeDragTimer = nil
// 翻页后继续检测
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
guard let self = self, self.isDraggingItem else { return }
self.checkEdgeDrag(at: self.dragCurrentPoint)
}
}
RunLoop.main.add(timer, forMode: .common)
edgeDragTimer = timer
}
func cancelEdgeDragTimer() {
edgeDragTimer?.invalidate()
edgeDragTimer = nil
}
func hardSnapToCurrentPage() {
guard bounds.width > 0 else { return }
resetScrollInteractionState()
isScrollAnimating = false
scrollAnimationStartTime = 0
let expectedOffset = -CGFloat(currentPage) * (bounds.width + pageSpacing)
scrollOffset = expectedOffset
targetScrollOffset = expectedOffset
CATransaction.begin()
CATransaction.setDisableActions(true)
CATransaction.setAnimationDuration(0)
pageContainerLayer.transform = CATransform3DMakeTranslation(scrollOffset, 0, 0)
CATransaction.commit()
}
func resetScrollInteractionState() {
isDragging = false
accumulatedDelta = 0
wheelAccumulatedDelta = 0
wheelLastDirection = 0
wheelLastFlipAt = nil
}
/// 计算点击位置对应的网格位置(即使是空白区域)
func gridPositionAt(_ point: CGPoint) -> Int? {
let pageWidth = bounds.width
let pageHeight = bounds.height
let adjustedX = point.x - scrollOffset
// 计算点击的页面
let pageStride = pageWidth + pageSpacing
let pageIndex = Int(floor(adjustedX / pageStride))
guard pageIndex >= 0 else { return nil }
// 允许拖拽到最后一页之后(会创建新页)
let effectivePageIndex = min(pageIndex, max(0, pageCount - 1))
let availableWidth = max(0, pageWidth - contentInsets.left - contentInsets.right)
let availableHeight = max(0, pageHeight - contentInsets.top - contentInsets.bottom)
let totalColumnSpacing = columnSpacing * CGFloat(max(columns - 1, 0))
let totalRowSpacing = rowSpacing * CGFloat(max(rows - 1, 0))
let usableWidth = max(0, availableWidth - totalColumnSpacing)
let usableHeight = max(0, availableHeight - totalRowSpacing)
let cellWidth = usableWidth / CGFloat(max(columns, 1))
let cellHeight = usableHeight / CGFloat(max(rows, 1))
let strideX = cellWidth + columnSpacing
let strideY = cellHeight + rowSpacing
// 计算点击位置相对于当前页的坐标
let pageX = adjustedX - CGFloat(effectivePageIndex) * pageStride
guard pageX >= 0, pageX <= pageWidth else { return nil }
let localX = pageX - contentInsets.left
let localY = pageHeight - point.y - contentInsets.top
// 钳制到有效范围
let clampedX = max(0, min(localX, availableWidth - 1))
let clampedY = max(0, min(localY, availableHeight - 1))
let col = Int(clampedX / strideX)
let row = Int(clampedY / strideY)
let clampedCol = max(0, min(col, columns - 1))
let clampedRow = max(0, min(row, rows - 1))
let localIndex = clampedRow * columns + clampedCol
let globalIndex = effectivePageIndex * itemsPerPage + localIndex
return globalIndex
}
func highlightDropTarget(at index: Int) {
// 清除之前的高亮
if let oldTarget = dropTargetIndex, oldTarget != index {
dropTargetIndex = nil
applyScaleForIndex(oldTarget, animated: true)
}
dropTargetIndex = index
applyScaleForIndex(index, animated: true)
}
func clearDropTargetHighlight() {
if let target = dropTargetIndex {
dropTargetIndex = nil
applyScaleForIndex(target, animated: true)
}
}
func setHighlight(at index: Int, highlighted _: Bool) {
applyScaleForIndex(index, animated: true)
}
func endDragging(at point: CGPoint) {
guard let dragIndex = draggingIndex, let dragItem = draggingItem else {
cancelDragging()
return
}
// Save current hover position before clearing
let savedHoverIndex = currentHoverIndex
// Clear highlights and reset
clearDropTargetHighlight()
cancelEdgeDragTimer()
// Calculate target position
let targetPosition = gridPositionAt(point)
// Track if we're doing a reorder (so we don't reset positions unnecessarily)
var didReorder = false
if isBatchDragging {
if let insertIndex = savedHoverIndex ?? targetPosition {
let clampedIndex = max(0, min(insertIndex, items.count))
let singleDragNoop = batchDraggingAppPathsOrdered.count == 1 && clampedIndex == dragIndex
if !singleDragNoop {
onReorderAppBatch?(batchDraggingAppPathsOrdered, clampedIndex)
didReorder = true
}
}
} else {
// 检查是否拖到另一个item上
if let (targetItem, targetIndex) = itemAt(point), targetIndex != dragIndex {
// print("🎯 [CAGrid] Dropped on item: \(targetItem.name) at index \(targetIndex)")
// 拖拽到另一个 item 上
if case .app(let dragApp) = dragItem {
switch targetItem {
case .app(let targetApp):
// 两个应用 -> 创建文件夹
// print("📁 [CAGrid] Creating folder: \(dragApp.name) + \(targetApp.name)")
onCreateFolder?(dragApp, targetApp, targetIndex)
cancelDragging()
return
case .folder(let folder):
// 拖到文件夹 -> 移入文件夹
// print("📂 [CAGrid] Moving to folder: \(dragApp.name) -> \(folder.name)")
onMoveToFolder?(dragApp, folder)
cancelDragging()
return
case .empty, .missingApp:
// 空白格子或丢失的应用 -> 当作重排序处理
// print("🔄 [CAGrid] Dropped on empty/missing, reordering: \(dragIndex) -> \(targetIndex)")
onReorderItems?(dragIndex, targetIndex)
didReorder = true
}
}
}
// Reorder to empty area - use saved hover position or calculated position
if !didReorder, let insertIndex = savedHoverIndex ?? targetPosition, insertIndex != dragIndex {
onReorderItems?(dragIndex, insertIndex)
didReorder = true
}
}
// If we did a reorder, data will update and rebuild layers
// Delay clearing dragging state to avoid visual glitches during rebuild
if didReorder {
// Save drag index before clearing
let savedDragIndex = draggingIndex
// Remove dragging layer immediately
draggingLayer?.removeFromSuperlayer()
draggingLayer = nil
// Clear dragging flags immediately
isDraggingItem = false
dropTargetIndex = nil
// Cancel pending updates
hoverUpdateTimer?.invalidate()
hoverUpdateTimer = nil
pendingHoverIndex = nil
currentHoverIndex = nil
// Delay clearing other state to let data update complete
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.draggingIndex = nil
self.draggingItem = nil
self.originalIconPositions.removeAll()
// Restore opacity of dragged item (if layers still exist)
if let dragIndex = savedDragIndex {
let pageIndex = dragIndex / self.itemsPerPage
let localIndex = dragIndex % self.itemsPerPage
if pageIndex < self.iconLayers.count, localIndex < self.iconLayers[pageIndex].count {
self.iconLayers[pageIndex][localIndex].opacity = 1.0
}
}
self.restoreBatchHiddenCompanionLayers()
if self.isBatchSelectionMode {
self.disableBatchSelectionMode()
}
self.forceSyncPageTransformIfNeeded()
}
} else {
// No reorder happened, reset positions to original
cancelDragging()
}
hardSnapToCurrentPage()
logIfMismatch("endDragging")
}
func cancelDragging() {
// Reset icon positions to original
resetIconPositions()
// Remove dragging layer
draggingLayer?.removeFromSuperlayer()
draggingLayer = nil
isDraggingItem = false
draggingIndex = nil
draggingItem = nil
dropTargetIndex = nil
restoreBatchHiddenCompanionLayers()
hardSnapToCurrentPage()
logIfMismatch("cancelDragging")
}
func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation {
context == .outsideApplication ? .copy : []
}
func ignoreModifierKeys(for session: NSDraggingSession) -> Bool {
true
}
func draggingSession(_ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) {
externalAppDragSessionActive = false
AppDelegate.shared?.endExternalSystemDragSession()
}
func itemAt(_ point: CGPoint) -> (LaunchpadItem, Int)? {
let pageWidth = bounds.width
let pageHeight = bounds.height
let adjustedX = point.x - scrollOffset
// 计算点击的页面
let pageStride = pageWidth + pageSpacing
let pageIndex = Int(floor(adjustedX / pageStride))
guard pageIndex >= 0 && pageIndex < pageCount else { return nil }
let availableWidth = max(0, pageWidth - contentInsets.left - contentInsets.right)
let availableHeight = max(0, pageHeight - contentInsets.top - contentInsets.bottom)
let totalColumnSpacing = columnSpacing * CGFloat(max(columns - 1, 0))
let totalRowSpacing = rowSpacing * CGFloat(max(rows - 1, 0))
let usableWidth = max(0, availableWidth - totalColumnSpacing)
let usableHeight = max(0, availableHeight - totalRowSpacing)
let cellWidth = usableWidth / CGFloat(max(columns, 1))
let cellHeight = usableHeight / CGFloat(max(rows, 1))
let strideX = cellWidth + columnSpacing
let strideY = cellHeight + rowSpacing
// 计算点击位置相对于当前页的坐标
let pageX = adjustedX - CGFloat(pageIndex) * pageStride
guard pageX >= 0, pageX <= pageWidth else { return nil }
let localX = pageX - contentInsets.left
let localY = pageHeight - point.y - contentInsets.top
guard localX >= 0, localY >= 0 else { return nil }
guard localX < availableWidth, localY < availableHeight else { return nil }
let col = Int(localX / strideX)
let row = Int(localY / strideY)
guard col >= 0, col < columns, row >= 0, row < rows else { return nil }
let cellOriginX = CGFloat(col) * strideX
let cellOriginY = CGFloat(row) * strideY
let cellLocalX = localX - cellOriginX
let cellLocalY = localY - cellOriginY
guard cellLocalX >= 0, cellLocalX <= cellWidth else { return nil }
guard cellLocalY >= 0, cellLocalY <= cellHeight else { return nil }
let localIndex = row * columns + col
let globalIndex = pageIndex * itemsPerPage + localIndex
guard globalIndex < items.count else { return nil }
// 检查是否点击在图标+标签区域内(不是单元格的空白部分)
let actualIconSize = iconSize
let labelHeight: CGFloat = showLabels ? (labelFontSize + 8) : 0
let labelTopSpacing: CGFloat = showLabels ? 6 : 0
let totalItemHeight = actualIconSize + labelTopSpacing + labelHeight
// 图标+标签区域居中于单元格
let itemStartX = (cellWidth - actualIconSize) / 2
let itemEndX = itemStartX + actualIconSize
let itemStartY = (cellHeight - totalItemHeight) / 2
let itemEndY = itemStartY + totalItemHeight
// 检查是否在图标+标签区域内
guard cellLocalX >= itemStartX && cellLocalX <= itemEndX else { return nil }
guard cellLocalY >= itemStartY && cellLocalY <= itemEndY else { return nil }
return (items[globalIndex], globalIndex)
}
}
```
## /LaunchNext/CAGridView+Layout.swift
```swift path="/LaunchNext/CAGridView+Layout.swift"
import AppKit
import QuartzCore
extension CAGridView {
// MARK: - Layer Management
func rebuildLayers() {
CATransaction.begin()
CATransaction.setDisableActions(true)
// 清除旧层
for pageLayers in iconLayers {
for layer in pageLayers {
layer.removeFromSuperlayer()
}
}
iconLayers.removeAll()
guard !items.isEmpty else {
CATransaction.commit()
// print("⚠️ [CAGrid] rebuildLayers: no items")
return
}
// 为每页创建图层
let totalPages = pageCount
// print("🔧 [CAGrid] rebuildLayers: \(items.count) items, \(totalPages) pages, \(itemsPerPage) per page")
for pageIndex in 0..<totalPages {
var pageLayers: [CALayer] = []
let startIndex = pageIndex * itemsPerPage
let endIndex = min(startIndex + itemsPerPage, items.count)
for i in startIndex..<endIndex {
let localIndex = i - startIndex
let layer = createIconLayer(for: items[i], localIndex: localIndex, pageIndex: pageIndex)
pageContainerLayer.addSublayer(layer)
pageLayers.append(layer)
}
iconLayers.append(pageLayers)
}
CATransaction.commit()
// Update layout if bounds are ready, otherwise layout() will handle it later
if bounds.width > 0 && bounds.height > 0 {
updateLayout()
}
// Navigate to current page (will be handled by layout() if bounds not ready)
navigateToPage(currentPage, animated: false)
logIfMismatch("rebuildLayers")
}
// Track if layout needs refresh after bounds become valid
func createIconLayer(for item: LaunchpadItem, localIndex: Int, pageIndex: Int) -> CALayer {
_ = localIndex
_ = pageIndex
let containerLayer = CALayer()
containerLayer.masksToBounds = false
containerLayer.drawsAsynchronously = true
// For folders, add a glass background layer
if case .folder = item {
let glassLayer = CALayer()
glassLayer.name = "glass"
let glassStyle = currentFolderGlassStyle()
glassLayer.backgroundColor = glassStyle.background.cgColor
glassLayer.borderColor = glassStyle.border.cgColor
glassLayer.borderWidth = 0.5
glassLayer.masksToBounds = false
// Subtle shadow
glassLayer.shadowColor = NSColor.black.cgColor
glassLayer.shadowOffset = glassStyle.shadowOffset
glassLayer.shadowRadius = glassStyle.shadowRadius
glassLayer.shadowOpacity = glassStyle.shadowOpacity
containerLayer.addSublayer(glassLayer)
}
// Icon layer
let iconLayer = CALayer()
iconLayer.name = "icon"
iconLayer.contentsGravity = .resizeAspect
iconLayer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0
iconLayer.masksToBounds = false
iconLayer.shouldRasterize = true
iconLayer.rasterizationScale = NSScreen.main?.backingScaleFactor ?? 2.0
iconLayer.drawsAsynchronously = true
iconLayer.shadowOpacity = 0
containerLayer.addSublayer(iconLayer)
// 文字标签层 - 匹配原 SwiftUI 样式
let textLayer = CATextLayer()
textLayer.name = "label"
textLayer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0
textLayer.fontSize = labelFontSize
textLayer.font = NSFont.systemFont(ofSize: labelFontSize, weight: labelFontWeight)
textLayer.isHidden = !showLabels
textLayer.alignmentMode = .center
textLayer.truncationMode = .end
textLayer.isWrapped = false
// 性能优化:栅格化文字层
textLayer.shouldRasterize = true
textLayer.rasterizationScale = NSScreen.main?.backingScaleFactor ?? 2.0
// Match system label color for light/dark
textLayer.foregroundColor = currentLabelColor().cgColor
textLayer.shadowOpacity = 0
// 设置文字内容
switch item {
case .app(let app):
textLayer.string = app.name
case .folder(let folder):
textLayer.string = folder.name
case .missingApp(let placeholder):
textLayer.string = placeholder.displayName
case .empty:
textLayer.string = ""
}
containerLayer.addSublayer(textLayer)
// 设置图标
setIcon(for: iconLayer, item: item)
if case .app = item {
let checkboxLayer = CALayer()
checkboxLayer.name = "batchSelectionCheckbox"
checkboxLayer.isHidden = true
checkboxLayer.cornerRadius = 8
checkboxLayer.borderWidth = 1.25
checkboxLayer.zPosition = 20
checkboxLayer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0
let markLayer = CAShapeLayer()
markLayer.name = "batchSelectionCheckboxMark"
markLayer.fillColor = NSColor.clear.cgColor
markLayer.strokeColor = NSColor.white.cgColor
markLayer.lineCap = .round
markLayer.lineJoin = .round
markLayer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0
checkboxLayer.addSublayer(markLayer)
containerLayer.addSublayer(checkboxLayer)
}
return containerLayer
}
func setIcon(for layer: CALayer, item: LaunchpadItem) {
switch item {
case .app(let app):
let path = app.url.path
layer.setValue(path, forKey: "iconPath")
if let cgImage = getCachedIcon(for: app.url.path) {
layer.contents = cgImage
} else {
// 异步加载 - 直接从系统获取图标
DispatchQueue.global(qos: .userInitiated).async { [weak self, weak layer] in
guard let self = self, let layer = layer else { return }
guard layer.value(forKey: "iconPath") as? String == path else { return }
// 使用 IconStore 获取图标(CA 模式走 Next Engine 逻辑)
let icon = IconStore.shared.icon(forPath: path)
if let cgImage = self.loadIcon(for: path, icon: icon) {
DispatchQueue.main.async {
guard layer.value(forKey: "iconPath") as? String == path else { return }
CATransaction.begin()
CATransaction.setDisableActions(true)
layer.contents = cgImage
CATransaction.commit()
}
}
}
}
case .folder(let folder):
// 异步加载文件夹图标
let folderIconSize = iconSize
let previewScale = folderPreviewScale
DispatchQueue.global(qos: .userInitiated).async { [weak layer] in
let icon = folder.icon(of: folderIconSize, scale: previewScale)
if let cgImage = icon.cgImage(forProposedRect: nil, context: nil, hints: nil) {
DispatchQueue.main.async {
CATransaction.begin()
CATransaction.setDisableActions(true)
layer?.contents = cgImage
CATransaction.commit()
}
}
}
case .missingApp(let placeholder):
if let cgImage = placeholder.icon.cgImage(forProposedRect: nil, context: nil, hints: nil) {
layer.contents = cgImage
}
case .empty:
layer.contents = nil
}
}
func getCachedIcon(for path: String) -> CGImage? {
iconCacheLock.lock()
defer { iconCacheLock.unlock() }
return iconCache[path]
}
func loadIcon(for path: String, icon: NSImage) -> CGImage? {
iconCacheLock.lock()
if let cached = iconCache[path] {
iconCacheLock.unlock()
return cached
}
iconCacheLock.unlock()
// 渲染为 CGImage
let size = NSSize(width: iconSize * 2, height: iconSize * 2) // Retina
let image = NSImage(size: size)
image.lockFocus()
icon.draw(in: NSRect(origin: .zero, size: size))
image.unlockFocus()
guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
return nil
}
iconCacheLock.lock()
iconCache[path] = cgImage
iconCacheLock.unlock()
return cgImage
}
func preloadIcons() {
guard enableIconPreload else { return }
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
guard let self = self else { return }
for item in self.items {
if case .app(let app) = item {
let icon = IconStore.shared.icon(forPath: app.url.path)
_ = self.loadIcon(for: app.url.path, icon: icon)
}
}
// print("✅ [CAGrid] Icons preloaded")
}
}
// MARK: - Layout
func updateLayout() {
guard bounds.width > 0, bounds.height > 0 else { return }
CATransaction.begin()
CATransaction.setDisableActions(true)
let pageWidth = bounds.width
let pageHeight = bounds.height
let pageStride = pageWidth + pageSpacing
let availableWidth = max(0, pageWidth - contentInsets.left - contentInsets.right)
let availableHeight = max(0, pageHeight - contentInsets.top - contentInsets.bottom)
let totalColumnSpacing = columnSpacing * CGFloat(max(columns - 1, 0))
let totalRowSpacing = rowSpacing * CGFloat(max(rows - 1, 0))
let usableWidth = max(0, availableWidth - totalColumnSpacing)
let usableHeight = max(0, availableHeight - totalRowSpacing)
let cellWidth = usableWidth / CGFloat(max(columns, 1))
let cellHeight = usableHeight / CGFloat(max(rows, 1))
let strideX = cellWidth + columnSpacing
let actualIconSize = iconSize
let labelHeight: CGFloat = showLabels ? (labelFontSize + 8) : 0
let labelTopSpacing: CGFloat = showLabels ? 6 : 0
for (pageIndex, pageLayers) in iconLayers.enumerated() {
for (localIndex, containerLayer) in pageLayers.enumerated() {
let col = localIndex % columns
let row = localIndex / columns
let cellOriginX = contentInsets.left + CGFloat(col) * strideX
let cellOriginY = pageHeight - contentInsets.top - CGFloat(row + 1) * cellHeight - CGFloat(row) * rowSpacing
let totalHeight = actualIconSize + labelTopSpacing + labelHeight
let containerX = CGFloat(pageIndex) * pageStride + cellOriginX
let containerY = cellOriginY + (cellHeight - totalHeight) / 2
containerLayer.frame = CGRect(x: containerX, y: containerY, width: cellWidth, height: totalHeight)
if let iconLayer = containerLayer.sublayers?.first(where: { $0.name == "icon" }) {
let iconX = (cellWidth - actualIconSize) / 2
let iconY = labelHeight + labelTopSpacing
let iconFrame = CGRect(x: iconX, y: iconY, width: actualIconSize, height: actualIconSize)
iconLayer.frame = iconFrame
if let checkboxLayer = containerLayer.sublayers?.first(where: { $0.name == "batchSelectionCheckbox" }) {
let checkboxSize = max(16, min(22, actualIconSize * 0.28))
let edgeInset = max(2.5, min(5.0, actualIconSize * 0.055))
let checkboxX = iconFrame.maxX - checkboxSize - edgeInset
let checkboxY = iconFrame.maxY - checkboxSize - edgeInset
checkboxLayer.frame = CGRect(x: checkboxX, y: checkboxY, width: checkboxSize, height: checkboxSize)
checkboxLayer.cornerRadius = checkboxSize * 0.5
if let markLayer = checkboxLayer.sublayers?.first(where: { $0.name == "batchSelectionCheckboxMark" }) as? CAShapeLayer {
markLayer.frame = checkboxLayer.bounds
markLayer.lineWidth = max(1.7, checkboxSize * 0.14)
markLayer.path = checkboxMarkPath(in: checkboxLayer.bounds)
}
}
}
// Update glass background for folders
if let glassLayer = containerLayer.sublayers?.first(where: { $0.name == "glass" }) {
let glassSize = actualIconSize * 0.8
let glassX = (cellWidth - glassSize) / 2
let glassY = labelHeight + labelTopSpacing + (actualIconSize - glassSize) / 2
glassLayer.frame = CGRect(x: glassX, y: glassY, width: glassSize, height: glassSize)
glassLayer.cornerRadius = glassSize * 0.25 // Larger corner radius
}
if let textLayer = containerLayer.sublayers?.first(where: { $0.name == "label" }) as? CATextLayer {
let labelWidth = cellWidth - 8
textLayer.isHidden = !showLabels
textLayer.frame = CGRect(x: 4, y: 0, width: labelWidth, height: labelHeight)
}
}
}
let totalWidth = pageWidth * CGFloat(max(1, pageCount)) + pageSpacing * CGFloat(max(pageCount - 1, 0))
// Avoid setting frame on a transformed layer; reset to identity, update frame, then re-apply translation.
// This prevents visual/data misalignment caused by frame updates under non-identity transforms.
pageContainerLayer.transform = CATransform3DIdentity
pageContainerLayer.frame = CGRect(x: 0, y: 0, width: totalWidth, height: bounds.height)
pageContainerLayer.transform = CATransform3DMakeTranslation(scrollOffset, 0, 0)
refreshBatchSelectionUI()
CATransaction.commit()
logIfMismatch("updateLayout")
// print("📐 [CAGrid] Layout: \(columns)x\(rows), iconSize=\(actualIconSize), cell=\(cellWidth)x\(cellHeight)")
}
func refreshBatchSelectionUI() {
CATransaction.begin()
CATransaction.setDisableActions(true)
for (pageIndex, pageLayers) in iconLayers.enumerated() {
let pageStart = pageIndex * itemsPerPage
for (localIndex, containerLayer) in pageLayers.enumerated() {
let globalIndex = pageStart + localIndex
guard items.indices.contains(globalIndex),
case .app(let app) = items[globalIndex],
let checkboxLayer = containerLayer.sublayers?.first(where: { $0.name == "batchSelectionCheckbox" }) else {
continue
}
let isSelected = batchSelectedAppPathSet.contains(app.url.path)
checkboxLayer.isHidden = !isBatchSelectionMode
if isSelected {
checkboxLayer.backgroundColor = NSColor.systemBlue.cgColor
checkboxLayer.borderColor = NSColor.systemBlue.withAlphaComponent(0.95).cgColor
} else {
checkboxLayer.backgroundColor = NSColor.white.withAlphaComponent(0.88).cgColor
checkboxLayer.borderColor = NSColor.black.withAlphaComponent(0.35).cgColor
}
if let markLayer = checkboxLayer.sublayers?.first(where: { $0.name == "batchSelectionCheckboxMark" }) as? CAShapeLayer {
markLayer.isHidden = !isSelected
markLayer.strokeColor = NSColor.white.cgColor
}
}
}
CATransaction.commit()
}
func checkboxMarkPath(in bounds: CGRect) -> CGPath {
let path = CGMutablePath()
let start = CGPoint(x: bounds.minX + bounds.width * 0.24, y: bounds.minY + bounds.height * 0.50)
let mid = CGPoint(x: bounds.minX + bounds.width * 0.44, y: bounds.minY + bounds.height * 0.30)
let end = CGPoint(x: bounds.minX + bounds.width * 0.76, y: bounds.minY + bounds.height * 0.66)
path.move(to: start)
path.addLine(to: mid)
path.addLine(to: end)
return path
}
override func viewWillDraw() {
super.viewWillDraw()
// 确保视图是第一响应者和滚轮监听器已安装
if window != nil {
makeFirstResponderIfAvailable()
// 确保滚轮监听器存在
if scrollEventMonitor == nil {
setupScrollEventMonitor()
}
}
}
override func layout() {
super.layout()
guard bounds.width > 0, bounds.height > 0 else { return }
CATransaction.begin()
CATransaction.setDisableActions(true)
containerLayer.frame = bounds
CATransaction.commit()
// Force layout update, especially important when bounds become valid after items were set
updateLayout()
needsLayoutRefresh = false
// Clamp currentPage to valid range after items/pageCount might have changed
let validPage = max(0, min(pageCount - 1, currentPage))
if validPage != currentPage {
currentPage = validPage
}
// Reposition to current page without animation
let pageStride = bounds.width + pageSpacing
scrollOffset = -CGFloat(currentPage) * pageStride
targetScrollOffset = scrollOffset
isScrollAnimating = false
CATransaction.begin()
CATransaction.setDisableActions(true)
pageContainerLayer.transform = CATransform3DMakeTranslation(scrollOffset, 0, 0)
CATransaction.commit()
logIfMismatch("layout")
}
}
```
## /LaunchNext/CAGridView.swift
```swift path="/LaunchNext/CAGridView.swift"
import AppKit
import QuartzCore
import Combine
import SwiftUI
// MARK: - Safe Array Subscript
extension Array {
subscript(safe index: Int) -> Element? {
return indices.contains(index) ? self[index] : nil
}
}
// MARK: - Core Animation Grid View
/// 使用 Core Animation 实现的高性能网格视图,支持 120Hz ProMotion
final class CAGridView: NSView, CALayerDelegate, NSDraggingSource {
// MARK: - Properties
var displayLink: CADisplayLink?
var containerLayer: CALayer!
var pageContainerLayer: CALayer!
var iconLayers: [[CALayer]] = [] // [page][item]
// 网格配置
var columns: Int = 7 { didSet { rebuildLayers() } }
var rows: Int = 5 { didSet { rebuildLayers() } }
var iconSize: CGFloat = 72 {
didSet {
guard iconSize != oldValue else { return }
clearIconCache()
updateLayout()
}
}
var columnSpacing: CGFloat = 24 { didSet { updateLayout() } }
var rowSpacing: CGFloat = 36 { didSet { updateLayout() } }
var labelFontSize: CGFloat = 12 { didSet { rebuildLayers() } } // 默认 12pt,比原来大一点
var labelFontWeight: NSFont.Weight = .medium { didSet { updateLabelFonts() } }
var showLabels: Bool = true { didSet { updateLabelVisibility() } }
var isLayoutLocked: Bool = false
var folderDropZoneScale: CGFloat = CGFloat(AppStore.defaultFolderDropZoneScale)
var folderPreviewScale: CGFloat = 1
var contentInsets: NSEdgeInsets = NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) { didSet { updateLayout() } }
var pageSpacing: CGFloat = 0 { didSet { updateLayout() } }
// Data source
var items: [LaunchpadItem] = [] {
didSet {
needsLayoutRefresh = true
syncBatchSelectionWithItems()
rebuildLayers()
if enableIconPreload {
preloadIcons()
}
}
}
var needsLayoutRefresh = true
// 分页
var currentPage: Int = 0
var itemsPerPage: Int { columns * rows }
var pageCount: Int { max(1, (items.count + itemsPerPage - 1) / itemsPerPage) }
// 滚动状态
var scrollOffset: CGFloat = 0
var targetScrollOffset: CGFloat = 0
var scrollVelocity: CGFloat = 0
var isScrollAnimating = false
var scrollSensitivity: Double = AppStore.defaultScrollSensitivity
var reverseWheelPagingDirection: Bool = false
var animationsEnabled: Bool = true
var animationDuration: Double = 0.3
var scrollAnimationStartTime: CFTimeInterval = 0
var scrollAnimationStartOffset: CGFloat = 0
var hoverMagnificationEnabled: Bool = false {
didSet {
if !hoverMagnificationEnabled {
clearHover()
}
}
}
var hoverMagnificationScale: CGFloat = 1.2
var activePressEffectEnabled: Bool = false
var activePressScale: CGFloat = 0.92
var isDragging = false
var dragStartOffset: CGFloat = 0
var accumulatedDelta: CGFloat = 0
// 性能监控
var lastFrameTime: CFAbsoluteTime = 0
var frameCount: Int = 0
var currentFPS: Double = 120
var frameTimes: [Double] = []
// 图标缓存
var iconCache: [String: CGImage] = [:]
let iconCacheLock = NSLock()
var enableIconPreload: Bool = false
// 回调
var onItemClicked: ((LaunchpadItem, Int) -> Void)?
var onItemDoubleClicked: ((LaunchpadItem, Int) -> Void)?
var onPageChanged: ((Int) -> Void)?
var onFPSUpdate: ((Double) -> Void)?
var onEmptyAreaClicked: (() -> Void)?
var onShowAppInFinder: ((AppInfo) -> Void)?
var onCopyAppPath: ((AppInfo) -> Void)?
var onHideApp: ((AppInfo) -> Void)?
var onRenameFolder: ((FolderInfo) -> Void)?
var onDissolveFolder: ((FolderInfo) -> Void)?
var onUninstallWithTool: ((AppInfo) -> Void)?
var onCreateFolder: ((AppInfo, AppInfo, Int) -> Void)? // (拖拽的app, 目标app, 位置)
var onMoveToFolder: ((AppInfo, FolderInfo) -> Void)? // 移动到已有文件夹
var onReorderItems: ((Int, Int) -> Void)? // 重新排序 (fromIndex, toIndex)
var onReorderAppBatch: (([String], Int) -> Void)? // 批量重排(按路径顺序)
var onRequestNewPage: (() -> Void)? // 请求创建新页面
var showInFinderMenuTitle: String = "Show in Finder"
var copyAppPathMenuTitle: String = "Copy App Path"
var hideAppMenuTitle: String = "Hide application"
var renameFolderMenuTitle: String = "Rename Folder"
var dissolveFolderMenuTitle: String = "Dissolve folder"
var uninstallWithToolMenuTitle: String = "Uninstall with configured tool"
var batchSelectAppsMenuTitle: String = "Batch Select Apps"
var finishBatchSelectionMenuTitle: String = "Finish Batch Selection"
var canUseConfiguredUninstallTool: Bool = false
var contextMenuTargetApp: AppInfo?
var contextMenuTargetFolder: FolderInfo?
var allowsBatchSelectionMode: Bool = true {
didSet {
if !allowsBatchSelectionMode {
disableBatchSelectionMode()
}
}
}
var isBatchSelectionMode = false
var batchSelectedAppPathsOrdered: [String] = []
var batchSelectedAppPathSet: Set<String> = []
var batchDraggingAppPathsOrdered: [String] = []
var batchHiddenCompanionIndices: [Int] = []
// 拖拽状态
var isDraggingItem = false
var draggingIndex: Int?
var draggingItem: LaunchpadItem?
var draggingLayer: CALayer?
var dragStartPoint: CGPoint = .zero
var dragCurrentPoint: CGPoint = .zero
var dropTargetIndex: Int?
var longPressTimer: Timer?
let longPressDuration: TimeInterval = 0.5
var pressedIndex: Int?
// 跨页拖拽
var edgeDragTimer: Timer?
let edgeDragThreshold: CGFloat = 60 // 边缘检测区域宽度
let edgeDragDelay: TimeInterval = 0.4 // 触发翻页延迟
// Live reorder during drag
var currentHoverIndex: Int?
var pendingHoverIndex: Int?
var originalIconPositions: [Int: CGPoint] = [:]
var hoverUpdateTimer: Timer?
let hoverUpdateDelay: TimeInterval = 0.15 // Delay before updating icon positions
// 鼠标拖拽翻页
var isPageDragging = false
var pageDragStartX: CGFloat = 0
var pageDragStartOffset: CGFloat = 0
// 事件监听器
var scrollEventMonitor: Any?
var wasWindowVisible = false // 跟踪窗口可见状态
// 鼠标滚轮分页状态(仅用于非精准滚动设备)
var wheelAccumulatedDelta: CGFloat = 0
var wheelLastDirection: Int = 0
var wheelLastFlipAt: Date?
let wheelFlipCooldown: TimeInterval = 0.15
// Legacy reference:
// var wheelSnapTimer: Timer?
// let wheelSnapDelay: TimeInterval = 0.15 // 停止滚动后多久触发 snap
let debugScrollMismatch = false
var externalDragActive = false
var externalAppDragSessionActive = false
var hoveredIndex: Int?
var selectedIndex: Int?
var hoverTrackingArea: NSTrackingArea?
var isScrollEnabled: Bool = true
var dockDragEnabled: Bool = true
let externalAppDragOutset: CGFloat = 18
var dockDragSide: AppStore.DockDragSide = .bottom
var externalAppDragTriggerDistance: CGFloat = CGFloat(AppStore.defaultDockDragTriggerDistance)
func logIfMismatch(_ tag: String, appPage: Int? = nil) {
guard debugScrollMismatch else { return }
guard bounds.width > 0 else { return }
let pageStride = bounds.width + pageSpacing
let expectedOffset = -CGFloat(currentPage) * pageStride
let transformOffset = pageContainerLayer.transform.m41
let offsetMismatch = abs(scrollOffset - expectedOffset) > 0.5
let transformMismatch = abs(transformOffset - scrollOffset) > 0.5
guard offsetMismatch || transformMismatch else { return }
let appInfo = appPage.map { ", appPage=\($0)" } ?? ""
// print("⚠️ [CAGrid #\(instanceId)] \(tag) mismatch: currentPage=\(currentPage)\(appInfo), scroll=\(scrollOffset), expected=\(expectedOffset), transform=\(transformOffset), boundsW=\(bounds.width), pageSpacing=\(pageSpacing)")
}
// 实例追踪
private static var instanceCounter = 0
let instanceId: Int
// MARK: - Initialization
override init(frame frameRect: NSRect) {
CAGridView.instanceCounter += 1
self.instanceId = CAGridView.instanceCounter
super.init(frame: frameRect)
setup()
}
required init?(coder: NSCoder) {
CAGridView.instanceCounter += 1
self.instanceId = CAGridView.instanceCounter
super.init(coder: coder)
setup()
}
deinit {
// print("💀 [CAGrid #\(instanceId)] deinit - instance being destroyed!")
displayLink?.invalidate()
removeScrollEventMonitor()
NotificationCenter.default.removeObserver(self)
}
func setup() {
wantsLayer = true
layerContentsRedrawPolicy = .onSetNeedsDisplay
// 创建容器层
containerLayer = CALayer()
containerLayer.frame = bounds
containerLayer.masksToBounds = false // 不裁剪,让滑动时内容可以超出边界
layer?.addSublayer(containerLayer)
// 页面容器层(用于整体偏移)
pageContainerLayer = CALayer()
pageContainerLayer.frame = bounds
containerLayer.addSublayer(pageContainerLayer)
// 禁用隐式动画
CATransaction.begin()
CATransaction.setDisableActions(true)
CATransaction.commit()
// 在初始化时就注册 launchpad 窗口通知(确保始终能接收)
NotificationCenter.default.addObserver(self, selector: #selector(launchpadWindowDidShow(_:)), name: .launchpadWindowShown, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(launchpadWindowDidHide(_:)), name: .launchpadWindowHidden, object: nil)
// 监听应用激活事件(作为备用方案)
NotificationCenter.default.addObserver(self, selector: #selector(appDidBecomeActive(_:)), name: NSApplication.didBecomeActiveNotification, object: nil)
// print("✅ [CAGrid #\(instanceId)] Core Animation grid initialized")
}
func makeFirstResponderIfAvailable() {
guard let win = window else { return }
if win.firstResponder == nil || win.firstResponder === self {
win.makeFirstResponder(self)
}
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
if let window = window {
window.acceptsMouseMovedEvents = true
setupDisplayLink()
// 始终安装滚轮事件监听器(更可靠)
setupScrollEventMonitor()
// 确保视图成为第一响应者
DispatchQueue.main.async { [weak self] in
self?.makeFirstResponderIfAvailable()
}
// print("✅ [CAGrid #\(instanceId)] View moved to window, scroll monitor installed")
// 监听窗口显示/隐藏事件
NotificationCenter.default.removeObserver(self, name: NSWindow.didBecomeKeyNotification, object: nil)
NotificationCenter.default.removeObserver(self, name: NSWindow.didBecomeMainNotification, object: nil)
NotificationCenter.default.removeObserver(self, name: NSWindow.didChangeOcclusionStateNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(windowDidActivate(_:)), name: NSWindow.didBecomeKeyNotification, object: window)
NotificationCenter.default.addObserver(self, selector: #selector(windowDidActivate(_:)), name: NSWindow.didBecomeMainNotification, object: window)
NotificationCenter.default.addObserver(self, selector: #selector(windowOcclusionChanged(_:)), name: NSWindow.didChangeOcclusionStateNotification, object: window)
// launchpad 窗口通知在 setup() 中注册,这里不需要重复注册
} else {
// 视图从窗口移除时清理窗口相关的事件监听器
// 注意:launchpad 窗口通知不在这里移除,因为它们在 setup() 中注册
removeScrollEventMonitor()
NotificationCenter.default.removeObserver(self, name: NSWindow.didBecomeKeyNotification, object: nil)
NotificationCenter.default.removeObserver(self, name: NSWindow.didBecomeMainNotification, object: nil)
NotificationCenter.default.removeObserver(self, name: NSWindow.didChangeOcclusionStateNotification, object: nil)
}
}
@objc func windowDidActivate(_ notification: Notification) {
// print("🪟 [CAGrid] Window activated, making first responder")
makeFirstResponderIfAvailable()
}
@objc func windowOcclusionChanged(_ notification: Notification) {
guard let window = window else { return }
if window.occlusionState.contains(.visible) {
// print("🪟 [CAGrid] Window became visible, making first responder")
makeFirstResponderIfAvailable()
}
}
@objc func launchpadWindowDidShow(_ notification: Notification) {
// 只有有窗口的实例才响应
guard let window = window else {
// print("⚠️ [CAGrid #\(instanceId)] Launchpad window shown - but no window, ignoring")
return
}
// print("🚀 [CAGrid #\(instanceId)] Launchpad window shown, hasMonitor=\(scrollEventMonitor != nil)")
// 立即安装滚轮事件监听器(如果没有)
if scrollEventMonitor == nil {
// print("🔄 [CAGrid #\(instanceId)] Reinstalling scroll monitor on window show")
setupScrollEventMonitor()
}
// 确保成为第一响应者
makeFirstResponderIfAvailable()
// 延迟再次确认(防止其他组件抢占)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
guard let self = self, let win = self.window else { return }
// print("🔄 [CAGrid #\(self.instanceId)] Delayed check, isFirstResponder=\(win.firstResponder === self), hasMonitor=\(self.scrollEventMonitor != nil)")
self.makeFirstResponderIfAvailable()
// 确保滚轮监听器存在
if self.scrollEventMonitor == nil {
self.setupScrollEventMonitor()
}
}
}
@objc func launchpadWindowDidHide(_ notification: Notification) {
// 只有有窗口的实例才响应
guard window != nil else {
// print("⚠️ [CAGrid #\(instanceId)] Window hidden - but no window, ignoring")
return
}
// print("🚀 [CAGrid #\(instanceId)] Window hidden, hasMonitor=\(scrollEventMonitor != nil)")
// 不再移除监听器 - 让它保持活跃,这样窗口重新显示时就能立即使用
// removeScrollEventMonitor()
wasWindowVisible = false
}
@objc func appDidBecomeActive(_ notification: Notification) {
// 应用激活时检查是否需要安装滚轮监听器
// print("🔔 [CAGrid #\(instanceId)] App became active notification received, window=\(window != nil), isVisible=\(window?.isVisible ?? false)")
guard let window = window else {
// print("🔔 [CAGrid #\(instanceId)] App became active - no window")
return
}
// 立即尝试重新安装滚轮监听器(不管窗口是否可见)
// 因为窗口可能正在动画中,isVisible 可能还是 false
// print("🔔 [CAGrid #\(instanceId)] Reinstalling scroll monitor immediately on app activate")
setupScrollEventMonitor()
makeFirstResponderIfAvailable()
// 延迟再次检查,确保滚轮监听器存在
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
guard let self = self, let win = self.window else { return }
// print("🔔 [CAGrid #\(self.instanceId)] Delayed check: isVisible=\(win.isVisible), scrollMonitor=\(self.scrollEventMonitor != nil)")
if self.scrollEventMonitor == nil {
// print("🔄 [CAGrid #\(self.instanceId)] App became active (delayed), reinstalling scroll monitor")
self.setupScrollEventMonitor()
}
self.makeFirstResponderIfAvailable()
}
}
func setupScrollEventMonitor() {
// 移除旧的监听器
removeScrollEventMonitor()
// 确保有窗口才设置监听器(可见性在事件处理时动态检查)
guard window != nil else {
// print("⚠️ [CAGrid #\(instanceId)] setupScrollEventMonitor: no window, skipping")
return
}
let myInstanceId = self.instanceId
scrollEventMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { [weak self] event in
guard let self = self else { return event }
guard self.isScrollEnabled else { return event }
guard let window = self.window else { return event }
// 只在窗口可见且是 key window 时处理
guard window.isVisible && window.isKeyWindow else { return event }
// 检查事件是否在视图范围内
let locationInWindow = event.locationInWindow
let locationInView = self.convert(locationInWindow, from: nil)
guard self.bounds.contains(locationInView) else { return event }
self.handleScrollWheel(with: event)
// 消费事件,不再传递,防止双重处理
return nil
}
// print("✅ [CAGrid #\(instanceId)] Scroll event monitor installed")
}
func removeScrollEventMonitor() {
if let monitor = scrollEventMonitor {
// print("🗑️ [CAGrid #\(instanceId)] Removing scroll event monitor")
NSEvent.removeMonitor(monitor)
scrollEventMonitor = nil
}
}
// MARK: - Display Link (120Hz)
func setupDisplayLink() {
displayLink?.invalidate()
guard let window = window else {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
self?.setupDisplayLink()
}
return
}
displayLink = window.displayLink(target: self, selector: #selector(displayLinkFired(_:)))
displayLink?.preferredFrameRateRange = CAFrameRateRange(minimum: 60, maximum: 120, preferred: 120)
displayLink?.add(to: .main, forMode: .common)
// print("✅ [CAGrid] DisplayLink configured for 120Hz")
}
@objc func displayLinkFired(_ link: CADisplayLink) {
// 只在动画时才更新
guard isScrollAnimating || isDraggingItem else {
// 空闲时重置帧计数
if frameCount > 0 {
frameCount = 0
lastFrameTime = 0
}
return
}
// 计算实时帧率(仅在动画时)
let now = CFAbsoluteTimeGetCurrent()
if lastFrameTime > 0 {
let delta = now - lastFrameTime
let instantFPS = 1.0 / delta
// 使用滑动窗口平均,减少数组操作
if frameTimes.count >= 30 {
frameTimes.removeFirst()
}
frameTimes.append(instantFPS)
currentFPS = frameTimes.reduce(0, +) / Double(frameTimes.count)
}
lastFrameTime = now
frameCount += 1
// 每 60 帧输出一次(约 0.5 秒)
if frameCount % 60 == 0 {
onFPSUpdate?(currentFPS)
// print("🎮 [CAGrid] Avg FPS: \(String(format: "%.1f", currentFPS))")
}
// 更新滚动动画
if isScrollAnimating {
updateScrollAnimation()
}
}
// MARK: - Scroll Animation
func updateScrollAnimation() {
if !animationsEnabled {
scrollOffset = targetScrollOffset
scrollVelocity = 0
isScrollAnimating = false
} else {
let diff = targetScrollOffset - scrollOffset
let snapThreshold: CGFloat = 0.5
if abs(diff) > snapThreshold {
// 非时间控制:指数收敛,距离越远移动越快
let t: CGFloat = 0.18
scrollOffset += diff * t
} else {
scrollOffset = targetScrollOffset
scrollVelocity = 0
isScrollAnimating = false
}
}
// 更新页面容器位置 - 使用最小开销的方式
CATransaction.begin()
CATransaction.setDisableActions(true)
CATransaction.setAnimationDuration(0)
pageContainerLayer.transform = CATransform3DMakeTranslation(scrollOffset, 0, 0)
CATransaction.commit()
}
func easeOutSoft(_ t: CGFloat) -> CGFloat {
// Damped spring: soft ease-out with a subtle bounce (about ~2% overshoot).
return springEaseOut(t, damping: 0.78, frequency: 1.4)
}
func springEaseOut(_ t: CGFloat, damping: CGFloat, frequency: CGFloat) -> CGFloat {
let clamped = max(0, min(1, t))
if clamped == 0 { return 0 }
if clamped == 1 { return 1 }
let dampingRatio = max(0, min(1, damping))
let omega = 2 * CGFloat.pi * max(0.1, frequency)
if dampingRatio >= 1 {
return 1 - exp(-omega * clamped)
}
let omegaD = omega * sqrt(1 - dampingRatio * dampingRatio)
let expTerm = exp(-dampingRatio * omega * clamped)
let cosTerm = cos(omegaD * clamped)
let sinTerm = sin(omegaD * clamped)
let coeff = dampingRatio / sqrt(1 - dampingRatio * dampingRatio)
return 1 - expTerm * (cosTerm + coeff * sinTerm)
}
func easeOutBack(_ t: CGFloat, overshoot: CGFloat) -> CGFloat {
let clamped = max(0, min(1, t))
let s = max(0, overshoot)
let t1 = clamped - 1
return 1 + (s + 1) * t1 * t1 * t1 + s * t1 * t1
}
func cubicBezier(_ x: CGFloat, c1: CGPoint, c2: CGPoint) -> CGFloat {
let clamped = max(0, min(1, x))
let t = solveBezierT(forX: clamped, c1x: c1.x, c2x: c2.x)
return cubicBezierValue(t, c1: c1.y, c2: c2.y)
}
func cubicBezierValue(_ t: CGFloat, c1: CGFloat, c2: CGFloat) -> CGFloat {
let oneMinusT = 1 - t
return 3 * oneMinusT * oneMinusT * t * c1
+ 3 * oneMinusT * t * t * c2
+ t * t * t
}
func cubicBezierDerivative(_ t: CGFloat, c1: CGFloat, c2: CGFloat) -> CGFloat {
let oneMinusT = 1 - t
return 3 * oneMinusT * oneMinusT * c1
+ 6 * oneMinusT * t * (c2 - c1)
+ 3 * t * t * (1 - c2)
}
func solveBezierT(forX x: CGFloat, c1x: CGFloat, c2x: CGFloat) -> CGFloat {
var t = x
for _ in 0..<5 {
let xAtT = cubicBezierValue(t, c1: c1x, c2: c2x)
let dx = xAtT - x
if abs(dx) < 1e-4 { return t }
let d = cubicBezierDerivative(t, c1: c1x, c2: c2x)
if abs(d) < 1e-5 { break }
t -= dx / d
if t < 0 || t > 1 { break }
}
var low: CGFloat = 0
var high: CGFloat = 1
for _ in 0..<8 {
let mid = (low + high) * 0.5
let xAtMid = cubicBezierValue(mid, c1: c1x, c2: c2x)
if xAtMid < x {
low = mid
} else {
high = mid
}
}
return (low + high) * 0.5
}
// Set initial page before items are set to ensure correct positioning
func setInitialPage(_ page: Int) {
currentPage = max(0, page)
}
func navigateToPage(_ page: Int, animated: Bool = true) {
let newPage = max(0, min(pageCount - 1, page))
let pageChanged = newPage != currentPage
currentPage = newPage
// 如果 bounds 还没准备好,只更新 currentPage,实际滚动交给 layout() 处理
guard bounds.width > 0 else {
if pageChanged {
onPageChanged?(currentPage)
}
return
}
let pageStride = bounds.width + pageSpacing
targetScrollOffset = -CGFloat(currentPage) * pageStride
// 检查是否需要动画(包括弹回原位的情况)
let needsAnimation = animated && abs(scrollOffset - targetScrollOffset) > 0.5
if needsAnimation && animationsEnabled {
isScrollAnimating = true
} else {
// 立即跳转
isScrollAnimating = false
scrollOffset = targetScrollOffset
CATransaction.begin()
CATransaction.setDisableActions(true)
pageContainerLayer.transform = CATransform3DMakeTranslation(scrollOffset, 0, 0)
CATransaction.commit()
}
if pageChanged {
onPageChanged?(currentPage)
}
logIfMismatch("navigateToPage")
}
// MARK: - Public Methods
var isBatchDragging: Bool { !batchDraggingAppPathsOrdered.isEmpty }
func enableBatchSelectionMode() {
guard allowsBatchSelectionMode else {
NSSound.beep()
return
}
isBatchSelectionMode = true
syncBatchSelectionWithItems()
refreshBatchSelectionUI()
}
func disableBatchSelectionMode() {
if isDraggingItem && isBatchDragging {
cancelDragging()
}
let hadState = isBatchSelectionMode ||
!batchSelectedAppPathsOrdered.isEmpty ||
!batchDraggingAppPathsOrdered.isEmpty
isBatchSelectionMode = false
batchSelectedAppPathsOrdered.removeAll()
batchSelectedAppPathSet.removeAll()
batchDraggingAppPathsOrdered.removeAll()
restoreBatchHiddenCompanionLayers()
if hadState {
refreshBatchSelectionUI()
}
}
func toggleBatchSelection(forAppPath path: String) {
guard isBatchSelectionMode else { return }
if batchSelectedAppPathSet.contains(path) {
batchSelectedAppPathSet.remove(path)
batchSelectedAppPathsOrdered.removeAll { $0 == path }
} else {
batchSelectedAppPathSet.insert(path)
batchSelectedAppPathsOrdered.append(path)
}
refreshBatchSelectionUI()
}
func orderedBatchDragPaths(leadingAppPath path: String) -> [String] {
guard batchSelectedAppPathSet.contains(path) else { return [] }
var ordered: [String] = [path]
ordered.append(contentsOf: batchSelectedAppPathsOrdered.filter { $0 != path })
return ordered
}
func appPath(at index: Int) -> String? {
guard items.indices.contains(index), case .app(let app) = items[index] else { return nil }
return app.url.path
}
func syncBatchSelectionWithItems() {
guard isBatchSelectionMode else { return }
let currentPaths = Set(items.compactMap { item -> String? in
guard case .app(let app) = item else { return nil }
return app.url.path
})
let oldCount = batchSelectedAppPathSet.count
batchSelectedAppPathSet = batchSelectedAppPathSet.intersection(currentPaths)
batchSelectedAppPathsOrdered = batchSelectedAppPathsOrdered.filter { batchSelectedAppPathSet.contains($0) }
if batchSelectedAppPathSet.count != oldCount {
refreshBatchSelectionUI()
}
}
func globalIndex(forAppPath path: String) -> Int? {
for (index, item) in items.enumerated() {
if case .app(let app) = item, app.url.path == path {
return index
}
}
return nil
}
func setOpacity(_ opacity: Float, forGlobalIndex index: Int) {
let pageIndex = index / itemsPerPage
let localIndex = index % itemsPerPage
guard pageIndex < iconLayers.count, localIndex < iconLayers[pageIndex].count else { return }
iconLayers[pageIndex][localIndex].opacity = opacity
}
func restoreBatchHiddenCompanionLayers() {
guard !batchHiddenCompanionIndices.isEmpty else { return }
for index in batchHiddenCompanionIndices {
setOpacity(1.0, forGlobalIndex: index)
}
batchHiddenCompanionIndices.removeAll()
batchDraggingAppPathsOrdered.removeAll()
}
func clearIconCache() {
iconCacheLock.lock()
iconCache.removeAll()
iconCacheLock.unlock()
}
func refreshLayout() {
rebuildLayers()
}
func snapToCurrentPageIfNeeded() {
// 如果用户正在拖拽或动画正在进行,不要强制 snap
guard !isDragging && !isScrollAnimating && !isPageDragging else { return }
guard bounds.width > 0 else { return }
let expectedOffset = -CGFloat(currentPage) * (bounds.width + pageSpacing)
let transformOffset = pageContainerLayer.transform.m41
let needsOffsetSync = abs(scrollOffset - expectedOffset) > 0.5
let needsTransformSync = abs(transformOffset - scrollOffset) > 0.5
guard needsOffsetSync || needsTransformSync else { return }
if needsOffsetSync {
scrollOffset = expectedOffset
targetScrollOffset = expectedOffset
}
CATransaction.begin()
CATransaction.setDisableActions(true)
CATransaction.setAnimationDuration(0)
pageContainerLayer.transform = CATransform3DMakeTranslation(scrollOffset, 0, 0)
CATransaction.commit()
}
func forceSyncPageTransformIfNeeded() {
guard bounds.width > 0 else { return }
let pageStride = bounds.width + pageSpacing
let expectedOffset = -CGFloat(currentPage) * pageStride
if abs(scrollOffset - expectedOffset) > 0.5 {
scrollOffset = expectedOffset
targetScrollOffset = expectedOffset
}
let transformOffset = pageContainerLayer.transform.m41
guard abs(transformOffset - scrollOffset) > 0.5 else { return }
CATransaction.begin()
CATransaction.setDisableActions(true)
CATransaction.setAnimationDuration(0)
pageContainerLayer.transform = CATransform3DMakeTranslation(scrollOffset, 0, 0)
CATransaction.commit()
}
/// 确保滚轮事件监听器已安装(供外部调用)
func ensureScrollMonitorInstalled() {
guard let window = window else {
// print("⚠️ [CAGrid #\(instanceId)] ensureScrollMonitorInstalled: no window")
return
}
// 只要有窗口且没有监听器就安装(可见性在事件处理时检查)
if scrollEventMonitor == nil {
// print("🔄 [CAGrid #\(instanceId)] ensureScrollMonitorInstalled: monitor missing, installing")
setupScrollEventMonitor()
makeFirstResponderIfAvailable()
}
}
/// 获取实例ID(用于调试)
var debugInstanceId: Int { instanceId }
}
```
## /LaunchNext/Extensions.swift
```swift path="/LaunchNext/Extensions.swift"
import SwiftUI
import AppKit
// MARK: - Color Extensions
extension Color {
static var launchpadBorder: Color {
Color(.systemBlue)
}
}
// MARK: - Font Extensions
extension Font {
static var `default`: Font {
.system(size: 11, weight: .medium)
}
}
// MARK: - View Extensions for Glass Effect
extension View {
@ViewBuilder
func liquidGlass<S: Shape>(in shape: S, isEnabled: Bool = true) -> some View {
if #available(macOS 26.0, iOS 18.0, *) {
self.glassEffect(.regular, in: shape)
} else {
self.background(.ultraThinMaterial, in: shape)
}
}
@ViewBuilder
func liquidGlass(isEnabled: Bool = true) -> some View {
if #available(macOS 26.0, iOS 18.0, *) {
self.glassEffect(.regular)
} else {
self.background(.ultraThinMaterial)
}
}
}
```
## /LaunchNext/Gesture/GestureConfiguration.swift
```swift path="/LaunchNext/Gesture/GestureConfiguration.swift"
import Foundation
struct GestureConfiguration: Equatable {
var isEnabled: Bool
var closeOnPinchOutEnabled: Bool = false
var tapEnabled: Bool = false
var tapTogglesWindow: Bool = false
var deviceSelectionMode: GestureDeviceSelectionMode = .automatic
var selectedDeviceIDs: [String] = []
var requiredFingerCount: Int = 4
var stableContactDuration: TimeInterval = 0.03
var openTriggerScaleRatio: Double = 0.84
var closeTriggerScaleRatio: Double = 1.06
var openPerFingerRadiusRatio: Double = 0.96
var closeLeadingFingerRadiusRatio: Double = 1.12
var minimumOpenParticipatingFingerCount: Int = 3
var minimumCloseLeadingGap: Double = 0.06
var maximumCloseSupportingSpread: Double = 0.22
var requiredConsecutiveMatches: Int = 2
var cooldownDuration: TimeInterval = 0.85
var maximumCentroidDriftRatio: Double = 0.55
var minimumBaselineScale: Double = 0.10
var tapMaxDuration: TimeInterval = 0.20
var tapMaxFingerMovement: Double = 0.045
var tapMaxScaleDeviation: Double = 0.10
}
```
## /LaunchNext/Gesture/GestureInputDevice.swift
```swift path="/LaunchNext/Gesture/GestureInputDevice.swift"
import Foundation
enum GestureDeviceSelectionMode: String, CaseIterable, Codable, Identifiable {
case automatic
case selected
var id: String { rawValue }
}
struct GestureInputDevice: Identifiable, Hashable {
let id: String
let name: String
let isBuiltIn: Bool
let kind: GestureInputDeviceKind
let familyID: Int
let sensorSurfaceWidth: Int
let sensorSurfaceHeight: Int
let isRecommended: Bool
}
enum GestureInputDeviceKind: String, Hashable {
case trackpad
case mouse
case touchBar
case unknown
}
extension OMSDeviceInfo {
var isGestureTrackpadCandidate: Bool {
isTrackpad || deviceName.localizedCaseInsensitiveContains("Trackpad")
}
var isRecommendedGestureDevice: Bool {
isGestureTrackpadCandidate && gestureInputDeviceKind == .trackpad
}
var gestureInputDeviceKind: GestureInputDeviceKind {
if deviceName.localizedCaseInsensitiveContains("Touch Bar") {
return .touchBar
}
if deviceName.localizedCaseInsensitiveContains("Mouse") {
return .mouse
}
if isGestureTrackpadCandidate {
return .trackpad
}
return .unknown
}
}
```
## /LaunchNext/ThirdParty/OpenMultitouchSupport/Framework/OpenMultitouchSupportXCF/OpenMTListener.h
```h path="/LaunchNext/ThirdParty/OpenMultitouchSupport/Framework/OpenMultitouchSupportXCF/OpenMTListener.h"
//
// OpenMTListener.h
// OpenMultitouchSupport
//
// Created by Takuto Nakamura on 2019/07/11.
// Copyright © 2019 Takuto Nakamura. All rights reserved.
//
#ifndef OpenMTListener_h
#define OpenMTListener_h
#import <Foundation/Foundation.h>
@interface OpenMTListener: NSObject
@property (assign, readwrite) BOOL listening;
@end
#endif /* OpenMTListener_h */
```
The content has been capped at 50000 tokens. The user could consider applying other filters to refine the result. The better and more specific the context, the better the LLM can follow instructions. If the context seems verbose, the user can refine the filter using uithub. Thank you for using https://uithub.com - Perfect LLM context for any GitHub repo.