16 Commits

Author SHA1 Message Date
73cc8f448a Bump version, update NEWS
All checks were successful
Alpine 3.20 Success
Arch Linux AUR Success
OpenBSD 7.5 Success
2024-07-28 07:29:15 +02:00
4565afe294 xC: expand a comment 2024-07-28 07:15:41 +02:00
3ad8c79de8 xC: handle multiline server commands properly
All checks were successful
Alpine 3.20 Success
Arch Linux AUR Success
OpenBSD 7.5 Success
Additional lines might have been passed to the server intact
as part of an argument, but we have /quote for that.
2024-07-28 04:10:30 +02:00
12fc3c228a xP: reset highlight state once reaching buffer end
All checks were successful
Alpine 3.20 Success
Arch Linux AUR Success
OpenBSD 7.5 Success
2024-07-28 03:44:37 +02:00
175533a5e9 xP: don't interrupt IME composition
All checks were successful
Alpine 3.20 Success
Arch Linux AUR Success
OpenBSD 7.5 Success
On Vivaldi/macOS, pressing Enter would send the input and still keep
editing it as it was.
2024-07-04 20:06:59 +02:00
a9b46141a9 xS/xN: add test targets
All checks were successful
Alpine 3.19 Success
Arch Linux AUR Success
OpenBSD 7.3 Success
2024-04-10 13:59:33 +02:00
c38cca3b92 Bump liberty
All checks were successful
Arch Linux AUR Success
Alpine 3.19 Success
2024-04-09 17:08:40 +02:00
aee7540faa Update README.adoc and xN usage output 2024-04-04 21:25:17 +02:00
53ba996ec9 Add a simple IRC notifier utility
All checks were successful
Arch Linux AUR Success
2024-04-03 15:56:33 +02:00
d450c6cc5f xP: do not send the Referrer header 2024-03-04 16:15:22 +01:00
f8ea1634c4 Bump liberty 2024-03-04 16:15:22 +01:00
ef257cd575 xP: avoid expensive updates/refreshes 2024-01-06 23:44:11 +01:00
69eccc7065 xP: don't let buffers grow indefinitely
Primarily for performance reasons.
2024-01-06 21:17:18 +01:00
13d2ff115b xM: improve the bundle icon a bit 2023-09-04 07:06:03 +02:00
9e4692bb09 xM: generate and use a bundle icon 2023-09-03 02:13:14 +02:00
1c4343058d Add a Cocoa frontend for xC
Some work remains to be done to get it to be even as good
as the Win32 frontend, but it's generally usable.
2023-09-01 01:12:51 +02:00
20 changed files with 2213 additions and 176 deletions

View File

@@ -1,4 +1,4 @@
Copyright (c) 2014 - 2023, Přemysl Eric Janouch <p@janouch.name> Copyright (c) 2014 - 2024, Přemysl Eric Janouch <p@janouch.name>
Permission to use, copy, modify, and/or distribute this software for any Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted. purpose with or without fee is hereby granted.

8
NEWS
View File

@@ -1,4 +1,4 @@
2.0.0 (Unreleased) 2.0.0 (2024-07-28) "Perfect Is the Enemy of Good"
* xD: now using SHA-256 for client certificate fingerprints * xD: now using SHA-256 for client certificate fingerprints
@@ -16,6 +16,8 @@
* xC: replaced behaviour.save_on_quit with general.autosave * xC: replaced behaviour.save_on_quit with general.autosave
* xC: the server *.command configuration option now supports multiple lines
* xC: improved pager integration capabilities * xC: improved pager integration capabilities
* xC: unsolicited JOINs will no longer automatically activate the buffer * xC: unsolicited JOINs will no longer automatically activate the buffer
@@ -33,8 +35,12 @@
* Added a Win32 frontend for xC called xW * Added a Win32 frontend for xC called xW
* Added a Cocoa frontend for xC called xM
* Added a Go port of xD called xS * Added a Go port of xD called xS
* Added a simple notifier called xN
1.5.0 (2021-12-21) "The Show Must Go On" 1.5.0 (2021-12-21) "The Show Must Go On"

View File

@@ -1,9 +1,10 @@
xK xK
== ==
'xK' (chat kit) is an IRC software suite consisting of a daemon, bot, terminal 'xK' (chat kit) is an IRC software suite consisting of a daemon, bot, notifier,
client, and web + Win32 frontends for the client. It's all you're ever going to terminal client, and web/Windows/macOS frontends for the client. It's all
need for chatting, so long as you can make do with slightly minimalist software. you're ever going to need for chatting, so long as you can make do with slightly
minimalist software.
They're all lean on dependencies, and offer a maximally permissive licence. They're all lean on dependencies, and offer a maximally permissive licence.
@@ -55,6 +56,10 @@ https://github.com/kiwiirc/webircgateway[].
Any further development, such as P10 or TS6 linking for IRC services, Any further development, such as P10 or TS6 linking for IRC services,
or plugin support for arbitrary bridges, will happen here. or plugin support for arbitrary bridges, will happen here.
xN
--
The IRC notifier, should you ever need to send automated messages from a script.
xB xB
-- --
The IRC bot. While originally intended to be a simple rewrite of my old GNU AWK The IRC bot. While originally intended to be a simple rewrite of my old GNU AWK
@@ -140,13 +145,23 @@ endpoint as the third command line argument in this case.
xW xW
~~ ~~
The Win32 frontend is a separate CMake subproject that should be compiled The Win32 frontend is a separate CMake subproject that should be compiled
using MinGW-w64. In order to run it, make a shortcut for the executable and using MinGW-w64. To avoid having to specify the relay address each time you
include the relay address in its _Target_ field: run it, create a shortcut for the executable and include the address in its
_Target_ field:
C:\...\xW.exe 127.0.0.1 9000 C:\...\xW.exe 127.0.0.1 9000
It works reasonably well starting with Windows 7. It works reasonably well starting with Windows 7.
xM
~~
The Cocoa frontend is a separate CMake subproject that requires Xcode to build.
It is currently not that usable. The relay address can either be passed on
the command line, or preset in the _defaults_ database:
$ defaults write name.janouch.xM relayHost 127.0.0.1
$ defaults write name.janouch.xM relayPort 9000
Client Certificates Client Certificates
------------------- -------------------
'xC' will use the SASL EXTERNAL method to authenticate using the TLS client 'xC' will use the SASL EXTERNAL method to authenticate using the TLS client

