From 7d51aaa9a49d8f7461993a564b5dfbac4e4ba6de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C5=99emysl=20Janouch?= Date: Mon, 24 Sep 2018 13:11:11 +0200 Subject: [PATCH] hpcu: add a selection unifier So far not supporting large selections. --- README | 21 ++- hpcu/main.go | 351 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 368 insertions(+), 4 deletions(-) create mode 100644 hpcu/main.go diff --git a/README b/README index 2095516..ccdd176 100644 --- a/README +++ b/README @@ -173,6 +173,22 @@ The result of testing hid with telnet, OpenSSL s_client, OpenBSD nc, GNU nc and Ncat is that neither of them can properly shutdown the connection. We need a good implementation with TLS support. +hpcu -- PRIMARY-CLIPBOARD unifier +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +An improved replacement for autocutsel in selection synchronization "mode": + + - using only one OS process; + - not polling selections twice a second unnecessarily; + - calling SetSelectionOwner on change even when it already owns the selection, + so that XFIXES SelectionNotify events are delivered; + - not using cut buffers for anything. + +Only UTF8_STRING-convertible selections are synchronized. + +ht -- terminal emulator +~~~~~~~~~~~~~~~~~~~~~~~ +Similar scope to st(1). Clever display of internal padding for better looks. + hib and hic -- IRC bouncer and client ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ An IRC client is a good starting application for building a GUI toolkit, as the @@ -241,6 +257,7 @@ most basic features includes a VFS for archives. The editing widget in read- -only mode could be used for F3. The shell is going to work very simply, creating a PTY device and running things under TERM=dumb while decoding SGR, or one could decide to run a new terminal emulator with a different shortcut. +ht could probably also be integrated. Eventually the number of panels should be arbitrary with proper shortcuts for working with them. We might also integrate a special view for picture previews, @@ -255,10 +272,6 @@ Indexing and search may be based on a common database, no need to get all fancy: http://rachbelaid.com/postgres-full-text-search-is-good-enough/ https://www.sqlite.org/fts3.html#full_text_index_queries (FTS4 seems better) -ht -- terminal emulator -~~~~~~~~~~~~~~~~~~~~~~~ -Similar scope to st(1). Clever display of internal padding for better looks. - The rest ~~~~~~~~ Currently there are no significant, specific plans about the other applications. diff --git a/hpcu/main.go b/hpcu/main.go new file mode 100644 index 0000000..6240ef1 --- /dev/null +++ b/hpcu/main.go @@ -0,0 +1,351 @@ +// hpcu unifies the PRIMARY and CLIPBOARD X11 selections for text contents. +package main + +import ( + "errors" + "janouch.name/haven/nexgb" + "janouch.name/haven/nexgb/xfixes" + "janouch.name/haven/nexgb/xproto" + "log" +) + +type selectionState struct { + name string // name of the selection + inProgress xproto.Timestamp // timestamp of retrieved selection + buffer []byte // UTF-8 text buffer + incr bool // INCR running + incrFailed bool // INCR failure indicator + owning xproto.Timestamp // since when we own the selection +} + +var ( + X *nexgb.Conn + setup *xproto.SetupInfo + screen *xproto.ScreenInfo + + atomCLIPBOARD xproto.Atom // X11 atom for CLIPBOARD + atomUTF8String xproto.Atom // X11 atom for UTF8_STRING + atomINCR xproto.Atom // X11 atom for INCR + atomTARGETS xproto.Atom // X11 atom for TARGETS + atomTIMESTAMP xproto.Atom // X11 atom for TIMESTAMP + + wid xproto.Window // auxiliary window + selections map[xproto.Atom]*selectionState + contents string // current shared selection contents +) + +// resolveAtoms resolves a few required atoms that are not in the core protocol. +func resolveAtoms() error { + for _, i := range []struct { + placement *xproto.Atom + name string + }{ + {&atomCLIPBOARD, "CLIPBOARD"}, + {&atomUTF8String, "UTF8_STRING"}, + {&atomINCR, "INCR"}, + {&atomTARGETS, "TARGETS"}, + {&atomTIMESTAMP, "TIMESTAMP"}, + } { + if reply, err := xproto.InternAtom(X, + false, uint16(len(i.name)), i.name).Reply(); err != nil { + return err + } else { + *i.placement = reply.Atom + } + } + return nil +} + +// setupAuxiliaryWindow creates a window that receives notifications about +// changed selection contents, and serves +func setupAuxiliaryWindow() error { + var err error + if wid, err = xproto.NewWindowId(X); err != nil { + return err + } + + _ = xproto.CreateWindow(X, screen.RootDepth, wid, screen.Root, 0, 0, 1, 1, + 0, xproto.WindowClassInputOutput, screen.RootVisual, xproto.CwEventMask, + []uint32{xproto.EventMaskPropertyChange}) + + for _, selection := range []xproto.Atom{xproto.AtomPrimary, atomCLIPBOARD} { + _ = xfixes.SelectSelectionInput(X, wid, selection, + xfixes.SelectionEventMaskSetSelectionOwner| + xfixes.SelectionEventMaskSelectionWindowDestroy| + xfixes.SelectionEventMaskSelectionClientClose) + } + return nil +} + +// getProperty reads a window property in a memory-efficient manner. +func getProperty(window xproto.Window, property xproto.Atom) ( + *xproto.GetPropertyReply, error) { + // xorg-xserver doesn't seem to limit the length of replies or even + // the length of properties in the first place. It only has a huge + // (0xffffffff - sizeof(xChangePropertyReq))/4 limit for ChangeProperty + // requests, even though I can't XChangeProperty more than 0xffffe0 + // bytes at a time. + // + // Since the XGB API doesn't let us provide our own buffer for + // value data, let us avoid multiplying the amount of consumed memory in + // pathological cases where properties are several gigabytes in size by + // chunking the requests. This has a cost of losing atomicity, although + // it shouldn't pose a problem except for timeout-caused INCR races. + var result xproto.GetPropertyReply + for result.Length == 0 || result.BytesAfter > 0 { + reply, err := xproto.GetProperty(X, false, /* delete */ + window, property, xproto.GetPropertyTypeAny, + uint32(len(result.Value))/4, + uint32(setup.MaximumRequestLength)).Reply() + if err != nil { + return nil, err + } + if result.Length != 0 && + (reply.Format != result.Format || reply.Type != result.Type) { + return nil, errors.New("property type changed during read") + } + + reply.Value = append(result.Value, reply.Value...) + reply.ValueLen += result.ValueLen + result = *reply + } + return &result, nil +} + +// appendText tries to append UTF-8 text to the selection state buffer. +func appendText(state *selectionState, prop *xproto.GetPropertyReply) bool { + if prop.Type == atomUTF8String && prop.Format == 8 { + state.buffer = append(state.buffer, prop.Value...) + return true + } + return false +} + +func requestOwnership(origin *selectionState, time xproto.Timestamp) { + contents = string(origin.buffer) + for selection, state := range selections { + // We might want to replace the originator as well but it might have + // undesirable effects, mainly with PRIMARY. + if state != origin { + // No need to GetSelectionOwner, XFIXES is more reliable. + _ = xproto.SetSelectionOwner(X, wid, selection, time) + } + } +} + +func handleEvent(ev nexgb.Event) { + switch e := ev.(type) { + case xfixes.SelectionNotifyEvent: + state, ok := selections[e.Selection] + if !ok { + break + } + + // Ownership request has been granted, don't ask ourselves for data. + if e.Owner == wid { + state.owning = e.SelectionTimestamp + break + } + + // This should always be true. + if state.owning < e.SelectionTimestamp { + state.owning = 0 + } + + // Not checking whether we should give up when our current retrieval + // attempt is interrupted--the timeout mostly solves this. + if e.Owner == xproto.WindowNone { + break + } + + // Don't try to process two things at once. Each request gets a few + // seconds to finish, then we move on, hoping that a property race + // doesn't commence. Ideally we'd set up a separate queue for these + // skipped requests and process them later. + if state.inProgress != 0 && e.Timestamp-state.inProgress < 5000 { + break + } + + // ICCCM says we should ensure the named property doesn't exist. + _ = xproto.DeleteProperty(X, e.Window, e.Selection) + + _ = xproto.ConvertSelection(X, e.Window, e.Selection, + atomUTF8String, e.Selection, e.Timestamp) + + state.inProgress = e.Timestamp + state.incr = false + + case xproto.SelectionNotifyEvent: + state, ok := selections[e.Selection] + if e.Requestor != wid || !ok || e.Time != state.inProgress { + break + } + + state.inProgress = 0 + if e.Property == xproto.AtomNone { + break + } + + state.buffer = nil + reply, err := getProperty(e.Requestor, e.Property) + if err != nil { + break + } + + // When you select a lot of text in VIM, it starts the ICCCM + // INCR mechanism, from which there is no opt-out. + if reply.Type == atomINCR { + state.inProgress = e.Time + state.incr = true + state.incrFailed = false + } else if appendText(state, reply) { + requestOwnership(state, e.Time) + } + + _ = xproto.DeleteProperty(X, e.Requestor, e.Property) + + case xproto.PropertyNotifyEvent: + state, ok := selections[e.Atom] + if e.Window != wid || e.State != xproto.PropertyNewValue || + !ok || !state.incr { + break + } + + reply, err := getProperty(e.Window, e.Atom) + if err != nil { + state.incrFailed = true + break + } + + if !appendText(state, reply) { + // We need to keep deleting the property. + state.incrFailed = true + } + + if reply.ValueLen == 0 { + if !state.incrFailed { + requestOwnership(state, e.Time) + } + state.inProgress = 0 + state.incr = false + } + + _ = xproto.DeleteProperty(X, e.Window, e.Atom) + + case xproto.SelectionRequestEvent: + property := e.Property + if property == xproto.AtomNone { + property = e.Target + } + + state, ok := selections[e.Selection] + if e.Owner != wid || !ok { + break + } + + var ( + typ xproto.Atom + format byte + data []byte + ) + + // XXX: We should also support the MULTIPLE target but it seems to be + // unimportant and largely abandoned today. + targets := []xproto.Atom{atomTARGETS, atomTIMESTAMP, atomUTF8String} + + switch e.Target { + case atomTARGETS: + typ = xproto.AtomAtom + format = 32 + + data = make([]byte, len(targets)*4) + for i, atom := range targets { + nexgb.Put32(data[i*4:], uint32(atom)) + } + + case atomTIMESTAMP: + typ = xproto.AtomInteger + format = 32 + + data = make([]byte, 4) + nexgb.Put32(data, uint32(state.owning)) + + case atomUTF8String: + typ = atomUTF8String + format = 8 + + data = []byte(contents) + } + + response := xproto.SelectionNotifyEvent{ + Time: e.Time, + Requestor: e.Requestor, + Selection: e.Selection, + Target: e.Target, + Property: xproto.AtomNone, + } + + if typ == 0 || len(data) > int(setup.MaximumRequestLength)*4-64 || + state.owning == 0 || e.Time < state.owning { + // TODO: Use the INCR mechanism for large data transfers instead + // of refusing the request, or at least use PropModeAppend. + // + // According to the ICCCM we need to set up a queue for concurrent + // (requestor, selection, target, timestamp) requests that differ + // only in the target property, and process them in order. The ICCCM + // has a nice rationale. It seems to only concern INCR. The queue + // might be a map[(who, what, how, when)][](where, data, offset). + // + // NOTE: Even with BigRequests support, it may technically be + // missing on the particular X server, and XGB copies buffers to yet + // another buffer, making very large transfers a very bad idea. + } else if xproto.ChangePropertyChecked(X, xproto.PropModeReplace, + e.Requestor, property, typ, format, + uint32(len(data)/int(format/8)), data).Check() == nil { + response.Property = property + } + + _ = xproto.SendEvent(X, false /* propagate */, e.Requestor, + 0 /* event mask */, string(response.Bytes())) + } +} + +func main() { + var err error + if X, err = nexgb.NewConn(); err != nil { + log.Fatalln(err) + } + if err = xfixes.Init(X); err != nil { + log.Fatalln(err) + } + + // Enable the extension. + _ = xfixes.QueryVersion(X, xfixes.MajorVersion, xfixes.MinorVersion) + + setup = xproto.Setup(X) + screen = setup.DefaultScreen(X) + + if err = resolveAtoms(); err != nil { + log.Fatalln(err) + } + if err = setupAuxiliaryWindow(); err != nil { + log.Fatalln(err) + } + + // Now that we have our atoms, we can initialize state. + selections = map[xproto.Atom]*selectionState{ + xproto.AtomPrimary: {name: "PRIMARY"}, + atomCLIPBOARD: {name: "CLIPBOARD"}, + } + + for { + ev, xerr := X.WaitForEvent() + if xerr != nil { + log.Printf("Error: %s\n", xerr) + return + } + if ev != nil { + handleEvent(ev) + } + } +}