32 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
e5156cddbf xW: render leaked lines a bit more accurately
There is no need to reset all text attributes, just the colour.
2023-08-25 22:48:31 +02:00
34521e61c1 xP/xW: fix buffer rename handling
Maintaining string pointers to the current/last buffer
means that renames invalidate them.
2023-08-25 22:48:31 +02:00
c22dd67fc1 xC: send missing relay events for newly added servers 2023-08-25 22:48:27 +02:00
274d5f03e7 xC: give the /away command a proper handler
Multiple words should be passed to the server as a single argument.
2023-08-25 22:46:43 +02:00
2f19e5a733 xW: improve command sending 2023-07-29 02:15:24 +02:00
b9cdabca5d xC: fix relay handling of missing log files
Intermediate error messages would trash the prepared static buffer.
2023-07-28 04:30:45 +02:00
f60ca43156 xW: do not unnecessarily enter compatibility mode 2023-07-28 04:30:45 +02:00
afe4e61f08 xW: mark a footgun
These messages are used by IsDialogMessage(), and use the WM_USER range.
2023-07-27 23:08:16 +02:00
8d9d1c60ec xW: make Up/Down go through input history
The input field isn't multiline, so this doesn't pose an issue.
Otherwise, we'd have to check if we're on the top line first.
2023-07-27 16:35:54 +02:00
8c1464822b xW: don't delay sending out pongs 2023-07-27 16:19:32 +02:00
fcd1b8e011 xW: improve beeping
This adds yet another build dependency,
but it's better than the alternatives of handling it in code.
2023-07-27 16:06:41 +02:00
3d345987c3 xW: cleanup 2023-07-27 02:37:20 +02:00
3e37efd9cd xW: show a connect dialog when run without args 2023-07-27 01:28:52 +02:00
efb25b8aae xW: un-highlight the icon when activating buffers 2023-07-26 16:07:21 +02:00
e72793e315 xW: make newline before unread marker conditional 2023-07-26 16:07:20 +02:00
5a412ab6e2 xW: handle WM_SYSCOLORCHANGE 2023-07-26 16:07:20 +02:00
24 changed files with 2449 additions and 281 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