Submodule liberty updated: 62166f9679...f04cc2c61e

7
xC.c
View File

@@ -1,7 +1,7 @@
/* /*
* xC.c: a terminal-based IRC client * xC.c: a terminal-based IRC client
* *
* Copyright (c) 2015 - 2022, Přemysl Eric Janouch <p@janouch.name> * Copyright (c) 2015 - 2024, Přemysl Eric Janouch <p@janouch.name>
* *
* Permission to use, copy, modify, and/or distribute this software for any * Permission to use, copy, modify, and/or distribute this software for any
* purpose with or without fee is hereby granted. * purpose with or without fee is hereby granted.
@@ -8317,6 +8317,8 @@ irc_try_parse_welcome_for_userhost (struct server *s, const char *m)
strv_free (&v); strv_free (&v);
} }
static void process_input
(struct app_context *, struct buffer *, const char *);
static bool process_input_line static bool process_input_line
(struct app_context *, struct buffer *, const char *, int); (struct app_context *, struct buffer *, const char *, int);
static void on_autoaway_timer (struct app_context *ctx); static void on_autoaway_timer (struct app_context *ctx);
@@ -8345,7 +8347,7 @@ irc_on_registered (struct server *s, const char *nickname)
if (command) if (command)
{ {
log_server_debug (s, "Executing \"#s\"", command); log_server_debug (s, "Executing \"#s\"", command);
(void) process_input_line (s->ctx, s->buffer, command, 0); (void) process_input (s->ctx, s->buffer, command);
} }
int64_t command_delay = get_config_integer (s->config, "command_delay"); int64_t command_delay = get_config_integer (s->config, "command_delay");
@@ -15865,6 +15867,7 @@ relay_start (struct app_context *ctx, char *address, struct error **e)
} }
// Just try the first one, disregarding IPv4/IPv6 ordering. // Just try the first one, disregarding IPv4/IPv6 ordering.
// Use 0.0.0.0 or [::] to request either one specifically.
int fd = relay_listen_with_context (ctx, result, e); int fd = relay_listen_with_context (ctx, result, e);
freeaddrinfo (result); freeaddrinfo (result);
if (fd == -1) if (fd == -1)

View File

@@ -1 +1 @@
1.5.0 2.0.0

45
xM/CMakeLists.txt Normal file
View File

@@ -0,0 +1,45 @@
# Swift language support
cmake_minimum_required (VERSION 3.15)
file (READ ../xK-version project_version)
configure_file (../xK-version xK-version.tag COPYONLY)
string (STRIP "${project_version}" project_version)
# There were two issues when building this from the main CMakeLists.txt:
# a) renaming main.swift to xM.swift requires removing top-level statements,
# b) there is a "redefinition of module 'FFI'" error.
project (xM VERSION "${project_version}"
DESCRIPTION "Cocoa frontend for xC" LANGUAGES Swift)
set (root "${PROJECT_SOURCE_DIR}/..")
add_custom_command (OUTPUT xC-proto.swift
COMMAND env LC_ALL=C awk
-f ${root}/liberty/tools/lxdrgen.awk
-f ${root}/liberty/tools/lxdrgen-swift.awk
-v PrefixCamel=Relay
${root}/xC.lxdr > xC-proto.swift
DEPENDS
${root}/liberty/tools/lxdrgen.awk
${root}/liberty/tools/lxdrgen-swift.awk
${root}/xC.lxdr
COMMENT "Generating xC relay protocol code" VERBATIM)
set (MACOSX_BUNDLE_GUI_IDENTIFIER name.janouch.${PROJECT_NAME})
set (MACOSX_BUNDLE_ICON_FILE xM.icns)
# Avoid including binary files in the repository by generating icons in code.
# sips(1) + Javascript + iconutil(1) could probably also be used.
find_program (SWIFT_EXECUTABLE swift REQUIRED)
set (icon "${PROJECT_BINARY_DIR}/${MACOSX_BUNDLE_ICON_FILE}")
add_custom_command (OUTPUT "${icon}"
COMMAND ${SWIFT_EXECUTABLE} "${PROJECT_SOURCE_DIR}/gen-icon.swift" "${icon}"
DEPENDS gen-icon.swift
COMMENT "Generating xM application icon" VERBATIM)
set_source_files_properties ("${icon}" PROPERTIES
MACOSX_PACKAGE_LOCATION Resources)
# Other requirements: macOS 10.14 for Network, and macOS 11 for Logger.
set (CMAKE_Swift_LANGUAGE_VERSION 5)
add_executable (xM MACOSX_BUNDLE
main.swift "${icon}" "${PROJECT_BINARY_DIR}/xC-proto.swift")

96
xM/gen-icon.swift Normal file
View File

@@ -0,0 +1,96 @@
// gen-icon.swift: generate a program icon for xM in the Apple icon format
//
// Copyright (c) 2023, Přemysl Eric Janouch <p@janouch.name>
// SPDX-License-Identifier: 0BSD
//
// NSGraphicsContext mostly just weirdly wraps over Quartz,
// so we do it all in Quartz directly.
import CoreGraphics
import Foundation
import ImageIO
import UniformTypeIdentifiers
// Apple uses something that's close to a "quintic superellipse" in their icons,
// but doesn't quite match. Either way, it looks better than rounded rectangles.
func addSquircle(context: CGContext, bounds: CGRect) {
context.move(to: CGPoint(x: bounds.maxX, y: bounds.midY))
for theta in stride(from: 0.0, to: .pi * 2, by: .pi / 1e4) {
let x = pow(abs(cos(theta)), 2 / 5.0) * bounds.width / 2
* CGFloat(signOf: cos(theta), magnitudeOf: 1) + bounds.midX
let y = pow(abs(sin(theta)), 2 / 5.0) * bounds.height / 2
* CGFloat(signOf: sin(theta), magnitudeOf: 1) + bounds.midY
context.addLine(to: CGPoint(x: x, y: y))
}
context.closePath()
}
func drawIcon(scale: CGFloat) -> CGImage? {
let size = CGSizeMake(1024, 1024)
let colorspace = CGColorSpaceCreateDeviceRGB()
let context = CGContext(data: nil,
width: Int(size.width * scale), height: Int(size.height * scale),
bitsPerComponent: 8, bytesPerRow: 0, space: colorspace,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!
context.scaleBy(x: scale, y: scale)
let bounds = CGRectMake(100, 100, size.width - 200, size.height - 200)
addSquircle(context: context, bounds: bounds)
let squircle = context.path!
// Gradients don't draw shadows, so draw it separately.
context.saveGState()
context.setShadow(offset: CGSizeMake(0, -12).applying(context.ctm),
blur: 28 * scale, color: CGColor(gray: 0, alpha: 0.5))
context.setFillColor(CGColor(red: 1, green: 0x55p-8, blue: 0, alpha: 1))
context.fillPath()
context.restoreGState()
context.saveGState()
context.addPath(squircle)
context.clip()
context.drawLinearGradient(
CGGradient(colorsSpace: colorspace, colors: [
CGColor(red: 1, green: 0x00p-8, blue: 0, alpha: 1),
CGColor(red: 1, green: 0xaap-8, blue: 0, alpha: 1)
] as CFArray, locations: [0, 1])!,
start: CGPointMake(0, 100), end: CGPointMake(0, size.height - 100),
options: CGGradientDrawingOptions(rawValue: 0))
context.restoreGState()
context.move(to: CGPoint(x: size.width * 0.30, y: size.height * 0.30))
context.addLine(to: CGPoint(x: size.width * 0.30, y: size.height * 0.70))
context.addLine(to: CGPoint(x: size.width * 0.575, y: size.height * 0.425))
context.move(to: CGPoint(x: size.width * 0.70, y: size.height * 0.30))
context.addLine(to: CGPoint(x: size.width * 0.70, y: size.height * 0.70))
context.addLine(to: CGPoint(x: size.width * 0.425, y: size.height * 0.425))
context.setLineWidth(80)
context.setLineCap(.round)
context.setLineJoin(.round)
context.setStrokeColor(CGColor.white)
context.strokePath()
return context.makeImage()
}
if CommandLine.arguments.count != 2 {
print("Usage: \(CommandLine.arguments.first!) OUTPUT.icns")
exit(EXIT_FAILURE)
}
let filename = CommandLine.arguments[1]
let macOSSizes: Array<CGFloat> = [16, 32, 128, 256, 512]
let icns = CGImageDestinationCreateWithURL(
URL(fileURLWithPath: filename) as CFURL,
UTType.icns.identifier as CFString, macOSSizes.count * 2, nil)!
for size in macOSSizes {
CGImageDestinationAddImage(icns, drawIcon(scale: size / 1024.0)!, nil)
CGImageDestinationAddImage(icns, drawIcon(scale: size / 1024.0 * 2)!, [
kCGImagePropertyDPIWidth: 144,
kCGImagePropertyDPIHeight: 144,
] as CFDictionary)
}
if !CGImageDestinationFinalize(icns) {
print("ICNS finalization failed.")
exit(EXIT_FAILURE)
}

1372
xM/main.swift Normal file

File diff suppressed because it is too large Load Diff

3
xN/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/xN
/xN.1
/irc.go

21
xN/Makefile Normal file
View File

@@ -0,0 +1,21 @@
.POSIX:
.SUFFIXES:
AWK = env LC_ALL=C awk
outputs = irc.go xN xN.1
all: $(outputs)
# If we want to keep module dependencies separate, we don't have many options.
# Symlinking seems to work good enough.
irc.go: ../xS/irc.go
ln -sf ../xS/irc.go $@
xN: xN.go ../xK-version irc.go
go build -ldflags "-X 'main.projectVersion=$$(cat ../xK-version)'" -o $@
xN.1: ../xK-version ../liberty/tools/asciiman.awk xN.adoc
env "asciidoc-release-version=$$(cat ../xK-version)" \
$(AWK) -f ../liberty/tools/asciiman.awk xN.adoc > $@
test: all
go test
clean:
rm -f $(outputs)

3
xN/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module janouch.name/xK/xN
go 1.19

86
xN/xN.adoc Normal file
View File

@@ -0,0 +1,86 @@
xN(1)
=====
:doctype: manpage
:manmanual: xK Manual
:mansource: xK {release-version}
Name
----
xN - IRC notifier
Synopsis
--------
*xN* [_OPTION_]... IRC-URL...
Description
-----------
*xN* is a simple IRC notifier, sending the text it receives on its standard
input to all IRC targets specified by its command line arguments.
The input text is forced to validate as UTF-8, and it is _not_ split
automatically to comply with the maximum IRC message length.
Thus, make sure to make the lines short, or they will be trimmed by the servers.
*xN* does not attempt to appease flood detectors.
Options
-------
*-debug*::
Print incoming IRC traffic, which may help in debugging any issues.
*-version*::
Output version information and exit.
URL format
----------
*xN* accepts URLs describing IRC users and channels, roughly as specified by
the Internet Draft _draft-butcher-irc-url-04.txt_. Note, however, that close
to no validation is done on these, and you should not pass URLs from untrusted
sources, so as to avoid command or parameter injection.
Any provided username will be propagated to the nickname, username,
and realname. The default value for these is the name of your system user.
As an extension, you may use the following options:
*skipjoin*::
Do not JOIN channels before sending messages to them.
This requires channels to allow external messages
(which are disabled by channel mode *+n*).
*usenotice*::
Send a NOTICE rather than a PRIVMSG, which distinguishes automated messages,
and is more appropriate for bots.
Examples
--------
$ uptime | xN 'irc://uptime@localhost/%23watch?skipjoin&usenotice'
Send *uptime*(1) information as an external notice to channel *#watch*
on the local server, using the standard port *6667*.
$ fortune -s | xN ircs://ohayou@irc.libera.chat/john,isuser
Greet user *john* with a fortune for this day. In compliance with _RFC 7194_,
the default TLS port is assumed to be *6697*.
$ xN 'ircs://agent:Password123@irc.cia.gov:1337/#hq?key=123456' <<EOF
The red fox trots quietly at midnight.
EOF
Connect over TLS to *irc.cia.gov* on port *1337*, use *Password123*
as the server password, register as user *agent*,
join channel *#hq* using the channel key *123456*,
and send a very secret message.
Reporting bugs
--------------
Use https://git.janouch.name/p/xK to report bugs, request features,
or submit pull requests.
See also
--------
_Uniform Resource Locator Schemes for Internet Relay Chat Entities_,
https://datatracker.ietf.org/doc/html/draft-butcher-irc-url-04[].
_Default Port for Internet Relay Chat (IRC) via TLS/SSL_,
https://datatracker.ietf.org/doc/html/rfc7194[].

279
xN/xN.go Normal file
View File

@@ -0,0 +1,279 @@
//
// Copyright (c) 2024, Přemysl Eric Janouch <p@janouch.name>
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION
// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
//
// xN is a simple IRC notifier.
package main
import (
"bufio"
"bytes"
"crypto/tls"
"errors"
"flag"
"fmt"
"io"
"log"
"net"
"net/url"
"os"
"os/user"
"strconv"
"strings"
)
const projectName = "xN"
var projectVersion = "?"
var debugMode = false
type parameters struct {
conn net.Conn // underlying network connection
username string // nickname + username + realname
password string // server password
target string // where to send text
isuser bool // the target is a user rather than a channel
chankey string // channel key
skipjoin bool // whether to send external messages to channels
usenotice bool // whether to use NOTICE rather than PRIVMSG
message []string // lines of the message to send
}
func (p *parameters) send(command string, args ...string) error {
var buf bytes.Buffer
buf.WriteString(command)
for i, arg := range args {
buf.WriteRune(' ')
if i+1 == len(args) {
buf.WriteRune(':')
}
buf.WriteString(arg)
}
buf.WriteString("\r\n")
_, err := p.conn.Write(buf.Bytes())
return err
}
const (
rplWELCOME = "001"
errNICKNAMEINUSE = "433"
)
func notify(p *parameters) error {
// The intro should comfortably fit in the TCP send buffer whole.
if p.password != "" {
p.send("PASS", p.password)
}
p.send("USER", p.username, "0", "*", p.username)
p.send("NICK", p.username)
scanner, nickCounter, issue := bufio.NewScanner(p.conn), 1, ""
for scanner.Scan() {
if debugMode {
log.Println(scanner.Text())
}
m := ircParseMessage(scanner.Text())
switch m.command {
case "PING":
p.send("PONG", m.params...)
case rplWELCOME:
if !p.isuser && !p.skipjoin {
if p.chankey != "" {
p.send("JOIN", p.target, p.chankey)
} else {
p.send("JOIN", p.target)
}
}
for _, line := range p.message {
if p.usenotice {
p.send("NOTICE", p.target, line)
} else {
p.send("PRIVMSG", p.target, line)
}
}
p.send("QUIT")
case errNICKNAMEINUSE:
p.send("NICK", fmt.Sprintf("%s%d", p.username, nickCounter))
nickCounter++
default:
// Prevent hanging on unsuccessful registrations.
numeric, _ := strconv.Atoi(m.command)
if numeric >= 400 && numeric <= 599 {
if len(m.params) > 1 {
issue = strings.Join(m.params[1:], " ")
} else {
issue = strings.Join(m.params, " ")
}
p.send("QUIT")
}
}
}
if err := scanner.Err(); err != nil {
return err
}
if issue != "" {
return errors.New(issue)
}
return nil
}
func parse(rawURL string, text []byte) (
p parameters, connect func() (net.Conn, error), err error) {
u, err := url.Parse(rawURL)
if err != nil {
return p, nil, err
} else if !u.IsAbs() || u.Opaque != "" {
return p, nil, errors.New("need an absolute URL")
} else if u.Path == "/" && u.Fragment != "" {
// Try to handle the common but degenerate case.
fragment := "%23" + u.Fragment
u.Fragment, u.RawFragment = "", ""
if u, err = url.Parse(u.String() + fragment); err != nil {
return p, nil, err
}
}
// Figure out registration details.
p.username = projectName
if u, _ := user.Current(); u != nil {
p.username = u.Username
}
if u.User.Username() != "" {
p.username = u.User.Username()
}
p.password, _ = u.User.Password()
// Figure out the target, which for our intents must accept messages.
path, _ := strings.CutPrefix(u.Path, "/")
elements := strings.Split(path, ",")
if path == "" || elements[0] == "" {
return p, nil, errors.New("unspecified entity")
}
// The last entity type wins.
p.target, p.isuser = elements[0], false
for _, typ := range elements[1:] {
switch typ {
case "isuser":
p.isuser = true
case "ischannel":
p.isuser = false
case "isserver":
// We do not support network names, and this is the default.
default:
return p, nil, errors.New("unsupported type: " + typ)
}
}
if p.isuser {
if i := strings.IndexAny(p.target, "!@"); i != -1 {
p.target = p.target[:i]
}
} else if !strings.HasPrefix(p.target, "#") {
// TODO(p): We should consult RPL_ISUPPORT rather than guess,
// though other prefixes are rare.
p.target = "#" + p.target
}
// Note that the draft RFC wants these to be case-insensitive.
p.chankey = u.Query().Get("key")
// Being able to skip channel join is our own requirement and invention,
// as are notices (names taken from Travis CI configuration).
p.skipjoin = u.Query().Has("skipjoin")
p.usenotice = u.Query().Has("usenotice")
// Ensure valid LF-separated UTF-8, and split it at lines.
sanitized := strings.ReplaceAll(string([]rune(string(text))), "\r", "\n")
for _, line := range strings.Split(sanitized, "\n") {
if line != "" {
p.message = append(p.message, line)
}
}
hostname, port := u.Hostname(), u.Port()
switch u.Scheme {
case "irc":
if port == "" {
port = "6667"
}
connect = func() (net.Conn, error) {
return net.Dial("tcp", net.JoinHostPort(hostname, port))
}
case "ircs":
if port == "" {
port = "6697"
}
connect = func() (net.Conn, error) {
return tls.Dial("tcp", net.JoinHostPort(hostname, port), nil)
}
default:
err = errors.New("unsupported scheme: " + u.Scheme)
}
return
}
// notifyByURL sends the given text to the IRC server specified by a URL.
// See draft-butcher-irc-url-04.txt for the URL scheme specification
// this function loosely follows.
func notifyByURL(rawURL string, text []byte) error {
p, connect, err := parse(rawURL, text)
if p.conn, err = connect(); err != nil {
return err
}
defer p.conn.Close()
return notify(&p)
}
func main() {
flag.BoolVar(&debugMode, "debug", false, "run in verbose debug mode")
version := flag.Bool("version", false, "show version and exit")
flag.Usage = func() {
f := flag.CommandLine.Output()
fmt.Fprintf(f, "Usage: %s [OPTION]... URL...\n", os.Args[0])
flag.PrintDefaults()
}
flag.Parse()
if flag.NArg() < 1 {
flag.Usage()
os.Exit(2)
}
if *version {
fmt.Printf("%s %s\n", projectName, projectVersion)
return
}
text, err := io.ReadAll(os.Stdin)
if err != nil {
log.Fatalln(err)
}
status := 0
for _, rawURL := range flag.Args() {
if err := notifyByURL(rawURL, text); err != nil {
status = 1
var ue *url.Error
if errors.As(err, &ue) {
log.Println(err)
} else {
log.Printf("notify %q: %s\n", rawURL, err)
}
}
}
os.Exit(status)
}

98
xN/xN_test.go Normal file
View File

@@ -0,0 +1,98 @@
//
// Copyright (c) 2024, Přemysl Eric Janouch <p@janouch.name>
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION
// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
//
package main
import "testing"
func TestParseURL(t *testing.T) {
for _, rawURL := range []string{
`irc:server/channel`,
`irc://server/channel,isnetwork`,
`ircs://ssl.ircnet.io`,
`ircs://ssl.ircnet.io/`,
`http://server/path`,
} {
if _, _, err := parse(rawURL, nil); err == nil {
t.Errorf("%q should not parse\n", rawURL)
}
}
for _, test := range []struct {
rawURL string
p parameters
}{
{
rawURL: `irc://uptime@localhost/%23watch?skipjoin&usenotice`,
p: parameters{
username: "uptime",
target: "#watch",
skipjoin: true,
usenotice: true,
},
},
{
rawURL: `ircs://ohayou@irc.libera.chat/john,isuser,isserver`,
p: parameters{
username: "ohayou",
target: "john",
isuser: true,
},
},
{
rawURL: `ircs://agent:Password123@irc.cia.gov:1337/#hq?key=123456`,
p: parameters{
username: "agent",
password: "Password123",
target: "#hq",
chankey: "123456",
},
},
} {
p, _, err := parse(test.rawURL, nil)
if err != nil {
t.Errorf("%q should parse, got: %s\n", test.rawURL, err)
continue
}
if p.username != test.p.username {
t.Errorf("%q: username: %v ≠ %v\n",
test.rawURL, p.username, test.p.username)
}
if p.password != test.p.password {
t.Errorf("%q: password: %v ≠ %v\n",
test.rawURL, p.password, test.p.password)
}
if p.target != test.p.target {
t.Errorf("%q: target: %v ≠ %v\n",
test.rawURL, p.target, test.p.target)
}
if p.isuser != test.p.isuser {
t.Errorf("%q: isuser: %v ≠ %v\n",
test.rawURL, p.isuser, test.p.isuser)
}
if p.chankey != test.p.chankey {
t.Errorf("%q: chankey: %v ≠ %v\n",
test.rawURL, p.chankey, test.p.chankey)
}
if p.skipjoin != test.p.skipjoin {
t.Errorf("%q: skipjoin: %v ≠ %v\n",
test.rawURL, p.skipjoin, test.p.skipjoin)
}
if p.usenotice != test.p.usenotice {
t.Errorf("%q: usenotice: %v ≠ %v\n",
test.rawURL, p.usenotice, test.p.usenotice)
}
}
}

View File

@@ -197,6 +197,14 @@ function bufferResetStats(b) {
b.highlighted = false b.highlighted = false
} }
function bufferPopExcessLines(b) {
// Let "new" messages be, if only because pulling the log file
// is much more problematic in the web browser than in xC.
// TODO: Make the limit configurable, or extract general.backlog_limit.
const old = b.lines.length - b.newMessages - b.newUnimportantMessages
b.lines.splice(0, old - 1000)
}
function bufferActivate(name) { function bufferActivate(name) {
rpc.send({command: 'BufferActivate', bufferName: name}) rpc.send({command: 'BufferActivate', bufferName: name})
} }
@@ -294,6 +302,13 @@ rpcEventHandlers.set(Relay.Event.BufferLine, e => {
b.newMessages++ b.newMessages++
} }
// XXX: In its unkeyed diff algorithm, Mithril.js can only efficiently
// deal with common prefixes, i.e., indefinitely growing buffers.
// But we don't want to key all children of Buffer,
// so only trim buffers while they are, or once they become invisible.
if (e.bufferName != bufferCurrent)
bufferPopExcessLines(b)
if (e.leakToActive) { if (e.leakToActive) {
let bc = buffers.get(bufferCurrent) let bc = buffers.get(bufferCurrent)
bc.lines.push({...line, leaked: true}) bc.lines.push({...line, leaked: true})
@@ -336,7 +351,7 @@ rpcEventHandlers.set(Relay.Event.BufferStats, e => {
if (b === undefined) if (b === undefined)
return return
b.newMessages = e.newMessages, b.newMessages = e.newMessages
b.newUnimportantMessages = e.newUnimportantMessages b.newUnimportantMessages = e.newUnimportantMessages
b.highlighted = e.highlighted b.highlighted = e.highlighted
}) })
@@ -359,8 +374,16 @@ rpcEventHandlers.set(Relay.Event.BufferRemove, e => {
rpcEventHandlers.set(Relay.Event.BufferActivate, e => { rpcEventHandlers.set(Relay.Event.BufferActivate, e => {
let old = buffers.get(bufferCurrent) let old = buffers.get(bufferCurrent)
if (old !== undefined) if (old !== undefined) {
bufferResetStats(old) bufferResetStats(old)
bufferPopExcessLines(old)
}
// Initial sync: trim all buffers to our limit, just for consistency.
if (bufferCurrent === undefined) {
for (let b of buffers.values())
bufferPopExcessLines(b)
}
bufferLast = bufferCurrent bufferLast = bufferCurrent
let b = buffers.get(e.bufferName) let b = buffers.get(e.bufferName)
@@ -506,7 +529,8 @@ let Content = {
while ((match = re.exec(text)) !== null) { while ((match = re.exec(text)) !== null) {
if (end < match.index) if (end < match.index)
a.push(m('span', attrs, text.substring(end, match.index))) a.push(m('span', attrs, text.substring(end, match.index)))
a.push(m('a[target=_blank]', {href: match[0], ...attrs}, match[0])) a.push(m('a[target=_blank][rel=noreferrer]',
{href: match[0], ...attrs}, match[0]))
end = re.lastIndex end = re.lastIndex
} }
if (end < text.length) if (end < text.length)
@@ -669,6 +693,12 @@ let Buffer = {
const dom = event.target const dom = event.target
bufferAutoscroll = bufferAutoscroll =
dom.scrollTop + dom.clientHeight + 1 >= dom.scrollHeight dom.scrollTop + dom.clientHeight + 1 >= dom.scrollHeight
let b = buffers.get(bufferCurrent)
if (b !== undefined && b.highlighted && !bufferAutoscroll) {
b.highlighted = false
m.redraw()
}
}}, lines) }}, lines)
}, },
} }
@@ -684,7 +714,8 @@ let Log = {
while ((match = re.exec(text)) !== null) { while ((match = re.exec(text)) !== null) {
if (end < match.index) if (end < match.index)
a.push(text.substring(end, match.index)) a.push(text.substring(end, match.index))
a.push(m('a[target=_blank]', {href: match[0]}, match[0])) a.push(m('a[target=_blank][rel=noreferrer]',
{href: match[0]}, match[0]))
end = re.lastIndex end = re.lastIndex
} }
if (end < text.length) if (end < text.length)
@@ -972,7 +1003,7 @@ let Input = {
rpc.send({command: 'Active'}) rpc.send({command: 'Active'})
let b = buffers.get(bufferCurrent) let b = buffers.get(bufferCurrent)
if (b === undefined) if (b === undefined || event.isComposing)
return return
let textarea = event.currentTarget let textarea = event.currentTarget

View File

@@ -12,5 +12,7 @@ xS-replies.go: xS-gen-replies.awk xS-replies
xS.1: ../xK-version ../liberty/tools/asciiman.awk xS.adoc xS.1: ../xK-version ../liberty/tools/asciiman.awk xS.adoc
env "asciidoc-release-version=$$(cat ../xK-version)" \ env "asciidoc-release-version=$$(cat ../xK-version)" \
$(AWK) -f ../liberty/tools/asciiman.awk xS.adoc > $@ $(AWK) -f ../liberty/tools/asciiman.awk xS.adoc > $@
test: all
go test
clean: clean:
rm -f $(outputs) rm -f $(outputs)

132
xS/irc.go Normal file
View File

@@ -0,0 +1,132 @@
package main
import (
"path/filepath"
"regexp"
"strings"
)
func ircToLower(c byte) byte {
switch c {
case '[':
return '{'
case ']':
return '}'
case '\\':
return '|'
case '~':
return '^'
}
if c >= 'A' && c <= 'Z' {
return c + ('a' - 'A')
}
return c
}
func ircToUpper(c byte) byte {
switch c {
case '{':
return '['
case '}':
return ']'
case '|':
return '\\'
case '^':
return '~'
}
if c >= 'a' && c <= 'z' {
return c - ('a' - 'A')
}
return c
}
// Convert identifier to a canonical form for case-insensitive comparisons.
// ircToUpper is used so that statically initialized maps can be in uppercase.
func ircToCanon(ident string) string {
var canon []byte
for _, c := range []byte(ident) {
canon = append(canon, ircToUpper(c))
}
return string(canon)
}
func ircEqual(s1, s2 string) bool {
return ircToCanon(s1) == ircToCanon(s2)
}
func ircFnmatch(pattern string, s string) bool {
pattern, s = ircToCanon(pattern), ircToCanon(s)
// FIXME: This should not support [] ranges and handle '/' specially.
// We could translate the pattern to a regular expression.
matched, _ := filepath.Match(pattern, s)
return matched
}
var reMsg = regexp.MustCompile(
`^(?:@([^ ]*) +)?(?::([^! ]*)(?:!([^@]*)@([^ ]*))? +)?([^ ]+)(.*)?$`)
var reArgs = regexp.MustCompile(`:.*| [^: ][^ ]*`)
type message struct {
tags map[string]string // IRC 3.2 message tags
nick string // optional nickname
user string // optional username
host string // optional hostname or IP address
command string // command name
params []string // arguments
}
func ircUnescapeMessageTag(value string) string {
var buf []byte
escape := false
for i := 0; i < len(value); i++ {
if escape {
switch value[i] {
case ':':
buf = append(buf, ';')
case 's':
buf = append(buf, ' ')
case 'r':
buf = append(buf, '\r')
case 'n':
buf = append(buf, '\n')
default:
buf = append(buf, value[i])
}
escape = false
} else if value[i] == '\\' {
escape = true
} else {
buf = append(buf, value[i])
}
}
return string(buf)
}
func ircParseMessageTags(tags string, out map[string]string) {
for _, tag := range strings.Split(tags, ";") {
if tag == "" {
// Ignore empty.
} else if equal := strings.IndexByte(tag, '='); equal < 0 {
out[tag] = ""
} else {
out[tag[:equal]] = ircUnescapeMessageTag(tag[equal+1:])
}
}
}
func ircParseMessage(line string) *message {
m := reMsg.FindStringSubmatch(line)
if m == nil {
return nil
}
msg := message{nil, m[2], m[3], m[4], m[5], nil}
if m[1] != "" {
msg.tags = make(map[string]string)
ircParseMessageTags(m[1], msg.tags)
}
for _, x := range reArgs.FindAllString(m[6], -1) {
msg.params = append(msg.params, x[1:])
}
return &msg
}

126
xS/xS.go
View File

@@ -456,132 +456,6 @@ func (fd *floodDetector) check() bool {
return count <= fd.limit return count <= fd.limit
} }
// --- IRC protocol ------------------------------------------------------------
func ircToLower(c byte) byte {
switch c {
case '[':
return '{'
case ']':
return '}'
case '\\':
return '|'
case '~':
return '^'
}
if c >= 'A' && c <= 'Z' {
return c + ('a' - 'A')
}
return c
}
func ircToUpper(c byte) byte {
switch c {
case '{':
return '['
case '}':
return ']'
case '|':
return '\\'
case '^':
return '~'
}
if c >= 'a' && c <= 'z' {
return c - ('a' - 'A')
}
return c
}
// Convert identifier to a canonical form for case-insensitive comparisons.
// ircToUpper is used so that statically initialized maps can be in uppercase.
func ircToCanon(ident string) string {
var canon []byte
for _, c := range []byte(ident) {
canon = append(canon, ircToUpper(c))
}
return string(canon)
}
func ircEqual(s1, s2 string) bool {
return ircToCanon(s1) == ircToCanon(s2)
}
func ircFnmatch(pattern string, s string) bool {
pattern, s = ircToCanon(pattern), ircToCanon(s)
// FIXME: This should not support [] ranges and handle '/' specially.
// We could translate the pattern to a regular expression.
matched, _ := filepath.Match(pattern, s)
return matched
}
var reMsg = regexp.MustCompile(
`^(?:@([^ ]*) +)?(?::([^! ]*)(?:!([^@]*)@([^ ]*))? +)?([^ ]+)(.*)?$`)
var reArgs = regexp.MustCompile(`:.*| [^: ][^ ]*`)
type message struct {
tags map[string]string // IRC 3.2 message tags
nick string // optional nickname
user string // optional username
host string // optional hostname or IP address
command string // command name
params []string // arguments
}
func ircUnescapeMessageTag(value string) string {
var buf []byte
escape := false
for i := 0; i < len(value); i++ {
if escape {
switch value[i] {
case ':':
buf = append(buf, ';')
case 's':
buf = append(buf, ' ')
case 'r':
buf = append(buf, '\r')
case 'n':
buf = append(buf, '\n')
default:
buf = append(buf, value[i])
}
escape = false
} else if value[i] == '\\' {
escape = true
} else {
buf = append(buf, value[i])
}
}
return string(buf)
}
func ircParseMessageTags(tags string, out map[string]string) {
for _, tag := range splitString(tags, ";", true /* ignoreEmpty */) {
if equal := strings.IndexByte(tag, '='); equal < 0 {
out[tag] = ""
} else {
out[tag[:equal]] = ircUnescapeMessageTag(tag[equal+1:])
}
}
}
func ircParseMessage(line string) *message {
m := reMsg.FindStringSubmatch(line)
if m == nil {
return nil
}
msg := message{nil, m[2], m[3], m[4], m[5], nil}
if m[1] != "" {
msg.tags = make(map[string]string)
ircParseMessageTags(m[1], msg.tags)
}
for _, x := range reArgs.FindAllString(m[6], -1) {
msg.params = append(msg.params, x[1:])
}
return &msg
}
// --- IRC token validation ---------------------------------------------------- // --- IRC token validation ----------------------------------------------------
// Everything as per RFC 2812 // Everything as per RFC 2812

