├── .config ├── build.ncl ├── ci │ └── release │ │ ├── generate_tool.cue │ │ ├── github-ci.cue │ │ ├── github.actions.workflow.schema.cue │ │ ├── release.sh │ │ ├── repo-install.sh │ │ └── workflows.cue ├── common.ncl ├── dev.ncl ├── nickel.lock.ncl └── project.ncl ├── .github └── workflows │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── cue.mod └── module.cue ├── flake.lock ├── flake.nix ├── infat-cli ├── Cargo.toml ├── build.rs └── src │ ├── cli.rs │ └── main.rs ├── infat-lib ├── Cargo.toml └── src │ ├── app.rs │ ├── association.rs │ ├── config.rs │ ├── error.rs │ ├── lib.rs │ ├── macos │ ├── ffi.rs │ ├── launch_services.rs │ ├── launch_services_db.rs │ └── workspace.rs │ └── uti.rs └── justfile /.config/build.ncl: -------------------------------------------------------------------------------- 1 | [ 2 | ] -------------------------------------------------------------------------------- /.config/ci/release/generate_tool.cue: -------------------------------------------------------------------------------- 1 | package release 2 | 3 | import ( 4 | "path" 5 | "encoding/yaml" 6 | "tool/file" 7 | ) 8 | 9 | // Generate GitHub Actions workflow files 10 | command: generate: { 11 | for workflowName, workflowConfig in workflows { 12 | let workflowFile = workflowName + ".yml" 13 | let workflowDir = path.FromSlash(".github/workflows", path.Unix) 14 | let workflowPath = path.Join([workflowDir, workflowFile]) 15 | let toolFile = ".config/ci/tools.cue" 16 | let donotedit = "Code generated by \(toolFile); DO NOT EDIT." 17 | 18 | "remove-\(workflowName)": file.RemoveAll & { 19 | path: workflowPath 20 | } 21 | 22 | "write-\(workflowName)": file.Create & { 23 | $after: "remove-\(workflowName)" 24 | filename: workflowPath 25 | contents: "# \(donotedit)\n\n\(yaml.Marshal(workflowConfig))" 26 | } 27 | } 28 | } 29 | 30 | // Validate workflow files without generating 31 | command: validate: { 32 | result: workflows 33 | } 34 | -------------------------------------------------------------------------------- /.config/ci/release/github-ci.cue: -------------------------------------------------------------------------------- 1 | @extern(embed) 2 | package release 3 | 4 | #InstallNix: { 5 | name: "Install Nix" 6 | uses: "cachix/install-nix-action@v31" 7 | with: { 8 | extra_nix_config: "access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}" 9 | } 10 | } 11 | 12 | workflows: release: { 13 | name: "Release" 14 | on: { 15 | push: tags: ["*"] 16 | workflow_dispatch: {} 17 | } 18 | defaults: run: shell: "bash" 19 | env: { 20 | RUSTFLAGS: "--deny warnings" 21 | BINARY_NAME: "infat" 22 | } 23 | jobs: { 24 | prerelease: { 25 | "runs-on": "ubuntu-latest" 26 | outputs: value: "${{ steps.prerelease.outputs.value }}" 27 | steps: [{ 28 | name: "Prerelease Check" 29 | id: "prerelease" 30 | run: string @embed(file=release.sh, type=text) 31 | }] 32 | } 33 | 34 | package: { 35 | strategy: { 36 | "fail-fast": false 37 | matrix: { 38 | target: [ 39 | "aarch64-apple-darwin", 40 | "x86_64-apple-darwin", 41 | ] 42 | include: [{ 43 | target: "aarch64-apple-darwin" 44 | os: "macos-latest" 45 | }, { 46 | target: "x86_64-apple-darwin" 47 | os: "macos-latest" 48 | }] 49 | } 50 | } 51 | "runs-on": "${{ matrix.os }}" 52 | needs: ["prerelease"] 53 | environment: name: "main" 54 | steps: [{ 55 | name: "Checkout code" 56 | uses: "actions/checkout@v4" 57 | }, 58 | #InstallNix, { 59 | name: "Cache Cargo registry and git" 60 | uses: "actions/cache@v4" 61 | with: { 62 | path: """ 63 | ~/.cargo/registry/index 64 | ~/.cargo/registry/cache 65 | ~/.cargo/git/db 66 | """ 67 | key: "${{ runner.os }}-cargo-registry-${{ hashFiles('Cargo.lock') }}" 68 | "restore-keys": "${{ runner.os }}-cargo-registry-" 69 | } 70 | }, { 71 | name: "Build and Package" 72 | run: "nix develop --command just package ${{ matrix.target }}" 73 | }, { 74 | name: "Extract changelog for the tag" 75 | run: "nix develop --command just create-notes ${{ github.ref_name }} release_notes.md CHANGELOG.md" 76 | }, { 77 | name: "Publish Release" 78 | uses: "softprops/action-gh-release@v2" 79 | if: "startsWith(github.ref, 'refs/tags/')" 80 | with: { 81 | files: "dist/${{ env.BINARY_NAME }}-${{ matrix.target }}.tar.gz" 82 | body_path: "release_notes.md" 83 | draft: false 84 | make_latest: true 85 | prerelease: "${{ needs.prerelease.outputs.value }}" 86 | token: "${{ secrets.PAT }}" 87 | } 88 | }] 89 | } 90 | 91 | checksum: { 92 | "runs-on": "ubuntu-latest" 93 | needs: ["package", "prerelease"] 94 | if: "startsWith(github.ref, 'refs/tags/')" 95 | environment: name: "main" 96 | steps: [#InstallNix, { 97 | name: "Download Release Archives" 98 | env: { 99 | GH_TOKEN: "${{ secrets.PAT }}" 100 | TAG_NAME: "${{ github.ref_name }}" 101 | } 102 | // The installation script for getting the release archive 103 | run: string @embed(file=repo-install.sh, type=text) 104 | }, { 105 | name: "Generate Checksums" 106 | run: "nix develop --command just checksum dist" 107 | }, { 108 | name: "Publish Checksums" 109 | uses: "softprops/action-gh-release@v2" 110 | with: { 111 | files: "dist/*.sum" 112 | draft: false 113 | prerelease: "${{ needs.prerelease.outputs.value }}" 114 | token: "${{ secrets.PAT }}" 115 | } 116 | }] 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /.config/ci/release/github.actions.workflow.schema.cue: -------------------------------------------------------------------------------- 1 | package release 2 | 3 | import ( 4 | "struct" 5 | "strings" 6 | ) 7 | 8 | #Workflow: { 9 | @jsonschema(schema="http://json-schema.org/draft-07/schema#") 10 | @jsonschema(id="https://json.schemastore.org/github-workflow.json") 11 | close({ 12 | // The name of your workflow. GitHub displays the names of your 13 | // workflows on your repository's actions page. If you omit this 14 | // field, GitHub sets the name to the workflow's filename. 15 | name?: string 16 | 17 | // The name of the GitHub event that triggers the workflow. You 18 | // can provide a single event string, array of events, array of 19 | // event types, or an event configuration map that schedules a 20 | // workflow or restricts the execution of a workflow to specific 21 | // files, tags, or branch changes. For a list of available 22 | // events, see 23 | // https://help.github.com/en/github/automating-your-workflow-with-github-actions/events-that-trigger-workflows. 24 | on!: matchN(1, [#event, [...#event] & [_, ...], close({ 25 | branch_protection_rule?: #eventObject 26 | check_run?: #eventObject 27 | check_suite?: #eventObject 28 | create?: #eventObject 29 | delete?: #eventObject 30 | deployment?: #eventObject 31 | deployment_status?: #eventObject 32 | discussion?: #eventObject 33 | discussion_comment?: #eventObject 34 | fork?: #eventObject 35 | gollum?: #eventObject 36 | issue_comment?: #eventObject 37 | issues?: #eventObject 38 | label?: #eventObject 39 | merge_group?: #eventObject 40 | milestone?: #eventObject 41 | page_build?: #eventObject 42 | project?: #eventObject 43 | project_card?: #eventObject 44 | project_column?: #eventObject 45 | public?: #eventObject 46 | pull_request?: #ref 47 | pull_request_review?: #eventObject 48 | pull_request_review_comment?: #eventObject 49 | pull_request_target?: #ref 50 | push?: #ref 51 | registry_package?: #eventObject 52 | release?: #eventObject 53 | status?: #eventObject 54 | watch?: #eventObject 55 | 56 | // Allows workflows to be reused by other workflows. 57 | workflow_call?: null | bool | number | string | [...] | { 58 | // When using the workflow_call keyword, you can optionally 59 | // specify inputs that are passed to the called workflow from the 60 | // caller workflow. 61 | inputs?: close({ 62 | {[=~"^[_a-zA-Z][a-zA-Z0-9_-]*$"]: close({ 63 | // A string description of the input parameter. 64 | description?: string 65 | 66 | // A boolean to indicate whether the action requires the input 67 | // parameter. Set to true when the parameter is required. 68 | required?: bool 69 | 70 | // Required if input is defined for the on.workflow_call keyword. 71 | // The value of this parameter is a string specifying the data 72 | // type of the input. This must be one of: boolean, number, or 73 | // string. 74 | type!: "boolean" | "number" | "string" 75 | 76 | // The default value is used when an input parameter isn't 77 | // specified in a workflow file. 78 | default?: bool | number | string 79 | }) 80 | } 81 | }) 82 | 83 | // When using the workflow_call keyword, you can optionally 84 | // specify inputs that are passed to the called workflow from the 85 | // caller workflow. 86 | outputs?: close({ 87 | {[=~"^[_a-zA-Z][a-zA-Z0-9_-]*$"]: close({ 88 | // A string description of the output parameter. 89 | description?: string 90 | 91 | // The value that the output parameter will be mapped to. You can 92 | // set this to a string or an expression with context. For 93 | // example, you can use the steps context to set the value of an 94 | // output to the output value of a step. 95 | value!: string 96 | }) 97 | } 98 | }) 99 | 100 | // A map of the secrets that can be used in the called workflow. 101 | // Within the called workflow, you can use the secrets context to 102 | // refer to a secret. 103 | secrets?: null | bool | number | string | [...] | close({ 104 | {[=~"^[_a-zA-Z][a-zA-Z0-9_-]*$"]: null | bool | number | string | [...] | close({ 105 | // A string description of the secret parameter. 106 | description?: string 107 | 108 | // A boolean specifying whether the secret must be supplied. 109 | required?: bool 110 | }) 111 | } 112 | }) 113 | ... 114 | } 115 | 116 | // You can now create workflows that are manually triggered with 117 | // the new workflow_dispatch event. You will then see a 'Run 118 | // workflow' button on the Actions tab, enabling you to easily 119 | // trigger a run. 120 | workflow_dispatch?: null | bool | number | string | [...] | close({ 121 | // Input parameters allow you to specify data that the action 122 | // expects to use during runtime. GitHub stores input parameters 123 | // as environment variables. Input ids with uppercase letters are 124 | // converted to lowercase during runtime. We recommended using 125 | // lowercase input ids. 126 | inputs?: close({ 127 | {[=~"^[_a-zA-Z][a-zA-Z0-9_-]*$"]: #workflowDispatchInput} 128 | }) 129 | }) 130 | workflow_run?: #eventObject 131 | repository_dispatch?: #eventObject 132 | 133 | // You can schedule a workflow to run at specific UTC times using 134 | // POSIX cron syntax 135 | // (https://pubs.opengroup.org/onlinepubs/9699919799/utilities/crontab.html#tag_20_25_07). 136 | // Scheduled workflows run on the latest commit on the default or 137 | // base branch. The shortest interval you can run scheduled 138 | // workflows is once every 5 minutes. 139 | // Note: GitHub Actions does not support the non-standard syntax 140 | // @yearly, @monthly, @weekly, @daily, @hourly, and @reboot. 141 | // You can use crontab guru (https://crontab.guru/). to help 142 | // generate your cron syntax and confirm what time it will run. 143 | // To help you get started, there is also a list of crontab guru 144 | // examples (https://crontab.guru/examples.html). 145 | schedule?: [...close({ 146 | cron?: string 147 | })] & [_, ...] 148 | })]) 149 | env?: #env 150 | defaults?: #defaults 151 | 152 | // Concurrency ensures that only a single job or workflow using 153 | // the same concurrency group will run at a time. A concurrency 154 | // group can be any string or expression. The expression can use 155 | // any context except for the secrets context. 156 | // You can also specify concurrency at the workflow level. 157 | // When a concurrent job or workflow is queued, if another job or 158 | // workflow using the same concurrency group in the repository is 159 | // in progress, the queued job or workflow will be pending. Any 160 | // previously pending job or workflow in the concurrency group 161 | // will be canceled. To also cancel any currently running job or 162 | // workflow in the same concurrency group, specify 163 | // cancel-in-progress: true. 164 | concurrency?: matchN(1, [string, #concurrency]) 165 | 166 | // A workflow run is made up of one or more jobs. Jobs run in 167 | // parallel by default. To run jobs sequentially, you can define 168 | // dependencies on other jobs using the jobs..needs 169 | // keyword. 170 | // Each job runs in a fresh instance of the virtual environment 171 | // specified by runs-on. 172 | // You can run an unlimited number of jobs as long as you are 173 | // within the workflow usage limits. For more information, see 174 | // https://help.github.com/en/github/automating-your-workflow-with-github-actions/workflow-syntax-for-github-actions#usage-limits. 175 | jobs!: struct.MinFields(1) & close({ 176 | {[=~"^[_a-zA-Z][a-zA-Z0-9_-]*$"]: matchN(1, [#normalJob, #reusableWorkflowCallJob])} 177 | }) 178 | 179 | // The name for workflow runs generated from the workflow. GitHub 180 | // displays the workflow run name in the list of workflow runs on 181 | // your repository's 'Actions' tab. 182 | "run-name"?: string 183 | permissions?: #permissions 184 | }) 185 | 186 | #: "permissions-event": close({ 187 | actions?: #."permissions-level" 188 | attestations?: #."permissions-level" 189 | checks?: #."permissions-level" 190 | contents?: #."permissions-level" 191 | deployments?: #."permissions-level" 192 | discussions?: #."permissions-level" 193 | "id-token"?: #."permissions-level" 194 | issues?: #."permissions-level" 195 | models?: "read" | "none" 196 | packages?: #."permissions-level" 197 | pages?: #."permissions-level" 198 | "pull-requests"?: #."permissions-level" 199 | "repository-projects"?: #."permissions-level" 200 | "security-events"?: #."permissions-level" 201 | statuses?: #."permissions-level" 202 | }) 203 | 204 | #: "permissions-level": "read" | "write" | "none" 205 | 206 | // Using the working-directory keyword, you can specify the 207 | // working directory of where to run the command. 208 | #: "working-directory": string 209 | 210 | #architecture: "ARM32" | "x64" | "x86" 211 | 212 | #branch: #globs 213 | 214 | #concurrency: close({ 215 | // When a concurrent job or workflow is queued, if another job or 216 | // workflow using the same concurrency group in the repository is 217 | // in progress, the queued job or workflow will be pending. Any 218 | // previously pending job or workflow in the concurrency group 219 | // will be canceled. 220 | group!: string 221 | 222 | // To cancel any currently running job or workflow in the same 223 | // concurrency group, specify cancel-in-progress: true. 224 | "cancel-in-progress"?: matchN(1, [bool, #expressionSyntax]) 225 | }) 226 | 227 | #configuration: matchN(1, [string, number, bool, { 228 | [string]: #configuration 229 | }, [...#configuration]]) 230 | 231 | #container: close({ 232 | // The Docker image to use as the container to run the action. The 233 | // value can be the Docker Hub image name or a registry name. 234 | image!: string 235 | 236 | // If the image's container registry requires authentication to 237 | // pull the image, you can use credentials to set a map of the 238 | // username and password. The credentials are the same values 239 | // that you would provide to the `docker login` command. 240 | credentials?: { 241 | username?: string 242 | password?: string 243 | ... 244 | } 245 | env?: #env 246 | 247 | // Sets an array of ports to expose on the container. 248 | ports?: [...number | string] & [_, ...] 249 | 250 | // Sets an array of volumes for the container to use. You can use 251 | // volumes to share data between services or other steps in a 252 | // job. You can specify named Docker volumes, anonymous Docker 253 | // volumes, or bind mounts on the host. 254 | // To specify a volume, you specify the source and destination 255 | // path: : 256 | // The is a volume name or an absolute path on the host 257 | // machine, and is an absolute path in the 258 | // container. 259 | volumes?: [...string] & [_, ...] 260 | 261 | // Additional Docker container resource options. For a list of 262 | // options, see 263 | // https://docs.docker.com/engine/reference/commandline/create/#options. 264 | options?: string 265 | }) 266 | 267 | #defaults: struct.MinFields(1) & close({ 268 | run?: struct.MinFields(1) & close({ 269 | shell?: #shell 270 | "working-directory"?: #."working-directory" 271 | }) 272 | }) 273 | 274 | // To set custom environment variables, you need to specify the 275 | // variables in the workflow file. You can define environment 276 | // variables for a step, job, or entire workflow using the 277 | // jobs..steps[*].env, jobs..env, and env 278 | // keywords. For more information, see 279 | // https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#jobsjob_idstepsenv 280 | #env: matchN(1, [{ 281 | [string]: bool | number | string 282 | }, #stringContainingExpressionSyntax]) 283 | 284 | // The environment that the job references 285 | #environment: close({ 286 | // The name of the environment configured in the repo. 287 | name!: string 288 | 289 | // A deployment URL 290 | url?: string 291 | }) 292 | 293 | #event: "branch_protection_rule" | "check_run" | "check_suite" | "create" | "delete" | "deployment" | "deployment_status" | "discussion" | "discussion_comment" | "fork" | "gollum" | "issue_comment" | "issues" | "label" | "merge_group" | "milestone" | "page_build" | "project" | "project_card" | "project_column" | "public" | "pull_request" | "pull_request_review" | "pull_request_review_comment" | "pull_request_target" | "push" | "registry_package" | "release" | "status" | "watch" | "workflow_call" | "workflow_dispatch" | "workflow_run" | "repository_dispatch" 294 | 295 | #eventObject: null | { 296 | ... 297 | } 298 | 299 | #expressionSyntax: =~""" 300 | ^\\$\\{\\{(.|[\r 301 | ])*\\}\\}$ 302 | """ 303 | 304 | #globs: [...strings.MinRunes(1)] & [_, ...] 305 | 306 | // Identifies any jobs that must complete successfully before this 307 | // job will run. It can be a string or array of strings. If a job 308 | // fails, all jobs that need it are skipped unless the jobs use a 309 | // conditional statement that causes the job to continue. 310 | #jobNeeds: matchN(1, [[...#name] & [_, ...], #name]) 311 | 312 | #machine: "linux" | "macos" | "windows" 313 | 314 | // A build matrix is a set of different configurations of the 315 | // virtual environment. For example you might run a job against 316 | // more than one supported version of a language, operating 317 | // system, or tool. Each configuration is a copy of the job that 318 | // runs and reports a status. 319 | // You can specify a matrix by supplying an array for the 320 | // configuration options. For example, if the GitHub virtual 321 | // environment supports Node.js versions 6, 8, and 10 you could 322 | // specify an array of those versions in the matrix. 323 | // When you define a matrix of operating systems, you must set the 324 | // required runs-on keyword to the operating system of the 325 | // current job, rather than hard-coding the operating system 326 | // name. To access the operating system name, you can use the 327 | // matrix.os context parameter to set runs-on. For more 328 | // information, see 329 | // https://help.github.com/en/articles/contexts-and-expression-syntax-for-github-actions. 330 | #matrix: matchN(1, [struct.MinFields(1) & { 331 | {[=~"^(in|ex)clude$"]: matchN(1, [#expressionSyntax, [...{ 332 | [string]: #configuration 333 | }] & [_, ...]]) 334 | } 335 | {[!~"^(in|ex)clude$" & !~"^()$"]: matchN(1, [[...#configuration] & [_, ...], #expressionSyntax])} 336 | }, #expressionSyntax]) 337 | 338 | #name: =~"^[_a-zA-Z][a-zA-Z0-9_-]*$" 339 | 340 | // Each job must have an id to associate with the job. The key 341 | // job_id is a string and its value is a map of the job's 342 | // configuration data. You must replace with a string 343 | // that is unique to the jobs object. The must start 344 | // with a letter or _ and contain only alphanumeric characters, 345 | // -, or _. 346 | #normalJob: close({ 347 | // The name of the job displayed on GitHub. 348 | name?: string 349 | needs?: #jobNeeds 350 | permissions?: #permissions 351 | 352 | // The type of machine to run the job on. The machine can be 353 | // either a GitHub-hosted runner, or a self-hosted runner. 354 | "runs-on"!: matchN(>=1, [string, [string, ...string] & [_, ...] & [...], { 355 | group?: string 356 | labels?: matchN(1, [string, [...string]]) 357 | ... 358 | }, #stringContainingExpressionSyntax, #expressionSyntax]) 359 | 360 | // The environment that the job references. 361 | environment?: matchN(1, [string, #environment]) 362 | 363 | // A map of outputs for a job. Job outputs are available to all 364 | // downstream jobs that depend on this job. 365 | outputs?: struct.MinFields(1) & { 366 | [string]: string 367 | } 368 | env?: #env 369 | defaults?: #defaults 370 | 371 | // You can use the if conditional to prevent a job from running 372 | // unless a condition is met. You can use any supported context 373 | // and expression to create a conditional. 374 | // Expressions in an if conditional do not require the ${{ }} 375 | // syntax. For more information, see 376 | // https://help.github.com/en/articles/contexts-and-expression-syntax-for-github-actions. 377 | if?: bool | number | string 378 | 379 | // A job contains a sequence of tasks called steps. Steps can run 380 | // commands, run setup tasks, or run an action in your 381 | // repository, a public repository, or an action published in a 382 | // Docker registry. Not all steps run actions, but all actions 383 | // run as a step. Each step runs in its own process in the 384 | // virtual environment and has access to the workspace and 385 | // filesystem. Because steps run in their own process, changes to 386 | // environment variables are not preserved between steps. GitHub 387 | // provides built-in steps to set up and complete a job. 388 | // Must contain either `uses` or `run` 389 | steps?: [...#step] & [_, ...] 390 | 391 | // The maximum number of minutes to let a workflow run before 392 | // GitHub automatically cancels it. Default: 360 393 | "timeout-minutes"?: matchN(1, [number, #expressionSyntax]) 394 | 395 | // A strategy creates a build matrix for your jobs. You can define 396 | // different variations of an environment to run each job in. 397 | strategy?: close({ 398 | matrix!: #matrix 399 | 400 | // When set to true, GitHub cancels all in-progress jobs if any 401 | // matrix job fails. Default: true 402 | "fail-fast"?: bool | string 403 | 404 | // The maximum number of jobs that can run simultaneously when 405 | // using a matrix job strategy. By default, GitHub will maximize 406 | // the number of jobs run in parallel depending on the available 407 | // runners on GitHub-hosted virtual machines. 408 | "max-parallel"?: number | string 409 | }) 410 | 411 | // Prevents a workflow run from failing when a job fails. Set to 412 | // true to allow a workflow run to pass when this job fails. 413 | "continue-on-error"?: matchN(1, [bool, #expressionSyntax]) 414 | 415 | // A container to run any steps in a job that don't already 416 | // specify a container. If you have steps that use both script 417 | // and container actions, the container actions will run as 418 | // sibling containers on the same network with the same volume 419 | // mounts. 420 | // If you do not set a container, all steps will run directly on 421 | // the host specified by runs-on unless a step refers to an 422 | // action configured to run in a container. 423 | container?: matchN(1, [string, #container]) 424 | 425 | // Additional containers to host services for a job in a workflow. 426 | // These are useful for creating databases or cache services like 427 | // redis. The runner on the virtual machine will automatically 428 | // create a network and manage the life cycle of the service 429 | // containers. 430 | // When you use a service container for a job or your step uses 431 | // container actions, you don't need to set port information to 432 | // access the service. Docker automatically exposes all ports 433 | // between containers on the same network. 434 | // When both the job and the action run in a container, you can 435 | // directly reference the container by its hostname. The hostname 436 | // is automatically mapped to the service name. 437 | // When a step does not use a container action, you must access 438 | // the service using localhost and bind the ports. 439 | services?: [string]: #container 440 | 441 | // Concurrency ensures that only a single job or workflow using 442 | // the same concurrency group will run at a time. A concurrency 443 | // group can be any string or expression. The expression can use 444 | // any context except for the secrets context. 445 | // You can also specify concurrency at the workflow level. 446 | // When a concurrent job or workflow is queued, if another job or 447 | // workflow using the same concurrency group in the repository is 448 | // in progress, the queued job or workflow will be pending. Any 449 | // previously pending job or workflow in the concurrency group 450 | // will be canceled. To also cancel any currently running job or 451 | // workflow in the same concurrency group, specify 452 | // cancel-in-progress: true. 453 | concurrency?: matchN(1, [string, #concurrency]) 454 | }) 455 | 456 | #path: #globs 457 | 458 | // You can modify the default permissions granted to the 459 | // GITHUB_TOKEN, adding or removing access as required, so that 460 | // you only allow the minimum required access. 461 | #permissions: matchN(1, ["read-all" | "write-all", #."permissions-event"]) 462 | 463 | #ref: matchN(1, [matchN(3, [matchN(0, [null | bool | number | string | [...] | { 464 | branches!: _ 465 | "branches-ignore"!: _ 466 | ... 467 | }]) & { 468 | ... 469 | }, matchN(0, [null | bool | number | string | [...] | { 470 | tags!: _ 471 | "tags-ignore"!: _ 472 | ... 473 | }]) & { 474 | ... 475 | }, matchN(0, [null | bool | number | string | [...] | { 476 | paths!: _ 477 | "paths-ignore"!: _ 478 | ... 479 | }]) & { 480 | ... 481 | }]), null]) & (null | { 482 | branches?: #branch 483 | "branches-ignore"?: #branch 484 | tags?: #branch 485 | "tags-ignore"?: #branch 486 | paths?: #path 487 | "paths-ignore"?: #path 488 | ... 489 | }) 490 | 491 | // Each job must have an id to associate with the job. The key 492 | // job_id is a string and its value is a map of the job's 493 | // configuration data. You must replace with a string 494 | // that is unique to the jobs object. The must start 495 | // with a letter or _ and contain only alphanumeric characters, 496 | // -, or _. 497 | #reusableWorkflowCallJob: close({ 498 | // The name of the job displayed on GitHub. 499 | name?: string 500 | needs?: #jobNeeds 501 | permissions?: #permissions 502 | 503 | // You can use the if conditional to prevent a job from running 504 | // unless a condition is met. You can use any supported context 505 | // and expression to create a conditional. 506 | // Expressions in an if conditional do not require the ${{ }} 507 | // syntax. For more information, see 508 | // https://help.github.com/en/articles/contexts-and-expression-syntax-for-github-actions. 509 | if?: bool | number | string 510 | 511 | // The location and version of a reusable workflow file to run as 512 | // a job, of the form './{path/to}/{localfile}.yml' or 513 | // '{owner}/{repo}/{path}/{filename}@{ref}'. {ref} can be a SHA, 514 | // a release tag, or a branch name. Using the commit SHA is the 515 | // safest for stability and security. 516 | uses!: =~"^(.+\\/)+(.+)\\.(ya?ml)(@.+)?$" 517 | with?: #env 518 | 519 | // When a job is used to call a reusable workflow, you can use 520 | // 'secrets' to provide a map of secrets that are passed to the 521 | // called workflow. Any secrets that you pass must match the 522 | // names defined in the called workflow. 523 | secrets?: matchN(1, [#env, "inherit"]) 524 | 525 | // A strategy creates a build matrix for your jobs. You can define 526 | // different variations of an environment to run each job in. 527 | strategy?: close({ 528 | matrix!: #matrix 529 | 530 | // When set to true, GitHub cancels all in-progress jobs if any 531 | // matrix job fails. Default: true 532 | "fail-fast"?: bool | string 533 | 534 | // The maximum number of jobs that can run simultaneously when 535 | // using a matrix job strategy. By default, GitHub will maximize 536 | // the number of jobs run in parallel depending on the available 537 | // runners on GitHub-hosted virtual machines. 538 | "max-parallel"?: number | string 539 | }) 540 | 541 | // Concurrency ensures that only a single job or workflow using 542 | // the same concurrency group will run at a time. A concurrency 543 | // group can be any string or expression. The expression can use 544 | // any context except for the secrets context. 545 | // You can also specify concurrency at the workflow level. 546 | // When a concurrent job or workflow is queued, if another job or 547 | // workflow using the same concurrency group in the repository is 548 | // in progress, the queued job or workflow will be pending. Any 549 | // previously pending job or workflow in the concurrency group 550 | // will be canceled. To also cancel any currently running job or 551 | // workflow in the same concurrency group, specify 552 | // cancel-in-progress: true. 553 | concurrency?: matchN(1, [string, #concurrency]) 554 | }) 555 | 556 | // You can override the default shell settings in the runner's 557 | // operating system using the shell keyword. You can use built-in 558 | // shell keywords, or you can define a custom set of shell 559 | // options. 560 | #shell: matchN(>=1, [string, "bash" | "pwsh" | "python" | "sh" | "cmd" | "powershell"]) 561 | 562 | #step: matchN(1, [{ 563 | uses!: _ 564 | ... 565 | }, { 566 | run!: _ 567 | ... 568 | }]) & close({ 569 | _t0="working-directory"?: _ 570 | if _t0 != _|_ { 571 | run!: _ 572 | } 573 | shell?: _ 574 | if shell != _|_ { 575 | run!: _ 576 | } 577 | {} 578 | 579 | // A unique identifier for the step. You can use the id to 580 | // reference the step in contexts. For more information, see 581 | // https://help.github.com/en/articles/contexts-and-expression-syntax-for-github-actions. 582 | id?: string 583 | 584 | // You can use the if conditional to prevent a step from running 585 | // unless a condition is met. You can use any supported context 586 | // and expression to create a conditional. 587 | // Expressions in an if conditional do not require the ${{ }} 588 | // syntax. For more information, see 589 | // https://help.github.com/en/articles/contexts-and-expression-syntax-for-github-actions. 590 | if?: bool | number | string 591 | 592 | // A name for your step to display on GitHub. 593 | name?: string 594 | 595 | // Selects an action to run as part of a step in your job. An 596 | // action is a reusable unit of code. You can use an action 597 | // defined in the same repository as the workflow, a public 598 | // repository, or in a published Docker container image 599 | // (https://hub.docker.com/). 600 | // We strongly recommend that you include the version of the 601 | // action you are using by specifying a Git ref, SHA, or Docker 602 | // tag number. If you don't specify a version, it could break 603 | // your workflows or cause unexpected behavior when the action 604 | // owner publishes an update. 605 | // - Using the commit SHA of a released action version is the 606 | // safest for stability and security. 607 | // - Using the specific major action version allows you to receive 608 | // critical fixes and security patches while still maintaining 609 | // compatibility. It also assures that your workflow should still 610 | // work. 611 | // - Using the master branch of an action may be convenient, but 612 | // if someone releases a new major version with a breaking 613 | // change, your workflow could break. 614 | // Some actions require inputs that you must set using the with 615 | // keyword. Review the action's README file to determine the 616 | // inputs required. 617 | // Actions are either JavaScript files or Docker containers. If 618 | // the action you're using is a Docker container you must run the 619 | // job in a Linux virtual environment. For more details, see 620 | // https://help.github.com/en/articles/virtual-environments-for-github-actions. 621 | uses?: string 622 | 623 | // Runs command-line programs using the operating system's shell. 624 | // If you do not provide a name, the step name will default to 625 | // the text specified in the run command. 626 | // Commands run using non-login shells by default. You can choose 627 | // a different shell and customize the shell used to run 628 | // commands. For more information, see 629 | // https://help.github.com/en/actions/automating-your-workflow-with-github-actions/workflow-syntax-for-github-actions#using-a-specific-shell. 630 | // Each run keyword represents a new process and shell in the 631 | // virtual environment. When you provide multi-line commands, 632 | // each line runs in the same shell. 633 | run?: string 634 | "working-directory"?: #."working-directory" 635 | "shell"?: #shell 636 | with?: #env 637 | env?: #env 638 | 639 | // Prevents a job from failing when a step fails. Set to true to 640 | // allow a job to pass when this step fails. 641 | "continue-on-error"?: matchN(1, [bool, #expressionSyntax]) 642 | 643 | // The maximum number of minutes to run the step before killing 644 | // the process. 645 | "timeout-minutes"?: matchN(1, [number, #expressionSyntax]) 646 | }) 647 | 648 | #stringContainingExpressionSyntax: =~""" 649 | ^.*\\$\\{\\{(.|[\r 650 | ])*\\}\\}.*$ 651 | """ 652 | 653 | // Selects the types of activity that will trigger a workflow run. 654 | // Most GitHub events are triggered by more than one type of 655 | // activity. For example, the event for the release resource is 656 | // triggered when a release is published, unpublished, created, 657 | // edited, deleted, or prereleased. The types keyword enables you 658 | // to narrow down activity that causes the workflow to run. When 659 | // only one activity type triggers a webhook event, the types 660 | // keyword is unnecessary. 661 | // You can use an array of event types. For more information about 662 | // each event and their activity types, see 663 | // https://help.github.com/en/articles/events-that-trigger-workflows#webhook-events. 664 | #types: matchN(1, [[_, ...], string]) 665 | 666 | // A string identifier to associate with the input. The value of 667 | // is a map of the input's metadata. The 668 | // must be a unique identifier within the inputs object. The 669 | // must start with a letter or _ and contain only 670 | // alphanumeric characters, -, or _. 671 | #workflowDispatchInput: matchN(5, [matchIf({ 672 | type!: "string" 673 | ... 674 | }, { 675 | default?: string 676 | ... 677 | }, _) & { 678 | ... 679 | }, matchIf({ 680 | type!: "boolean" 681 | ... 682 | }, { 683 | default?: bool 684 | ... 685 | }, _) & { 686 | ... 687 | }, matchIf({ 688 | type!: "number" 689 | ... 690 | }, { 691 | default?: number 692 | ... 693 | }, _) & { 694 | ... 695 | }, matchIf({ 696 | type!: "environment" 697 | ... 698 | }, { 699 | default?: string 700 | ... 701 | }, _) & { 702 | ... 703 | }, matchIf({ 704 | type!: "choice" 705 | ... 706 | }, { 707 | options!: _ 708 | ... 709 | }, _) & { 710 | ... 711 | }]) & close({ 712 | // A string description of the input parameter. 713 | description!: string 714 | 715 | // A string shown to users using the deprecated input. 716 | deprecationMessage?: string 717 | 718 | // A boolean to indicate whether the action requires the input 719 | // parameter. Set to true when the parameter is required. 720 | required?: bool 721 | 722 | // A string representing the default value. The default value is 723 | // used when an input parameter isn't specified in a workflow 724 | // file. 725 | default?: _ 726 | 727 | // A string representing the type of the input. 728 | type?: "string" | "choice" | "boolean" | "number" | "environment" 729 | 730 | // The options of the dropdown list, if the type is a choice. 731 | options?: [...string] & [_, ...] 732 | }) 733 | } 734 | -------------------------------------------------------------------------------- /.config/ci/release/release.sh: -------------------------------------------------------------------------------- 1 | tag=${GITHUB_REF##*/} 2 | if [[ "$tag" =~ -(alpha|beta)$ ]]; then 3 | echo "value=true" >> $GITHUB_OUTPUT 4 | else 5 | echo "value=false" >> $GITHUB_OUTPUT 6 | fi 7 | -------------------------------------------------------------------------------- /.config/ci/release/repo-install.sh: -------------------------------------------------------------------------------- 1 | nix develop --command gh release download "$TAG_NAME" \ 2 | --repo "$GITHUB_REPOSITORY" \ 3 | --pattern '*' \ 4 | --dir dist 5 | -------------------------------------------------------------------------------- /.config/ci/release/workflows.cue: -------------------------------------------------------------------------------- 1 | package release 2 | 3 | // Apply schema validation to all workflows 4 | workflows: [string]: #Workflow 5 | -------------------------------------------------------------------------------- /.config/common.ncl: -------------------------------------------------------------------------------- 1 | [ 2 | "git", 3 | "just", 4 | "typos", 5 | "coreutils", 6 | "b3sum", 7 | "nushell", 8 | 9 | # Rust compiler yay 10 | "cargo", 11 | "rustc", 12 | ] -------------------------------------------------------------------------------- /.config/dev.ncl: -------------------------------------------------------------------------------- 1 | [ 2 | "hyperfine", 3 | "jujutsu", 4 | "cue", 5 | "typos-lsp", 6 | "just-lsp", 7 | "taplo", 8 | "nickel", 9 | "jq", 10 | "xh", 11 | 12 | # Rust stuff 13 | "rust-analyzer", 14 | "rustfmt", 15 | "clippy", 16 | ] -------------------------------------------------------------------------------- /.config/nickel.lock.ncl: -------------------------------------------------------------------------------- 1 | { 2 | organist = import "/nix/store/fjxrgrx0s69m5vkss5ff1i5akjcx39ss-source/lib/organist.ncl", 3 | } 4 | -------------------------------------------------------------------------------- /.config/project.ncl: -------------------------------------------------------------------------------- 1 | let inputs = import "./nickel.lock.ncl" in 2 | let organist = inputs.organist in 3 | 4 | # Normalizer: accepts list of pkgs (strings/enums) -> record of nix imports 5 | let normalizePkgs = fun pkgs => 6 | std.array.fold_left 7 | (fun acc pkg => 8 | let pkgName = 9 | if std.is_string pkg then pkg 10 | else if std.is_enum pkg then std.enum.to_string pkg 11 | else std.abort "Unsupported package reference: %{pkg}" 12 | in 13 | acc & { "%{pkgName}" = organist.import_nix "nixpkgs#%{pkgName}" } 14 | ) 15 | {} 16 | pkgs 17 | in 18 | 19 | # external files 20 | let buildPkgs = import "./build.ncl" in 21 | let commonPkgs = import "./common.ncl" in 22 | let devPkgs = import "./dev.ncl" in 23 | 24 | organist.OrganistExpression 25 | & organist.tools.editorconfig 26 | & { 27 | Schema, 28 | config | Schema = { 29 | shells = organist.shells.Bash, 30 | 31 | shells.build = { 32 | packages = normalizePkgs buildPkgs & normalizePkgs commonPkgs, 33 | }, 34 | 35 | shells.dev = { 36 | packages = normalizePkgs devPkgs & normalizePkgs commonPkgs, 37 | }, 38 | 39 | editorconfig.sections = { 40 | "*".indent_style = 'tab, 41 | "*".insert_final_newline = false, 42 | }, 43 | }, 44 | } 45 | | organist.modules.T 46 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # Code generated by .config/ci/tools.cue; DO NOT EDIT. 2 | 3 | name: Release 4 | "on": 5 | push: 6 | tags: 7 | - '*' 8 | workflow_dispatch: {} 9 | defaults: 10 | run: 11 | shell: bash 12 | env: 13 | RUSTFLAGS: --deny warnings 14 | BINARY_NAME: infat 15 | jobs: 16 | prerelease: 17 | runs-on: ubuntu-latest 18 | outputs: 19 | value: ${{ steps.prerelease.outputs.value }} 20 | steps: 21 | - name: Prerelease Check 22 | id: prerelease 23 | run: | 24 | tag=${GITHUB_REF##*/} 25 | if [[ "$tag" =~ -(alpha|beta)$ ]]; then 26 | echo "value=true" >> $GITHUB_OUTPUT 27 | else 28 | echo "value=false" >> $GITHUB_OUTPUT 29 | fi 30 | package: 31 | strategy: 32 | fail-fast: false 33 | matrix: 34 | target: 35 | - aarch64-apple-darwin 36 | - x86_64-apple-darwin 37 | include: 38 | - target: aarch64-apple-darwin 39 | os: macos-latest 40 | - target: x86_64-apple-darwin 41 | os: macos-latest 42 | runs-on: ${{ matrix.os }} 43 | needs: 44 | - prerelease 45 | environment: 46 | name: main 47 | steps: 48 | - name: Checkout code 49 | uses: actions/checkout@v4 50 | - name: Install Nix 51 | uses: cachix/install-nix-action@v31 52 | with: 53 | extra_nix_config: access-tokens = github.com=${{ secrets.GITHUB_TOKEN }} 54 | - name: Cache Cargo registry and git 55 | uses: actions/cache@v4 56 | with: 57 | path: |- 58 | ~/.cargo/registry/index 59 | ~/.cargo/registry/cache 60 | ~/.cargo/git/db 61 | key: ${{ runner.os }}-cargo-registry-${{ hashFiles('Cargo.lock') }} 62 | restore-keys: ${{ runner.os }}-cargo-registry- 63 | - name: Build and Package 64 | run: nix develop --command just package ${{ matrix.target }} 65 | - name: Extract changelog for the tag 66 | run: nix develop --command just create-notes ${{ github.ref_name }} release_notes.md CHANGELOG.md 67 | - name: Publish Release 68 | uses: softprops/action-gh-release@v2 69 | if: startsWith(github.ref, 'refs/tags/') 70 | with: 71 | files: dist/${{ env.BINARY_NAME }}-${{ matrix.target }}.tar.gz 72 | body_path: release_notes.md 73 | draft: false 74 | make_latest: true 75 | prerelease: ${{ needs.prerelease.outputs.value }} 76 | token: ${{ secrets.PAT }} 77 | checksum: 78 | runs-on: ubuntu-latest 79 | needs: 80 | - package 81 | - prerelease 82 | if: startsWith(github.ref, 'refs/tags/') 83 | environment: 84 | name: main 85 | steps: 86 | - name: Install Nix 87 | uses: cachix/install-nix-action@v31 88 | with: 89 | extra_nix_config: access-tokens = github.com=${{ secrets.GITHUB_TOKEN }} 90 | - name: Download Release Archives 91 | env: 92 | GH_TOKEN: ${{ secrets.PAT }} 93 | TAG_NAME: ${{ github.ref_name }} 94 | run: | 95 | nix develop --command gh release download "$TAG_NAME" \ 96 | --repo "$GITHUB_REPOSITORY" \ 97 | --pattern '*' \ 98 | --dir dist 99 | - name: Generate Checksums 100 | run: nix develop --command just checksum dist 101 | - name: Publish Checksums 102 | uses: softprops/action-gh-release@v2 103 | with: 104 | files: dist/*.sum 105 | draft: false 106 | prerelease: ${{ needs.prerelease.outputs.value }} 107 | token: ${{ secrets.PAT }} 108 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | .editorconfig 3 | nickel.lock.ncl 4 | dist 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 5 | 6 | ## [Unreleased] 7 | 8 | ## [3.0.3] – 2025-09-29 9 | ### Changed 10 | - Replaced emojis with devicons 11 | 12 | ### Fixed 13 | - Workflow action hopefully?... 14 | 15 | ## [3.0.2] – 2025-09-29 16 | ### Added 17 | - Configuration handling now prefers **XDG-compliant directories** over traditional fallback locations, ensuring consistent behavior across environments. 18 | - Handles `XDG_CONFIG_HOME` correctly when set. 19 | - Provides explicit error guidance if no configuration path is derivable, prompting users to set `XDG_CONFIG_HOME`. 20 | - Automatically falls back to platforms’ default configuration directories (e.g., `~/.config`, `~/Library/Application Support`). 21 | 22 | ### Changed 23 | - Internal `get_config_paths` and `find_config_file` APIs now return `Result` types instead of raw values, making error handling explicit. 24 | - GitHub CI release workflow updated to use **softprops/action-gh-release@v2.3** (was `@v5`), ensuring compatibility with maintained versions. 25 | 26 | ## [3.0.1] – 2025-09-26 27 | ### Changed 28 | - The justfile was updated with better organization, formatting, and documentation. 29 | 30 | ### Added 31 | - A new build and CI pipeline using Nix, CUE, and Just, which includes fixes for version and script errors. 32 | - --scheme option to the info command to get information about URL schemes. 33 | - clap for command-line argument parsing, including shell completion generation. 34 | - Linters like clippy and typos to improve code quality. 35 | - Support for more supertypes in uti.rs. 36 | - dist/ directory added to .gitignore. 37 | 38 | ### Fixed 39 | - Various typos in the codebase and documentation. 40 | - Corrected an issue with an inaccurate environment variable for the binary name in the build script. 41 | - Improved bundle ID canonicalization and input validation. 42 | - Improved handling of symlinks. 43 | 44 | ## [3.0.0] – 2025-09-25 45 | ### Changed 46 | - Complete rewrite to Rust! 47 | 48 | ### Added 49 | - `--scheme` option for the info subcommand 50 | 51 | ## [2.5.2] – 2025-06-21 52 | 53 | ### Added 54 | - System service detection and graceful handling during initialization 55 | - Applications identified as system services are now automatically skipped with informational logging 56 | - Prevents processing errors when encountering macOS system services during app discovery 57 | 58 | ### Changed 59 | - Updated `.gitignore` to exclude `dist` directory from version control 60 | 61 | ## [2.5.1] – 2025-06-20 62 | 63 | ### Added 64 | - Support for multiple supertype initialization schemes, allowing identifiers not in the enum to be processed correctly 65 | - Enhanced type key processing that can handle both UTType-based and raw value-based supertype initialization 66 | - Automatic routing of HTML and web browser types to HTTP URL handlers for improved web application handling 67 | - Conditional availability checks for newer UTTypes (DNG, EXR, JPEG XL, TAR archives) with proper fallback for older macOS versions 68 | 69 | ### Changed 70 | - Improved type association logic in ConfigManager to be more flexible with type identifiers 71 | - Updated minimum macOS requirement from 15.2 to 13.0 for broader compatibility 72 | - Refactored supertype mapping to use conditional compilation for macOS version-specific types 73 | - Enhanced error handling for unsupported or invalid supertypes 74 | 75 | ### Fixed 76 | - Corrected dictionary key-value mapping in Init command for schemes, types, and extensions (was previously reversed) 77 | - Resolved issues with HTML and HTTPS type handling by implementing proper HTTP scheme routing 78 | 79 | ## [2.5.0] – 2025-06-19 80 | 81 | ### Added 82 | - New `init` command to bootstrap a declarative TOML config from the system’s LaunchServices settings, emitting nested 83 | tables `[extensions]`, `[schemes]` and `[types]`. 84 | - `GlobalOptions` struct and a `--robust` flag to continue on missing‐application errors (with warnings). 85 | - Two new `InfatError` variants: 86 | - `superTypeMissing(intendedType: UTType)` when a UTI’s supertype isn’t in the known bundle 87 | - `invalidBundle(bundle: String, app: String)` for corrupted/invalid app bundles 88 | - A comprehensive expansion of the `Supertypes` enum with dozens of new UTType conformances (e.g. RealMedia, IINA, Matroska, 89 | Xiph formats, Asciidoc, Markdown, AC3/AAC audio, DV, MP2, USD/USDC, fonts, certificates, 3D scene formats, etc.), each mapping 90 | to its `UTType` identifier 91 | - Helper `getAppName(from:)` to resolve bundle identifiers to human-readable application names 92 | 93 | ### Changed 94 | - Switched from `swift-toml` to **TOMLKit** for all TOML parsing and encoding 95 | - Refactored `ConfigManager` and CLI subcommands (`set`, `init`) to consume `GlobalOptions` via `@OptionGroup` and respect 96 | the `--robust` flag 97 | - Updated `Package.swift`: bumped macOS deployment target to **15.2** and added `TOMLKit` dependency 98 | - Improved file-system utilities and error handling in `getAppName` and path expansions; unified error propagation 99 | - Consolidated global flags `--verbose`, `--quiet`, `--config` under `GlobalOptions` 100 | - Enhanced TOML processing in `ConfigManager`: guard against missing tables, validate that values are strings, and emit clearer 101 | error messages 102 | 103 | ### Fixed 104 | - Corrected guard logic in the `init` command to skip malformed entries (`LSHandlerRoleAll == "-"`) and missing extensions 105 | - Fixed a type mismatch for `LSHandlerModificationDate` in the LaunchServices decoder 106 | - Removed trailing commas that were causing Swift compilation errors 107 | - Resolved string‐concatenation and formatting issues in log messages and TOML output 108 | 109 | 110 | ## [2.4.0] – 2025-05-22 111 | 112 | ### Added 113 | - Introduce a new `--robust` flag to the `infat set` command and configuration loader. 114 | When enabled, missing applications are no longer treated as errors; instead you’ll see 115 | a clear warning: 116 | ``` 117 | Application '' not found but ignoring due to passed options 118 | ``` 119 | - Add a `format` target to the Justfile (`just format`), which runs `swift-format` on all 120 | `.swift` source files for consistent code styling. 121 | 122 | ## [2.3.4] – 2025-04-29 123 | 124 | ### Changed 125 | - Loading order. Now goes Types -> Extensions -> Schemes. 126 | 127 | ## [2.3.3] – 2025-04-29 128 | 129 | ### Added 130 | - Support XDG Base Directory spec for configuration file search: respect 131 | `XDG_CONFIG_HOME` (default `~/.config/infat/config.toml`) and 132 | `XDG_CONFIG_DIRS` (default `/etc/xdg/infat/config.toml`). 133 | - Add a GitHub Actions **homebrew** job to automatically bump the Homebrew 134 | formula on tagged releases. 135 | 136 | ### Changed 137 | - Refactor Zsh, Bash and Fish completion scripts to use the official file-type 138 | list and improve argument parsing. 139 | - Update README: 140 | - Change Homebrew installation to `brew install infat`. 141 | - Add instructions for manual generation of shell completions until the formula 142 | supports them. 143 | - Update `.github/workflows/release.yml` to integrate the Homebrew bump step. 144 | 145 | ### Fixed 146 | - Correct README misdocumentation by updating the list of supported file supertypes. 147 | 148 | ## [2.3.2] – 2025-04-27 149 | 150 | ### Fixed 151 | - Set `overwrite: true` in the GitHub Actions release workflow to ensure existing releases can be replaced. 152 | - Refine the `just check` recipe to ignore `CHANGELOG*`, `README*`, `Package*` files and the `.build` directory when scanning for version patterns. 153 | - Update the `compress-binaries` recipe in Justfile so that archives 154 | - strip version suffixes from file names 155 | - use only the base filename when creating the `.tar.gz` 156 | 157 | ## [2.3.1] – 2025-04-27 158 | 159 | ### Changed 160 | - Print success messages in italic formatting for `infat set` commands (file, scheme, and supertype bindings). 161 | - Clarify README instructions: allow user-relative paths via `~` and note that shell expansions are not supported. 162 | 163 | ### Fixed 164 | - Remove duplicate `run` step in the GitHub Actions `release.yml` workflow. 165 | 166 | ## [2.3.0] – 2025-04-27 167 | 168 | ### Added 169 | - compress-binaries: use the project’s `infat` name to create `.tar.gz` archives with un-versioned internal filenames 170 | - AssociationManager: support relative paths (tilde expansion) and file:// URLs in `findApplication(named:)` 171 | 172 | ### Changed 173 | - restyled `justfile` with decorative section markers and switched to `/`-based path concatenation for clarity 174 | 175 | ### Fixed 176 | - release workflow: replaced `overwrite: true` with `make_latest: true` to correctly mark the latest GitHub release 177 | - prerelease check in Actions: now properly detects `-alpha` and `-beta` tags 178 | 179 | ## [2.2.0] – 2025-04-26 180 | 181 | ### Added 182 | - Introduce ColorizeSwift (v1.5.0) as a new dependency for rich terminal styling 183 | - Reroute `.html` file‐extension and `https` URL‐scheme inputs to the HTTP handler 184 | - Support colorized output styling via `.bold()` and `.underline()` in `ConfigManager` 185 | 186 | ### Changed 187 | - Replace custom ANSI escape‐sequence constants with ColorizeSwift’s `.bold()` and `.underline()` methods 188 | - Docs updates: 189 | - Clarify application name casing and optional `.app` suffix in README 190 | - Expand configuration section to cover three TOML tables and XDG_CONFIG_HOME usage 191 | - Correct CLI usage examples, header numbering, typos, and outdated information 192 | 193 | ## [2.1.0] – 2025-04-26 194 | 195 | ### Added 196 | - Justfile 197 | Introduce a `check` task that prompts you to confirm version bumps in the README, Swift bundle and CHANGELOG. 198 | - Commands 199 | Print a success message when an application is bound to a file extension or URL scheme. 200 | - FileSystemUtilities 201 | Include `/System/Library/CoreServices/Applications/` in the list of search paths for installed apps. 202 | - AssociationManager 203 | Add a fallback for `setDefaultApplication` failures: if `NSWorkspace.setDefaultApplication` is restricted, catch the error and invoke `LSSetDefaultRoleHandlerForContentType` directly. 204 | 205 | ### Changed 206 | - Package.swift 207 | 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). 208 | - AssociationManager 209 | Refactor application lookup into a throwing `findApplication(named:)`, supporting both file paths (or file:// URLs) and plain `.app` names (case-insensitive). 210 | - FileSystemUtilities 211 | Downgrade log level for unreadable paths from **warning** to **debug** to reduce noise. 212 | 213 | ## [2.0.1] – 2025-04-25 214 | 215 | ### Added 216 | - Support for cascading “blanket” types: use `infat set --type ` to 217 | assign openers for base types (e.g. `plain-text`); introduced a new 218 | `[types]` table in the TOML schema. 219 | - Explicit handling when no config is provided or found: Infat now prints 220 | an informative prompt and throws `InfatError.missingOption` if neither 221 | `--config` nor `$XDG_CONFIG_HOME/infat/config.toml` exist. 222 | 223 | ### Changed 224 | - Bumped CLI version to **2.0.1** and updated the abstract to 225 | “Declaratively set associations for URLs and files.” 226 | - Revised README examples and docs: 227 | - Renamed `infat list` → `infat info` 228 | - Changed flag `--file-type` → `--ext` 229 | - Renumbered tutorial steps and cleaned up formatting 230 | - Updated TOML example: `[files]` → `[extensions]` 231 | 232 | ### Fixed 233 | - Quiet mode now logs at `warning` (was `error`), preventing silent failures. 234 | 235 | ## [2.0.0] – 2025-04-25 236 | 237 | ### Added 238 | - 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. 239 | - 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`). 240 | - Add the `plain-text` supertype (mapped to `UTType.plainText`) to the set of supported conformances. 241 | - Render configuration section headings (`[extensions]`, `[types]`, `[schemes]`) in bold & underlined text when processing TOML files. 242 | 243 | ### Changed 244 | - 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. 245 | - Change the default logging level for verbose mode from `debug` to `trace`. 246 | - 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. 247 | 248 | ### Deprecated 249 | - Rename the `List` command to `Info`. 250 | - Renamed files table to extensions to match with cli options 251 | 252 | ## [1.3.0] – 2025-04-25 253 | 254 | ### Added 255 | - `--app` option to `infat list` for listing document types handled by a given application. 256 | - New `InfatError.conflictingOptions` to enforce that exactly one of `--app` or `--ext` is provided. 257 | - Enhanced UTI-derivation errors via `InfatError.couldNotDeriveUTI`, including the offending extension in the message. 258 | 259 | ### Changed 260 | - Refactored the `list` command to use two exclusive `@Option` parameters (`--app`, `--ext`) with XOR validation. 261 | - Switched PList parsing to `DictionaryPList(url:)` and UTI lookup to `UTType(filenameExtension:)`. 262 | - Replaced ad-hoc `print` calls with `logger.info` for consistent, leveled logging. 263 | - Renamed `deriveUTIFromExtension(extension:)` to `deriveUTIFromExtension(ext:)` for clarity and consistency. 264 | 265 | ### Fixed 266 | - Corrected typos in `FileSystemUtilities.deriveUTIFromExtension` signature and related debug messages. 267 | - Fixed `FileManager` existence checks for `Info.plist` by using the correct `path` property. 268 | - Resolved parsing discrepancies in `listTypesForApp` to ensure accurate reading of `CFBundleDocumentTypes`. 269 | 270 | ## [1.2.0] – 2025-04-25 271 | 272 | ### Fixed 273 | - Swift badge reflects true version 274 | 275 | ### Changed 276 | - Using function overloading to set default application based on Uttype or extension 277 | 278 | ### Deprecated 279 | - Removed --associations option in list into the basic list command 280 | - Filetype option, now ext. 281 | 282 | ### Added 283 | - A supertype conformance enum 284 | - Class option in CLI and Config 285 | 286 | ## [1.1.0] – 2025-04-24 287 | 288 | ### Added 289 | - Add MIT License (`LICENSE`) under the MIT terms. 290 | - Justfile enhancements: 291 | - Require `just` (command-runner) in documentation. 292 | - Introduce a `package` recipe (`just package`) to build and bundle release binaries. 293 | - Detect `current_platform` dynamically via `uname -m`. 294 | - GitHub Actions release workflow: enable `overwrite: true` in `release.yml`. 295 | 296 | ### Changed 297 | - Migrate `setDefaultApplication` and `ConfigManager.loadConfig` to async/await; remove semaphore-based callbacks. 298 | - Simplify UTI resolution by passing `typeIdentifier` directly. 299 | - Documentation updates: 300 | - Clarify README summary and usage examples for file-type and URL-scheme associations. 301 | - Revamp badges and stylistic copy (“ultra-powerful” intro, more user-friendly tone). 302 | - Streamline source installation instructions (use `just package` and wildcard install). 303 | 304 | ### Fixed 305 | - Remove redundant separators in README. 306 | 307 | ## [1.0.0] - 2025-04-24 308 | 309 | ### Added 310 | - Support for URL‐scheme associations in the `set` command via a new `--scheme` option 311 | - `InfatError.conflictingOptions` error case to enforce mutual exclusion of `--file-type` and `--scheme` 312 | - Unified binding functionality—`set` now handles both file‐type and URL‐scheme associations, replacing the standalone `bind` command 313 | 314 | ### Changed 315 | - Merged the former `bind` subcommand into `set` and switched its parameters from positional arguments to named options 316 | - Updated the `justfile` changelog target to use a top‐level `# What's new` header instead of `## Changes` 317 | 318 | ### Removed 319 | - Removed the standalone `Bind` subcommand and its `Bind.swift` implementation 320 | - Removed the `Info` subcommand (and `Info.swift`), which previously displayed system information 321 | 322 | ## [0.6.0] - 2025-04-24 323 | 324 | ### Added 325 | * Homebrew support 326 | 327 | ## [0.5.3] - 2025-04-24 328 | 329 | ### Fixed 330 | * Typos in CI 331 | 332 | ## [0.5.2] - 2025-04-24 333 | 334 | ### Fixed 335 | * Justfile platform targeting for the CI 336 | 337 | ## [0.5.1] - 2025-04-24 338 | 339 | ### Fixed 340 | * Fixed logging to print diff in List command. 341 | * Fixed Swift version in release workflow to a specific version instead of 'latest'. 342 | 343 | ## [0.5.0] – 2025-04-24 344 | 345 | ### Fixed 346 | * Wrong swift toolchain action 347 | 348 | ## [0.4.0] – 2025-04-24 349 | 350 | ### Added 351 | * Config support for schemes 352 | * Bind subcommand to set URL scheme associations 353 | * GitHub workflow for automated releases 354 | * `create-notes` just recipe to extract changelog entries for release notes 355 | 356 | ### Changed 357 | * Moved app name resolution logic to a function for better reusability 358 | * Changed argument order in `setURLHandler` function 359 | * Optimized Swift release flags for better performance 360 | * Updated changelog to reflect the current state of the project 361 | 362 | ### Deprecated 363 | * Associations table in config; it has been replaced by separate tables for files and schemes 364 | 365 | ### Fixed 366 | * Logic in the Bind command to correctly handle application URL resolution and error handling 367 | 368 | ## [0.3.0] – 2025-04-23 369 | 370 | ### Added 371 | - Support loading configuration from the XDG config directory (`$XDG_CONFIG_HOME/infat/config.toml`) when no `--config` flag is supplied. 372 | - Add a `Justfile` with curated recipes for: 373 | - building (debug / release) 374 | - running (debug / release) 375 | - packaging and compressing binaries 376 | - generating checksums 377 | - installing (and force-installing) 378 | - cleaning and updating dependencies 379 | 380 | ## [0.2.0] - 2025-04-22 381 | 382 | ### Added 383 | - Initial project setup with basic command structure (`list`, `set`, `info`) using `swift-argument-parser`. 384 | - Structured logging using `swift-log`. 385 | - Custom error handling via the `InfatError` enum for specific error conditions. 386 | - Defined `FileUTIInfo` struct for holding Uniform Type Identifier data. 387 | - Added utility `findApplications` to locate application bundles in standard macOS directories. 388 | - Added utility `deriveUTIFromExtension` to get UTI info from file extensions, requiring macOS 11.0+. 389 | - Added utility `getBundleName` to extract bundle identifiers from application `Info.plist`. 390 | - Implemented `list` subcommand to show default or all registered applications for a file type, using the logger for output. 391 | - Implemented `set` subcommand to associate a file type with a specified application. 392 | - Implemented `info` subcommand to display details about the current frontmost application. 393 | - 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"). 394 | - Added dependencies: `PListKit` (for `Info.plist` parsing) and `swift-toml` (for configuration file parsing). 395 | - Added shell completion scripts for Zsh, Bash, and Fish. 396 | - Added comprehensive `README.md` documentation detailing features, usage, installation, and dependencies, with corrected links. 397 | 398 | ### Changed 399 | - Renamed project, executable target, and main command struct from "bart"/"WorkspaceTool" to "infat". 400 | - Refactored codebase from a single `main.swift` into multiple files (`Commands`, `Utilities`, `Managers`, `Error`, etc.) for better organization and readability. 401 | - Updated the tool's abstract description for better clarity. 402 | - Improved the output formatting of the `list` command for enhanced readability. 403 | - Refactored `list` command options (using `--assigned` flag instead of `--all`, requiring identifier argument) and improved help descriptions. 404 | - 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. 405 | - Made the global logger instance mutable (`var`) to allow runtime log level configuration based on flags. 406 | - Created a reusable `setDefaultApplication` function to avoid code duplication between the `set` command and configuration loading logic. 407 | - Significantly enhanced error handling with more specific `InfatError` cases (e.g., plist reading, timeout, configuration errors) and improved logging messages throughout the application. 408 | - Implemented a 10-second timeout for the asynchronous `setDefaultApplication` operation using `DispatchSemaphore`. 409 | - Updated `findApplications` utility to search `/System/Applications` in addition to other standard paths and use modern Swift API for home directory path resolution. 410 | - Switched from using UTI strings to `UTType` objects within `FileUTIInfo` and related functions for better type safety and access to UTI properties. 411 | - Updated `README.md` content, added TOML configuration documentation, and noted `set` command status (reflecting commit `1ec6358`). 412 | - Set the minimum required macOS deployment target to 13.0 in `Package.swift`. 413 | - Renamed the `set` command argument from `mimeType` to `fileType` for clarity. 414 | - Updated the main command struct (`Infat`) and removed the redundant explicit `--version` flag (ArgumentParser provides this by default). 415 | - Added `Package.resolved` to `.gitignore`. 416 | 417 | ### Fixed 418 | - Corrected the bundle ID used internally and for logging from `com.example.burt` to `com.philocalyst.infat`. 419 | - Addressed minor code formatting inconsistencies across several files. 420 | 421 | --- 422 | 423 | [Unreleased]: https://github.com/your-org/your-repo/compare/v3.0.3...HEAD 424 | [3.0.3]: https://github.com/your-org/your-repo/compare/v3.0.2...v3.0.3 425 | [3.0.2]: https://github.com/your-org/your-repo/compare/v3.0.1...v3.0.2 426 | [3.0.1]: https://github.com/philocalyst/infat/compare/v3.0.0…v3.0.1 427 | [3.0.0]: https://github.com/philocalyst/infat/compare/v2.5.2…v3.0.0 428 | [2.5.2]: https://github.com/philocalyst/infat/compare/v2.5.1…v2.5.2 429 | [2.5.1]: https://github.com/philocalyst/infat/compare/v2.5.0…v2.5.1 430 | [2.5.0]: https://github.com/philocalyst/infat/compare/v2.4.0…v2.5.0 431 | [2.4.0]: https://github.com/philocalyst/infat/compare/v2.3.4...v2.4.0 432 | [2.3.3]: https://github.com/philocalyst/infat/compare/v2.3.2...v2.3.3 433 | [2.3.2]: https://github.com/philocalyst/infat/compare/v2.3.1...v2.3.2 434 | [2.3.1]: https://github.com/philocalyst/infat/compare/v2.3.0...v2.3.1 435 | [2.3.0]: https://github.com/philocalyst/infat/compare/v2.2.0...v2.3.0 436 | [2.2.0]: https://github.com/philocalyst/infat/compare/v2.1.0...v2.2.0 437 | [2.1.0]: https://github.com/philocalyst/infat/compare/v2.0.1...v2.1.0 438 | [2.0.1]: https://github.com/philocalyst/infat/compare/v2.0.0...v2.0.1 439 | [2.0.0]: https://github.com/philocalyst/infat/compare/v1.3.0...v2.0.0 440 | [1.3.0]: https://github.com/philocalyst/infat/compare/v1.2.0...v1.3.0 441 | [1.2.0]: https://github.com/philocalyst/infat/compare/v1.1.0...v1.2.0 442 | [1.1.0]: https://github.com/philocalyst/infat/compare/v1.0.0...v1.1.0 443 | [1.0.0]: https://github.com/philocalyst/infat/compare/v0.6.0...v1.0.0 444 | [0.6.0]: https://github.com/philocalyst/infat/compare/v0.5.3...v0.6.0 445 | [0.5.3]: https://github.com/philocalyst/infat/compare/v0.5.2...v0.5.3 446 | [0.5.2]: https://github.com/philocalyst/infat/compare/v0.5.1...v0.5.2 447 | [0.5.1]: https://github.com/philocalyst/infat/compare/v0.5.0...v0.5.1 448 | [0.5.0]: https://github.com/philocalyst/infat/compare/v0.4.0...v0.5.0 449 | [0.4.0]: https://github.com/philocalyst/infat/compare/v0.3.0...v0.4.0 450 | [0.3.0]: https://github.com/philocalyst/infat/compare/v0.2.0...v0.3.0 451 | [0.2.0]: https://github.com/philocalyst/infat/compare/63822faf94def58bf347f8be4983e62da90383bb...d32aec000bf040c48887f104decf4a9736aea78b (Comparing against the start of the project) 452 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "addr2line" 7 | version = "0.24.2" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" 10 | dependencies = [ 11 | "gimli", 12 | ] 13 | 14 | [[package]] 15 | name = "adler2" 16 | version = "2.0.1" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" 19 | 20 | [[package]] 21 | name = "aho-corasick" 22 | version = "1.1.3" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" 25 | dependencies = [ 26 | "memchr", 27 | ] 28 | 29 | [[package]] 30 | name = "anstream" 31 | version = "0.6.20" 32 | source = "registry+https://github.com/rust-lang/crates.io-index" 33 | checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" 34 | dependencies = [ 35 | "anstyle", 36 | "anstyle-parse", 37 | "anstyle-query", 38 | "anstyle-wincon", 39 | "colorchoice", 40 | "is_terminal_polyfill", 41 | "utf8parse", 42 | ] 43 | 44 | [[package]] 45 | name = "anstyle" 46 | version = "1.0.11" 47 | source = "registry+https://github.com/rust-lang/crates.io-index" 48 | checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" 49 | 50 | [[package]] 51 | name = "anstyle-parse" 52 | version = "0.2.7" 53 | source = "registry+https://github.com/rust-lang/crates.io-index" 54 | checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" 55 | dependencies = [ 56 | "utf8parse", 57 | ] 58 | 59 | [[package]] 60 | name = "anstyle-query" 61 | version = "1.1.4" 62 | source = "registry+https://github.com/rust-lang/crates.io-index" 63 | checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" 64 | dependencies = [ 65 | "windows-sys 0.60.2", 66 | ] 67 | 68 | [[package]] 69 | name = "anstyle-wincon" 70 | version = "3.0.10" 71 | source = "registry+https://github.com/rust-lang/crates.io-index" 72 | checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" 73 | dependencies = [ 74 | "anstyle", 75 | "once_cell_polyfill", 76 | "windows-sys 0.60.2", 77 | ] 78 | 79 | [[package]] 80 | name = "backtrace" 81 | version = "0.3.75" 82 | source = "registry+https://github.com/rust-lang/crates.io-index" 83 | checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" 84 | dependencies = [ 85 | "addr2line", 86 | "cfg-if", 87 | "libc", 88 | "miniz_oxide", 89 | "object", 90 | "rustc-demangle", 91 | "windows-targets 0.52.6", 92 | ] 93 | 94 | [[package]] 95 | name = "base64" 96 | version = "0.22.1" 97 | source = "registry+https://github.com/rust-lang/crates.io-index" 98 | checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" 99 | 100 | [[package]] 101 | name = "bitflags" 102 | version = "2.9.4" 103 | source = "registry+https://github.com/rust-lang/crates.io-index" 104 | checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" 105 | 106 | [[package]] 107 | name = "block" 108 | version = "0.1.6" 109 | source = "registry+https://github.com/rust-lang/crates.io-index" 110 | checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" 111 | 112 | [[package]] 113 | name = "cfg-if" 114 | version = "1.0.3" 115 | source = "registry+https://github.com/rust-lang/crates.io-index" 116 | checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" 117 | 118 | [[package]] 119 | name = "clap" 120 | version = "4.5.48" 121 | source = "registry+https://github.com/rust-lang/crates.io-index" 122 | checksum = "e2134bb3ea021b78629caa971416385309e0131b351b25e01dc16fb54e1b5fae" 123 | dependencies = [ 124 | "clap_builder", 125 | "clap_derive", 126 | ] 127 | 128 | [[package]] 129 | name = "clap_builder" 130 | version = "4.5.48" 131 | source = "registry+https://github.com/rust-lang/crates.io-index" 132 | checksum = "c2ba64afa3c0a6df7fa517765e31314e983f51dda798ffba27b988194fb65dc9" 133 | dependencies = [ 134 | "anstream", 135 | "anstyle", 136 | "clap_lex", 137 | "strsim", 138 | ] 139 | 140 | [[package]] 141 | name = "clap_complete" 142 | version = "4.5.58" 143 | source = "registry+https://github.com/rust-lang/crates.io-index" 144 | checksum = "75bf0b32ad2e152de789bb635ea4d3078f6b838ad7974143e99b99f45a04af4a" 145 | dependencies = [ 146 | "clap", 147 | ] 148 | 149 | [[package]] 150 | name = "clap_derive" 151 | version = "4.5.47" 152 | source = "registry+https://github.com/rust-lang/crates.io-index" 153 | checksum = "bbfd7eae0b0f1a6e63d4b13c9c478de77c2eb546fba158ad50b4203dc24b9f9c" 154 | dependencies = [ 155 | "heck", 156 | "proc-macro2", 157 | "quote", 158 | "syn", 159 | ] 160 | 161 | [[package]] 162 | name = "clap_lex" 163 | version = "0.7.5" 164 | source = "registry+https://github.com/rust-lang/crates.io-index" 165 | checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" 166 | 167 | [[package]] 168 | name = "color-eyre" 169 | version = "0.6.5" 170 | source = "registry+https://github.com/rust-lang/crates.io-index" 171 | checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d" 172 | dependencies = [ 173 | "backtrace", 174 | "color-spantrace", 175 | "eyre", 176 | "indenter", 177 | "once_cell", 178 | "owo-colors", 179 | "tracing-error", 180 | ] 181 | 182 | [[package]] 183 | name = "color-spantrace" 184 | version = "0.3.0" 185 | source = "registry+https://github.com/rust-lang/crates.io-index" 186 | checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427" 187 | dependencies = [ 188 | "once_cell", 189 | "owo-colors", 190 | "tracing-core", 191 | "tracing-error", 192 | ] 193 | 194 | [[package]] 195 | name = "colorchoice" 196 | version = "1.0.4" 197 | source = "registry+https://github.com/rust-lang/crates.io-index" 198 | checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" 199 | 200 | [[package]] 201 | name = "core-foundation" 202 | version = "0.9.4" 203 | source = "registry+https://github.com/rust-lang/crates.io-index" 204 | checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" 205 | dependencies = [ 206 | "core-foundation-sys", 207 | "libc", 208 | ] 209 | 210 | [[package]] 211 | name = "core-foundation-sys" 212 | version = "0.8.7" 213 | source = "registry+https://github.com/rust-lang/crates.io-index" 214 | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 215 | 216 | [[package]] 217 | name = "deranged" 218 | version = "0.5.3" 219 | source = "registry+https://github.com/rust-lang/crates.io-index" 220 | checksum = "d630bccd429a5bb5a64b5e94f693bfc48c9f8566418fda4c494cc94f911f87cc" 221 | dependencies = [ 222 | "powerfmt", 223 | ] 224 | 225 | [[package]] 226 | name = "dirs" 227 | version = "5.0.1" 228 | source = "registry+https://github.com/rust-lang/crates.io-index" 229 | checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" 230 | dependencies = [ 231 | "dirs-sys", 232 | ] 233 | 234 | [[package]] 235 | name = "dirs-sys" 236 | version = "0.4.1" 237 | source = "registry+https://github.com/rust-lang/crates.io-index" 238 | checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" 239 | dependencies = [ 240 | "libc", 241 | "option-ext", 242 | "redox_users", 243 | "windows-sys 0.48.0", 244 | ] 245 | 246 | [[package]] 247 | name = "equivalent" 248 | version = "1.0.2" 249 | source = "registry+https://github.com/rust-lang/crates.io-index" 250 | checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 251 | 252 | [[package]] 253 | name = "eyre" 254 | version = "0.6.12" 255 | source = "registry+https://github.com/rust-lang/crates.io-index" 256 | checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" 257 | dependencies = [ 258 | "indenter", 259 | "once_cell", 260 | ] 261 | 262 | [[package]] 263 | name = "getrandom" 264 | version = "0.2.16" 265 | source = "registry+https://github.com/rust-lang/crates.io-index" 266 | checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" 267 | dependencies = [ 268 | "cfg-if", 269 | "libc", 270 | "wasi", 271 | ] 272 | 273 | [[package]] 274 | name = "gimli" 275 | version = "0.31.1" 276 | source = "registry+https://github.com/rust-lang/crates.io-index" 277 | checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" 278 | 279 | [[package]] 280 | name = "hashbrown" 281 | version = "0.16.0" 282 | source = "registry+https://github.com/rust-lang/crates.io-index" 283 | checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" 284 | 285 | [[package]] 286 | name = "heck" 287 | version = "0.5.0" 288 | source = "registry+https://github.com/rust-lang/crates.io-index" 289 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 290 | 291 | [[package]] 292 | name = "indenter" 293 | version = "0.3.4" 294 | source = "registry+https://github.com/rust-lang/crates.io-index" 295 | checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" 296 | 297 | [[package]] 298 | name = "indexmap" 299 | version = "2.11.4" 300 | source = "registry+https://github.com/rust-lang/crates.io-index" 301 | checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" 302 | dependencies = [ 303 | "equivalent", 304 | "hashbrown", 305 | ] 306 | 307 | [[package]] 308 | name = "infat-cli" 309 | version = "3.0.3" 310 | dependencies = [ 311 | "clap", 312 | "clap_complete", 313 | "color-eyre", 314 | "eyre", 315 | "infat-lib", 316 | "nerdicons_rs", 317 | "tokio", 318 | "tracing", 319 | ] 320 | 321 | [[package]] 322 | name = "infat-lib" 323 | version = "0.1.0" 324 | dependencies = [ 325 | "core-foundation", 326 | "core-foundation-sys", 327 | "dirs", 328 | "eyre", 329 | "libc", 330 | "objc", 331 | "objc-foundation", 332 | "plist", 333 | "serde", 334 | "thiserror", 335 | "toml", 336 | "tracing", 337 | "tracing-subscriber", 338 | ] 339 | 340 | [[package]] 341 | name = "io-uring" 342 | version = "0.7.10" 343 | source = "registry+https://github.com/rust-lang/crates.io-index" 344 | checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" 345 | dependencies = [ 346 | "bitflags", 347 | "cfg-if", 348 | "libc", 349 | ] 350 | 351 | [[package]] 352 | name = "is_terminal_polyfill" 353 | version = "1.70.1" 354 | source = "registry+https://github.com/rust-lang/crates.io-index" 355 | checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" 356 | 357 | [[package]] 358 | name = "itoa" 359 | version = "1.0.15" 360 | source = "registry+https://github.com/rust-lang/crates.io-index" 361 | checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" 362 | 363 | [[package]] 364 | name = "lazy_static" 365 | version = "1.5.0" 366 | source = "registry+https://github.com/rust-lang/crates.io-index" 367 | checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 368 | 369 | [[package]] 370 | name = "libc" 371 | version = "0.2.175" 372 | source = "registry+https://github.com/rust-lang/crates.io-index" 373 | checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" 374 | 375 | [[package]] 376 | name = "libredox" 377 | version = "0.1.10" 378 | source = "registry+https://github.com/rust-lang/crates.io-index" 379 | checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" 380 | dependencies = [ 381 | "bitflags", 382 | "libc", 383 | ] 384 | 385 | [[package]] 386 | name = "log" 387 | version = "0.4.28" 388 | source = "registry+https://github.com/rust-lang/crates.io-index" 389 | checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" 390 | 391 | [[package]] 392 | name = "malloc_buf" 393 | version = "0.0.6" 394 | source = "registry+https://github.com/rust-lang/crates.io-index" 395 | checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" 396 | dependencies = [ 397 | "libc", 398 | ] 399 | 400 | [[package]] 401 | name = "matchers" 402 | version = "0.2.0" 403 | source = "registry+https://github.com/rust-lang/crates.io-index" 404 | checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" 405 | dependencies = [ 406 | "regex-automata", 407 | ] 408 | 409 | [[package]] 410 | name = "memchr" 411 | version = "2.7.5" 412 | source = "registry+https://github.com/rust-lang/crates.io-index" 413 | checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" 414 | 415 | [[package]] 416 | name = "miniz_oxide" 417 | version = "0.8.9" 418 | source = "registry+https://github.com/rust-lang/crates.io-index" 419 | checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" 420 | dependencies = [ 421 | "adler2", 422 | ] 423 | 424 | [[package]] 425 | name = "mio" 426 | version = "1.0.4" 427 | source = "registry+https://github.com/rust-lang/crates.io-index" 428 | checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" 429 | dependencies = [ 430 | "libc", 431 | "wasi", 432 | "windows-sys 0.59.0", 433 | ] 434 | 435 | [[package]] 436 | name = "nerdicons_rs" 437 | version = "0.1.0" 438 | source = "registry+https://github.com/rust-lang/crates.io-index" 439 | checksum = "988d06af1a37aa0c5e3e9f571fb26b0f014c1ff3f4eb1ea95788a6099b74f4e2" 440 | dependencies = [ 441 | "serde", 442 | "serde_json", 443 | ] 444 | 445 | [[package]] 446 | name = "nu-ansi-term" 447 | version = "0.50.1" 448 | source = "registry+https://github.com/rust-lang/crates.io-index" 449 | checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399" 450 | dependencies = [ 451 | "windows-sys 0.52.0", 452 | ] 453 | 454 | [[package]] 455 | name = "num-conv" 456 | version = "0.1.0" 457 | source = "registry+https://github.com/rust-lang/crates.io-index" 458 | checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" 459 | 460 | [[package]] 461 | name = "objc" 462 | version = "0.2.7" 463 | source = "registry+https://github.com/rust-lang/crates.io-index" 464 | checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" 465 | dependencies = [ 466 | "malloc_buf", 467 | ] 468 | 469 | [[package]] 470 | name = "objc-foundation" 471 | version = "0.1.1" 472 | source = "registry+https://github.com/rust-lang/crates.io-index" 473 | checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" 474 | dependencies = [ 475 | "block", 476 | "objc", 477 | "objc_id", 478 | ] 479 | 480 | [[package]] 481 | name = "objc_id" 482 | version = "0.1.1" 483 | source = "registry+https://github.com/rust-lang/crates.io-index" 484 | checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" 485 | dependencies = [ 486 | "objc", 487 | ] 488 | 489 | [[package]] 490 | name = "object" 491 | version = "0.36.7" 492 | source = "registry+https://github.com/rust-lang/crates.io-index" 493 | checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" 494 | dependencies = [ 495 | "memchr", 496 | ] 497 | 498 | [[package]] 499 | name = "once_cell" 500 | version = "1.21.3" 501 | source = "registry+https://github.com/rust-lang/crates.io-index" 502 | checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 503 | 504 | [[package]] 505 | name = "once_cell_polyfill" 506 | version = "1.70.1" 507 | source = "registry+https://github.com/rust-lang/crates.io-index" 508 | checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" 509 | 510 | [[package]] 511 | name = "option-ext" 512 | version = "0.2.0" 513 | source = "registry+https://github.com/rust-lang/crates.io-index" 514 | checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" 515 | 516 | [[package]] 517 | name = "owo-colors" 518 | version = "4.2.2" 519 | source = "registry+https://github.com/rust-lang/crates.io-index" 520 | checksum = "48dd4f4a2c8405440fd0462561f0e5806bd0f77e86f51c761481bdd4018b545e" 521 | 522 | [[package]] 523 | name = "pin-project-lite" 524 | version = "0.2.16" 525 | source = "registry+https://github.com/rust-lang/crates.io-index" 526 | checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" 527 | 528 | [[package]] 529 | name = "plist" 530 | version = "1.8.0" 531 | source = "registry+https://github.com/rust-lang/crates.io-index" 532 | checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" 533 | dependencies = [ 534 | "base64", 535 | "indexmap", 536 | "quick-xml", 537 | "serde", 538 | "time", 539 | ] 540 | 541 | [[package]] 542 | name = "powerfmt" 543 | version = "0.2.0" 544 | source = "registry+https://github.com/rust-lang/crates.io-index" 545 | checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" 546 | 547 | [[package]] 548 | name = "proc-macro2" 549 | version = "1.0.101" 550 | source = "registry+https://github.com/rust-lang/crates.io-index" 551 | checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" 552 | dependencies = [ 553 | "unicode-ident", 554 | ] 555 | 556 | [[package]] 557 | name = "quick-xml" 558 | version = "0.38.3" 559 | source = "registry+https://github.com/rust-lang/crates.io-index" 560 | checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89" 561 | dependencies = [ 562 | "memchr", 563 | ] 564 | 565 | [[package]] 566 | name = "quote" 567 | version = "1.0.40" 568 | source = "registry+https://github.com/rust-lang/crates.io-index" 569 | checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" 570 | dependencies = [ 571 | "proc-macro2", 572 | ] 573 | 574 | [[package]] 575 | name = "redox_users" 576 | version = "0.4.6" 577 | source = "registry+https://github.com/rust-lang/crates.io-index" 578 | checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" 579 | dependencies = [ 580 | "getrandom", 581 | "libredox", 582 | "thiserror", 583 | ] 584 | 585 | [[package]] 586 | name = "regex-automata" 587 | version = "0.4.10" 588 | source = "registry+https://github.com/rust-lang/crates.io-index" 589 | checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" 590 | dependencies = [ 591 | "aho-corasick", 592 | "memchr", 593 | "regex-syntax", 594 | ] 595 | 596 | [[package]] 597 | name = "regex-syntax" 598 | version = "0.8.6" 599 | source = "registry+https://github.com/rust-lang/crates.io-index" 600 | checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" 601 | 602 | [[package]] 603 | name = "rustc-demangle" 604 | version = "0.1.26" 605 | source = "registry+https://github.com/rust-lang/crates.io-index" 606 | checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" 607 | 608 | [[package]] 609 | name = "ryu" 610 | version = "1.0.20" 611 | source = "registry+https://github.com/rust-lang/crates.io-index" 612 | checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" 613 | 614 | [[package]] 615 | name = "serde" 616 | version = "1.0.226" 617 | source = "registry+https://github.com/rust-lang/crates.io-index" 618 | checksum = "0dca6411025b24b60bfa7ec1fe1f8e710ac09782dca409ee8237ba74b51295fd" 619 | dependencies = [ 620 | "serde_core", 621 | "serde_derive", 622 | ] 623 | 624 | [[package]] 625 | name = "serde_core" 626 | version = "1.0.226" 627 | source = "registry+https://github.com/rust-lang/crates.io-index" 628 | checksum = "ba2ba63999edb9dac981fb34b3e5c0d111a69b0924e253ed29d83f7c99e966a4" 629 | dependencies = [ 630 | "serde_derive", 631 | ] 632 | 633 | [[package]] 634 | name = "serde_derive" 635 | version = "1.0.226" 636 | source = "registry+https://github.com/rust-lang/crates.io-index" 637 | checksum = "8db53ae22f34573731bafa1db20f04027b2d25e02d8205921b569171699cdb33" 638 | dependencies = [ 639 | "proc-macro2", 640 | "quote", 641 | "syn", 642 | ] 643 | 644 | [[package]] 645 | name = "serde_json" 646 | version = "1.0.145" 647 | source = "registry+https://github.com/rust-lang/crates.io-index" 648 | checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" 649 | dependencies = [ 650 | "itoa", 651 | "memchr", 652 | "ryu", 653 | "serde", 654 | "serde_core", 655 | ] 656 | 657 | [[package]] 658 | name = "serde_spanned" 659 | version = "0.6.9" 660 | source = "registry+https://github.com/rust-lang/crates.io-index" 661 | checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" 662 | dependencies = [ 663 | "serde", 664 | ] 665 | 666 | [[package]] 667 | name = "sharded-slab" 668 | version = "0.1.7" 669 | source = "registry+https://github.com/rust-lang/crates.io-index" 670 | checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" 671 | dependencies = [ 672 | "lazy_static", 673 | ] 674 | 675 | [[package]] 676 | name = "slab" 677 | version = "0.4.11" 678 | source = "registry+https://github.com/rust-lang/crates.io-index" 679 | checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" 680 | 681 | [[package]] 682 | name = "smallvec" 683 | version = "1.15.1" 684 | source = "registry+https://github.com/rust-lang/crates.io-index" 685 | checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" 686 | 687 | [[package]] 688 | name = "strsim" 689 | version = "0.11.1" 690 | source = "registry+https://github.com/rust-lang/crates.io-index" 691 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 692 | 693 | [[package]] 694 | name = "syn" 695 | version = "2.0.106" 696 | source = "registry+https://github.com/rust-lang/crates.io-index" 697 | checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" 698 | dependencies = [ 699 | "proc-macro2", 700 | "quote", 701 | "unicode-ident", 702 | ] 703 | 704 | [[package]] 705 | name = "thiserror" 706 | version = "1.0.69" 707 | source = "registry+https://github.com/rust-lang/crates.io-index" 708 | checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" 709 | dependencies = [ 710 | "thiserror-impl", 711 | ] 712 | 713 | [[package]] 714 | name = "thiserror-impl" 715 | version = "1.0.69" 716 | source = "registry+https://github.com/rust-lang/crates.io-index" 717 | checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" 718 | dependencies = [ 719 | "proc-macro2", 720 | "quote", 721 | "syn", 722 | ] 723 | 724 | [[package]] 725 | name = "thread_local" 726 | version = "1.1.9" 727 | source = "registry+https://github.com/rust-lang/crates.io-index" 728 | checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" 729 | dependencies = [ 730 | "cfg-if", 731 | ] 732 | 733 | [[package]] 734 | name = "time" 735 | version = "0.3.44" 736 | source = "registry+https://github.com/rust-lang/crates.io-index" 737 | checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" 738 | dependencies = [ 739 | "deranged", 740 | "itoa", 741 | "num-conv", 742 | "powerfmt", 743 | "serde", 744 | "time-core", 745 | "time-macros", 746 | ] 747 | 748 | [[package]] 749 | name = "time-core" 750 | version = "0.1.6" 751 | source = "registry+https://github.com/rust-lang/crates.io-index" 752 | checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" 753 | 754 | [[package]] 755 | name = "time-macros" 756 | version = "0.2.24" 757 | source = "registry+https://github.com/rust-lang/crates.io-index" 758 | checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" 759 | dependencies = [ 760 | "num-conv", 761 | "time-core", 762 | ] 763 | 764 | [[package]] 765 | name = "tokio" 766 | version = "1.47.1" 767 | source = "registry+https://github.com/rust-lang/crates.io-index" 768 | checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" 769 | dependencies = [ 770 | "backtrace", 771 | "io-uring", 772 | "libc", 773 | "mio", 774 | "pin-project-lite", 775 | "slab", 776 | "tokio-macros", 777 | ] 778 | 779 | [[package]] 780 | name = "tokio-macros" 781 | version = "2.5.0" 782 | source = "registry+https://github.com/rust-lang/crates.io-index" 783 | checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" 784 | dependencies = [ 785 | "proc-macro2", 786 | "quote", 787 | "syn", 788 | ] 789 | 790 | [[package]] 791 | name = "toml" 792 | version = "0.8.23" 793 | source = "registry+https://github.com/rust-lang/crates.io-index" 794 | checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" 795 | dependencies = [ 796 | "serde", 797 | "serde_spanned", 798 | "toml_datetime", 799 | "toml_edit", 800 | ] 801 | 802 | [[package]] 803 | name = "toml_datetime" 804 | version = "0.6.11" 805 | source = "registry+https://github.com/rust-lang/crates.io-index" 806 | checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" 807 | dependencies = [ 808 | "serde", 809 | ] 810 | 811 | [[package]] 812 | name = "toml_edit" 813 | version = "0.22.27" 814 | source = "registry+https://github.com/rust-lang/crates.io-index" 815 | checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" 816 | dependencies = [ 817 | "indexmap", 818 | "serde", 819 | "serde_spanned", 820 | "toml_datetime", 821 | "toml_write", 822 | "winnow", 823 | ] 824 | 825 | [[package]] 826 | name = "toml_write" 827 | version = "0.1.2" 828 | source = "registry+https://github.com/rust-lang/crates.io-index" 829 | checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" 830 | 831 | [[package]] 832 | name = "tracing" 833 | version = "0.1.41" 834 | source = "registry+https://github.com/rust-lang/crates.io-index" 835 | checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" 836 | dependencies = [ 837 | "pin-project-lite", 838 | "tracing-attributes", 839 | "tracing-core", 840 | ] 841 | 842 | [[package]] 843 | name = "tracing-attributes" 844 | version = "0.1.30" 845 | source = "registry+https://github.com/rust-lang/crates.io-index" 846 | checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" 847 | dependencies = [ 848 | "proc-macro2", 849 | "quote", 850 | "syn", 851 | ] 852 | 853 | [[package]] 854 | name = "tracing-core" 855 | version = "0.1.34" 856 | source = "registry+https://github.com/rust-lang/crates.io-index" 857 | checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" 858 | dependencies = [ 859 | "once_cell", 860 | "valuable", 861 | ] 862 | 863 | [[package]] 864 | name = "tracing-error" 865 | version = "0.2.1" 866 | source = "registry+https://github.com/rust-lang/crates.io-index" 867 | checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" 868 | dependencies = [ 869 | "tracing", 870 | "tracing-subscriber", 871 | ] 872 | 873 | [[package]] 874 | name = "tracing-log" 875 | version = "0.2.0" 876 | source = "registry+https://github.com/rust-lang/crates.io-index" 877 | checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" 878 | dependencies = [ 879 | "log", 880 | "once_cell", 881 | "tracing-core", 882 | ] 883 | 884 | [[package]] 885 | name = "tracing-subscriber" 886 | version = "0.3.20" 887 | source = "registry+https://github.com/rust-lang/crates.io-index" 888 | checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" 889 | dependencies = [ 890 | "matchers", 891 | "nu-ansi-term", 892 | "once_cell", 893 | "regex-automata", 894 | "sharded-slab", 895 | "smallvec", 896 | "thread_local", 897 | "tracing", 898 | "tracing-core", 899 | "tracing-log", 900 | ] 901 | 902 | [[package]] 903 | name = "unicode-ident" 904 | version = "1.0.19" 905 | source = "registry+https://github.com/rust-lang/crates.io-index" 906 | checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" 907 | 908 | [[package]] 909 | name = "utf8parse" 910 | version = "0.2.2" 911 | source = "registry+https://github.com/rust-lang/crates.io-index" 912 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 913 | 914 | [[package]] 915 | name = "valuable" 916 | version = "0.1.1" 917 | source = "registry+https://github.com/rust-lang/crates.io-index" 918 | checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" 919 | 920 | [[package]] 921 | name = "wasi" 922 | version = "0.11.1+wasi-snapshot-preview1" 923 | source = "registry+https://github.com/rust-lang/crates.io-index" 924 | checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" 925 | 926 | [[package]] 927 | name = "windows-link" 928 | version = "0.1.3" 929 | source = "registry+https://github.com/rust-lang/crates.io-index" 930 | checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" 931 | 932 | [[package]] 933 | name = "windows-sys" 934 | version = "0.48.0" 935 | source = "registry+https://github.com/rust-lang/crates.io-index" 936 | checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" 937 | dependencies = [ 938 | "windows-targets 0.48.5", 939 | ] 940 | 941 | [[package]] 942 | name = "windows-sys" 943 | version = "0.52.0" 944 | source = "registry+https://github.com/rust-lang/crates.io-index" 945 | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 946 | dependencies = [ 947 | "windows-targets 0.52.6", 948 | ] 949 | 950 | [[package]] 951 | name = "windows-sys" 952 | version = "0.59.0" 953 | source = "registry+https://github.com/rust-lang/crates.io-index" 954 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 955 | dependencies = [ 956 | "windows-targets 0.52.6", 957 | ] 958 | 959 | [[package]] 960 | name = "windows-sys" 961 | version = "0.60.2" 962 | source = "registry+https://github.com/rust-lang/crates.io-index" 963 | checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" 964 | dependencies = [ 965 | "windows-targets 0.53.3", 966 | ] 967 | 968 | [[package]] 969 | name = "windows-targets" 970 | version = "0.48.5" 971 | source = "registry+https://github.com/rust-lang/crates.io-index" 972 | checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" 973 | dependencies = [ 974 | "windows_aarch64_gnullvm 0.48.5", 975 | "windows_aarch64_msvc 0.48.5", 976 | "windows_i686_gnu 0.48.5", 977 | "windows_i686_msvc 0.48.5", 978 | "windows_x86_64_gnu 0.48.5", 979 | "windows_x86_64_gnullvm 0.48.5", 980 | "windows_x86_64_msvc 0.48.5", 981 | ] 982 | 983 | [[package]] 984 | name = "windows-targets" 985 | version = "0.52.6" 986 | source = "registry+https://github.com/rust-lang/crates.io-index" 987 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 988 | dependencies = [ 989 | "windows_aarch64_gnullvm 0.52.6", 990 | "windows_aarch64_msvc 0.52.6", 991 | "windows_i686_gnu 0.52.6", 992 | "windows_i686_gnullvm 0.52.6", 993 | "windows_i686_msvc 0.52.6", 994 | "windows_x86_64_gnu 0.52.6", 995 | "windows_x86_64_gnullvm 0.52.6", 996 | "windows_x86_64_msvc 0.52.6", 997 | ] 998 | 999 | [[package]] 1000 | name = "windows-targets" 1001 | version = "0.53.3" 1002 | source = "registry+https://github.com/rust-lang/crates.io-index" 1003 | checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" 1004 | dependencies = [ 1005 | "windows-link", 1006 | "windows_aarch64_gnullvm 0.53.0", 1007 | "windows_aarch64_msvc 0.53.0", 1008 | "windows_i686_gnu 0.53.0", 1009 | "windows_i686_gnullvm 0.53.0", 1010 | "windows_i686_msvc 0.53.0", 1011 | "windows_x86_64_gnu 0.53.0", 1012 | "windows_x86_64_gnullvm 0.53.0", 1013 | "windows_x86_64_msvc 0.53.0", 1014 | ] 1015 | 1016 | [[package]] 1017 | name = "windows_aarch64_gnullvm" 1018 | version = "0.48.5" 1019 | source = "registry+https://github.com/rust-lang/crates.io-index" 1020 | checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" 1021 | 1022 | [[package]] 1023 | name = "windows_aarch64_gnullvm" 1024 | version = "0.52.6" 1025 | source = "registry+https://github.com/rust-lang/crates.io-index" 1026 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 1027 | 1028 | [[package]] 1029 | name = "windows_aarch64_gnullvm" 1030 | version = "0.53.0" 1031 | source = "registry+https://github.com/rust-lang/crates.io-index" 1032 | checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" 1033 | 1034 | [[package]] 1035 | name = "windows_aarch64_msvc" 1036 | version = "0.48.5" 1037 | source = "registry+https://github.com/rust-lang/crates.io-index" 1038 | checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" 1039 | 1040 | [[package]] 1041 | name = "windows_aarch64_msvc" 1042 | version = "0.52.6" 1043 | source = "registry+https://github.com/rust-lang/crates.io-index" 1044 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 1045 | 1046 | [[package]] 1047 | name = "windows_aarch64_msvc" 1048 | version = "0.53.0" 1049 | source = "registry+https://github.com/rust-lang/crates.io-index" 1050 | checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" 1051 | 1052 | [[package]] 1053 | name = "windows_i686_gnu" 1054 | version = "0.48.5" 1055 | source = "registry+https://github.com/rust-lang/crates.io-index" 1056 | checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" 1057 | 1058 | [[package]] 1059 | name = "windows_i686_gnu" 1060 | version = "0.52.6" 1061 | source = "registry+https://github.com/rust-lang/crates.io-index" 1062 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 1063 | 1064 | [[package]] 1065 | name = "windows_i686_gnu" 1066 | version = "0.53.0" 1067 | source = "registry+https://github.com/rust-lang/crates.io-index" 1068 | checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" 1069 | 1070 | [[package]] 1071 | name = "windows_i686_gnullvm" 1072 | version = "0.52.6" 1073 | source = "registry+https://github.com/rust-lang/crates.io-index" 1074 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 1075 | 1076 | [[package]] 1077 | name = "windows_i686_gnullvm" 1078 | version = "0.53.0" 1079 | source = "registry+https://github.com/rust-lang/crates.io-index" 1080 | checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" 1081 | 1082 | [[package]] 1083 | name = "windows_i686_msvc" 1084 | version = "0.48.5" 1085 | source = "registry+https://github.com/rust-lang/crates.io-index" 1086 | checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" 1087 | 1088 | [[package]] 1089 | name = "windows_i686_msvc" 1090 | version = "0.52.6" 1091 | source = "registry+https://github.com/rust-lang/crates.io-index" 1092 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 1093 | 1094 | [[package]] 1095 | name = "windows_i686_msvc" 1096 | version = "0.53.0" 1097 | source = "registry+https://github.com/rust-lang/crates.io-index" 1098 | checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" 1099 | 1100 | [[package]] 1101 | name = "windows_x86_64_gnu" 1102 | version = "0.48.5" 1103 | source = "registry+https://github.com/rust-lang/crates.io-index" 1104 | checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" 1105 | 1106 | [[package]] 1107 | name = "windows_x86_64_gnu" 1108 | version = "0.52.6" 1109 | source = "registry+https://github.com/rust-lang/crates.io-index" 1110 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 1111 | 1112 | [[package]] 1113 | name = "windows_x86_64_gnu" 1114 | version = "0.53.0" 1115 | source = "registry+https://github.com/rust-lang/crates.io-index" 1116 | checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" 1117 | 1118 | [[package]] 1119 | name = "windows_x86_64_gnullvm" 1120 | version = "0.48.5" 1121 | source = "registry+https://github.com/rust-lang/crates.io-index" 1122 | checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" 1123 | 1124 | [[package]] 1125 | name = "windows_x86_64_gnullvm" 1126 | version = "0.52.6" 1127 | source = "registry+https://github.com/rust-lang/crates.io-index" 1128 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 1129 | 1130 | [[package]] 1131 | name = "windows_x86_64_gnullvm" 1132 | version = "0.53.0" 1133 | source = "registry+https://github.com/rust-lang/crates.io-index" 1134 | checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" 1135 | 1136 | [[package]] 1137 | name = "windows_x86_64_msvc" 1138 | version = "0.48.5" 1139 | source = "registry+https://github.com/rust-lang/crates.io-index" 1140 | checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" 1141 | 1142 | [[package]] 1143 | name = "windows_x86_64_msvc" 1144 | version = "0.52.6" 1145 | source = "registry+https://github.com/rust-lang/crates.io-index" 1146 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 1147 | 1148 | [[package]] 1149 | name = "windows_x86_64_msvc" 1150 | version = "0.53.0" 1151 | source = "registry+https://github.com/rust-lang/crates.io-index" 1152 | checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" 1153 | 1154 | [[package]] 1155 | name = "winnow" 1156 | version = "0.7.13" 1157 | source = "registry+https://github.com/rust-lang/crates.io-index" 1158 | checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" 1159 | dependencies = [ 1160 | "memchr", 1161 | ] 1162 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | # Cargo.toml 2 | [workspace] 3 | members = [ 4 | "infat-lib", 5 | "infat-cli", 6 | ] 7 | resolver = "2" 8 | 9 | [workspace.dependencies] 10 | # CLI and config 11 | clap = { version = "4.5", features = ["derive"] } 12 | toml = "0.8" 13 | serde = { version = "1.0", features = ["derive"] } 14 | 15 | # Error handling 16 | thiserror = "1.0" 17 | eyre = "0.6" 18 | color-eyre = "0.6" 19 | 20 | # Logging 21 | tracing = "0.1" 22 | tracing-subscriber = { version = "0.3", features = ["env-filter"] } 23 | 24 | # macOS system integration 25 | core-foundation = "0.10" 26 | core-services = "0.2" 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2025 Philocalyst [milestheperson@posteo.net] 2 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the " Software "), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software , and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 3 | 4 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software . 5 | 6 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE . 7 | 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Welcome to Infat 2 | 3 | [![Rust Version](https://badgen.net/static/Rust/2024/orange)](https://swift.org) 4 | [![Apple Platform](https://badgen.net/badge/icon/macOS%2013+?icon=apple&label)](https://developer.apple.com/macOS) 5 | 6 | Infat is an ultra-powerful, macOS-native CLI tool for declaratively 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? 7 | 8 | --- 9 | 10 | ## Summary 11 | 12 | - List which apps open for a given file extension or URL scheme (Like when you double click a file in Finder) 13 | - Set a default application for a file extension or URL scheme 14 | - Load associations from a TOML config (`[extensions]` `[types]` and `[schemes]` tables) 15 | - Verbose, scriptable, and ideal for power users and admins 16 | 17 | ## Get Started 18 | 19 | Get started by installing Infat — jump to the [Install](#install) section below. 20 | 21 | ## Tutorial 22 | 23 | ### 1. Getting association information 24 | 25 | ```shell 26 | # Show the default app for .txt files and all registered apps 27 | infat info --ext txt 28 | ``` 29 | 30 | ### 2. Setting a Default Application 31 | > [!TIP] 32 | > These aren't strict extensions, for example, yml and yaml extensions share a common resolver. 33 | 34 | ```shell 35 | # Use TextEdit for .md files 36 | infat set TextEdit --ext md 37 | 38 | # Use VSCode for .json files 39 | infat set VSCode --ext json 40 | ``` 41 | 42 | ### 3. Binding a URL Scheme 43 | 44 | ```shell 45 | # Use Mail.app for mailto: links 46 | infat set Mail --scheme mailto 47 | ``` 48 | 49 | ### 4. Fallback types 50 | 51 | > [!TIP] 52 | > Openers are cascading in macOS. Most common file formats will have their own identifier, 53 | > Which will be read from before the plain-text type it inherits from 54 | > Try setting from extension if you face unexpected issues 55 | 56 | ```shell 57 | # Set VSCode as the opener for files containing text 58 | infat set VSCode --type plain-text 59 | ``` 60 | 61 | Infat currently supports these supertypes: 62 | 63 | - plain-text 64 | - text 65 | - csv 66 | - image 67 | - raw-image 68 | - audio 69 | - video 70 | - movie 71 | - mp4-audio 72 | - quicktime 73 | - mp4-movie 74 | - archive 75 | - sourcecode 76 | - c-source 77 | - cpp-source 78 | - objc-source 79 | - shell 80 | - makefile 81 | - data 82 | - directory 83 | - folder 84 | - symlink 85 | - executable 86 | - unix-executable 87 | - app-bundle 88 | 89 | ### 5. Configuration 90 | 91 | Place a TOML file at `$XDG_CONFIG_HOME/infat/config.toml` (or pass `--config path/to/config.toml`). 92 | 93 | > [!NOTE] 94 | > `$XDG_CONFIG_HOME` is not set by default, you need to set in your shell config ex: `.zshenv`. 95 | 96 | On the right is the app you want to bind. You can pass: 97 | 1. The name (As seen when you hover on the icon) **IF** It's in a default location. 98 | 2. The relative path (To your user directory: ~) 99 | 3. The absolute path 100 | 101 | All case sensitive, all can be with or without a .app suffix, and no shell expansions... 102 | 103 | ```toml 104 | [extensions] 105 | md = "TextEdit" 106 | html = "Safari" 107 | pdf = "Preview" 108 | 109 | [schemes] 110 | mailto = "Mail" 111 | web = "Safari" 112 | 113 | [types] 114 | plain-text = "VSCode" 115 | ``` 116 | 117 | Run without arguments to apply all entries. 118 | 119 | ```shell 120 | infat --config ~/.config/infat/config.toml 121 | ``` 122 | 123 | --- 124 | 125 | ## Design Philosophy 126 | 127 | - **Minimal & Scriptable** 128 | Infat is a single-binary tool that plays well in shells and automation pipelines. 129 | 130 | - **macOS-First** 131 | Leverages native `NSWorkspace`, Launch Services, and UTType for robust integration. 132 | 133 | - **Declarative Configuration** 134 | TOML support allows you to version-control your associations alongside other dotfiles. 135 | 136 | ## Building and Debugging 137 | 138 | You’ll need [just](https://github.com/casey/just) and the rust compiler for the build. If you want to be simple, install nix, and run `nix develop .` 139 | 140 | ```shell 141 | # Debug build 142 | just build 143 | 144 | # Release build 145 | just build-release 146 | 147 | # Run in debug mode 148 | just run "list txt" 149 | 150 | # Enable verbose logging for troubleshooting 151 | infat --verbose info --ext pdf 152 | ``` 153 | 154 | --- 155 | 156 | ## Install 157 | 158 | ### Homebrew 159 | 160 | ```shell 161 | brew update # Optional but recommended 162 | brew install infat 163 | ``` 164 | 165 | ### From Source 166 | 167 | 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. 168 | 169 | ```shell 170 | git clone https://github.com/philocalyst/infat.git && cd infat 171 | just package && mv dist/infat* /usr/local/bin/infat # Wildcard because output name includes platform 172 | ``` 173 | 174 | ## Changelog 175 | 176 | For the full history of changes, see [CHANGELOG.md](CHANGELOG.md). 177 | 178 | ## Libraries Used 179 | 180 | - [clap](https://lib.rs/crates/clap) 181 | - [Toml](https://lib.rs/crates/toml) 182 | - [Serde](https://lib.rs/crates/serde) 183 | - [thiserror](https://lib.rs/crates/thiserror) 184 | - [eyre](https://lib.rs/crates/eyre) 185 | - [color-eyre](https://lib.rs/crates/color-eyre) 186 | - [tracing](https://lib.rs/crates/tracing) 187 | - [tracing-subscriber](https://lib.rs/crates/tracing-subscriber) 188 | - [core-foundation](https://lib.rs/crates/core-foundation) 189 | - [core-services](https://lib.rs/crates/core-services) 190 | 191 | 192 | ## Acknowledgements 193 | 194 | - Inspired by [duti](https://github.com/moretension/duti) 195 | - Built with Apple API's, thank you's to our corporate overlord Apple for not locking these capabilities away and instead just having poorly-documented error codes :) 196 | - Thanks to all contributors and issue submitters, y'all rock and combat my lack of test-cases.. heh 197 | 198 | ## License 199 | 200 | Infat is licensed under the [MIT License](LICENSE). 201 | Feel free to use, modify, and distribute! 202 | -------------------------------------------------------------------------------- /cue.mod/module.cue: -------------------------------------------------------------------------------- 1 | module: "github.com/philocalyst/infat" 2 | language: { 3 | version: "v0.14.1" 4 | } 5 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "fenix": { 4 | "inputs": { 5 | "nixpkgs": "nixpkgs", 6 | "rust-analyzer-src": "rust-analyzer-src" 7 | }, 8 | "locked": { 9 | "lastModified": 1758695884, 10 | "narHash": "sha256-rnHjtBRkcwRkrUZxg0RqN1qWTG+QC/gj4vn9uzEkBww=", 11 | "owner": "nix-community", 12 | "repo": "fenix", 13 | "rev": "9cdb79384d02234fb2868eba6c7d390253ef6f83", 14 | "type": "github" 15 | }, 16 | "original": { 17 | "owner": "nix-community", 18 | "repo": "fenix", 19 | "type": "github" 20 | } 21 | }, 22 | "flake-compat": { 23 | "flake": false, 24 | "locked": { 25 | "lastModified": 1696426674, 26 | "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=", 27 | "owner": "edolstra", 28 | "repo": "flake-compat", 29 | "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33", 30 | "type": "github" 31 | }, 32 | "original": { 33 | "owner": "edolstra", 34 | "repo": "flake-compat", 35 | "type": "github" 36 | } 37 | }, 38 | "flake-utils": { 39 | "inputs": { 40 | "systems": "systems" 41 | }, 42 | "locked": { 43 | "lastModified": 1710146030, 44 | "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", 45 | "owner": "numtide", 46 | "repo": "flake-utils", 47 | "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", 48 | "type": "github" 49 | }, 50 | "original": { 51 | "owner": "numtide", 52 | "repo": "flake-utils", 53 | "type": "github" 54 | } 55 | }, 56 | "nixpkgs": { 57 | "locked": { 58 | "lastModified": 1758427187, 59 | "narHash": "sha256-pHpxZ/IyCwoTQPtFIAG2QaxuSm8jWzrzBGjwQZIttJc=", 60 | "owner": "nixos", 61 | "repo": "nixpkgs", 62 | "rev": "554be6495561ff07b6c724047bdd7e0716aa7b46", 63 | "type": "github" 64 | }, 65 | "original": { 66 | "owner": "nixos", 67 | "ref": "nixos-unstable", 68 | "repo": "nixpkgs", 69 | "type": "github" 70 | } 71 | }, 72 | "nixpkgs_2": { 73 | "locked": { 74 | "lastModified": 1758427187, 75 | "narHash": "sha256-pHpxZ/IyCwoTQPtFIAG2QaxuSm8jWzrzBGjwQZIttJc=", 76 | "owner": "nixos", 77 | "repo": "nixpkgs", 78 | "rev": "554be6495561ff07b6c724047bdd7e0716aa7b46", 79 | "type": "github" 80 | }, 81 | "original": { 82 | "owner": "nixos", 83 | "ref": "nixos-unstable", 84 | "repo": "nixpkgs", 85 | "type": "github" 86 | } 87 | }, 88 | "nixpkgs_3": { 89 | "locked": { 90 | "lastModified": 1719075281, 91 | "narHash": "sha256-CyyxvOwFf12I91PBWz43iGT1kjsf5oi6ax7CrvaMyAo=", 92 | "owner": "NixOS", 93 | "repo": "nixpkgs", 94 | "rev": "a71e967ef3694799d0c418c98332f7ff4cc5f6af", 95 | "type": "github" 96 | }, 97 | "original": { 98 | "id": "nixpkgs", 99 | "ref": "nixos-unstable", 100 | "type": "indirect" 101 | } 102 | }, 103 | "organist": { 104 | "inputs": { 105 | "flake-compat": "flake-compat", 106 | "flake-utils": "flake-utils", 107 | "nixpkgs": "nixpkgs_3" 108 | }, 109 | "locked": { 110 | "lastModified": 1755004808, 111 | "narHash": "sha256-ivs3qgkRULIF925fJTEJfH85B4f+tl5e2gSrVJH58MU=", 112 | "owner": "nickel-lang", 113 | "repo": "organist", 114 | "rev": "a7e4e638cade5e7c4f36a129b80d91bf3538088e", 115 | "type": "github" 116 | }, 117 | "original": { 118 | "owner": "nickel-lang", 119 | "repo": "organist", 120 | "type": "github" 121 | } 122 | }, 123 | "root": { 124 | "inputs": { 125 | "fenix": "fenix", 126 | "nixpkgs": "nixpkgs_2", 127 | "organist": "organist" 128 | } 129 | }, 130 | "rust-analyzer-src": { 131 | "flake": false, 132 | "locked": { 133 | "lastModified": 1758620797, 134 | "narHash": "sha256-Ly4rHgrixFMBnkbMursVt74mxnntnE6yVdF5QellJ+A=", 135 | "owner": "rust-lang", 136 | "repo": "rust-analyzer", 137 | "rev": "905641f3520230ad6ef421bcf5da9c6b49f2479b", 138 | "type": "github" 139 | }, 140 | "original": { 141 | "owner": "rust-lang", 142 | "ref": "nightly", 143 | "repo": "rust-analyzer", 144 | "type": "github" 145 | } 146 | }, 147 | "systems": { 148 | "locked": { 149 | "lastModified": 1681028828, 150 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 151 | "owner": "nix-systems", 152 | "repo": "default", 153 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 154 | "type": "github" 155 | }, 156 | "original": { 157 | "owner": "nix-systems", 158 | "repo": "default", 159 | "type": "github" 160 | } 161 | } 162 | }, 163 | "root": "root", 164 | "version": 7 165 | } 166 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs.nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; 3 | inputs.fenix.url = "github:nix-community/fenix"; 4 | inputs.organist.url = "github:nickel-lang/organist"; 5 | 6 | nixConfig = { 7 | extra-substituters = ["https://organist.cachix.org" "https://nix-community.cachix.org"]; 8 | extra-trusted-public-keys = ["organist.cachix.org-1:GB9gOx3rbGl7YEh6DwOscD1+E/Gc5ZCnzqwObNH2Faw=" "nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs="]; 9 | }; 10 | 11 | outputs = {organist, ...} @ inputs: 12 | organist.flake.outputsFromNickel .config/. inputs {}; 13 | } 14 | -------------------------------------------------------------------------------- /infat-cli/Cargo.toml: -------------------------------------------------------------------------------- 1 | # infat-cli/Cargo.toml 2 | [package] 3 | name = "infat-cli" 4 | version = "3.0.3" 5 | edition = "2024" 6 | description = "Command-line interface for infat" 7 | license = "MIT" 8 | 9 | [[bin]] 10 | name = "infat" 11 | path = "src/main.rs" 12 | 13 | [dependencies] 14 | infat-lib = { path = "../infat-lib" } 15 | clap = { workspace = true } 16 | tokio = { version = "1.0", features = ["rt-multi-thread", "macros"] } 17 | eyre = { workspace = true } 18 | color-eyre = { workspace = true } 19 | tracing = { workspace = true } 20 | clap_complete = "4.5.58" 21 | nerdicons_rs = "0.1.0" 22 | 23 | [build-dependencies] 24 | clap_complete = "4.5.58" 25 | infat-lib = { path = "../infat-lib" } 26 | clap = { workspace = true } 27 | 28 | -------------------------------------------------------------------------------- /infat-cli/build.rs: -------------------------------------------------------------------------------- 1 | use clap::{CommandFactory, ValueEnum}; 2 | use clap_complete::{Shell, generate_to}; 3 | use std::{env, error::Error, fs, path::Path}; 4 | 5 | include!("src/cli.rs"); 6 | 7 | fn main() -> Result<(), Box> { 8 | // Always re-run if OUT_DIR or build.rs or your CLI model changes: 9 | println!("cargo:rerun-if-env-changed=OUT_DIR"); 10 | println!("cargo:rerun-if-changed=build.rs"); 11 | println!("cargo:rerun-if-changed=src/main.rs"); 12 | 13 | // grab OUT_DIR 14 | let out_dir = PathBuf::from(env::var("OUT_DIR")?); 15 | println!("OUT_DIR = {}", out_dir.display()); 16 | 17 | // grab PROFILE ("debug" or "release") 18 | let profile = env::var("PROFILE")?; 19 | println!("PROFILE = {profile}"); 20 | 21 | // walk up ancestors until we find the profile directory 22 | let mut candidate: &Path = out_dir.as_path(); 23 | let dest = loop { 24 | if let Some(name) = candidate.file_name().and_then(|s| s.to_str()) { 25 | if name == profile { 26 | break candidate.to_path_buf(); 27 | } 28 | } 29 | candidate = candidate 30 | .parent() 31 | .ok_or("could not locate `debug` or `release` in OUT_DIR")?; 32 | }; 33 | 34 | println!("writing completions into `{}`", dest.display()); 35 | 36 | // make sure destination directory exists 37 | fs::create_dir_all(&dest)?; 38 | 39 | // generate completions 40 | let bin_name = "infat"; 41 | let mut cmd = Cli::command(); 42 | 43 | for &shell in Shell::value_variants() { 44 | match generate_to(shell, &mut cmd, bin_name, &dest) { 45 | Ok(path) => { 46 | println!(" • {:?} -> {}", shell, path.display()); 47 | } 48 | Err(e) => { 49 | println!("failed to generate {shell:?}: {e}"); 50 | } 51 | } 52 | } 53 | 54 | Ok(()) 55 | } 56 | -------------------------------------------------------------------------------- /infat-cli/src/cli.rs: -------------------------------------------------------------------------------- 1 | use clap::{Parser, Subcommand}; 2 | use infat_lib::GlobalOptions; 3 | use std::path::PathBuf; 4 | 5 | #[derive(Parser, Debug, Clone)] 6 | #[command( 7 | author, 8 | version, 9 | about = "Declaratively manage macOS file associations and URL schemes", 10 | long_about = "Infat allows you to inspect and modify default applications for file types \ 11 | and URL schemes on macOS. It supports declarative configuration through TOML \ 12 | files for reproducible setups across machines." 13 | )] 14 | pub(crate) struct Cli { 15 | #[command(subcommand)] 16 | pub(crate) command: Option, 17 | 18 | /// Path to the configuration file 19 | #[arg(short, long, value_name = "PATH")] 20 | config: Option, 21 | 22 | /// Enable verbose logging 23 | #[arg(short, long)] 24 | verbose: bool, 25 | 26 | /// Suppress all output except errors 27 | #[arg(short, long)] 28 | quiet: bool, 29 | 30 | /// Continue processing on errors when possible 31 | #[arg(long)] 32 | robust: bool, 33 | } 34 | 35 | #[derive(Subcommand, Debug, Clone)] 36 | pub(crate) enum Commands { 37 | /// Show file association information 38 | Info { 39 | /// Show information for a specific application 40 | #[arg(long, conflicts_with_all = ["ext", "scheme", "type"])] 41 | app: Option, 42 | 43 | /// Show information for a file extension 44 | #[arg(long, conflicts_with_all = ["app", "scheme", "type"])] 45 | ext: Option, 46 | 47 | /// Show information for a URL scheme 48 | #[arg(long, conflicts_with_all = ["app", "ext", "type"])] 49 | scheme: Option, 50 | 51 | /// Show information for a file type 52 | #[arg(long, conflicts_with_all = ["app", "ext", "scheme"])] 53 | r#type: Option, 54 | }, 55 | 56 | /// Set default application for file extension, URL scheme, or file type 57 | Set { 58 | /// Application name to set as default 59 | app_name: String, 60 | 61 | /// File extension to associate (without the dot) 62 | #[arg(long, conflicts_with_all = ["scheme", "type"])] 63 | ext: Option, 64 | 65 | /// URL scheme to associate 66 | #[arg(long, conflicts_with_all = ["ext", "type"])] 67 | scheme: Option, 68 | 69 | /// File type to associate 70 | #[arg(long, conflicts_with_all = ["ext", "scheme"])] 71 | r#type: Option, 72 | }, 73 | 74 | /// Initialize configuration from current Launch Services settings 75 | Init { 76 | /// Output configuration file path (defaults to XDG config location) 77 | #[arg(short, long, value_name = "PATH")] 78 | output: Option, 79 | }, 80 | } 81 | 82 | impl From<&Cli> for GlobalOptions { 83 | fn from(cli: &Cli) -> Self { 84 | Self { 85 | config_path: cli.config.clone(), 86 | verbose: cli.verbose, 87 | quiet: cli.quiet, 88 | robust: cli.robust, 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /infat-cli/src/main.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | use color_eyre::{ 3 | eyre::{Context, Result}, 4 | owo_colors::OwoColorize, 5 | }; 6 | use infat_lib::{GlobalOptions, app, association, config, macos::launch_services_db}; 7 | use nerdicons_rs::icons::md::{ 8 | RSCHART_BAR, RSCHECK, RSCONTENT_SAVE_MOVE_OUTLINE, RSFILE_DOCUMENT, RSFILE_SEARCH, RSLINK, 9 | RSTAG, 10 | }; 11 | use std::path::PathBuf; 12 | use tracing::info; 13 | 14 | mod cli; 15 | 16 | use cli::{Cli, Commands}; 17 | 18 | #[tokio::main] 19 | async fn main() -> Result<()> { 20 | // Color eyre for them goooood errors 21 | color_eyre::install().wrap_err("Failed to install color-eyre error handler")?; 22 | 23 | let cli = Cli::parse(); 24 | let global_opts: GlobalOptions = (&cli).into(); 25 | 26 | // Initialize tracing 27 | infat_lib::init_tracing(&global_opts).wrap_err("Failed to initialize logging")?; 28 | 29 | // Handle commands 30 | match cli.command { 31 | None => { 32 | // No subcommand provided - load and apply configuration 33 | // Kind of bespoke behavior but infat stands for infatuate 34 | // I like to think it's just running the verb 35 | handle_config_load(&global_opts) 36 | .await 37 | .wrap_err("Failed to load and apply configuration")?; 38 | } 39 | Some(Commands::Info { 40 | app, 41 | ext, 42 | scheme, 43 | r#type, 44 | }) => { 45 | handle_info_command(app, ext, scheme, r#type) 46 | .await 47 | .wrap_err("Info command failed")?; 48 | } 49 | Some(Commands::Set { 50 | app_name, 51 | ext, 52 | scheme, 53 | r#type, 54 | }) => { 55 | handle_set_command(&global_opts, app_name, ext, scheme, r#type) 56 | .await 57 | .wrap_err("Set command failed")?; 58 | } 59 | Some(Commands::Init { output }) => { 60 | handle_init_command(&global_opts, output) 61 | .await 62 | .wrap_err("Init command failed")?; 63 | } 64 | } 65 | 66 | Ok(()) 67 | } 68 | 69 | async fn handle_config_load(opts: &GlobalOptions) -> Result<()> { 70 | let config_path = match &opts.config_path { 71 | Some(path) => { 72 | if !path.exists() { 73 | return Err(color_eyre::eyre::eyre!( 74 | "Configuration file not found: {}", 75 | path.display().bright_red() 76 | )); 77 | } 78 | path.clone() 79 | } 80 | None => config::find_config_file()?.ok_or_else(|| { 81 | color_eyre::eyre::eyre!( 82 | "No configuration file found. Use {} or place config at default location", 83 | "--config".bright_yellow() 84 | ) 85 | })?, 86 | }; 87 | 88 | if !opts.quiet { 89 | println!( 90 | "{RSFILE_DOCUMENT} Loading configuration from: {}", 91 | config_path.display().bright_cyan() 92 | ); 93 | } 94 | 95 | let config = config::Config::from_file(&config_path).wrap_err_with(|| { 96 | format!( 97 | "Failed to load configuration from {}", 98 | config_path.display().bright_red() 99 | ) 100 | })?; 101 | 102 | if config.is_empty() { 103 | return Err(color_eyre::eyre::eyre!( 104 | "Configuration file is empty or contains no valid tables" 105 | )); 106 | } 107 | 108 | let summary = config.summary(); 109 | if !opts.quiet { 110 | println!( 111 | "{RSCHART_BAR} Found {} associations: {} extensions, {} schemes, {} types", 112 | summary.total().to_string().bright_green(), 113 | summary.extensions_count, 114 | summary.schemes_count, 115 | summary.types_count 116 | ); 117 | } 118 | 119 | // Apply configuration 120 | config::apply_config(&config, opts.robust) 121 | .await 122 | .wrap_err("Failed to apply configuration settings")?; 123 | 124 | if !opts.quiet { 125 | println!( 126 | "{RSCHECK} {}", 127 | "Configuration applied successfully".bright_green() 128 | ); 129 | } 130 | 131 | Ok(()) 132 | } 133 | 134 | async fn handle_info_command( 135 | app: Option, 136 | ext: Option, 137 | scheme: Option, 138 | r#type: Option, 139 | ) -> Result<()> { 140 | let provided_count = [ 141 | app.is_some(), 142 | ext.is_some(), 143 | scheme.is_some(), 144 | r#type.is_some(), 145 | ] 146 | .iter() 147 | .filter(|&&x| x) 148 | .count(); 149 | 150 | // Some basic validation that clap can't provide 151 | if provided_count == 0 { 152 | return Err(color_eyre::eyre::eyre!( 153 | "Must provide one of: {}, {}, {}, or {}", 154 | "--app".bright_yellow(), 155 | "--ext".bright_yellow(), 156 | "--scheme".bright_yellow(), 157 | "--type".bright_yellow() 158 | )); 159 | } 160 | 161 | if provided_count > 1 { 162 | return Err(color_eyre::eyre::eyre!( 163 | "Only one of {}, {}, {}, or {} may be provided", 164 | "--app".bright_yellow(), 165 | "--ext".bright_yellow(), 166 | "--scheme".bright_yellow(), 167 | "--type".bright_yellow() 168 | )); 169 | } 170 | 171 | if let Some(app_name) = app { 172 | info!("Getting info for application: {}", app_name); 173 | 174 | let app_info = app::get_app_info(&app_name) 175 | .wrap_err_with(|| format!("Failed to get info for app: {app_name}"))?; 176 | 177 | // Display application information 178 | println!("{}", "Application Information".bright_blue().bold()); 179 | println!(" Name: {}", app_info.name.bright_cyan()); 180 | println!(" Bundle ID: {}", app_info.bundle_id.bright_green()); 181 | println!(" Version: {}", app_info.version); 182 | println!(" Path: {}", app_info.path.display().dimmed()); 183 | 184 | // Declared means just those it claims to support 185 | 186 | // Display declared URL schemes 187 | if !app_info.declared_schemes.is_empty() { 188 | println!("\n{}", "Declared URL Schemes:".bright_blue().bold()); 189 | for scheme in &app_info.declared_schemes { 190 | println!(" • {}", scheme.bright_yellow()); 191 | } 192 | } 193 | 194 | // Display declared file types 195 | if !app_info.declared_types.is_empty() { 196 | println!("\n{}", "Declared File Types:".bright_blue().bold()); 197 | for declared_type in &app_info.declared_types { 198 | println!(" • {}", declared_type.name.bright_cyan()); 199 | 200 | if !declared_type.utis.is_empty() { 201 | println!(" UTIs: {}", declared_type.utis.join(", ").dimmed()); 202 | } 203 | 204 | if !declared_type.extensions.is_empty() { 205 | let exts: Vec = declared_type 206 | .extensions 207 | .iter() 208 | .map(|ext| format!(".{ext}")) 209 | .collect(); 210 | println!(" Extensions: {}", exts.join(", ").bright_green()); 211 | } 212 | 213 | if let Some(desc) = &declared_type.description { 214 | println!(" Description: {}", desc.italic()); 215 | } 216 | println!(); 217 | } 218 | } 219 | } else if let Some(extension) = ext { 220 | info!("Getting info for extension: .{}", extension); 221 | 222 | let info = association::get_info_for_extension(&extension) 223 | .wrap_err_with(|| format!("Failed to get info for extension: .{extension}"))?; 224 | 225 | println!( 226 | "{RSFILE_DOCUMENT} File Extension: {}", 227 | format!(".{extension}").bright_green() 228 | ); 229 | 230 | if let Some(uti) = &info.uti { 231 | println!(" UTI: {}", uti.bright_cyan()); 232 | } 233 | 234 | match info.default_app_name()? { 235 | Some(app_name) => { 236 | println!(" Default app: {}", app_name.bright_yellow()); 237 | } 238 | None => { 239 | println!(" Default app: {}", "None".bright_red()); 240 | } 241 | } 242 | 243 | let all_app_names = info.all_app_names(); 244 | if !all_app_names.is_empty() { 245 | println!("\n{}", "All registered apps:".bright_blue().bold()); 246 | for app_name in all_app_names { 247 | println!(" • {app_name}"); 248 | } 249 | } else { 250 | println!( 251 | "\n{}", 252 | "No applications registered for this extension".yellow() 253 | ); 254 | } 255 | } else if let Some(url_scheme) = scheme { 256 | info!("Getting info for URL scheme: {}", url_scheme); 257 | 258 | let info = association::get_info_for_url_scheme(&url_scheme) 259 | .wrap_err_with(|| format!("Failed to get info for URL scheme: {url_scheme}"))?; 260 | 261 | println!("{RSLINK} URL Scheme: {}", url_scheme.bright_green()); 262 | 263 | match info.default_app_name()? { 264 | Some(app_name) => { 265 | println!(" Default app: {}", app_name.bright_yellow()); 266 | } 267 | None => { 268 | println!(" Default app: {}", "None".bright_red()); 269 | } 270 | } 271 | 272 | let all_app_names = info.all_app_names(); 273 | if !all_app_names.is_empty() { 274 | println!("\n{}", "All registered apps:".bright_blue().bold()); 275 | for app_name in all_app_names { 276 | println!(" • {app_name}"); 277 | } 278 | } else { 279 | println!( 280 | "\n{}", 281 | "No applications registered for this scheme".yellow() 282 | ); 283 | } 284 | } else if let Some(type_name) = r#type { 285 | info!("Getting info for type: {}", type_name); 286 | 287 | let info = association::get_info_for_type(&type_name) 288 | .wrap_err_with(|| format!("Failed to get info for type: {type_name}"))?; 289 | 290 | println!("{RSTAG} File Type: {}", type_name.bright_green()); 291 | 292 | if let Some(uti) = &info.uti { 293 | println!(" UTI: {}", uti.bright_cyan()); 294 | } 295 | 296 | match info.default_app_name()? { 297 | Some(app_name) => { 298 | println!(" Default app: {}", app_name.bright_yellow()); 299 | } 300 | None => { 301 | println!(" Default app: {}", "None".bright_red()); 302 | } 303 | } 304 | 305 | let all_app_names = info.all_app_names(); 306 | if !all_app_names.is_empty() { 307 | println!("\n{}", "All registered apps:".bright_blue().bold()); 308 | for app_name in all_app_names { 309 | println!(" • {app_name}"); 310 | } 311 | } else { 312 | println!("\n{}", "No applications registered for this type".yellow()); 313 | } 314 | } 315 | 316 | Ok(()) 317 | } 318 | 319 | async fn handle_set_command( 320 | opts: &GlobalOptions, 321 | app_name: String, 322 | ext: Option, 323 | scheme: Option, 324 | r#type: Option, 325 | ) -> Result<()> { 326 | let provided_count = [ext.is_some(), scheme.is_some(), r#type.is_some()] 327 | .iter() 328 | .filter(|&&x| x) 329 | .count(); 330 | 331 | if provided_count == 0 { 332 | return Err(color_eyre::eyre::eyre!( 333 | "Must provide one of: {}, {}, or {}", 334 | "--ext".bright_yellow(), 335 | "--scheme".bright_yellow(), 336 | "--type".bright_yellow() 337 | )); 338 | } 339 | 340 | if provided_count > 1 { 341 | return Err(color_eyre::eyre::eyre!( 342 | "Only one of {}, {}, or {} may be provided", 343 | "--ext".bright_yellow(), 344 | "--scheme".bright_yellow(), 345 | "--type".bright_yellow() 346 | )); 347 | } 348 | 349 | if let Some(extension) = ext { 350 | info!("Setting {} as default for .{}", app_name, extension); 351 | 352 | association::set_default_app_for_extension(&extension, &app_name) 353 | .await 354 | .wrap_err_with(|| format!("Failed to set default app for .{extension}"))?; 355 | 356 | if !opts.quiet { 357 | println!( 358 | "{} Set .{} → {}", 359 | "✓".bright_green(), 360 | extension, 361 | app_name.bright_cyan() 362 | ); 363 | } 364 | } else if let Some(url_scheme) = scheme { 365 | info!("Setting {} as default for {} scheme", app_name, url_scheme); 366 | 367 | association::set_default_app_for_url_scheme(&url_scheme, &app_name) 368 | .await 369 | .wrap_err_with(|| format!("Failed to set default app for {url_scheme} scheme"))?; 370 | 371 | if !opts.quiet { 372 | println!( 373 | "{} Set {} → {}", 374 | "✓".bright_green(), 375 | url_scheme, 376 | app_name.bright_cyan() 377 | ); 378 | } 379 | } else if let Some(type_name) = r#type { 380 | info!("Setting {} as default for type {}", app_name, type_name); 381 | 382 | association::set_default_app_for_type(&type_name, &app_name) 383 | .await 384 | .wrap_err_with(|| format!("Failed to set default app for type {type_name}"))?; 385 | 386 | if !opts.quiet { 387 | println!( 388 | "{} Set type {} → {}", 389 | "✓".bright_green(), 390 | type_name, 391 | app_name.bright_cyan() 392 | ); 393 | } 394 | } 395 | 396 | Ok(()) 397 | } 398 | 399 | async fn handle_init_command(opts: &GlobalOptions, output: Option) -> Result<()> { 400 | info!("Initializing configuration from Launch Services database"); 401 | 402 | if !opts.quiet { 403 | println!("{RSFILE_SEARCH} Reading Launch Services database..."); 404 | } 405 | 406 | let config = launch_services_db::generate_config_from_launch_services(opts.robust) 407 | .wrap_err("Failed to generate configuration from Launch Services database")?; 408 | 409 | let summary = config.summary(); 410 | 411 | if !opts.quiet { 412 | println!( 413 | "{RSCHART_BAR} Generated {} associations: {} extensions, {} schemes, {} types", 414 | summary.total().to_string().bright_green(), 415 | summary.extensions_count, 416 | summary.schemes_count, 417 | summary.types_count 418 | ); 419 | } 420 | 421 | // Determine output path 422 | let output_path = match output { 423 | Some(path) => path, 424 | None => match &opts.config_path { 425 | Some(path) => path.clone(), 426 | None => { 427 | let paths = config::get_config_paths(); 428 | paths? 429 | .first() 430 | .ok_or_else(|| color_eyre::eyre::eyre!("Could not determine config path"))? 431 | .clone() 432 | } 433 | }, 434 | }; 435 | 436 | if !opts.quiet { 437 | println!( 438 | "{RSCONTENT_SAVE_MOVE_OUTLINE} Writing configuration to: {}", 439 | output_path.display().bright_cyan() 440 | ); 441 | } 442 | 443 | config 444 | .to_file(&output_path) 445 | .wrap_err_with(|| format!("Failed to write configuration to {}", output_path.display()))?; 446 | 447 | if !opts.quiet { 448 | println!( 449 | "{RSCHECK} {}", 450 | "Configuration initialized successfully".bright_green() 451 | ); 452 | println!("To apply these settings, run: {}", "infat".bright_yellow()); 453 | } 454 | 455 | Ok(()) 456 | } 457 | -------------------------------------------------------------------------------- /infat-lib/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "infat-lib" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | # Error handling 8 | thiserror = "1.0" 9 | eyre = { workspace = true } 10 | tracing = { workspace = true } 11 | tracing-subscriber = { version = "0.3", features = ["env-filter"] } 12 | 13 | # Serialization 14 | serde = { version = "1.0", features = ["derive"] } 15 | toml = { workspace = true } 16 | plist = "1.6" 17 | 18 | # System 19 | dirs = "5.0" 20 | 21 | [target.'cfg(target_os = "macos")'.dependencies] 22 | # macOS system integration 23 | core-foundation = "0.9" 24 | core-foundation-sys = "0.8" 25 | libc = "0.2" 26 | 27 | # Objective-C bindings 28 | objc = "0.2" 29 | objc-foundation = "0.1" 30 | -------------------------------------------------------------------------------- /infat-lib/src/app.rs: -------------------------------------------------------------------------------- 1 | //! Application information and management 2 | 3 | use crate::{ 4 | error::{InfatError, Result}, 5 | macos::workspace, 6 | }; 7 | use plist::Value; 8 | use std::path::PathBuf; 9 | use tracing::debug; 10 | 11 | /// Information about an application's declared file types and URL schemes 12 | #[derive(Debug, Clone)] 13 | pub struct AppInfo { 14 | pub bundle_id: String, 15 | pub name: String, 16 | pub version: String, 17 | pub path: PathBuf, 18 | pub declared_types: Vec, 19 | pub declared_schemes: Vec, 20 | } 21 | 22 | /// A file type or UTI declared by an application 23 | #[derive(Debug, Clone)] 24 | pub struct DeclaredType { 25 | pub name: String, 26 | pub utis: Vec, 27 | pub extensions: Vec, 28 | pub description: Option, 29 | } 30 | 31 | /// Get detailed information about an application 32 | pub fn get_app_info(app_name_or_bundle_id: &str) -> Result { 33 | debug!("Getting app info for: {}", app_name_or_bundle_id); 34 | 35 | // Find the application 36 | let app_path = workspace::find_application(app_name_or_bundle_id)?.ok_or_else(|| { 37 | InfatError::ApplicationNotFound { 38 | name: app_name_or_bundle_id.to_string(), 39 | } 40 | })?; 41 | 42 | // Get bundle ID 43 | let bundle_id = workspace::get_bundle_id_from_app_path(&app_path)?; 44 | 45 | // Read Info.plist 46 | let info_plist_path = app_path.join("Contents").join("Info.plist"); 47 | let plist_data = std::fs::read(&info_plist_path)?; 48 | let plist: Value = plist::from_bytes(&plist_data).map_err(|e| InfatError::PlistReadError { 49 | path: info_plist_path.clone(), 50 | source: Box::new(e), 51 | })?; 52 | 53 | let dict = plist 54 | .as_dictionary() 55 | .ok_or_else(|| InfatError::PlistReadError { 56 | path: info_plist_path.clone(), 57 | source: "Info.plist root is not a dictionary".into(), 58 | })?; 59 | 60 | // Get app name 61 | let name = dict 62 | .get("CFBundleDisplayName") 63 | .or_else(|| dict.get("CFBundleName")) 64 | .and_then(|val| val.as_string()) 65 | .unwrap_or("Unknown") 66 | .to_string(); 67 | 68 | // Get version 69 | let version = dict 70 | .get("CFBundleShortVersionString") 71 | .or_else(|| dict.get("CFBundleVersion")) 72 | .and_then(|val| val.as_string()) 73 | .unwrap_or("Unknown") 74 | .to_string(); 75 | 76 | // Parse declared document types 77 | let declared_types = parse_document_types(dict); 78 | 79 | // Parse declared URL schemes 80 | let declared_schemes = parse_url_schemes(dict); 81 | 82 | Ok(AppInfo { 83 | bundle_id, 84 | name, 85 | version, 86 | path: app_path, 87 | declared_types, 88 | declared_schemes, 89 | }) 90 | } 91 | 92 | /// Get the bundle ID for an application 93 | pub fn get_app_bundle_id(app_name_or_path: &str) -> Result { 94 | debug!("Getting bundle ID for: {}", app_name_or_path); 95 | 96 | if app_name_or_path.contains('.') && !app_name_or_path.contains('/') { 97 | // Already looks like a bundle ID 98 | return Ok(app_name_or_path.to_string()); 99 | } 100 | 101 | workspace::resolve_to_bundle_id(app_name_or_path) 102 | } 103 | 104 | /// Get the version of an application 105 | pub fn get_app_version(app_name_or_bundle_id: &str) -> Result { 106 | let app_info = get_app_info(app_name_or_bundle_id)?; 107 | Ok(app_info.version) 108 | } 109 | 110 | /// Find application paths for a bundle identifier 111 | pub fn get_app_paths_for_bundle_id(bundle_id: &str) -> Result> { 112 | workspace::get_app_paths_for_bundle_id(bundle_id) 113 | } 114 | 115 | fn parse_document_types(info_dict: &plist::Dictionary) -> Vec { 116 | let mut declared_types = Vec::new(); 117 | 118 | if let Some(doc_types) = info_dict 119 | .get("CFBundleDocumentTypes") 120 | .and_then(|v| v.as_array()) 121 | { 122 | for doc_type in doc_types { 123 | if let Some(type_dict) = doc_type.as_dictionary() { 124 | let name = type_dict 125 | .get("CFBundleTypeName") 126 | .and_then(|v| v.as_string()) 127 | .unwrap_or("Unknown Type") 128 | .to_string(); 129 | 130 | let description = type_dict 131 | .get("CFBundleTypeDescription") 132 | .and_then(|v| v.as_string()) 133 | .map(|s| s.to_string()); 134 | 135 | // Get UTIs 136 | let utis = if let Some(uti_array) = type_dict 137 | .get("LSItemContentTypes") 138 | .and_then(|v| v.as_array()) 139 | { 140 | uti_array 141 | .iter() 142 | .filter_map(|v| v.as_string()) 143 | .map(|s| s.to_string()) 144 | .collect() 145 | } else { 146 | Vec::new() 147 | }; 148 | 149 | // Get file extensions 150 | let extensions = if let Some(ext_array) = type_dict 151 | .get("CFBundleTypeExtensions") 152 | .and_then(|v| v.as_array()) 153 | { 154 | ext_array 155 | .iter() 156 | .filter_map(|v| v.as_string()) 157 | .map(|s| s.to_string()) 158 | .collect() 159 | } else { 160 | Vec::new() 161 | }; 162 | 163 | declared_types.push(DeclaredType { 164 | name, 165 | utis, 166 | extensions, 167 | description, 168 | }); 169 | } 170 | } 171 | } 172 | 173 | declared_types 174 | } 175 | 176 | fn parse_url_schemes(info_dict: &plist::Dictionary) -> Vec { 177 | let mut schemes = Vec::new(); 178 | 179 | if let Some(url_types) = info_dict.get("CFBundleURLTypes").and_then(|v| v.as_array()) { 180 | for url_type in url_types { 181 | if let Some(type_dict) = url_type.as_dictionary() { 182 | if let Some(scheme_array) = type_dict 183 | .get("CFBundleURLSchemes") 184 | .and_then(|v| v.as_array()) 185 | { 186 | for scheme_value in scheme_array { 187 | if let Some(scheme) = scheme_value.as_string() { 188 | schemes.push(scheme.to_string()); 189 | } 190 | } 191 | } 192 | } 193 | } 194 | } 195 | 196 | schemes 197 | } 198 | -------------------------------------------------------------------------------- /infat-lib/src/association.rs: -------------------------------------------------------------------------------- 1 | //! High-level association management combining all the pieces 2 | 3 | use crate::{ 4 | error::{InfatError, Result}, 5 | macos::{launch_services, workspace}, 6 | uti::SuperType, 7 | }; 8 | use tracing::{debug, info}; 9 | 10 | /// Set the default application for a file extension 11 | pub async fn set_default_app_for_extension(extension: &str, app_name: &str) -> Result<()> { 12 | info!( 13 | "Setting default app for extension .{} to {}", 14 | extension, app_name 15 | ); 16 | 17 | // Handle special routing for HTML 18 | if extension.to_lowercase() == "html" { 19 | debug!("Routing .html to HTTP scheme handler"); 20 | return set_default_app_for_url_scheme("http", app_name).await; 21 | } 22 | 23 | // Get the UTI for the extension 24 | let uti = launch_services::get_uti_for_extension(extension)?; 25 | debug!("Extension .{} maps to UTI: {}", extension, uti); 26 | 27 | // Resolve app name to bundle ID 28 | let bundle_id = workspace::resolve_to_bundle_id(app_name)?; 29 | debug!("Resolved app '{}' to bundle ID: {}", app_name, bundle_id); 30 | 31 | // Set the default app for the UTI 32 | launch_services::set_default_app_for_uti(&uti, &bundle_id)?; 33 | 34 | Ok(()) 35 | } 36 | 37 | /// Set the default application for a URL scheme 38 | pub async fn set_default_app_for_url_scheme(scheme: &str, app_name: &str) -> Result<()> { 39 | info!( 40 | "Setting default app for URL scheme {} to {}", 41 | scheme, app_name 42 | ); 43 | 44 | // Handle HTTPS routing to HTTP 45 | let actual_scheme = if scheme.to_lowercase() == "https" { 46 | debug!("Routing HTTPS to HTTP scheme handler"); 47 | "http" 48 | } else { 49 | scheme 50 | }; 51 | 52 | // Resolve app name to bundle ID 53 | let bundle_id = workspace::resolve_to_bundle_id(app_name)?; 54 | debug!("Resolved app '{}' to bundle ID: {}", app_name, bundle_id); 55 | 56 | // Register the application first to ensure it's known to Launch Services 57 | if let Some(app_path) = workspace::find_application(app_name)? { 58 | launch_services::register_application(&app_path)?; 59 | } 60 | 61 | // Set the URL scheme handler 62 | launch_services::set_default_app_for_url_scheme(actual_scheme, &bundle_id)?; 63 | 64 | Ok(()) 65 | } 66 | 67 | /// Set the default application for a supertype/UTI 68 | pub async fn set_default_app_for_type(type_name: &str, app_name: &str) -> Result<()> { 69 | info!("Setting default app for type {} to {}", type_name, app_name); 70 | 71 | // Handle special routing for web types 72 | if type_name == "com.apple.default-app.web-browser" || type_name == "public.html" { 73 | debug!("Routing web browser type to HTTP scheme handler"); 74 | return set_default_app_for_url_scheme("http", app_name).await; 75 | } 76 | 77 | // Try to parse as a SuperType first 78 | let uti = if let Ok(supertype) = type_name.parse::() { 79 | supertype.uti_string().to_string() 80 | } else { 81 | // Assume it's already a UTI string 82 | type_name.to_string() 83 | }; 84 | 85 | debug!("Type '{}' resolved to UTI: {}", type_name, uti); 86 | 87 | // Resolve app name to bundle ID 88 | let bundle_id = workspace::resolve_to_bundle_id(app_name)?; 89 | debug!("Resolved app '{}' to bundle ID: {}", app_name, bundle_id); 90 | 91 | // Set the default app for the UTI 92 | launch_services::set_default_app_for_uti(&uti, &bundle_id)?; 93 | 94 | Ok(()) 95 | } 96 | 97 | /// Get information about the default app for a file extension 98 | pub fn get_info_for_extension(extension: &str) -> Result { 99 | debug!("Getting info for extension: .{}", extension); 100 | 101 | let uti = launch_services::get_uti_for_extension(extension)?; 102 | let default_app = launch_services::get_default_app_for_uti(&uti)?; 103 | let all_apps = launch_services::get_all_apps_for_uti(&uti)?; 104 | 105 | Ok(AssociationInfo { 106 | identifier: format!(".{extension}"), 107 | uti: Some(uti), 108 | default_app, 109 | all_apps, 110 | }) 111 | } 112 | 113 | /// Get information about the default app for a URL scheme 114 | pub fn get_info_for_url_scheme(scheme: &str) -> Result { 115 | debug!("Getting info for URL scheme: {}", scheme); 116 | 117 | let default_app = launch_services::get_default_app_for_url_scheme(scheme)?; 118 | let all_apps = launch_services::get_all_apps_for_url_scheme(scheme)?; 119 | 120 | Ok(AssociationInfo { 121 | identifier: scheme.to_string(), 122 | uti: None, 123 | default_app, 124 | all_apps, 125 | }) 126 | } 127 | 128 | /// Get information about the default app for a UTI/supertype 129 | pub fn get_info_for_type(type_name: &str) -> Result { 130 | debug!("Getting info for type: {}", type_name); 131 | 132 | // Try to parse as a SuperType first 133 | let uti = if let Ok(supertype) = type_name.parse::() { 134 | supertype.uti_string().to_string() 135 | } else { 136 | // Assume it's already a UTI string 137 | type_name.to_string() 138 | }; 139 | 140 | let default_app = launch_services::get_default_app_for_uti(&uti)?; 141 | let all_apps = launch_services::get_all_apps_for_uti(&uti)?; 142 | 143 | Ok(AssociationInfo { 144 | identifier: type_name.to_string(), 145 | uti: Some(uti), 146 | default_app, 147 | all_apps, 148 | }) 149 | } 150 | 151 | /// Information about file/URL associations 152 | #[derive(Debug, Clone)] 153 | pub struct AssociationInfo { 154 | pub identifier: String, 155 | pub uti: Option, 156 | pub default_app: Option, 157 | pub all_apps: Vec, 158 | } 159 | 160 | impl AssociationInfo { 161 | /// Get the default app name (if available) 162 | pub fn default_app_name(&self) -> Result> { 163 | if let Some(bundle_id) = &self.default_app { 164 | match workspace::get_app_name_from_bundle_id(bundle_id) { 165 | Ok(name) => Ok(Some(name)), 166 | Err(InfatError::SystemService { .. }) => Ok(Some(bundle_id.clone())), 167 | Err(InfatError::ApplicationNotFound { .. }) => Ok(Some(bundle_id.clone())), 168 | Err(e) => Err(e), 169 | } 170 | } else { 171 | Ok(None) 172 | } 173 | } 174 | 175 | /// Get all app names (with fallback to bundle IDs) 176 | pub fn all_app_names(&self) -> Vec { 177 | self.all_apps 178 | .iter() 179 | .map(|bundle_id| { 180 | workspace::get_app_name_from_bundle_id(bundle_id) 181 | .unwrap_or_else(|_| bundle_id.clone()) 182 | }) 183 | .collect() 184 | } 185 | 186 | /// Get app paths for all registered apps 187 | pub fn all_app_paths(&self) -> Vec { 188 | self.all_apps 189 | .iter() 190 | .filter_map(|bundle_id| { 191 | workspace::get_app_paths_for_bundle_id(bundle_id) 192 | .ok() 193 | .and_then(|paths| paths.first().cloned()) 194 | .map(|path| path.display().to_string()) 195 | }) 196 | .collect() 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /infat-lib/src/config.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | association, 3 | error::{InfatError, Result}, 4 | }; 5 | use serde::{Deserialize, Serialize}; 6 | use std::collections::HashMap; 7 | use std::fs; 8 | use std::path::{Path, PathBuf}; 9 | use tracing::{debug, info, warn}; 10 | 11 | #[derive(Debug, Serialize, Deserialize, Default, Clone)] 12 | pub struct Config { 13 | #[serde(default, skip_serializing_if = "HashMap::is_empty")] 14 | pub extensions: HashMap, 15 | 16 | #[serde(default, skip_serializing_if = "HashMap::is_empty")] 17 | pub schemes: HashMap, 18 | 19 | #[serde(default, skip_serializing_if = "HashMap::is_empty")] 20 | pub types: HashMap, 21 | } 22 | 23 | #[derive(Debug, Clone)] 24 | pub struct ConfigSummary { 25 | pub extensions_count: usize, 26 | pub schemes_count: usize, 27 | pub types_count: usize, 28 | } 29 | 30 | impl ConfigSummary { 31 | pub fn total(&self) -> usize { 32 | self.extensions_count + self.schemes_count + self.types_count 33 | } 34 | } 35 | 36 | impl Config { 37 | /// Load configuration from a TOML file 38 | pub fn from_file>(path: P) -> Result { 39 | let path = path.as_ref(); 40 | 41 | let content = fs::read_to_string(path)?; 42 | let config: Self = toml::from_str(&content)?; 43 | 44 | Ok(config) 45 | } 46 | 47 | /// Save configuration to a TOML file 48 | pub fn to_file>(&self, path: P) -> Result<()> { 49 | let path = path.as_ref(); 50 | 51 | // Create parent directories if they don't exist 52 | if let Some(parent) = path.parent() { 53 | fs::create_dir_all(parent)?; 54 | } 55 | 56 | let content = toml::to_string_pretty(self)?; 57 | fs::write(path, content)?; 58 | 59 | Ok(()) 60 | } 61 | 62 | /// Check if the configuration is empty 63 | pub fn is_empty(&self) -> bool { 64 | self.extensions.is_empty() && self.schemes.is_empty() && self.types.is_empty() 65 | } 66 | 67 | /// Validate the configuration 68 | pub fn validate(&self) -> Result<()> { 69 | if self.is_empty() { 70 | return Err(InfatError::NoConfigTables { 71 | path: PathBuf::from(""), 72 | }); 73 | } 74 | 75 | // Check for invalid keys in types 76 | for type_name in self.types.keys() { 77 | // Try parsing as SuperType or assume it's a UTI 78 | if type_name.parse::().is_err() && !type_name.contains('.') { 79 | warn!("Type '{}' may not be a valid UTI or supertype", type_name); 80 | } 81 | } 82 | 83 | Ok(()) 84 | } 85 | 86 | /// Get summary statistics 87 | pub fn summary(&self) -> ConfigSummary { 88 | ConfigSummary { 89 | extensions_count: self.extensions.len(), 90 | schemes_count: self.schemes.len(), 91 | types_count: self.types.len(), 92 | } 93 | } 94 | } 95 | 96 | /// Get XDG-compliant configuration file paths in order of preference 97 | pub fn get_config_paths() -> Result> { 98 | let mut paths = Vec::new(); 99 | 100 | // User-specified configuration directory 101 | let xdg_config_dirs = std::env::var("XDG_CONFIG_HOME"); 102 | 103 | if let Ok(xdg_config) = xdg_config_dirs { 104 | paths.push( 105 | std::path::PathBuf::from(xdg_config) 106 | .join("infat") 107 | .join("config.toml"), 108 | ); 109 | } 110 | 111 | // Default configuration directory ($XDG_CONFIG_HOME or ~/Library/Application Support) 112 | if let Some(config_dir) = dirs::config_dir() { 113 | paths.push(config_dir.join("infat").join("config.toml")); 114 | } 115 | 116 | if paths.is_empty() { 117 | return Err(InfatError::Generic { message: "Couldn't derive a configuration location, please file an issue -- until it's resolved, please set XDG_CONFIG_HOME".to_string() }); 118 | } 119 | 120 | Ok(paths) 121 | } 122 | 123 | /// Find the first existing configuration file 124 | pub fn find_config_file() -> Result> { 125 | Ok(get_config_paths()?.into_iter().find(|path| path.exists())) 126 | } 127 | 128 | /// Apply configuration settings 129 | pub async fn apply_config(config: &Config, robust: bool) -> Result<()> { 130 | info!("Applying configuration settings"); 131 | 132 | config.validate()?; 133 | 134 | let summary = config.summary(); 135 | info!( 136 | "Configuration contains {} total associations", 137 | summary.total() 138 | ); 139 | 140 | let mut errors = Vec::new(); 141 | let mut success_count = 0; 142 | 143 | // Apply types first 144 | if !config.types.is_empty() { 145 | info!( 146 | "Processing [types] associations ({} entries)...", 147 | config.types.len() 148 | ); 149 | for (type_name, app_name) in &config.types { 150 | match association::set_default_app_for_type(type_name, app_name).await { 151 | Ok(_) => { 152 | info!("✓ Set type {} → {}", type_name, app_name); 153 | success_count += 1; 154 | } 155 | Err(e) => { 156 | let msg = format!("Failed to set type {type_name} → {app_name}: {e}"); 157 | if robust { 158 | warn!("{}", msg); 159 | errors.push(msg); 160 | } else { 161 | return Err(e); 162 | } 163 | } 164 | } 165 | } 166 | } 167 | 168 | // Apply extensions 169 | if !config.extensions.is_empty() { 170 | info!( 171 | "Processing [extensions] associations ({} entries)...", 172 | config.extensions.len() 173 | ); 174 | for (ext, app_name) in &config.extensions { 175 | match association::set_default_app_for_extension(ext, app_name).await { 176 | Ok(_) => { 177 | info!("✓ Set .{} → {}", ext, app_name); 178 | success_count += 1; 179 | } 180 | Err(e) => { 181 | let msg = format!("Failed to set .{ext} → {app_name}: {e}"); 182 | if robust { 183 | warn!("{}", msg); 184 | errors.push(msg); 185 | } else { 186 | return Err(e); 187 | } 188 | } 189 | } 190 | } 191 | } 192 | 193 | // Apply schemes 194 | if !config.schemes.is_empty() { 195 | info!( 196 | "Processing [schemes] associations ({} entries)...", 197 | config.schemes.len() 198 | ); 199 | for (scheme, app_name) in &config.schemes { 200 | match association::set_default_app_for_url_scheme(scheme, app_name).await { 201 | Ok(_) => { 202 | info!("✓ Set {} → {}", scheme, app_name); 203 | success_count += 1; 204 | } 205 | Err(e) => { 206 | let msg = format!("Failed to set {scheme} → {app_name}: {e}"); 207 | if robust { 208 | warn!("{}", msg); 209 | errors.push(msg); 210 | } else { 211 | return Err(e); 212 | } 213 | } 214 | } 215 | } 216 | } 217 | 218 | info!( 219 | "Configuration applied: {} successful, {} errors", 220 | success_count, 221 | errors.len() 222 | ); 223 | 224 | if !errors.is_empty() && robust { 225 | warn!( 226 | "Configuration applied with {} errors in robust mode", 227 | errors.len() 228 | ); 229 | for (i, error) in errors.iter().enumerate() { 230 | debug!(" Error {}: {}", i + 1, error); 231 | } 232 | } 233 | 234 | Ok(()) 235 | } 236 | -------------------------------------------------------------------------------- /infat-lib/src/error.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | use thiserror::Error; 3 | 4 | pub type Result = std::result::Result; 5 | 6 | #[derive(Error, Debug)] 7 | pub enum InfatError { 8 | #[error("Could not derive UTI for extension '.{extension}'")] 9 | CouldNotDeriveUTI { extension: String }, 10 | 11 | #[error("System service '{bundle}' cannot be used as default application")] 12 | SystemService { bundle: String }, 13 | 14 | #[error("Missing required option")] 15 | MissingOption, 16 | 17 | #[error("No valid configuration tables found in '{path}'")] 18 | NoConfigTables { path: PathBuf }, 19 | 20 | #[error("Info.plist not found in application bundle: {app_path}")] 21 | InfoPlistNotFound { app_path: PathBuf }, 22 | 23 | #[error("Unsupported or invalid supertype: {name}")] 24 | UnsupportedSupertype { name: String }, 25 | 26 | #[error("Cannot set URL scheme for application '{app_name}'")] 27 | CannotSetURL { app_name: String }, 28 | 29 | #[error("Cannot register URL, Launch Services error: {error_code}")] 30 | CannotRegisterURL { error_code: i32 }, 31 | 32 | #[error("macOS version not supported for this operation")] 33 | UnsupportedOSVersion, 34 | 35 | #[error("Supertype missing for intended type: {intended_type}")] 36 | SupertypeMissing { intended_type: String }, 37 | 38 | #[error("Conflicting options: {message}")] 39 | ConflictingOptions { message: String }, 40 | 41 | #[error("Error reading directory '{path}'")] 42 | DirectoryReadError { 43 | path: PathBuf, 44 | #[source] 45 | source: std::io::Error, 46 | }, 47 | 48 | #[error("Could not expand path: {path}")] 49 | PathExpansionError { path: PathBuf }, 50 | 51 | #[error("Application not found: {name}")] 52 | ApplicationNotFound { name: String }, 53 | 54 | #[error("Could not get bundle identifier from path: {path}")] 55 | BundleIdNotFound { path: PathBuf }, 56 | 57 | #[error("Error reading Info.plist at '{path}'")] 58 | PlistReadError { 59 | path: PathBuf, 60 | #[source] 61 | source: Box, 62 | }, 63 | 64 | #[error("Failed to set default application")] 65 | DefaultAppSettingError { 66 | #[source] 67 | source: Box, 68 | }, 69 | 70 | #[error("No active application found")] 71 | NoActiveApplication, 72 | 73 | #[error("Failed to load configuration from '{path}'")] 74 | ConfigurationLoadError { 75 | path: PathBuf, 76 | #[source] 77 | source: Box, 78 | }, 79 | 80 | #[error("Operation timed out")] 81 | OperationTimeout, 82 | 83 | #[error("TOML value for key '{key}' is not a string")] 84 | TomlValueNotString { key: String }, 85 | 86 | #[error("Invalid bundle '{bundle}' for application '{app}'")] 87 | InvalidBundle { bundle: String, app: String }, 88 | 89 | #[error("Launch Services API error: {message}")] 90 | LaunchServicesError { message: String }, 91 | 92 | #[error("Core Foundation error: {message}")] 93 | CoreFoundationError { message: String }, 94 | 95 | #[error("Generic error: {message}")] 96 | Generic { message: String }, 97 | 98 | #[error("IO error")] 99 | Io(#[from] std::io::Error), 100 | 101 | #[error("TOML parsing error")] 102 | TomlParse(#[from] toml::de::Error), 103 | 104 | #[error("TOML serialization error")] 105 | TomlSerialize(#[from] toml::ser::Error), 106 | } 107 | 108 | // Add From for InfatError 109 | impl From for InfatError { 110 | fn from(report: eyre::Report) -> Self { 111 | InfatError::Generic { 112 | message: report.to_string(), 113 | } 114 | } 115 | } 116 | 117 | // Helper trait for adding context to our custom errors 118 | pub trait InfatErrorExt { 119 | fn app_not_found(self, name: impl Into) -> Result; 120 | fn config_load_error(self, path: impl Into) -> Result; 121 | fn plist_read_error(self, path: impl Into) -> Result; 122 | } 123 | 124 | impl InfatErrorExt for std::result::Result 125 | where 126 | E: std::error::Error + Send + Sync + 'static, 127 | { 128 | fn app_not_found(self, name: impl Into) -> Result { 129 | self.map_err(|_| InfatError::ApplicationNotFound { name: name.into() }) 130 | } 131 | 132 | fn config_load_error(self, path: impl Into) -> Result { 133 | self.map_err(|e| InfatError::ConfigurationLoadError { 134 | path: path.into(), 135 | source: Box::new(e), 136 | }) 137 | } 138 | 139 | fn plist_read_error(self, path: impl Into) -> Result { 140 | self.map_err(|e| InfatError::PlistReadError { 141 | path: path.into(), 142 | source: Box::new(e), 143 | }) 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /infat-lib/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![allow(unexpected_cfgs)] 2 | //! Infat - Declarative macOS file association and URL scheme management 3 | //! 4 | //! This library provides functionality to inspect and modify default applications 5 | //! for file types and URL schemes on macOS using Launch Services. 6 | 7 | pub mod app; 8 | pub mod association; 9 | pub mod config; 10 | pub mod error; 11 | pub mod uti; 12 | 13 | #[cfg(target_os = "macos")] 14 | pub mod macos { 15 | pub mod ffi; 16 | pub mod launch_services; 17 | pub mod launch_services_db; 18 | pub mod workspace; 19 | } 20 | 21 | pub use error::{InfatError, Result}; 22 | 23 | /// Global configuration and runtime options 24 | #[derive(Debug, Clone, Default)] 25 | pub struct GlobalOptions { 26 | pub config_path: Option, 27 | pub verbose: bool, 28 | pub quiet: bool, 29 | pub robust: bool, 30 | } 31 | 32 | /// Initialize tracing subscriber based on global options 33 | pub fn init_tracing(opts: &GlobalOptions) -> eyre::Result<()> { 34 | use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; 35 | 36 | let filter = if opts.verbose { 37 | EnvFilter::new("trace") 38 | } else if opts.quiet { 39 | EnvFilter::new("error") 40 | } else { 41 | EnvFilter::new("warn") 42 | }; 43 | 44 | tracing_subscriber::registry() 45 | .with(filter) 46 | .with(tracing_subscriber::fmt::layer()) 47 | .try_init() 48 | .map_err(|e| eyre::eyre!("Failed to initialize tracing subscriber: {}", e))?; 49 | 50 | Ok(()) 51 | } 52 | -------------------------------------------------------------------------------- /infat-lib/src/macos/ffi.rs: -------------------------------------------------------------------------------- 1 | //! Raw FFI bindings to macOS Launch Services and related APIs 2 | 3 | use core_foundation::{array::CFArrayRef, base::OSStatus, string::CFStringRef, url::CFURLRef}; 4 | 5 | pub type LSRolesMask = u32; 6 | 7 | pub const K_LS_ROLES_VIEWER: LSRolesMask = 2; 8 | pub const K_LS_ROLES_ALL: LSRolesMask = 0xFFFFFFFF; 9 | 10 | // Launch Services error codes 11 | pub const K_LS_APPLICATION_NOT_FOUND_ERR: OSStatus = -10814; 12 | pub const K_LS_UNKNOWN_ERR: OSStatus = -10810; 13 | 14 | // Unsafe bindings 15 | #[link(name = "CoreServices", kind = "framework")] 16 | extern "C" { 17 | pub fn LSSetDefaultHandlerForURLScheme( 18 | inURLScheme: CFStringRef, 19 | inHandlerBundleID: CFStringRef, 20 | ) -> OSStatus; 21 | 22 | pub fn LSSetDefaultRoleHandlerForContentType( 23 | inContentType: CFStringRef, 24 | inRole: LSRolesMask, 25 | inHandlerBundleID: CFStringRef, 26 | ) -> OSStatus; 27 | 28 | pub fn LSCopyDefaultHandlerForURLScheme(inURLScheme: CFStringRef) -> CFStringRef; 29 | 30 | pub fn LSCopyDefaultRoleHandlerForContentType( 31 | inContentType: CFStringRef, 32 | inRole: LSRolesMask, 33 | ) -> CFStringRef; 34 | 35 | pub fn LSCopyAllHandlersForURLScheme(inURLScheme: CFStringRef) -> CFArrayRef; 36 | 37 | pub fn LSCopyAllRoleHandlersForContentType( 38 | inContentType: CFStringRef, 39 | inRole: LSRolesMask, 40 | ) -> CFArrayRef; 41 | 42 | pub fn LSRegisterURL(inURL: CFURLRef, inUpdate: bool) -> OSStatus; 43 | 44 | pub fn UTTypeCreatePreferredIdentifierForTag( 45 | inTagClass: CFStringRef, 46 | inTag: CFStringRef, 47 | inConformingToUTI: CFStringRef, 48 | ) -> CFStringRef; 49 | 50 | pub fn UTTypeCopyPreferredTagWithClass( 51 | inUTI: CFStringRef, 52 | inTagClass: CFStringRef, 53 | ) -> CFStringRef; 54 | } 55 | 56 | // UTI tag classes 57 | pub const K_UT_TAG_CLASS_FILENAME_EXTENSION: &str = "public.filename-extension"; 58 | pub const K_UT_TAG_CLASS_MIME_TYPE: &str = "public.mime-type"; 59 | -------------------------------------------------------------------------------- /infat-lib/src/macos/launch_services.rs: -------------------------------------------------------------------------------- 1 | //! High-level Launch Services API wrappers 2 | 3 | use super::ffi::*; 4 | use crate::error::{InfatError, Result}; 5 | use core_foundation::{array::CFArray, base::TCFType, string::CFString, url::CFURL}; 6 | use std::path::Path; 7 | use tracing::debug; 8 | 9 | /// Set the default application for a URL scheme 10 | pub fn set_default_app_for_url_scheme(scheme: &str, bundle_id: &str) -> Result<()> { 11 | debug!( 12 | "Setting default app for scheme '{}' to '{}'", 13 | scheme, bundle_id 14 | ); 15 | 16 | let cf_scheme = CFString::new(scheme); 17 | let cf_bundle_id = CFString::new(bundle_id); 18 | 19 | let status = unsafe { 20 | LSSetDefaultHandlerForURLScheme( 21 | cf_scheme.as_concrete_TypeRef(), 22 | cf_bundle_id.as_concrete_TypeRef(), 23 | ) 24 | }; 25 | 26 | if status != 0 { 27 | return Err(InfatError::LaunchServicesError { 28 | message: format!("Failed to set URL scheme handler: error {status}"), 29 | }); 30 | } 31 | 32 | debug!("Successfully set {} → {}", scheme, bundle_id); 33 | Ok(()) 34 | } 35 | 36 | /// Set the default application for a UTI 37 | pub fn set_default_app_for_uti(uti: &str, bundle_id: &str) -> Result<()> { 38 | debug!("Setting default app for UTI '{}' to '{}'", uti, bundle_id); 39 | 40 | let cf_uti = CFString::new(uti); 41 | let cf_bundle_id = CFString::new(bundle_id); 42 | 43 | let status = unsafe { 44 | LSSetDefaultRoleHandlerForContentType( 45 | cf_uti.as_concrete_TypeRef(), 46 | K_LS_ROLES_VIEWER, 47 | cf_bundle_id.as_concrete_TypeRef(), 48 | ) 49 | }; 50 | 51 | if status != 0 { 52 | return Err(InfatError::LaunchServicesError { 53 | message: format!("Failed to set UTI handler: error {status}"), 54 | }); 55 | } 56 | 57 | debug!("Successfully set UTI {} → {}", uti, bundle_id); 58 | Ok(()) 59 | } 60 | 61 | /// Get the default application bundle ID for a URL scheme 62 | pub fn get_default_app_for_url_scheme(scheme: &str) -> Result> { 63 | debug!("Getting default app for URL scheme: {}", scheme); 64 | 65 | let cf_scheme = CFString::new(scheme); 66 | let cf_bundle_id = unsafe { LSCopyDefaultHandlerForURLScheme(cf_scheme.as_concrete_TypeRef()) }; 67 | 68 | if cf_bundle_id.is_null() { 69 | return Ok(None); 70 | } 71 | 72 | let bundle_id = unsafe { CFString::wrap_under_create_rule(cf_bundle_id) }.to_string(); 73 | debug!("Default app for scheme '{}': {}", scheme, bundle_id); 74 | Ok(Some(bundle_id)) 75 | } 76 | 77 | /// Get the default application bundle ID for a UTI 78 | pub fn get_default_app_for_uti(uti: &str) -> Result> { 79 | debug!("Getting default app for UTI: {}", uti); 80 | 81 | let cf_uti = CFString::new(uti); 82 | let cf_bundle_id = unsafe { 83 | LSCopyDefaultRoleHandlerForContentType(cf_uti.as_concrete_TypeRef(), K_LS_ROLES_VIEWER) 84 | }; 85 | 86 | if cf_bundle_id.is_null() { 87 | return Ok(None); 88 | } 89 | 90 | let bundle_id = unsafe { CFString::wrap_under_create_rule(cf_bundle_id) }.to_string(); 91 | debug!("Default app for UTI '{}': {}", uti, bundle_id); 92 | Ok(Some(bundle_id)) 93 | } 94 | 95 | /// Get all applications that can handle a URL scheme 96 | pub fn get_all_apps_for_url_scheme(scheme: &str) -> Result> { 97 | debug!("Getting all apps for URL scheme: {}", scheme); 98 | 99 | let cf_scheme = CFString::new(scheme); 100 | let cf_array = unsafe { LSCopyAllHandlersForURLScheme(cf_scheme.as_concrete_TypeRef()) }; 101 | 102 | if cf_array.is_null() { 103 | return Ok(Vec::new()); 104 | } 105 | 106 | let array = unsafe { CFArray::::wrap_under_create_rule(cf_array) }; 107 | let mut result = Vec::new(); 108 | 109 | for i in 0..array.len() { 110 | if let Some(cf_string) = array.get(i) { 111 | result.push(cf_string.to_string()); 112 | } 113 | } 114 | 115 | debug!("Found {} apps for scheme '{}'", result.len(), scheme); 116 | Ok(result) 117 | } 118 | 119 | /// Get all applications that can handle a UTI 120 | pub fn get_all_apps_for_uti(uti: &str) -> Result> { 121 | debug!("Getting all apps for UTI: {}", uti); 122 | 123 | let cf_uti = CFString::new(uti); 124 | let cf_array = unsafe { 125 | LSCopyAllRoleHandlersForContentType(cf_uti.as_concrete_TypeRef(), K_LS_ROLES_VIEWER) 126 | }; 127 | 128 | if cf_array.is_null() { 129 | return Ok(Vec::new()); 130 | } 131 | 132 | let array = unsafe { CFArray::::wrap_under_create_rule(cf_array) }; 133 | let mut result = Vec::new(); 134 | 135 | for i in 0..array.len() { 136 | if let Some(cf_string) = array.get(i) { 137 | result.push(cf_string.to_string()); 138 | } 139 | } 140 | 141 | debug!("Found {} apps for UTI '{}'", result.len(), uti); 142 | Ok(result) 143 | } 144 | 145 | /// Register an application bundle with Launch Services 146 | pub fn register_application>(app_path: P) -> Result<()> { 147 | let path = app_path.as_ref(); 148 | debug!("Registering application: {}", path.display()); 149 | 150 | let cf_url = CFURL::from_path(path, true).ok_or_else(|| InfatError::PathExpansionError { 151 | path: path.to_path_buf(), 152 | })?; 153 | 154 | let status = unsafe { LSRegisterURL(cf_url.as_concrete_TypeRef(), true) }; 155 | 156 | if status != 0 { 157 | return Err(InfatError::LaunchServicesError { 158 | message: format!("Failed to register application: error {status}"), 159 | }); 160 | } 161 | 162 | debug!("Successfully registered application: {}", path.display()); 163 | Ok(()) 164 | } 165 | 166 | /// Get the UTI for a file extension 167 | pub fn get_uti_for_extension(extension: &str) -> Result { 168 | debug!("Getting UTI for extension: {}", extension); 169 | 170 | let cf_tag_class = CFString::new(K_UT_TAG_CLASS_FILENAME_EXTENSION); 171 | let cf_extension = CFString::new(extension); 172 | let cf_conforming_to = CFString::new(""); 173 | 174 | let cf_uti = unsafe { 175 | UTTypeCreatePreferredIdentifierForTag( 176 | cf_tag_class.as_concrete_TypeRef(), 177 | cf_extension.as_concrete_TypeRef(), 178 | cf_conforming_to.as_concrete_TypeRef(), 179 | ) 180 | }; 181 | 182 | if cf_uti.is_null() { 183 | return Err(InfatError::CouldNotDeriveUTI { 184 | extension: extension.to_string(), 185 | }); 186 | } 187 | 188 | let uti = unsafe { CFString::wrap_under_create_rule(cf_uti) }.to_string(); 189 | debug!("UTI for extension '{}': {}", extension, uti); 190 | Ok(uti) 191 | } 192 | 193 | /// Get the preferred file extension for a UTI 194 | pub fn get_extension_for_uti(uti: &str) -> Result> { 195 | debug!("Getting extension for UTI: {}", uti); 196 | 197 | let cf_uti = CFString::new(uti); 198 | let cf_tag_class = CFString::new(K_UT_TAG_CLASS_FILENAME_EXTENSION); 199 | 200 | let cf_extension = unsafe { 201 | UTTypeCopyPreferredTagWithClass( 202 | cf_uti.as_concrete_TypeRef(), 203 | cf_tag_class.as_concrete_TypeRef(), 204 | ) 205 | }; 206 | 207 | if cf_extension.is_null() { 208 | return Ok(None); 209 | } 210 | 211 | let extension = unsafe { CFString::wrap_under_create_rule(cf_extension) }.to_string(); 212 | debug!("Extension for UTI '{}': {}", uti, extension); 213 | Ok(Some(extension)) 214 | } 215 | -------------------------------------------------------------------------------- /infat-lib/src/macos/launch_services_db.rs: -------------------------------------------------------------------------------- 1 | //! Launch Services database parsing for the init command 2 | 3 | use crate::error::{InfatError, Result}; 4 | use crate::macos::workspace::{self, resolve_to_bundle_id}; 5 | use plist::Value; 6 | use serde::{Deserialize, Serialize}; 7 | use std::collections::HashMap; 8 | use tracing::{debug, info, warn}; 9 | 10 | #[derive(Debug, Deserialize, Serialize)] 11 | pub struct LaunchServicesHandler { 12 | #[serde(rename = "LSHandlerContentType")] 13 | pub content_type: Option, 14 | 15 | #[serde(rename = "LSHandlerContentTag")] 16 | pub content_tag: Option, 17 | 18 | #[serde(rename = "LSHandlerContentTagClass")] 19 | pub content_tag_class: Option, 20 | 21 | #[serde(rename = "LSHandlerURLScheme")] 22 | pub url_scheme: Option, 23 | 24 | #[serde(rename = "LSHandlerRoleAll")] 25 | pub role_all: Option, 26 | 27 | #[serde(rename = "LSHandlerRoleViewer")] 28 | pub role_viewer: Option, 29 | 30 | #[serde(rename = "LSHandlerRoleEditor")] 31 | pub role_editor: Option, 32 | 33 | #[serde(rename = "LSHandlerPreferredVersions")] 34 | pub preferred_versions: Option>, 35 | 36 | #[serde(rename = "LSHandlerModificationDate")] 37 | pub modification_date: Option, 38 | } 39 | 40 | #[derive(Debug, Deserialize, Serialize)] 41 | pub struct LaunchServicesDatabase { 42 | #[serde(rename = "LSHandlers")] 43 | pub handlers: Vec, 44 | } 45 | 46 | /// Read the Launch Services database from the user's preferences 47 | pub fn read_launch_services_database() -> Result { 48 | let home = dirs::home_dir().ok_or_else(|| InfatError::LaunchServicesError { 49 | message: "Could not determine home directory".to_string(), 50 | })?; 51 | 52 | // Needs to be consistent across systems 53 | let ls_path = home 54 | .join("Library") 55 | .join("Preferences") 56 | .join("com.apple.LaunchServices") 57 | .join("com.apple.launchservices.secure.plist"); 58 | 59 | debug!( 60 | "Reading Launch Services database from: {}", 61 | ls_path.display() 62 | ); 63 | 64 | if !ls_path.exists() { 65 | return Err(InfatError::LaunchServicesError { 66 | message: format!( 67 | "Launch Services database not found at: {}", 68 | ls_path.display() 69 | ), 70 | }); 71 | } 72 | 73 | // Here we first parse into an arbitrary value and then compare against our schema 74 | 75 | let plist_data = std::fs::read(&ls_path)?; 76 | let value: Value = 77 | plist::from_bytes(&plist_data).map_err(|e| InfatError::LaunchServicesError { 78 | message: format!("Failed to parse Launch Services database: {e}"), 79 | })?; 80 | 81 | let db: LaunchServicesDatabase = 82 | plist::from_value(&value).map_err(|e| InfatError::LaunchServicesError { 83 | message: format!("Failed to deserialize Launch Services database: {e}"), 84 | })?; 85 | 86 | info!( 87 | "Successfully loaded Launch Services database with {} handlers", 88 | db.handlers.len() 89 | ); 90 | 91 | Ok(db) 92 | } 93 | 94 | /// Generate a config from the current Launch Services database 95 | pub fn generate_config_from_launch_services(robust: bool) -> Result { 96 | let db = read_launch_services_database()?; 97 | 98 | let mut extensions = HashMap::new(); 99 | let mut schemes = HashMap::new(); 100 | let mut types = HashMap::new(); 101 | let mut skipped_count = 0; 102 | let mut processed_count = 0; 103 | 104 | for handler in db.handlers { 105 | if let Some(bundle_id) = handler.role_all { 106 | // Skip malformed entries 107 | if bundle_id == "-" { 108 | debug!("Skipping malformed handler entry"); 109 | skipped_count += 1; 110 | continue; 111 | } 112 | 113 | // Skip system services 114 | if crate::macos::workspace::is_system_service(&bundle_id) { 115 | debug!("Skipping system service: {}", bundle_id); 116 | skipped_count += 1; 117 | continue; 118 | } 119 | 120 | // Canonicalize the id (There's sometimes a difference between the id the application provides to launchservices and the one it'll key itself as to be identified as) 121 | let canonical_id = match resolve_to_bundle_id(&bundle_id) { 122 | Ok(id) => id, 123 | Err(_) => { 124 | // couldn’t resolve, so skip or warn 125 | if robust { 126 | warn!("Skipping unresolved bundle id {:?}", bundle_id); 127 | skipped_count += 1; 128 | continue; 129 | } else { 130 | return Err(InfatError::ApplicationNotFound { name: bundle_id }); 131 | } 132 | } 133 | }; 134 | 135 | let app_name = match workspace::get_app_name_from_bundle_id(&canonical_id) { 136 | Ok(name) => name, 137 | Err(e) => { 138 | if robust { 139 | warn!("Skipping {}: {}", canonical_id, e); 140 | skipped_count += 1; 141 | continue; 142 | } else { 143 | return Err(e); 144 | } 145 | } 146 | }; 147 | 148 | // Process different handler types 149 | if let Some(scheme) = handler.url_scheme { 150 | schemes.insert(scheme, app_name); 151 | processed_count += 1; 152 | } else if let Some(content_type) = handler.content_type { 153 | types.insert(content_type, app_name); 154 | processed_count += 1; 155 | } else if let Some(tag_class) = handler.content_tag_class { 156 | if tag_class == "public.filename-extension" { 157 | if let Some(ext) = handler.content_tag { 158 | extensions.insert(ext, app_name); 159 | processed_count += 1; 160 | } 161 | } 162 | } 163 | } 164 | } 165 | 166 | info!( 167 | "Processed {} handlers, skipped {} (system services/not found)", 168 | processed_count, skipped_count 169 | ); 170 | 171 | Ok(crate::config::Config { 172 | extensions, 173 | schemes, 174 | types, 175 | }) 176 | } 177 | -------------------------------------------------------------------------------- /infat-lib/src/macos/workspace.rs: -------------------------------------------------------------------------------- 1 | //! NSWorkspace integration for app discovery and management 2 | 3 | use crate::error::{InfatError, Result}; 4 | use objc::{class, msg_send, runtime::Object, sel, sel_impl}; 5 | use objc_foundation::{INSString, NSString}; 6 | use std::path::{Path, PathBuf}; 7 | use tracing::debug; 8 | 9 | // Make a point of linking the AppKit framework 10 | #[link(name = "AppKit", kind = "framework")] 11 | extern "C" {} 12 | 13 | /// Get the shared NSWorkspace instance 14 | unsafe fn shared_workspace() -> *mut Object { 15 | let workspace_class = class!(NSWorkspace); 16 | msg_send![workspace_class, sharedWorkspace] 17 | } 18 | 19 | /// Find application paths for a bundle identifier 20 | pub fn get_app_paths_for_bundle_id(bundle_id: &str) -> Result> { 21 | debug!("Finding app paths for bundle ID: {}", bundle_id); 22 | 23 | unsafe { 24 | let workspace = shared_workspace(); 25 | let ns_bundle_id = NSString::from_str(bundle_id); 26 | let cf_url: *mut Object = 27 | msg_send![workspace, URLForApplicationWithBundleIdentifier: ns_bundle_id]; 28 | 29 | if cf_url.is_null() { 30 | debug!("No application found for bundle ID: {}", bundle_id); 31 | return Ok(Vec::new()); 32 | } 33 | 34 | let ns_path: *mut NSString = msg_send![cf_url, path]; 35 | let path_str = (*ns_path).as_str(); 36 | let path = PathBuf::from(path_str); 37 | 38 | debug!("Found app path for {}: {}", bundle_id, path.display()); 39 | Ok(vec![path]) 40 | } 41 | } 42 | 43 | /// Get bundle identifier from application path 44 | pub fn get_bundle_id_from_app_path>(app_path: P) -> Result { 45 | let path = app_path.as_ref(); 46 | debug!("Getting bundle ID for app: {}", path.display()); 47 | 48 | // Read Info.plist from the app bundle 49 | let info_plist_path = path.join("Contents").join("Info.plist"); 50 | 51 | if !info_plist_path.exists() { 52 | return Err(InfatError::InfoPlistNotFound { 53 | app_path: path.to_path_buf(), 54 | }); 55 | } 56 | 57 | let plist_data = std::fs::read(&info_plist_path)?; 58 | let plist: plist::Value = 59 | plist::from_bytes(&plist_data).map_err(|e| InfatError::PlistReadError { 60 | path: info_plist_path.clone(), 61 | source: Box::new(e), 62 | })?; 63 | 64 | let bundle_id = plist 65 | .as_dictionary() 66 | .and_then(|dict| dict.get("CFBundleIdentifier")) 67 | .and_then(|val| val.as_string()) 68 | .ok_or_else(|| InfatError::BundleIdNotFound { 69 | path: path.to_path_buf(), 70 | })?; 71 | 72 | debug!("Bundle ID for {}: {}", path.display(), bundle_id); 73 | Ok(bundle_id.to_string()) 74 | } 75 | 76 | /// Get app name (display name) from bundle ID 77 | pub fn get_app_name_from_bundle_id(bundle_id: &str) -> Result { 78 | debug!("Getting app name for bundle ID: {}", bundle_id); 79 | 80 | // Check for system services 81 | if is_system_service(bundle_id) { 82 | return Err(InfatError::SystemService { 83 | bundle: bundle_id.to_string(), 84 | }); 85 | } 86 | 87 | let app_paths = get_app_paths_for_bundle_id(bundle_id)?; 88 | 89 | let app_path = app_paths 90 | .first() 91 | .ok_or_else(|| InfatError::ApplicationNotFound { 92 | name: bundle_id.to_string(), 93 | })?; 94 | 95 | // Read display name from Info.plist 96 | let info_plist_path = app_path.join("Contents").join("Info.plist"); 97 | let plist_data = std::fs::read(&info_plist_path)?; 98 | let plist: plist::Value = 99 | plist::from_bytes(&plist_data).map_err(|e| InfatError::PlistReadError { 100 | path: info_plist_path.clone(), 101 | source: Box::new(e), 102 | })?; 103 | 104 | let dict = plist 105 | .as_dictionary() 106 | .ok_or_else(|| InfatError::PlistReadError { 107 | path: info_plist_path.clone(), 108 | source: "Info.plist root is not a dictionary".into(), 109 | })?; 110 | 111 | // Try CFBundleDisplayName first, then CFBundleName 112 | let app_name = dict 113 | .get("CFBundleDisplayName") 114 | .or_else(|| dict.get("CFBundleName")) 115 | .and_then(|val| val.as_string()) 116 | .unwrap_or("Unknown"); 117 | 118 | let authoritative_id = dict 119 | .get("CFBundleIdentifier") 120 | .expect("Required for any registered app") 121 | .as_string() 122 | .unwrap_or("Unknown"); 123 | 124 | // Prioritize the pretty name but fallbak on the authoritative 125 | if app_name != authoritative_id { 126 | return Ok(app_name.to_string()); 127 | } 128 | 129 | debug!("App name for {}: {}", bundle_id, app_name); 130 | Ok(app_name.to_string()) 131 | } 132 | 133 | /// Find applications in standard directories 134 | pub fn find_applications() -> Result> { 135 | debug!("Searching for applications in standard directories"); 136 | 137 | let search_paths = [ 138 | "/Applications", 139 | "/System/Applications", 140 | "/System/Library/CoreServices/Applications", 141 | &format!("{}/Applications", std::env::var("HOME").unwrap_or_default()), 142 | ]; 143 | 144 | let mut apps = Vec::new(); 145 | 146 | for search_path in &search_paths { 147 | let path = Path::new(search_path); 148 | if !path.exists() { 149 | debug!("Skipping non-existent path: {}", search_path); 150 | continue; 151 | } 152 | 153 | match std::fs::read_dir(path) { 154 | Ok(entries) => { 155 | let mut found_count = 0; 156 | for entry in entries.flatten() { 157 | let entry_path = entry.path(); 158 | 159 | // Follow symlinks to get the actual target 160 | let resolved_path = if entry_path.is_symlink() { 161 | match std::fs::canonicalize(&entry_path) { 162 | Ok(canonical) => canonical, 163 | Err(_) => continue, // Skip broken symlinks 164 | } 165 | } else { 166 | entry_path.clone() 167 | }; 168 | 169 | if resolved_path.extension().is_some_and(|ext| ext == "app") { 170 | apps.push(entry_path); 171 | found_count += 1; 172 | } 173 | } 174 | debug!("Found {} apps in {}", found_count, search_path); 175 | } 176 | Err(e) => { 177 | debug!("Could not read directory {}: {}", search_path, e); 178 | } 179 | } 180 | } 181 | 182 | debug!("Total applications found: {}", apps.len()); 183 | Ok(apps) 184 | } 185 | 186 | /// Find application by name or bundle ID 187 | pub fn find_application(name_or_bundle_id: &str) -> Result> { 188 | debug!("Finding application: {}", name_or_bundle_id); 189 | 190 | // If it looks like a bundle ID, try that first 191 | if name_or_bundle_id.contains('.') { 192 | if let Ok(paths) = get_app_paths_for_bundle_id(name_or_bundle_id) { 193 | if let Some(path) = paths.first() { 194 | return Ok(Some(path.clone())); 195 | } 196 | } 197 | } 198 | 199 | // Try as a file path 200 | let path = PathBuf::from(name_or_bundle_id); 201 | if path.exists() && path.extension().is_some_and(|ext| ext == "app") { 202 | return Ok(Some(path)); 203 | } 204 | 205 | // Search by name in standard directories 206 | let apps = find_applications()?; 207 | for app_path in apps { 208 | let app_name = app_path 209 | .file_stem() 210 | .and_then(|stem| stem.to_str()) 211 | .unwrap_or(""); 212 | 213 | if app_name.eq_ignore_ascii_case(name_or_bundle_id) { 214 | return Ok(Some(app_path)); 215 | } 216 | } 217 | 218 | debug!("Application not found: {}", name_or_bundle_id); 219 | Ok(None) 220 | } 221 | 222 | /// Check if a bundle ID represents a system service 223 | pub fn is_system_service(bundle_id: &str) -> bool { 224 | bundle_id.starts_with("com.apple.") 225 | && (bundle_id.contains("service") 226 | || bundle_id.contains("ui") 227 | || bundle_id.contains("daemon")) 228 | } 229 | 230 | /// Resolve app name or bundle ID to a bundle ID 231 | pub fn resolve_to_bundle_id(name_or_bundle_id: &str) -> Result { 232 | debug!("Resolving to bundle ID: {}", name_or_bundle_id); 233 | 234 | // Find the application and get its bundle ID 235 | let app_path = 236 | find_application(name_or_bundle_id)?.ok_or_else(|| InfatError::ApplicationNotFound { 237 | name: name_or_bundle_id.to_string(), 238 | })?; 239 | 240 | get_bundle_id_from_app_path(app_path) 241 | } 242 | -------------------------------------------------------------------------------- /infat-lib/src/uti.rs: -------------------------------------------------------------------------------- 1 | use crate::error::{InfatError, Result}; 2 | use serde::{Deserialize, Serialize}; 3 | use std::str::FromStr; 4 | 5 | /// Standard UTI supertypes that infat recognizes 6 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 7 | #[serde(rename_all = "kebab-case")] 8 | pub enum SuperType { 9 | // Text & Documents 10 | Text, 11 | PlainText, 12 | Csv, 13 | Json, 14 | Xml, 15 | Yaml, 16 | Html, 17 | Markdown, 18 | Rtf, 19 | 20 | // Images 21 | Image, 22 | RawImage, 23 | Png, 24 | Jpeg, 25 | Gif, 26 | Tiff, 27 | Svg, 28 | WebP, 29 | Heic, 30 | Heif, 31 | Bmp, 32 | 33 | // Audio 34 | Audio, 35 | Mp3, 36 | Wav, 37 | Aiff, 38 | Midi, 39 | Mp4Audio, 40 | AppleProtectedMp4Audio, 41 | Flac, 42 | OggAudio, 43 | Ac3Audio, 44 | AacAudio, 45 | 46 | // Video 47 | Video, 48 | Mpeg2TransportStream, 49 | Movie, 50 | QuicktimeMovie, 51 | Mp4Movie, 52 | AppleProtectedMp4Video, 53 | Mpeg, 54 | Mpeg2Video, 55 | Avi, 56 | DvMovie, 57 | RealMedia, 58 | RealAudio, 59 | Webm, 60 | Matroska, 61 | M3uPlaylist, 62 | 63 | // Archives 64 | Archive, 65 | Zip, 66 | Gzip, 67 | Tar, 68 | Bz2, 69 | AppleArchive, 70 | 71 | // Source Code 72 | Sourcecode, 73 | CSource, 74 | CHeader, 75 | CppSource, 76 | CppHeader, 77 | ObjcSource, 78 | ObjcPlusPlusSource, 79 | SwiftSource, 80 | Shell, 81 | Makefile, 82 | Javascript, 83 | PythonScript, 84 | RubyScript, 85 | PerlScript, 86 | PhpScript, 87 | AppleScript, 88 | AssemblySource, 89 | 90 | // System 91 | Data, 92 | Directory, 93 | Folder, 94 | Symlink, 95 | Executable, 96 | UnixExecutable, 97 | AppBundle, 98 | Framework, 99 | DiskImage, 100 | Volume, 101 | MountPoint, 102 | AliasFile, 103 | 104 | // 3D Content 105 | ThreeDContent, 106 | Usd, 107 | Usdz, 108 | RealityFile, 109 | SceneKitScene, 110 | 111 | // Fonts 112 | Font, 113 | 114 | // Cryptographic 115 | Pkcs12, 116 | X509Certificate, 117 | 118 | // URLs 119 | Url, 120 | FileUrl, 121 | UrlBookmarkData, 122 | 123 | // Property Lists 124 | PropertyList, 125 | XmlPropertyList, 126 | BinaryPropertyList, 127 | 128 | // Misc 129 | Log, 130 | Bookmark, 131 | InternetLocation, 132 | InternetShortcut, 133 | 134 | // Apple-specific 135 | DefaultAppWebBrowser, 136 | DefaultAppMailClient, 137 | M4vVideo, 138 | M4aAudio, 139 | } 140 | 141 | impl SuperType { 142 | /// Get the corresponding macOS UTI string 143 | pub fn uti_string(&self) -> &'static str { 144 | match self { 145 | Self::Text => "public.text", 146 | Self::PlainText => "public.plain-text", 147 | Self::Csv => "public.comma-separated-values-text", 148 | Self::Json => "public.json", 149 | Self::Xml => "public.xml", 150 | Self::Yaml => "public.yaml", 151 | Self::Html => "public.html", 152 | Self::Markdown => "net.daringfireball.markdown", 153 | Self::Rtf => "public.rtf", 154 | 155 | Self::Image => "public.image", 156 | Self::RawImage => "public.camera-raw-image", 157 | Self::Png => "public.png", 158 | Self::Jpeg => "public.jpeg", 159 | Self::Gif => "com.compuserve.gif", 160 | Self::Tiff => "public.tiff", 161 | Self::Svg => "public.svg-image", 162 | Self::WebP => "org.webmproject.webp", 163 | Self::Heic => "public.heic", 164 | Self::Heif => "public.heif", 165 | Self::Bmp => "com.microsoft.bmp", 166 | 167 | Self::Audio => "public.audio", 168 | Self::Mp3 => "public.mp3", 169 | Self::Wav => "com.microsoft.waveform-audio", 170 | Self::Aiff => "public.aiff-audio", 171 | Self::Midi => "public.midi-audio", 172 | Self::Mp4Audio => "public.mpeg-4-audio", 173 | Self::AppleProtectedMp4Audio => "com.apple.protected-mpeg-4-audio", 174 | Self::Flac => "org.xiph.flac", 175 | Self::OggAudio => "org.xiph.ogg-audio", 176 | Self::Ac3Audio => "public.ac3-audio", 177 | Self::AacAudio => "public.aac-audio", 178 | 179 | Self::Video => "public.video", 180 | Self::Movie => "public.movie", 181 | Self::QuicktimeMovie => "com.apple.quicktime-movie", 182 | Self::Mp4Movie => "public.mpeg-4", 183 | Self::AppleProtectedMp4Video => "com.apple.protected-mpeg-4-video", 184 | Self::Mpeg => "public.mpeg", 185 | Self::Mpeg2Video => "public.mpeg-2-video", 186 | Self::Avi => "public.avi", 187 | Self::DvMovie => "public.dv-movie", 188 | Self::RealMedia => "com.real.realmedia", 189 | Self::RealAudio => "com.real.realaudio", 190 | Self::Mpeg2TransportStream => "mpeg2-transport-stream", 191 | Self::Webm => "org.webmproject.webm", 192 | Self::M3uPlaylist => "m3u-playlist", 193 | Self::Matroska => "org.matroska.mkv", 194 | 195 | Self::Archive => "public.archive", 196 | Self::Zip => "public.zip-archive", 197 | Self::Gzip => "org.gnu.gnu-zip-archive", 198 | Self::Tar => "public.tar-archive", 199 | Self::Bz2 => "public.bzip2-archive", 200 | Self::AppleArchive => "com.apple.archive", 201 | 202 | Self::Sourcecode => "public.source-code", 203 | Self::CSource => "public.c-source", 204 | Self::CHeader => "public.c-header", 205 | Self::CppSource => "public.c-plus-plus-source", 206 | Self::CppHeader => "public.c-plus-plus-header", 207 | Self::ObjcSource => "public.objective-c-source", 208 | Self::ObjcPlusPlusSource => "public.objective-c-plus-plus-source", 209 | Self::SwiftSource => "public.swift-source", 210 | Self::Shell => "public.shell-script", 211 | Self::Makefile => "public.make-source", 212 | Self::Javascript => "com.netscape.javascript-source", 213 | Self::PythonScript => "public.python-script", 214 | Self::RubyScript => "public.ruby-script", 215 | Self::PerlScript => "public.perl-script", 216 | Self::PhpScript => "public.php-script", 217 | Self::AppleScript => "com.apple.applescript.text", 218 | Self::AssemblySource => "public.assembly-source", 219 | 220 | Self::Data => "public.data", 221 | Self::Directory => "public.directory", 222 | Self::Folder => "public.folder", 223 | Self::Symlink => "public.symlink", 224 | Self::Executable => "public.executable", 225 | Self::UnixExecutable => "public.unix-executable", 226 | Self::AppBundle => "com.apple.application-bundle", 227 | Self::Framework => "com.apple.framework", 228 | Self::DiskImage => "public.disk-image", 229 | Self::Volume => "public.volume", 230 | Self::MountPoint => "com.apple.mount-point", 231 | Self::AliasFile => "com.apple.alias-file", 232 | 233 | Self::ThreeDContent => "public.3d-content", 234 | Self::Usd => "com.pixar.universal-scene-description", 235 | Self::Usdz => "com.pixar.universal-scene-description-mobile", 236 | Self::RealityFile => "com.apple.reality", 237 | Self::SceneKitScene => "com.apple.scenekit.scene", 238 | 239 | Self::Font => "public.font", 240 | 241 | Self::Pkcs12 => "com.rsa.pkcs-12", 242 | Self::X509Certificate => "public.x509-certificate", 243 | 244 | Self::Url => "public.url", 245 | Self::FileUrl => "public.file-url", 246 | Self::UrlBookmarkData => "com.apple.bookmark", 247 | 248 | Self::PropertyList => "com.apple.property-list", 249 | Self::XmlPropertyList => "com.apple.xml-property-list", 250 | Self::BinaryPropertyList => "com.apple.binary-property-list", 251 | 252 | Self::Log => "public.log", 253 | Self::Bookmark => "public.bookmark", 254 | Self::InternetLocation => "com.apple.web-internet-location", 255 | Self::InternetShortcut => "com.microsoft.internet-shortcut", 256 | 257 | Self::DefaultAppWebBrowser => "com.apple.default-app.web-browser", 258 | Self::DefaultAppMailClient => "com.apple.default-app.mail-client", 259 | Self::M4vVideo => "com.apple.m4v-video", 260 | Self::M4aAudio => "com.apple.m4a-audio", 261 | } 262 | } 263 | 264 | /// Get all available supertypes 265 | pub fn all() -> Vec { 266 | vec![ 267 | Self::Text, 268 | Self::PlainText, 269 | Self::Csv, 270 | Self::Json, 271 | Self::Xml, 272 | Self::Yaml, 273 | Self::M3uPlaylist, 274 | Self::Html, 275 | Self::Markdown, 276 | Self::Rtf, 277 | Self::Image, 278 | Self::RawImage, 279 | Self::Png, 280 | Self::Jpeg, 281 | Self::Gif, 282 | Self::Tiff, 283 | Self::Svg, 284 | Self::WebP, 285 | Self::Heic, 286 | Self::Heif, 287 | Self::Bmp, 288 | Self::Audio, 289 | Self::Mp3, 290 | Self::Wav, 291 | Self::Aiff, 292 | Self::Midi, 293 | Self::Mp4Audio, 294 | Self::AppleProtectedMp4Audio, 295 | Self::Flac, 296 | Self::OggAudio, 297 | Self::Ac3Audio, 298 | Self::AacAudio, 299 | Self::Video, 300 | Self::Movie, 301 | Self::QuicktimeMovie, 302 | Self::Mp4Movie, 303 | Self::AppleProtectedMp4Video, 304 | Self::Mpeg, 305 | Self::Mpeg2Video, 306 | Self::Avi, 307 | Self::DvMovie, 308 | Self::RealMedia, 309 | Self::RealAudio, 310 | Self::Webm, 311 | Self::Matroska, 312 | Self::Archive, 313 | Self::Zip, 314 | Self::Gzip, 315 | Self::Tar, 316 | Self::Bz2, 317 | Self::AppleArchive, 318 | Self::Sourcecode, 319 | Self::CSource, 320 | Self::CHeader, 321 | Self::CppSource, 322 | Self::CppHeader, 323 | Self::ObjcSource, 324 | Self::ObjcPlusPlusSource, 325 | Self::SwiftSource, 326 | Self::Shell, 327 | Self::Makefile, 328 | Self::Javascript, 329 | Self::PythonScript, 330 | Self::RubyScript, 331 | Self::PerlScript, 332 | Self::PhpScript, 333 | Self::AppleScript, 334 | Self::AssemblySource, 335 | Self::Data, 336 | Self::Directory, 337 | Self::Folder, 338 | Self::Symlink, 339 | Self::Executable, 340 | Self::UnixExecutable, 341 | Self::AppBundle, 342 | Self::Framework, 343 | Self::DiskImage, 344 | Self::Volume, 345 | Self::MountPoint, 346 | Self::AliasFile, 347 | Self::ThreeDContent, 348 | Self::Usd, 349 | Self::Usdz, 350 | Self::RealityFile, 351 | Self::SceneKitScene, 352 | Self::Font, 353 | Self::Pkcs12, 354 | Self::X509Certificate, 355 | Self::Url, 356 | Self::FileUrl, 357 | Self::UrlBookmarkData, 358 | Self::PropertyList, 359 | Self::Mpeg2TransportStream, 360 | Self::XmlPropertyList, 361 | Self::BinaryPropertyList, 362 | Self::Log, 363 | Self::Bookmark, 364 | Self::InternetLocation, 365 | Self::InternetShortcut, 366 | Self::DefaultAppWebBrowser, 367 | Self::DefaultAppMailClient, 368 | Self::M4vVideo, 369 | Self::M4aAudio, 370 | ] 371 | } 372 | 373 | /// Try to find a supertype by UTI string 374 | pub fn from_uti_string(uti: &str) -> Option { 375 | Self::all().into_iter().find(|st| st.uti_string() == uti) 376 | } 377 | } 378 | 379 | impl FromStr for SuperType { 380 | type Err = InfatError; 381 | 382 | fn from_str(s: &str) -> Result { 383 | let normalized = s.to_lowercase().replace('_', "-"); 384 | 385 | match normalized.as_str() { 386 | "text" => Ok(Self::Text), 387 | "plain-text" => Ok(Self::PlainText), 388 | "json" => Ok(Self::Json), 389 | "xml" => Ok(Self::Xml), 390 | "yaml" => Ok(Self::Yaml), 391 | "html" => Ok(Self::Html), 392 | "markdown" => Ok(Self::Markdown), 393 | "rtf" => Ok(Self::Rtf), 394 | 395 | "image" => Ok(Self::Image), 396 | "raw-image" => Ok(Self::RawImage), 397 | "png" => Ok(Self::Png), 398 | "jpeg" => Ok(Self::Jpeg), 399 | "gif" => Ok(Self::Gif), 400 | "tiff" => Ok(Self::Tiff), 401 | "svg" => Ok(Self::Svg), 402 | "webp" => Ok(Self::WebP), 403 | "heic" => Ok(Self::Heic), 404 | "heif" => Ok(Self::Heif), 405 | "bmp" => Ok(Self::Bmp), 406 | 407 | "audio" => Ok(Self::Audio), 408 | "mp3" => Ok(Self::Mp3), 409 | "wav" => Ok(Self::Wav), 410 | "aiff" => Ok(Self::Aiff), 411 | "midi" => Ok(Self::Midi), 412 | "mp4-audio" => Ok(Self::Mp4Audio), 413 | 414 | "video" => Ok(Self::Video), 415 | "movie" => Ok(Self::Movie), 416 | "quicktime-movie" => Ok(Self::QuicktimeMovie), 417 | "mp4-movie" => Ok(Self::Mp4Movie), 418 | "mpeg" => Ok(Self::Mpeg), 419 | "avi" => Ok(Self::Avi), 420 | 421 | "csv" | "comma-separated-text" => Ok(Self::Csv), 422 | "mpeg2-video" => Ok(Self::Mpeg2Video), 423 | "mpeg2-transport-stream" => Ok(Self::Mpeg2TransportStream), 424 | "mpeg4-movie" => Ok(Self::Mp4Movie), 425 | "m3u" | "m3u-playlist" => Ok(Self::M3uPlaylist), 426 | 427 | "archive" => Ok(Self::Archive), 428 | "zip" => Ok(Self::Zip), 429 | "gzip" => Ok(Self::Gzip), 430 | "tar" => Ok(Self::Tar), 431 | 432 | "sourcecode" => Ok(Self::Sourcecode), 433 | "c-source" => Ok(Self::CSource), 434 | "cpp-source" => Ok(Self::CppSource), 435 | "objc-source" => Ok(Self::ObjcSource), 436 | "swift-source" => Ok(Self::SwiftSource), 437 | "shell" => Ok(Self::Shell), 438 | "makefile" => Ok(Self::Makefile), 439 | "javascript" => Ok(Self::Javascript), 440 | "python-script" => Ok(Self::PythonScript), 441 | 442 | "data" => Ok(Self::Data), 443 | "directory" => Ok(Self::Directory), 444 | "folder" => Ok(Self::Folder), 445 | "symlink" => Ok(Self::Symlink), 446 | "executable" => Ok(Self::Executable), 447 | "unix-executable" => Ok(Self::UnixExecutable), 448 | "app-bundle" => Ok(Self::AppBundle), 449 | 450 | _ => Err(InfatError::UnsupportedSupertype { 451 | name: s.to_string(), 452 | }), 453 | } 454 | } 455 | } 456 | 457 | impl std::fmt::Display for SuperType { 458 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 459 | let kebab_case = match self { 460 | Self::PlainText => "plain-text", 461 | Self::RawImage => "raw-image", 462 | Self::Mp4Audio => "mp4-audio", 463 | Self::AppleProtectedMp4Audio => "apple-protected-mp4-audio", 464 | Self::QuicktimeMovie => "quicktime-movie", 465 | Self::Mp4Movie => "mp4-movie", 466 | Self::AppleProtectedMp4Video => "apple-protected-mp4-video", 467 | Self::Mpeg2Video => "mpeg2-video", 468 | Self::DvMovie => "dv-movie", 469 | Self::AppleArchive => "apple-archive", 470 | Self::CSource => "c-source", 471 | Self::CHeader => "c-header", 472 | Self::CppSource => "cpp-source", 473 | Self::CppHeader => "cpp-header", 474 | Self::ObjcSource => "objc-source", 475 | Self::ObjcPlusPlusSource => "objc-plus-plus-source", 476 | Self::SwiftSource => "swift-source", 477 | Self::PythonScript => "python-script", 478 | Self::RubyScript => "ruby-script", 479 | Self::PerlScript => "perl-script", 480 | Self::PhpScript => "php-script", 481 | Self::AppleScript => "apple-script", 482 | Self::AssemblySource => "assembly-source", 483 | Self::UnixExecutable => "unix-executable", 484 | Self::AppBundle => "app-bundle", 485 | Self::DiskImage => "disk-image", 486 | Self::MountPoint => "mount-point", 487 | Self::AliasFile => "alias-file", 488 | Self::ThreeDContent => "3d-content", 489 | Self::RealityFile => "reality-file", 490 | Self::SceneKitScene => "scenekit-scene", 491 | Self::X509Certificate => "x509-certificate", 492 | Self::FileUrl => "file-url", 493 | Self::UrlBookmarkData => "url-bookmark-data", 494 | Self::PropertyList => "property-list", 495 | Self::XmlPropertyList => "xml-property-list", 496 | Self::BinaryPropertyList => "binary-property-list", 497 | Self::InternetLocation => "internet-location", 498 | Self::InternetShortcut => "internet-shortcut", 499 | Self::DefaultAppWebBrowser => "default-app-web-browser", 500 | Self::DefaultAppMailClient => "default-app-mail-client", 501 | Self::M4vVideo => "m4v-video", 502 | Self::M4aAudio => "m4a-audio", 503 | _ => { 504 | return write!(f, "{self:?}") 505 | .map(|_| ()) 506 | .map_err(|_| std::fmt::Error) 507 | } 508 | }; 509 | 510 | write!(f, "{}", kebab_case.to_lowercase()) 511 | } 512 | } 513 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env just 2 | 3 | # --- Settings --- # 4 | set shell := ["nu", "-c"] 5 | set positional-arguments := true 6 | set allow-duplicate-variables := true 7 | set windows-shell := ["nu", "-c"] 8 | set dotenv-load := true 9 | 10 | # --- Variables --- # 11 | project_root := justfile_directory() 12 | output_directory := project_root + "/dist" 13 | build_directory := `cargo metadata --format-version 1 | jq -r .target_directory` 14 | system := `rustc --version --verbose | grep '^host:' | awk '{print $2}'` 15 | main_package := "infat" 16 | 17 | # ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰ # 18 | # Recipes # 19 | # ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰ # 20 | 21 | [doc('List all available recipes')] 22 | default: 23 | @just --list 24 | 25 | # --- Build & Check --- # 26 | [doc('Check workspace for compilation errors')] 27 | [group('build')] 28 | check: 29 | @echo "🔎 Checking workspace..." 30 | cargo check --workspace 31 | cargo clippy 32 | typos --sort 33 | 34 | [doc('Build workspace in debug mode')] 35 | [group('build')] 36 | build target="aarch64-apple-darwin" package=(main_package): 37 | @echo "🔨 Building workspace (debug)..." 38 | cargo build --workspace --bin '{{ main_package }}' --target '{{ target }}' 39 | 40 | [doc('Build workspace in release mode')] 41 | [group('build')] 42 | build-release target=(system) package=(main_package): 43 | @echo "🚀 Building workspace (release) for {{ target }}…" 44 | cargo build --workspace --release --bin '{{ main_package }}' --target '{{ target }}' 45 | 46 | # --- Packaging --- # 47 | [doc('Package release binary with completions for distribution')] 48 | [group('packaging')] 49 | package target=(system): 50 | #!/usr/bin/env nu 51 | def build_error [msg: string, error?: record] { 52 | if ($error != null) { 53 | let annotated_error = ($error | upsert msg $'($msg): ($error.msg)') 54 | $annotated_error.rendered | print --stderr 55 | } else { 56 | (error make --unspanned { msg: $msg }) | print --stderr 57 | } 58 | exit 1 59 | } 60 | 61 | print "📦 Packaging release binary…" 62 | 63 | 64 | let target = '{{ target }}' 65 | let prime = '{{ main_package }}' 66 | let out = "{{ output_directory }}" 67 | let artifact_dir = $'{{ build_directory }}/($target)/release' 68 | 69 | try { 70 | just build-release $target 71 | 72 | print $'🛬 Destination is ($out)' 73 | 74 | # Windows the only one that has an executable extension 75 | let ext = if ($target | str contains 'windows-msvc') { '.exe' } else { '' } 76 | 77 | let bin_path = $'($artifact_dir)/($prime)($ext)' # Where rust puts the binary artifact 78 | let out_path = $'($out)/($prime)($ext)' 79 | 80 | # Create output directory structure 81 | try { 82 | mkdir $out 83 | } catch {|e| 84 | build_error $"Failed to create directory: ($out)" $e 85 | } 86 | 87 | # Copy completion scripts 88 | let completions = [$'($prime).bash', $'($prime).elv', $'($prime).fish', $'_($prime).ps1', $'_($prime)'] 89 | 90 | for completion in $completions { 91 | let src = $'($artifact_dir)/($completion)' 92 | let dst = $'($out)/($completion)' 93 | 94 | if ($src | path exists) { 95 | try { 96 | cp --force $src $dst # Using force here because default nu copy only works with existing files otherwise 97 | print $"('Successfully copied completion to destination:' | ansi gradient --fgstart '0x00ff00' --fgend '0xff0080' --bgstart '0x1a1a1a' --bgend '0x0d0d0d') (basename $src)" 98 | } catch {|e| 99 | build_error $"Failed to copy completion script ($src)" $e 100 | } 101 | } else { 102 | print --stderr $"Warning: completion script missing: ($src)" 103 | } 104 | } 105 | 106 | # Copy main binary 107 | try { 108 | cp --force $bin_path $out_path 109 | print $"('Successfully copied binary to destination:' | ansi gradient --fgstart '0x00ff00' --fgend '0xff0080' --bgstart '0x1a1a1a' --bgend '0x0d0d0d') (basename $bin_path)" 110 | } catch { |e| 111 | build_error $"Failed to copy binary ($bin_path)" $e 112 | } 113 | 114 | } catch { |e| 115 | build_error "Packaging failed" $e 116 | } 117 | 118 | [doc('Generate checksums for distribution files')] 119 | [group('packaging')] 120 | checksum directory=(output_directory): 121 | #!/usr/bin/env nu 122 | def build_error [msg: string, error?: record] { 123 | if ($error != null) { 124 | let annotated_error = ($error | upsert msg $'($msg): ($error.msg)') 125 | $annotated_error.rendered | print --stderr 126 | exit 1 127 | } else { 128 | (error make --unspanned { msg: $msg }) | print --stderr 129 | exit 1 130 | } 131 | } 132 | 133 | let dir = '{{ directory }}' 134 | print $"🔒 Generating checksums in '($dir)'…" 135 | 136 | # Validate directory exists 137 | if not ($dir | path exists) { 138 | build_error $"'($dir)' is not a directory." 139 | } 140 | 141 | try { 142 | cd $dir 143 | 144 | # Remove existing checksum files 145 | try { 146 | glob '*.sum' | each { |file| rm $file } 147 | } catch { 148 | # Ignore errors if no .sum files exist 149 | } 150 | 151 | # Get all files except checksum files 152 | let files = ls | where type == file | where name !~ '\.sum$' | get name 153 | 154 | if (($files | length) == 0) { 155 | print --stderr "Warning: No files found to checksum" 156 | return 157 | } 158 | 159 | # Generate SHA256 checksums 160 | try { 161 | let sha256_results = $files | each { |file| 162 | let hash = (open --raw $file | hash sha256) 163 | $"($hash) ./($file | path basename)" 164 | } 165 | $sha256_results | str join (char newline) | save SHA256.sum 166 | } catch {|e| 167 | build_error $"Failed to generate SHA256 checksums" $e 168 | } 169 | 170 | # Generate MD5 checksums 171 | try { 172 | let md5_results = $files | each { |file| 173 | let hash = (open --raw $file | hash md5) 174 | $"($hash) ./($file | path basename)" 175 | } 176 | $md5_results | str join (char newline) | save MD5.sum 177 | } catch {|e| 178 | build_error $"Failed to generate MD5 checksums" $e 179 | } 180 | 181 | # Generate BLAKE3 checksums (using b3sum command) 182 | try { 183 | let b3_results = $files | each { |file| 184 | let result = (run-external 'b3sum' $file | complete) 185 | if $result.exit_code != 0 { 186 | build_error $"b3sum failed for ($file): ($result.stderr)" 187 | } 188 | let hash = ($result.stdout | str trim | split row ' ' | get 0) 189 | $"($hash) ./($file | path basename)" 190 | } 191 | $b3_results | str join (char newline) | save BLAKE3.sum 192 | } catch {|e| 193 | build_error $"Failed to generate BLAKE3 checksums" $e 194 | } 195 | 196 | print $"✅ Checksums created in '($dir)'" 197 | 198 | } catch {|e| 199 | build_error $"Checksum generation failed" $e 200 | } 201 | 202 | [doc('Compress all release packages into tar.gz archives')] 203 | [group('packaging')] 204 | compress directory=(output_directory): 205 | #!/usr/bin/env nu 206 | def build_error [msg: string, error?: record] { 207 | if ($error != null) { 208 | let annotated_error = ($error | upsert msg $'($msg): ($error.msg)') 209 | $annotated_error.rendered | print --stderr 210 | exit 1 211 | } else { 212 | (error make --unspanned { msg: $msg }) | print --stderr 213 | exit 1 214 | } 215 | } 216 | 217 | let prime = '{{ main_package }}' 218 | let sys = '{{ system }}' 219 | 220 | print "🗜️ Compressing release packages..." 221 | 222 | let dir = '{{ directory }}' 223 | if not ($dir | path exists) { 224 | build_error $"Directory '($dir)' does not exist" 225 | } 226 | 227 | try { 228 | # Find all package directories 229 | mut package_dirs = ls $dir | where type == dir | get name 230 | 231 | if (($package_dirs | length) == 0) { 232 | # Just one package found to compress 233 | $package_dirs = ($package_dirs | append $dir) 234 | } 235 | 236 | for pkg_dir in $package_dirs { 237 | let pkg_name = ($pkg_dir | path basename) 238 | print $"🎁 Compressing package: ($pkg_name)" 239 | 240 | try { 241 | let parent_dir = ($pkg_dir | path dirname) 242 | let archive_name = $'($prime)-($sys).tar.gz' 243 | 244 | # Use tar command to create compressed archive 245 | let result = (run-external 'tar' '-czf' $archive_name '-C' $parent_dir $pkg_name | complete) 246 | 247 | if $result.exit_code != 0 { 248 | build_error $"Failed to create archive for ($pkg_name): ($result.stderr)" 249 | } 250 | 251 | print $"✅ Successfully compressed ($pkg_name)" 252 | 253 | } catch { |e| 254 | build_error $"Compression failed for ($pkg_name)" $e 255 | } 256 | } 257 | 258 | print "🎉 All packages compressed successfully!" 259 | 260 | } catch { |e| 261 | build_error $"Compression process failed" $e 262 | } 263 | 264 | [doc('Complete release pipeline: build, checksum, and compress')] 265 | [group('packaging')] 266 | release: build-release 267 | just checksum 268 | 269 | # --- Execution --- # 270 | [doc('Run application in debug mode')] 271 | [group('execution')] 272 | run package=(main_package) +args="": 273 | @echo "▶️ Running {{ package }} (debug)..." 274 | cargo run --bin '{{ package }}' -- '$@' 275 | 276 | [doc('Run application in release mode')] 277 | [group('execution')] 278 | run-release package=(main_package) +args="": 279 | @echo "▶️ Running '{{ package }}' (release)..." 280 | cargo run --bin '{{ package }}' --release -- '$@' 281 | 282 | # --- Testing --- # 283 | [doc('Run all workspace tests')] 284 | [group('testing')] 285 | test: 286 | @echo "🧪 Running workspace tests..." 287 | cargo test --workspace 288 | 289 | [doc('Run workspace tests with additional arguments')] 290 | [group('testing')] 291 | test-with +args: 292 | @echo "🧪 Running workspace tests with args: '$@'" 293 | cargo test --workspace -- '$@' 294 | 295 | # --- Code Quality --- # 296 | [doc('Format all Rust code in the workspace')] 297 | [group('quality')] 298 | fmt: 299 | @echo "💅 Formatting Rust code..." 300 | cargo fmt 301 | 302 | [doc('Check if Rust code is properly formatted')] 303 | [group('quality')] 304 | fmt-check: 305 | @echo "💅 Checking Rust code formatting..." 306 | cargo fmt 307 | 308 | [doc('Lint code with Clippy in debug mode')] 309 | [group('quality')] 310 | lint: 311 | @echo "🧹 Linting with Clippy (debug)..." 312 | cargo clippy --workspace -- -D warnings 313 | 314 | [doc('Automatically fix Clippy lints where possible')] 315 | [group('quality')] 316 | lint-fix: 317 | @echo "🩹 Fixing Clippy lints..." 318 | cargo clippy --workspace --fix --allow-dirty --allow-staged 319 | 320 | # --- Documentation --- # 321 | [doc('Generate project documentation')] 322 | [group('common')] 323 | doc: 324 | @echo "📚 Generating documentation..." 325 | cargo doc --workspace --no-deps 326 | 327 | [doc('Generate and open project documentation in browser')] 328 | [group('common')] 329 | doc-open: doc 330 | @echo "📚 Opening documentation in browser..." 331 | cargo doc --workspace --no-deps --open 332 | 333 | # --- Maintenance --- # 334 | [doc('Extract release notes from changelog for specified tag')] 335 | [group('common')] 336 | create-notes raw_tag outfile changelog: 337 | #!/usr/bin/env nu 338 | def build_error [msg: string, error?: record] { 339 | if ($error != null) { 340 | let annotated_error = ($error | upsert msg $'($msg): ($error.msg)') 341 | $annotated_error.rendered | print --stderr 342 | exit 1 343 | } else { 344 | (error make --unspanned { msg: $msg }) | print --stderr 345 | exit 1 346 | } 347 | } 348 | 349 | let tag_v = '{{ raw_tag }}' 350 | let tag = ($tag_v | str replace --regex '^v' '') # Remove prefix v 351 | let outfile = '{{ outfile }}' 352 | let changelog_file = '{{ changelog }}' 353 | 354 | try { 355 | # Verify changelog exists 356 | if not ($changelog_file | path exists) { 357 | build_error $"($changelog_file) not found." 358 | } 359 | 360 | print $"Extracting notes for tag: ($tag_v) \(searching for section [($tag)]\)" 361 | 362 | # Write header to output file 363 | "# What's new\n" | save --force $outfile 364 | 365 | # Read and process changelog 366 | let content = (open $changelog_file | lines) 367 | 368 | # Find the start of the target section 369 | let start_idx = ($content | enumerate | where ($it.item | str contains $tag) | get index | first) 370 | 371 | if ($start_idx | is-empty) { 372 | build_error $"Could not find tag ($tag) in ($changelog_file)" 373 | } 374 | 375 | # Find the end of the target section (next ## [ header) 376 | let remaining_lines = ($content | skip ($start_idx + 1)) 377 | let next_section_idx = ($remaining_lines | enumerate | where item =~ '^## \[' | get index | first) 378 | 379 | let section_lines = if ($next_section_idx | is-empty) { 380 | $remaining_lines 381 | } else { 382 | $remaining_lines | take $next_section_idx 383 | } 384 | 385 | # Append section content to output file 386 | $section_lines | str join (char newline) | save --append $outfile 387 | 388 | # Check if output file has meaningful content 389 | let output_size = (open $outfile | str length) 390 | if $output_size > 20 { # More than just the header 391 | print $"Successfully extracted release notes to '($outfile)'." 392 | } else { 393 | print --stderr $"Warning: '($outfile)' appears empty. Is '($tag)' present in '($changelog_file)'?" 394 | } 395 | 396 | } catch { |e| 397 | build_error $"Failed to extract release notes:" $e 398 | } 399 | 400 | [doc('Update Cargo dependencies')] 401 | [group('maintenance')] 402 | update: 403 | @echo "🔄 Updating dependencies..." 404 | cargo update 405 | 406 | [doc('Clean build artifacts')] 407 | [group('maintenance')] 408 | clean: 409 | @echo "🧹 Cleaning build artifacts..." 410 | cargo clean 411 | 412 | # --- Installation --- # 413 | [doc('Build and install binary to system')] 414 | [group('installation')] 415 | install package=(main_package): build-release 416 | @echo "💾 Installing {{ main_package }} binary..." 417 | cargo install --bin '{{ package }}' 418 | 419 | [doc('Force install binary')] 420 | [group('installation')] 421 | install-force package=(main_package): build-release 422 | @echo "💾 Force installing {{ main_package }} binary..." 423 | cargo install --bin '{{ package }}' --force 424 | 425 | # --- Aliases --- # 426 | alias b := build 427 | alias br := build-release 428 | alias c := check 429 | alias t := test 430 | alias f := fmt 431 | alias l := lint 432 | alias lf := lint-fix 433 | alias cl := clean 434 | alias up := update 435 | alias i := install 436 | alias ifo := install-force 437 | alias rr := run-release 438 | --------------------------------------------------------------------------------