42
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");
@@ -9439,6 +9441,9 @@ server_add (struct app_context *ctx,
str_map_set (&ctx->servers, s->name, s); str_map_set (&ctx->servers, s->name, s);
s->config = subtree; s->config = subtree;
relay_prepare_server_update (ctx, s);
relay_broadcast (ctx);
// Add a buffer and activate it // Add a buffer and activate it
struct buffer *buffer = s->buffer = buffer_new (ctx->input, struct buffer *buffer = s->buffer = buffer_new (ctx->input,
BUFFER_SERVER, irc_make_buffer_name (s, NULL)); BUFFER_SERVER, irc_make_buffer_name (s, NULL));
@@ -12937,6 +12942,16 @@ handle_command_kill (struct handler_args *a)
return true; return true;
} }
static bool
handle_command_away (struct handler_args *a)
{
if (*a->arguments)
irc_send (a->s, "AWAY :%s", a->arguments);
else
irc_send (a->s, "AWAY");
return true;
}
static bool static bool
handle_command_nick (struct handler_args *a) handle_command_nick (struct handler_args *a)
{ {
@@ -13002,7 +13017,6 @@ TRIVIAL_HANDLER (who, "WHO")
TRIVIAL_HANDLER (motd, "MOTD") TRIVIAL_HANDLER (motd, "MOTD")
TRIVIAL_HANDLER (oper, "OPER") TRIVIAL_HANDLER (oper, "OPER")
TRIVIAL_HANDLER (stats, "STATS") TRIVIAL_HANDLER (stats, "STATS")
TRIVIAL_HANDLER (away, "AWAY")
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
@@ -15543,14 +15557,13 @@ static void
client_process_buffer_log client_process_buffer_log
(struct client *c, uint32_t seq, struct buffer *buffer) (struct client *c, uint32_t seq, struct buffer *buffer)
{ {
struct relay_event_data_response *e = relay_prepare_response (c->ctx, seq); // XXX: We log failures to the global buffer,
e->data.command = RELAY_COMMAND_BUFFER_LOG; // so the client just receives nothing if there is no log file.
struct str log = str_make ();
char *path = buffer_get_log_path (buffer); char *path = buffer_get_log_path (buffer);
FILE *fp = open_log_path (c->ctx, buffer, path); FILE *fp = open_log_path (c->ctx, buffer, path);
if (fp) if (fp)
{ {
struct str log = str_make ();
char buf[BUFSIZ]; char buf[BUFSIZ];
size_t len; size_t len;
while ((len = fread (buf, 1, sizeof buf, fp))) while ((len = fread (buf, 1, sizeof buf, fp)))
@@ -15558,17 +15571,15 @@ client_process_buffer_log
if (ferror (fp)) if (ferror (fp))
log_global_error (c->ctx, "Failed to read `#l': #l", log_global_error (c->ctx, "Failed to read `#l': #l",
path, strerror (errno)); path, strerror (errno));
// On overflow, it will later fail serialization.
e->data.buffer_log.log_len = MIN (UINT32_MAX, log.len);
e->data.buffer_log.log = (uint8_t *) str_steal (&log);
fclose (fp); fclose (fp);
} }
// XXX: We log failures to the global buffer,
// so the client just receives nothing if there is no log file.
free (path); free (path);
struct relay_event_data_response *e = relay_prepare_response (c->ctx, seq);
e->data.command = RELAY_COMMAND_BUFFER_LOG;
// On overflow, it will later fail serialization (frame will be too long).
e->data.buffer_log.log_len = MIN (UINT32_MAX, log.len);
e->data.buffer_log.log = (uint8_t *) str_steal (&log);
relay_send (c); relay_send (c);
} }
@@ -15856,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
}) })
@@ -344,6 +359,11 @@ rpcEventHandlers.set(Relay.Event.BufferStats, e => {
rpcEventHandlers.set(Relay.Event.BufferRename, e => { rpcEventHandlers.set(Relay.Event.BufferRename, e => {
buffers.set(e.new, buffers.get(e.bufferName)) buffers.set(e.new, buffers.get(e.bufferName))
buffers.delete(e.bufferName) buffers.delete(e.bufferName)
if (e.bufferName === bufferCurrent)
bufferCurrent = e.new
if (e.bufferName === bufferLast)
bufferLast = e.new
}) })
rpcEventHandlers.set(Relay.Event.BufferRemove, e => { rpcEventHandlers.set(Relay.Event.BufferRemove, e => {
@@ -354,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)
@@ -501,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)
@@ -664,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)
}, },
} }
@@ -679,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)
@@ -967,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,22 @@ 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) find_program (sox_EXECUTABLE sox ${find_program_REQUIRE})
set (_dimensions ${size}x${size}) add_custom_command (OUTPUT beep.wav
set (_png_path ${output_dir}/hicolor/${_dimensions}/apps) COMMAND ${sox_EXECUTABLE} -b 16 -Dr 44100 -n beep.wav
set (_png ${_png_path}/${name}.png) synth 0.1 0 25 triangle 800 vol 0.5 fade t 0 -0 0.005 pad 0 0.05
set (${output} ${_png} PARENT_SCOPE) COMMENT "Generating a beep sample" VERBATIM)
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 ()
# 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)
@@ -76,11 +56,10 @@ foreach (icon xW xW-highlighted)
list (APPEND icon_ico_list ${icon_ico}) list (APPEND icon_ico_list ${icon_ico})
endforeach () endforeach ()
set_property (SOURCE xW.rc APPEND PROPERTY OBJECT_DEPENDS ${icon_ico_list}) set_property (SOURCE xW.rc
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}
@@ -97,7 +76,7 @@ add_custom_target (xC-proto DEPENDS ${PROJECT_BINARY_DIR}/xC-proto.cpp)
add_executable (xW WIN32 xW.cpp xW.rc xW.manifest ${project_config} add_executable (xW WIN32 xW.cpp xW.rc xW.manifest ${project_config}
${root}/liberty/tools/lxdrgen-cpp-win32.cpp) ${root}/liberty/tools/lxdrgen-cpp-win32.cpp)
target_link_libraries (xW comctl32 ws2_32) target_link_libraries (xW comctl32 ws2_32 winmm)
add_dependencies (xW xC-proto) add_dependencies (xW xC-proto)
# At least with MinGW, this is a fully independent portable executable # At least with MinGW, this is a fully independent portable executable

View File

@@ -1,5 +1,6 @@
#define IDI_ICON 1 #define IDI_ICON 1
#define IDI_HIGHLIGHTED 2 #define IDI_HIGHLIGHTED 2
#define IDR_BEEP 3
#define IDA_ACCELERATORS 10 #define IDA_ACCELERATORS 10
// Named after input_add_functions() in xC. // Named after input_add_functions() in xC.
@@ -10,3 +11,8 @@
#define ID_GOTO_ACTIVITY 15 #define ID_GOTO_ACTIVITY 15
#define ID_TOGGLE_UNIMPORTANT 16 #define ID_TOGGLE_UNIMPORTANT 16
#define ID_DISPLAY_FULL_LOG 17 #define ID_DISPLAY_FULL_LOG 17
#define IDD_CONNECT 20
#define IDC_STATIC 21
#define IDC_HOST 22
#define IDC_PORT 23

