Compare commits
19 Commits
5247d898cd
...
2.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10943f9ce3 | ||
|
|
6927464d47 | ||
|
|
31ed16c828 | ||
|
|
ac1c86c431 | ||
|
|
4b28c293cb | ||
|
|
55f3ca2f7b | ||
|
|
134e4e152d | ||
|
|
c87099e4a7 | ||
|
|
914bbe3153 | ||
|
|
a30e954737 | ||
|
|
e2bfb8280b | ||
|
|
e78e2c082d | ||
|
|
76f91f67a6 | ||
|
|
ffac6d17a5 | ||
|
|
a8d7fb017f | ||
|
|
b41b82bd0f | ||
|
|
d57f998fd4 | ||
|
|
e44e1fc9b8 | ||
|
|
58a05e9b61 |
61
.github/workflows/docs.yml
vendored
Normal file
61
.github/workflows/docs.yml
vendored
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
name: Docs
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: "pages"
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docs:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- name: Set up Pages
|
||||||
|
uses: actions/configure-pages@v5
|
||||||
|
- name: Set up Swift
|
||||||
|
uses: swift-actions/setup-swift@v3
|
||||||
|
with:
|
||||||
|
skip-verify-signature: true
|
||||||
|
- name: Generate Docs
|
||||||
|
run: |
|
||||||
|
swift package add-dependency --from 1.4.0 "https://github.com/apple/swift-docc-plugin.git"
|
||||||
|
for target in Inotify InotifyTaskCLI; do
|
||||||
|
lower="${target,,}"
|
||||||
|
mkdir -p "./public/$lower"
|
||||||
|
swift package --allow-writing-to-directory "./public/$lower" \
|
||||||
|
generate-documentation --disable-indexing --transform-for-static-hosting \
|
||||||
|
--target "$target" \
|
||||||
|
--hosting-base-path "swift-inotify/$lower" \
|
||||||
|
--output-path "./public/$lower"
|
||||||
|
done
|
||||||
|
- name: Copy Index Page
|
||||||
|
run: |
|
||||||
|
cp ./.github/workflows/index.tpl.html public/index.html
|
||||||
|
sed -i -e 's/{{project.name}}/Swift Inotify/g' public/index.html
|
||||||
|
sed -i -e 's/{{project.tagline}}/🗂️ Monitor filesystem events on Linux using modern Swift concurrency/g' public/index.html
|
||||||
|
sed -i -e 's|{{project.links}}|<li><a href="inotify/documentation/inotify/">Inotify</a>: The actual library.</li><li><a href="inotifytaskcli/documentation/inotifytaskcli/">TaskCLI</a>: The project build command.</li>|g' public/index.html
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-pages-artifact@v4
|
||||||
|
with:
|
||||||
|
path: ./public
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: docs
|
||||||
|
steps:
|
||||||
|
- name: Deploy Docs
|
||||||
|
uses: actions/deploy-pages@v4
|
||||||
72
.github/workflows/index.tpl.html
vendored
Normal file
72
.github/workflows/index.tpl.html
vendored
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Astzweig | Swift Inotify</title>
|
||||||
|
<meta name="description" content="Monitor filesystem events on Linux using modern Swift concurrency.">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
background-color: ligh-dark(#ffffff, #000000);
|
||||||
|
color: ligh-dark(#000000, #ffffff);
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
max-width: min(40em, 100vw);
|
||||||
|
min-height: 80vh;
|
||||||
|
margin: 1rem auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
max-width: 12em;
|
||||||
|
margin: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
max-width: min(60em, 100vw);
|
||||||
|
margin: 1rem auto;
|
||||||
|
font-size: .7rem;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 531 118"><g fill-rule="evenodd"><style>g { fill: #000; } @media (prefers-color-scheme: dark) { g { fill: #fff; } }</style><g fill-rule="nonzero"><path d="M147.81 96.877q-6.907 0-13.474-1.579t-10.55-4.009l5.703-12.975q3.777 2.3 8.88 3.662t10.034 1.362q4.752 0 6.621-1.01t1.87-2.77q0-1.541-1.732-2.308t-4.578-1.176a920 920 0 0 0-6.258-.874q-3.411-.465-6.859-1.35t-6.293-2.623-4.577-4.71-1.731-7.64q0-5.14 3.057-9.184t8.95-6.424 14.379-2.38q5.783 0 11.612 1.147 5.83 1.147 9.881 3.416l-5.703 12.976q-4.046-2.3-8.033-3.15-3.99-.85-7.58-.85-4.799 0-6.782 1.11-1.984 1.11-1.984 2.7 0 1.602 1.73 2.42 1.732.819 4.578 1.258 2.846.438 6.259.904 3.413.465 6.824 1.385 3.411.918 6.258 2.641t4.578 4.68 1.73 7.594q0 4.932-3.037 8.98t-9.012 6.413-14.76 2.364m59.104 0q-10.4 0-16.173-5.125-5.772-5.124-5.772-15.555V29.652h19v46.314q0 2.966 1.567 4.614t4.126 1.648q3.334 0 5.752-1.748l4.748 13.28q-2.476 1.591-5.97 2.354-3.495.763-7.278.763m-29.88-38.87V43.835h39.252v14.174zM224.539 96V84.831l30.465-35.037 3.203 6.144h-32.931V41.766h48.283v11.168l-30.466 35.038-3.265-6.144h34.739V96zM294.751 96l-19.217-54.234h17.988l15.268 45.362h-8.625l15.969-45.362h16.128l15.363 45.362h-8.417l15.768-45.362h16.697L352.425 96h-18.432l-12.957-37.528h5.543L313.184 96zM404.332 96.877q-9.597 0-16.768-3.639-7.17-3.638-11.115-9.995t-3.946-14.395q0-8.175 3.88-14.498t10.65-9.891q6.77-3.57 15.261-3.57 8.007 0 14.593 3.278t10.504 9.547q3.917 6.27 3.917 15.258 0 1.046-.084 2.34a87 87 0 0 1-.185 2.37H388.1v-9.968h32.863l-7.228 2.806q.031-3.697-1.414-6.397t-3.957-4.213-5.932-1.513-5.983 1.513-3.957 4.23-1.395 6.474v2.903q0 3.966 1.68 6.885t4.776 4.448 7.355 1.53q4.049 0 6.902-1.156t5.584-3.496l10 10.41q-3.876 4.268-9.587 6.503-5.71 2.236-13.475 2.236M439.997 96V41.8h19V96zm9.5-60.2q-5.199 0-8.4-2.9-3.2-2.9-3.2-7.2t3.2-7.2q3.201-2.9 8.4-2.9 5.2 0 8.4 2.75t3.2 7.05q0 4.5-3.15 7.45t-8.45 2.95m48.242 80.477q-7.852 0-14.92-1.779-7.07-1.778-11.985-5.301l6.888-13.26q3.324 2.655 8.413 4.215t9.893 1.559q7.651 0 11.04-3.338t3.388-9.77v-6.369l1-15.23-.087-15.263v-9.975h18.087v44.328q0 15.403-8.304 22.793t-23.413 7.39m-3.376-23.06q-7.18 0-13.226-3.25t-9.7-9.119-3.654-13.845q0-7.975 3.654-13.844 3.654-5.87 9.7-9.07t13.226-3.2q6.976 0 11.836 2.77t7.433 8.572 2.573 14.772q0 8.97-2.573 14.773-2.573 5.804-7.433 8.622-4.86 2.82-11.836 2.82m4.51-15.117q3.444 0 6.103-1.395t4.17-3.907q1.51-2.512 1.51-5.795 0-3.351-1.51-5.829-1.512-2.477-4.17-3.822-2.66-1.344-6.102-1.344-3.346 0-6.054 1.344-2.71 1.345-4.253 3.822-1.545 2.478-1.545 5.83 0 3.282 1.545 5.794 1.544 2.513 4.253 3.907 2.707 1.395 6.054 1.395"/></g><path d="M102.666 13.772h-.014v20.424q2.888.297 5.777.823l.886.167a3.66 3.66 0 0 1 2.96 3.592v53.253a3.66 3.66 0 0 1-2.96 3.595A74 74 0 0 1 95.137 97a75 75 0 0 1-14.267-1.38 3.66 3.66 0 0 1-2.963-3.59V73.025L48.733 58.685l-16.18 12.641a3.66 3.66 0 0 1-4.743-.198 89 89 0 0 1-6.423-6.61V76.41a.84.84 0 0 1-.839.84H9.625v-6.275h5.462V62.61H6.684v5.437H.383V57.175c0-.463.375-.839.838-.839h13.816a90 90 0 0 1-4.592-7.441 3.66 3.66 0 0 1 .95-4.653L55.14 10.064a3.66 3.66 0 0 1 4.743.206 92 92 0 0 1 4.114 4.076L80.529 2.338l-.011-.015c7.448-5.467 22.318-.83 22.148 11.45m-20.448 8.263-7.713 5.812a90 90 0 0 1 2.747 4.647 3.66 3.66 0 0 1-.951 4.654L63.132 47.436l14.773 7.362V38.785a3.66 3.66 0 0 1 2.969-3.596 78 78 0 0 1 4.593-.744v-9.294h-.008v-.387c-.012-1.379-.215-4.408-3.085-2.818zm.946-9.41-12.606 9.497a88 88 0 0 1 2.437 3.42l7.589-5.718-.005-.008.067-.065c1.184-1.096 7.144-4.065 7.558 4.454l.013.324q.01.303.01.622h-.01v8.984q2.065-.194 4.124-.273V19.77h-.036c-.113-7.551-3.735-10.214-8.96-7.25zm-1.004-8.07-16.292 11.83a88 88 0 0 1 3.007 3.563l12.505-9.423-.022-.028q.314-.25.662-.48l.248-.16c4.557-2.813 12.944-2.85 12.838 9.914h-.016l.001 14.038h.045q2.384 0 4.767.155V13.772h-.013c.142-7.384-9.941-14.798-17.73-9.216"/></g></svg>
|
||||||
|
<hgroup>
|
||||||
|
<h1>{{project.name}}</h1>
|
||||||
|
<p>{{project.tagline}}</p>
|
||||||
|
</hgroup>
|
||||||
|
<h2>Documentations</h2>
|
||||||
|
<ul>
|
||||||
|
{{project.links}}
|
||||||
|
</ul>
|
||||||
|
</main>
|
||||||
|
<footer vocab="https://schema.org/" typeof="Organization">
|
||||||
|
© <span property="legalName">Astzweig GmbH & Co. KG</span>,
|
||||||
|
based in
|
||||||
|
<span property="location">Freiburg i. Br.</span>,
|
||||||
|
entered in the commercial register of the Freiburg Local Court under number HRA 703853 and VAT ID number:
|
||||||
|
<span property="vatID">DE297037836</span>.
|
||||||
|
The general partner is
|
||||||
|
<span property="parentOrganization" typeof="Organization">
|
||||||
|
<span property="legalName">Astzweig Verwaltungsgesellschaft mbH</span>
|
||||||
|
based in <span property="location">Freiburg i.Br.</span>,
|
||||||
|
entered in the commercial register of the same court under number HRB 718037 and with a capital of €25,000.00, represented by the managing director
|
||||||
|
<span property="employee" type="Person"><span property="givenName">Thomas R.</span> <span property="familyName">Bernstein</span>.</span>
|
||||||
|
</span>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
|||||||
.build
|
.build
|
||||||
|
public
|
||||||
|
|||||||
5
.spi.yml
Normal file
5
.spi.yml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
version: 1
|
||||||
|
builder:
|
||||||
|
configs:
|
||||||
|
- documentation_targets: [Inotify, TaskCLI]
|
||||||
|
platform: Linux
|
||||||
287
LICENSE
Normal file
287
LICENSE
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
EUROPEAN UNION PUBLIC LICENCE v. 1.2
|
||||||
|
EUPL © the European Union 2007, 2016
|
||||||
|
|
||||||
|
This European Union Public Licence (the ‘EUPL’) applies to the Work (as defined
|
||||||
|
below) which is provided under the terms of this Licence. Any use of the Work,
|
||||||
|
other than as authorised under this Licence is prohibited (to the extent such
|
||||||
|
use is covered by a right of the copyright holder of the Work).
|
||||||
|
|
||||||
|
The Work is provided under the terms of this Licence when the Licensor (as
|
||||||
|
defined below) has placed the following notice immediately following the
|
||||||
|
copyright notice for the Work:
|
||||||
|
|
||||||
|
Licensed under the EUPL
|
||||||
|
|
||||||
|
or has expressed by any other means his willingness to license under the EUPL.
|
||||||
|
|
||||||
|
1. Definitions
|
||||||
|
|
||||||
|
In this Licence, the following terms have the following meaning:
|
||||||
|
|
||||||
|
- ‘The Licence’: this Licence.
|
||||||
|
|
||||||
|
- ‘The Original Work’: the work or software distributed or communicated by the
|
||||||
|
Licensor under this Licence, available as Source Code and also as Executable
|
||||||
|
Code as the case may be.
|
||||||
|
|
||||||
|
- ‘Derivative Works’: the works or software that could be created by the
|
||||||
|
Licensee, based upon the Original Work or modifications thereof. This Licence
|
||||||
|
does not define the extent of modification or dependence on the Original Work
|
||||||
|
required in order to classify a work as a Derivative Work; this extent is
|
||||||
|
determined by copyright law applicable in the country mentioned in Article 15.
|
||||||
|
|
||||||
|
- ‘The Work’: the Original Work or its Derivative Works.
|
||||||
|
|
||||||
|
- ‘The Source Code’: the human-readable form of the Work which is the most
|
||||||
|
convenient for people to study and modify.
|
||||||
|
|
||||||
|
- ‘The Executable Code’: any code which has generally been compiled and which is
|
||||||
|
meant to be interpreted by a computer as a program.
|
||||||
|
|
||||||
|
- ‘The Licensor’: the natural or legal person that distributes or communicates
|
||||||
|
the Work under the Licence.
|
||||||
|
|
||||||
|
- ‘Contributor(s)’: any natural or legal person who modifies the Work under the
|
||||||
|
Licence, or otherwise contributes to the creation of a Derivative Work.
|
||||||
|
|
||||||
|
- ‘The Licensee’ or ‘You’: any natural or legal person who makes any usage of
|
||||||
|
the Work under the terms of the Licence.
|
||||||
|
|
||||||
|
- ‘Distribution’ or ‘Communication’: any act of selling, giving, lending,
|
||||||
|
renting, distributing, communicating, transmitting, or otherwise making
|
||||||
|
available, online or offline, copies of the Work or providing access to its
|
||||||
|
essential functionalities at the disposal of any other natural or legal
|
||||||
|
person.
|
||||||
|
|
||||||
|
2. Scope of the rights granted by the Licence
|
||||||
|
|
||||||
|
The Licensor hereby grants You a worldwide, royalty-free, non-exclusive,
|
||||||
|
sublicensable licence to do the following, for the duration of copyright vested
|
||||||
|
in the Original Work:
|
||||||
|
|
||||||
|
- use the Work in any circumstance and for all usage,
|
||||||
|
- reproduce the Work,
|
||||||
|
- modify the Work, and make Derivative Works based upon the Work,
|
||||||
|
- communicate to the public, including the right to make available or display
|
||||||
|
the Work or copies thereof to the public and perform publicly, as the case may
|
||||||
|
be, the Work,
|
||||||
|
- distribute the Work or copies thereof,
|
||||||
|
- lend and rent the Work or copies thereof,
|
||||||
|
- sublicense rights in the Work or copies thereof.
|
||||||
|
|
||||||
|
Those rights can be exercised on any media, supports and formats, whether now
|
||||||
|
known or later invented, as far as the applicable law permits so.
|
||||||
|
|
||||||
|
In the countries where moral rights apply, the Licensor waives his right to
|
||||||
|
exercise his moral right to the extent allowed by law in order to make effective
|
||||||
|
the licence of the economic rights here above listed.
|
||||||
|
|
||||||
|
The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to
|
||||||
|
any patents held by the Licensor, to the extent necessary to make use of the
|
||||||
|
rights granted on the Work under this Licence.
|
||||||
|
|
||||||
|
3. Communication of the Source Code
|
||||||
|
|
||||||
|
The Licensor may provide the Work either in its Source Code form, or as
|
||||||
|
Executable Code. If the Work is provided as Executable Code, the Licensor
|
||||||
|
provides in addition a machine-readable copy of the Source Code of the Work
|
||||||
|
along with each copy of the Work that the Licensor distributes or indicates, in
|
||||||
|
a notice following the copyright notice attached to the Work, a repository where
|
||||||
|
the Source Code is easily and freely accessible for as long as the Licensor
|
||||||
|
continues to distribute or communicate the Work.
|
||||||
|
|
||||||
|
4. Limitations on copyright
|
||||||
|
|
||||||
|
Nothing in this Licence is intended to deprive the Licensee of the benefits from
|
||||||
|
any exception or limitation to the exclusive rights of the rights owners in the
|
||||||
|
Work, of the exhaustion of those rights or of other applicable limitations
|
||||||
|
thereto.
|
||||||
|
|
||||||
|
5. Obligations of the Licensee
|
||||||
|
|
||||||
|
The grant of the rights mentioned above is subject to some restrictions and
|
||||||
|
obligations imposed on the Licensee. Those obligations are the following:
|
||||||
|
|
||||||
|
Attribution right: The Licensee shall keep intact all copyright, patent or
|
||||||
|
trademarks notices and all notices that refer to the Licence and to the
|
||||||
|
disclaimer of warranties. The Licensee must include a copy of such notices and a
|
||||||
|
copy of the Licence with every copy of the Work he/she distributes or
|
||||||
|
communicates. The Licensee must cause any Derivative Work to carry prominent
|
||||||
|
notices stating that the Work has been modified and the date of modification.
|
||||||
|
|
||||||
|
Copyleft clause: If the Licensee distributes or communicates copies of the
|
||||||
|
Original Works or Derivative Works, this Distribution or Communication will be
|
||||||
|
done under the terms of this Licence or of a later version of this Licence
|
||||||
|
unless the Original Work is expressly distributed only under this version of the
|
||||||
|
Licence — for example by communicating ‘EUPL v. 1.2 only’. The Licensee
|
||||||
|
(becoming Licensor) cannot offer or impose any additional terms or conditions on
|
||||||
|
the Work or Derivative Work that alter or restrict the terms of the Licence.
|
||||||
|
|
||||||
|
Compatibility clause: If the Licensee Distributes or Communicates Derivative
|
||||||
|
Works or copies thereof based upon both the Work and another work licensed under
|
||||||
|
a Compatible Licence, this Distribution or Communication can be done under the
|
||||||
|
terms of this Compatible Licence. For the sake of this clause, ‘Compatible
|
||||||
|
Licence’ refers to the licences listed in the appendix attached to this Licence.
|
||||||
|
Should the Licensee's obligations under the Compatible Licence conflict with
|
||||||
|
his/her obligations under this Licence, the obligations of the Compatible
|
||||||
|
Licence shall prevail.
|
||||||
|
|
||||||
|
Provision of Source Code: When distributing or communicating copies of the Work,
|
||||||
|
the Licensee will provide a machine-readable copy of the Source Code or indicate
|
||||||
|
a repository where this Source will be easily and freely available for as long
|
||||||
|
as the Licensee continues to distribute or communicate the Work.
|
||||||
|
|
||||||
|
Legal Protection: This Licence does not grant permission to use the trade names,
|
||||||
|
trademarks, service marks, or names of the Licensor, except as required for
|
||||||
|
reasonable and customary use in describing the origin of the Work and
|
||||||
|
reproducing the content of the copyright notice.
|
||||||
|
|
||||||
|
6. Chain of Authorship
|
||||||
|
|
||||||
|
The original Licensor warrants that the copyright in the Original Work granted
|
||||||
|
hereunder is owned by him/her or licensed to him/her and that he/she has the
|
||||||
|
power and authority to grant the Licence.
|
||||||
|
|
||||||
|
Each Contributor warrants that the copyright in the modifications he/she brings
|
||||||
|
to the Work are owned by him/her or licensed to him/her and that he/she has the
|
||||||
|
power and authority to grant the Licence.
|
||||||
|
|
||||||
|
Each time You accept the Licence, the original Licensor and subsequent
|
||||||
|
Contributors grant You a licence to their contributions to the Work, under the
|
||||||
|
terms of this Licence.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty
|
||||||
|
|
||||||
|
The Work is a work in progress, which is continuously improved by numerous
|
||||||
|
Contributors. It is not a finished work and may therefore contain defects or
|
||||||
|
‘bugs’ inherent to this type of development.
|
||||||
|
|
||||||
|
For the above reason, the Work is provided under the Licence on an ‘as is’ basis
|
||||||
|
and without warranties of any kind concerning the Work, including without
|
||||||
|
limitation merchantability, fitness for a particular purpose, absence of defects
|
||||||
|
or errors, accuracy, non-infringement of intellectual property rights other than
|
||||||
|
copyright as stated in Article 6 of this Licence.
|
||||||
|
|
||||||
|
This disclaimer of warranty is an essential part of the Licence and a condition
|
||||||
|
for the grant of any rights to the Work.
|
||||||
|
|
||||||
|
8. Disclaimer of Liability
|
||||||
|
|
||||||
|
Except in the cases of wilful misconduct or damages directly caused to natural
|
||||||
|
persons, the Licensor will in no event be liable for any direct or indirect,
|
||||||
|
material or moral, damages of any kind, arising out of the Licence or of the use
|
||||||
|
of the Work, including without limitation, damages for loss of goodwill, work
|
||||||
|
stoppage, computer failure or malfunction, loss of data or any commercial
|
||||||
|
damage, even if the Licensor has been advised of the possibility of such damage.
|
||||||
|
However, the Licensor will be liable under statutory product liability laws as
|
||||||
|
far such laws apply to the Work.
|
||||||
|
|
||||||
|
9. Additional agreements
|
||||||
|
|
||||||
|
While distributing the Work, You may choose to conclude an additional agreement,
|
||||||
|
defining obligations or services consistent with this Licence. However, if
|
||||||
|
accepting obligations, You may act only on your own behalf and on your sole
|
||||||
|
responsibility, not on behalf of the original Licensor or any other Contributor,
|
||||||
|
and only if You agree to indemnify, defend, and hold each Contributor harmless
|
||||||
|
for any liability incurred by, or claims asserted against such Contributor by
|
||||||
|
the fact You have accepted any warranty or additional liability.
|
||||||
|
|
||||||
|
10. Acceptance of the Licence
|
||||||
|
|
||||||
|
The provisions of this Licence can be accepted by clicking on an icon ‘I agree’
|
||||||
|
placed under the bottom of a window displaying the text of this Licence or by
|
||||||
|
affirming consent in any other similar way, in accordance with the rules of
|
||||||
|
applicable law. Clicking on that icon indicates your clear and irrevocable
|
||||||
|
acceptance of this Licence and all of its terms and conditions.
|
||||||
|
|
||||||
|
Similarly, you irrevocably accept this Licence and all of its terms and
|
||||||
|
conditions by exercising any rights granted to You by Article 2 of this Licence,
|
||||||
|
such as the use of the Work, the creation by You of a Derivative Work or the
|
||||||
|
Distribution or Communication by You of the Work or copies thereof.
|
||||||
|
|
||||||
|
11. Information to the public
|
||||||
|
|
||||||
|
In case of any Distribution or Communication of the Work by means of electronic
|
||||||
|
communication by You (for example, by offering to download the Work from a
|
||||||
|
remote location) the distribution channel or media (for example, a website) must
|
||||||
|
at least provide to the public the information requested by the applicable law
|
||||||
|
regarding the Licensor, the Licence and the way it may be accessible, concluded,
|
||||||
|
stored and reproduced by the Licensee.
|
||||||
|
|
||||||
|
12. Termination of the Licence
|
||||||
|
|
||||||
|
The Licence and the rights granted hereunder will terminate automatically upon
|
||||||
|
any breach by the Licensee of the terms of the Licence.
|
||||||
|
|
||||||
|
Such a termination will not terminate the licences of any person who has
|
||||||
|
received the Work from the Licensee under the Licence, provided such persons
|
||||||
|
remain in full compliance with the Licence.
|
||||||
|
|
||||||
|
13. Miscellaneous
|
||||||
|
|
||||||
|
Without prejudice of Article 9 above, the Licence represents the complete
|
||||||
|
agreement between the Parties as to the Work.
|
||||||
|
|
||||||
|
If any provision of the Licence is invalid or unenforceable under applicable
|
||||||
|
law, this will not affect the validity or enforceability of the Licence as a
|
||||||
|
whole. Such provision will be construed or reformed so as necessary to make it
|
||||||
|
valid and enforceable.
|
||||||
|
|
||||||
|
The European Commission may publish other linguistic versions or new versions of
|
||||||
|
this Licence or updated versions of the Appendix, so far this is required and
|
||||||
|
reasonable, without reducing the scope of the rights granted by the Licence. New
|
||||||
|
versions of the Licence will be published with a unique version number.
|
||||||
|
|
||||||
|
All linguistic versions of this Licence, approved by the European Commission,
|
||||||
|
have identical value. Parties can take advantage of the linguistic version of
|
||||||
|
their choice.
|
||||||
|
|
||||||
|
14. Jurisdiction
|
||||||
|
|
||||||
|
Without prejudice to specific agreement between parties,
|
||||||
|
|
||||||
|
- any litigation resulting from the interpretation of this License, arising
|
||||||
|
between the European Union institutions, bodies, offices or agencies, as a
|
||||||
|
Licensor, and any Licensee, will be subject to the jurisdiction of the Court
|
||||||
|
of Justice of the European Union, as laid down in article 272 of the Treaty on
|
||||||
|
the Functioning of the European Union,
|
||||||
|
|
||||||
|
- any litigation arising between other parties and resulting from the
|
||||||
|
interpretation of this License, will be subject to the exclusive jurisdiction
|
||||||
|
of the competent court where the Licensor resides or conducts its primary
|
||||||
|
business.
|
||||||
|
|
||||||
|
15. Applicable Law
|
||||||
|
|
||||||
|
Without prejudice to specific agreement between parties,
|
||||||
|
|
||||||
|
- this Licence shall be governed by the law of the European Union Member State
|
||||||
|
where the Licensor has his seat, resides or has his registered office,
|
||||||
|
|
||||||
|
- this licence shall be governed by Belgian law if the Licensor has no seat,
|
||||||
|
residence or registered office inside a European Union Member State.
|
||||||
|
|
||||||
|
Appendix
|
||||||
|
|
||||||
|
‘Compatible Licences’ according to Article 5 EUPL are:
|
||||||
|
|
||||||
|
- GNU General Public License (GPL) v. 2, v. 3
|
||||||
|
- GNU Affero General Public License (AGPL) v. 3
|
||||||
|
- Open Software License (OSL) v. 2.1, v. 3.0
|
||||||
|
- Eclipse Public License (EPL) v. 1.0
|
||||||
|
- CeCILL v. 2.0, v. 2.1
|
||||||
|
- Mozilla Public Licence (MPL) v. 2
|
||||||
|
- GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3
|
||||||
|
- Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for
|
||||||
|
works other than software
|
||||||
|
- European Union Public Licence (EUPL) v. 1.1, v. 1.2
|
||||||
|
- Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong
|
||||||
|
Reciprocity (LiLiQ-R+).
|
||||||
|
|
||||||
|
The European Commission may update this Appendix to later versions of the above
|
||||||
|
licences without producing a new version of the EUPL, as long as they provide
|
||||||
|
the rights granted in Article 2 of this Licence and protect the covered Source
|
||||||
|
Code from exclusive appropriation.
|
||||||
|
|
||||||
|
All other changes or additions to this Appendix require the production of a new
|
||||||
|
EUPL version.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "db0ba74c125e968c67646390cbba012a5572a5c9c54171588ecbb73e370a448d",
|
"originHash" : "17ce26ba5c862ca674cd3ceeb43a9fe8a5c5251c5561de65e632a06d79916342",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "noora",
|
"identity" : "noora",
|
||||||
@@ -33,17 +33,17 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/apple/swift-argument-parser",
|
"location" : "https://github.com/apple/swift-argument-parser",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615",
|
"revision" : "626b5b7b2f45e1b0b1c6f4a309296d1d21d7311b",
|
||||||
"version" : "1.7.0"
|
"version" : "1.7.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"identity" : "swift-async-algorithms",
|
"identity" : "swift-atomics",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/apple/swift-async-algorithms",
|
"location" : "https://github.com/apple/swift-atomics.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "9d349bcc328ac3c31ce40e746b5882742a0d1272",
|
"revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7",
|
||||||
"version" : "1.1.3"
|
"version" : "1.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -64,6 +64,15 @@
|
|||||||
"version" : "1.10.1"
|
"version" : "1.10.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-nio",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-nio",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "e932d3c4d8f77433c8f7093b5ebcbf91463948a0",
|
||||||
|
"version" : "2.95.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "swift-subprocess",
|
"identity" : "swift-subprocess",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|||||||
@@ -8,16 +8,13 @@ let package = Package(
|
|||||||
.library(
|
.library(
|
||||||
name: "Inotify",
|
name: "Inotify",
|
||||||
targets: ["Inotify"]
|
targets: ["Inotify"]
|
||||||
),
|
|
||||||
.executable(
|
|
||||||
name: "task",
|
|
||||||
targets: ["TaskCLI"]
|
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.7.0"),
|
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.7.1"),
|
||||||
.package(url: "https://github.com/apple/swift-async-algorithms", from: "1.1.3"),
|
|
||||||
.package(url: "https://github.com/apple/swift-log", from: "1.10.1"),
|
.package(url: "https://github.com/apple/swift-log", from: "1.10.1"),
|
||||||
|
.package(url: "https://github.com/apple/swift-nio", from: "2.95.0"),
|
||||||
|
.package(url: "https://github.com/apple/swift-system", from: "1.6.4"),
|
||||||
.package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.3.0"),
|
.package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.3.0"),
|
||||||
.package(url: "https://github.com/tuist/Noora", from: "0.55.1")
|
.package(url: "https://github.com/tuist/Noora", from: "0.55.1")
|
||||||
],
|
],
|
||||||
@@ -28,21 +25,27 @@ let package = Package(
|
|||||||
dependencies: [
|
dependencies: [
|
||||||
"CInotify",
|
"CInotify",
|
||||||
.product(name: "Logging", package: "swift-log"),
|
.product(name: "Logging", package: "swift-log"),
|
||||||
|
.product(name: "_NIOFileSystem", package: "swift-nio"),
|
||||||
|
.product(name: "SystemPackage", package: "swift-system")
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "InotifyIntegrationTests",
|
name: "InotifyIntegrationTests",
|
||||||
dependencies: ["Inotify"],
|
dependencies: [
|
||||||
|
"Inotify",
|
||||||
|
.product(name: "SystemPackage", package: "swift-system")
|
||||||
|
],
|
||||||
),
|
),
|
||||||
.executableTarget(
|
.executableTarget(
|
||||||
name: "TaskCLI",
|
name: "InotifyTaskCLI",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
||||||
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),
|
|
||||||
.product(name: "Logging", package: "swift-log"),
|
.product(name: "Logging", package: "swift-log"),
|
||||||
|
.product(name: "_NIOFileSystem", package: "swift-nio"),
|
||||||
.product(name: "Subprocess", package: "swift-subprocess"),
|
.product(name: "Subprocess", package: "swift-subprocess"),
|
||||||
.product(name: "Noora", package: "Noora")
|
.product(name: "Noora", package: "Noora")
|
||||||
]
|
],
|
||||||
|
path: "Sources/TaskCLI"
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
168
README.md
Normal file
168
README.md
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
# Inotify
|
||||||
|
|
||||||
|
A Swift wrapper around the Linux [inotify](https://man7.org/linux/man-pages/man7/inotify.7.html) API, built on modern Swift concurrency. It lets you watch individual files or directories for filesystem events, recursively monitor entire subtrees, and optionally have newly created subdirectories watched automatically.
|
||||||
|
|
||||||
|
Events are delivered as an `AsyncSequence`, so you can consume them with a simple `for await` loop.
|
||||||
|
|
||||||
|
## Adding Inotify to Your Project
|
||||||
|
|
||||||
|
Add the package dependency in your `Package.swift`:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
dependencies: [
|
||||||
|
.package(url: "https://github.com/astzweig/swift-inotify.git", from: "1.0.0")
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add `Inotify` to your target's dependencies:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
.target(
|
||||||
|
...
|
||||||
|
dependencies: [
|
||||||
|
.product(name: "Inotify", package: "swift-inotify")
|
||||||
|
]
|
||||||
|
...
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```swift
|
||||||
|
import Inotify
|
||||||
|
|
||||||
|
let inotify = try Inotify()
|
||||||
|
|
||||||
|
// Watch a single file for modifications
|
||||||
|
try inotify.addWatch(path: "/tmp/some-existing-file.txt", mask: [.modify])
|
||||||
|
|
||||||
|
// Watch a single directory for file creations and modifications
|
||||||
|
try inotify.addWatch(path: "/tmp/watched", mask: [.create, .modify])
|
||||||
|
|
||||||
|
// Consume events as they arrive
|
||||||
|
for await event in await inotify.events {
|
||||||
|
print("Event at \(event.path): \(event.mask)")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Watching Subtrees
|
||||||
|
|
||||||
|
Inotify operates on individual watch descriptors, so monitoring a directory does not automatically cover its children. This library provides two convenience methods that handle the recursion for you.
|
||||||
|
|
||||||
|
### Recursive Watch
|
||||||
|
|
||||||
|
`addRecursiveWatch` walks the directory tree at setup time and installs a watch on every existing subdirectory:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
try await inotify.addRecursiveWatch(
|
||||||
|
forDirectory: "/home/user/project",
|
||||||
|
mask: [.create, .modify, .delete]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Subdirectories created after the call are **not** watched.
|
||||||
|
|
||||||
|
### Automatic Subtree Watching
|
||||||
|
|
||||||
|
`addWatchWithAutomaticSubtreeWatching` does everything `addRecursiveWatch` does, and additionally listens for `CREATE` events with the `isDir` flag. Whenever a new subdirectory appears, a watch is installed on it automatically:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
try await inotify.addWatchWithAutomaticSubtreeWatching(
|
||||||
|
forDirectory: "/home/user/project",
|
||||||
|
mask: [.create, .modify, .delete]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the most convenient option when you need full coverage of a growing directory tree.
|
||||||
|
|
||||||
|
## Excluding Items
|
||||||
|
|
||||||
|
You can tell the `Inotify` actor to ignore certain file or directory names. Excluded names are skipped during recursive directory resolution (so no watch is installed on them) and silently dropped from the event stream:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
let inotify = try Inotify()
|
||||||
|
|
||||||
|
// Ignore version-control and build directories
|
||||||
|
await inotify.exclude(names: ".git", "node_modules", ".build")
|
||||||
|
|
||||||
|
try await inotify.addWatchWithAutomaticSubtreeWatching(
|
||||||
|
forDirectory: "/home/user/project",
|
||||||
|
mask: [.create, .modify, .delete]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `isExcluded(_:)` to check whether a name is currently on the exclusion list.
|
||||||
|
|
||||||
|
## Event Masks
|
||||||
|
|
||||||
|
`InotifyEventMask` is an `OptionSet` that mirrors the native inotify flags. You can combine them freely.
|
||||||
|
|
||||||
|
| Mask | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `.access` | File was read |
|
||||||
|
| `.attrib` | Metadata changed (permissions, timestamps, ...) |
|
||||||
|
| `.closeWrite` | File opened for writing was closed |
|
||||||
|
| `.closeNoWrite` | File **not** opened for writing was closed |
|
||||||
|
| `.create` | File or directory created in watched directory |
|
||||||
|
| `.delete` | File or directory deleted in watched directory |
|
||||||
|
| `.deleteSelf` | Watched item itself was deleted |
|
||||||
|
| `.modify` | File was written to |
|
||||||
|
| `.moveSelf` | Watched item itself was moved |
|
||||||
|
| `.movedFrom` | File moved **out** of watched directory |
|
||||||
|
| `.movedTo` | File moved **into** watched directory |
|
||||||
|
| `.open` | File was opened |
|
||||||
|
|
||||||
|
Convenience combinations: `.move` (`.movedFrom` + `.movedTo`), `.close` (`.closeWrite` + `.closeNoWrite`), `.allEvents`.
|
||||||
|
|
||||||
|
Watch flags: `.dontFollow`, `.onlyDir`, `.oneShot`.
|
||||||
|
|
||||||
|
Kernel-only flags returned in events: `.isDir`, `.ignored`, `.queueOverflow`, `.unmount`.
|
||||||
|
|
||||||
|
## Removing a Watch
|
||||||
|
|
||||||
|
Every `addWatch` variant returns one or more watch descriptors that you can use to remove the watch later:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
let wd = try inotify.addWatch(path: "/tmp/watched", mask: .create)
|
||||||
|
|
||||||
|
// ... later
|
||||||
|
try inotify.removeWatch(wd)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build Tool
|
||||||
|
|
||||||
|
The package ships with a `task` executable (the `TaskCLI` target) that serves as the project's build tool. It automates running tests and generating documentation inside Linux Docker containers, so you can validate everything on the correct platform even when developing on macOS.
|
||||||
|
Because of a Swift Package Manager Bug in the [package dependency resolution][swiftpm-bug], the executable needs to be run using the `task.sh` shell script.
|
||||||
|
|
||||||
|
[swiftpm-bug]: https://github.com/swiftlang/swift-package-manager/issues/8482
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./task.sh test
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `-v`, `-vv`, or `-vvv` to increase log verbosity. The command runs two passes: first all tests except `InotifyLimitTests`, then only `InotifyLimitTests` (which manipulate system-level inotify limits and need to run in isolation).
|
||||||
|
|
||||||
|
Docker must be installed and running on your machine.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
Full API documentation is available as DocC catalogs bundled with the package. Generate them locally with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./task.sh generate-docs
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open the files in the newly created `public` folder.
|
||||||
|
Or preview in Xcode by selecting **Product > Build Documentation**.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Swift 6.0+
|
||||||
|
- Linux (inotify is a Linux-only API)
|
||||||
|
- Docker (for running the test suite via `swift run task test`)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
See [LICENSE](LICENSE) for details.
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
#include <sys/inotify.h>
|
#include <sys/inotify.h>
|
||||||
|
#include <unistd.h>
|
||||||
#include <errno.h>
|
#include <errno.h>
|
||||||
|
|
||||||
static inline int cinotify_deinit(int fd) {
|
static inline int cinotify_deinit(int fd) {
|
||||||
|
|||||||
37
Sources/Inotify/DirectoryResolver.swift
Normal file
37
Sources/Inotify/DirectoryResolver.swift
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import _NIOFileSystem
|
||||||
|
|
||||||
|
public struct DirectoryResolver {
|
||||||
|
static let fileManager = FileSystem.shared
|
||||||
|
|
||||||
|
public static func resolve(_ paths: String..., excluding itemNames: Set<String> = []) async throws -> [FilePath] {
|
||||||
|
try await Self.resolve(paths, excluding: itemNames)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func resolve(_ paths: [String], excluding itemNames: Set<String> = []) async throws -> [FilePath] {
|
||||||
|
var resolved: [FilePath] = []
|
||||||
|
|
||||||
|
for path in paths {
|
||||||
|
let path = FilePath(path)
|
||||||
|
resolved.append(path)
|
||||||
|
try await withSubdirectories(at: path, recursive: true) { subdirectoryPath in
|
||||||
|
guard let basename = subdirectoryPath.lastComponent?.description else { return }
|
||||||
|
guard !itemNames.contains(basename) else { return }
|
||||||
|
resolved.append(subdirectoryPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func withSubdirectories(at path: FilePath, recursive: Bool = false, body: (FilePath) async throws -> Void) async throws {
|
||||||
|
let directoryHandle = try await fileManager.openDirectory(atPath: path)
|
||||||
|
for try await childContent in directoryHandle.listContents() {
|
||||||
|
guard childContent.type == .directory else { continue }
|
||||||
|
try await body(childContent.path)
|
||||||
|
if recursive {
|
||||||
|
try await withSubdirectories(at: childContent.path, recursive: recursive, body: body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try await directoryHandle.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
16
Sources/Inotify/DirectoryResolverErrror.swift
Normal file
16
Sources/Inotify/DirectoryResolverErrror.swift
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import Foundation
|
||||||
|
import SystemPackage
|
||||||
|
|
||||||
|
public enum DirectoryResolverError: LocalizedError, Equatable {
|
||||||
|
case pathNotFound(FilePath)
|
||||||
|
case pathIsNoDirectory(FilePath)
|
||||||
|
|
||||||
|
var errorDescription: String {
|
||||||
|
switch self {
|
||||||
|
case .pathNotFound(let path):
|
||||||
|
return "Path not found: \(path)"
|
||||||
|
case .pathIsNoDirectory(let path):
|
||||||
|
return "Path is not a directory: \(path)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
46
Sources/Inotify/Inotify.docc/Inotify.md
Normal file
46
Sources/Inotify/Inotify.docc/Inotify.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# ``Inotify``
|
||||||
|
|
||||||
|
Monitor filesystem events on Linux using modern Swift concurrency.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Inotify library wraps the Linux [inotify](https://man7.org/linux/man-pages/man7/inotify.7.html) API in a Swift-native interface built around actors and async sequences. You create an ``Inotify/Inotify`` actor, add watches for the paths you care about, and iterate over the ``Inotify/Inotify/events`` property to receive ``InotifyEvent`` values as they occur.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
let inotify = try Inotify()
|
||||||
|
try inotify.addWatch(path: "/tmp/inbox", mask: [.create, .modify])
|
||||||
|
|
||||||
|
for await event in await inotify.events {
|
||||||
|
print("\(event.mask) at \(event.path)")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Beyond single-directory watches, the library provides two higher-level methods for monitoring entire directory trees:
|
||||||
|
|
||||||
|
- ``Inotify/Inotify/addRecursiveWatch(forDirectory:mask:)`` installs watches on every existing subdirectory at setup time.
|
||||||
|
- ``Inotify/Inotify/addWatchWithAutomaticSubtreeWatching(forDirectory:mask:)`` does the same **and** automatically watches subdirectories that are created after setup.
|
||||||
|
|
||||||
|
You can also exclude certain file or directory names so that they are skipped during directory resolution and silently dropped from the event stream. See ``Inotify/Inotify/exclude(names:)`` and <doc:WatchingDirectoryTrees> for details.
|
||||||
|
|
||||||
|
All public types conform to `Sendable`, so they can be safely passed across concurrency boundaries.
|
||||||
|
|
||||||
|
## Topics
|
||||||
|
|
||||||
|
### Essentials
|
||||||
|
|
||||||
|
- ``Inotify/Inotify``
|
||||||
|
- ``InotifyEvent``
|
||||||
|
- ``InotifyEventMask``
|
||||||
|
|
||||||
|
### Articles
|
||||||
|
|
||||||
|
- <doc:WatchingDirectoryTrees>
|
||||||
|
|
||||||
|
### Errors
|
||||||
|
|
||||||
|
- ``InotifyError``
|
||||||
|
- ``DirectoryResolverError``
|
||||||
|
|
||||||
|
### Low-Level Types
|
||||||
|
|
||||||
|
- ``RawInotifyEvent``
|
||||||
58
Sources/Inotify/Inotify.docc/WatchingDirectoryTrees.md
Normal file
58
Sources/Inotify/Inotify.docc/WatchingDirectoryTrees.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Watching Directory Trees
|
||||||
|
|
||||||
|
Monitor an entire directory hierarchy for filesystem events.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Linux inotify API watches individual directories — it does not descend into subdirectories automatically. The ``Inotify/Inotify`` actor offers two convenience methods that handle the recursion for you.
|
||||||
|
|
||||||
|
### Recursive Watch
|
||||||
|
|
||||||
|
Call ``Inotify/Inotify/addRecursiveWatch(forDirectory:mask:)`` to walk the directory tree once and install a watch on every subdirectory that exists at the time of the call:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
let inotify = try Inotify()
|
||||||
|
let descriptors = try await inotify.addRecursiveWatch(
|
||||||
|
forDirectory: "/home/user/project",
|
||||||
|
mask: [.create, .modify, .delete]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
The returned array contains one watch descriptor per directory. Subdirectories created **after** this call are not covered.
|
||||||
|
|
||||||
|
### Automatic Subtree Watching
|
||||||
|
|
||||||
|
When you also want future subdirectories to be picked up, use ``Inotify/Inotify/addWatchWithAutomaticSubtreeWatching(forDirectory:mask:)`` instead:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
let descriptors = try await inotify.addWatchWithAutomaticSubtreeWatching(
|
||||||
|
forDirectory: "/home/user/project",
|
||||||
|
mask: [.create, .modify, .delete]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Internally this listens for `CREATE` events carrying the ``InotifyEventMask/isDir`` flag and installs a new watch with the same mask whenever a subdirectory appears.
|
||||||
|
|
||||||
|
### Excluding Directories
|
||||||
|
|
||||||
|
When watching large trees you often want to skip certain subdirectories entirely — version-control metadata, build artefacts, dependency caches, and so on. Call ``Inotify/Inotify/exclude(names:)`` **before** adding a recursive or automatic-subtree watch:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
let inotify = try Inotify()
|
||||||
|
await inotify.exclude(names: ".git", "node_modules", ".build")
|
||||||
|
|
||||||
|
try await inotify.addWatchWithAutomaticSubtreeWatching(
|
||||||
|
forDirectory: "/home/user/project",
|
||||||
|
mask: .allEvents
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Excluded names are matched against the last path component of each directory during resolution and are also filtered from the event stream, so you never receive events for items whose name is on the exclusion list.
|
||||||
|
|
||||||
|
### Choosing the Right Method
|
||||||
|
|
||||||
|
| Method | Covers existing subdirectories | Covers new subdirectories |
|
||||||
|
|--------|:----:|:----:|
|
||||||
|
| ``Inotify/Inotify/addWatch(path:mask:)`` | No | No |
|
||||||
|
| ``Inotify/Inotify/addRecursiveWatch(forDirectory:mask:)`` | Yes | No |
|
||||||
|
| ``Inotify/Inotify/addWatchWithAutomaticSubtreeWatching(forDirectory:mask:)`` | Yes | Yes |
|
||||||
@@ -1,34 +1,121 @@
|
|||||||
|
import Dispatch
|
||||||
import CInotify
|
import CInotify
|
||||||
|
|
||||||
public actor Inotify {
|
public actor Inotify {
|
||||||
private let fd: Int32
|
private let fd: CInt
|
||||||
private var watches: [Int32: String] = [:]
|
private var excludedItemNames: Set<String> = []
|
||||||
|
private var watches = InotifyWatchManager()
|
||||||
|
private var eventReader: any DispatchSourceRead
|
||||||
|
private nonisolated let eventStream: AsyncStream<RawInotifyEvent>
|
||||||
|
public nonisolated var events: AsyncCompactMapSequence<AsyncStream<RawInotifyEvent>, InotifyEvent> {
|
||||||
|
self.eventStream.compactMap(self.transform(_:))
|
||||||
|
}
|
||||||
|
|
||||||
public init() throws {
|
public init() throws {
|
||||||
self.fd = inotify_init1(Int32(IN_NONBLOCK | IN_CLOEXEC))
|
self.fd = inotify_init1(CInt(IN_NONBLOCK | IN_CLOEXEC))
|
||||||
guard self.fd >= 0 else {
|
guard self.fd >= 0 else {
|
||||||
throw InotifyError.initFailed(errno: cinotify_get_errno())
|
throw InotifyError.initFailed(errno: cinotify_get_errno())
|
||||||
}
|
}
|
||||||
|
(self.eventReader, self.eventStream) = Self.createEventReader(forFileDescriptor: fd)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func isExcluded(_ name: String) -> Bool {
|
||||||
|
self.excludedItemNames.contains(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func exclude(name: String) {
|
||||||
|
self.excludedItemNames.insert(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func exclude(names: String...) {
|
||||||
|
self.exclude(names: names)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func exclude(names: [String]) {
|
||||||
|
for name in names {
|
||||||
|
self.excludedItemNames.insert(name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
public func addWatch(path: String, mask: InotifyEventMask) throws -> Int32 {
|
public func addWatch(path: String, mask: InotifyEventMask) throws -> CInt {
|
||||||
let wd = inotify_add_watch(self.fd, path, mask.rawValue)
|
let wd = inotify_add_watch(self.fd, path, mask.rawValue)
|
||||||
guard wd >= 0 else {
|
guard wd >= 0 else {
|
||||||
throw InotifyError.addWatchFailed(path: path, errno: cinotify_get_errno())
|
throw InotifyError.addWatchFailed(path: path, errno: cinotify_get_errno())
|
||||||
}
|
}
|
||||||
watches[wd] = path
|
watches.add(path, withId: wd, mask: mask)
|
||||||
return wd
|
return wd
|
||||||
}
|
}
|
||||||
|
|
||||||
public func removeWatch(_ wd: Int32) throws {
|
@discardableResult
|
||||||
|
public func addRecursiveWatch(forDirectory path: String, mask: InotifyEventMask) async throws -> [CInt] {
|
||||||
|
let directoryPaths = try await DirectoryResolver.resolve(path, excluding: self.excludedItemNames)
|
||||||
|
var result: [CInt] = []
|
||||||
|
for path in directoryPaths {
|
||||||
|
let wd = try self.addWatch(path: path.string, mask: mask)
|
||||||
|
result.append(wd)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public func addWatchWithAutomaticSubtreeWatching(forDirectory path: String, mask: InotifyEventMask) async throws -> [CInt] {
|
||||||
|
let wds = try await self.addRecursiveWatch(forDirectory: path, mask: mask)
|
||||||
|
watches.enableAutomaticSubtreeWatching(forIds: wds)
|
||||||
|
return wds
|
||||||
|
}
|
||||||
|
|
||||||
|
public func removeWatch(_ wd: CInt) throws {
|
||||||
guard inotify_rm_watch(self.fd, wd) == 0 else {
|
guard inotify_rm_watch(self.fd, wd) == 0 else {
|
||||||
throw InotifyError.removeWatchFailed(watchDescriptor: wd, errno: cinotify_get_errno())
|
throw InotifyError.removeWatchFailed(watchDescriptor: wd, errno: cinotify_get_errno())
|
||||||
}
|
}
|
||||||
watches.removeValue(forKey: wd)
|
watches.remove(forId: wd)
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
cinotify_deinit(self.fd)
|
cinotify_deinit(self.fd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func transform(_ rawEvent: RawInotifyEvent) async -> InotifyEvent? {
|
||||||
|
guard let path = self.watches.path(forId: rawEvent.watchDescriptor) else { return nil }
|
||||||
|
guard !self.excludedItemNames.contains(rawEvent.name) else { return nil }
|
||||||
|
let event = InotifyEvent.init(from: rawEvent, inDirectory: path)
|
||||||
|
await self.addWatchInCaseOfAutomaticSubtreeWatching(event)
|
||||||
|
return InotifyEvent.init(from: rawEvent, inDirectory: path)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addWatchInCaseOfAutomaticSubtreeWatching(_ event: InotifyEvent) async {
|
||||||
|
guard watches.isAutomaticSubtreeWatching(event.watchDescriptor),
|
||||||
|
event.mask.contains(.create),
|
||||||
|
event.mask.contains(.isDir) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let mask = self.watches.mask(forId: event.watchDescriptor) else { return }
|
||||||
|
let _ = try? await self.addWatchWithAutomaticSubtreeWatching(forDirectory: event.path.string, mask: mask)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func createEventReader(forFileDescriptor fd: CInt) -> (any DispatchSourceRead, AsyncStream<RawInotifyEvent>) {
|
||||||
|
let (stream, continuation) = AsyncStream<RawInotifyEvent>.makeStream(
|
||||||
|
of: RawInotifyEvent.self,
|
||||||
|
bufferingPolicy: .bufferingNewest(512)
|
||||||
|
)
|
||||||
|
|
||||||
|
let reader = DispatchSource.makeReadSource(
|
||||||
|
fileDescriptor: fd,
|
||||||
|
queue: DispatchQueue(label: "Inotify.read", qos: .utility)
|
||||||
|
)
|
||||||
|
|
||||||
|
reader.setEventHandler {
|
||||||
|
for rawEvent in InotifyEventParser.parse(fromFileDescriptor: fd) {
|
||||||
|
continuation.yield(rawEvent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.setCancelHandler {
|
||||||
|
continuation.finish()
|
||||||
|
}
|
||||||
|
reader.activate()
|
||||||
|
|
||||||
|
return (reader, stream)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
26
Sources/Inotify/InotifyEvent.swift
Normal file
26
Sources/Inotify/InotifyEvent.swift
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import SystemPackage
|
||||||
|
|
||||||
|
public struct InotifyEvent: Sendable, Hashable, CustomStringConvertible {
|
||||||
|
public let watchDescriptor: Int32
|
||||||
|
public let mask: InotifyEventMask
|
||||||
|
public let cookie: UInt32
|
||||||
|
public let path: FilePath
|
||||||
|
|
||||||
|
public var description: String {
|
||||||
|
var parts = ["InotifyEvent(wd: \(watchDescriptor), mask: \(mask), path: \"\(path)\""]
|
||||||
|
if cookie != 0 { parts.append("cookie: \(cookie)") }
|
||||||
|
return parts.joined(separator: ", ") + ")"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension InotifyEvent {
|
||||||
|
public init(from rawEvent: RawInotifyEvent, inDirectory path: String) {
|
||||||
|
let dirPath = FilePath(path)
|
||||||
|
self.init(
|
||||||
|
watchDescriptor: rawEvent.watchDescriptor,
|
||||||
|
mask: rawEvent.mask,
|
||||||
|
cookie: rawEvent.cookie,
|
||||||
|
path: dirPath.appending(rawEvent.name)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import CInotify
|
import CInotify
|
||||||
|
|
||||||
public struct InotifyEventMask: OptionSet, Sendable, Hashable {
|
public struct InotifyEventMask: OptionSet, Sendable, Hashable {
|
||||||
public let rawValue: UInt32
|
public let rawValue: CUnsignedInt
|
||||||
|
|
||||||
public init(rawValue: UInt32) {
|
public init(rawValue: UInt32) {
|
||||||
self.rawValue = rawValue
|
self.rawValue = rawValue
|
||||||
@@ -9,18 +9,18 @@ public struct InotifyEventMask: OptionSet, Sendable, Hashable {
|
|||||||
|
|
||||||
// MARK: - Watchable Events
|
// MARK: - Watchable Events
|
||||||
|
|
||||||
public static let access = InotifyEventMask(rawValue: UInt32(IN_ACCESS))
|
public static let access = InotifyEventMask(rawValue: CUnsignedInt(IN_ACCESS))
|
||||||
public static let attrib = InotifyEventMask(rawValue: UInt32(IN_ATTRIB))
|
public static let attrib = InotifyEventMask(rawValue: CUnsignedInt(IN_ATTRIB))
|
||||||
public static let closeWrite = InotifyEventMask(rawValue: UInt32(IN_CLOSE_WRITE))
|
public static let closeWrite = InotifyEventMask(rawValue: CUnsignedInt(IN_CLOSE_WRITE))
|
||||||
public static let closeNoWrite = InotifyEventMask(rawValue: UInt32(IN_CLOSE_NOWRITE))
|
public static let closeNoWrite = InotifyEventMask(rawValue: CUnsignedInt(IN_CLOSE_NOWRITE))
|
||||||
public static let create = InotifyEventMask(rawValue: UInt32(IN_CREATE))
|
public static let create = InotifyEventMask(rawValue: CUnsignedInt(IN_CREATE))
|
||||||
public static let delete = InotifyEventMask(rawValue: UInt32(IN_DELETE))
|
public static let delete = InotifyEventMask(rawValue: CUnsignedInt(IN_DELETE))
|
||||||
public static let deleteSelf = InotifyEventMask(rawValue: UInt32(IN_DELETE_SELF))
|
public static let deleteSelf = InotifyEventMask(rawValue: CUnsignedInt(IN_DELETE_SELF))
|
||||||
public static let modify = InotifyEventMask(rawValue: UInt32(IN_MODIFY))
|
public static let modify = InotifyEventMask(rawValue: CUnsignedInt(IN_MODIFY))
|
||||||
public static let moveSelf = InotifyEventMask(rawValue: UInt32(IN_MOVE_SELF))
|
public static let moveSelf = InotifyEventMask(rawValue: CUnsignedInt(IN_MOVE_SELF))
|
||||||
public static let movedFrom = InotifyEventMask(rawValue: UInt32(IN_MOVED_FROM))
|
public static let movedFrom = InotifyEventMask(rawValue: CUnsignedInt(IN_MOVED_FROM))
|
||||||
public static let movedTo = InotifyEventMask(rawValue: UInt32(IN_MOVED_TO))
|
public static let movedTo = InotifyEventMask(rawValue: CUnsignedInt(IN_MOVED_TO))
|
||||||
public static let open = InotifyEventMask(rawValue: UInt32(IN_OPEN))
|
public static let open = InotifyEventMask(rawValue: CUnsignedInt(IN_OPEN))
|
||||||
|
|
||||||
// MARK: - Combinations
|
// MARK: - Combinations
|
||||||
|
|
||||||
@@ -34,14 +34,14 @@ public struct InotifyEventMask: OptionSet, Sendable, Hashable {
|
|||||||
|
|
||||||
// MARK: - Watch Flags
|
// MARK: - Watch Flags
|
||||||
|
|
||||||
public static let dontFollow = InotifyEventMask(rawValue: UInt32(IN_DONT_FOLLOW))
|
public static let dontFollow = InotifyEventMask(rawValue: CUnsignedInt(IN_DONT_FOLLOW))
|
||||||
public static let onlyDir = InotifyEventMask(rawValue: UInt32(IN_ONLYDIR))
|
public static let onlyDir = InotifyEventMask(rawValue: CUnsignedInt(IN_ONLYDIR))
|
||||||
public static let oneShot = InotifyEventMask(rawValue: UInt32(IN_ONESHOT))
|
public static let oneShot = InotifyEventMask(rawValue: CUnsignedInt(IN_ONESHOT))
|
||||||
|
|
||||||
// MARK: - Kernel-Only Flags
|
// MARK: - Kernel-Only Flags
|
||||||
|
|
||||||
public static let isDir = InotifyEventMask(rawValue: UInt32(IN_ISDIR))
|
public static let isDir = InotifyEventMask(rawValue: CUnsignedInt(IN_ISDIR))
|
||||||
public static let ignored = InotifyEventMask(rawValue: UInt32(IN_IGNORED))
|
public static let ignored = InotifyEventMask(rawValue: CUnsignedInt(IN_IGNORED))
|
||||||
public static let queueOverflow = InotifyEventMask(rawValue: UInt32(IN_Q_OVERFLOW))
|
public static let queueOverflow = InotifyEventMask(rawValue: CUnsignedInt(IN_Q_OVERFLOW))
|
||||||
public static let unmount = InotifyEventMask(rawValue: UInt32(IN_UNMOUNT))
|
public static let unmount = InotifyEventMask(rawValue: CUnsignedInt(IN_UNMOUNT))
|
||||||
}
|
}
|
||||||
|
|||||||
57
Sources/Inotify/InotifyEventParser.swift
Normal file
57
Sources/Inotify/InotifyEventParser.swift
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import CInotify
|
||||||
|
|
||||||
|
struct InotifyEventParser {
|
||||||
|
static let readBufferSize = 4096
|
||||||
|
|
||||||
|
static func parse(fromFileDescriptor fd: Int32) -> [RawInotifyEvent] {
|
||||||
|
let buffer = UnsafeMutableRawPointer.allocate(
|
||||||
|
byteCount: Self.readBufferSize,
|
||||||
|
alignment: MemoryLayout<inotify_event>.alignment
|
||||||
|
)
|
||||||
|
defer { buffer.deallocate() }
|
||||||
|
|
||||||
|
let bytesRead = read(fd, buffer, readBufferSize)
|
||||||
|
guard bytesRead > 0 else { return [] }
|
||||||
|
|
||||||
|
return Self.parseEventBuffer(buffer, bytesRead: bytesRead)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseEventBuffer(
|
||||||
|
_ buffer: UnsafeMutableRawPointer,
|
||||||
|
bytesRead: Int
|
||||||
|
) -> [RawInotifyEvent] {
|
||||||
|
var events: [RawInotifyEvent] = []
|
||||||
|
var offset = 0
|
||||||
|
|
||||||
|
while offset < bytesRead {
|
||||||
|
let eventPointer = buffer.advanced(by: offset)
|
||||||
|
let rawEvent = eventPointer.assumingMemoryBound(to: inotify_event.self).pointee
|
||||||
|
|
||||||
|
events.append(RawInotifyEvent(
|
||||||
|
watchDescriptor: rawEvent.wd,
|
||||||
|
mask: InotifyEventMask(rawValue: rawEvent.mask),
|
||||||
|
cookie: rawEvent.cookie,
|
||||||
|
name: Self.extractName(from: eventPointer, nameLength: rawEvent.len)
|
||||||
|
))
|
||||||
|
|
||||||
|
offset += Self.eventSize(nameLength: rawEvent.len)
|
||||||
|
}
|
||||||
|
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func extractName(
|
||||||
|
from eventPointer: UnsafeMutableRawPointer,
|
||||||
|
nameLength: UInt32
|
||||||
|
) -> String {
|
||||||
|
guard nameLength > 0 else { return "" }
|
||||||
|
let namePointer = eventPointer
|
||||||
|
.advanced(by: MemoryLayout<inotify_event>.size)
|
||||||
|
.assumingMemoryBound(to: CChar.self)
|
||||||
|
return String(cString: namePointer)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func eventSize(nameLength: UInt32) -> Int {
|
||||||
|
MemoryLayout<inotify_event>.size + Int(nameLength)
|
||||||
|
}
|
||||||
|
}
|
||||||
46
Sources/Inotify/InotifyWatchManager.swift
Normal file
46
Sources/Inotify/InotifyWatchManager.swift
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
struct InotifyWatchManager {
|
||||||
|
private var watchPaths: [CInt: String] = [:]
|
||||||
|
private var watchMasks: [CInt: InotifyEventMask] = [:]
|
||||||
|
private var activeWatches: Set<CInt> = []
|
||||||
|
private var watchesWithAutomaticSubtreeWatching: Set<CInt> = []
|
||||||
|
|
||||||
|
mutating func add(_ path: String, withId watchDescriptor: CInt, mask: InotifyEventMask) {
|
||||||
|
self.watchPaths[watchDescriptor] = path
|
||||||
|
self.watchMasks[watchDescriptor] = mask
|
||||||
|
self.activeWatches.insert(watchDescriptor)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func enableAutomaticSubtreeWatching(forId watchDescriptor: CInt) {
|
||||||
|
assert(self.activeWatches.contains(watchDescriptor))
|
||||||
|
self.watchesWithAutomaticSubtreeWatching.insert(watchDescriptor)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func enableAutomaticSubtreeWatching(forIds watchDescriptors: CInt...) {
|
||||||
|
self.enableAutomaticSubtreeWatching(forIds: watchDescriptors)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func enableAutomaticSubtreeWatching(forIds watchDescriptors: [CInt]) {
|
||||||
|
for watchDescriptor in watchDescriptors {
|
||||||
|
self.enableAutomaticSubtreeWatching(forId: watchDescriptor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func remove(forId watchDescriptor: CInt) {
|
||||||
|
self.watchPaths.removeValue(forKey: watchDescriptor)
|
||||||
|
self.watchMasks.removeValue(forKey: watchDescriptor)
|
||||||
|
self.activeWatches.remove(watchDescriptor)
|
||||||
|
self.watchesWithAutomaticSubtreeWatching.remove(watchDescriptor)
|
||||||
|
}
|
||||||
|
|
||||||
|
func path(forId watchDescriptor: CInt) -> String? {
|
||||||
|
return self.watchPaths[watchDescriptor]
|
||||||
|
}
|
||||||
|
|
||||||
|
func mask(forId watchDescriptor: CInt) -> InotifyEventMask? {
|
||||||
|
return self.watchMasks[watchDescriptor]
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAutomaticSubtreeWatching(_ watchDescriptor: CInt) -> Bool {
|
||||||
|
return self.watchesWithAutomaticSubtreeWatching.contains(watchDescriptor)
|
||||||
|
}
|
||||||
|
}
|
||||||
12
Sources/Inotify/RawInotifyEvent.swift
Normal file
12
Sources/Inotify/RawInotifyEvent.swift
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
public struct RawInotifyEvent: Sendable, Hashable, CustomStringConvertible {
|
||||||
|
public let watchDescriptor: Int32
|
||||||
|
public let mask: InotifyEventMask
|
||||||
|
public let cookie: UInt32
|
||||||
|
public let name: String
|
||||||
|
|
||||||
|
public var description: String {
|
||||||
|
var parts = ["RawInotifyEvent(wd: \(watchDescriptor), mask: \(mask), name: \"\(name)\""]
|
||||||
|
if cookie != 0 { parts.append("cookie: \(cookie)") }
|
||||||
|
return parts.joined(separator: ", ") + ")"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,6 @@ import ArgumentParser
|
|||||||
struct Command: AsyncParsableCommand {
|
struct Command: AsyncParsableCommand {
|
||||||
static let configuration = CommandConfiguration(
|
static let configuration = CommandConfiguration(
|
||||||
abstract: "Project tasks of Astzweig's Swift Inotify project.",
|
abstract: "Project tasks of Astzweig's Swift Inotify project.",
|
||||||
subcommands: [TestCommand.self]
|
subcommands: [TestCommand.self, GenerateDocumentationCommand.self]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
25
Sources/TaskCLI/DoccFinder.swift
Normal file
25
Sources/TaskCLI/DoccFinder.swift
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import _NIOFileSystem
|
||||||
|
|
||||||
|
public struct DoccFinder {
|
||||||
|
static let fileManager = FileSystem.shared
|
||||||
|
|
||||||
|
public static func hasDoccFolder(at path: String) async throws -> Bool {
|
||||||
|
let itemPath = FilePath(path)
|
||||||
|
var hasDoccFolder = false
|
||||||
|
|
||||||
|
try await withSubdirectories(at: itemPath) { subdirectory in
|
||||||
|
guard subdirectory.description.hasSuffix(".docc") else { return }
|
||||||
|
hasDoccFolder = true
|
||||||
|
}
|
||||||
|
return hasDoccFolder
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func withSubdirectories(at path: FilePath, body: (FilePath) async throws -> Void) async throws {
|
||||||
|
let directoryHandle = try await fileManager.openDirectory(atPath: path)
|
||||||
|
for try await childContent in directoryHandle.listContents() {
|
||||||
|
guard childContent.type == .directory else { continue }
|
||||||
|
try await body(childContent.path)
|
||||||
|
}
|
||||||
|
try await directoryHandle.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
9
Sources/TaskCLI/Docker.swift
Normal file
9
Sources/TaskCLI/Docker.swift
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
struct Docker {
|
||||||
|
static func getLinuxPlatformStringWithHostArchitecture() -> String {
|
||||||
|
#if arch(x86_64)
|
||||||
|
return "linux/amd64"
|
||||||
|
#else
|
||||||
|
return "linux/arm64"
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
204
Sources/TaskCLI/GenerateDocumentation Command.swift
Normal file
204
Sources/TaskCLI/GenerateDocumentation Command.swift
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import Foundation
|
||||||
|
import Logging
|
||||||
|
import Noora
|
||||||
|
import Subprocess
|
||||||
|
|
||||||
|
struct GenerateDocumentationCommand: AsyncParsableCommand {
|
||||||
|
static let configuration = CommandConfiguration(
|
||||||
|
commandName: "generate-documentation",
|
||||||
|
abstract: "Generate DocC documentation of all targets inside a Linux container.",
|
||||||
|
aliases: ["gd"],
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptionGroup var global: GlobalOptions
|
||||||
|
|
||||||
|
private static let doccPluginURL = "https://github.com/apple/swift-docc-plugin.git"
|
||||||
|
private static let doccPluginMinVersion = "1.4.0"
|
||||||
|
private static let skipItems: Set<String> = [".git", ".build", ".swiftpm", "public"]
|
||||||
|
|
||||||
|
// MARK: - Run
|
||||||
|
|
||||||
|
func run() async throws {
|
||||||
|
let noora = Noora()
|
||||||
|
let logger = global.makeLogger(labeled: "swift-inotify.cli.task.generate-documentation")
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let projectDirectory = URL(fileURLWithPath: fileManager.currentDirectoryPath)
|
||||||
|
|
||||||
|
let targets = try await Self.targets(for: projectDirectory)
|
||||||
|
|
||||||
|
noora.info("Generating DocC documentation on Linux.")
|
||||||
|
logger.debug("Current directory", metadata: ["current-directory": "\(projectDirectory.path(percentEncoded: false))", "targets": "\(targets.joined(separator: ", "))"])
|
||||||
|
|
||||||
|
let tempDirectory = try copyProject(from: projectDirectory)
|
||||||
|
logger.info("Copied project to temporary directory.", metadata: ["path": "\(tempDirectory.path(percentEncoded: false))"])
|
||||||
|
|
||||||
|
defer {
|
||||||
|
try? fileManager.removeItem(at: tempDirectory)
|
||||||
|
logger.info("Cleaned up temporary directory.")
|
||||||
|
}
|
||||||
|
|
||||||
|
try await injectDoccPluginDependency(in: tempDirectory, logger: logger)
|
||||||
|
let script = Self.makeRunScript(for: targets)
|
||||||
|
|
||||||
|
logger.debug("Container script", metadata: ["script": "\(script)"])
|
||||||
|
let dockerRunResult = try await Subprocess.run(
|
||||||
|
.name("docker"),
|
||||||
|
arguments: [
|
||||||
|
"run", "--rm",
|
||||||
|
"-v", "\(tempDirectory.path):/code",
|
||||||
|
"-v", "swift-inotify-build-cache:/code/.build",
|
||||||
|
"--platform", Docker.getLinuxPlatformStringWithHostArchitecture(),
|
||||||
|
"-w", "/code",
|
||||||
|
"swift:latest",
|
||||||
|
"/bin/bash", "-c", script
|
||||||
|
],
|
||||||
|
output: .standardOutput,
|
||||||
|
error: .standardError
|
||||||
|
)
|
||||||
|
if !dockerRunResult.terminationStatus.isSuccess {
|
||||||
|
noora.error("Documentation generation failed.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try copyResults(from: tempDirectory, to: projectDirectory)
|
||||||
|
try Self.generateIndexHTML(
|
||||||
|
templateURL: projectDirectory.appending(path: ".github/workflows/index.tpl.html"),
|
||||||
|
outputURL: projectDirectory.appending(path: "public/index.html")
|
||||||
|
)
|
||||||
|
|
||||||
|
noora.success(
|
||||||
|
.alert("Documentation generated successfully.",
|
||||||
|
takeaways: ["Start a local web server with ./public as document root, i.e. with python3 -m http.server to browse the documentation."]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func generateIndexHTML(templateURL: URL, outputURL: URL) throws {
|
||||||
|
var content = try String(contentsOf: templateURL, encoding: .utf8)
|
||||||
|
|
||||||
|
let replacements: [(String, String)] = [
|
||||||
|
("{{project.name}}", "Swift Inotify"),
|
||||||
|
("{{project.tagline}}", "🗂️ Monitor filesystem events on Linux using modern Swift concurrency"),
|
||||||
|
("{{project.links}}", """
|
||||||
|
<li><a href="inotify/documentation/inotify/">Inotify</a>: The actual library.</li>\
|
||||||
|
<li><a href="inotifytaskcli/documentation/inotifytaskcli/">TaskCLI</a>: The project build command.</li>
|
||||||
|
"""),
|
||||||
|
]
|
||||||
|
|
||||||
|
for (placeholder, value) in replacements {
|
||||||
|
content = content.replacingOccurrences(of: placeholder, with: value)
|
||||||
|
}
|
||||||
|
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: outputURL.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try content.write(to: outputURL, atomically: true, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func targets(for projectDirectory: URL) async throws -> [String] {
|
||||||
|
let packages = try await Self.packageTargets()
|
||||||
|
var packagesWithDoccFolder: [(name: String, path: String)] = []
|
||||||
|
for package in packages {
|
||||||
|
guard try await DoccFinder.hasDoccFolder(at: package.path) else { continue }
|
||||||
|
packagesWithDoccFolder.append(package)
|
||||||
|
}
|
||||||
|
return packagesWithDoccFolder.map { $0.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func packageTargets() async throws -> [(name: String, path: String)] {
|
||||||
|
let packageDescriptionResult = try await Subprocess.run(
|
||||||
|
.name("swift"),
|
||||||
|
arguments: ["package", "describe", "--type", "json"],
|
||||||
|
output: .data(limit: 10_000),
|
||||||
|
error: .standardError
|
||||||
|
)
|
||||||
|
|
||||||
|
struct PackageDescription: Codable {
|
||||||
|
let targets: [Target]
|
||||||
|
}
|
||||||
|
struct Target: Codable {
|
||||||
|
let name: String
|
||||||
|
let path: String
|
||||||
|
}
|
||||||
|
|
||||||
|
if !packageDescriptionResult.terminationStatus.isSuccess {
|
||||||
|
throw GenerateDocumentationError.unableToReadPackageDescription
|
||||||
|
}
|
||||||
|
|
||||||
|
let package = try JSONDecoder().decode(PackageDescription.self, from: packageDescriptionResult.standardOutput)
|
||||||
|
return package.targets.map { ($0.name, $0.path) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeRunScript(for targets: [String]) -> String {
|
||||||
|
targets.map {
|
||||||
|
"mkdir -p \"./public/\($0.localizedLowercase)\" && " +
|
||||||
|
"swift package --allow-writing-to-directory \"\($0.localizedLowercase)\" " +
|
||||||
|
"generate-documentation --disable-indexing --transform-for-static-hosting " +
|
||||||
|
"--target \"\($0)\" " +
|
||||||
|
"--hosting-base-path \"\($0.localizedLowercase)\" " +
|
||||||
|
"--output-path \"./public/\($0.localizedLowercase)\""
|
||||||
|
}.joined(separator: " && ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Project Copy
|
||||||
|
|
||||||
|
private func copyProject(from source: URL) throws -> URL {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let tempDirectory = fileManager.temporaryDirectory.appending(path: "swift-inotify-docs-\(UUID().uuidString)")
|
||||||
|
try fileManager.createDirectory(at: tempDirectory, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
let contents = try fileManager.contentsOfDirectory(at: source, includingPropertiesForKeys: nil)
|
||||||
|
for item in contents {
|
||||||
|
guard !Self.skipItems.contains(item.lastPathComponent) else { continue }
|
||||||
|
try fileManager.copyItem(at: item, to: tempDirectory.appending(path: item.lastPathComponent))
|
||||||
|
}
|
||||||
|
|
||||||
|
return tempDirectory
|
||||||
|
}
|
||||||
|
|
||||||
|
private func copyResults(from tempDirectory: URL, to projectDirectory: URL) throws {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let source = tempDirectory.appending(path: "public")
|
||||||
|
let destination = projectDirectory.appending(path: "public")
|
||||||
|
|
||||||
|
if fileManager.fileExists(atPath: destination.path(percentEncoded: false)) {
|
||||||
|
try fileManager.removeItem(at: destination)
|
||||||
|
}
|
||||||
|
try fileManager.copyItem(at: source, to: destination)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Dependency Injection
|
||||||
|
|
||||||
|
private func injectDoccPluginDependency(in directory: URL, logger: Logger) async throws {
|
||||||
|
let swiftRunResult = try await Subprocess.run(
|
||||||
|
.name("swift"),
|
||||||
|
arguments: [
|
||||||
|
"package", "--package-path", directory.path(percentEncoded: false),
|
||||||
|
"add-dependency", "--from", Self.doccPluginMinVersion, Self.doccPluginURL
|
||||||
|
],
|
||||||
|
output: .standardOutput,
|
||||||
|
error: .standardError
|
||||||
|
)
|
||||||
|
if !swiftRunResult.terminationStatus.isSuccess {
|
||||||
|
throw GenerateDocumentationError.dependencyInjectionFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Injected swift-docc-plugin dependency.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum GenerateDocumentationError: Error, CustomStringConvertible {
|
||||||
|
case dependencyInjectionFailed
|
||||||
|
case unableToReadPackageDescription
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .dependencyInjectionFailed:
|
||||||
|
"Failed to add swift-docc-plugin dependency to Package.swift."
|
||||||
|
case .unableToReadPackageDescription:
|
||||||
|
"Failed to read the package description."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
65
Sources/TaskCLI/TaskCLI.docc/TaskCLI.md
Normal file
65
Sources/TaskCLI/TaskCLI.docc/TaskCLI.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# ``InotifyTaskCLI``
|
||||||
|
|
||||||
|
The build tool for the Swift Inotify project.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
`TaskCLI` is a small command-line executable (exposed as `task` in `Package.swift`) that automates project-level workflows. Its primary purpose is running integration tests and generating documentation inside Linux Docker containers, so you can validate inotify-dependent code on the correct platform even when developing on macOS.
|
||||||
|
|
||||||
|
Because of a Swift Package Manager Bug in the [package dependency resolution][swiftpm-bug], the executable needs to be run using the `task.sh` shell script.
|
||||||
|
|
||||||
|
[swiftpm-bug]: https://github.com/swiftlang/swift-package-manager/issues/8482
|
||||||
|
|
||||||
|
### Running the Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./task.sh test
|
||||||
|
```
|
||||||
|
|
||||||
|
This launches a `swift:latest` Docker container with the repository mounted at `/code`, then executes two test passes:
|
||||||
|
|
||||||
|
1. All tests **except** `InotifyLimitTests` — the regular integration suite.
|
||||||
|
2. Only `InotifyLimitTests` (with `--skip-build`) — tests that manipulate system-level inotify limits and must run in isolation.
|
||||||
|
|
||||||
|
The container is started with `--security-opt systempaths=unconfined` so that the limit tests can write to `/proc/sys/fs/inotify/*`.
|
||||||
|
|
||||||
|
### Generating Documentation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./task.sh generate-documentation
|
||||||
|
```
|
||||||
|
|
||||||
|
This copies the project to a temporary directory, injects the `swift-docc-plugin` dependency via `swift package add-dependency` (if absent), and runs documentation generation inside a `swift:latest` Docker container. The resulting static sites are written to `./public/inotify/` and `./public/taskcli/`, ready for deployment to GitHub Pages.
|
||||||
|
|
||||||
|
The working tree is never modified — all changes happen in the temporary copy, which is cleaned up automatically.
|
||||||
|
|
||||||
|
### Verbosity
|
||||||
|
|
||||||
|
Pass one or more `-v` flags to increase log output:
|
||||||
|
|
||||||
|
| Flag | Level |
|
||||||
|
|------|-------|
|
||||||
|
| *(none)* | `notice` |
|
||||||
|
| `-v` | `info` |
|
||||||
|
| `-vv` | `debug` |
|
||||||
|
| `-vvv` | `trace` |
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
Docker must be installed and running on the host machine. The container uses the `linux/arm64` platform by default.
|
||||||
|
|
||||||
|
## Topics
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
- ``Command``
|
||||||
|
- ``TestCommand``
|
||||||
|
- ``GenerateDocumentationCommand``
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
- ``GlobalOptions``
|
||||||
|
|
||||||
|
### Errors
|
||||||
|
|
||||||
|
- ``GenerateDocumentationError``
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import ArgumentParser
|
import ArgumentParser
|
||||||
import AsyncAlgorithms
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Subprocess
|
|
||||||
import Noora
|
import Noora
|
||||||
|
import Subprocess
|
||||||
|
|
||||||
struct TestCommand: AsyncParsableCommand {
|
struct TestCommand: AsyncParsableCommand {
|
||||||
static let configuration = CommandConfiguration(
|
static let configuration = CommandConfiguration(
|
||||||
@@ -22,21 +21,21 @@ struct TestCommand: AsyncParsableCommand {
|
|||||||
|
|
||||||
noora.info("Running tests on Linux.")
|
noora.info("Running tests on Linux.")
|
||||||
logger.debug("Current directory", metadata: ["current-directory": "\(currentDirectory)"])
|
logger.debug("Current directory", metadata: ["current-directory": "\(currentDirectory)"])
|
||||||
async let monitorResult = Subprocess.run(
|
let dockerRunResult = try await Subprocess.run(
|
||||||
.name("docker"),
|
.name("docker"),
|
||||||
arguments: ["run", "-v", "\(currentDirectory):/code", "--platform", "linux/arm64", "-w", "/code", "swift:latest", "swift", "test"],
|
arguments: [
|
||||||
preferredBufferSize: 10,
|
"run",
|
||||||
) { execution, standardInput, standardOutput, standardError in
|
"-v", "\(currentDirectory):/code",
|
||||||
print("")
|
"-v", "swift-inotify-build-cache:/code/.build",
|
||||||
let stdout = standardOutput.lines()
|
"--security-opt", "systempaths=unconfined",
|
||||||
let stderr = standardError.lines()
|
"--platform", Docker.getLinuxPlatformStringWithHostArchitecture(),
|
||||||
for try await line in merge(stdout, stderr) {
|
"-w", "/code", "swift:latest",
|
||||||
noora.passthrough("\(line)")
|
"/bin/bash", "-c", "swift test --skip InotifyLimitTests; swift test --skip-build --filter InotifyLimitTests"
|
||||||
}
|
],
|
||||||
print("")
|
output: .standardOutput,
|
||||||
}
|
error: .standardError
|
||||||
|
)
|
||||||
if (try await monitorResult.terminationStatus.isSuccess) {
|
if dockerRunResult.terminationStatus.isSuccess {
|
||||||
noora.success("All tests completed successfully.")
|
noora.success("All tests completed successfully.")
|
||||||
} else {
|
} else {
|
||||||
noora.error("Not all tests completed successfully.")
|
noora.error("Not all tests completed successfully.")
|
||||||
|
|||||||
17
Tests/InotifyIntegrationTests/DirectoryResolverTests.swift
Normal file
17
Tests/InotifyIntegrationTests/DirectoryResolverTests.swift
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Inotify
|
||||||
|
|
||||||
|
@Suite("Directory Resolver")
|
||||||
|
struct DirectoryResolverTests {
|
||||||
|
@Test func listsDirectoryTree() async throws {
|
||||||
|
try await withTempDir { dir in
|
||||||
|
let subDirectory = "\(dir)/Subfolder/Folder 01"
|
||||||
|
try FileManager.default.createDirectory(atPath: subDirectory, withIntermediateDirectories: true)
|
||||||
|
let directories = try await DirectoryResolver.resolve(dir)
|
||||||
|
|
||||||
|
#expect(directories.count == 3)
|
||||||
|
#expect(directories.map { $0.description } == [dir, "\(dir)/Subfolder", subDirectory])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
118
Tests/InotifyIntegrationTests/EventTests.swift
Normal file
118
Tests/InotifyIntegrationTests/EventTests.swift
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Inotify
|
||||||
|
|
||||||
|
@Suite("File Event Detection")
|
||||||
|
struct EventTests {
|
||||||
|
@Test func detectsFileCreation() async throws {
|
||||||
|
try await withTempDir { dir in
|
||||||
|
let filename = "testfile.txt"
|
||||||
|
let events = try await getEventsForTrigger(
|
||||||
|
in: dir,
|
||||||
|
mask: [.create, .closeWrite],
|
||||||
|
) { try createFile(at: "\($0)/\(filename)", contents: "hello") }
|
||||||
|
|
||||||
|
let createEvent = events.first { $0.mask.contains(.create) && $0.path.lastComponent?.string == filename }
|
||||||
|
#expect(createEvent != nil, "Expected CREATE for '\(filename)', got: \(events)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func detectsFileModification() async throws {
|
||||||
|
try await withTempDir { dir in
|
||||||
|
let filepath = "\(dir)/modify-target.txt"
|
||||||
|
try createFile(at: filepath)
|
||||||
|
|
||||||
|
let events = try await getEventsForTrigger(
|
||||||
|
in: dir,
|
||||||
|
mask: .modify,
|
||||||
|
) { _ in try "hello".write(toFile: filepath, atomically: false, encoding: .utf8) }
|
||||||
|
|
||||||
|
let modifyEvent = events.first { $0.mask.contains(.modify) && $0.path.string == filepath }
|
||||||
|
#expect(modifyEvent != nil, "Expected MODIFY for '\(filepath)', got: \(events)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func detectsFileDeletion() async throws {
|
||||||
|
try await withTempDir { dir in
|
||||||
|
let filepath = "\(dir)/delete-me.txt"
|
||||||
|
try createFile(at: filepath)
|
||||||
|
|
||||||
|
let events = try await getEventsForTrigger(
|
||||||
|
in: dir,
|
||||||
|
mask: .delete,
|
||||||
|
) { _ in try FileManager.default.removeItem(atPath: filepath) }
|
||||||
|
|
||||||
|
let deleteEvent = events.first { $0.mask.contains(.delete) && $0.path.string == filepath }
|
||||||
|
#expect(deleteEvent != nil, "Expected DELETE for '\(filepath)', got: \(events)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func detectsSubdirectoryCreationWithIsDirFlag() async throws {
|
||||||
|
try await withTempDir { dir in
|
||||||
|
let folderpath = "\(dir)/subdir-\(UUID())"
|
||||||
|
|
||||||
|
let events = try await getEventsForTrigger(
|
||||||
|
in: dir,
|
||||||
|
mask: .create,
|
||||||
|
) { _ in try FileManager.default.createDirectory(atPath: folderpath, withIntermediateDirectories: false) }
|
||||||
|
|
||||||
|
let createEvent = events.first { $0.mask.contains(.create) && $0.mask.contains(.isDir) && $0.path.string == folderpath }
|
||||||
|
#expect(createEvent != nil, "Expected CREATE for folder '\(folderpath)', got: \(events)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func detectsMoveWithMatchingCookies() async throws {
|
||||||
|
try await withTempDir { dir in
|
||||||
|
let sourceFilePath = "\(dir)/move-src.txt"
|
||||||
|
let destionationFilePath = "\(dir)/move-dst.txt"
|
||||||
|
try createFile(at: sourceFilePath)
|
||||||
|
|
||||||
|
let events = try await getEventsForTrigger(
|
||||||
|
in: dir,
|
||||||
|
mask: .move,
|
||||||
|
) { _ in try FileManager.default.moveItem(atPath: sourceFilePath, toPath: destionationFilePath) }
|
||||||
|
|
||||||
|
let movedFromEvent = events.first { $0.mask.contains(.movedFrom) && $0.path.string == sourceFilePath }
|
||||||
|
#expect(movedFromEvent != nil, "Expected MOVED_FROM for '\(movedFromEvent)', got: \(events)")
|
||||||
|
|
||||||
|
let movedToEvent = events.first { $0.mask.contains(.movedTo) && $0.path.string == destionationFilePath }
|
||||||
|
#expect(movedToEvent != nil, "Expected MOVED_TO for '\(destionationFilePath)', got: \(events)")
|
||||||
|
#expect(movedFromEvent?.cookie == movedToEvent?.cookie)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func eventsArriveInOrder() async throws {
|
||||||
|
try await withTempDir { dir in
|
||||||
|
let filepath = "\(dir)/ordered-test.txt"
|
||||||
|
|
||||||
|
let events = try await getEventsForTrigger(in: dir, mask: [.create, .delete]) { _ in
|
||||||
|
try createFile(at: filepath)
|
||||||
|
try await Task.sleep(for: .milliseconds(50))
|
||||||
|
try FileManager.default.removeItem(atPath: filepath)
|
||||||
|
}
|
||||||
|
|
||||||
|
let createIdx = events.firstIndex { $0.mask.contains(.create) && $0.path.string == filepath }
|
||||||
|
#expect(createIdx != nil)
|
||||||
|
|
||||||
|
let deleteIdx = events.firstIndex { $0.mask.contains(.delete) && $0.path.string == filepath }
|
||||||
|
#expect(deleteIdx != nil)
|
||||||
|
|
||||||
|
if let createIdx, let deleteIdx {
|
||||||
|
#expect(createIdx < deleteIdx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func maskFiltersCorrectly() async throws {
|
||||||
|
try await withTempDir { dir in
|
||||||
|
let filepath = "\(dir)/mask-filter.txt"
|
||||||
|
|
||||||
|
let events = try await getEventsForTrigger(in: dir, mask: .delete) { _ in
|
||||||
|
try createFile(at: filepath)
|
||||||
|
}
|
||||||
|
|
||||||
|
let deleteEvent = events.first { $0.mask.contains(.delete) && $0.path.string == filepath }
|
||||||
|
#expect(deleteEvent == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
43
Tests/InotifyIntegrationTests/InotifyLimitTests.swift
Normal file
43
Tests/InotifyIntegrationTests/InotifyLimitTests.swift
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import Inotify
|
||||||
|
|
||||||
|
@Suite("Inotify Limits", .serialized)
|
||||||
|
struct InotifyLimitTests {
|
||||||
|
@Test func throwsIfInotifyUpperLimitReached() async throws {
|
||||||
|
try await withTempDir { dir in
|
||||||
|
try await withInotifyWatchLimit(of: 10) {
|
||||||
|
try createSubdirectorytree(at: dir, foldersPerLevel: 4, levels: 3)
|
||||||
|
try await Task.sleep(for: .milliseconds(100))
|
||||||
|
|
||||||
|
await #expect(throws: InotifyError.self) {
|
||||||
|
let watcher = try Inotify()
|
||||||
|
try await watcher.addRecursiveWatch(forDirectory: dir, mask: .allEvents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func watchesMassivSubtreesIfAllowed() async throws {
|
||||||
|
try await withTempDir { dir in
|
||||||
|
try await withInotifyWatchLimit(of: 1000) {
|
||||||
|
try createSubdirectorytree(at: dir, foldersPerLevel: 8, levels: 3)
|
||||||
|
let subDirectory = "\(dir)/Folder 8/Folder 8/Folder 8"
|
||||||
|
let filepath = "\(subDirectory)/new-file.txt"
|
||||||
|
try await Task.sleep(for: .milliseconds(100))
|
||||||
|
|
||||||
|
let events = try await getEventsForTrigger(
|
||||||
|
in: dir,
|
||||||
|
mask: [.create],
|
||||||
|
recursive: .recursive
|
||||||
|
) { _ in
|
||||||
|
assert(FileManager.default.fileExists(atPath: subDirectory))
|
||||||
|
try createFile(at: "\(filepath)", contents: "hello")
|
||||||
|
}
|
||||||
|
|
||||||
|
let createEvent = events.first { $0.mask.contains(.create) && $0.path.string == filepath }
|
||||||
|
#expect(createEvent != nil, "Expected CREATE for '\(filepath)', got: \(events)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
61
Tests/InotifyIntegrationTests/RecursiveEventTests.swift
Normal file
61
Tests/InotifyIntegrationTests/RecursiveEventTests.swift
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Inotify
|
||||||
|
|
||||||
|
@Suite("Recursive Event Detection")
|
||||||
|
struct RecursiveEventTests {
|
||||||
|
@Test func detectsFileCreationInSubfolder() async throws {
|
||||||
|
try await withTempDir { dir in
|
||||||
|
let subDirectory = "\(dir)/Subfolder"
|
||||||
|
let filepath = "\(subDirectory)/modify-target.txt"
|
||||||
|
try FileManager.default.createDirectory(atPath: subDirectory, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
let events = try await getEventsForTrigger(
|
||||||
|
in: dir,
|
||||||
|
mask: [.create],
|
||||||
|
recursive: .recursive
|
||||||
|
) { _ in try createFile(at: "\(filepath)", contents: "hello") }
|
||||||
|
|
||||||
|
let createEvent = events.first { $0.mask.contains(.create) && $0.path.string == filepath }
|
||||||
|
#expect(createEvent != nil, "Expected CREATE for '\(filepath)', got: \(events)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func ignoresFileCreationInIgnoredSubfolder() async throws {
|
||||||
|
try await withTempDir { dir in
|
||||||
|
let subDirectory = "\(dir)/Subfolder"
|
||||||
|
let filepath = "\(subDirectory)/modify-target.txt"
|
||||||
|
try FileManager.default.createDirectory(atPath: subDirectory, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
let events = try await getEventsForTrigger(
|
||||||
|
in: dir,
|
||||||
|
mask: [.create],
|
||||||
|
recursive: .recursive,
|
||||||
|
exclude: ["Subfolder"]
|
||||||
|
) { _ in try createFile(at: "\(filepath)", contents: "hello") }
|
||||||
|
|
||||||
|
let createEvent = events.first { $0.mask.contains(.create) && $0.path.string == filepath }
|
||||||
|
#expect(createEvent == nil, "Did not expect CREATE for '\(filepath)', got: \(events)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func newSubfoldersOfRecursiveWatchAreAutomaticallyWatchedToo() async throws {
|
||||||
|
try await withTempDir { dir in
|
||||||
|
let subDirectory = "\(dir)/Subfolder"
|
||||||
|
let filepath = "\(subDirectory)/modify-target.txt"
|
||||||
|
|
||||||
|
let events = try await getEventsForTrigger(
|
||||||
|
in: dir,
|
||||||
|
mask: [.create],
|
||||||
|
recursive: .withAutomaticSubtreeWatching
|
||||||
|
) { _ in
|
||||||
|
try FileManager.default.createDirectory(atPath: subDirectory, withIntermediateDirectories: true)
|
||||||
|
try await Task.sleep(for: .milliseconds(400))
|
||||||
|
try createFile(at: "\(filepath)", contents: "hello")
|
||||||
|
}
|
||||||
|
|
||||||
|
let createEvent = events.first { $0.mask.contains(.create) && $0.path.string == filepath }
|
||||||
|
#expect(createEvent != nil, "Expected CREATE for '\(filepath)', got: \(events)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
Tests/InotifyIntegrationTests/Utilities/createFile.swift
Normal file
3
Tests/InotifyIntegrationTests/Utilities/createFile.swift
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
func createFile(at path: String, contents: String = "") throws {
|
||||||
|
try contents.write(toFile: path, atomically: false, encoding: .utf8)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import Foundation
|
||||||
|
import SystemPackage
|
||||||
|
|
||||||
|
func createSubdirectorytree(at dir: String, foldersPerLevel: Int, levels: Int) throws {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
|
||||||
|
for path in SubfolderTreeIterator(basePath: dir, foldersPerLevel: foldersPerLevel, levels: levels) {
|
||||||
|
try fileManager.createDirectory(
|
||||||
|
at: path,
|
||||||
|
withIntermediateDirectories: true,
|
||||||
|
attributes: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SubfolderTreeIterator: IteratorProtocol, Sequence {
|
||||||
|
let basePath: URL
|
||||||
|
let foldersPerLevel: Int
|
||||||
|
let levels: Int
|
||||||
|
private var indices: [Int]
|
||||||
|
private var done = false
|
||||||
|
|
||||||
|
init(basePath: String, foldersPerLevel: Int, levels: Int) {
|
||||||
|
self.basePath = URL(filePath: basePath)
|
||||||
|
self.foldersPerLevel = foldersPerLevel
|
||||||
|
self.levels = levels
|
||||||
|
self.indices = Array(repeating: 1, count: levels)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func next() -> URL? {
|
||||||
|
guard !done else { return nil }
|
||||||
|
|
||||||
|
let path = indices.reduce(basePath) { partialPath, index in
|
||||||
|
partialPath.appending(path: "Folder \(index)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance indices (odometer-style, rightmost increments first)
|
||||||
|
var carry = true
|
||||||
|
for i in (0..<levels).reversed() {
|
||||||
|
if carry {
|
||||||
|
indices[i] += 1
|
||||||
|
if indices[i] > foldersPerLevel {
|
||||||
|
indices[i] = 1
|
||||||
|
} else {
|
||||||
|
carry = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if carry { done = true }
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import Inotify
|
||||||
|
|
||||||
|
enum RecursivKind {
|
||||||
|
case nonrecursive
|
||||||
|
case recursive
|
||||||
|
case withAutomaticSubtreeWatching
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEventsForTrigger(
|
||||||
|
in dir: String,
|
||||||
|
mask: InotifyEventMask,
|
||||||
|
recursive: RecursivKind = .nonrecursive,
|
||||||
|
exclude: [String] = [],
|
||||||
|
trigger: @escaping (String) async throws -> Void,
|
||||||
|
) async throws -> [InotifyEvent] {
|
||||||
|
let watcher = try Inotify()
|
||||||
|
await watcher.exclude(names: exclude)
|
||||||
|
switch recursive {
|
||||||
|
case .nonrecursive:
|
||||||
|
try await watcher.addWatch(path: dir, mask: mask)
|
||||||
|
case .recursive:
|
||||||
|
try await watcher.addRecursiveWatch(forDirectory: dir, mask: mask)
|
||||||
|
case .withAutomaticSubtreeWatching:
|
||||||
|
try await watcher.addWatchWithAutomaticSubtreeWatching(forDirectory: dir, mask: mask)
|
||||||
|
}
|
||||||
|
|
||||||
|
let eventTask = Task {
|
||||||
|
var events: [InotifyEvent] = []
|
||||||
|
for await event in await watcher.events {
|
||||||
|
events.append(event)
|
||||||
|
}
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
|
||||||
|
try await Task.sleep(for: .milliseconds(100))
|
||||||
|
try await trigger(dir)
|
||||||
|
try await Task.sleep(for: .milliseconds(500))
|
||||||
|
|
||||||
|
eventTask.cancel()
|
||||||
|
return await eventTask.value
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
func withInotifyWatchLimit(of limit: Int, _ body: () async throws -> Void) async throws {
|
||||||
|
let confPath = URL(filePath: "/proc/sys/fs/inotify")
|
||||||
|
let filenames = ["max_user_watches", "max_user_instances", "max_queued_events"]
|
||||||
|
var previousLimits: [String: String] = [:]
|
||||||
|
|
||||||
|
for filename in filenames {
|
||||||
|
let filePath = confPath.appending(path: filename)
|
||||||
|
let currentLimit = try String(contentsOf: filePath, encoding: .utf8)
|
||||||
|
previousLimits[filename] = currentLimit
|
||||||
|
try "\(limit)".write(to: filePath, atomically: false, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
try await body()
|
||||||
|
|
||||||
|
for filename in filenames {
|
||||||
|
let filePath = confPath.appending(path: filename)
|
||||||
|
guard let previousLimit = previousLimits[filename] else { continue }
|
||||||
|
try previousLimit.write(to: filePath, atomically: false, encoding: .utf8)
|
||||||
|
}
|
||||||
|
}
|
||||||
58
task.sh
Executable file
58
task.sh
Executable file
@@ -0,0 +1,58 @@
|
|||||||
|
#!/usr/bin/env zsh
|
||||||
|
|
||||||
|
# task - Run the package's TaskCLI target via a transient "task" product.
|
||||||
|
#
|
||||||
|
# Works around https://github.com/swiftlang/swift-package-manager/issues/8482
|
||||||
|
# by temporarily adding an executable product named "task" to Package.swift,
|
||||||
|
# running it with `swift run`, and restoring the original manifest afterwards.
|
||||||
|
#
|
||||||
|
# Usage: task [arguments...]
|
||||||
|
#
|
||||||
|
# The script auto-detects the package name from Package.swift and expects an
|
||||||
|
# executable target named "<PackageName>TaskCLI" to exist.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# --- Resolve the package root (search upward for Package.swift) -----------
|
||||||
|
|
||||||
|
package_root="${PWD}"
|
||||||
|
while [[ ! -f "${package_root}/Package.swift" ]]; do
|
||||||
|
package_root="${package_root:h}" # zsh dirname
|
||||||
|
if [[ "${package_root}" == "/" ]]; then
|
||||||
|
echo "error: Could not find Package.swift in any parent directory." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
manifest="${package_root}/Package.swift"
|
||||||
|
backup="${manifest}.task-backup"
|
||||||
|
|
||||||
|
# --- Extract the package name ---------------------------------------------
|
||||||
|
|
||||||
|
package_name=$(sed -n 's/^.*name:[[:space:]]*"\([^"]*\)".*/\1/p' "${manifest}" | head -1)
|
||||||
|
if [[ -z "${package_name}" ]]; then
|
||||||
|
echo "error: Could not determine package name from ${manifest}." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
target_name="${package_name}TaskCLI"
|
||||||
|
|
||||||
|
# --- Cleanup trap (runs on EXIT — covers success, failure, signals) -------
|
||||||
|
|
||||||
|
function cleanup {
|
||||||
|
if [[ -f "${backup}" ]]; then
|
||||||
|
mv -f "${backup}" "${manifest}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
# --- Inject the transient "task" product ----------------------------------
|
||||||
|
|
||||||
|
cp -f "${manifest}" "${backup}"
|
||||||
|
|
||||||
|
swift package --package-path "${package_root}" \
|
||||||
|
add-product task --type executable --targets "${target_name}"
|
||||||
|
|
||||||
|
# --- Run it (forward all script arguments) --------------------------------
|
||||||
|
|
||||||
|
swift run --package-path "${package_root}" task "$@"
|
||||||
Reference in New Issue
Block a user