``` ├── .github/ ├── workflows/ ├── release.yml (1200 tokens) ├── .gitignore ├── CHANGELOG.md (3.4k tokens) ├── Completions/ ├── _infat (600 tokens) ├── infat (500 tokens) ├── infat.fish (600 tokens) ├── LICENSE (omitted) ├── Package.swift (200 tokens) ├── README.md (1000 tokens) ├── Sources/ ├── AssociationManager.swift (1000 tokens) ├── Commands/ ├── Info.swift (1000 tokens) ├── Set.swift (600 tokens) ├── ConfigManager.swift (900 tokens) ├── ConformanceTypes.swift (500 tokens) ├── Error.swift (600 tokens) ├── FileSystemUtilities.swift (400 tokens) ├── FileUTIInfo.swift (100 tokens) ├── infat.swift (500 tokens) ├── dist/ ├── infat-arm64-apple-macos ├── justfile (1000 tokens) ``` ## /.github/workflows/release.yml ```yml path="/.github/workflows/release.yml" # .github/workflows/release.yml name: Release on: push: tags: - '*' # Trigger on any tag push defaults: run: shell: bash env: # Project name used in the justfile for artifact naming BINARY_NAME: infat jobs: prerelease: # This job determines if the tag is a pre-release based on its format. # It remains unchanged as it controls the GitHub Release 'prerelease' flag. runs-on: macos-latest outputs: value: ${{ steps.prerelease.outputs.value }} steps: - name: Prerelease Check id: prerelease run: | # extract just the tag name (e.g. v1.2.3-alpha) tag=${GITHUB_REF##*/} # if it ends in -alpha or -beta → prerelease if [[ "$tag" =~ -(alpha|beta)$ ]]; then echo "value=true" >> $GITHUB_OUTPUT else echo "value=false" >> $GITHUB_OUTPUT fi package: # This job builds and packages the project for various targets using the justfile. strategy: fail-fast: false # Don't cancel other jobs if one fails matrix: target: - arm64-apple-macos - x86_64-apple-macos include: # Define OS and specific flags for cross-compilation targets - target: arm64-apple-macos os: macos-latest target_flags: '' - target: x86_64-apple-macos os: macos-latest target_flags: '' runs-on: ${{ matrix.os }} needs: - prerelease # Wait for prerelease check environment: name: main steps: - name: Checkout code uses: actions/checkout@v4 # Cache the Swift Package Manager cache - name: Cache Swift Package Manager uses: actions/cache@v3 with: path: | ~/.swiftpm key: ${{ runner.os }}-swiftpm-${{ hashFiles('**/Package.swift') }} restore-keys: | ${{ runner.os }}-swiftpm- - name: Set up Swift toolchain uses: swift-actions/setup-swift@v2 with: swift-version: "6.1.0" - name: Install Just (Command Runner) run: | set -euxo pipefail # Using || true to prevent failure if package isn't found to catch on fallback # Try installing via native package manager first if [[ "$RUNNER_OS" == "Linux" ]]; then echo "Attempting to install just via apt..." sudo apt-get update -y sudo apt-get install -y just || echo "apt install failed or package not found." elif [[ "$RUNNER_OS" == "macOS" ]]; then echo "Attempting to install just via Homebrew..." brew install just || echo "brew install failed." elif [[ "$RUNNER_OS" == "Windows" ]]; then echo "Attempting to install just via Chocolatey..." choco install just --yes || echo "choco install failed." else echo "Unsupported OS for package manager installation: $RUNNER_OS." fi # Fallback to cargo install if 'just' command is not found after package manager attempt if ! command -v just &>/dev/null; then echo "Just not found after package manager attempt. Installing via cargo install..." cargo install just else echo "Just installed successfully via package manager or was already present." fi # --- Build using Just --- - name: Build the release version # Set flags combining global and target-specific flags for swift build inside just env: SWIFT_FLAGS: ${{ matrix.target_flags }} # Run the just recipe, passing the target from the matrix, outputs to "dist/" run: just package ${{ matrix.target }} - name: Compress the binaries run: just compress-binaries "dist/" # --- Publish Artifact --- - name: Determine Artifact Name id: artifact_name run: | ARTIFACT_PATH="dist/${{ env.BINARY_NAME }}-${{ matrix.target }}" # Get the archived version echo "path=${ARTIFACT_PATH}.tar.gz" >> $GITHUB_OUTPUT - name: Extract changelog for the tag id: extract_changelog run: just create-notes ${{ github.ref_name }} release_notes.md CHANGELOG.md - name: Publish Release uses: softprops/action-gh-release@v2 if: startsWith(github.ref, 'refs/tags/') with: files: ${{ steps.artifact_name.outputs.path }} body_path: release_notes.md draft: false overwrite: true prerelease: ${{ needs.prerelease.outputs.value }} make_latest: true token: ${{ secrets.PAT }} checksum: # This job downloads all published artifacts and creates a checksum file. runs-on: ubuntu-latest needs: - package # Wait for all package jobs to potentially complete - prerelease # Only run for tag pushes if: startsWith(github.ref, 'refs/tags/') environment: name: main steps: - name: Install GitHub CLI run: sudo apt-get update && sudo apt-get install -y gh - name: Download Release Archives env: # Use PAT for gh CLI authentication GH_TOKEN: ${{ secrets.PAT }} # Get the tag name from the ref TAG_NAME: ${{ github.ref_name }} run: | gh release download "$TAG_NAME" \ --repo "$GITHUB_REPOSITORY" \ --pattern '*' \ --dir release - name: Create Checksums run: | find release/ -type f \ ! -name "checksums.sha256" \ ! -name "README*" \ ! -name "*.sha256" \ -print0 \ | while IFS= read -r -d '' file; do sha256sum "$file" > "$file.sha256" done - name: Publish Individual Checksums uses: softprops/action-gh-release@v2 with: # Use a wildcard to upload all generated .sha256 files from the release dir files: release/*.sha256 draft: false prerelease: ${{ needs.prerelease.outputs.value }} token: ${{ secrets.PAT }} ``` ## /.gitignore ```gitignore path="/.gitignore" .DS_Store /.build /Packages xcuserdata/ DerivedData/ .swiftpm/configuration/registries.json .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc Package.resolved ``` ## /CHANGELOG.md # Changelog All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ## [2.4.0] – 2025-05-22 ### Added - Introduce a new `--robust` flag to the `infat set` command and configuration loader. When enabled, missing applications are no longer treated as errors; instead you’ll see a clear warning: ``` Application '<name>' not found but ignoring due to passed options ``` - Add a `format` target to the Justfile (`just format`), which runs `swift-format` on all `.swift` source files for consistent code styling. ## [2.3.4] – 2025-04-29 ### Changed - Loading order. Now goes Types -> Extensions -> Schemes. ## [2.3.3] – 2025-04-29 ### Added - Support XDG Base Directory spec for configuration file search: respect `XDG_CONFIG_HOME` (default `~/.config/infat/config.toml`) and `XDG_CONFIG_DIRS` (default `/etc/xdg/infat/config.toml`). - Add a GitHub Actions **homebrew** job to automatically bump the Homebrew formula on tagged releases. ### Changed - Refactor Zsh, Bash and Fish completion scripts to use the official file-type list and improve argument parsing. - Update README: - Change Homebrew installation to `brew install infat`. - Add instructions for manual generation of shell completions until the formula supports them. - Update `.github/workflows/release.yml` to integrate the Homebrew bump step. ### Fixed - Correct README misdocumentation by updating the list of supported file supertypes. ## [2.3.2] – 2025-04-27 ### Fixed - Set `overwrite: true` in the GitHub Actions release workflow to ensure existing releases can be replaced. - Refine the `just check` recipe to ignore `CHANGELOG*`, `README*`, `Package*` files and the `.build` directory when scanning for version patterns. - Update the `compress-binaries` recipe in Justfile so that archives - strip version suffixes from file names - use only the base filename when creating the `.tar.gz` ## [2.3.1] – 2025-04-27 ### Changed - Print success messages in italic formatting for `infat set` commands (file, scheme, and supertype bindings). - Clarify README instructions: allow user-relative paths via `~` and note that shell expansions are not supported. ### Fixed - Remove duplicate `run` step in the GitHub Actions `release.yml` workflow. ## [2.3.0] – 2025-04-27 ### Added - compress-binaries: use the project’s `infat` name to create `.tar.gz` archives with un-versioned internal filenames - AssociationManager: support relative paths (tilde expansion) and file:// URLs in `findApplication(named:)` ### Changed - restyled `justfile` with decorative section markers and switched to `/`-based path concatenation for clarity ### Fixed - release workflow: replaced `overwrite: true` with `make_latest: true` to correctly mark the latest GitHub release - prerelease check in Actions: now properly detects `-alpha` and `-beta` tags ## [2.2.0] – 2025-04-26 ### Added - Introduce ColorizeSwift (v1.5.0) as a new dependency for rich terminal styling - Reroute `.html` file‐extension and `https` URL‐scheme inputs to the HTTP handler - Support colorized output styling via `.bold()` and `.underline()` in `ConfigManager` ### Changed - Replace custom ANSI escape‐sequence constants with ColorizeSwift’s `.bold()` and `.underline()` methods - Docs updates: - Clarify application name casing and optional `.app` suffix in README - Expand configuration section to cover three TOML tables and XDG_CONFIG_HOME usage - Correct CLI usage examples, header numbering, typos, and outdated information ## [2.1.0] – 2025-04-26 ### Added - Justfile Introduce a `check` task that prompts you to confirm version bumps in the README, Swift bundle and CHANGELOG. - Commands Print a success message when an application is bound to a file extension or URL scheme. - FileSystemUtilities Include `/System/Library/CoreServices/Applications/` in the list of search paths for installed apps. - AssociationManager Add a fallback for `setDefaultApplication` failures: if `NSWorkspace.setDefaultApplication` is restricted, catch the error and invoke `LSSetDefaultRoleHandlerForContentType` directly. ### Changed - Package.swift Pin all external Swift package dependencies to exact versions (ArgumentParser 1.2.0, Swift-Log 1.5.3, PListKit 2.0.3, swift-toml 1.0.0). - AssociationManager Refactor application lookup into a throwing `findApplication(named:)`, supporting both file paths (or file:// URLs) and plain `.app` names (case-insensitive). - FileSystemUtilities Downgrade log level for unreadable paths from **warning** to **debug** to reduce noise. ## [2.0.1] – 2025-04-25 ### Added - Support for cascading “blanket” types: use `infat set <App> --type <type>` to assign openers for base types (e.g. `plain-text`); introduced a new `[types]` table in the TOML schema. - Explicit handling when no config is provided or found: Infat now prints an informative prompt and throws `InfatError.missingOption` if neither `--config` nor `$XDG_CONFIG_HOME/infat/config.toml` exist. ### Changed - Bumped CLI version to **2.0.1** and updated the abstract to “Declaritively set associations for URLs and files.” - Revised README examples and docs: - Renamed `infat list` → `infat info` - Changed flag `--file-type` → `--ext` - Renumbered tutorial steps and cleaned up formatting - Updated TOML example: `[files]` → `[extensions]` ### Fixed - Quiet mode now logs at `warning` (was `error`), preventing silent failures. ## [2.0.0] – 2025-04-25 ### Added - Enforce that exactly one of `--scheme`, `--type`, or `--ext` is provided in both the Info and Set commands; throw clear errors when options are missing or conflicting. - Introduce a new `--type` option to the Info command, allowing users to list both the default and all registered applications for a given supertype (e.g. `text`). - Add the `plain-text` supertype (mapped to `UTType.plainText`) to the set of supported conformances. - Render configuration section headings (`[extensions]`, `[types]`, `[schemes]`) in bold & underlined text when processing TOML files. ### Changed - Require at least one of the `[extensions]`, `[types]`, or `[schemes]` tables in the TOML configuration (instead of mandating all); process each table if present, emit a debug-level log when a table is missing, and standardize table naming. - Change the default logging level for verbose mode from `debug` to `trace`. - Strengthen error handling in `_setDefaultApplication`: after attempting to set a default opener, verify success and log an info on success or a warning on failure. ### Deprecated - Rename the `List` command to `Info`. - Renamed files table to extensions to match with cli options ## [1.3.0] – 2025-04-25 ### Added - `--app` option to `infat list` for listing document types handled by a given application. - New `InfatError.conflictingOptions` to enforce that exactly one of `--app` or `--ext` is provided. - Enhanced UTI-derivation errors via `InfatError.couldNotDeriveUTI`, including the offending extension in the message. ### Changed - Refactored the `list` command to use two exclusive `@Option` parameters (`--app`, `--ext`) with XOR validation. - Switched PList parsing to `DictionaryPList(url:)` and UTI lookup to `UTType(filenameExtension:)`. - Replaced ad-hoc `print` calls with `logger.info` for consistent, leveled logging. - Renamed `deriveUTIFromExtension(extention:)` to `deriveUTIFromExtension(ext:)` for clarity and consistency. ### Fixed - Corrected typos in `FileSystemUtilities.deriveUTIFromExtension` signature and related debug messages. - Fixed `FileManager` existence checks for `Info.plist` by using the correct `path` property. - Resolved parsing discrepancies in `listTypesForApp` to ensure accurate reading of `CFBundleDocumentTypes`. ## [1.2.0] – 2025-04-25 ### Fixed - Swift badge reflects true version ### Changed - Using function overloading to set default application based on Uttype or extension ### Deprecated - Removed --assocations option in list into the basic list command - Filetype option, now ext. ### Added - A supertype conformance enum - Class option in CLI and Config ## [1.1.0] – 2025-04-24 ### Added - Add MIT License (`LICENSE`) under the MIT terms. - Justfile enhancements: - Require `just` (command-runner) in documentation. - Introduce a `package` recipe (`just package`) to build and bundle release binaries. - Detect `current_platform` dynamically via `uname -m`. - GitHub Actions release workflow: enable `overwrite: true` in `release.yml`. ### Changed - Migrate `setDefaultApplication` and `ConfigManager.loadConfig` to async/await; remove semaphore-based callbacks. - Simplify UTI resolution by passing `typeIdentifier` directly. - Documentation updates: - Clarify README summary and usage examples for file-type and URL-scheme associations. - Revamp badges and stylistic copy (“ultra-powerful” intro, more user-friendly tone). - Streamline source installation instructions (use `just package` and wildcard install). ### Fixed - Remove redundant separators in README. ## [1.0.0] - 2025-04-24 ### Added - Support for URL‐scheme associations in the `set` command via a new `--scheme` option - `InfatError.conflictingOptions` error case to enforce mutual exclusion of `--file-type` and `--scheme` - Unified binding functionality—`set` now handles both file‐type and URL‐scheme associations, replacing the standalone `bind` command ### Changed - Merged the former `bind` subcommand into `set` and switched its parameters from positional arguments to named options - Updated the `justfile` changelog target to use a top‐level `# What's new` header instead of `## Changes` ### Removed - Removed the standalone `Bind` subcommand and its `Bind.swift` implementation - Removed the `Info` subcommand (and `Info.swift`), which previously displayed system information ## [0.6.0] - 2025-04-24 ### Added * Homebrew support ## [0.5.3] - 2025-04-24 ### Fixed * Typos in CI ## [0.5.2] - 2025-04-24 ### Fixed * Justfile platform targeting for the CI ## [0.5.1] - 2025-04-24 ### Fixed * Fixed logging to print diff in List command. * Fixed Swift version in release workflow to a specific version instead of 'latest'. ## [0.5.0] – 2025-04-24 ### Fixed * Wrong swift toolchain action ## [0.4.0] – 2025-04-24 ### Added * Config support for schemes * Bind subcommand to set URL scheme associations * GitHub workflow for automated releases * `create-notes` just recipe to extract changelog entries for release notes ### Changed * Moved app name resolution logic to a function for better reusability * Changed argument order in `setURLHandler` function * Optimized Swift release flags for better performance * Updated changelog to reflect the current state of the project ### Deprecated * Associations table in config; it has been replaced by separate tables for files and schemes ### Fixed * Logic in the Bind command to correctly handle application URL resolution and error handling ## [0.3.0] – 2025-04-23 ### Added - Support loading configuration from the XDG config directory (`$XDG_CONFIG_HOME/infat/config.toml`) when no `--config` flag is supplied. - Add a `Justfile` with curated recipes for: - building (debug / release) - running (debug / release) - packaging and compressing binaries - generating checksums - installing (and force-installing) - cleaning and updating dependencies ## [0.2.0] - 2025-04-22 ### Added - Initial project setup with basic command structure (`list`, `set`, `info`) using `swift-argument-parser`. - Structured logging using `swift-log`. - Custom error handling via the `InfatError` enum for specific error conditions. - Defined `FileUTIInfo` struct for holding Uniform Type Identifier data. - Added utility `findApplications` to locate application bundles in standard macOS directories. - Added utility `deriveUTIFromExtension` to get UTI info from file extensions, requiring macOS 11.0+. - Added utility `getBundleName` to extract bundle identifiers from application `Info.plist`. - Implemented `list` subcommand to show default or all registered applications for a file type, using the logger for output. - Implemented `set` subcommand to associate a file type with a specified application. - Implemented `info` subcommand to display details about the current frontmost application. - Added support for loading file associations from a TOML configuration file (`--config`), including specific error handling for TOML format issues and correcting an initial table name typo ("associations"). - Added dependencies: `PListKit` (for `Info.plist` parsing) and `swift-toml` (for configuration file parsing). - Added shell completion scripts for Zsh, Bash, and Fish. - Added comprehensive `README.md` documentation detailing features, usage, installation, and dependencies, with corrected links. ### Changed - Renamed project, executable target, and main command struct from "bart"/"WorkspaceTool" to "infat". - Refactored codebase from a single `main.swift` into multiple files (`Commands`, `Utilities`, `Managers`, `Error`, etc.) for better organization and readability. - Updated the tool's abstract description for better clarity. - Improved the output formatting of the `list` command for enhanced readability. - Refactored `list` command options (using `--assigned` flag instead of `--all`, requiring identifier argument) and improved help descriptions. - Consolidated logging flags: replaced previous `--debug` and `--verbose` flags with a single `--verbose` flag (which includes debug level) and a `--quiet` flag for minimal output. - Made the global logger instance mutable (`var`) to allow runtime log level configuration based on flags. - Created a reusable `setDefaultApplication` function to avoid code duplication between the `set` command and configuration loading logic. - Significantly enhanced error handling with more specific `InfatError` cases (e.g., plist reading, timeout, configuration errors) and improved logging messages throughout the application. - Implemented a 10-second timeout for the asynchronous `setDefaultApplication` operation using `DispatchSemaphore`. - Updated `findApplications` utility to search `/System/Applications` in addition to other standard paths and use modern Swift API for home directory path resolution. - Switched from using UTI strings to `UTType` objects within `FileUTIInfo` and related functions for better type safety and access to UTI properties. - Updated `README.md` content, added TOML configuration documentation, and noted `set` command status (reflecting commit `1ec6358`). - Set the minimum required macOS deployment target to 13.0 in `Package.swift`. - Renamed the `set` command argument from `mimeType` to `fileType` for clarity. - Updated the main command struct (`Infat`) and removed the redundant explicit `--version` flag (ArgumentParser provides this by default). - Added `Package.resolved` to `.gitignore`. ### Fixed - Corrected the bundle ID used internally and for logging from `com.example.burt` to `com.philocalyst.infat`. - Addressed minor code formatting inconsistencies across several files. --- [Unreleased]: https://github.com/philocalyst/infat/compare/v2.4.0...HEAD [2.4.0]: https://github.com/philocalyst/infat/compare/v2.3.4...v2.4.0 [2.3.3]: https://github.com/philocalyst/infat/compare/v2.3.2...v2.3.3 [2.3.2]: https://github.com/your-org/your-repo/compare/v2.3.1...v2.3.2 [2.3.1]: https://github.com/your-org/your-repo/compare/v2.3.0...v2.3.1 [2.3.0]: https://github.com/your-org/your-repo/compare/v2.2.0...v2.3.0 [2.2.0]: https://github.com/philocalyst/infat/compare/v2.1.0...v2.2.0 [2.1.0]: https://github.com/your-org/your-repo/compare/v2.0.1...v2.1.0 [2.0.1]: https://github.com/philocalyst/infat/compare/v2.0.0...v2.0.1 [2.0.0]: https://github.com/yourorg/yourrepo/compare/v1.3.0...v2.0.0 [1.3.0]: https://github.com/your-org/infat/compare/v1.2.0...v1.3.0 [1.2.0]: https://github.com/philocalyst/infat/compare/v1.1.0...v1.2.0 [1.1.0]: https://github.com/philocalyst/infat/compare/v1.0.0...v1.1.0 [1.0.0]: https://github.com/philocalyst/infat/compare/v0.6.0...v1.0.0 [0.6.0]: https://github.com/philocalyst/infat/compare/v0.5.3...v0.6.0 [0.5.3]: https://github.com/philocalyst/infat/compare/v0.5.2...v0.5.3 [0.5.2]: https://github.com/philocalyst/infat/compare/v0.5.1...v0.5.2 [0.5.1]: https://github.com/philocalyst/infat/compare/v0.5.0...v0.5.1 [0.5.0]: https://github.com/philocalyst/infat/compare/v0.4.0...v0.5.0 [0.4.0]: https://github.com/philocalyst/infat/compare/v0.3.0...v0.4.0 [0.3.0]: https://github.com/philocalyst/infat/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/philocalyst/infat/compare/63822faf94def58bf347f8be4983e62da90383bb...d32aec000bf040c48887f104decf4a9736aea78b (Comparing agaisnt the start of the project) ## /Completions/_infat ``` path="/Completions/_infat" #compdef infat local context state state_descr line _infat_commandname=$words[1] typeset -A opt_args _infat() { integer ret=1 local -a args args+=( '(-c --config)'{-c,--config}'[Path to the configuration file.]:config:' '(-v --verbose)'{-v,--verbose}'[Enable verbose logging.]' '(-q --quiet)'{-q,--quiet}'[Quiet output.]' '--version[Show the version.]' '(-h --help)'{-h,--help}'[Show help information.]' '(-): :->command' '(-)*:: :->arg' ) _arguments -w -s -S $args[@] && ret=0 case $state in (command) local subcommands subcommands=( 'info:Lists file association information.' 'set:Sets an application association.' 'help:Show subcommand help information.' ) _describe "subcommand" subcommands ;; (arg) case ${words[1]} in (info) _infat_info ;; (set) _infat_set ;; (help) _infat_help ;; esac ;; esac return ret } _infat_info() { integer ret=1 local -a args args+=( '(-a --app)'{-a,--app}'[Application name (e.g., '"'"'Google Chrome'"'"').]:app:' '(-e --ext)'{-e,--ext}'[File extension (without the dot, e.g., '"'"'html'"'"').]:ext:' '(-t --type)'{-t,--type}'[File type (e.g., text).]:type:(plain-text text csv image raw-image audio video movie mp4-audio quicktime mp4-movie archive sourcecode c-source cpp-source objc-source shell makefile data directory folder symlink executable unix-executable app-bundle)' '--version[Show the version.]' '(-h --help)'{-h,--help}'[Show help information.]' ) _arguments -w -s -S $args[@] && ret=0 return ret } _infat_set() { integer ret=1 local -a args args+=( ':app-name:' '--ext[A file extension without leading dot.]:ext:' '--scheme[A URL scheme. ex: mailto.]:scheme:' '--type[A file class. ex: image]:type:(plain-text text csv image raw-image audio video movie mp4-audio quicktime mp4-movie archive sourcecode c-source cpp-source objc-source shell makefile data directory folder symlink executable unix-executable app-bundle)' '--version[Show the version.]' '(-h --help)'{-h,--help}'[Show help information.]' ) _arguments -w -s -S $args[@] && ret=0 return ret } _infat_help() { integer ret=1 local -a args args+=( ':subcommands:' '--version[Show the version.]' ) _arguments -w -s -S $args[@] && ret=0 return ret } _custom_completion() { local completions=("${(@f)$($*)}") _describe '' completions } _infat ``` ## /Completions/infat ``` path="/Completions/infat" #!/bin/bash _infat() { cur="${COMP_WORDS[COMP_CWORD]}" prev="${COMP_WORDS[COMP_CWORD-1]}" COMPREPLY=() opts="-c --config -v --verbose -q --quiet --version -h --help info set help" if [[ $COMP_CWORD == "1" ]]; then COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) return fi case $prev in -c|--config) return ;; esac case ${COMP_WORDS[1]} in (info) _infat_info 2 return ;; (set) _infat_set 2 return ;; (help) _infat_help 2 return ;; esac COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) } _infat_info() { opts="-a --app -e --ext -t --type --version -h --help" if [[ $COMP_CWORD == "$1" ]]; then COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) return fi case $prev in -a|--app) return ;; -e|--ext) return ;; -t|--type) COMPREPLY=( $(compgen -W "plain-text text csv image raw-image audio video movie mp4-audio quicktime mp4-movie archive sourcecode c-source cpp-source objc-source shell makefile data directory folder symlink executable unix-executable app-bundle" -- "$cur") ) return ;; esac COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) } _infat_set() { opts="--ext --scheme --type --version -h --help" if [[ $COMP_CWORD == "$1" ]]; then COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) return fi case $prev in --ext) return ;; --scheme) return ;; --type) COMPREPLY=( $(compgen -W "plain-text text csv image raw-image audio video movie mp4-audio quicktime mp4-movie archive sourcecode c-source cpp-source objc-source shell makefile data directory folder symlink executable unix-executable app-bundle" -- "$cur") ) return ;; esac COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) } _infat_help() { opts="--version" if [[ $COMP_CWORD == "$1" ]]; then COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) return fi COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) } complete -F _infat infat ``` ## /Completions/infat.fish ```fish path="/Completions/infat.fish" function _swift_infat_using_command set -l cmd (commandline -opc) if [ (count $cmd) -eq (count $argv) ] for i in (seq (count $argv)) if [ $cmd[$i] != $argv[$i] ] return 1 end end return 0 end return 1 end complete -c infat -n '_swift_infat_using_command infat' -f -r -s c -l config -d 'Path to the configuration file.' complete -c infat -n '_swift_infat_using_command infat' -f -s v -l verbose -d 'Enable verbose logging.' complete -c infat -n '_swift_infat_using_command infat' -f -s q -l quiet -d 'Quiet output.' complete -c infat -n '_swift_infat_using_command infat' -f -l version -d 'Show the version.' complete -c infat -n '_swift_infat_using_command infat' -f -s h -l help -d 'Show help information.' complete -c infat -n '_swift_infat_using_command infat' -f -a info -d 'Lists file association information.' complete -c infat -n '_swift_infat_using_command infat' -f -a set -d 'Sets an application association.' complete -c infat -n '_swift_infat_using_command infat' -f -a help -d 'Show subcommand help information.' complete -c infat -n '_swift_infat_using_command infat info' -f -r -s a -l app -d 'Application name (e.g., \'Google Chrome\').' complete -c infat -n '_swift_infat_using_command infat info' -f -r -s e -l ext -d 'File extension (without the dot, e.g., \'html\').' complete -c infat -n '_swift_infat_using_command infat info' -f -r -s t -l type -d 'File type (e.g., text).' complete -c infat -n '_swift_infat_using_command infat info -t' -f -k -a 'plain-text text csv image raw-image audio video movie mp4-audio quicktime mp4-movie archive sourcecode c-source cpp-source objc-source shell makefile data directory folder symlink executable unix-executable app-bundle' complete -c infat -n '_swift_infat_using_command infat info --type' -f -k -a 'plain-text text csv image raw-image audio video movie mp4-audio quicktime mp4-movie archive sourcecode c-source cpp-source objc-source shell makefile data directory folder symlink executable unix-executable app-bundle' complete -c infat -n '_swift_infat_using_command infat info' -f -s h -l help -d 'Show help information.' complete -c infat -n '_swift_infat_using_command infat set' -f -r -l ext -d 'A file extension without leading dot.' complete -c infat -n '_swift_infat_using_command infat set' -f -r -l scheme -d 'A URL scheme. ex: mailto.' complete -c infat -n '_swift_infat_using_command infat set' -f -r -l type -d 'A file class. ex: image' complete -c infat -n '_swift_infat_using_command infat set --type' -f -k -a 'plain-text text csv image raw-image audio video movie mp4-audio quicktime mp4-movie archive sourcecode c-source cpp-source objc-source shell makefile data directory folder symlink executable unix-executable app-bundle' complete -c infat -n '_swift_infat_using_command infat set' -f -s h -l help -d 'Show help information.' ``` ## /Package.swift ```swift path="/Package.swift" // swift-tools-version: 5.9 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "infat", platforms: [ .macOS(.v13) ], dependencies: [ .package(url: "https://github.com/apple/swift-argument-parser.git", exact: "1.2.0"), .package(url: "https://github.com/apple/swift-log.git", exact: "1.5.3"), .package(url: "https://github.com/orchetect/PListKit", exact: "2.0.3"), .package(url: "https://github.com/jdfergason/swift-toml", exact: "1.0.0"), .package(url: "https://github.com/mtynior/ColorizeSwift.git", exact: "1.5.0"), ], targets: [ .executableTarget( name: "infat", dependencies: [ .product(name: "PListKit", package: "PListKit"), .product(name: "Toml", package: "swift-toml"), "ColorizeSwift", .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "Logging", package: "swift-log"), ] ) ] ) ``` ## /README.md # Welcome to Infat [](https://swift.org) [](https://developer.apple.com/macOS) Infat is an ultra-powerful, macOS-native CLI tool for declaritively managing both file-type and URL-scheme associations. Avoid the hassle of navigating sub-menus to setup your default browser or image viewer, and the pain of doing that *every time* you get a new machine. Setup the rules once, and bask in your own ingenuity forevermore. Take back control, and bind your openers to whatever. You. Want. Override everything! Who's going to stop you? --- ## Summary - List which apps open for a given file extension or URL scheme (Like when you double click a file in Finder) - Set a default application for a file extension or URL scheme - Load associations from a TOML config (`[extensions]` `[types]` and `[schemes]` tables) - Verbose, scriptable, and ideal for power users and admins ## Get Started Get started by installing Infat — jump to the [Install](#install) section below. ## Tutorial ### 1. Getting assocation information ```shell # Show the default app for .txt files and all registered apps infat info --ext txt ``` ### 2. Setting a Default Application > [!TIP] > These aren't strict extensions, for example, yml and yaml extensions share a common resolver. ```shell # Use TextEdit for .md files infat set TextEdit --ext md # Use VSCode for .json files infat set VSCode --ext json ``` ### 3. Binding a URL Scheme ```shell # Use Mail.app for mailto: links infat set Mail --scheme mailto ``` ### 4. Fallback types > [!TIP] > Openers are cascading in macOS. Most common file formats will have their own identifier, > Which will be read from before the plain-text type it inherits from > Try setting from extension if you face unexpected issues ```shell # Set VSCode as the opener for files containing text infat set VSCode --type plain-text ``` Infat currently supports these supertypes: - plain-text - text - csv - image - raw-image - audio - video - movie - mp4-audio - quicktime - mp4-movie - archive - sourcecode - c-source - cpp-source - objc-source - shell - makefile - data - directory - folder - symlink - executable - unix-executable - app-bundle ### 5. Configuration Place a TOML file at `$XDG_CONFIG_HOME/infat/config.toml` (or pass `--config path/to/config.toml`). > [!NOTE] > `$XDG_CONFIG_HOME` is not set by default, you need to set in your shell config ex: `.zshenv`. On the right is the app you want to bind. You can pass: 1. The name (As seen when you hover on the icon) **IF** It's in a default location. 2. The relative path (To your user directory: ~) 3. The absolute path All case sensitive, all can be with or without a .app suffix, and no shell expansions... ```toml [extensions] md = "TextEdit" html = "Safari" pdf = "Preview" [schemes] mailto = "Mail" web = "Safari" [types] plain-text = "VSCode" ``` Run without arguments to apply all entries. ```shell infat --config ~/.config/infat/config.toml ``` --- ## Design Philosophy - **Minimal & Scriptable** Infat is a single-binary tool that plays well in shells and automation pipelines. - **macOS-First** Leverages native `NSWorkspace`, Launch Services, and UTType for robust integration. - **Declarative Configuration** TOML support allows you to version-control your associations alongside other dotfiles. ## Building and Debugging You’ll need [just](https://github.com/casey/just) and Swift 5.9+: ```shell # Debug build just build # Release build just build-release # Run in debug mode just run "list txt" # Enable verbose logging for troubleshooting infat --verbose info --ext pdf ``` --- ## Install ### Homebrew ```shell brew update # Optional but recommended brew install infat ``` ### From Source Please make sure `just` (our command-runner) is installed before running. If you don't want to use `just`, the project is managed with SPM, and you can build with "Swift build -c release" and move the result in the .build folder to wherever. ```shell git clone https://github.com/philocalyst/infat.git && cd infat just package && mv dist/infat* /usr/local/bin/infat # Wildcard because output name includes platform ``` ## Changelog For the full history of changes, see [CHANGELOG.md](CHANGELOG.md). ## Libraries Used - [ArgumentParser](https://github.com/apple/swift-argument-parser) - [swift-log](https://github.com/apple/swift-log) - [PListKit](https://github.com/orchetect/PListKit) - [swift-toml](https://github.com/jdfergason/swift-toml) ## Acknowledgements - Inspired by [duti](https://github.com/moretension/duti) - Built with Swift, thanks to corporate overlord Apple’s frameworks - Thanks to all contributors and issue submitters (One day!!) ## License Infat is licensed under the [MIT License](LICENSE). Feel free to use, modify, and distribute! ## /Sources/AssociationManager.swift ```swift path="/Sources/AssociationManager.swift" import AppKit import Foundation import Logging import PListKit import UniformTypeIdentifiers func getBundleIdentifier(appURL: URL) throws -> String? { let plistURL = appURL .appendingPathComponent("Contents") .appendingPathComponent("Info.plist") do { let plist = try DictionaryPList(url: plistURL) return plist.root.string(key: "CFBundleIdentifier").value } catch { throw InfatError.plistReadError( path: plistURL.path, underlyingError: error) } } func setURLHandler(appName: String, scheme: String) throws { let applicationURL = try findApplication(named: appName) if let appURL = applicationURL { let appBundleIdentifier = try getBundleIdentifier(appURL: appURL) if let appBundleID = appBundleIdentifier { // Register the URL in the launch services database, and yes, update information for the app if it already exists. let registerResultCode = LSRegisterURL(appURL as CFURL, true) if registerResultCode != 0 { throw InfatError.cannotRegisterURL(error: registerResultCode) } // Takes URL scheme and bundle ID, as CF strings and sets the handler using a deprecated method. Risky code, until big papa Apple releases an alternative. let setResultCode = LSSetDefaultHandlerForURLScheme( scheme as CFString, appBundleID as CFString) if setResultCode != 0 { throw InfatError.cannotRegisterURL(error: setResultCode) } } else { throw InfatError.cannotSetURL(appName: appURL.path()) } } } func findApplication(named key: String) throws -> URL? { let fullKey = (key as NSString).expandingTildeInPath let fm = FileManager.default // |1| Normalize the key in case user provided a ".app" extension. let rawExt = (fullKey as NSString).pathExtension.lowercased() let baseName = rawExt == "app" ? (fullKey as NSString).deletingPathExtension : fullKey // |2| If this is a valid file‐system path or a file:// URL, // perform basic checks and return instantly if a .app bundle. let isFileURL = (URL(string: fullKey)?.isFileURL) ?? false if isFileURL || fm.fileExists(atPath: fullKey) { // Initalize properly depending on type of URL let url = isFileURL ? URL(string: fullKey)! : URL(fileURLWithPath: fullKey) let r = try url.resourceValues( forKeys: [.isDirectoryKey, .typeIdentifierKey] ) // Final check, these attributes are required for app bundles. if r.isDirectory == true, let tid = r.typeIdentifier, let ut = UTType(tid), ut.conforms(to: .applicationBundle) { return url } return nil } // |3| Otherwise treat `key` as a provided app name: scan all installed .app bundles let installed = try FileSystemUtilities.findApplications() return installed.first { $0.deletingPathExtension() .lastPathComponent .caseInsensitiveCompare(baseName) == .orderedSame } } private func setDefaultApplication( appName: String, appURL: URL, typeIdentifier: UTType, inputDescription: String ) async throws { let workspace = NSWorkspace.shared do { try await workspace.setDefaultApplication( at: appURL, toOpen: typeIdentifier ) // success! } catch { let nsErr = error as NSError // Detect the restriction let isFileOpenError = nsErr.domain == NSCocoaErrorDomain && nsErr.code == CocoaError.fileReadUnknown.rawValue guard isFileOpenError else { // Some other error—rethrow it throw error } // Fallback: call LSSetDefaultRoleHandlerForContentType directly guard let bundleID = try getBundleIdentifier(appURL: appURL) else { throw InfatError.applicationNotFound(name: appURL.path) } let utiCF = typeIdentifier.identifier as CFString let lsErr = LSSetDefaultRoleHandlerForContentType( utiCF, LSRolesMask.viewer, bundleID as CFString ) guard lsErr == noErr else { // propagate the LaunchServices error throw InfatError.cannotRegisterURL(error: lsErr) } } } /// Sets the default application for a given file type specified by its extension. func setDefaultApplication(appName: String, ext: String) async throws { guard let appURL = try findApplication(named: appName) else { throw InfatError.applicationNotFound(name: appName) } // Derive UTType from the extension string guard let uti = UTType(filenameExtension: ext) else { throw InfatError.couldNotDeriveUTI(msg: ext) } try await setDefaultApplication( appName: appName, appURL: appURL, typeIdentifier: uti, // Pass the UTI string identifier inputDescription: ".\(ext)" // For logging clarity ) } /// Sets the default application for a given file type specified by its UTType. func setDefaultApplication(appName: String, supertype: UTType) async throws { guard let appURL = try findApplication(named: appName) else { throw InfatError.applicationNotFound(name: appName) } try await setDefaultApplication( appName: appName, appURL: appURL, typeIdentifier: supertype, inputDescription: supertype.description ) } ``` ## /Sources/Commands/Info.swift ```swift path="/Sources/Commands/Info.swift" import AppKit import ArgumentParser import Foundation import PListKit import UniformTypeIdentifiers struct Info: ParsableCommand { static let configuration = CommandConfiguration( abstract: """ Lists file association information. """ ) @Option(name: [.short, .long], help: "Application name (e.g., 'Google Chrome').") var app: String? @Option(name: [.short, .long], help: "File extension (without the dot, e.g., 'html').") var ext: String? @Option(name: [.short, .long], help: "File type (e.g., text).") var type: Supertypes? mutating func run() throws { let providedCount = [app != nil, ext != nil, type != nil] .filter { $0 } .count guard providedCount > 0 else { throw InfatError.missingOption } guard providedCount == 1 else { throw InfatError.conflictingOptions( error: "Either --scheme, --type, or --ext must be provided, but not more than one." ) } if let appName = app { try listForApp(appName: appName) } else if let fileExtension = ext { try listForExtension(fileExtension: fileExtension) } else if let sType = type?.utType { try listForType(type: sType) } } // ▰▰▰ Helper Methods ▰▰▰ private func listForType(type: UTType) throws { let workspace = NSWorkspace.shared if let defaultAppURL = workspace.urlForApplication(toOpen: type) { print("Default app: \(defaultAppURL.lastPathComponent) (\(defaultAppURL.path))") } else { print( "No default application registered for '.\(type.debugDescription)' (UTI: \(type.identifier))." ) } let allAppURLs = workspace.urlsForApplications(toOpen: type) if !allAppURLs.isEmpty { print("\nAll registered apps:") allAppURLs.forEach { url in print(" • \(url.lastPathComponent) (\(url.path))") } } else { print( "No applications specifically registered for '.\(type.debugDescription)' (UTI: \(type.identifier))." ) } } private func listForApp(appName: String) throws { logger.info("Looking for types handled by '\(appName)'...") guard let appPath = try findApplication(named: appName) else { throw InfatError.applicationNotFound(name: appName) } logger.info("Found application at: \(appPath)") let infoPlistPath = appPath .appendingPathComponent("Contents") .appendingPathComponent("Info.plist") guard FileManager.default.fileExists(atPath: infoPlistPath.path) else { throw InfatError.infoPlistNotFound(appPath: appPath.path) } let plist = try DictionaryPList(url: infoPlistPath) guard let documentTypes = plist.root.array(key: "CFBundleDocumentTypes").value else { print( "No 'CFBundleDocumentTypes' found in \(infoPlistPath). This app might not declare specific document types." ) return } print("\nDeclared Document Types:") if documentTypes.isEmpty { print(" (None declared)") return } for item in documentTypes { if let docType = item as? PListDictionary { let typeName = docType print(" • \(typeName):") if let utis = docType["LSItemContentTypes"] as? [String], !utis.isEmpty { print(" - UTIs: \(utis.joined(separator: ", "))") } else { print(" - UTIs: (None specified)") } if let extensions = docType["CFBundleTypeExtensions"] as? [String], !extensions.isEmpty { print( " - Extensions: \(extensions.map { ".\($0)" }.joined(separator: ", "))") } else { print(" - Extensions: (None specified)") } print("") } } } private func listForExtension(fileExtension: String) throws { print("Looking for apps associated with '.\(fileExtension)'...") guard let uti = deriveUTIFromExtension(extension: fileExtension) else { throw InfatError.couldNotDeriveUTI(msg: fileExtension) } print("Derived UTI: \(uti.identifier)") let workspace = NSWorkspace.shared if let defaultAppURL = workspace.urlForApplication(toOpen: uti) { print("Default app: \(defaultAppURL.lastPathComponent) (\(defaultAppURL.path))") } else { print( "No default application registered for '.\(fileExtension)' (UTI: \(uti.identifier))." ) } let allAppURLs = workspace.urlsForApplications(toOpen: uti) if !allAppURLs.isEmpty { print("\nAll registered apps:") allAppURLs.forEach { url in print(" • \(url.lastPathComponent) (\(url.path))") } } else { print( "No applications specifically registered for '.\(fileExtension)' (UTI: \(uti.identifier))." ) } } private func deriveUTIFromExtension(extension ext: String) -> UTType? { return UTType(filenameExtension: ext) } } ``` ## /Sources/Commands/Set.swift ```swift path="/Sources/Commands/Set.swift" import ArgumentParser import Logging extension Infat { struct Set: AsyncParsableCommand { static let configuration = CommandConfiguration( abstract: "Sets an application association." ) @Argument(help: "The name of the application.") var appName: String @Option(name: .long, help: "A file extension without leading dot.") var ext: String? @Option(name: .long, help: "A URL scheme. ex: mailto.") var scheme: String? @Option(name: .long, help: "A file class. ex: image") var type: Supertypes? @Flag(name: .long, help: "Ignore missing app errors") var robust: Bool = false mutating func run() async throws { let providedCount = [scheme != nil, ext != nil, type != nil] .filter { $0 } .count guard providedCount > 0 else { throw InfatError.missingOption } guard providedCount == 1 else { throw InfatError.conflictingOptions( error: "Either --scheme, --type, or --ext must be provided, but not more than one." ) } if let fType = ext { switch fType.lowercased() { case "html": // Route .html to the http URL handler try setURLHandler(appName: appName, scheme: "http") print("Successfully bound \(appName) to http") default: do { try await setDefaultApplication( appName: appName, ext: fType, ) } catch InfatError.applicationNotFound(let name) { // Only throw for real if not robust if !robust { throw InfatError.applicationNotFound(name: name) } print( "Application '\(appName)' not found but ignoring due to passed options".bold().red()) return // Otherwise just eat that thing up } catch { // propogate the rest throw error } print("Successfully bound \(appName) to \(fType)".italic()) } } else if let schm = scheme { switch schm.lowercased() { case "https": // Route https to the http URL handler try setURLHandler(appName: appName, scheme: "http") print("Successfully bound \(appName) to http") default: try setURLHandler( appName: appName, scheme: schm ) print("Successfully bound \(appName) to \(schm)".italic()) } } else if let superType = type { if let sUTI = superType.utType { try await setDefaultApplication( appName: appName, supertype: sUTI ) } print("Set default app for type \(superType) to \(appName)".italic()) } } } } ``` ## /Sources/ConfigManager.swift ```swift path="/Sources/ConfigManager.swift" import ColorizeSwift import Foundation import Logging import Toml struct ConfigManager { static func loadConfig(from configPath: String) async throws { let tomlConfig = try Toml(contentsOfFile: configPath) // Convenience names let extensionTableName = "extensions" let schemeTableName = "schemes" let typeTableName = "types" // Check upfront that at least one table is present let hasExtensions = tomlConfig.table(extensionTableName) != nil let hasSchemes = tomlConfig.table(schemeTableName) != nil let hasTypes = tomlConfig.table(typeTableName) != nil guard hasExtensions || hasSchemes || hasTypes else { throw InfatError.noConfigTables(path: configPath) } // MARK: – Process [types] if let typeTable = tomlConfig.table(typeTableName) { logger.info("Processing [types] associations...") print("\(typeTableName.uppercased().bold().underline())") for key in typeTable.keyNames { let typeKey = key.components.joined() guard let appName = typeTable.string(key.components) else { logger.warning( "Value for key '\(typeKey)' in [types] is not a string. Skipping." ) continue } guard let supertypeEnum = Supertypes(rawValue: typeKey) else { logger.error( "Invalid type key '\(typeKey)' found in [types]. Skipping." ) continue } guard let targetUTType = supertypeEnum.utType else { logger.error( "Well-known type '\(typeKey)' not supported or invalid. Skipping." ) throw InfatError.unsupportedOrInvalidSupertype(name: typeKey) } logger.debug( "Queueing default app for type \(typeKey) (\(targetUTType.identifier)) → \(appName)" ) try await setDefaultApplication( appName: appName, supertype: targetUTType ) print("Set type \(typeKey) → \(appName)") } } else { logger.debug("No [types] table found in \(configPath)") } // MARK: – Processs [extensions] if let extensionTable = tomlConfig.table(extensionTableName) { logger.info("Processing [extensions] associations...") print("\(extensionTableName.uppercased().bold().underline())") for key in extensionTable.keyNames { guard let appName = extensionTable.string(key.components) else { throw InfatError.tomlValueNotString( path: configPath, key: key.components.joined() ) } let ext = key.components.joined() switch ext.lowercased() { case "html": // Route .html to the http URL handler try setURLHandler(appName: appName, scheme: "http") print("Set .\(ext) → \(appName) (routed to http)") default: do { try await setDefaultApplication( appName: appName, ext: ext, ) } catch InfatError.applicationNotFound(_) { print( "Application '\(appName)' not found but ignoring due to passed options".bold().red()) // Just eat that thing up } catch { // propogate the rest throw error } print("Set .\(ext) → \(appName)") } } } else { logger.debug("No [extensions] table found in \(configPath)") } // MARK: – Process [schemes] if let schemeTable = tomlConfig.table(schemeTableName) { logger.info("Processing [schemes] associations...") print("\(schemeTableName.uppercased().bold().underline())") for key in schemeTable.keyNames { guard let appName = schemeTable.string(key.components) else { throw InfatError.tomlValueNotString( path: configPath, key: key.components.joined() ) } let scheme = key.components.joined() switch scheme.lowercased() { case "https": // Route https to the http URL handler try setURLHandler(appName: appName, scheme: "http") print("Set https → \(appName) (routed to http)") default: try setURLHandler(appName: appName, scheme: scheme) print("Set \(scheme) → \(appName)") } } } else { logger.debug("No [schemes] table found in \(configPath)") } } } ``` ## /Sources/ConformanceTypes.swift ```swift path="/Sources/ConformanceTypes.swift" import ArgumentParser import UniformTypeIdentifiers // Define a selection of the UTI supertypes Apple defines (https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct) // Convert raw values to kebab-case for friendleness // // General philosphy for types here, besides the requirement that they be conformance types, // is that they are not made redudant by extensions. They need to be an umbrella type, or stand // in for situations where you couldn't derive the type from the extension alone (makefile for example) enum Supertypes: String, CaseIterable, ExpressibleByArgument { // Text & Documents case plainText = "plain-text" case text = "text" case csv = "csv" case image = "image" case rawImage = "raw-image" // Audio & Video case audio = "audio" // Base audio case video = "video" // Base video (no audio) case movie = "movie" // Base audiovisual case mpeg4Audio = "mp4-audio" case quicktime = "quicktime" case mpeg4Movie = "mp4-movie" // Archives case archive = "archive" // Source Code case source = "sourcecode" case cSource = "c-source" case cppSource = "cpp-source" case objcSource = "objc-source" case shell = "shell" case makefile = "makefile" // Filesystem & System case data = "data" case directory = "directory" case folder = "folder" case symbolicLink = "symlink" case executable = "executable" case unixExecutable = "unix-executable" case applicationBundle = "app-bundle" // Computed property to get the actual UTType var utType: UTType? { switch self { // Text & Documents case .plainText: return .plainText case .text: return .text case .csv: return .commaSeparatedText // Map to most common CSV UTI // Images case .image: return .image case .rawImage: return .rawImage // Audio & Video case .audio: return .audio case .video: return .video case .movie: return .movie case .mpeg4Audio: return .mpeg4Audio case .quicktime: return .quickTimeMovie case .mpeg4Movie: return .mpeg4Movie // Archives case .archive: return .archive // Source Code case .source: return .sourceCode case .cSource: return .cSource case .cppSource: return .cPlusPlusSource case .objcSource: return .objectiveCSource case .shell: return .shellScript case .makefile: return .makefile // Filesystem & System case .data: return .data case .directory: return .directory case .folder: return .folder case .symbolicLink: return .symbolicLink case .executable: return .executable case .unixExecutable: return .unixExecutable case .applicationBundle: return .applicationBundle // default: return nil } } } ``` ## /Sources/Error.swift ```swift path="/Sources/Error.swift" import Foundation enum InfatError: Error, LocalizedError { case couldNotDeriveUTI(msg: String) case missingOption case noConfigTables(path: String) case infoPlistNotFound(appPath: String) case unsupportedOrInvalidSupertype(name: String) case cannotSetURL(appName: String) case cannotRegisterURL(error: Int32) case unsupportedOSVersion case conflictingOptions(error: String) case directoryReadError(path: String, underlyingError: Error) case pathExpansionError(path: String) case applicationNotFound(name: String) case plistReadError(path: String, underlyingError: Error) case defaultAppSettingError(underlyingError: Error) case noActiveApplication case configurationLoadError(path: String, underlyingError: Error) case operationTimeout case tomlLoadError(path: String, underlyingError: Error) case tomlTableNotFoundError(path: String, table: String) case tomlValueNotString(path: String, key: String) var errorDescription: String? { switch self { case .couldNotDeriveUTI(let ext): return "Cannot determine UTI for extension '.\(ext)'" case .missingOption: return "No option provided" case .noConfigTables(let path): return "No valid tables found in provided configuration at \(path)" case .infoPlistNotFound(let appPath): return "Info.plist not found within application bundle: \(appPath)" case .conflictingOptions(let str): return str case .cannotRegisterURL(let error): return "Cannot register provided URL, got error \(error)" case .cannotSetURL(let app): return "Cannot set scheme for app \(app)" case .unsupportedOSVersion: return "This functionality requires a later version of macOS" case .directoryReadError(let path, let error): return "Error reading directory at \(path): \(error.localizedDescription)" case .pathExpansionError(let path): return "Could not expand path: \(path)" case .applicationNotFound(let name): return "Application not found: \(name)" case .plistReadError(let path, let error): return "Error reading or parsing Info.plist at \(path): \(error.localizedDescription)" case .defaultAppSettingError(let error): return "Failed to set default application: \(error.localizedDescription)" case .noActiveApplication: return "No active application found" case .configurationLoadError(let path, let error): return "Failed to load configuration from \(path): \(error.localizedDescription)" case .operationTimeout: return "Operation timed out" case .tomlLoadError(let path, let error): return "Failed to load TOML from \(path): \(error.localizedDescription)" case .tomlTableNotFoundError(let path, let table): return "Table '\(table)' not found in TOML file \(path)" case .tomlValueNotString(let path, let key): return "Value for key '\(key)' in TOML file \(path) is not a string." case .unsupportedOrInvalidSupertype(let supertype): return "Supertype \(supertype) is invalid or unsupported" } } } ``` ## /Sources/FileSystemUtilities.swift ```swift path="/Sources/FileSystemUtilities.swift" import Foundation import Logging import UniformTypeIdentifiers struct FileSystemUtilities { static func findApplications() throws -> [URL] { let fileManager = FileManager.default let home = fileManager.homeDirectoryForCurrentUser.path var allAppURLs: [URL] = [] let paths = [ "/Applications/", "/System/Library/CoreServices/Applications/", "/System/Applications/", "\(home)/Applications/", ] for path in paths { do { let urls = try fileManager.contentsOfDirectory( at: URL(fileURLWithPath: path), includingPropertiesForKeys: nil, options: [] ) allAppURLs.append(contentsOf: urls) logger.debug("Found \(urls.count) items in \(path)") } catch { logger.debug("Could not read \(path): \(error.localizedDescription)") } } guard !allAppURLs.isEmpty else { throw InfatError.directoryReadError( path: "All application directories", underlyingError: NSError( domain: "com.philocalyst.infat", code: 1, userInfo: [NSLocalizedDescriptionKey: "No applications found"] ) ) } return allAppURLs } static func deriveUTIFromExtension(ext: String) throws -> FileUTIInfo { guard #available(macOS 11.0, *) else { throw InfatError.unsupportedOSVersion } let commonUTTypes: [UTType] = [ .content, .text, .plainText, .pdf, .image, .png, .jpeg, .gif, .audio, .mp3, .movie, .mpeg4Movie, .zip, .gzip, .archive, ] guard let utType = UTType(filenameExtension: ext) else { throw InfatError.couldNotDeriveUTI(msg: ext) } let conforms = commonUTTypes .filter { utType.conforms(to: $0) } .map { $0.identifier } logger.debug("Determined UTI \(utType.identifier) for .\(ext)") return FileUTIInfo( typeIdentifier: utType, preferredMIMEType: utType.preferredMIMEType, localizedDescription: utType.localizedDescription, isDynamic: utType.isDynamic, conformsTo: conforms ) } } ``` ## /Sources/FileUTIInfo.swift ```swift path="/Sources/FileUTIInfo.swift" import UniformTypeIdentifiers struct FileUTIInfo { let typeIdentifier: UTType let preferredMIMEType: String? let localizedDescription: String? let isDynamic: Bool let conformsTo: [String] var description: String { var output = "UTI: \(typeIdentifier)\n" if let mimeType = preferredMIMEType { output += "MIME Type: \(mimeType)\n" } if let description = localizedDescription { output += "Description: \(description)\n" } output += "Is Dynamic: \(isDynamic ? "Yes" : "No")\n" if !conformsTo.isEmpty { output += "Conforms To: \(conformsTo.joined(separator: ", "))\n" } return output } } ``` ## /Sources/infat.swift ```swift path="/Sources/infat.swift" import ArgumentParser import Foundation import Logging import class Foundation.ProcessInfo var logger = Logger(label: "com.philocalyst.infat") @main struct Infat: AsyncParsableCommand { static let configuration = CommandConfiguration( abstract: "Declaritively set assocations for URLs and files", version: "2.4.0", subcommands: [Info.self, Set.self] ) @Option( name: [.short, .long], help: "Path to the configuration file.") var config: String? @Flag( name: [.short, .long], help: "Enable verbose logging.") var verbose = false @Flag( name: [.short, .long], help: "Quiet output.") var quiet = false func validate() throws { let level: Logger.Level = verbose ? .trace : (quiet ? .critical : .warning) LoggingSystem.bootstrap { label in var h = StreamLogHandler.standardOutput(label: label) h.logLevel = level return h } logger = Logger(label: "com.philocalyst.infat") } mutating func run() async throws { // First check if a config was passed through the CLI. Then check if one was found at the XDG config home. If neither, error. if let cfg = config { try await ConfigManager.loadConfig(from: cfg) } else { // No config path passed, try XDG‐compliant locations: let env = ProcessInfo.processInfo.environment // |1| Determine XDG_CONFIG_HOME (or default to ~/.config) let homeDir = env["HOME"] ?? NSHomeDirectory() let xdgConfigHomePath = env["XDG_CONFIG_HOME"] ?? "\(homeDir)/.config" let xdgConfigHome = URL(fileURLWithPath: xdgConfigHomePath, isDirectory: true) // |2| Set up the per‐app relative path let appConfigSubpath = "infat/config.toml" // |3| Build the search list: user then system var searchPaths: [URL] = [ xdgConfigHome.appendingPathComponent(appConfigSubpath) ] // If user has more than one config directory let systemConfigDirs = env["XDG_CONFIG_DIRS"]? .split(separator: ":") .map(String.init) ?? ["/etc/xdg"] for dir in systemConfigDirs { let url = URL(fileURLWithPath: dir, isDirectory: true) .appendingPathComponent(appConfigSubpath) searchPaths.append(url) } // |4| Try each path in order for url in searchPaths { if FileManager.default.fileExists(atPath: url.path) { try await ConfigManager.loadConfig(from: url.path) return } } // |5| Nothing found → error print( "Did you mean to pass in a config? Use -c or put one at " + "\(searchPaths[0].path)" ) throw InfatError.missingOption } } } ``` ## /dist/infat-arm64-apple-macos Binary file available at https://raw.githubusercontent.com/philocalyst/infat/refs/heads/main/dist/infat-arm64-apple-macos ## /justfile ``` path="/justfile" #!/usr/bin/env just set shell := ["bash", "-euo", "pipefail", "-c"] set dotenv-load := true set allow-duplicate-recipes := true # ▰▰▰ Variables ▰▰▰ # project_root := justfile_directory() output_directory := project_root / "dist" current_platform := `uname -m` + "-apple-" + os() default_bin := "infat" build_dir := project_root / ".build" debug_bin := build_dir / "debug" / default_bin release_bin := build_dir / "release" / default_bin # ▰▰▰ Default ▰▰▰ # default: build [confirm("You've updated the versionings?")] check: @echo "At the README?" @echo "At the swift bundle?" @echo "At the CHANGELOG?" grep -R \ --exclude='CHANGELOG*' \ --exclude='README*' \ --exclude='Package*' \ --exclude-dir='.build' \ -nE '\b([0-9]+\.){2}[0-9]+\b' \ . # ▰▰▰ Build & Check ▰▰▰ # build target=(current_platform): @echo "🔨 Building Swift package (debug)…" swift build --triple {{target}} build-release target=(current_platform): @echo "🚀 Building Swift package (release)…" swift build -c release -Xswiftc "-whole-module-optimization" --triple {{target}} -Xlinker "-dead_strip" # ▰▰▰ Packaging ▰▰▰ # package target=(current_platform) result_directory=(output_directory): just build-release {{target}} @echo "📦 Packaging release binary…" @mkdir -p {{output_directory}} @cp {{release_bin}} "{{result_directory}}/{{default_bin}}-{{target}}" @echo "✅ Packaged → {{result_directory}}/{{default_bin}}-{{target}}" compress-binaries target_directory=("."): #!/usr/bin/env bash find "{{target_directory}}" -maxdepth 1 -type f -print0 | while IFS= read -r -d {{contextString}}#39;\0' file; do # Check if the file command output indicates a binary/executable type if file "$file" | grep -q -E 'executable|ELF|Mach-O|shared object'; then # Get the base filename without the path filename=$(basename "$file") # Get the base name without version number basename="${filename%%-*}" echo "Archiving binary file: $filename" # Create archive with just the basename, no directory structure tar -czf "${file}.tar.gz" \ -C "$(dirname "$file")" \ -s "|^${filename}$|${basename}|" \ "$(basename "$file")" fi done format: find . -name "*.swift" -type f -exec swift-format format -i {} + checksum directory=(output_directory): @echo "🔒 Creating checksums in {{directory}}…" @find "{{directory}}" -type f \ ! -name "checksums.sha256" \ ! -name "*.sha256" \ -exec sh -c 'sha256sum "$1" > "$1.sha256"' _ {} \; @echo "✅ Checksums created!" create-notes raw_tag outfile changelog: #!/usr/bin/env bash tag_v="{{raw_tag}}" tag="${tag_v#v}" # Remove prefix v # Changes header for release notes printf "# What's new\n" > "{{outfile}}" if [[ ! -f "{{changelog}}" ]]; then echo "Error: {{changelog}} not found." >&2 exit 1 fi echo "Extracting notes for tag: {{raw_tag}} (searching for section [$tag])" # Use awk to extract the relevant section from the changelog awk -v tag="$tag" ' # start printing when we see "## [<tag>]" (escape brackets for regex) $0 ~ ("^## \\[" tag "\\]") { printing = 1; next } # stop as soon as we hit the next "## [" section header printing && /^## \[/ { exit } # otherwise, if printing is enabled, print the current line printing { print } # Error handling END { if (found_section != 0) { # Print error to stderr print "Error: awk could not find section header ## [" tag "] in " changelog_file > "/dev/stderr" exit 1 } } ' "{{changelog}}" >> "{{outfile}}" # Check if the output file has content if [[ -s {{outfile}} ]]; then echo "Successfully extracted release notes to '{{outfile}}'." else # Output a warning if no notes were found for the tag echo "Warning: '{{outfile}}' is empty. Is '## [$tag]' present in '{{changelog}}'?" >&2 fi # ▰▰▰ Run ▰▰▰ # run +args="": @echo "▶️ Running (debug)…" swift run {{default_bin}} {{args}} run-release +args="": @echo "▶️ Running (release)…" swift run -c release -Xswiftc "-whole-module-optimization" {{release_bin}} {{args}} # ▰▰▰ Cleaning ▰▰▰ # clean: @echo "🧹 Cleaning build artifacts…" swift package clean swift package resolve # ▰▰▰ Installation & Update ▰▰▰ # install: build-release @echo "💾 Installing {{default_bin}} → /usr/local/bin…" @cp {{release_bin}} /usr/local/bin/{{default_bin}} install-force: build-release @echo "💾 Force installing {{default_bin}} → /usr/local/bin…" @cp {{release_bin}} /usr/local/bin/{{default_bin}} --force update: @echo "🔄 Updating Swift package dependencies…" swift package update # ▰▰▰ Aliases ▰▰▰ # alias b := build alias br := build-release alias p := package alias cb := compress-binaries alias ch := checksum alias r := run alias rr := run-release alias cl := clean alias i := install alias ifo := install-force alias up := update ``` 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.