251
xW/xW.cpp
View File

@@ -113,10 +113,14 @@ struct {
// Networking: // Networking:
std::wstring host; ///< Host as given by user
std::wstring port; ///< Port/service as given by user
addrinfoW *addresses; ///< GetAddrInfo() result addrinfoW *addresses; ///< GetAddrInfo() result
addrinfoW *addresses_iterator; ///< Currently processed address addrinfoW *addresses_iterator; ///< Currently processed address
SOCKET socket; ///< Relay socket SOCKET socket; ///< Relay socket
WSAEVENT event; ///< Relay socket event WSAEVENT socket_event; ///< Relay socket event
HANDLE flush_event; ///< Write buffer has new data
std::vector<uint8_t> write_buffer; ///< Write buffer std::vector<uint8_t> write_buffer; ///< Write buffer
std::vector<uint8_t> read_buffer; ///< Read buffer std::vector<uint8_t> read_buffer; ///< Read buffer
@@ -153,6 +157,23 @@ format_error_message(int err)
return copy; return copy;
} }
static std::wstring
window_get_text(HWND hWnd)
{
int length = GetWindowTextLength(hWnd);
std::wstring buffer(length, {});
GetWindowText(hWnd, buffer.data(), length + 1);
return buffer;
}
static void
beep()
{
if (!PlaySound(MAKEINTRESOURCE(IDR_BEEP),
GetModuleHandle(NULL), SND_ASYNC | SND_RESOURCE))
Beep(800, 100);
}
// --- Networking -------------------------------------------------------------- // --- Networking --------------------------------------------------------------
static bool static bool
@@ -181,6 +202,8 @@ relay_try_read(std::wstring &error)
static bool static bool
relay_try_write(std::wstring &error) relay_try_write(std::wstring &error)
{ {
ResetEvent(g.flush_event);
auto &w = g.write_buffer; auto &w = g.write_buffer;
int err = {}; int err = {};
while (!w.empty()) { while (!w.empty()) {
@@ -215,18 +238,10 @@ relay_send(Relay::CommandData *data, Callback callback = {})
g.write_buffer.insert(g.write_buffer.end(), prefix, prefix + sizeof len); g.write_buffer.insert(g.write_buffer.end(), prefix, prefix + sizeof len);
g.write_buffer.insert(g.write_buffer.end(), w.data.begin(), w.data.end()); g.write_buffer.insert(g.write_buffer.end(), w.data.begin(), w.data.end());
// Call relay_try_write() separately. // There doesn't seem to be a way to cause FD_WRITE without first
} // unsuccessfully trying to send some data, but we don't want to
// handle any errors at this level.
static void SetEvent(g.flush_event);
relay_send_now(Relay::CommandData *data, Callback callback = {})
{
relay_send(data, callback);
// TODO(p): Either tear down here, or run relay_try_write() from a timer.
std::wstring error;
if (!relay_try_write(error))
show_error_message(error.c_str());
} }
// --- Buffers ----------------------------------------------------------------- // --- Buffers -----------------------------------------------------------------
@@ -245,7 +260,7 @@ buffer_activate(const std::wstring &name)
{ {
auto activate = new Relay::CommandData_BufferActivate(); auto activate = new Relay::CommandData_BufferActivate();
activate->buffer_name = name; activate->buffer_name = name;
relay_send_now(activate); relay_send(activate);
} }
static void static void
@@ -253,7 +268,7 @@ buffer_toggle_unimportant(const std::wstring &name)
{ {
auto toggle = new Relay::CommandData_BufferToggleUnimportant(); auto toggle = new Relay::CommandData_BufferToggleUnimportant();
toggle->buffer_name = name; toggle->buffer_name = name;
relay_send_now(toggle); relay_send(toggle);
} }
// --- Current buffer ---------------------------------------------------------- // --- Current buffer ----------------------------------------------------------
@@ -299,7 +314,7 @@ buffer_toggle_log()
auto log = new Relay::CommandData_BufferLog(); auto log = new Relay::CommandData_BufferLog();
log->buffer_name = g.buffer_current; log->buffer_name = g.buffer_current;
relay_send_now(log, [name = g.buffer_current](auto error, auto response) { relay_send(log, [name = g.buffer_current](auto error, auto response) {
if (g.buffer_current != name) if (g.buffer_current != name)
return; return;
buffer_toggle_log(error, buffer_toggle_log(error,
@@ -411,10 +426,7 @@ refresh_status()
} }
// Buffer scrolling would cause a ton of flickering redraws. // Buffer scrolling would cause a ton of flickering redraws.
int length = GetWindowTextLength(g.hwndStatus); if (window_get_text(g.hwndStatus) != status)
std::wstring buffer(length, {});
GetWindowText(g.hwndStatus, buffer.data(), length + 1);
if (buffer != status)
SetWindowText(g.hwndStatus, status.c_str()); SetWindowText(g.hwndStatus, status.c_str());
} }
@@ -499,7 +511,7 @@ convert_item_formatting(Relay::ItemData *item, CHARFORMAT2 &cf, bool &inverse)
} }
static std::vector<BufferLineItem> static std::vector<BufferLineItem>
convert_items(std::vector<std::unique_ptr<Relay::ItemData>> &items) convert_items(const std::vector<std::unique_ptr<Relay::ItemData>> &items)
{ {
CHARFORMAT2 cf = default_charformat(); CHARFORMAT2 cf = default_charformat();
std::vector<BufferLineItem> result; std::vector<BufferLineItem> result;
@@ -666,14 +678,12 @@ buffer_print_line(std::vector<BufferLine>::const_iterator begin,
if (!prefix.empty()) if (!prefix.empty())
richedit_replacesel(g.hwndBuffer, &pcf, prefix.c_str()); richedit_replacesel(g.hwndBuffer, &pcf, prefix.c_str());
std::wstring text; for (auto it : line->items) {
for (const auto &it : line->items) it.format.dwEffects &= ~CFE_AUTOCOLOR;
text += it.text; it.format.crTextColor = GetSysColor(COLOR_GRAYTEXT);
it.format.dwEffects |= CFE_AUTOBACKCOLOR;
CHARFORMAT2 format = default_charformat(); richedit_replacesel(g.hwndBuffer, &it.format, it.text.c_str());
format.dwEffects &= ~CFE_AUTOCOLOR; }
format.crTextColor = GetSysColor(COLOR_GRAYTEXT);
richedit_replacesel(g.hwndBuffer, &format, text.c_str());
} else { } else {
if (!prefix.empty()) if (!prefix.empty())
richedit_replacesel(g.hwndBuffer, &pcf, prefix.c_str()); richedit_replacesel(g.hwndBuffer, &pcf, prefix.c_str());
@@ -685,10 +695,12 @@ buffer_print_line(std::vector<BufferLine>::const_iterator begin,
static void static void
buffer_print_separator() buffer_print_separator()
{ {
bool sameline = !GetWindowTextLength(g.hwndBuffer);
CHARFORMAT2 format = default_charformat(); CHARFORMAT2 format = default_charformat();
format.dwEffects &= ~CFE_AUTOCOLOR; format.dwEffects &= ~CFE_AUTOCOLOR;
format.crTextColor = RGB(0xff, 0x5f, 0x00); format.crTextColor = RGB(0xff, 0x5f, 0x00);
richedit_replacesel(g.hwndBuffer, &format, L"\n---"); richedit_replacesel(g.hwndBuffer, &format, &L"\n---"[sameline]);
} }
static void static void
@@ -774,8 +786,7 @@ relay_process_buffer_line(Buffer &b, Relay::EventData_BufferLine &m)
if (line->is_highlight || (!visible && !line->is_unimportant && if (line->is_highlight || (!visible && !line->is_unimportant &&
b.kind == Relay::BufferKind::PRIVATE_MESSAGE)) { b.kind == Relay::BufferKind::PRIVATE_MESSAGE)) {
// TODO(p): Avoid the PC speaker, which is also unreliable. beep();
Beep(800, 100);
if (!visible) { if (!visible) {
b.highlighted = true; b.highlighted = true;
@@ -809,8 +820,6 @@ relay_process_callbacks(uint32_t command_seq,
} }
} }
static std::wstring input_get_contents();
static void static void
relay_process_message(const Relay::EventMessage &m) relay_process_message(const Relay::EventMessage &m)
{ {
@@ -903,11 +912,15 @@ relay_process_message(const Relay::EventMessage &m)
if (!b) if (!b)
break; break;
b->buffer_name = data.buffer_name; b->buffer_name = data.new_;
refresh_buffer_list(); refresh_buffer_list();
if (b->buffer_name == g.buffer_current) if (data.buffer_name == g.buffer_current) {
g.buffer_current = data.new_;
refresh_status(); refresh_status();
}
if (data.buffer_name == g.buffer_last)
g.buffer_last = data.new_;
break; break;
} }
case Relay::Event::BUFFER_REMOVE: case Relay::Event::BUFFER_REMOVE:
@@ -939,7 +952,7 @@ relay_process_message(const Relay::EventMessage &m)
old->new_unimportant_messages = 0; old->new_unimportant_messages = 0;
old->highlighted = false; old->highlighted = false;
old->input = input_get_contents(); old->input = window_get_text(g.hwndInput);
SendMessage(g.hwndInput, EM_GETSEL, SendMessage(g.hwndInput, EM_GETSEL,
(WPARAM) &old->input_start, (LPARAM) &old->input_end); (WPARAM) &old->input_start, (LPARAM) &old->input_end);
@@ -954,6 +967,7 @@ relay_process_message(const Relay::EventMessage &m)
b->highlighted = false; b->highlighted = false;
SendMessage(g.hwndBufferList, LB_SETCURSEL, b - g.buffers.data(), 0); SendMessage(g.hwndBufferList, LB_SETCURSEL, b - g.buffers.data(), 0);
refresh_icon();
refresh_topic(b->topic); refresh_topic(b->topic);
refresh_buffer(*b); refresh_buffer(*b);
refresh_prompt(); refresh_prompt();
@@ -1067,8 +1081,8 @@ relay_destroy_socket()
{ {
closesocket(g.socket); closesocket(g.socket);
g.socket = INVALID_SOCKET; g.socket = INVALID_SOCKET;
WSACloseEvent(g.event); WSACloseEvent(g.socket_event);
g.event = NULL; g.socket_event = NULL;
g.read_buffer.clear(); g.read_buffer.clear();
g.write_buffer.clear(); g.write_buffer.clear();
@@ -1089,8 +1103,8 @@ relay_connect_step(std::wstring& error)
return false; return false;
} }
g.event = WSACreateEvent(); g.socket_event = WSACreateEvent();
if (WSAEventSelect(g.socket, g.event, if (WSAEventSelect(g.socket, g.socket_event,
FD_CONNECT | FD_READ | FD_WRITE | FD_CLOSE)) FD_CONNECT | FD_READ | FD_WRITE | FD_CLOSE))
error = format_error_message(WSAGetLastError()); error = format_error_message(WSAGetLastError());
else if (!connect(g.socket, p->ai_addr, (int) p->ai_addrlen)) else if (!connect(g.socket, p->ai_addr, (int) p->ai_addrlen))
@@ -1160,7 +1174,7 @@ static bool
relay_process_socket_events(std::wstring &error) relay_process_socket_events(std::wstring &error)
{ {
WSANETWORKEVENTS wne = {}; WSANETWORKEVENTS wne = {};
if (WSAEnumNetworkEvents(g.socket, g.event, &wne)) { if (WSAEnumNetworkEvents(g.socket, g.socket_event, &wne)) {
error = format_error_message(WSAGetLastError()); error = format_error_message(WSAGetLastError());
return false; return false;
} }
@@ -1180,15 +1194,6 @@ relay_process_socket_events(std::wstring &error)
// --- Input line -------------------------------------------------------------- // --- Input line --------------------------------------------------------------
static std::wstring
input_get_contents()
{
int length = GetWindowTextLength(g.hwndInput);
std::wstring buffer(length, {});
GetWindowText(g.hwndInput, buffer.data(), length + 1);
return buffer;
}
static void static void
input_set_contents(const std::wstring &input) input_set_contents(const std::wstring &input)
{ {
@@ -1206,7 +1211,7 @@ input_submit()
auto input = new Relay::CommandData_BufferInput(); auto input = new Relay::CommandData_BufferInput();
input->buffer_name = b->buffer_name; input->buffer_name = b->buffer_name;
input->text = input_get_contents(); input->text = window_get_text(g.hwndInput);
// Buffer::history[Buffer::history.size()] is virtual, // Buffer::history[Buffer::history.size()] is virtual,
// and is represented either by edit contents when it's currently // and is represented either by edit contents when it's currently
@@ -1215,7 +1220,7 @@ input_submit()
b->history_at = b->history.size(); b->history_at = b->history.size();
input_set_contents({}); input_set_contents({});
relay_send_now(input); relay_send(input);
return true; return true;
} }
@@ -1230,7 +1235,7 @@ input_stamp()
{ {
DWORD start = {}, end = {}; DWORD start = {}, end = {};
SendMessage(g.hwndInput, EM_GETSEL, (WPARAM) &start, (LPARAM) &end); SendMessage(g.hwndInput, EM_GETSEL, (WPARAM) &start, (LPARAM) &end);
return {start, end, input_get_contents()}; return {start, end, window_get_text(g.hwndInput)};
} }
static void static void
@@ -1260,9 +1265,8 @@ input_complete(const InputStamp &state, const std::wstring &error,
SendMessage(g.hwndInput, EM_REPLACESEL, TRUE, (LPARAM) insert.c_str()); SendMessage(g.hwndInput, EM_REPLACESEL, TRUE, (LPARAM) insert.c_str());
} }
// TODO(p): Avoid the PC speaker, which is also unreliable.
if (response->completions.size() != 1) if (response->completions.size() != 1)
Beep(800, 100); beep();
// TODO(p): Show all completion options. // TODO(p): Show all completion options.
} }
@@ -1283,7 +1287,7 @@ input_complete()
complete->buffer_name = g.buffer_current; complete->buffer_name = g.buffer_current;
complete->text = state.input; complete->text = state.input;
complete->position = utf8.length(); complete->position = utf8.length();
relay_send_now(complete, [state](auto error, auto response) { relay_send(complete, [state](auto error, auto response) {
auto stamp = input_stamp(); auto stamp = input_stamp();
if (std::make_tuple(stamp.start, stamp.end, stamp.input) != if (std::make_tuple(stamp.start, stamp.end, stamp.input) !=
std::make_tuple(state.start, state.end, state.input)) std::make_tuple(state.start, state.end, state.input))
@@ -1294,6 +1298,32 @@ input_complete()
return true; return true;
} }
static bool
input_up()
{
auto b = buffer_by_name(g.buffer_current);
if (!b || b->history_at < 1)
return false;
if (b->history_at == b->history.size())
b->input = window_get_text(g.hwndInput);
input_set_contents(b->history.at(--b->history_at));
return true;
}
static bool
input_down()
{
auto b = buffer_by_name(g.buffer_current);
if (!b || b->history_at >= b->history.size())
return false;
input_set_contents(++b->history_at == b->history.size()
? b->input
: b->history.at(b->history_at));
return true;
}
static boolean static boolean
input_wants(const MSG *message) input_wants(const MSG *message)
{ {
@@ -1325,34 +1355,18 @@ input_proc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam,
return lResult; return lResult;
} }
case WM_SYSCHAR: case WM_SYSCHAR:
{
auto b = buffer_by_name(g.buffer_current);
if (!b)
break;
// TODO(p): Emacs-style cursor movement shortcuts. // TODO(p): Emacs-style cursor movement shortcuts.
switch (wParam) { switch (wParam) {
case 'p': case 'p':
{ if (input_up())
if (b->history_at < 1) return 0;
break; break;
if (b->history_at == b->history.size())
b->input = input_get_contents();
input_set_contents(b->history.at(--b->history_at));
return 0;
}
case 'n': case 'n':
{ if (input_down())
if (b->history_at >= b->history.size()) return 0;
break; break;
input_set_contents(++b->history_at == b->history.size()
? b->input
: b->history.at(b->history_at));
return 0;
}
} }
break; break;
}
case WM_KEYDOWN: case WM_KEYDOWN:
{ {
HWND scrollable = IsWindowVisible(g.hwndBufferLog) HWND scrollable = IsWindowVisible(g.hwndBufferLog)
@@ -1360,6 +1374,14 @@ input_proc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam,
: g.hwndBuffer; : g.hwndBuffer;
switch (wParam) { switch (wParam) {
case VK_UP:
if (input_up())
return 0;
break;
case VK_DOWN:
if (input_down())
return 0;
break;
case VK_PRIOR: case VK_PRIOR:
SendMessage(scrollable, EM_SCROLL, SB_PAGEUP, 0); SendMessage(scrollable, EM_SCROLL, SB_PAGEUP, 0);
return 0; return 0;
@@ -1372,7 +1394,7 @@ input_proc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam,
case WM_CHAR: case WM_CHAR:
{ {
// This could be implemented more precisely, but it will do. // This could be implemented more precisely, but it will do.
relay_send_now(new Relay::CommandData_Active()); relay_send(new Relay::CommandData_Active());
switch (wParam) { switch (wParam) {
case VK_RETURN: case VK_RETURN:
@@ -1412,7 +1434,7 @@ bufferlist_proc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam,
auto input = new Relay::CommandData_BufferInput(); auto input = new Relay::CommandData_BufferInput();
input->buffer_name = g.buffer_current; input->buffer_name = g.buffer_current;
input->text = L"/buffer close " + g.buffers.at(index).buffer_name; input->text = L"/buffer close " + g.buffers.at(index).buffer_name;
relay_send_now(input); relay_send(input);
return 0; return 0;
} }
case WM_NCDESTROY: case WM_NCDESTROY:
@@ -1627,6 +1649,21 @@ window_proc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
if (auto b = buffer_by_name(g.buffer_current)) if (auto b = buffer_by_name(g.buffer_current))
refresh_buffer(*b); refresh_buffer(*b);
return 0; return 0;
case WM_SYSCOLORCHANGE:
// The topic would flicker with WS_EX_TRANSPARENT.
// The buffer only changed its text colour, not background.
SendMessage(g.hwndTopic,
EM_SETBKGNDCOLOR, 0, GetSysColor(COLOR_3DFACE));
SendMessage(g.hwndBuffer,
EM_SETBKGNDCOLOR, 1, 0);
// XXX: This is incomplete, we'd have to run convert_items() again;
// essentially only COLOR_GRAYTEXT is reloaded in here.
if (auto b = buffer_by_name(g.buffer_current))
refresh_buffer(*b);
// Pass it to all child windows, through DefWindowProc().
break;
case WM_ACTIVATE: case WM_ACTIVATE:
if (LOWORD(wParam) == WA_INACTIVE) if (LOWORD(wParam) == WA_INACTIVE)
g.hwndLastFocused = GetFocus(); g.hwndLastFocused = GetFocus();
@@ -1683,10 +1720,33 @@ window_proc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
break; break;
} }
} }
case DM_GETDEFID:
case DM_SETDEFID:
break;
} }
return DefWindowProc(hWnd, uMsg, wParam, lParam); return DefWindowProc(hWnd, uMsg, wParam, lParam);
} }
static INT_PTR CALLBACK
connect_proc(
HWND hDlg, UINT uMsg, WPARAM wParam, [[maybe_unused]] LPARAM lParam)
{
switch (uMsg) {
case WM_INITDIALOG:
return TRUE;
case WM_COMMAND:
switch (LOWORD(wParam)) {
case IDOK:
case IDCANCEL:
g.host = window_get_text(GetDlgItem(hDlg, IDC_HOST));
g.port = window_get_text(GetDlgItem(hDlg, IDC_PORT));
EndDialog(hDlg, LOWORD(wParam));
return TRUE;
}
}
return FALSE;
}
static void static void
get_font() get_font()
{ {
@@ -1865,11 +1925,14 @@ wWinMain(HINSTANCE hInstance, [[maybe_unused]] HINSTANCE hPrevInstance,
int argc = 0; int argc = 0;
LPWSTR *argv = CommandLineToArgvW(pCmdLine, &argc); LPWSTR *argv = CommandLineToArgvW(pCmdLine, &argc);
if (argc < 2) { if (argc >= 2) {
show_error_message( g.host = argv[0];
L"You must pass the relay address and port on the command line."); g.port = argv[1];
return 1; } else if (DialogBox(hInstance, MAKEINTRESOURCE(IDD_CONNECT),
g.hwndMain, connect_proc) != IDOK) {
return 0;
} }
LocalFree(argv);
// We have a few suboptimal asynchronous options: // We have a few suboptimal asynchronous options:
// a) WSAAsyncGetHostByName() requires us to distinguish hostnames // a) WSAAsyncGetHostByName() requires us to distinguish hostnames
@@ -1880,8 +1943,7 @@ wWinMain(HINSTANCE hInstance, [[maybe_unused]] HINSTANCE hPrevInstance,
hints.ai_family = AF_UNSPEC; hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM; hints.ai_socktype = SOCK_STREAM;
hints.ai_protocol = IPPROTO_TCP; hints.ai_protocol = IPPROTO_TCP;
err = GetAddrInfo(argv[0], argv[1], &hints, &g.addresses); err = GetAddrInfo(g.host.c_str(), g.port.c_str(), &hints, &g.addresses);
LocalFree(argv);
if (err) { if (err) {
show_error_message(format_error_message(err).c_str()); show_error_message(format_error_message(err).c_str());
return 1; return 1;
@@ -1894,15 +1956,15 @@ wWinMain(HINSTANCE hInstance, [[maybe_unused]] HINSTANCE hPrevInstance,
return 1; return 1;
} }
g.date_change_timer = CreateWaitableTimer(NULL, FALSE, NULL); if (!(g.date_change_timer = CreateWaitableTimer(NULL, FALSE, NULL)) ||
if (!g.date_change_timer) { !(g.flush_event = CreateEvent(NULL, FALSE, FALSE, NULL))) {
show_error_message(format_error_message(GetLastError()).c_str()); show_error_message(format_error_message(GetLastError()).c_str());
return 1; return 1;
} }
while (process_messages(accelerators)) { while (process_messages(accelerators)) {
HANDLE handles[] = {g.date_change_timer, g.event}; HANDLE handles[] = {g.date_change_timer, g.flush_event, g.socket_event};
DWORD count = 2 - !handles[1]; DWORD count = 3 - !handles[2];
DWORD result = MsgWaitForMultipleObjects( DWORD result = MsgWaitForMultipleObjects(
count, handles, FALSE, INFINITE, QS_ALLINPUT); count, handles, FALSE, INFINITE, QS_ALLINPUT);
if (result == WAIT_FAILED) { if (result == WAIT_FAILED) {
@@ -1919,7 +1981,11 @@ wWinMain(HINSTANCE hInstance, [[maybe_unused]] HINSTANCE hPrevInstance,
if (to_bottom) if (to_bottom)
buffer_scroll_to_bottom(); buffer_scroll_to_bottom();
} }
if (signalled == g.event && !relay_process_socket_events(error)) { if (signalled == g.flush_event && !relay_try_write(error)) {
show_error_message(error.c_str());
return 1;
}
if (signalled == g.socket_event && !relay_process_socket_events(error)) {
show_error_message(error.c_str()); show_error_message(error.c_str());
return 1; return 1;
} }
@@ -1927,5 +1993,6 @@ wWinMain(HINSTANCE hInstance, [[maybe_unused]] HINSTANCE hPrevInstance,
FreeAddrInfo(g.addresses); FreeAddrInfo(g.addresses);
WSACleanup(); WSACleanup();
CloseHandle(g.date_change_timer); CloseHandle(g.date_change_timer);
CloseHandle(g.flush_event);
return 0; return 0;
} }

View File

@@ -8,4 +8,18 @@
publicKeyToken="6595b64144ccf1df" language="*" /> publicKeyToken="6595b64144ccf1df" language="*" />
</dependentAssembly> </dependentAssembly>
</dependency> </dependency>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 10 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
<!-- Windows 8.1 -->
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" />
<!-- Windows 8 -->
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}" />
<!-- Windows 7 -->
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}" />
<!-- Windows Vista -->
<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}" />
</application>
</compatibility>
</assembly> </assembly>

View File

@@ -12,6 +12,7 @@ CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "xW.manifest"
IDI_ICON ICON "xW.ico" IDI_ICON ICON "xW.ico"
IDI_HIGHLIGHTED ICON "xW-highlighted.ico" IDI_HIGHLIGHTED ICON "xW-highlighted.ico"
IDR_BEEP WAVE "beep.wav"
IDA_ACCELERATORS ACCELERATORS IDA_ACCELERATORS ACCELERATORS
BEGIN BEGIN
@@ -31,6 +32,27 @@ BEGIN
#endif #endif
END END
// https://devblogs.microsoft.com/oldnewthing/20050204-00/?p=36523
// https://devblogs.microsoft.com/oldnewthing/20050207-00/?p=36513
//
// Note that this is still not the right font to use in newest Windows,
// that would be 9pt Segoe UI, as described in:
// https://learn.microsoft.com/en-us/windows/win32/uxguide/vis-fonts
// or even better yet, NONCLIENTMETRICS::lfMessageFont.
IDD_CONNECT DIALOGEX 0, 0, 150, 64
STYLE DS_SHELLFONT | DS_MODALFRAME | DS_CENTER \
| WS_POPUP | WS_CAPTION | WS_SYSMENU
CAPTION "Connect to Relay"
FONT 8, "MS Shell Dlg", 400 /*FW_NORMAL*/, 0 /*FALSE*/, 0x1 /*DEFAULT_CHARSET*/
BEGIN
LTEXT "&Host:", IDC_STATIC, 7, 10, 18, 8
EDITTEXT IDC_HOST, 39, 7, 104, 14, ES_AUTOHSCROLL
LTEXT "&Port:", IDC_STATIC, 7, 28, 18, 8
EDITTEXT IDC_PORT, 39, 25, 104, 14, ES_AUTOHSCROLL
DEFPUSHBUTTON "&Connect", IDOK, 39, 43, 50, 14
PUSHBUTTON "E&xit", IDCANCEL, 93, 43, 50, 14
END
VS_VERSION_INFO VERSIONINFO VS_VERSION_INFO VERSIONINFO
FILEVERSION PROJECT_MAJOR, PROJECT_MINOR, PROJECT_PATCH, PROJECT_TWEAK FILEVERSION PROJECT_MAJOR, PROJECT_MINOR, PROJECT_PATCH, PROJECT_TWEAK
PRODUCTVERSION PROJECT_MAJOR, PROJECT_MINOR, PROJECT_PATCH, PROJECT_TWEAK PRODUCTVERSION PROJECT_MAJOR, PROJECT_MINOR, PROJECT_PATCH, PROJECT_TWEAK