diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..4cc4220
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+*.o
+catgirl
+config.mk
+tags
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..f288702
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,674 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ Copyright (C)
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..b1ffede
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,41 @@
+PREFIX = /usr/local
+MANDIR = ${PREFIX}/share/man
+
+CFLAGS += -std=c11 -Wall -Wextra -Wpedantic
+LDLIBS = -lcrypto -ltls -lncursesw
+
+-include config.mk
+
+OBJS += chat.o
+OBJS += command.o
+OBJS += complete.o
+OBJS += config.o
+OBJS += edit.o
+OBJS += handle.o
+OBJS += irc.o
+OBJS += ui.o
+OBJS += url.o
+OBJS += xdg.o
+
+dev: tags all
+
+all: catgirl
+
+catgirl: ${OBJS}
+ ${CC} ${LDFLAGS} ${OBJS} ${LDLIBS} -o $@
+
+${OBJS}: chat.h
+
+tags: *.h *.c
+ ctags -w *.h *.c
+
+clean:
+ rm -f tags catgirl ${OBJS}
+
+install: catgirl catgirl.1
+ install -d ${PREFIX}/bin ${MANDIR}/man1
+ install catgirl ${PREFIX}/bin
+ gzip -c catgirl.1 > ${MANDIR}/man1/catgirl.1.gz
+
+uninstall:
+ rm -f ${PREFIX}/bin/catgirl ${MANDIR}/man1/catgirl.1.gz
diff --git a/README.7 b/README.7
new file mode 100644
index 0000000..1478722
--- /dev/null
+++ b/README.7
@@ -0,0 +1,126 @@
+.Dd February 11, 2020
+.Dt README 7
+.Os "Causal Agency"
+.
+.Sh NAME
+.Nm catgirl
+.Nd IRC client
+.
+.Sh DESCRIPTION
+.Xr catgirl 1
+is a TLS-only terminal IRC client.
+.
+.Ss Notable Features
+.Bl -bullet
+.It
+Tab complete:
+most recently seen or mentioned nicks
+are completed first.
+Commas are inserted between multple nicks.
+.It
+Indicators:
+the prompt clearly shows whether input
+will be interpreted as a command
+or sent as a message.
+An indicator appears when scrolled up
+in the chat history.
+.It
+Nick coloring:
+color generation based on usernames
+remains stable across nick changes.
+Mentions of users in messages are colored.
+.It
+URL detection:
+recent URLs from a particular user
+or matching a substring
+can be opened or copied.
+.It
+History:
+window contents can be saved
+and restored on startup.
+.El
+.
+.Ss Non-features
+.Bl -bullet
+.It
+Dynamic configuration:
+all configuration happens
+in a simple text file
+or on the command line.
+.It
+Multi-network:
+a terminal multiplexer such as
+.Xr screen 1
+or
+.Xr tmux 1
+(or just your regular terminal emulator tabs)
+can be used to connect
+.Nm
+to multiple networks.
+.It
+Reconnection:
+when the connection to the server is lost,
+.Nm
+exits.
+It can be run in a loop
+or connected to a bouncer,
+such as
+.Lk https://git.causal.agency/pounce "pounce" .
+.It
+Cleartext IRC:
+TLS is now ubiquitous
+and certificates are easy to obtain.
+.El
+.
+.Sh INSTALLING
+.Nm
+requires LibreSSL
+.Pq Fl ltls
+and ncurses
+.Pq Fl lncursesw .
+It primarily targets
+.Fx
+and macOS,
+as well as Linux.
+.Bd -literal -offset indent
+\&./configure
+make all
+sudo make install PREFIX=/usr/local
+.Ed
+.
+.Sh FILES
+.Bl -tag -width "complete.c" -compact
+.It Pa chat.h
+global state and declarations
+.It Pa chat.c
+startup and event loop
+.It Pa irc.c
+IRC connection and parsing
+.It Pa ui.c
+curses interface
+.It Pa handle.c
+IRC message handling
+.It Pa command.c
+input command handling
+.It Pa edit.c
+line editing
+.It Pa complete.c
+tab complete
+.It Pa url.c
+URL detection
+.It Pa config.c
+configuration parsing
+.It Pa xdg.c
+XDG base directories
+.El
+.
+.Sh CONTRIBUTING
+The upstream URL of this project is
+.Aq Lk https://git.causal.agency/catgirl .
+I'm happy to receive contributions in any form at
+.Aq Mt june@causal.agency .
+For sending patches by email, see
+.Aq Lk https://git-send-email.io .
+.
+.Sh SEE ALSO
+.Xr catgirl 1
diff --git a/catgirl.1 b/catgirl.1
new file mode 100644
index 0000000..7c51b08
--- /dev/null
+++ b/catgirl.1
@@ -0,0 +1,497 @@
+.Dd February 10, 2020
+.Dt CATGIRL 1
+.Os
+.
+.Sh NAME
+.Nm catgirl
+.Nd IRC client
+.
+.Sh SYNOPSIS
+.Nm
+.Op Fl ev
+.Op Fl C Ar copy
+.Op Fl H Ar hash
+.Op Fl O Ar open
+.Op Fl a Ar auth
+.Op Fl c Ar cert
+.Op Fl h Ar host
+.Op Fl j Ar join
+.Op Fl k Ar priv
+.Op Fl n Ar nick
+.Op Fl p Ar port
+.Op Fl r Ar real
+.Op Fl s Ar save
+.Op Fl u Ar user
+.Op Fl w Ar pass
+.Op Ar config ...
+.
+.Sh DESCRIPTION
+The
+.Nm
+program is a TLS-only
+curses IRC client.
+.
+.Pp
+Options can be loaded from files
+listed on the command line.
+Files are searched for in
+.Pa $XDG_CONFIG_DIRS/catgirl
+unless the path starts with
+.Ql /
+or
+.Ql \&. .
+Each option is placed on a line,
+and lines beginning with
+.Ql #
+are ignored.
+The options are listed below
+following their corresponding flags.
+.
+.Pp
+The arguments are as follows:
+.Bl -tag -width Ds
+.It Fl C Ar util , Cm copy = Ar util
+Set the utility used by
+.Ic /copy .
+The default is the first available of
+.Xr pbcopy 1 ,
+.Xr wl-copy 1 ,
+.Xr xclip 1 ,
+.Xr xsel 1 .
+.
+.It Fl H Ar hash , Cm hash = Ar hash
+Set the initial value of
+the nick color hash function.
+.
+.It Fl O Ar util , Cm open = Ar util
+Set the utility used by
+.Ic /open .
+The default is the first available of
+.Xr open 1 ,
+.Xr xdg-open 1 .
+.
+.It Fl a Ar user Ns : Ns Ar pass , Cm sasl-plain = Ar user Ns : Ns Ar pass
+Authenticate as
+.Ar user
+with
+.Ar pass
+using SASL PLAIN.
+Since this requires the account password
+in plain text,
+it is recommended to use SASL EXTERNAL instead with
+.Fl e .
+.
+.It Fl c Ar path , Cm cert = Ar path
+Load the TLS client certificate from
+.Ar path .
+If the private key is in a separate file,
+it is loaded with
+.Fl k .
+With
+.Fl e ,
+authenticate using SASL EXTERNAL.
+.
+.It Fl e , Cm sasl-external
+Authenticate using SASL EXTERNAL,
+also known as CertFP.
+The TLS client certificate is loaded with
+.Fl c .
+.
+.It Fl h Ar host , Cm host = Ar host
+Connect to
+.Ar host .
+.
+.It Fl j Ar join , Cm join = Ar join
+Join the comma-separated list of channels
+.Ar join .
+.
+.It Fl k Ar path , Cm priv = Ar priv
+Load the TLS client private key from
+.Ar path .
+.
+.It Fl n Ar nick , Cm nick = Ar nick
+Set nickname to
+.Ar nick .
+The default nickname is the user's name.
+.
+.It Fl p Ar port , Cm port = Ar port
+Connect to
+.Ar port .
+The default port is 6697.
+.
+.It Fl r Ar real , Cm real = Ar real
+Set realname to
+.Ar real .
+The default realname is the same as the nickname.
+.
+.It Fl s Ar name , Cm save = Ar name
+Load and save the contents of windows from
+.Ar name
+in
+.Pa $XDG_DATA_DIRS/catgirl ,
+or an absolute or relative path if
+.Ar name
+starts with
+.Ql /
+or
+.Ql \&. .
+.
+.It Fl u Ar user , Cm user = Ar user
+Set username to
+.Ar user .
+The default username is the same as the nickname.
+.
+.It Fl v , Cm debug
+Log raw IRC messages to the
+.Sy
+window
+as well as standard error
+if it is not a terminal.
+.
+.It Fl w Ar pass , Cm pass = Ar pass
+Log in with the server password
+.Ar pass .
+.El
+.
+.Sh COMMANDS
+Any unique prefix can be used to abbreviate a command.
+For example,
+.Ic /join
+can be typed
+.Ic /j .
+.
+.Ss Chat Commands
+.Bl -tag -width Ds
+.It Ic /join Ar channel
+Join a channel.
+.It Ic /me Op Ar action
+Send an action message.
+.It Ic /msg Ar nick message
+Send a private message.
+.It Ic /names
+List users in the channel.
+.It Ic /nick Ar nick
+Change nicknames.
+.It Ic /notice Ar message
+Send a notice.
+.It Ic /part Op Ar message
+Leave the channel.
+.It Ic /query Ar nick
+Start a private conversation.
+.It Ic /quit Op Ar message
+Quit IRC.
+.It Ic /quote Ar command
+Send a raw IRC command.
+.It Ic /topic Op Ar topic
+Show or set the topic of the channel.
+.It Ic /whois Ar nick
+Query information about a user.
+.El
+.
+.Ss UI Commands
+.Bl -tag -width Ds
+.It Ic /close Op Ar name | num
+Close the named, numbered or current window.
+.It Ic /copy Op Ar nick | substring
+Copy the most recent URL from
+.Ar nick
+or matching
+.Ar substring .
+.It Ic /debug
+Toggle logging in the
+.Sy
+window.
+.It Ic /help Op Ar search
+View this manual.
+Type
+.Ic q
+to return to
+.Nm .
+.It Ic /open Op Ar count
+Open each of
+.Ar count
+most recent URLs.
+.It Ic /open Ar nick | substring
+Open the most recent URL from
+.Ar nick
+or matching
+.Ar substring .
+.It Ic /window Ar name
+Switch to window by name.
+.It Ic /window Ar num , Ic / Ns Ar num
+Switch to window by number.
+.El
+.
+.Sh KEY BINDINGS
+The
+.Nm
+interface provides
+.Xr emacs 1 Ns -like
+line editing
+as well as keys for IRC formatting.
+The prefixes
+.Ic C-
+and
+.Ic M-
+represent the control and meta (alt)
+modifiers, respectively.
+.
+.Ss Line Editing
+.Bl -tag -width Ds -compact
+.It Ic C-a
+Move to beginning of line.
+.It Ic C-b
+Move left.
+.It Ic C-d
+Delete next character.
+.It Ic C-e
+Move to end of line.
+.It Ic C-f
+Move right.
+.It Ic C-k
+Delete to end of line.
+.It Ic C-u
+Delete to beginning of line.
+.It Ic C-w
+Delete previous word.
+.It Ic C-y
+Paste previously deleted text.
+.It Ic M-b
+Move to previous word.
+.It Ic M-d
+Delete next word.
+.It Ic M-f
+Move to next word.
+.It Ic Tab
+Complete nick, channel or command.
+.El
+.
+.Ss Window Keys
+.Bl -tag -width Ds -compact
+.It Ic C-l
+Redraw the UI.
+.It Ic C-n
+Switch to next window.
+.It Ic C-o
+Switch to previously selected window.
+.It Ic C-p
+Switch to previous window.
+.It Ic M-/
+Switch to previously selected window.
+.It Ic M-a
+Cycle through unread windows.
+.It Ic M-l
+List the contents of the window
+without word-wrapping.
+Press
+.Ic Enter
+to return to
+.Nm .
+.It Ic M-m
+Insert a blank line in the window.
+.It Ic M- Ns Ar n
+Switch to window by number 0\(en9.
+.It Ic M-u
+Scroll to first unread line.
+.El
+.
+.Ss IRC Formatting
+.Bl -tag -width Ds -compact
+.It Ic C-z b
+Toggle bold.
+.It Ic C-z c
+Set or reset color.
+.It Ic C-z i
+Toggle italics.
+.It Ic C-z o
+Reset formatting.
+.It Ic C-z r
+Toggle reverse color.
+.It Ic C-z u
+Toggle underline.
+.El
+.
+.Pp
+To set colors, follow
+.Ic C-z c
+by one or two digits for the foreground color,
+optionally followed by a comma
+and one or two digits for the background color.
+To reset color, follow
+.Ic C-z c
+by a non-digit.
+.
+.Pp
+The color numbers are as follows:
+.Pp
+.Bl -column "99" "orange (dark yellow)" "15" "pink (light magenta)"
+.It \ 0 Ta white Ta \ 8 Ta yellow
+.It \ 1 Ta black Ta \ 9 Ta light green
+.It \ 2 Ta blue Ta 10 Ta cyan
+.It \ 3 Ta green Ta 11 Ta light cyan
+.It \ 4 Ta red Ta 12 Ta light blue
+.It \ 5 Ta brown (dark red) Ta 13 Ta pink (light magenta)
+.It \ 6 Ta magenta Ta 14 Ta gray
+.It \ 7 Ta orange (dark yellow) Ta 15 Ta light gray
+.It 99 Ta default
+.El
+.
+.Sh FILES
+.Bl -tag -width Ds
+.It Pa $XDG_CONFIG_DIRS/catgirl
+Configuration files are searched for first in
+.Ev $XDG_CONFIG_HOME ,
+usually
+.Pa ~/.config ,
+followed by the colon-separated list of paths
+.Ev $XDG_CONFIG_DIRS ,
+usually
+.Pa /etc/xdg .
+.It Pa ~/.config/catgirl
+The most likely location of configuration files.
+.
+.It Pa $XDG_DATA_DIRS/catgirl
+Save files are searched for first in
+.Ev $XDG_DATA_HOME ,
+usually
+.Pa ~/.local/share ,
+followed by the colon-separated list of paths
+.Ev $XDG_DATA_DIRS ,
+usually
+.Pa /usr/local/share:/usr/share .
+.It Pa ~/.local/share/catgirl
+The most likely location of save files.
+.El
+.
+.Sh EXAMPLES
+Command line:
+.Bd -literal -offset indent
+catgirl -h chat.freenode.net -j '#ascii.town'
+.Ed
+.Pp
+Configuration file:
+.Bd -literal -offset indent
+host = chat.freenode.net
+join = #ascii.town
+.Ed
+.
+.Sh STANDARDS
+.Bl -item
+.It
+.Rs
+.%A Kiyoshi Aman
+.%T IRCv3.1 extended-join Extension
+.%I IRCv3 Working Group
+.%U https://ircv3.net/specs/extensions/extended-join-3.1
+.Re
+.
+.It
+.Rs
+.%A Waldo Bastian
+.%A Ryan Lortie
+.%A Lennart Poettering
+.%T XDG Base Directory Specification
+.%D November 24, 2010
+.%U https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
+.Re
+.
+.It
+.Rs
+.%A Kyle Fuller
+.%A St\('ephan Kochen
+.%A Alexey Sokolov
+.%A James Wheare
+.%T IRCv3.2 server-time Extension
+.%I IRCv3 Working Group
+.%U https://ircv3.net/specs/extensions/server-time-3.2
+.Re
+.
+.It
+.Rs
+.%A Lee Hardy
+.%A Perry Lorier
+.%A Kevin L. Mitchell
+.%A William Pitcock
+.%T IRCv3.1 Client Capability Negotiation
+.%I IRCv3 Working Group
+.%U https://ircv3.net/specs/core/capability-negotiation-3.1.html
+.Re
+.
+.It
+.Rs
+.%A S. Josefsson
+.%T The Base16, Base32, and Base64 Data Encodings
+.%I IETF
+.%N RFC 4648
+.%D October 2006
+.%U https://tools.ietf.org/html/rfc4648
+.Re
+.
+.It
+.Rs
+.%A C. Kalt
+.%T Internet Relay Chat: Client Protocol
+.%I IETF
+.%N RFC 2812
+.%D April 2000
+.%U https://tools.ietf.org/html/rfc2812
+.Re
+.
+.It
+.Rs
+.%A Mantas Mikul\[u0117]nas
+.%T IRCv3.2 userhost-in-names Extension
+.%I IRCv3 Working Group
+.%U https://ircv3.net/specs/extensions/userhost-in-names-3.2
+.Re
+.
+.It
+.Rs
+.%A Daniel Oaks
+.%T IRC Formatting
+.%I ircdocs
+.%U https://modern.ircdocs.horse/formatting.html
+.Re
+.
+.It
+.Rs
+.%A William Pitcock
+.%A Jilles Tjoelker
+.%T IRCv3.1 SASL Authentication
+.%I IRCv3 Working Group
+.%U https://ircv3.net/specs/extensions/sasl-3.1.html
+.Re
+.
+.It
+.Rs
+.%A Alexey Sokolov
+.%A St\('ephan Kochen
+.%A Kyle Fuller
+.%A Kiyoshi Aman
+.%A James Wheare
+.%T IRCv3 Message Tags
+.%I IRCv3 Working Group
+.%U https://ircv3.net/specs/extensions/message-tags
+.Re
+.
+.It
+.Rs
+.%A K. Zeilenga, Ed.
+.%T The PLAIN Simple Authentication and Security Layer (SASL) Mechanism
+.%I IETF
+.%N RFC 4616
+.%D August 2006
+.%U https://tools.ietf.org/html/rfc4616
+.Re
+.El
+.
+.Sh AUTHORS
+.An June Bug Aq Mt june@causal.agency
+.
+.Sh BUGS
+Send mail to
+.Aq Mt june@causal.agency
+or join
+.Li #ascii.town
+on
+.Li chat.freenode.net .
diff --git a/chat.c b/chat.c
new file mode 100644
index 0000000..f854a33
--- /dev/null
+++ b/chat.c
@@ -0,0 +1,259 @@
+/* Copyright (C) 2020 C. McEnroe
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "chat.h"
+
+char *idNames[IDCap] = {
+ [None] = "",
+ [Debug] = "",
+ [Network] = "",
+};
+
+enum Color idColors[IDCap] = {
+ [None] = Black,
+ [Debug] = Green,
+ [Network] = Gray,
+};
+
+size_t idNext = Network + 1;
+
+struct Self self = { .color = Default };
+
+static const char *save;
+static void exitSave(void) {
+ int error = uiSave(save);
+ if (error) {
+ warn("%s", save);
+ _exit(EX_IOERR);
+ }
+}
+
+uint32_t hashInit;
+
+int procPipe[2] = { -1, -1 };
+
+static void pipeRead(void) {
+ char buf[1024];
+ ssize_t len = read(procPipe[0], buf, sizeof(buf) - 1);
+ if (len < 0) err(EX_IOERR, "read");
+ if (!len) return;
+ buf[len - 1] = '\0';
+ char *ptr = buf;
+ while (ptr) {
+ char *line = strsep(&ptr, "\n");
+ uiFormat(Network, Warm, NULL, "%s", line);
+ }
+}
+
+static volatile sig_atomic_t signals[NSIG];
+static void signalHandler(int signal) {
+ signals[signal] = 1;
+}
+
+int main(int argc, char *argv[]) {
+ setlocale(LC_CTYPE, "");
+
+ bool insecure = false;
+ const char *host = NULL;
+ const char *port = "6697";
+ const char *cert = NULL;
+ const char *priv = NULL;
+
+ bool sasl = false;
+ const char *pass = NULL;
+ const char *nick = NULL;
+ const char *user = NULL;
+ const char *real = NULL;
+
+ const char *Opts = "!C:H:O:a:c:eh:j:k:n:p:r:s:u:vw:";
+ const struct option LongOpts[] = {
+ { "insecure", no_argument, NULL, '!' },
+ { "copy", required_argument, NULL, 'C' },
+ { "hash", required_argument, NULL, 'H' },
+ { "open", required_argument, NULL, 'O' },
+ { "sasl-plain", required_argument, NULL, 'a' },
+ { "cert", required_argument, NULL, 'c' },
+ { "sasl-external", no_argument, NULL, 'e' },
+ { "host", required_argument, NULL, 'h' },
+ { "join", required_argument, NULL, 'j' },
+ { "priv", required_argument, NULL, 'k' },
+ { "nick", required_argument, NULL, 'n' },
+ { "port", required_argument, NULL, 'p' },
+ { "real", required_argument, NULL, 'r' },
+ { "save", required_argument, NULL, 's' },
+ { "user", required_argument, NULL, 'u' },
+ { "debug", no_argument, NULL, 'v' },
+ { "pass", required_argument, NULL, 'w' },
+ {0},
+ };
+
+ int opt;
+ while (0 < (opt = getopt_config(argc, argv, Opts, LongOpts, NULL))) {
+ switch (opt) {
+ break; case '!': insecure = true;
+ break; case 'C': urlCopyUtil = optarg;
+ break; case 'H': hashInit = strtoul(optarg, NULL, 0);
+ break; case 'O': urlOpenUtil = optarg;
+ break; case 'a': sasl = true; self.plain = optarg;
+ break; case 'c': cert = optarg;
+ break; case 'e': sasl = true;
+ break; case 'h': host = optarg;
+ break; case 'j': self.join = optarg;
+ break; case 'k': priv = optarg;
+ break; case 'n': nick = optarg;
+ break; case 'p': port = optarg;
+ break; case 'r': real = optarg;
+ break; case 's': save = optarg;
+ break; case 'u': user = optarg;
+ break; case 'v': self.debug = true;
+ break; case 'w': pass = optarg;
+ break; default: return EX_USAGE;
+ }
+ }
+ if (!host) errx(EX_USAGE, "host required");
+
+ if (!nick) nick = getenv("USER");
+ if (!nick) errx(EX_CONFIG, "USER unset");
+ if (!user) user = nick;
+ if (!real) real = nick;
+
+ set(&self.network, host);
+ set(&self.chanTypes, "#&");
+ set(&self.prefixes, "@+");
+ commandComplete();
+
+ FILE *certFile = NULL;
+ FILE *privFile = NULL;
+ if (cert) {
+ certFile = configOpen(cert, "r");
+ if (!certFile) return EX_NOINPUT;
+ }
+ if (priv) {
+ privFile = configOpen(priv, "r");
+ if (!privFile) return EX_NOINPUT;
+ }
+ ircConfig(insecure, certFile, privFile);
+ if (certFile) fclose(certFile);
+ if (privFile) fclose(privFile);
+
+ uiInit();
+ if (save) {
+ uiLoad(save);
+ atexit(exitSave);
+ }
+ uiShowID(Network);
+ uiFormat(Network, Cold, NULL, "Traveling...");
+ uiDraw();
+
+ int irc = ircConnect(host, port);
+ if (pass) ircFormat("PASS :%s\r\n", pass);
+ if (sasl) ircFormat("CAP REQ :sasl\r\n");
+ ircFormat("CAP LS\r\n");
+ ircFormat("NICK :%s\r\n", nick);
+ ircFormat("USER %s 0 * :%s\r\n", user, real);
+
+ signal(SIGHUP, signalHandler);
+ signal(SIGINT, signalHandler);
+ signal(SIGTERM, signalHandler);
+ signal(SIGCHLD, signalHandler);
+ sig_t cursesWinch = signal(SIGWINCH, signalHandler);
+
+ int error = pipe(procPipe);
+ if (error) err(EX_OSERR, "pipe");
+
+ fcntl(irc, F_SETFD, FD_CLOEXEC);
+ fcntl(procPipe[0], F_SETFD, FD_CLOEXEC);
+ fcntl(procPipe[1], F_SETFD, FD_CLOEXEC);
+
+ struct pollfd fds[3] = {
+ { .events = POLLIN, .fd = STDIN_FILENO },
+ { .events = POLLIN, .fd = irc },
+ { .events = POLLIN, .fd = procPipe[0] },
+ };
+ while (!self.quit) {
+ int nfds = poll(fds, ARRAY_LEN(fds), -1);
+ if (nfds < 0 && errno != EINTR) err(EX_IOERR, "poll");
+ if (nfds > 0) {
+ if (fds[0].revents) uiRead();
+ if (fds[1].revents) ircRecv();
+ if (fds[2].revents) pipeRead();
+ }
+
+ if (signals[SIGHUP]) self.quit = "zzz";
+ if (signals[SIGINT] || signals[SIGTERM]) break;
+
+ if (signals[SIGCHLD]) {
+ signals[SIGCHLD] = 0;
+ int status;
+ while (0 < waitpid(-1, &status, WNOHANG)) {
+ if (WIFEXITED(status) && WEXITSTATUS(status)) {
+ uiFormat(
+ Network, Warm, NULL,
+ "Process exits with status %d", WEXITSTATUS(status)
+ );
+ } else if (WIFSIGNALED(status)) {
+ uiFormat(
+ Network, Warm, NULL,
+ "Process terminates from %s",
+ strsignal(WTERMSIG(status))
+ );
+ }
+ }
+ uiShow();
+ }
+
+ if (signals[SIGWINCH]) {
+ signals[SIGWINCH] = 0;
+ cursesWinch(SIGWINCH);
+ // XXX: For some reason, calling uiDraw() here is the only way to
+ // get uiRead() to properly receive KEY_RESIZE.
+ uiDraw();
+ uiRead();
+ }
+
+ uiDraw();
+ }
+
+ if (self.quit) {
+ ircFormat("QUIT :%s\r\n", self.quit);
+ } else {
+ ircFormat("QUIT\r\n");
+ }
+ struct Message msg = {
+ .nick = self.nick,
+ .user = self.user,
+ .cmd = "QUIT",
+ .params[0] = self.quit,
+ };
+ handle(msg);
+
+ uiHide();
+}
diff --git a/chat.h b/chat.h
new file mode 100644
index 0000000..f47b244
--- /dev/null
+++ b/chat.h
@@ -0,0 +1,241 @@
+/* Copyright (C) 2020 C. McEnroe
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#define ARRAY_LEN(a) (sizeof(a) / sizeof(a[0]))
+#define BIT(x) x##Bit, x = 1 << x##Bit, x##Bit_ = x##Bit
+
+#define XDG_SUBDIR "catgirl"
+
+typedef unsigned char byte;
+
+int procPipe[2];
+
+enum Color {
+ White, Black, Blue, Green, Red, Brown, Magenta, Orange,
+ Yellow, LightGreen, Cyan, LightCyan, LightBlue, Pink, Gray, LightGray,
+ Default = 99,
+};
+
+enum { None, Debug, Network, IDCap = 256 };
+extern char *idNames[IDCap];
+extern enum Color idColors[IDCap];
+extern size_t idNext;
+
+static inline size_t idFind(const char *name) {
+ for (size_t id = 0; id < idNext; ++id) {
+ if (!strcmp(idNames[id], name)) return id;
+ }
+ return None;
+}
+
+static inline size_t idFor(const char *name) {
+ size_t id = idFind(name);
+ if (id) return id;
+ if (idNext == IDCap) return Network;
+ idNames[idNext] = strdup(name);
+ if (!idNames[idNext]) err(EX_OSERR, "strdup");
+ idColors[idNext] = Default;
+ return idNext++;
+}
+
+#define ENUM_CAP \
+ X("extended-join", CapExtendedJoin) \
+ X("sasl", CapSASL) \
+ X("server-time", CapServerTime) \
+ X("userhost-in-names", CapUserhostInNames)
+
+enum Cap {
+#define X(name, id) BIT(id),
+ ENUM_CAP
+#undef X
+};
+
+extern struct Self {
+ bool debug;
+ char *plain;
+ const char *join;
+ enum Cap caps;
+ char *network;
+ char *chanTypes;
+ char *prefixes;
+ char *nick;
+ char *user;
+ enum Color color;
+ char *quit;
+} self;
+
+static inline void set(char **field, const char *value) {
+ free(*field);
+ *field = strdup(value);
+ if (!*field) err(EX_OSERR, "strdup");
+}
+
+#define ENUM_TAG \
+ X("time", TagTime)
+
+enum Tag {
+#define X(name, id) id,
+ ENUM_TAG
+#undef X
+ TagCap,
+};
+
+enum { ParamCap = 15 };
+struct Message {
+ char *tags[TagCap];
+ char *nick;
+ char *user;
+ char *host;
+ char *cmd;
+ char *params[ParamCap];
+};
+
+void ircConfig(bool insecure, FILE *cert, FILE *priv);
+int ircConnect(const char *host, const char *port);
+void ircRecv(void);
+void ircSend(const char *ptr, size_t len);
+void ircFormat(const char *format, ...)
+ __attribute__((format(printf, 1, 2)));
+
+extern struct Replies {
+ size_t join;
+ size_t topic;
+ size_t names;
+ size_t whois;
+} replies;
+
+void handle(struct Message msg);
+void command(size_t id, char *input);
+const char *commandIsPrivmsg(size_t id, const char *input);
+const char *commandIsNotice(size_t id, const char *input);
+const char *commandIsAction(size_t id, const char *input);
+void commandComplete(void);
+
+enum Heat { Cold, Warm, Hot };
+void uiInit(void);
+void uiShow(void);
+void uiHide(void);
+void uiDraw(void);
+void uiShowID(size_t id);
+void uiShowNum(size_t num);
+void uiCloseID(size_t id);
+void uiCloseNum(size_t id);
+void uiRead(void);
+void uiWrite(size_t id, enum Heat heat, const time_t *time, const char *str);
+void uiFormat(
+ size_t id, enum Heat heat, const time_t *time, const char *format, ...
+) __attribute__((format(printf, 4, 5)));
+void uiLoad(const char *name);
+int uiSave(const char *name);
+
+enum Edit {
+ EditHead,
+ EditTail,
+ EditPrev,
+ EditNext,
+ EditPrevWord,
+ EditNextWord,
+ EditDeleteHead,
+ EditDeleteTail,
+ EditDeletePrev,
+ EditDeleteNext,
+ EditDeletePrevWord,
+ EditDeleteNextWord,
+ EditPaste,
+ EditInsert,
+ EditComplete,
+ EditEnter,
+};
+void edit(size_t id, enum Edit op, wchar_t ch);
+char *editBuffer(size_t *pos);
+
+const char *complete(size_t id, const char *prefix);
+void completeAccept(void);
+void completeReject(void);
+void completeAdd(size_t id, const char *str, enum Color color);
+void completeTouch(size_t id, const char *str, enum Color color);
+void completeReplace(size_t id, const char *old, const char *new);
+void completeRemove(size_t id, const char *str);
+void completeClear(size_t id);
+size_t completeID(const char *str);
+enum Color completeColor(size_t id, const char *str);
+
+extern const char *urlOpenUtil;
+extern const char *urlCopyUtil;
+void urlScan(size_t id, const char *nick, const char *mesg);
+void urlOpenCount(size_t id, size_t count);
+void urlOpenMatch(size_t id, const char *str);
+void urlCopyMatch(size_t id, const char *str);
+
+FILE *configOpen(const char *path, const char *mode);
+FILE *dataOpen(const char *path, const char *mode);
+
+int getopt_config(
+ int argc, char *const *argv,
+ const char *optstring, const struct option *longopts, int *longindex
+);
+
+extern uint32_t hashInit;
+static inline enum Color hash(const char *str) {
+ if (*str == '~') str++;
+ uint32_t hash = hashInit;
+ for (; *str; ++str) {
+ hash = (hash << 5) | (hash >> 27);
+ hash ^= *str;
+ hash *= 0x27220A95;
+ }
+ return 2 + hash % 74;
+}
+
+#define BASE64_SIZE(len) (1 + ((len) + 2) / 3 * 4)
+static const char Base64[64] = {
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
+};
+static inline void base64(char *dst, const byte *src, size_t len) {
+ size_t i = 0;
+ while (len > 2) {
+ dst[i++] = Base64[0x3F & (src[0] >> 2)];
+ dst[i++] = Base64[0x3F & (src[0] << 4 | src[1] >> 4)];
+ dst[i++] = Base64[0x3F & (src[1] << 2 | src[2] >> 6)];
+ dst[i++] = Base64[0x3F & src[2]];
+ src += 3;
+ len -= 3;
+ }
+ if (len) {
+ dst[i++] = Base64[0x3F & (src[0] >> 2)];
+ if (len > 1) {
+ dst[i++] = Base64[0x3F & (src[0] << 4 | src[1] >> 4)];
+ dst[i++] = Base64[0x3F & (src[1] << 2)];
+ } else {
+ dst[i++] = Base64[0x3F & (src[0] << 4)];
+ dst[i++] = '=';
+ }
+ dst[i++] = '=';
+ }
+ dst[i] = '\0';
+}
+
+// Defined in libcrypto if missing from libc:
+void explicit_bzero(void *b, size_t len);
diff --git a/command.c b/command.c
new file mode 100644
index 0000000..5cb43cf
--- /dev/null
+++ b/command.c
@@ -0,0 +1,280 @@
+/* Copyright (C) 2020 C. McEnroe
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#include
+#include
+#include
+#include
+#include
+
+#include "chat.h"
+
+typedef void Command(size_t id, char *params);
+
+static void commandDebug(size_t id, char *params) {
+ (void)id;
+ (void)params;
+ self.debug ^= true;
+ uiFormat(
+ Debug, Warm, NULL,
+ "\3%dDebug is %s", Gray, (self.debug ? "on" : "off")
+ );
+}
+
+static void commandQuote(size_t id, char *params) {
+ (void)id;
+ if (params) ircFormat("%s\r\n", params);
+}
+
+static void commandPrivmsg(size_t id, char *params) {
+ if (!params || !params[0]) return;
+ ircFormat("PRIVMSG %s :%s\r\n", idNames[id], params);
+ struct Message msg = {
+ .nick = self.nick,
+ .user = self.user,
+ .cmd = "PRIVMSG",
+ .params[0] = idNames[id],
+ .params[1] = params,
+ };
+ handle(msg);
+}
+
+static void commandNotice(size_t id, char *params) {
+ if (!params || !params[0]) return;
+ ircFormat("NOTICE %s :%s\r\n", idNames[id], params);
+ struct Message msg = {
+ .nick = self.nick,
+ .user = self.user,
+ .cmd = "NOTICE",
+ .params[0] = idNames[id],
+ .params[1] = params,
+ };
+ handle(msg);
+}
+
+static void commandMe(size_t id, char *params) {
+ char buf[512];
+ snprintf(buf, sizeof(buf), "\1ACTION %s\1", (params ? params : ""));
+ commandPrivmsg(id, buf);
+}
+
+static void commandMsg(size_t id, char *params) {
+ (void)id;
+ char *nick = strsep(¶ms, " ");
+ if (!params) return;
+ commandPrivmsg(idFor(nick), params);
+}
+
+static void commandJoin(size_t id, char *params) {
+ size_t count = 1;
+ if (params) {
+ for (char *ch = params; *ch && *ch != ' '; ++ch) {
+ if (*ch == ',') count++;
+ }
+ }
+ ircFormat("JOIN %s\r\n", (params ? params : idNames[id]));
+ replies.join += count;
+ replies.topic += count;
+ replies.names += count;
+}
+
+static void commandPart(size_t id, char *params) {
+ if (params) {
+ ircFormat("PART %s :%s\r\n", idNames[id], params);
+ } else {
+ ircFormat("PART %s\r\n", idNames[id]);
+ }
+}
+
+static void commandQuit(size_t id, char *params) {
+ (void)id;
+ set(&self.quit, (params ? params : "Goodbye"));
+}
+
+static void commandNick(size_t id, char *params) {
+ (void)id;
+ if (!params) return;
+ ircFormat("NICK :%s\r\n", params);
+}
+
+static void commandTopic(size_t id, char *params) {
+ if (params) {
+ ircFormat("TOPIC %s :%s\r\n", idNames[id], params);
+ } else {
+ ircFormat("TOPIC %s\r\n", idNames[id]);
+ replies.topic++;
+ }
+}
+
+static void commandNames(size_t id, char *params) {
+ (void)params;
+ ircFormat("NAMES :%s\r\n", idNames[id]);
+ replies.names++;
+}
+
+static void commandWhois(size_t id, char *params) {
+ (void)id;
+ if (!params) return;
+ ircFormat("WHOIS :%s\r\n", params);
+ replies.whois++;
+}
+
+static void commandQuery(size_t id, char *params) {
+ if (!params) return;
+ size_t query = idFor(params);
+ idColors[query] = completeColor(id, params);
+ uiShowID(query);
+}
+
+static void commandWindow(size_t id, char *params) {
+ if (!params) return;
+ if (isdigit(params[0])) {
+ uiShowNum(strtoul(params, NULL, 10));
+ } else {
+ id = idFind(params);
+ if (id) uiShowID(id);
+ }
+}
+
+static void commandClose(size_t id, char *params) {
+ if (!params) {
+ uiCloseID(id);
+ } else if (isdigit(params[0])) {
+ uiCloseNum(strtoul(params, NULL, 10));
+ } else {
+ id = idFind(params);
+ if (id) uiCloseID(id);
+ }
+}
+
+static void commandOpen(size_t id, char *params) {
+ if (!params) {
+ urlOpenCount(id, 1);
+ } else if (isdigit(params[0])) {
+ urlOpenCount(id, strtoul(params, NULL, 10));
+ } else {
+ urlOpenMatch(id, params);
+ }
+}
+
+static void commandCopy(size_t id, char *params) {
+ urlCopyMatch(id, params);
+}
+
+static void commandHelp(size_t id, char *params) {
+ (void)id;
+ uiHide();
+
+ pid_t pid = fork();
+ if (pid < 0) err(EX_OSERR, "fork");
+ if (pid) return;
+
+ char buf[256];
+ snprintf(buf, sizeof(buf), "ip%s$", (params ? params : "COMMANDS"));
+ setenv("LESS", buf, 1);
+ execlp("man", "man", "1", "catgirl", NULL);
+ dup2(procPipe[1], STDERR_FILENO);
+ warn("man");
+ _exit(EX_UNAVAILABLE);
+}
+
+static const struct Handler {
+ const char *cmd;
+ Command *fn;
+} Commands[] = {
+ { "/close", commandClose },
+ { "/copy", commandCopy },
+ { "/debug", commandDebug },
+ { "/help", commandHelp },
+ { "/join", commandJoin },
+ { "/me", commandMe },
+ { "/msg", commandMsg },
+ { "/names", commandNames },
+ { "/nick", commandNick },
+ { "/notice", commandNotice },
+ { "/open", commandOpen },
+ { "/part", commandPart },
+ { "/query", commandQuery },
+ { "/quit", commandQuit },
+ { "/quote", commandQuote },
+ { "/topic", commandTopic },
+ { "/whois", commandWhois },
+ { "/window", commandWindow },
+};
+
+static int compar(const void *cmd, const void *_handler) {
+ const struct Handler *handler = _handler;
+ return strcmp(cmd, handler->cmd);
+}
+
+const char *commandIsPrivmsg(size_t id, const char *input) {
+ if (id == Network || id == Debug) return NULL;
+ if (input[0] != '/') return input;
+ const char *space = strchr(&input[1], ' ');
+ const char *slash = strchr(&input[1], '/');
+ if (slash && (!space || slash < space)) return input;
+ return NULL;
+}
+
+const char *commandIsNotice(size_t id, const char *input) {
+ if (id == Network || id == Debug) return NULL;
+ if (strncmp(input, "/notice ", 8)) return NULL;
+ return &input[8];
+}
+
+const char *commandIsAction(size_t id, const char *input) {
+ if (id == Network || id == Debug) return NULL;
+ if (strncmp(input, "/me ", 4)) return NULL;
+ return &input[4];
+}
+
+void command(size_t id, char *input) {
+ if (id == Debug && input[0] != '/') {
+ commandQuote(id, input);
+ } else if (commandIsPrivmsg(id, input)) {
+ commandPrivmsg(id, input);
+ } else if (input[0] == '/' && isdigit(input[1])) {
+ commandWindow(id, &input[1]);
+ } else {
+ const char *cmd = strsep(&input, " ");
+ const char *unique = complete(None, cmd);
+ if (unique && !complete(None, cmd)) {
+ cmd = unique;
+ completeReject();
+ }
+ const struct Handler *handler = bsearch(
+ cmd, Commands, ARRAY_LEN(Commands), sizeof(*handler), compar
+ );
+ if (handler) {
+ if (input) {
+ input += strspn(input, " ");
+ size_t len = strlen(input);
+ while (input[len - 1] == ' ') input[--len] = '\0';
+ if (!input[0]) input = NULL;
+ }
+ if (input && !input[0]) input = NULL;
+ handler->fn(id, input);
+ } else {
+ uiFormat(id, Hot, NULL, "No such command %s", cmd);
+ }
+ }
+}
+
+void commandComplete(void) {
+ for (size_t i = 0; i < ARRAY_LEN(Commands); ++i) {
+ completeAdd(None, Commands[i].cmd, Default);
+ }
+}
diff --git a/complete.c b/complete.c
new file mode 100644
index 0000000..2f5275f
--- /dev/null
+++ b/complete.c
@@ -0,0 +1,162 @@
+/* Copyright (C) 2020 C. McEnroe
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#include
+#include
+#include
+#include
+#include
+
+#include "chat.h"
+
+struct Node {
+ size_t id;
+ char *str;
+ enum Color color;
+ struct Node *prev;
+ struct Node *next;
+};
+
+static struct Node *alloc(size_t id, const char *str, enum Color color) {
+ struct Node *node = malloc(sizeof(*node));
+ if (!node) err(EX_OSERR, "malloc");
+ node->id = id;
+ node->str = strdup(str);
+ if (!node->str) err(EX_OSERR, "strdup");
+ node->color = color;
+ node->prev = NULL;
+ node->next = NULL;
+ return node;
+}
+
+static struct Node *head;
+static struct Node *tail;
+
+static struct Node *detach(struct Node *node) {
+ if (node->prev) node->prev->next = node->next;
+ if (node->next) node->next->prev = node->prev;
+ if (head == node) head = node->next;
+ if (tail == node) tail = node->prev;
+ node->prev = NULL;
+ node->next = NULL;
+ return node;
+}
+
+static struct Node *prepend(struct Node *node) {
+ node->prev = NULL;
+ node->next = head;
+ if (head) head->prev = node;
+ head = node;
+ if (!tail) tail = node;
+ return node;
+}
+
+static struct Node *append(struct Node *node) {
+ node->next = NULL;
+ node->prev = tail;
+ if (tail) tail->next = node;
+ tail = node;
+ if (!head) head = node;
+ return node;
+}
+
+static struct Node *find(size_t id, const char *str) {
+ for (struct Node *node = head; node; node = node->next) {
+ if (node->id == id && !strcmp(node->str, str)) return node;
+ }
+ return NULL;
+}
+
+void completeAdd(size_t id, const char *str, enum Color color) {
+ if (!find(id, str)) append(alloc(id, str, color));
+}
+
+void completeTouch(size_t id, const char *str, enum Color color) {
+ struct Node *node = find(id, str);
+ if (node && node->color != color) node->color = color;
+ prepend(node ? detach(node) : alloc(id, str, color));
+}
+
+enum Color completeColor(size_t id, const char *str) {
+ struct Node *node = find(id, str);
+ return (node ? node->color : Default);
+}
+
+static struct Node *match;
+
+const char *complete(size_t id, const char *prefix) {
+ for (match = (match ? match->next : head); match; match = match->next) {
+ if (match->id && match->id != id) continue;
+ if (strncasecmp(match->str, prefix, strlen(prefix))) continue;
+ return match->str;
+ }
+ return NULL;
+}
+
+void completeAccept(void) {
+ if (match) prepend(detach(match));
+ match = NULL;
+}
+
+void completeReject(void) {
+ match = NULL;
+}
+
+size_t completeID(const char *str) {
+ for (match = (match ? match->next : head); match; match = match->next) {
+ if (match->id && !strcmp(match->str, str)) return match->id;
+ }
+ return None;
+}
+
+void completeReplace(size_t id, const char *old, const char *new) {
+ struct Node *next = NULL;
+ for (struct Node *node = head; node; node = node->next) {
+ next = node->next;
+ if (id && node->id != id) continue;
+ if (strcmp(node->str, old)) continue;
+ if (match == node) match = NULL;
+ free(node->str);
+ node->str = strdup(new);
+ if (!node->str) err(EX_OSERR, "strdup");
+ prepend(detach(node));
+ }
+}
+
+void completeRemove(size_t id, const char *str) {
+ struct Node *next = NULL;
+ for (struct Node *node = head; node; node = next) {
+ next = node->next;
+ if (id && node->id != id) continue;
+ if (strcmp(node->str, str)) continue;
+ if (match == node) match = NULL;
+ detach(node);
+ free(node->str);
+ free(node);
+ }
+}
+
+void completeClear(size_t id) {
+ struct Node *next = NULL;
+ for (struct Node *node = head; node; node = next) {
+ next = node->next;
+ if (node->id != id) continue;
+ if (match == node) match = NULL;
+ detach(node);
+ free(node->str);
+ free(node);
+ }
+}
diff --git a/config.c b/config.c
new file mode 100644
index 0000000..3a87948
--- /dev/null
+++ b/config.c
@@ -0,0 +1,137 @@
+/* Copyright (C) 2019 C. McEnroe
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "chat.h"
+
+#define WS "\t "
+
+static const char *path;
+static FILE *file;
+static size_t num;
+static char *line;
+static size_t cap;
+
+static int clean(int opt) {
+ if (file) fclose(file);
+ free(line);
+ line = NULL;
+ cap = 0;
+ return opt;
+}
+
+int getopt_config(
+ int argc, char *const *argv,
+ const char *optstring, const struct option *longopts, int *longindex
+) {
+ static int opt;
+ if (opt >= 0) {
+ opt = getopt_long(argc, argv, optstring, longopts, longindex);
+ }
+ if (opt >= 0) return opt;
+
+ for (;;) {
+ if (!file) {
+ if (optind < argc) {
+ num = 0;
+ path = argv[optind++];
+ file = configOpen(path, "r");
+ if (!file) return clean('?');
+ } else {
+ return clean(-1);
+ }
+ }
+
+ for (;;) {
+ ssize_t llen = getline(&line, &cap, file);
+ if (ferror(file)) {
+ warn("%s", path);
+ return clean('?');
+ }
+ if (llen <= 0) break;
+ if (line[llen - 1] == '\n') line[llen - 1] = '\0';
+ num++;
+
+ char *name = line + strspn(line, WS);
+ size_t len = strcspn(name, WS "=");
+ if (!name[0] || name[0] == '#') continue;
+
+ const struct option *option;
+ for (option = longopts; option->name; ++option) {
+ if (strlen(option->name) != len) continue;
+ if (!strncmp(option->name, name, len)) break;
+ }
+ if (!option->name) {
+ warnx(
+ "%s:%zu: unrecognized option `%.*s'",
+ path, num, (int)len, name
+ );
+ return clean('?');
+ }
+
+ char *equal = &name[len] + strspn(&name[len], WS);
+ if (*equal && *equal != '=') {
+ warnx(
+ "%s:%zu: option `%s' missing equals sign",
+ path, num, option->name
+ );
+ return clean('?');
+ }
+ if (option->has_arg == no_argument && *equal) {
+ warnx(
+ "%s:%zu: option `%s' doesn't allow an argument",
+ path, num, option->name
+ );
+ return clean('?');
+ }
+ if (option->has_arg == required_argument && !*equal) {
+ warnx(
+ "%s:%zu: option `%s' requires an argument",
+ path, num, option->name
+ );
+ return clean(':');
+ }
+
+ optarg = NULL;
+ if (*equal) {
+ char *arg = &equal[1] + strspn(&equal[1], WS);
+ optarg = strdup(arg);
+ if (!optarg) {
+ warn("getopt_config");
+ return clean('?');
+ }
+ }
+
+ if (longindex) *longindex = option - longopts;
+ if (option->flag) {
+ *option->flag = option->val;
+ return 0;
+ } else {
+ return option->val;
+ }
+ }
+
+ fclose(file);
+ file = NULL;
+ }
+}
diff --git a/configure b/configure
new file mode 100755
index 0000000..90e1173
--- /dev/null
+++ b/configure
@@ -0,0 +1,11 @@
+#!/bin/sh
+set -eu
+
+libs='libcrypto libtls ncursesw'
+pkg-config --print-errors $libs
+
+cat >config.mk <
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "chat.h"
+
+enum { Cap = 512 };
+static wchar_t buf[Cap];
+static size_t len;
+static size_t pos;
+
+char *editBuffer(size_t *mbsPos) {
+ static char mbs[MB_LEN_MAX * Cap];
+
+ const wchar_t *ptr = buf;
+ size_t mbsLen = wcsnrtombs(mbs, &ptr, pos, sizeof(mbs) - 1, NULL);
+ assert(mbsLen != (size_t)-1);
+ if (mbsPos) *mbsPos = mbsLen;
+
+ ptr = &buf[pos];
+ size_t n = wcsnrtombs(
+ &mbs[mbsLen], &ptr, len - pos, sizeof(mbs) - mbsLen - 1, NULL
+ );
+ assert(n != (size_t)-1);
+ mbsLen += n;
+
+ mbs[mbsLen] = '\0';
+ return mbs;
+}
+
+static struct {
+ wchar_t buf[Cap];
+ size_t len;
+} cut;
+
+static bool reserve(size_t index, size_t count) {
+ if (len + count > Cap) return false;
+ memmove(&buf[index + count], &buf[index], sizeof(*buf) * (len - index));
+ len += count;
+ return true;
+}
+
+static void delete(size_t index, size_t count) {
+ if (index + count > len) return;
+ if (count > 1) {
+ memcpy(cut.buf, &buf[index], sizeof(*buf) * count);
+ cut.len = count;
+ }
+ memmove(
+ &buf[index], &buf[index + count], sizeof(*buf) * (len - index - count)
+ );
+ len -= count;
+}
+
+static struct {
+ size_t pos;
+ size_t pre;
+ size_t len;
+} tab;
+
+static void tabComplete(size_t id) {
+ if (!tab.len) {
+ tab.pos = pos;
+ while (tab.pos && buf[tab.pos - 1] != L' ') tab.pos--;
+ if (tab.pos == pos) return;
+ tab.pre = pos - tab.pos;
+ tab.len = tab.pre;
+ }
+
+ char mbs[MB_LEN_MAX * Cap];
+ const wchar_t *ptr = &buf[tab.pos];
+ size_t n = wcsnrtombs(mbs, &ptr, tab.pre, sizeof(mbs) - 1, NULL);
+ assert(n != (size_t)-1);
+ mbs[n] = '\0';
+
+ const char *comp = complete(id, mbs);
+ if (!comp) comp = complete(id, mbs);
+ if (!comp) {
+ tab.len = 0;
+ return;
+ }
+
+ wchar_t wcs[Cap];
+ n = mbstowcs(wcs, comp, sizeof(wcs));
+ assert(n != (size_t)-1);
+ if (tab.pos + n + 2 > Cap) {
+ completeReject();
+ tab.len = 0;
+ return;
+ }
+
+ delete(tab.pos, tab.len);
+ if (wcs[0] != L'/' && !tab.pos) {
+ tab.len = n + 2;
+ reserve(tab.pos, tab.len);
+ buf[tab.pos + n + 0] = L':';
+ buf[tab.pos + n + 1] = L' ';
+ } else if (
+ tab.pos >= 2 && (buf[tab.pos - 2] == L':' || buf[tab.pos - 2] == L',')
+ ) {
+ tab.len = n + 2;
+ reserve(tab.pos, tab.len);
+ buf[tab.pos - 2] = L',';
+ buf[tab.pos + n + 0] = L':';
+ buf[tab.pos + n + 1] = L' ';
+ } else {
+ tab.len = n + 1;
+ reserve(tab.pos, tab.len);
+ buf[tab.pos + n] = L' ';
+ }
+ memcpy(&buf[tab.pos], wcs, sizeof(*wcs) * n);
+ pos = tab.pos + tab.len;
+}
+
+static void tabAccept(void) {
+ completeAccept();
+ tab.len = 0;
+}
+
+static void tabReject(void) {
+ completeReject();
+ tab.len = 0;
+}
+
+void edit(size_t id, enum Edit op, wchar_t ch) {
+ size_t init = pos;
+ switch (op) {
+ break; case EditHead: pos = 0;
+ break; case EditTail: pos = len;
+ break; case EditPrev: if (pos) pos--;
+ break; case EditNext: if (pos < len) pos++;
+ break; case EditPrevWord: {
+ if (pos) pos--;
+ while (pos && !iswspace(buf[pos - 1])) pos--;
+ }
+ break; case EditNextWord: {
+ if (pos < len) pos++;
+ while (pos < len && !iswspace(buf[pos])) pos++;
+ }
+
+ break; case EditDeleteHead: delete(0, pos); pos = 0;
+ break; case EditDeleteTail: delete(pos, len - pos);
+ break; case EditDeletePrev: if (pos) delete(--pos, 1);
+ break; case EditDeleteNext: delete(pos, 1);
+ break; case EditDeletePrevWord: {
+ if (!pos) break;
+ size_t word = pos - 1;
+ while (word && !iswspace(buf[word - 1])) word--;
+ delete(word, pos - word);
+ pos = word;
+ }
+ break; case EditDeleteNextWord: {
+ if (pos == len) break;
+ size_t word = pos + 1;
+ while (word < len && !iswspace(buf[word])) word++;
+ delete(pos, word - pos);
+ }
+ break; case EditPaste: {
+ if (reserve(pos, cut.len)) {
+ memcpy(&buf[pos], cut.buf, sizeof(*buf) * cut.len);
+ pos += cut.len;
+ }
+ }
+
+ break; case EditInsert: {
+ if (reserve(pos, 1)) {
+ buf[pos++] = ch;
+ }
+ }
+ break; case EditComplete: {
+ tabComplete(id);
+ return;
+ }
+ break; case EditEnter: {
+ tabAccept();
+ command(id, editBuffer(NULL));
+ len = pos = 0;
+ return;
+ }
+ }
+
+ if (pos < init) {
+ tabReject();
+ } else {
+ tabAccept();
+ }
+}
diff --git a/handle.c b/handle.c
new file mode 100644
index 0000000..ce56a51
--- /dev/null
+++ b/handle.c
@@ -0,0 +1,637 @@
+/* Copyright (C) 2020 C. McEnroe
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "chat.h"
+
+struct Replies replies;
+
+static const char *CapNames[] = {
+#define X(name, id) [id##Bit] = name,
+ ENUM_CAP
+#undef X
+};
+
+static enum Cap capParse(const char *list) {
+ enum Cap caps = 0;
+ while (*list) {
+ enum Cap cap = 0;
+ size_t len = strcspn(list, " ");
+ for (size_t i = 0; i < ARRAY_LEN(CapNames); ++i) {
+ if (len != strlen(CapNames[i])) continue;
+ if (strncmp(list, CapNames[i], len)) continue;
+ cap = 1 << i;
+ break;
+ }
+ caps |= cap;
+ list += len;
+ if (*list) list++;
+ }
+ return caps;
+}
+
+static const char *capList(enum Cap caps) {
+ static char buf[1024];
+ buf[0] = '\0';
+ for (size_t i = 0; i < ARRAY_LEN(CapNames); ++i) {
+ if (caps & (1 << i)) {
+ if (buf[0]) strlcat(buf, " ", sizeof(buf));
+ strlcat(buf, CapNames[i], sizeof(buf));
+ }
+ }
+ return buf;
+}
+
+static void require(struct Message *msg, bool origin, size_t len) {
+ if (origin) {
+ if (!msg->nick) errx(EX_PROTOCOL, "%s missing origin", msg->cmd);
+ if (!msg->user) msg->user = msg->nick;
+ if (!msg->host) msg->host = msg->user;
+ }
+ for (size_t i = 0; i < len; ++i) {
+ if (msg->params[i]) continue;
+ errx(EX_PROTOCOL, "%s missing parameter %zu", msg->cmd, 1 + i);
+ }
+}
+
+static const time_t *tagTime(const struct Message *msg) {
+ static time_t time;
+ struct tm tm;
+ if (!msg->tags[TagTime]) return NULL;
+ if (!strptime(msg->tags[TagTime], "%FT%T", &tm)) return NULL;
+ time = timegm(&tm);
+ return &time;
+}
+
+typedef void Handler(struct Message *msg);
+
+static void handleErrorNicknameInUse(struct Message *msg) {
+ if (self.nick) return;
+ require(msg, false, 2);
+ ircFormat("NICK :%s_\r\n", msg->params[1]);
+}
+
+static void handleErrorErroneousNickname(struct Message *msg) {
+ require(msg, false, 3);
+ errx(EX_CONFIG, "%s: %s", msg->params[1], msg->params[2]);
+}
+
+static void handleCap(struct Message *msg) {
+ require(msg, false, 3);
+ enum Cap caps = capParse(msg->params[2]);
+ if (!strcmp(msg->params[1], "LS")) {
+ caps &= ~CapSASL;
+ if (caps) {
+ ircFormat("CAP REQ :%s\r\n", capList(caps));
+ } else {
+ if (!(self.caps & CapSASL)) ircFormat("CAP END\r\n");
+ }
+ } else if (!strcmp(msg->params[1], "ACK")) {
+ self.caps |= caps;
+ if (caps & CapSASL) {
+ ircFormat("AUTHENTICATE %s\r\n", (self.plain ? "PLAIN" : "EXTERNAL"));
+ }
+ if (!(self.caps & CapSASL)) ircFormat("CAP END\r\n");
+ } else if (!strcmp(msg->params[1], "NAK")) {
+ errx(EX_CONFIG, "server does not support %s", msg->params[2]);
+ }
+}
+
+static void handleAuthenticate(struct Message *msg) {
+ (void)msg;
+ if (!self.plain) {
+ ircFormat("AUTHENTICATE +\r\n");
+ return;
+ }
+
+ byte buf[299];
+ size_t len = 1 + strlen(self.plain);
+ if (sizeof(buf) < len) errx(EX_CONFIG, "SASL PLAIN is too long");
+ buf[0] = 0;
+ for (size_t i = 0; self.plain[i]; ++i) {
+ buf[1 + i] = (self.plain[i] == ':' ? 0 : self.plain[i]);
+ }
+
+ char b64[BASE64_SIZE(sizeof(buf))];
+ base64(b64, buf, len);
+ ircFormat("AUTHENTICATE ");
+ ircSend(b64, BASE64_SIZE(len));
+ ircFormat("\r\n");
+
+ explicit_bzero(b64, sizeof(b64));
+ explicit_bzero(buf, sizeof(buf));
+ explicit_bzero(self.plain, strlen(self.plain));
+}
+
+static void handleReplyLoggedIn(struct Message *msg) {
+ (void)msg;
+ ircFormat("CAP END\r\n");
+}
+
+static void handleErrorSASLFail(struct Message *msg) {
+ require(msg, false, 2);
+ errx(EX_CONFIG, "%s", msg->params[1]);
+}
+
+static void handleReplyWelcome(struct Message *msg) {
+ require(msg, false, 1);
+ set(&self.nick, msg->params[0]);
+ completeTouch(Network, self.nick, Default);
+ if (self.join) {
+ size_t count = 1;
+ for (const char *ch = self.join; *ch && *ch != ' '; ++ch) {
+ if (*ch == ',') count++;
+ }
+ ircFormat("JOIN %s\r\n", self.join);
+ replies.join += count;
+ replies.topic += count;
+ replies.names += count;
+ }
+}
+
+static void handleReplyISupport(struct Message *msg) {
+ for (size_t i = 1; i < ParamCap; ++i) {
+ if (!msg->params[i]) break;
+ char *key = strsep(&msg->params[i], "=");
+ if (!msg->params[i]) continue;
+ if (!strcmp(key, "NETWORK")) {
+ set(&self.network, msg->params[i]);
+ uiFormat(
+ Network, Cold, tagTime(msg),
+ "You arrive in %s", msg->params[i]
+ );
+ } else if (!strcmp(key, "CHANTYPES")) {
+ set(&self.chanTypes, msg->params[i]);
+ } else if (!strcmp(key, "PREFIX")) {
+ strsep(&msg->params[i], ")");
+ if (!msg->params[i]) continue;
+ set(&self.prefixes, msg->params[i]);
+ }
+ }
+}
+
+static void handleReplyMOTD(struct Message *msg) {
+ require(msg, false, 2);
+ char *line = msg->params[1];
+ urlScan(Network, msg->nick, line);
+ if (!strncmp(line, "- ", 2)) {
+ uiFormat(Network, Cold, tagTime(msg), "\3%d-\3\t%s", Gray, &line[2]);
+ } else {
+ uiFormat(Network, Cold, tagTime(msg), "%s", line);
+ }
+}
+
+static void handleJoin(struct Message *msg) {
+ require(msg, true, 1);
+ size_t id = idFor(msg->params[0]);
+ if (self.nick && !strcmp(msg->nick, self.nick)) {
+ if (!self.user) {
+ set(&self.user, msg->user);
+ self.color = hash(msg->user);
+ }
+ idColors[id] = hash(msg->params[0]);
+ completeTouch(None, msg->params[0], idColors[id]);
+ if (replies.join) {
+ uiShowID(id);
+ replies.join--;
+ }
+ }
+ completeTouch(id, msg->nick, hash(msg->user));
+ if (msg->params[2] && !strcasecmp(msg->params[2], msg->nick)) {
+ msg->params[2] = NULL;
+ }
+ uiFormat(
+ id, Cold, tagTime(msg),
+ "\3%02d%s\3\t%s%s%sarrives in \3%02d%s\3",
+ hash(msg->user), msg->nick,
+ (msg->params[2] ? "(" : ""),
+ (msg->params[2] ? msg->params[2] : ""),
+ (msg->params[2] ? ") " : ""),
+ hash(msg->params[0]), msg->params[0]
+ );
+}
+
+static void handlePart(struct Message *msg) {
+ require(msg, true, 1);
+ size_t id = idFor(msg->params[0]);
+ if (self.nick && !strcmp(msg->nick, self.nick)) {
+ completeClear(id);
+ }
+ completeRemove(id, msg->nick);
+ urlScan(id, msg->nick, msg->params[1]);
+ uiFormat(
+ id, Cold, tagTime(msg),
+ "\3%02d%s\3\tleaves \3%02d%s\3%s%s",
+ hash(msg->user), msg->nick, hash(msg->params[0]), msg->params[0],
+ (msg->params[1] ? ": " : ""),
+ (msg->params[1] ? msg->params[1] : "")
+ );
+}
+
+static void handleKick(struct Message *msg) {
+ require(msg, true, 2);
+ size_t id = idFor(msg->params[0]);
+ bool kicked = self.nick && !strcmp(msg->params[1], self.nick);
+ completeTouch(id, msg->nick, hash(msg->user));
+ urlScan(id, msg->nick, msg->params[2]);
+ uiFormat(
+ id, (kicked ? Hot : Cold), tagTime(msg),
+ "%s\3%02d%s\17\tkicks \3%02d%s\3 out of \3%02d%s\3%s%s",
+ (kicked ? "\26" : ""),
+ hash(msg->user), msg->nick,
+ completeColor(id, msg->params[1]), msg->params[1],
+ hash(msg->params[0]), msg->params[0],
+ (msg->params[2] ? ": " : ""),
+ (msg->params[2] ? msg->params[2] : "")
+ );
+ completeRemove(id, msg->params[1]);
+ if (kicked) completeClear(id);
+}
+
+static void handleNick(struct Message *msg) {
+ require(msg, true, 1);
+ if (self.nick && !strcmp(msg->nick, self.nick)) {
+ set(&self.nick, msg->params[0]);
+ uiRead(); // Update prompt.
+ }
+ size_t id;
+ while (None != (id = completeID(msg->nick))) {
+ uiFormat(
+ id, Cold, tagTime(msg),
+ "\3%02d%s\3\tis now known as \3%02d%s\3",
+ hash(msg->user), msg->nick, hash(msg->user), msg->params[0]
+ );
+ }
+ completeReplace(None, msg->nick, msg->params[0]);
+}
+
+static void handleQuit(struct Message *msg) {
+ require(msg, true, 0);
+ size_t id;
+ while (None != (id = completeID(msg->nick))) {
+ urlScan(id, msg->nick, msg->params[0]);
+ uiFormat(
+ id, Cold, tagTime(msg),
+ "\3%02d%s\3\tleaves%s%s",
+ hash(msg->user), msg->nick,
+ (msg->params[0] ? ": " : ""),
+ (msg->params[0] ? msg->params[0] : "")
+ );
+ }
+ completeRemove(None, msg->nick);
+}
+
+static void handleReplyNames(struct Message *msg) {
+ require(msg, false, 4);
+ size_t id = idFor(msg->params[2]);
+ char buf[1024];
+ size_t len = 0;
+ while (msg->params[3]) {
+ char *name = strsep(&msg->params[3], " ");
+ name += strspn(name, self.prefixes);
+ char *nick = strsep(&name, "!");
+ char *user = strsep(&name, "@");
+ enum Color color = (user ? hash(user) : Default);
+ completeAdd(id, nick, color);
+ if (!replies.names) continue;
+ int n = snprintf(
+ &buf[len], sizeof(buf) - len,
+ "%s\3%02d%s\3", (len ? ", " : ""), color, nick
+ );
+ assert(n > 0 && len + n < sizeof(buf));
+ len += n;
+ }
+ if (!replies.names) return;
+ uiFormat(
+ id, Cold, tagTime(msg),
+ "In \3%02d%s\3 are %s",
+ hash(msg->params[2]), msg->params[2], buf
+ );
+}
+
+static void handleReplyEndOfNames(struct Message *msg) {
+ (void)msg;
+ if (replies.names) replies.names--;
+}
+
+static void handleReplyNoTopic(struct Message *msg) {
+ require(msg, false, 2);
+ if (!replies.topic) return;
+ replies.topic--;
+ uiFormat(
+ idFor(msg->params[1]), Cold, tagTime(msg),
+ "There is no sign in \3%02d%s\3",
+ hash(msg->params[1]), msg->params[1]
+ );
+}
+
+static void handleReplyTopic(struct Message *msg) {
+ require(msg, false, 3);
+ if (!replies.topic) return;
+ replies.topic--;
+ size_t id = idFor(msg->params[1]);
+ urlScan(id, NULL, msg->params[2]);
+ uiFormat(
+ id, Cold, tagTime(msg),
+ "The sign in \3%02d%s\3 reads: %s",
+ hash(msg->params[1]), msg->params[1], msg->params[2]
+ );
+}
+
+static void handleTopic(struct Message *msg) {
+ require(msg, true, 2);
+ size_t id = idFor(msg->params[0]);
+ if (msg->params[1][0]) {
+ urlScan(id, msg->nick, msg->params[1]);
+ uiFormat(
+ id, Warm, tagTime(msg),
+ "\3%02d%s\3\tplaces a new sign in \3%02d%s\3: %s",
+ hash(msg->user), msg->nick, hash(msg->params[0]), msg->params[0],
+ msg->params[1]
+ );
+ } else {
+ uiFormat(
+ id, Warm, tagTime(msg),
+ "\3%02d%s\3\tremoves the sign in \3%02d%s\3",
+ hash(msg->user), msg->nick, hash(msg->params[0]), msg->params[0]
+ );
+ }
+}
+
+static void handleReplyWhoisUser(struct Message *msg) {
+ require(msg, false, 6);
+ if (!replies.whois) return;
+ completeTouch(Network, msg->params[1], hash(msg->params[2]));
+ uiFormat(
+ Network, Warm, tagTime(msg),
+ "\3%02d%s\3\tis %s!%s@%s (%s)",
+ hash(msg->params[2]), msg->params[1],
+ msg->params[1], msg->params[2], msg->params[3], msg->params[5]
+ );
+}
+
+static void handleReplyWhoisServer(struct Message *msg) {
+ require(msg, false, 4);
+ if (!replies.whois) return;
+ uiFormat(
+ Network, Warm, tagTime(msg),
+ "\3%02d%s\3\tis connected to %s (%s)",
+ completeColor(Network, msg->params[1]), msg->params[1],
+ msg->params[2], msg->params[3]
+ );
+}
+
+static void handleReplyWhoisIdle(struct Message *msg) {
+ require(msg, false, 3);
+ if (!replies.whois) return;
+ unsigned long idle = strtoul(msg->params[2], NULL, 10);
+ const char *unit = "second";
+ if (idle / 60) { idle /= 60; unit = "minute"; }
+ if (idle / 60) { idle /= 60; unit = "hour"; }
+ if (idle / 24) { idle /= 24; unit = "day"; }
+ time_t signon = (msg->params[3] ? strtoul(msg->params[3], NULL, 10) : 0);
+ uiFormat(
+ Network, Warm, tagTime(msg),
+ "\3%02d%s\3\tis idle for %lu %s%s%s%.*s",
+ completeColor(Network, msg->params[1]), msg->params[1],
+ idle, unit, (idle != 1 ? "s" : ""),
+ (signon ? ", signed on " : ""),
+ 24, (signon ? ctime(&signon) : "")
+ );
+}
+
+static void handleReplyWhoisChannels(struct Message *msg) {
+ require(msg, false, 3);
+ if (!replies.whois) return;
+ char buf[1024];
+ size_t len = 0;
+ while (msg->params[2]) {
+ char *channel = strsep(&msg->params[2], " ");
+ channel += strspn(channel, self.prefixes);
+ int n = snprintf(
+ &buf[len], sizeof(buf) - len,
+ "%s\3%02d%s\3", (len ? ", " : ""), hash(channel), channel
+ );
+ assert(n > 0 && len + n < sizeof(buf));
+ len += n;
+ }
+ uiFormat(
+ Network, Warm, tagTime(msg),
+ "\3%02d%s\3\tis in %s",
+ completeColor(Network, msg->params[1]), msg->params[1], buf
+ );
+}
+
+static void handleReplyWhoisGeneric(struct Message *msg) {
+ require(msg, false, 3);
+ if (!replies.whois) return;
+ if (msg->params[3]) {
+ msg->params[0] = msg->params[2];
+ msg->params[2] = msg->params[3];
+ msg->params[3] = msg->params[0];
+ }
+ uiFormat(
+ Network, Warm, tagTime(msg),
+ "\3%02d%s\3\t%s%s%s",
+ completeColor(Network, msg->params[1]), msg->params[1],
+ msg->params[2],
+ (msg->params[3] ? " " : ""),
+ (msg->params[3] ? msg->params[3] : "")
+ );
+}
+
+static void handleReplyEndOfWhois(struct Message *msg) {
+ require(msg, false, 2);
+ if (!replies.whois) return;
+ if (!self.nick || strcmp(msg->params[1], self.nick)) {
+ completeRemove(Network, msg->params[1]);
+ }
+ replies.whois--;
+}
+
+static bool isAction(struct Message *msg) {
+ if (strncmp(msg->params[1], "\1ACTION ", 8)) return false;
+ msg->params[1] += 8;
+ size_t len = strlen(msg->params[1]);
+ if (msg->params[1][len - 1] == '\1') msg->params[1][len - 1] = '\0';
+ return true;
+}
+
+static bool isMention(const struct Message *msg) {
+ if (!self.nick) return false;
+ size_t len = strlen(self.nick);
+ const char *match = msg->params[1];
+ while (NULL != (match = strcasestr(match, self.nick))) {
+ char a = (match > msg->params[1] ? match[-1] : ' ');
+ char b = (match[len] ? match[len] : ' ');
+ if ((isspace(a) || ispunct(a)) && (isspace(b) || ispunct(b))) {
+ return true;
+ }
+ match = &match[len];
+ }
+ return false;
+}
+
+static const char *colorMentions(size_t id, struct Message *msg) {
+ char *split = strchr(msg->params[1], ':');
+ if (!split) split = strchr(msg->params[1], ' ');
+ if (!split) split = &msg->params[1][strlen(msg->params[1])];
+ for (char *ch = msg->params[1]; ch < split; ++ch) {
+ if (iscntrl(*ch)) return "";
+ }
+ char delimit = *split;
+ char *mention = msg->params[1];
+ msg->params[1] = (delimit ? &split[1] : split);
+ *split = '\0';
+
+ static char buf[1024];
+ FILE *str = fmemopen(buf, sizeof(buf), "w");
+ if (!str) err(EX_OSERR, "fmemopen");
+
+ while (*mention) {
+ size_t skip = strspn(mention, ",<> ");
+ fwrite(mention, skip, 1, str);
+ mention += skip;
+
+ size_t len = strcspn(mention, ",<> ");
+ char punct = mention[len];
+ mention[len] = '\0';
+ fprintf(str, "\3%02d%s\3", completeColor(id, mention), mention);
+ mention[len] = punct;
+ mention += len;
+ }
+ fputc(delimit, str);
+
+ fclose(str);
+ buf[sizeof(buf) - 1] = '\0';
+ return buf;
+}
+
+static void handlePrivmsg(struct Message *msg) {
+ require(msg, true, 2);
+ bool query = !strchr(self.chanTypes, msg->params[0][0]);
+ bool network = strchr(msg->nick, '.');
+ bool mine = self.nick && !strcmp(msg->nick, self.nick);
+ size_t id;
+ if (query && network) {
+ id = Network;
+ } else if (query && !mine) {
+ id = idFor(msg->nick);
+ idColors[id] = hash(msg->user);
+ } else {
+ id = idFor(msg->params[0]);
+ }
+
+ bool notice = (msg->cmd[0] == 'N');
+ bool action = isAction(msg);
+ bool mention = !mine && isMention(msg);
+ if (!notice && !mine) completeTouch(id, msg->nick, hash(msg->user));
+ urlScan(id, msg->nick, msg->params[1]);
+ if (notice) {
+ uiFormat(
+ id, Warm, tagTime(msg),
+ "%s\3%d-%s-\17\3%d\t%s",
+ (mention ? "\26" : ""), hash(msg->user), msg->nick,
+ LightGray, msg->params[1]
+ );
+ } else if (action) {
+ uiFormat(
+ id, (mention || query ? Hot : Warm), tagTime(msg),
+ "%s\35\3%d* %s\17\35\t%s",
+ (mention ? "\26" : ""), hash(msg->user), msg->nick, msg->params[1]
+ );
+ } else {
+ const char *mentions = colorMentions(id, msg);
+ uiFormat(
+ id, (mention || query ? Hot : Warm), tagTime(msg),
+ "%s\3%d<%s>\17\t%s%s",
+ (mention ? "\26" : ""), hash(msg->user), msg->nick,
+ mentions, msg->params[1]
+ );
+ }
+}
+
+static void handlePing(struct Message *msg) {
+ require(msg, false, 1);
+ ircFormat("PONG :%s\r\n", msg->params[0]);
+}
+
+static void handleError(struct Message *msg) {
+ require(msg, false, 1);
+ errx(EX_UNAVAILABLE, "%s", msg->params[0]);
+}
+
+static const struct Handler {
+ const char *cmd;
+ Handler *fn;
+} Handlers[] = {
+ { "001", handleReplyWelcome },
+ { "005", handleReplyISupport },
+ { "276", handleReplyWhoisGeneric },
+ { "307", handleReplyWhoisGeneric },
+ { "311", handleReplyWhoisUser },
+ { "312", handleReplyWhoisServer },
+ { "313", handleReplyWhoisGeneric },
+ { "317", handleReplyWhoisIdle },
+ { "318", handleReplyEndOfWhois },
+ { "319", handleReplyWhoisChannels },
+ { "330", handleReplyWhoisGeneric },
+ { "331", handleReplyNoTopic },
+ { "332", handleReplyTopic },
+ { "353", handleReplyNames },
+ { "366", handleReplyEndOfNames },
+ { "372", handleReplyMOTD },
+ { "432", handleErrorErroneousNickname },
+ { "433", handleErrorNicknameInUse },
+ { "671", handleReplyWhoisGeneric },
+ { "900", handleReplyLoggedIn },
+ { "904", handleErrorSASLFail },
+ { "905", handleErrorSASLFail },
+ { "906", handleErrorSASLFail },
+ { "AUTHENTICATE", handleAuthenticate },
+ { "CAP", handleCap },
+ { "ERROR", handleError },
+ { "JOIN", handleJoin },
+ { "KICK", handleKick },
+ { "NICK", handleNick },
+ { "NOTICE", handlePrivmsg },
+ { "PART", handlePart },
+ { "PING", handlePing },
+ { "PRIVMSG", handlePrivmsg },
+ { "QUIT", handleQuit },
+ { "TOPIC", handleTopic },
+};
+
+static int compar(const void *cmd, const void *_handler) {
+ const struct Handler *handler = _handler;
+ return strcmp(cmd, handler->cmd);
+}
+
+void handle(struct Message msg) {
+ if (!msg.cmd) return;
+ const struct Handler *handler = bsearch(
+ msg.cmd, Handlers, ARRAY_LEN(Handlers), sizeof(*handler), compar
+ );
+ if (handler) handler->fn(&msg);
+}
diff --git a/irc.c b/irc.c
new file mode 100644
index 0000000..05f8f9d
--- /dev/null
+++ b/irc.c
@@ -0,0 +1,250 @@
+/* Copyright (C) 2020 C. McEnroe
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "chat.h"
+
+struct tls *client;
+
+static byte *readFile(size_t *len, FILE *file) {
+ struct stat stat;
+ int error = fstat(fileno(file), &stat);
+ if (error) err(EX_IOERR, "fstat");
+
+ byte *buf = malloc(stat.st_size);
+ if (!buf) err(EX_OSERR, "malloc");
+
+ rewind(file);
+ *len = fread(buf, 1, stat.st_size, file);
+ if (ferror(file)) err(EX_IOERR, "fread");
+
+ return buf;
+}
+
+void ircConfig(bool insecure, FILE *cert, FILE *priv) {
+ struct tls_config *config = tls_config_new();
+ if (!config) errx(EX_SOFTWARE, "tls_config_new");
+
+ int error = tls_config_set_ciphers(config, "compat");
+ if (error) {
+ errx(
+ EX_SOFTWARE, "tls_config_set_ciphers: %s",
+ tls_config_error(config)
+ );
+ }
+
+ if (insecure) {
+ tls_config_insecure_noverifycert(config);
+ tls_config_insecure_noverifyname(config);
+ }
+
+ if (cert) {
+ size_t len;
+ byte *buf = readFile(&len, cert);
+ error = tls_config_set_cert_mem(config, buf, len);
+ if (error) {
+ errx(
+ EX_CONFIG, "tls_config_set_cert_mem: %s",
+ tls_config_error(config)
+ );
+ }
+ if (priv) {
+ free(buf);
+ buf = readFile(&len, priv);
+ }
+ error = tls_config_set_key_mem(config, buf, len);
+ if (error) {
+ errx(
+ EX_CONFIG, "tls_config_set_key_mem: %s",
+ tls_config_error(config)
+ );
+ }
+ explicit_bzero(buf, len);
+ free(buf);
+ }
+
+ client = tls_client();
+ if (!client) errx(EX_SOFTWARE, "tls_client");
+
+ error = tls_configure(client, config);
+ if (error) errx(EX_SOFTWARE, "tls_configure: %s", tls_error(client));
+ tls_config_free(config);
+}
+
+int ircConnect(const char *host, const char *port) {
+ assert(client);
+
+ struct addrinfo *head;
+ struct addrinfo hints = {
+ .ai_family = AF_UNSPEC,
+ .ai_socktype = SOCK_STREAM,
+ .ai_protocol = IPPROTO_TCP,
+ };
+ int error = getaddrinfo(host, port, &hints, &head);
+ if (error) errx(EX_NOHOST, "%s:%s: %s", host, port, gai_strerror(error));
+
+ int sock = -1;
+ for (struct addrinfo *ai = head; ai; ai = ai->ai_next) {
+ sock = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol);
+ if (sock < 0) err(EX_OSERR, "socket");
+
+ error = connect(sock, ai->ai_addr, ai->ai_addrlen);
+ if (!error) break;
+
+ close(sock);
+ sock = -1;
+ }
+ if (sock < 0) err(EX_UNAVAILABLE, "%s:%s", host, port);
+ freeaddrinfo(head);
+
+ error = tls_connect_socket(client, sock, host);
+ if (error) errx(EX_PROTOCOL, "tls_connect: %s", tls_error(client));
+
+ error = tls_handshake(client);
+ if (error) errx(EX_PROTOCOL, "tls_handshake: %s", tls_error(client));
+
+ return sock;
+}
+
+static void debug(char dir, const char *line) {
+ if (!self.debug) return;
+ size_t len = strcspn(line, "\r\n");
+ uiFormat(
+ Debug, Cold, NULL, "\3%d%c%c\3\t%.*s",
+ Gray, dir, dir, (int)len, line
+ );
+ if (!isatty(STDERR_FILENO)) {
+ fprintf(stderr, "%c%c %.*s\n", dir, dir, (int)len, line);
+ }
+}
+
+void ircSend(const char *ptr, size_t len) {
+ assert(client);
+ while (len) {
+ ssize_t ret = tls_write(client, ptr, len);
+ if (ret == TLS_WANT_POLLIN || ret == TLS_WANT_POLLOUT) continue;
+ if (ret < 0) errx(EX_IOERR, "tls_write: %s", tls_error(client));
+ ptr += ret;
+ len -= ret;
+ }
+}
+
+void ircFormat(const char *format, ...) {
+ char buf[1024];
+ va_list ap;
+ va_start(ap, format);
+ int len = vsnprintf(buf, sizeof(buf), format, ap);
+ va_end(ap);
+ assert((size_t)len < sizeof(buf));
+ debug('<', buf);
+ ircSend(buf, len);
+}
+
+static const char *TagNames[TagCap] = {
+#define X(name, id) [id] = name,
+ ENUM_TAG
+#undef X
+};
+
+static void unescape(char *tag) {
+ for (;;) {
+ tag = strchr(tag, '\\');
+ if (!tag) break;
+ switch (tag[1]) {
+ break; case ':': tag[1] = ';';
+ break; case 's': tag[1] = ' ';
+ break; case 'r': tag[1] = '\r';
+ break; case 'n': tag[1] = '\n';
+ }
+ memmove(tag, &tag[1], strlen(&tag[1]) + 1);
+ if (tag[0]) tag = &tag[1];
+ }
+}
+
+static struct Message parse(char *line) {
+ struct Message msg = { .cmd = NULL };
+
+ if (line[0] == '@') {
+ char *tags = 1 + strsep(&line, " ");
+ while (tags) {
+ char *tag = strsep(&tags, ";");
+ char *key = strsep(&tag, "=");
+ for (size_t i = 0; i < TagCap; ++i) {
+ if (strcmp(key, TagNames[i])) continue;
+ unescape(tag);
+ msg.tags[i] = tag;
+ break;
+ }
+ }
+ }
+
+ if (line[0] == ':') {
+ char *origin = 1 + strsep(&line, " ");
+ msg.nick = strsep(&origin, "!");
+ msg.user = strsep(&origin, "@");
+ msg.host = origin;
+ }
+
+ msg.cmd = strsep(&line, " ");
+ for (size_t i = 0; line && i < ParamCap; ++i) {
+ if (line[0] == ':') {
+ msg.params[i] = &line[1];
+ break;
+ }
+ msg.params[i] = strsep(&line, " ");
+ }
+
+ return msg;
+}
+
+void ircRecv(void) {
+ static char buf[8191 + 512];
+ static size_t len = 0;
+
+ assert(client);
+ ssize_t ret = tls_read(client, &buf[len], sizeof(buf) - len);
+ if (ret == TLS_WANT_POLLIN || ret == TLS_WANT_POLLOUT) return;
+ if (ret < 0) errx(EX_IOERR, "tls_read: %s", tls_error(client));
+ if (!ret) errx(EX_PROTOCOL, "server closed connection");
+ len += ret;
+
+ char *crlf;
+ char *line = buf;
+ for (;;) {
+ crlf = memmem(line, &buf[len] - line, "\r\n", 2);
+ if (!crlf) break;
+ *crlf = '\0';
+ debug('>', line);
+ handle(parse(line));
+ line = crlf + 2;
+ }
+
+ len -= line - buf;
+ memmove(buf, line, len);
+}
diff --git a/ui.c b/ui.c
new file mode 100644
index 0000000..6c9606d
--- /dev/null
+++ b/ui.c
@@ -0,0 +1,974 @@
+/* Copyright (C) 2020 C. McEnroe
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#define _XOPEN_SOURCE_EXTENDED
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "chat.h"
+
+// Annoying stuff from :
+#undef lines
+#undef tab
+
+#ifndef A_ITALIC
+#define A_ITALIC A_NORMAL
+#endif
+
+#define BOTTOM (LINES - 1)
+#define RIGHT (COLS - 1)
+#define PAGE_LINES (LINES - 2)
+
+static WINDOW *status;
+static WINDOW *marker;
+static WINDOW *input;
+
+enum { BufferCap = 512 };
+struct Buffer {
+ time_t times[BufferCap];
+ char *lines[BufferCap];
+ size_t len;
+};
+static_assert(!(BufferCap & (BufferCap - 1)), "BufferCap is power of two");
+
+static void bufferPush(struct Buffer *buffer, time_t time, const char *line) {
+ size_t i = buffer->len++ % BufferCap;
+ free(buffer->lines[i]);
+ buffer->times[i] = time;
+ buffer->lines[i] = strdup(line);
+ if (!buffer->lines[i]) err(EX_OSERR, "strdup");
+}
+
+static time_t bufferTime(const struct Buffer *buffer, size_t i) {
+ return buffer->times[(buffer->len + i) % BufferCap];
+}
+static const char *bufferLine(const struct Buffer *buffer, size_t i) {
+ return buffer->lines[(buffer->len + i) % BufferCap];
+}
+
+enum { WindowLines = BufferCap };
+struct Window {
+ size_t id;
+ struct Buffer buffer;
+ WINDOW *pad;
+ int scroll;
+ bool mark;
+ enum Heat heat;
+ int unreadCount;
+ int unreadLines;
+ struct Window *prev;
+ struct Window *next;
+};
+
+static struct {
+ struct Window *active;
+ struct Window *other;
+ struct Window *head;
+ struct Window *tail;
+} windows;
+
+static void windowAdd(struct Window *window) {
+ if (windows.tail) windows.tail->next = window;
+ window->prev = windows.tail;
+ window->next = NULL;
+ windows.tail = window;
+ if (!windows.head) windows.head = window;
+}
+
+static void windowRemove(struct Window *window) {
+ if (window->prev) window->prev->next = window->next;
+ if (window->next) window->next->prev = window->prev;
+ if (windows.head == window) windows.head = window->next;
+ if (windows.tail == window) windows.tail = window->prev;
+}
+
+static struct Window *windowFor(size_t id) {
+ struct Window *window;
+ for (window = windows.head; window; window = window->next) {
+ if (window->id == id) return window;
+ }
+ window = calloc(1, sizeof(*window));
+ if (!window) err(EX_OSERR, "malloc");
+
+ window->id = id;
+ window->pad = newpad(WindowLines, COLS);
+ if (!window->pad) err(EX_OSERR, "newpad");
+ scrollok(window->pad, true);
+ wmove(window->pad, WindowLines - 1, 0);
+ window->mark = true;
+
+ windowAdd(window);
+ return window;
+}
+
+static short colorPairs;
+
+static void colorInit(void) {
+ start_color();
+ use_default_colors();
+ for (short pair = 0; pair < 16; ++pair) {
+ init_pair(1 + pair, pair % COLORS, -1);
+ }
+ colorPairs = 17;
+}
+
+static attr_t colorAttr(short fg) {
+ if (fg != COLOR_BLACK && fg % COLORS == COLOR_BLACK) return A_BOLD;
+ if (COLORS > 8) return A_NORMAL;
+ return (fg / COLORS & 1 ? A_BOLD : A_NORMAL);
+}
+
+static short colorPair(short fg, short bg) {
+ fg %= COLORS;
+ bg %= COLORS;
+ if (bg == -1 && fg < 16) return 1 + fg;
+ for (short pair = 17; pair < colorPairs; ++pair) {
+ short f, b;
+ pair_content(pair, &f, &b);
+ if (f == fg && b == bg) return pair;
+ }
+ init_pair(colorPairs, fg, bg);
+ return colorPairs++;
+}
+
+// XXX: Assuming terminals will be fine with these even if they're unsupported,
+// since they're "private" modes.
+static const char *EnterFocusMode = "\33[?1004h";
+static const char *ExitFocusMode = "\33[?1004l";
+static const char *EnterPasteMode = "\33[?2004h";
+static const char *ExitPasteMode = "\33[?2004l";
+
+// Gain use of C-q, C-s, C-c, C-z, C-y, C-o.
+static void acquireKeys(void) {
+ struct termios term;
+ int error = tcgetattr(STDOUT_FILENO, &term);
+ if (error) err(EX_OSERR, "tcgetattr");
+ term.c_iflag &= ~IXON;
+ term.c_cc[VINTR] = _POSIX_VDISABLE;
+ term.c_cc[VSUSP] = _POSIX_VDISABLE;
+ term.c_cc[VDSUSP] = _POSIX_VDISABLE;
+ term.c_cc[VDISCARD] = _POSIX_VDISABLE;
+ error = tcsetattr(STDOUT_FILENO, TCSADRAIN, &term);
+ if (error) err(EX_OSERR, "tcsetattr");
+}
+
+static void errExit(void) {
+ reset_shell_mode();
+}
+
+#define ENUM_KEY \
+ X(KeyMeta0, "\0330") \
+ X(KeyMeta1, "\0331") \
+ X(KeyMeta2, "\0332") \
+ X(KeyMeta3, "\0333") \
+ X(KeyMeta4, "\0334") \
+ X(KeyMeta5, "\0335") \
+ X(KeyMeta6, "\0336") \
+ X(KeyMeta7, "\0337") \
+ X(KeyMeta8, "\0338") \
+ X(KeyMeta9, "\0339") \
+ X(KeyMetaA, "\33a") \
+ X(KeyMetaB, "\33b") \
+ X(KeyMetaD, "\33d") \
+ X(KeyMetaF, "\33f") \
+ X(KeyMetaL, "\33l") \
+ X(KeyMetaM, "\33m") \
+ X(KeyMetaU, "\33u") \
+ X(KeyMetaSlash, "\33/") \
+ X(KeyFocusIn, "\33[I") \
+ X(KeyFocusOut, "\33[O") \
+ X(KeyPasteOn, "\33[200~") \
+ X(KeyPasteOff, "\33[201~")
+
+enum {
+ KeyMax = KEY_MAX,
+#define X(id, seq) id,
+ ENUM_KEY
+#undef X
+};
+
+void uiInit(void) {
+ initscr();
+ cbreak();
+ noecho();
+ acquireKeys();
+ def_prog_mode();
+ atexit(errExit);
+ colorInit();
+
+ if (!to_status_line && !strncmp(termname(), "xterm", 5)) {
+ to_status_line = "\33]2;";
+ from_status_line = "\7";
+ }
+
+#define X(id, seq) define_key(seq, id);
+ ENUM_KEY
+#undef X
+
+ status = newwin(1, COLS, 0, 0);
+ if (!status) err(EX_OSERR, "newwin");
+
+ marker = newwin(1, COLS, LINES - 2, 0);
+ short fg = 8 + COLOR_BLACK;
+ wbkgd(marker, '~' | colorAttr(fg) | COLOR_PAIR(colorPair(fg, -1)));
+
+ input = newpad(1, 512);
+ if (!input) err(EX_OSERR, "newpad");
+ keypad(input, true);
+ nodelay(input, true);
+
+ windows.active = windowFor(Network);
+ uiShow();
+}
+
+static bool hidden;
+static bool waiting;
+
+static char title[256];
+static char prevTitle[sizeof(title)];
+
+void uiDraw(void) {
+ if (hidden) return;
+ wnoutrefresh(status);
+ struct Window *window = windows.active;
+ pnoutrefresh(
+ window->pad,
+ WindowLines - window->scroll - PAGE_LINES + !!window->scroll, 0,
+ 1, 0,
+ BOTTOM - 1 - !!window->scroll, RIGHT
+ );
+ if (window->scroll) {
+ touchwin(marker);
+ wnoutrefresh(marker);
+ }
+ int y, x;
+ getyx(input, y, x);
+ pnoutrefresh(
+ input,
+ 0, (x + 1 > RIGHT ? x + 1 - RIGHT : 0),
+ BOTTOM, 0,
+ BOTTOM, RIGHT
+ );
+ doupdate();
+
+ if (!to_status_line) return;
+ if (!strcmp(title, prevTitle)) return;
+ strcpy(prevTitle, title);
+ putp(to_status_line);
+ putp(title);
+ putp(from_status_line);
+ fflush(stdout);
+}
+
+void uiShow(void) {
+ prevTitle[0] = '\0';
+ putp(EnterFocusMode);
+ putp(EnterPasteMode);
+ fflush(stdout);
+ hidden = false;
+}
+
+void uiHide(void) {
+ hidden = true;
+ putp(ExitFocusMode);
+ putp(ExitPasteMode);
+ endwin();
+}
+
+struct Style {
+ attr_t attr;
+ enum Color fg, bg;
+};
+static const struct Style Reset = { A_NORMAL, Default, Default };
+
+static const short Colors[100] = {
+ [Default] = -1,
+ [White] = 8 + COLOR_WHITE,
+ [Black] = 0 + COLOR_BLACK,
+ [Blue] = 0 + COLOR_BLUE,
+ [Green] = 0 + COLOR_GREEN,
+ [Red] = 8 + COLOR_RED,
+ [Brown] = 0 + COLOR_RED,
+ [Magenta] = 0 + COLOR_MAGENTA,
+ [Orange] = 0 + COLOR_YELLOW,
+ [Yellow] = 8 + COLOR_YELLOW,
+ [LightGreen] = 8 + COLOR_GREEN,
+ [Cyan] = 0 + COLOR_CYAN,
+ [LightCyan] = 8 + COLOR_CYAN,
+ [LightBlue] = 8 + COLOR_BLUE,
+ [Pink] = 8 + COLOR_MAGENTA,
+ [Gray] = 8 + COLOR_BLACK,
+ [LightGray] = 0 + COLOR_WHITE,
+ 52, 94, 100, 58, 22, 29, 23, 24, 17, 54, 53, 89,
+ 88, 130, 142, 64, 28, 35, 30, 25, 18, 91, 90, 125,
+ 124, 166, 184, 106, 34, 49, 37, 33, 19, 129, 127, 161,
+ 196, 208, 226, 154, 46, 86, 51, 75, 21, 171, 201, 198,
+ 203, 215, 227, 191, 83, 122, 87, 111, 63, 177, 207, 205,
+ 217, 223, 229, 193, 157, 158, 159, 153, 147, 183, 219, 212,
+ 16, 233, 235, 237, 239, 241, 244, 247, 250, 254, 231,
+};
+
+enum { B = '\2', C = '\3', O = '\17', R = '\26', I = '\35', U = '\37' };
+
+static void styleParse(struct Style *style, const char **str, size_t *len) {
+ switch (**str) {
+ break; case B: (*str)++; style->attr ^= A_BOLD;
+ break; case O: (*str)++; *style = Reset;
+ break; case R: (*str)++; style->attr ^= A_REVERSE;
+ break; case I: (*str)++; style->attr ^= A_ITALIC;
+ break; case U: (*str)++; style->attr ^= A_UNDERLINE;
+ break; case C: {
+ (*str)++;
+ if (!isdigit(**str)) {
+ style->fg = Default;
+ style->bg = Default;
+ break;
+ }
+ style->fg = *(*str)++ - '0';
+ if (isdigit(**str)) style->fg = style->fg * 10 + *(*str)++ - '0';
+ if ((*str)[0] != ',' || !isdigit((*str)[1])) break;
+ (*str)++;
+ style->bg = *(*str)++ - '0';
+ if (isdigit(**str)) style->bg = style->bg * 10 + *(*str)++ - '0';
+ }
+ }
+ *len = strcspn(*str, (const char[]) { B, C, O, R, I, U, '\0' });
+}
+
+static void statusAdd(const char *str) {
+ size_t len;
+ struct Style style = Reset;
+ while (*str) {
+ styleParse(&style, &str, &len);
+ wattr_set(
+ status,
+ style.attr | colorAttr(Colors[style.fg]),
+ colorPair(Colors[style.fg], Colors[style.bg]),
+ NULL
+ );
+ waddnstr(status, str, len);
+ str += len;
+ }
+}
+
+static void statusUpdate(void) {
+ int otherUnread = 0;
+ enum Heat otherHeat = Cold;
+ wmove(status, 0, 0);
+
+ int num;
+ const struct Window *window;
+ for (num = 0, window = windows.head; window; ++num, window = window->next) {
+ if (!window->heat && window != windows.active) continue;
+ if (window != windows.active) {
+ otherUnread += window->unreadCount;
+ if (window->heat > otherHeat) otherHeat = window->heat;
+ }
+ int trunc;
+ char buf[256];
+ snprintf(
+ buf, sizeof(buf), "\3%d%s %d %s %n(\3%02d%d\3%d) ",
+ idColors[window->id], (window == windows.active ? "\26" : ""),
+ num, idNames[window->id],
+ &trunc, (window->heat > Warm ? White : idColors[window->id]),
+ window->unreadCount,
+ idColors[window->id]
+ );
+ if (!window->mark || !window->unreadCount) buf[trunc] = '\0';
+ statusAdd(buf);
+ }
+ wclrtoeol(status);
+
+ window = windows.active;
+ snprintf(title, sizeof(title), "%s %s", self.network, idNames[window->id]);
+ if (window->mark && window->unreadCount) {
+ snprintf(
+ &title[strlen(title)], sizeof(title) - strlen(title),
+ " (%d%s)", window->unreadCount, (window->heat > Warm ? "!" : "")
+ );
+ }
+ if (otherUnread) {
+ snprintf(
+ &title[strlen(title)], sizeof(title) - strlen(title),
+ " (+%d%s)", otherUnread, (otherHeat > Warm ? "!" : "")
+ );
+ }
+}
+
+static void mark(struct Window *window) {
+ if (window->scroll) return;
+ window->mark = true;
+ window->unreadCount = 0;
+ window->unreadLines = 0;
+}
+
+static void unmark(struct Window *window) {
+ if (!window->scroll) {
+ window->mark = false;
+ window->heat = Cold;
+ }
+ statusUpdate();
+}
+
+static void windowScroll(struct Window *window, int n) {
+ mark(window);
+ window->scroll += n;
+ if (window->scroll > WindowLines - PAGE_LINES) {
+ window->scroll = WindowLines - PAGE_LINES;
+ }
+ if (window->scroll < 0) window->scroll = 0;
+ unmark(window);
+}
+
+static void windowScrollUnread(struct Window *window) {
+ window->scroll = 0;
+ windowScroll(window, window->unreadLines - PAGE_LINES + 1);
+}
+
+static int wordWidth(const char *str) {
+ size_t len = strcspn(str, " ");
+ int width = 0;
+ while (len) {
+ wchar_t wc;
+ int n = mbtowc(&wc, str, len);
+ if (n < 1) return width + len;
+ width += (iswprint(wc) ? wcwidth(wc) : 0);
+ str += n;
+ len -= n;
+ }
+ return width;
+}
+
+static int wordWrap(WINDOW *win, const char *str) {
+ int y, x, width;
+ getmaxyx(win, y, width);
+
+ size_t len;
+ int lines = 0;
+ int align = 0;
+ struct Style style = Reset;
+ while (*str) {
+ if (*str == '\t') {
+ if (align) {
+ waddch(win, '\t');
+ str++;
+ } else {
+ waddch(win, ' ');
+ getyx(win, y, align);
+ str++;
+ }
+ } else if (*str == ' ') {
+ getyx(win, y, x);
+ const char *word = &str[strspn(str, " ")];
+ if (width - x - 1 <= wordWidth(word)) {
+ lines += 1 + (align + wordWidth(word)) / width;
+ waddch(win, '\n');
+ getyx(win, y, x);
+ wmove(win, y, align);
+ str = word;
+ } else {
+ waddch(win, ' ');
+ str++;
+ }
+ }
+
+ styleParse(&style, &str, &len);
+ size_t ws = strcspn(str, "\t ");
+ if (ws < len) len = ws;
+
+ wattr_set(
+ win,
+ style.attr | colorAttr(Colors[style.fg]),
+ colorPair(Colors[style.fg], Colors[style.bg]),
+ NULL
+ );
+ waddnstr(win, str, len);
+ str += len;
+ }
+ return lines;
+}
+
+void uiWrite(size_t id, enum Heat heat, const time_t *src, const char *str) {
+ struct Window *window = windowFor(id);
+ time_t clock = (src ? *src : time(NULL));
+ bufferPush(&window->buffer, clock, str);
+
+ int lines = 1;
+ waddch(window->pad, '\n');
+ if (window->mark && heat > Cold) {
+ if (!window->unreadCount++) {
+ lines++;
+ waddch(window->pad, '\n');
+ }
+ if (window->heat < heat) window->heat = heat;
+ statusUpdate();
+ }
+ lines += wordWrap(window->pad, str);
+ window->unreadLines += lines;
+ if (window->scroll) windowScroll(window, lines);
+ if (heat > Warm) beep();
+}
+
+void uiFormat(
+ size_t id, enum Heat heat, const time_t *time, const char *format, ...
+) {
+ char buf[1024];
+ va_list ap;
+ va_start(ap, format);
+ int len = vsnprintf(buf, sizeof(buf), format, ap);
+ va_end(ap);
+ assert((size_t)len < sizeof(buf));
+ uiWrite(id, heat, time, buf);
+}
+
+static void reflow(struct Window *window) {
+ werase(window->pad);
+ wmove(window->pad, WindowLines - 1, 0);
+ window->unreadLines = 0;
+ for (size_t i = 0; i < BufferCap; ++i) {
+ const char *line = bufferLine(&window->buffer, i);
+ if (!line) continue;
+ waddch(window->pad, '\n');
+ if (i >= (size_t)(BufferCap - window->unreadCount)) {
+ window->unreadLines += 1 + wordWrap(window->pad, line);
+ } else {
+ wordWrap(window->pad, line);
+ }
+ }
+}
+
+static void resize(void) {
+ mvwin(marker, LINES - 2, 0);
+ int height, width;
+ getmaxyx(windows.active->pad, height, width);
+ if (width == COLS) return;
+ for (struct Window *window = windows.head; window; window = window->next) {
+ wresize(window->pad, BufferCap, COLS);
+ reflow(window);
+ }
+ statusUpdate();
+}
+
+static void bufferList(const struct Buffer *buffer) {
+ uiHide();
+ waiting = true;
+ for (size_t i = 0; i < BufferCap; ++i) {
+ time_t time = bufferTime(buffer, i);
+ const char *line = bufferLine(buffer, i);
+ if (!line) continue;
+
+ struct tm *tm = localtime(&time);
+ if (!tm) continue;
+ char buf[sizeof("[00:00:00]")];
+ strftime(buf, sizeof(buf), "[%T]", tm);
+ vid_attr(colorAttr(Colors[Gray]), colorPair(Colors[Gray], -1), NULL);
+ printf("%s ", buf);
+
+ size_t len;
+ bool align = false;
+ struct Style style = Reset;
+ while (*line) {
+ if (*line == '\t') {
+ printf("%c", (align ? '\t' : ' '));
+ align = true;
+ line++;
+ }
+ styleParse(&style, &line, &len);
+ size_t tab = strcspn(line, "\t");
+ if (tab < len) len = tab;
+ vid_attr(
+ style.attr | colorAttr(Colors[style.fg]),
+ colorPair(Colors[style.fg], Colors[style.bg]),
+ NULL
+ );
+ if (len) printf("%.*s", (int)len, line);
+ line += len;
+ }
+ printf("\n");
+ }
+}
+
+static void inputAdd(struct Style *style, const char *str) {
+ size_t len;
+ while (*str) {
+ const char *code = str;
+ styleParse(style, &str, &len);
+ wattr_set(input, A_BOLD | A_REVERSE, 0, NULL);
+ switch (*code) {
+ break; case B: waddch(input, 'B');
+ break; case C: waddch(input, 'C');
+ break; case O: waddch(input, 'O');
+ break; case R: waddch(input, 'R');
+ break; case I: waddch(input, 'I');
+ break; case U: waddch(input, 'U');
+ }
+ if (str - code > 1) waddnstr(input, &code[1], str - &code[1]);
+ wattr_set(
+ input,
+ style->attr | colorAttr(Colors[style->fg]),
+ colorPair(Colors[style->fg], Colors[style->bg]),
+ NULL
+ );
+ waddnstr(input, str, len);
+ str += len;
+ }
+}
+
+static void inputUpdate(void) {
+ size_t id = windows.active->id;
+ size_t pos;
+ char *buf = editBuffer(&pos);
+
+ const char *skip = NULL;
+ struct Style init = { .fg = self.color, .bg = Default };
+ struct Style rest = Reset;
+ const char *prefix = "";
+ const char *prompt = (self.nick ? self.nick : "");
+ const char *suffix = "";
+ if (NULL != (skip = commandIsPrivmsg(id, buf))) {
+ prefix = "<"; suffix = "> ";
+ } else if (NULL != (skip = commandIsNotice(id, buf))) {
+ prefix = "-"; suffix = "- ";
+ rest.fg = LightGray;
+ } else if (NULL != (skip = commandIsAction(id, buf))) {
+ init.attr |= A_ITALIC;
+ prefix = "* "; suffix = " ";
+ rest.attr |= A_ITALIC;
+ } else if (id == Debug) {
+ skip = buf;
+ init.fg = Gray;
+ prompt = "<< ";
+ } else {
+ prompt = "";
+ }
+ if (skip && skip > &buf[pos]) {
+ skip = NULL;
+ prefix = prompt = suffix = "";
+ }
+
+ int y, x;
+ wmove(input, 0, 0);
+ wattr_set(
+ input,
+ init.attr | colorAttr(Colors[init.fg]),
+ colorPair(Colors[init.fg], Colors[init.bg]),
+ NULL
+ );
+ waddstr(input, prefix);
+ waddstr(input, prompt);
+ waddstr(input, suffix);
+ struct Style style = rest;
+ char p = buf[pos];
+ buf[pos] = '\0';
+ inputAdd(&style, (skip ? skip : buf));
+ getyx(input, y, x);
+ buf[pos] = p;
+ inputAdd(&style, &buf[pos]);
+ wclrtoeol(input);
+ wmove(input, y, x);
+}
+
+static void windowShow(struct Window *window) {
+ if (!window) return;
+ touchwin(window->pad);
+ windows.other = windows.active;
+ windows.active = window;
+ mark(windows.other);
+ unmark(windows.active);
+ inputUpdate();
+}
+
+void uiShowID(size_t id) {
+ windowShow(windowFor(id));
+}
+
+void uiShowNum(size_t num) {
+ struct Window *window = windows.head;
+ for (size_t i = 0; i < num; ++i) {
+ window = window->next;
+ if (!window) return;
+ }
+ windowShow(window);
+}
+
+static void windowClose(struct Window *window) {
+ if (window->id == Network) return;
+ if (windows.active == window) {
+ if (windows.other && windows.other != window) {
+ windowShow(windows.other);
+ } else {
+ windowShow(window->prev ? window->prev : window->next);
+ }
+ }
+ if (windows.other == window) windows.other = NULL;
+ windowRemove(window);
+ for (size_t i = 0; i < BufferCap; ++i) {
+ free(window->buffer.lines[i]);
+ }
+ delwin(window->pad);
+ free(window);
+ statusUpdate();
+}
+
+void uiCloseID(size_t id) {
+ windowClose(windowFor(id));
+}
+
+void uiCloseNum(size_t num) {
+ struct Window *window = windows.head;
+ for (size_t i = 0; i < num; ++i) {
+ window = window->next;
+ if (!window) return;
+ }
+ windowClose(window);
+}
+
+static void showAuto(void) {
+ static struct Window *other;
+ if (windows.other != other) {
+ other = windows.active;
+ }
+ for (struct Window *window = windows.head; window; window = window->next) {
+ if (window->heat < Hot) continue;
+ windowShow(window);
+ windows.other = other;
+ return;
+ }
+ for (struct Window *window = windows.head; window; window = window->next) {
+ if (window->heat < Warm) continue;
+ windowShow(window);
+ windows.other = other;
+ return;
+ }
+ windowShow(windows.other);
+}
+
+static void keyCode(int code) {
+ struct Window *window = windows.active;
+ size_t id = window->id;
+ switch (code) {
+ break; case KEY_RESIZE: resize();
+ break; case KeyFocusIn: unmark(window);
+ break; case KeyFocusOut: mark(window);
+ break; case KeyPasteOn:; // TODO
+ break; case KeyPasteOff:; // TODO
+
+ break; case KeyMetaSlash: windowShow(windows.other);
+
+ break; case KeyMetaA: showAuto();
+ break; case KeyMetaB: edit(id, EditPrevWord, 0);
+ break; case KeyMetaD: edit(id, EditDeleteNextWord, 0);
+ break; case KeyMetaF: edit(id, EditNextWord, 0);
+ break; case KeyMetaL: bufferList(&window->buffer);
+ break; case KeyMetaM: waddch(window->pad, '\n');
+ break; case KeyMetaU: windowScrollUnread(window);
+
+ break; case KEY_BACKSPACE: edit(id, EditDeletePrev, 0);
+ break; case KEY_DC: edit(id, EditDeleteNext, 0);
+ break; case KEY_DOWN: windowScroll(window, -1);
+ break; case KEY_END: edit(id, EditTail, 0);
+ break; case KEY_ENTER: edit(id, EditEnter, 0);
+ break; case KEY_HOME: edit(id, EditHead, 0);
+ break; case KEY_LEFT: edit(id, EditPrev, 0);
+ break; case KEY_NPAGE: windowScroll(window, -(PAGE_LINES - 2));
+ break; case KEY_PPAGE: windowScroll(window, +(PAGE_LINES - 2));
+ break; case KEY_RIGHT: edit(id, EditNext, 0);
+ break; case KEY_UP: windowScroll(window, +1);
+
+ break; default: {
+ if (code >= KeyMeta0 && code <= KeyMeta9) {
+ uiShowNum(code - KeyMeta0);
+ }
+ }
+ }
+}
+
+static void keyCtrl(wchar_t ch) {
+ size_t id = windows.active->id;
+ switch (ch ^ L'@') {
+ break; case L'?': edit(id, EditDeletePrev, 0);
+ break; case L'A': edit(id, EditHead, 0);
+ break; case L'B': edit(id, EditPrev, 0);
+ break; case L'C': raise(SIGINT);
+ break; case L'D': edit(id, EditDeleteNext, 0);
+ break; case L'E': edit(id, EditTail, 0);
+ break; case L'F': edit(id, EditNext, 0);
+ break; case L'H': edit(id, EditDeletePrev, 0);
+ break; case L'I': edit(id, EditComplete, 0);
+ break; case L'J': edit(id, EditEnter, 0);
+ break; case L'K': edit(id, EditDeleteTail, 0);
+ break; case L'L': clearok(curscr, true);
+ break; case L'N': windowShow(windows.active->next);
+ break; case L'O': windowShow(windows.other);
+ break; case L'P': windowShow(windows.active->prev);
+ break; case L'U': edit(id, EditDeleteHead, 0);
+ break; case L'W': edit(id, EditDeletePrevWord, 0);
+ break; case L'Y': edit(id, EditPaste, 0);
+ }
+}
+
+static void keyStyle(wchar_t ch) {
+ size_t id = windows.active->id;
+ switch (iswcntrl(ch) ? ch ^ L'@' : towupper(ch)) {
+ break; case L'B': edit(id, EditInsert, B);
+ break; case L'C': edit(id, EditInsert, C);
+ break; case L'I': edit(id, EditInsert, I);
+ break; case L'O': edit(id, EditInsert, O);
+ break; case L'R': edit(id, EditInsert, R);
+ break; case L'U': edit(id, EditInsert, U);
+ }
+}
+
+void uiRead(void) {
+ if (hidden) {
+ if (waiting) {
+ uiShow();
+ flushinp();
+ waiting = false;
+ } else {
+ return;
+ }
+ }
+
+ int ret;
+ wint_t ch;
+ static bool style;
+ while (ERR != (ret = wget_wch(input, &ch))) {
+ if (ret == KEY_CODE_YES) {
+ keyCode(ch);
+ } else if (ch == (L'Z' ^ L'@')) {
+ style = true;
+ continue;
+ } else if (style) {
+ keyStyle(ch);
+ } else if (iswcntrl(ch)) {
+ keyCtrl(ch);
+ } else {
+ edit(windows.active->id, EditInsert, ch);
+ }
+ style = false;
+ }
+ inputUpdate();
+}
+
+static const size_t Signatures[] = {
+ 0x6C72696774616301,
+};
+
+static size_t signatureVersion(size_t signature) {
+ for (size_t i = 0; i < ARRAY_LEN(Signatures); ++i) {
+ if (signature == Signatures[i]) return i;
+ }
+ err(EX_DATAERR, "unknown file signature %zX", signature);
+}
+
+static int writeSize(FILE *file, size_t value) {
+ return (fwrite(&value, sizeof(value), 1, file) ? 0 : -1);
+}
+static int writeTime(FILE *file, time_t time) {
+ return (fwrite(&time, sizeof(time), 1, file) ? 0 : -1);
+}
+static int writeString(FILE *file, const char *str) {
+ return (fwrite(str, strlen(str) + 1, 1, file) ? 0 : -1);
+}
+
+int uiSave(const char *name) {
+ FILE *file = dataOpen(name, "w");
+ if (!file) return -1;
+
+ if (writeSize(file, Signatures[0])) return -1;
+ const struct Window *window;
+ for (window = windows.head; window; window = window->next) {
+ if (writeString(file, idNames[window->id])) return -1;
+ for (size_t i = 0; i < BufferCap; ++i) {
+ time_t time = bufferTime(&window->buffer, i);
+ const char *line = bufferLine(&window->buffer, i);
+ if (!line) continue;
+ if (writeTime(file, time)) return -1;
+ if (writeString(file, line)) return -1;
+ }
+ if (writeTime(file, 0)) return -1;
+ }
+ return fclose(file);
+}
+
+static size_t readSize(FILE *file) {
+ size_t value;
+ fread(&value, sizeof(value), 1, file);
+ if (ferror(file)) err(EX_IOERR, "fread");
+ if (feof(file)) errx(EX_DATAERR, "unexpected eof");
+ return value;
+}
+static time_t readTime(FILE *file) {
+ time_t time;
+ fread(&time, sizeof(time), 1, file);
+ if (ferror(file)) err(EX_IOERR, "fread");
+ if (feof(file)) errx(EX_DATAERR, "unexpected eof");
+ return time;
+}
+static ssize_t readString(FILE *file, char **buf, size_t *cap) {
+ ssize_t len = getdelim(buf, cap, '\0', file);
+ if (len < 0 && !feof(file)) err(EX_IOERR, "getdelim");
+ return len;
+}
+
+void uiLoad(const char *name) {
+ FILE *file = dataOpen(name, "r");
+ if (!file) {
+ if (errno != ENOENT) exit(EX_NOINPUT);
+ file = dataOpen(name, "w");
+ if (!file) exit(EX_CANTCREAT);
+ fclose(file);
+ return;
+ }
+
+ size_t signature = readSize(file);
+ signatureVersion(signature);
+
+ char *buf = NULL;
+ size_t cap = 0;
+ while (0 < readString(file, &buf, &cap)) {
+ struct Window *window = windowFor(idFor(buf));
+ for (;;) {
+ time_t time = readTime(file);
+ if (!time) break;
+ readString(file, &buf, &cap);
+ bufferPush(&window->buffer, time, buf);
+ }
+ reflow(window);
+ waddch(window->pad, '\n');
+ }
+
+ free(buf);
+ fclose(file);
+}
diff --git a/url.c b/url.c
new file mode 100644
index 0000000..1ccc206
--- /dev/null
+++ b/url.c
@@ -0,0 +1,202 @@
+/* Copyright (C) 2020 C. McEnroe
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "chat.h"
+
+static const char *Pattern = {
+ "("
+ "cvs|"
+ "ftp|"
+ "git|"
+ "gopher|"
+ "http|"
+ "https|"
+ "irc|"
+ "ircs|"
+ "magnet|"
+ "sftp|"
+ "ssh|"
+ "svn|"
+ "telnet|"
+ "vnc"
+ ")"
+ ":([^[:space:]>\"()]|[(][^)]*[)])+"
+};
+static regex_t Regex;
+
+static void compile(void) {
+ static bool compiled;
+ if (compiled) return;
+ compiled = true;
+ int error = regcomp(&Regex, Pattern, REG_EXTENDED);
+ if (!error) return;
+ char buf[256];
+ regerror(error, &Regex, buf, sizeof(buf));
+ errx(EX_SOFTWARE, "regcomp: %s: %s", buf, Pattern);
+}
+
+struct URL {
+ size_t id;
+ char *nick;
+ char *url;
+};
+
+enum { Cap = 32 };
+static struct {
+ struct URL urls[Cap];
+ size_t len;
+} ring;
+static_assert(!(Cap & (Cap - 1)), "Cap is power of two");
+
+static void push(size_t id, const char *nick, const char *str, size_t len) {
+ struct URL *url = &ring.urls[ring.len++ % Cap];
+ free(url->nick);
+ free(url->url);
+ url->id = id;
+ url->nick = NULL;
+ if (nick) {
+ url->nick = strdup(nick);
+ if (!url->nick) err(EX_OSERR, "strdup");
+ }
+ url->url = strndup(str, len);
+ if (!url->url) err(EX_OSERR, "strndup");
+}
+
+void urlScan(size_t id, const char *nick, const char *mesg) {
+ if (!mesg) return;
+ compile();
+ regmatch_t match = {0};
+ for (const char *ptr = mesg; *ptr; ptr += match.rm_eo) {
+ if (regexec(&Regex, ptr, 1, &match, 0)) break;
+ push(id, nick, &ptr[match.rm_so], match.rm_eo - match.rm_so);
+ }
+}
+
+const char *urlOpenUtil;
+static const char *OpenUtils[] = { "open", "xdg-open" };
+
+static void urlOpen(const char *url) {
+ pid_t pid = fork();
+ if (pid < 0) err(EX_OSERR, "fork");
+ if (pid) return;
+
+ close(STDIN_FILENO);
+ dup2(procPipe[1], STDOUT_FILENO);
+ dup2(procPipe[1], STDERR_FILENO);
+ if (urlOpenUtil) {
+ execlp(urlOpenUtil, urlOpenUtil, url, NULL);
+ warn("%s", urlOpenUtil);
+ _exit(EX_CONFIG);
+ }
+ for (size_t i = 0; i < ARRAY_LEN(OpenUtils); ++i) {
+ execlp(OpenUtils[i], OpenUtils[i], url, NULL);
+ if (errno != ENOENT) {
+ warn("%s", OpenUtils[i]);
+ _exit(EX_CONFIG);
+ }
+ }
+ warnx("no open utility found");
+ _exit(EX_CONFIG);
+}
+
+const char *urlCopyUtil;
+static const char *CopyUtils[] = { "pbcopy", "wl-copy", "xclip", "xsel" };
+
+static void urlCopy(const char *url) {
+ int rw[2];
+ int error = pipe(rw);
+ if (error) err(EX_OSERR, "pipe");
+
+ ssize_t len = write(rw[1], url, strlen(url));
+ if (len < 0) err(EX_IOERR, "write");
+
+ error = close(rw[1]);
+ if (error) err(EX_IOERR, "close");
+
+ pid_t pid = fork();
+ if (pid < 0) err(EX_OSERR, "fork");
+ if (pid) {
+ close(rw[0]);
+ return;
+ }
+
+ dup2(rw[0], STDIN_FILENO);
+ dup2(procPipe[1], STDOUT_FILENO);
+ dup2(procPipe[1], STDERR_FILENO);
+ close(rw[0]);
+ if (urlCopyUtil) {
+ execlp(urlCopyUtil, urlCopyUtil, NULL);
+ warn("%s", urlCopyUtil);
+ _exit(EX_CONFIG);
+ }
+ for (size_t i = 0; i < ARRAY_LEN(CopyUtils); ++i) {
+ execlp(CopyUtils[i], CopyUtils[i], NULL);
+ if (errno != ENOENT) {
+ warn("%s", CopyUtils[i]);
+ _exit(EX_CONFIG);
+ }
+ }
+ warnx("no copy utility found");
+ _exit(EX_CONFIG);
+}
+
+void urlOpenCount(size_t id, size_t count) {
+ for (size_t i = 1; i <= Cap; ++i) {
+ const struct URL *url = &ring.urls[(ring.len - i) % Cap];
+ if (!url->url) break;
+ if (url->id != id) continue;
+ urlOpen(url->url);
+ if (!--count) break;
+ }
+}
+
+void urlOpenMatch(size_t id, const char *str) {
+ for (size_t i = 1; i <= Cap; ++i) {
+ const struct URL *url = &ring.urls[(ring.len - i) % Cap];
+ if (!url->url) break;
+ if (url->id != id) continue;
+ if ((url->nick && !strcmp(url->nick, str)) || strstr(url->url, str)) {
+ urlOpen(url->url);
+ break;
+ }
+ }
+}
+
+void urlCopyMatch(size_t id, const char *str) {
+ for (size_t i = 1; i <= Cap; ++i) {
+ const struct URL *url = &ring.urls[(ring.len - i) % Cap];
+ if (!url->url) break;
+ if (url->id != id) continue;
+ if (
+ !str
+ || (url->nick && !strcmp(url->nick, str))
+ || strstr(url->url, str)
+ ) {
+ urlCopy(url->url);
+ break;
+ }
+ }
+}
diff --git a/xdg.c b/xdg.c
new file mode 100644
index 0000000..6e33210
--- /dev/null
+++ b/xdg.c
@@ -0,0 +1,134 @@
+/* Copyright (C) 2019, 2020 C. McEnroe
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "chat.h"
+
+FILE *configOpen(const char *path, const char *mode) {
+ if (path[0] == '/' || path[0] == '.') goto local;
+
+ const char *home = getenv("HOME");
+ const char *configHome = getenv("XDG_CONFIG_HOME");
+ const char *configDirs = getenv("XDG_CONFIG_DIRS");
+
+ char buf[PATH_MAX];
+ if (configHome) {
+ snprintf(buf, sizeof(buf), "%s/" XDG_SUBDIR "/%s", configHome, path);
+ } else {
+ if (!home) goto local;
+ snprintf(buf, sizeof(buf), "%s/.config/" XDG_SUBDIR "/%s", home, path);
+ }
+ FILE *file = fopen(buf, mode);
+ if (file) return file;
+ if (errno != ENOENT) {
+ warn("%s", buf);
+ return NULL;
+ }
+
+ if (!configDirs) configDirs = "/etc/xdg";
+ while (*configDirs) {
+ size_t len = strcspn(configDirs, ":");
+ snprintf(
+ buf, sizeof(buf), "%.*s/" XDG_SUBDIR "/%s",
+ (int)len, configDirs, path
+ );
+ file = fopen(buf, mode);
+ if (file) return file;
+ if (errno != ENOENT) {
+ warn("%s", buf);
+ return NULL;
+ }
+ configDirs += len;
+ if (*configDirs) configDirs++;
+ }
+
+local:
+ file = fopen(path, mode);
+ if (!file) warn("%s", path);
+ return file;
+}
+
+FILE *dataOpen(const char *path, const char *mode) {
+ if (path[0] == '/' || path[0] == '.') goto local;
+
+ const char *home = getenv("HOME");
+ const char *dataHome = getenv("XDG_DATA_HOME");
+ const char *dataDirs = getenv("XDG_DATA_DIRS");
+
+ char homePath[PATH_MAX];
+ if (dataHome) {
+ snprintf(
+ homePath, sizeof(homePath),
+ "%s/" XDG_SUBDIR "/%s", dataHome, path
+ );
+ } else {
+ if (!home) goto local;
+ snprintf(
+ homePath, sizeof(homePath),
+ "%s/.local/share/" XDG_SUBDIR "/%s", home, path
+ );
+ }
+ FILE *file = fopen(homePath, mode);
+ if (file) return file;
+ if (errno != ENOENT) {
+ warn("%s", homePath);
+ return NULL;
+ }
+
+ char buf[PATH_MAX];
+ if (!dataDirs) dataDirs = "/usr/local/share:/usr/share";
+ while (*dataDirs) {
+ size_t len = strcspn(dataDirs, ":");
+ snprintf(
+ buf, sizeof(buf), "%.*s/" XDG_SUBDIR "/%s",
+ (int)len, dataDirs, path
+ );
+ file = fopen(buf, mode);
+ if (file) return file;
+ if (errno != ENOENT) {
+ warn("%s", buf);
+ return NULL;
+ }
+ dataDirs += len;
+ if (*dataDirs) dataDirs++;
+ }
+
+ if (mode[0] != 'r') {
+ char *base = strrchr(homePath, '/');
+ *base = '\0';
+ int error = mkdir(homePath, S_IRWXU);
+ if (error && errno != EEXIST) {
+ warn("%s", homePath);
+ return NULL;
+ }
+ *base = '/';
+ file = fopen(homePath, mode);
+ if (!file) warn("%s", homePath);
+ return file;
+ }
+
+local:
+ file = fopen(path, mode);
+ if (!file) warn("%s", path);
+ return file;
+}