View File

@@ -24,42 +24,11 @@ set (project_config ${PROJECT_BINARY_DIR}/config.h)
configure_file (${PROJECT_SOURCE_DIR}/config.h.in ${project_config}) configure_file (${PROJECT_SOURCE_DIR}/config.h.in ${project_config})
include_directories (${PROJECT_SOURCE_DIR} ${PROJECT_BINARY_DIR}) include_directories (${PROJECT_SOURCE_DIR} ${PROJECT_BINARY_DIR})
# Icon generation utilities # Produce a beep sample
# TODO: Shove this into liberty as a CMake module, similar to AddThreads,
# and remove the copies in the parent CMakeLists.txt as well as in tdv.
if (NOT ${CMAKE_VERSION} VERSION_LESS 3.18.0) if (NOT ${CMAKE_VERSION} VERSION_LESS 3.18.0)
set (find_program_REQUIRE REQUIRED) set (find_program_REQUIRE REQUIRED)
endif () endif ()
function (icon_to_png name svg size output_dir output)
set (_dimensions ${size}x${size})
set (_png_path ${output_dir}/hicolor/${_dimensions}/apps)
set (_png ${_png_path}/${name}.png)
set (${output} ${_png} PARENT_SCOPE)
find_program (rsvg_convert_EXECUTABLE rsvg-convert ${find_program_REQUIRE})
add_custom_command (OUTPUT ${_png}
COMMAND ${CMAKE_COMMAND} -E make_directory ${_png_path}
COMMAND ${rsvg_convert_EXECUTABLE} --output=${_png}
--width=${size} --height=${size} ${svg}
DEPENDS ${svg}
COMMENT "Generating ${name} ${_dimensions} application icon" VERBATIM)
endfunction ()
function (icon_for_win32 ico pngs pngs_raw)
set (_raws)
foreach (png ${pngs_raw})
list (APPEND _raws "--raw=${png}")
endforeach ()
find_program (icotool_EXECUTABLE icotool ${find_program_REQUIRE})
add_custom_command (OUTPUT ${ico}
COMMAND ${icotool_EXECUTABLE} -c -o ${ico} ${_raws} -- ${pngs}
DEPENDS ${pngs} ${pngs_raw}
COMMENT "Generating Windows program icon" VERBATIM)
endfunction ()
# Produce a beep sample
find_program (sox_EXECUTABLE sox ${find_program_REQUIRE}) find_program (sox_EXECUTABLE sox ${find_program_REQUIRE})
add_custom_command (OUTPUT beep.wav add_custom_command (OUTPUT beep.wav
COMMAND ${sox_EXECUTABLE} -b 16 -Dr 44100 -n beep.wav COMMAND ${sox_EXECUTABLE} -b 16 -Dr 44100 -n beep.wav
@@ -67,6 +36,10 @@ add_custom_command (OUTPUT beep.wav
COMMENT "Generating a beep sample" VERBATIM) COMMENT "Generating a beep sample" VERBATIM)
# Rasterize SVG icons # Rasterize SVG icons
set (root "${PROJECT_SOURCE_DIR}/..")
set (CMAKE_MODULE_PATH ${root}/liberty/cmake)
include (IconUtils)
set (icon_ico_list) set (icon_ico_list)
foreach (icon xW xW-highlighted) foreach (icon xW xW-highlighted)
set (icon_png_list) set (icon_png_list)
@@ -87,8 +60,6 @@ set_property (SOURCE xW.rc
APPEND PROPERTY OBJECT_DEPENDS ${icon_ico_list} beep.wav) APPEND PROPERTY OBJECT_DEPENDS ${icon_ico_list} beep.wav)
# Build the main executable and link it # Build the main executable and link it
set (root "${PROJECT_SOURCE_DIR}/..")
find_program (awk_EXECUTABLE awk ${find_program_REQUIRE}) find_program (awk_EXECUTABLE awk ${find_program_REQUIRE})
add_custom_command (OUTPUT xC-proto.cpp add_custom_command (OUTPUT xC-proto.cpp
COMMAND ${CMAKE_COMMAND} -E env LC_ALL=C ${awk_EXECUTABLE} COMMAND ${CMAKE_COMMAND} -E env LC_ALL=C ${awk_EXECUTABLE}