diff --git a/cmd/occamy-gui/README.md b/cmd/occamy-gui/README.md new file mode 100644 index 0000000..376f70f --- /dev/null +++ b/cmd/occamy-gui/README.md @@ -0,0 +1,10 @@ +# Occamy GUI + +This is a minimum example of building an occamy client. + +It is based on the [bring](https://github.com/deluan/bring) implementation of the Guacamole protocol. + +Note that `bring` is a software renderer that does not utilize Gio's GPU backend. +Hence there is a lot of improvements for building this application. + +Later coding will revise the code into a gio accelerated rendering. \ No newline at end of file diff --git a/cmd/occamy-gui/occamy.go b/cmd/occamy-gui/occamy.go new file mode 100644 index 0000000..34eceb8 --- /dev/null +++ b/cmd/occamy-gui/occamy.go @@ -0,0 +1,114 @@ +// Copyright 2021 Changkun Ou. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package main + +import ( + "image" + "log" + "os" + + "changkun.de/x/occamy/internal/guac" + + "gioui.org/app" + "gioui.org/io/event" + "gioui.org/io/key" + "gioui.org/io/pointer" + "gioui.org/io/system" + "gioui.org/op" + "gioui.org/op/paint" + "gioui.org/unit" +) + +func main() { + if len(os.Args) < 3 { + log.Fatal("Usage: occamy-gui host:port") + return + } + + a, err := NewApp(os.Args[1], os.Args[2]) + if err != nil { + log.Fatalf("cannot create Occamy client: %v", err) + } + go a.Run() + app.Main() +} + +type App struct { + client *guac.Client + win *app.Window +} + +func NewApp(protocol, addr string) (a *App, err error) { + log.SetPrefix("occamy: ") + log.SetFlags(log.Ldate | log.Ltime | log.Lmicroseconds | log.Lshortfile) + + a = &App{} + a.win = app.NewWindow(app.Title("Occamy GUI Client")) + a.client, err = guac.NewClient("0.0.0.0:5636", map[string]string{ + "host": addr, + "protocol": protocol, + "username": "", + "password": "vncpassword", + }, a.win) + if err != nil { + return nil, err + } + w, h := 1280*2, 1024*2 + a.win.Option( + app.Size(unit.Px(float32(w)), unit.Px(float32(h))), + app.MaxSize(unit.Px(float32(w)), unit.Px(float32(h))), + app.MinSize(unit.Px(float32(w)), unit.Px(float32(h))), + ) + return a, nil +} + +func (a *App) Run() { + for e := range a.win.Events() { + switch e := e.(type) { + case system.DestroyEvent: + log.Println(e.Err) + os.Exit(0) + case system.FrameEvent: + ops := &op.Ops{} + a.updateScreen(ops, e.Queue) + e.Frame(ops) + case pointer.Event: + if err := a.client.SendMouse( + image.Point{X: int(e.Position.X), Y: int(e.Position.Y)}, + guac.MouseToGioButton[e.Buttons]); err != nil { + log.Println(err) + } + a.win.Invalidate() + case key.Event: + log.Printf("%+v, %+v", e.Name, e.Modifiers) + + // TODO: keyboard seems problematic, yet. + // See https://todo.sr.ht/~eliasnaur/gio/319 + // var keycode guac.KeyCode + // switch { + // case e.Modifiers.Contain(key.ModCtrl): + // keycode = guac.KeyCode(guac.KeyLeftControl) + // case e.Modifiers.Contain(key.ModCommand): + // keycode = guac.KeyCode(guac.KeyLeftControl) + // case e.Modifiers.Contain(key.ModShift): + // keycode = guac.KeyCode(guac.KeyLeftShift) + // case e.Modifiers.Contain(key.ModAlt): + // keycode = guac.KeyCode(guac.KeyLeftAlt) + // case e.Modifiers.Contain(key.ModSuper): + // keycode = guac.KeyCode(guac.KeySuper) + // } + // err := a.client.SendKey(keycode, e.State == key.Press) + // if err != nil { + // log.Println(err) + // } + } + } +} + +func (a *App) updateScreen(ops *op.Ops, q event.Queue) { + img, _ := a.client.Screen() + paint.NewImageOp(img).Add(ops) + paint.PaintOp{}.Add(ops) +} diff --git a/go.mod b/go.mod index 10a0f25..f89587b 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module changkun.de/x/occamy go 1.15 require ( - gioui.org v0.0.0-20211202105001-872b4ba41be0 + gioui.org v0.0.0-20211207114553-03016f0c69b7 github.com/appleboy/gin-jwt/v2 v2.6.2 github.com/gin-gonic/gin v1.4.0 github.com/gorilla/websocket v1.4.0 diff --git a/go.sum b/go.sum index ff094b6..e51eedf 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,10 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -gioui.org v0.0.0-20211126213425-227c5a132be6 h1:I/VXI7iHRGYHVqkhLBOxw43zvDH+uzANxKhCj2DNbms= -gioui.org v0.0.0-20211126213425-227c5a132be6/go.mod h1:yoWOxPng6WkDpsud+NRmkoftmyWn3rkKsYGEcWHpjTI= gioui.org v0.0.0-20211202105001-872b4ba41be0 h1:rXO+2zdXvX6G19M5oF1fs1U7kmUWb4uXCKWa+WHZSpA= gioui.org v0.0.0-20211202105001-872b4ba41be0/go.mod h1:yoWOxPng6WkDpsud+NRmkoftmyWn3rkKsYGEcWHpjTI= +gioui.org v0.0.0-20211207114553-03016f0c69b7 h1:iJza27sQfbK41uk2Jlx8MpsrlWqMj7n5vCg0GGcC9DM= +gioui.org v0.0.0-20211207114553-03016f0c69b7/go.mod h1:yoWOxPng6WkDpsud+NRmkoftmyWn3rkKsYGEcWHpjTI= gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ= gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2 h1:AGDDxsJE1RpcXTAxPG2B4jrwVUJGFDjINIPi1jtO6pc= gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ= diff --git a/internal/guac/buttons.go b/internal/guac/buttons.go new file mode 100644 index 0000000..c42d03c --- /dev/null +++ b/internal/guac/buttons.go @@ -0,0 +1,269 @@ +// Copyright 2021 Changkun Ou. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +// The following code is modified from +// https://github.com/deluan/bring +// Authored by Deluan Quintao released under MIT license. + +package guac + +import "gioui.org/io/pointer" + +// Mouse buttons recognized by guacd +type MouseButton int + +const ( + MouseLeft MouseButton = 1 << iota + MouseMiddle + MouseRight + MouseUp + MouseDown +) + +var MouseToGioButton = map[pointer.Buttons]MouseButton{ + pointer.ButtonPrimary: MouseLeft, + pointer.ButtonTertiary: MouseMiddle, + pointer.ButtonSecondary: MouseRight, +} + +// Keys recognized by guacd. ASCII symbols from 32 to 126 do not need mapping. +type KeyCode int32 + +const ( + KeyAgain KeyCode = 1024 + iota + KeyAllCandidates + KeyAlphanumeric + KeyLeftAlt + KeyRightAlt + KeyAttn + KeyAltGraph + KeyArrowDown + KeyArrowLeft + KeyArrowRight + KeyArrowUp + KeyBackspace + KeyCapsLock + KeyCancel + KeyClear + KeyConvert + KeyCopy + KeyCrsel + KeyCrSel + KeyCodeInput + KeyCompose + KeyLeftControl + KeyRightControl + KeyContextMenu + KeyDelete + KeyDown + KeyEnd + KeyEnter + KeyEraseEof + KeyEscape + KeyExecute + KeyExsel + KeyExSel + KeyF1 + KeyF2 + KeyF3 + KeyF4 + KeyF5 + KeyF6 + KeyF7 + KeyF8 + KeyF9 + KeyF10 + KeyF11 + KeyF12 + KeyF13 + KeyF14 + KeyF15 + KeyF16 + KeyF17 + KeyF18 + KeyF19 + KeyF20 + KeyF21 + KeyF22 + KeyF23 + KeyF24 + KeyFind + KeyGroupFirst + KeyGroupLast + KeyGroupNext + KeyGroupPrevious + KeyFullWidth + KeyHalfWidth + KeyHangulMode + KeyHankaku + KeyHanjaMode + KeyHelp + KeyHiragana + KeyHiraganaKatakana + KeyHome + KeyHyper + KeyInsert + KeyJapaneseHiragana + KeyJapaneseKatakana + KeyJapaneseRomaji + KeyJunjaMode + KeyKanaMode + KeyKanjiMode + KeyKatakana + KeyLeft + KeyMeta + KeyModeChange + KeyNumLock + KeyPageDown + KeyPageUp + KeyPause + KeyPlay + KeyPreviousCandidate + KeyPrintScreen + KeyRedo + KeyRight + KeyRomanCharacters + KeyScroll + KeySelect + KeySeparator + KeyLeftShift + KeyRightShift + KeySingleCandidate + KeySuper + KeyTab + KeyUIKeyInputDownArrow + KeyUIKeyInputEscape + KeyUIKeyInputLeftArrow + KeyUIKeyInputRightArrow + KeyUIKeyInputUpArrow + KeyUp + KeyUndo + KeyWin + KeyZenkaku + KeyZenkakuHankaku +) + +// KeyCodes mapped to X11 keysyms (used by guacd) +type keySym []int + +var keySyms map[KeyCode]keySym + +func init() { + keySyms = make(map[KeyCode]keySym) + keySyms[KeyAgain] = keySym{0xFF66} + keySyms[KeyAllCandidates] = keySym{0xFF3D} + keySyms[KeyAlphanumeric] = keySym{0xFF30} + keySyms[KeyLeftAlt] = keySym{0xFFE9} + keySyms[KeyRightAlt] = keySym{0xFFE9, 0xFE03} + keySyms[KeyAttn] = keySym{0xFD0E} + keySyms[KeyAltGraph] = keySym{0xFE03} + keySyms[KeyArrowDown] = keySym{0xFF54} + keySyms[KeyArrowLeft] = keySym{0xFF51} + keySyms[KeyArrowRight] = keySym{0xFF53} + keySyms[KeyArrowUp] = keySym{0xFF52} + keySyms[KeyBackspace] = keySym{0xFF08} + keySyms[KeyCapsLock] = keySym{0xFFE5} + keySyms[KeyCancel] = keySym{0xFF69} + keySyms[KeyClear] = keySym{0xFF0B} + keySyms[KeyConvert] = keySym{0xFF21} + keySyms[KeyCopy] = keySym{0xFD15} + keySyms[KeyCrsel] = keySym{0xFD1C} + keySyms[KeyCrSel] = keySym{0xFD1C} + keySyms[KeyCodeInput] = keySym{0xFF37} + keySyms[KeyCompose] = keySym{0xFF20} + keySyms[KeyLeftControl] = keySym{0xFFE3} + keySyms[KeyRightControl] = keySym{0xFFE3, 0xFFE4} + keySyms[KeyContextMenu] = keySym{0xFF67} + keySyms[KeyDelete] = keySym{0xFFFF} + keySyms[KeyDown] = keySym{0xFF54} + keySyms[KeyEnd] = keySym{0xFF57} + keySyms[KeyEnter] = keySym{0xFF0D} + keySyms[KeyEraseEof] = keySym{0xFD06} + keySyms[KeyEscape] = keySym{0xFF1B} + keySyms[KeyExecute] = keySym{0xFF62} + keySyms[KeyExsel] = keySym{0xFD1D} + keySyms[KeyExSel] = keySym{0xFD1D} + keySyms[KeyF1] = keySym{0xFFBE} + keySyms[KeyF2] = keySym{0xFFBF} + keySyms[KeyF3] = keySym{0xFFC0} + keySyms[KeyF4] = keySym{0xFFC1} + keySyms[KeyF5] = keySym{0xFFC2} + keySyms[KeyF6] = keySym{0xFFC3} + keySyms[KeyF7] = keySym{0xFFC4} + keySyms[KeyF8] = keySym{0xFFC5} + keySyms[KeyF9] = keySym{0xFFC6} + keySyms[KeyF10] = keySym{0xFFC7} + keySyms[KeyF11] = keySym{0xFFC8} + keySyms[KeyF12] = keySym{0xFFC9} + keySyms[KeyF13] = keySym{0xFFCA} + keySyms[KeyF14] = keySym{0xFFCB} + keySyms[KeyF15] = keySym{0xFFCC} + keySyms[KeyF16] = keySym{0xFFCD} + keySyms[KeyF17] = keySym{0xFFCE} + keySyms[KeyF18] = keySym{0xFFCF} + keySyms[KeyF19] = keySym{0xFFD0} + keySyms[KeyF20] = keySym{0xFFD1} + keySyms[KeyF21] = keySym{0xFFD2} + keySyms[KeyF22] = keySym{0xFFD3} + keySyms[KeyF23] = keySym{0xFFD4} + keySyms[KeyF24] = keySym{0xFFD5} + keySyms[KeyFind] = keySym{0xFF68} + keySyms[KeyGroupFirst] = keySym{0xFE0C} + keySyms[KeyGroupLast] = keySym{0xFE0E} + keySyms[KeyGroupNext] = keySym{0xFE08} + keySyms[KeyGroupPrevious] = keySym{0xFE0A} + keySyms[KeyFullWidth] = keySym(nil) + keySyms[KeyHalfWidth] = keySym(nil) + keySyms[KeyHangulMode] = keySym{0xFF31} + keySyms[KeyHankaku] = keySym{0xFF29} + keySyms[KeyHanjaMode] = keySym{0xFF34} + keySyms[KeyHelp] = keySym{0xFF6A} + keySyms[KeyHiragana] = keySym{0xFF25} + keySyms[KeyHiraganaKatakana] = keySym{0xFF27} + keySyms[KeyHome] = keySym{0xFF50} + keySyms[KeyHyper] = keySym{0xFFED, 0xFFED, 0xFFEE} + keySyms[KeyInsert] = keySym{0xFF63} + keySyms[KeyJapaneseHiragana] = keySym{0xFF25} + keySyms[KeyJapaneseKatakana] = keySym{0xFF26} + keySyms[KeyJapaneseRomaji] = keySym{0xFF24} + keySyms[KeyJunjaMode] = keySym{0xFF38} + keySyms[KeyKanaMode] = keySym{0xFF2D} + keySyms[KeyKanjiMode] = keySym{0xFF21} + keySyms[KeyKatakana] = keySym{0xFF26} + keySyms[KeyLeft] = keySym{0xFF51} + keySyms[KeyMeta] = keySym{0xFFE7, 0xFFE7, 0xFFE8} + keySyms[KeyModeChange] = keySym{0xFF7E} + keySyms[KeyNumLock] = keySym{0xFF7F} + keySyms[KeyPageDown] = keySym{0xFF56} + keySyms[KeyPageUp] = keySym{0xFF55} + keySyms[KeyPause] = keySym{0xFF13} + keySyms[KeyPlay] = keySym{0xFD16} + keySyms[KeyPreviousCandidate] = keySym{0xFF3E} + keySyms[KeyPrintScreen] = keySym{0xFF61} + keySyms[KeyRedo] = keySym{0xFF66} + keySyms[KeyRight] = keySym{0xFF53} + keySyms[KeyRomanCharacters] = keySym(nil) + keySyms[KeyScroll] = keySym{0xFF14} + keySyms[KeySelect] = keySym{0xFF60} + keySyms[KeySeparator] = keySym{0xFFAC} + keySyms[KeyLeftShift] = keySym{0xFFE1} + keySyms[KeyRightShift] = keySym{0xFFE1, 0xFFE2} + keySyms[KeySingleCandidate] = keySym{0xFF3C} + keySyms[KeySuper] = keySym{0xFFEB, 0xFFEB, 0xFFEC} + keySyms[KeyTab] = keySym{0xFF09} + keySyms[KeyUIKeyInputDownArrow] = keySym{0xFF54} + keySyms[KeyUIKeyInputEscape] = keySym{0xFF1B} + keySyms[KeyUIKeyInputLeftArrow] = keySym{0xFF51} + keySyms[KeyUIKeyInputRightArrow] = keySym{0xFF53} + keySyms[KeyUIKeyInputUpArrow] = keySym{0xFF52} + keySyms[KeyUp] = keySym{0xFF52} + keySyms[KeyUndo] = keySym{0xFF65} + keySyms[KeyWin] = keySym{0xFFEB} + keySyms[KeyZenkaku] = keySym{0xFF28} + keySyms[KeyZenkakuHankaku] = keySym{0xFF2} + + for ch := 32; ch < 127; ch++ { + keySyms[KeyCode(ch)] = keySym{ch} + } +} diff --git a/internal/guac/client.go b/internal/guac/client.go new file mode 100644 index 0000000..5816c10 --- /dev/null +++ b/internal/guac/client.go @@ -0,0 +1,181 @@ +// Copyright 2021 Changkun Ou. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +// The following code is modified from +// https://github.com/deluan/bring +// Authored by Deluan Quintao released under MIT license. + +package guac + +import ( + "errors" + "image" + "log" + "strconv" + "time" + + "changkun.de/x/occamy/internal/protocol" + "gioui.org/app" +) + +var ErrInvalidKeyCode = errors.New("invalid key code") + +type OnSyncFunc = func(image image.Image, lastUpdate int64) + +// Guacamole protocol client. Automatically handles incoming and outgoing Guacamole instructions, +// updating its display using one or more graphic primitives. +type Client struct { + session *session + display *display + streams streams + onSync OnSyncFunc +} + +// NewClient creates a Client and connects it to the guacd server with the provided configuration. Logger is optional +func NewClient(addr string, config map[string]string, win *app.Window) (*Client, error) { + s, err := newSession(addr, config) + if err != nil { + return nil, err + } + + c := &Client{ + session: s, + display: newDisplay(), + streams: newStreams(), + } + go func() { + ping := time.NewTicker(pingFrequency) + defer ping.Stop() + for { + select { + case <-ping.C: + err := s.Send(protocol.NewInstruction("nop")) + if err != nil { + log.Printf("Failed ping the server: %s", err) + } + case <-s.done: + return + } + } + }() + + inschan := make(chan *protocol.Instruction, 100) + go func() { + for { + _, raw, err := s.tunnel.ReadMessage() + if err != nil { + log.Printf("Disconnecting from server. Reason: %v", err) + s.Terminate() + break + } + ins, err := protocol.ParseInstruction(raw) + if err != nil { + log.Printf("Failed to parse instruction: %v", err) + s.Terminate() + break + } + if ins.Opcode != "blob" { + log.Printf("S> %s", ins) + } + if ins.Opcode == "nop" { + continue + } + + inschan <- ins + } + }() + go func() { + log.Println("client instruction handler started!") + for ins := range inschan { + h, ok := handlers[ins.Opcode] + if !ok { + log.Printf("Instruction not implemented: %s", ins.Opcode) + continue + } + err = h(c, ins.Args) + if err != nil { + s.Terminate() + } + } + }() + return c, nil +} + +func (c *Client) OnSync(f OnSyncFunc) { + c.onSync = f +} + +// Returns a snapshot of the current screen, together with the last updated timestamp +func (c *Client) Screen() (image image.Image, lastUpdate int64) { + return c.display.getCanvas() +} + +// Returns the current session state +func (c *Client) State() SessionState { + return c.session.State +} + +// Send mouse events to the server. An event is composed by position of the +// cursor, and a list of any currently pressed MouseButtons +func (c *Client) SendMouse(p image.Point, pressedButtons ...MouseButton) error { + if c.session.State != SessionActive { + return ErrNotConnected + } + + buttonMask := 0 + for _, b := range pressedButtons { + buttonMask |= int(b) + } + c.display.moveCursor(p.X, p.Y) + err := c.session.Send(protocol.NewInstruction("mouse", strconv.Itoa(p.X), strconv.Itoa(p.Y), strconv.Itoa(buttonMask))) + if err != nil { + return err + } + return nil +} + +// Send the sequence of characters as they were typed. Only works with simple chars +// (no combination with control keys) +func (c *Client) SendText(sequence string) error { + if c.session.State != SessionActive { + return ErrNotConnected + } + + for _, ch := range sequence { + keycode := strconv.Itoa(int(ch)) + err := c.session.Send(protocol.NewInstruction("key", keycode, "1")) + if err != nil { + return nil + } + err = c.session.Send(protocol.NewInstruction("key", keycode, "0")) + if err != nil { + return nil + } + } + return nil +} + +// Send key presses and releases. +func (c *Client) SendKey(key KeyCode, pressed bool) error { + if c.session.State != SessionActive { + return ErrNotConnected + } + + p := "0" + if pressed { + p = "1" + } + keySym, ok := keySyms[key] + if !ok { + return ErrInvalidKeyCode + } + for _, k := range keySym { + keycode := strconv.Itoa(k) + err := c.session.Send(protocol.NewInstruction("key", keycode, p)) + if err != nil { + return nil + } + } + return nil +} diff --git a/internal/guac/display.go b/internal/guac/display.go new file mode 100644 index 0000000..93c1d91 --- /dev/null +++ b/internal/guac/display.go @@ -0,0 +1,196 @@ +// Copyright 2021 Changkun Ou. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +// The following code is modified from +// https://github.com/deluan/bring +// Authored by Deluan Quintao released under MIT license. + +package guac + +import ( + "fmt" + "image" + "image/draw" + "log" + "time" + + "changkun.de/x/occamy/internal/uuid" +) + +var compositeOperations = map[byte]draw.Op{ + 0xC: draw.Src, + 0xE: draw.Over, +} + +type display struct { + cursor *layer + cursorHotspotX int + cursorHotspotY int + cursorX int + cursorY int + tasks []task + layers layers + defaultLayer *layer + canvas *image.RGBA + lastUpdate int64 +} + +func newDisplay() *display { + d := &display{ + cursor: newBuffer(), + layers: newLayers(), + canvas: image.NewRGBA(image.Rectangle{}), + } + d.defaultLayer = d.layers.getDefault() + return d +} + +type taskFunc func() error + +type task struct { + taskFunc taskFunc + name string + uuid string +} + +func (t *task) String() string { + return fmt.Sprintf("%s [%s]", t.name, t.uuid) +} + +func (d *display) scheduleTask(name string, t taskFunc) { + task := task{ + taskFunc: t, + name: name, + uuid: uuid.NewID(""), + } + log.Printf("Adding new task: %s. Total: %d", task.String(), len(d.tasks)+1) + d.tasks = append(d.tasks, task) +} + +func (d *display) processSingleTask(t task) { + log.Printf("Executing task %s", t.String()) + err := t.taskFunc() + if err != nil { + log.Printf("Skipping task %s due to error. This can lead to invalid screen state! Error: %s", t.String(), err) + return + } + if !d.defaultLayer.modified { + return + } + // TODO: Only update canvas after all tasks are applied? + mr := d.defaultLayer.modifiedRect + copyImage(d.canvas, mr.Min.X, mr.Min.Y, d.defaultLayer.image, mr, draw.Src) + d.lastUpdate = time.Now().UnixNano() + + d.defaultLayer.resetModified() +} + +func (d *display) flush() { + if len(d.tasks) == 0 { + return + } + log.Printf("Processing %d pending tasks", len(d.tasks)) + for _, t := range d.tasks { + d.processSingleTask(t) + } + log.Println("All pending tasks were completed") + d.tasks = nil +} + +func (d *display) getCanvas() (image.Image, int64) { + return d.canvas, d.lastUpdate +} + +func (d *display) dispose(layerIdx int) { + d.scheduleTask("dispose", func() error { + d.layers.delete(layerIdx) + return nil + }) +} + +func (d *display) copy(srcL, srcX, srcY, srcWidth, srcHeight, dstL, dstX, dstY int, compositeOperation byte) { + op := compositeOperations[compositeOperation] + d.scheduleTask("copy", func() error { + srcLayer := d.layers.get(srcL) + dstLayer := d.layers.get(dstL) + dstLayer.Copy(srcLayer, srcX, srcY, srcWidth, srcHeight, dstX, dstY, op) + return nil + }) +} + +func (d *display) draw(layerIdx, x, y int, compositeOperation byte, s *stream) { + op := compositeOperations[compositeOperation] + img, err := s.image() + + d.scheduleTask("draw", func() error { + if err != nil { + return err + } + layer := d.layers.get(layerIdx) + layer.Draw(x, y, img, op) + return nil + }) +} + +func (d *display) fill(layerIdx int, r, g, b, a, compositeOperation byte) { + op := compositeOperations[compositeOperation] + d.scheduleTask("fill", func() error { + layer := d.layers.get(layerIdx) + layer.Fill(r, g, b, a, op) + return nil + }) +} +func (d *display) rect(layerIdx int, x int, y int, width int, height int) { + d.scheduleTask("rect", func() error { + layer := d.layers.get(layerIdx) + layer.Rect(x, y, width, height) + return nil + }) +} + +func (d *display) resize(layerIdx, w, h int) { + d.scheduleTask("resize", func() error { + layer := d.layers.get(layerIdx) + layer.Resize(w, h) + if layerIdx == 0 { + d.canvas = image.NewRGBA(layer.image.Bounds()) + copyImage(d.canvas, 0, 0, layer.image, layer.image.Bounds(), draw.Src) + } + return nil + }) +} + +func (d *display) hideCursor() { + cr := image.Rect(d.cursorX, d.cursorY, d.cursorX+d.cursor.width, d.cursorY+d.cursor.height) + copyImage(d.canvas, d.cursorX, d.cursorY, d.defaultLayer.image, cr, draw.Src) +} + +func (d *display) moveCursor(x, y int) { + d.hideCursor() + + d.cursorX = x + d.cursorY = y + + copyImage(d.canvas, d.cursorX, d.cursorY, d.cursor.image, d.cursor.image.Bounds(), draw.Over) + d.lastUpdate = time.Now().UnixNano() +} + +func (d *display) setCursor(cursorHotspotX, cursorHotspotY, srcL, srcX, srcY, srcWidth, srcHeight int) { + d.scheduleTask("setCursor", func() error { + d.hideCursor() + + layer := d.layers.get(srcL) + d.cursor.Resize(srcWidth, srcHeight) + d.cursor.Copy(layer, srcX, srcY, srcWidth, srcHeight, 0, 0, draw.Src) + d.cursorHotspotX = cursorHotspotX + d.cursorHotspotY = cursorHotspotY + + // TODO: Calculate correct position based on cursorHotspot + //d.cursorX = cursorHotspotX + //d.cursorY = cursorHotspotY + + copyImage(d.canvas, d.cursorX, d.cursorY, d.cursor.image, d.cursor.image.Bounds(), draw.Over) + return nil + }) +} diff --git a/internal/guac/handlers.go b/internal/guac/handlers.go new file mode 100644 index 0000000..2486743 --- /dev/null +++ b/internal/guac/handlers.go @@ -0,0 +1,143 @@ +// Copyright 2021 Changkun Ou. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +// The following code is modified from +// https://github.com/deluan/bring +// Authored by Deluan Quintao released under MIT license. + +package guac + +import ( + "log" + "strconv" + + "changkun.de/x/occamy/internal/protocol" +) + +// Handler func for Guacamole instructions +type handlerFunc = func(client *Client, args []string) error + +// Handlers for all instruction opcodes receivable by this Guacamole client. +var handlers = map[string]handlerFunc{ + "blob": func(c *Client, args []string) error { + idx := parseInt(args[0]) + return c.streams.append(idx, args[1]) + }, + + "copy": func(c *Client, args []string) error { + srcL := parseInt(args[0]) + srcX := parseInt(args[1]) + srcY := parseInt(args[2]) + srcWidth := parseInt(args[3]) + srcHeight := parseInt(args[4]) + mask := parseInt(args[5]) + dstL := parseInt(args[6]) + dstX := parseInt(args[7]) + dstY := parseInt(args[8]) + c.display.copy(srcL, srcX, srcY, srcWidth, srcHeight, + dstL, dstX, dstY, byte(mask)) + return nil + }, + + "cfill": func(c *Client, args []string) error { + mask := parseInt(args[0]) + layerIdx := parseInt(args[1]) + r := parseInt(args[2]) + g := parseInt(args[3]) + b := parseInt(args[4]) + a := parseInt(args[5]) + c.display.fill(layerIdx, byte(r), byte(g), byte(b), byte(a), byte(mask)) + return nil + }, + + "cursor": func(c *Client, args []string) error { + cursorHotspotX := parseInt(args[0]) + cursorHotspotY := parseInt(args[1]) + srcL := parseInt(args[2]) + srcX := parseInt(args[3]) + srcY := parseInt(args[4]) + srcWidth := parseInt(args[5]) + srcHeight := parseInt(args[6]) + c.display.setCursor(cursorHotspotX, cursorHotspotY, + srcL, srcX, srcY, srcWidth, srcHeight) + return nil + }, + + "disconnect": func(c *Client, args []string) error { + c.session.Terminate() + return nil + }, + + "dispose": func(c *Client, args []string) error { + layerIdx := parseInt(args[0]) + c.display.dispose(layerIdx) + return nil + }, + + "end": func(c *Client, args []string) error { + idx := parseInt(args[0]) + c.streams.end(idx) + c.streams.delete(idx) + return nil + }, + + "error": func(c *Client, args []string) error { + log.Printf("Received error from server: (%s) - %s", args[1], args[0]) + return nil + }, + + "img": func(c *Client, args []string) error { + s := c.streams.get(parseInt(args[0])) + op := byte(parseInt(args[1])) + layerIdx := parseInt(args[2]) + //mimetype := args[3] // Not used + x := parseInt(args[4]) + y := parseInt(args[5]) + s.onEnd = func(s *stream) { + c.display.draw(layerIdx, x, y, op, s) + } + return nil + }, + + "log": func(c *Client, args []string) error { + log.Printf("Log from server: %s", args[0]) + return nil + }, + + "rect": func(c *Client, args []string) error { + layerIdx := parseInt(args[0]) + x := parseInt(args[1]) + y := parseInt(args[2]) + w := parseInt(args[3]) + h := parseInt(args[4]) + c.display.rect(layerIdx, x, y, w, h) + return nil + }, + + "size": func(c *Client, args []string) error { + layerIdx := parseInt(args[0]) + w := parseInt(args[1]) + h := parseInt(args[2]) + c.display.resize(layerIdx, w, h) + return nil + }, + + "sync": func(c *Client, args []string) error { + c.display.flush() + if err := c.session.Send(protocol.NewInstruction("sync", args...)); err != nil { + log.Printf("Failed to send 'sync' back to server: %s", err) + return err + } + if c.onSync != nil { + img, ts := c.display.getCanvas() + c.onSync(img, ts) + } + return nil + }, +} + +func parseInt(s string) int { + n, _ := strconv.Atoi(s) + return n +} diff --git a/internal/guac/layer.go b/internal/guac/layer.go new file mode 100644 index 0000000..d19b929 --- /dev/null +++ b/internal/guac/layer.go @@ -0,0 +1,196 @@ +// Copyright 2021 Changkun Ou. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +// The following code is modified from +// https://github.com/deluan/bring +// Authored by Deluan Quintao released under MIT license. + +package guac + +import ( + "image" + "image/draw" + + "github.com/tfriedel6/canvas" + "github.com/tfriedel6/canvas/backend/softwarebackend" +) + +type layer struct { + width int + height int + image *image.RGBA + gc *canvas.Canvas + visible bool + modified bool + modifiedRect image.Rectangle + pathOpen bool + pathRect image.Rectangle + autosize bool +} + +func (l *layer) updateModifiedRect(modArea image.Rectangle) { + before := l.modifiedRect + l.modifiedRect = l.modifiedRect.Union(modArea) + l.modified = l.modified || !before.Eq(l.modifiedRect) +} + +func (l *layer) resetModified() { + l.modifiedRect = image.Rectangle{} + l.modified = false +} + +func (l *layer) setupCanvas() { + be := softwarebackend.New(l.width, l.height) + be.Image = l.image + l.gc = canvas.New(be) +} + +func (l *layer) fitRect(x int, y int, w int, h int) { + rect := image.Rect(x, y, x+w, y+h) + final := l.image.Bounds().Union(rect) + l.Resize(final.Max.X, final.Max.Y) +} + +func copyImage(dest draw.Image, x, y int, src image.Image, sr image.Rectangle, op draw.Op) { + dp := image.Pt(x, y) + dr := image.Rectangle{Min: dp, Max: dp.Add(sr.Size())} + draw.Draw(dest, dr, src, sr.Min, op) +} + +func (l *layer) Copy(srcLayer *layer, srcx, srcy, srcw, srch, x, y int, op draw.Op) { + srcImg := srcLayer.image + srcDim := srcImg.Bounds() + + // If entire rectangle outside source canvas, stop + if srcx >= srcDim.Max.X || srcy >= srcDim.Max.Y { + return + } + + // Otherwise, clip rectangle to area + if srcx+srcw > srcDim.Max.X { + srcw = srcDim.Max.X - srcx + } + + if srcy+srch > srcDim.Max.Y { + srch = srcDim.Max.Y - srcy + } + + // Stop if nothing to draw. + if srcw == 0 || srch == 0 { + return + } + + if l.autosize { + l.fitRect(x, y, srcw, srch) + } + + srcCopyDim := image.Rect(srcx, srcy, srcx+srcw, srcy+srch) + copyImage(l.image, x, y, srcImg, srcCopyDim, op) + l.updateModifiedRect(image.Rect(x, y, x+srcw, y+srch)) +} + +func (l *layer) Draw(x, y int, src image.Image, op draw.Op) { + srcDim := src.Bounds() + if l.autosize { + l.fitRect(x, y, srcDim.Max.X, srcDim.Max.Y) + } + copyImage(l.image, x, y, src, srcDim, op) + l.updateModifiedRect(image.Rect(x, y, x+srcDim.Max.X, y+srcDim.Max.Y)) +} + +func (l *layer) Resize(w int, h int) { + original := l.image.Bounds() + if w == l.width && h == l.height { + return + } + newImage := image.NewRGBA(image.Rect(0, 0, w, h)) + draw.Draw(newImage, l.image.Bounds(), l.image, image.Pt(0, 0), draw.Src) + l.image = newImage + l.width = w + l.height = h + l.setupCanvas() + l.updateModifiedRect(original.Union(l.image.Bounds())) +} + +func (l *layer) appendToPath(rect image.Rectangle) { + if !l.pathOpen { + l.gc.BeginPath() + l.pathOpen = true + l.pathRect = image.Rectangle{} + } + l.pathRect = l.pathRect.Union(rect) +} + +func (l *layer) endPath() { + l.updateModifiedRect(l.pathRect) + l.pathOpen = false + l.pathRect = image.Rectangle{} +} + +func (l *layer) Rect(x int, y int, width int, height int) { + l.appendToPath(image.Rect(x, y, x+width, y+height)) + l.gc.Rect(float64(x), float64(y), float64(width), float64(height)) +} + +func (l *layer) Fill(r byte, g byte, b byte, a byte, op draw.Op) { + // Ignores op, as the canvas library does not support it :/ + l.gc.SetFillStyle(r, g, b, a) + l.gc.Fill() + l.endPath() +} + +type layers map[int]*layer + +func newLayers() layers { + ls := make(layers) + ls[0] = newBuffer() + ls[0].visible = true + return ls +} + +func newBuffer() *layer { + l := &layer{ + image: image.NewRGBA(image.Rect(0, 0, 0, 0)), + autosize: true, + } + l.setupCanvas() + return l +} + +func newVisibleLayer(l0 *layer) *layer { + l := &layer{ + width: l0.width, + height: l0.height, + image: image.NewRGBA(image.Rect(0, 0, l0.width, l0.height)), + visible: true, + } + l.setupCanvas() + return l +} + +func (ls layers) getDefault() *layer { + return ls[0] +} + +func (ls layers) get(id int) *layer { + if l, ok := ls[id]; ok { + return l + } + if id > 0 { + ls[id] = newVisibleLayer(ls[0]) + } else { + ls[id] = newBuffer() + } + return ls[id] +} + +func (ls layers) delete(id int) { + if id == 0 { + return + } + ls[0].updateModifiedRect(ls[id].image.Bounds()) + ls[id].image = nil + ls[id] = nil + delete(ls, id) +} diff --git a/internal/guac/session.go b/internal/guac/session.go new file mode 100644 index 0000000..9edc5d5 --- /dev/null +++ b/internal/guac/session.go @@ -0,0 +1,125 @@ +// Copyright 2021 Changkun Ou. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +// The following code is modified from +// https://github.com/deluan/bring +// Authored by Deluan Quintao released under MIT license. + +package guac + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "log" + "net/http" + "strings" + "time" + + "changkun.de/x/occamy/internal/protocol" + "github.com/gorilla/websocket" +) + +type SessionState int + +const ( + SessionClosed SessionState = iota + SessionHandshake + SessionActive +) + +var ErrNotConnected = errors.New("not connected") + +const pingFrequency = 5 * time.Second + +// Session is used to create and keep a connection with a guacd server, +// and it is responsible for the initial handshake and to send and receive instructions. +// Instructions received are put in the In channel. Instructions are sent using the Send() function +type session struct { + State SessionState + Id string + + tunnel *websocket.Conn + ins chan *protocol.Instruction + done chan bool + config map[string]string +} + +// newSession creates a new connection with the guacd server, using the configuration provided +func newSession(addr string, config map[string]string) (*session, error) { + b, err := json.Marshal(config) + if err != nil { + return nil, err + } + resp, err := http.DefaultClient.Post("http://"+addr+"/api/v1/login", "application/json", bytes.NewReader(b)) + if err != nil { + return nil, err + } + b, err = io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + resp.Body.Close() + token := struct { + Token string `json:"token"` + }{} + err = json.Unmarshal(b, &token) + if err != nil { + return nil, err + } + + c, _, err := websocket.DefaultDialer.Dial("ws://"+addr+"/api/v1/connect?token="+token.Token, nil) + if err != nil { + return nil, err + } + + s := &session{ + State: SessionClosed, + done: make(chan bool), + tunnel: c, + ins: make(chan *protocol.Instruction, 100), + config: config, + } + go s.sender() + log.Printf("Initiating %s session with %s", strings.ToUpper(config["protocol"]), addr) + s.State = SessionActive + log.Printf("Handshake successful. Got connection ID %s", s.Id) + return s, nil +} + +// Terminate the current session, disconnecting from the server +func (s *session) Terminate() { + if s.State == SessionClosed { + return + } + close(s.done) +} + +// Send instructions to the server. Multiple instructions are sent in one single transaction +func (s *session) Send(ins ...*protocol.Instruction) (err error) { + // Serialize the sending instructions. + for _, i := range ins { + s.ins <- i + } + return +} + +func (s *session) sender() { + for { + select { + case ins := <-s.ins: + log.Printf("C> %s", ins) + err := s.tunnel.WriteMessage(websocket.TextMessage, []byte(ins.String())) + if err != nil { + return + } + case <-s.done: + _ = s.tunnel.WriteMessage(websocket.TextMessage, []byte(protocol.NewInstruction("disconnect").String())) + s.State = SessionClosed + s.tunnel.Close() + return + } + } +} diff --git a/internal/guac/stream.go b/internal/guac/stream.go new file mode 100644 index 0000000..6374225 --- /dev/null +++ b/internal/guac/stream.go @@ -0,0 +1,64 @@ +// Copyright 2021 Changkun Ou. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +// The following code is modified from +// https://github.com/deluan/bring +// Authored by Deluan Quintao released under MIT license. + +package guac + +import ( + "bytes" + "encoding/base64" + "image" +) + +type onEndFunc func(s *stream) + +type stream struct { + buffer *bytes.Buffer + onEnd onEndFunc +} + +func (s *stream) image() (image.Image, error) { + dec := base64.NewDecoder(base64.StdEncoding, s.buffer) + img, _, err := image.Decode(dec) + return img, err +} + +type streams map[int]*stream + +func newStreams() streams { + return make(map[int]*stream) +} + +func (ss streams) get(id int) *stream { + if s, ok := ss[id]; ok { + return s + } + s := &stream{ + buffer: &bytes.Buffer{}, + } + ss[id] = s + return s +} + +func (ss streams) append(id int, data string) error { + s := ss.get(id) + _, err := s.buffer.WriteString(data) + return err +} + +func (ss streams) end(id int) { + s := ss.get(id) + if s.onEnd != nil { + s.onEnd(s) + } +} + +func (ss streams) delete(id int) { + ss[id].buffer = nil + ss[id] = nil + delete(ss, id) +} diff --git a/vendor/gioui.org/io/semantic/semantic.go b/vendor/gioui.org/io/semantic/semantic.go new file mode 100644 index 0000000..7499fc8 --- /dev/null +++ b/vendor/gioui.org/io/semantic/semantic.go @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: Unlicense OR MIT + +// Package semantic provides operations for semantic descriptions of a user +// interface, to facilitate presentation and interaction in external software +// such as screen readers. +// +// Semantic descriptions are organized in a tree, with clip operations as +// nodes. Operations in this package are associated with the current semantic +// node, that is the most recent pushed clip operation. +package semantic + +import ( + "gioui.org/internal/ops" + "gioui.org/op" +) + +// LabelOp provides the content of a textual component. +type LabelOp string + +// DescriptionOp describes a component. +type DescriptionOp string + +// ClassOp provides the component class. +type ClassOp int + +const ( + Unknown ClassOp = iota + Button + CheckBox + Editor + RadioButton + Switch +) + +// SelectedOp describes the selected state for components that have +// boolean state. +type SelectedOp bool + +// DisabledOp describes the disabled state. +type DisabledOp bool + +func (l LabelOp) Add(o *op.Ops) { + s := string(l) + data := ops.Write1(&o.Internal, ops.TypeSemanticLabelLen, &s) + data[0] = byte(ops.TypeSemanticLabel) +} + +func (d DescriptionOp) Add(o *op.Ops) { + s := string(d) + data := ops.Write1(&o.Internal, ops.TypeSemanticDescLen, &s) + data[0] = byte(ops.TypeSemanticDesc) +} + +func (c ClassOp) Add(o *op.Ops) { + data := ops.Write(&o.Internal, ops.TypeSemanticClassLen) + data[0] = byte(ops.TypeSemanticClass) + data[1] = byte(c) +} + +func (s SelectedOp) Add(o *op.Ops) { + data := ops.Write(&o.Internal, ops.TypeSemanticSelectedLen) + data[0] = byte(ops.TypeSemanticSelected) + if s { + data[1] = 1 + } +} + +func (d DisabledOp) Add(o *op.Ops) { + data := ops.Write(&o.Internal, ops.TypeSemanticDisabledLen) + data[0] = byte(ops.TypeSemanticDisabled) + if d { + data[1] = 1 + } +} + +func (c ClassOp) String() string { + switch c { + case Unknown: + return "Unknown" + case Button: + return "Button" + case CheckBox: + return "CheckBox" + case Editor: + return "Editor" + case RadioButton: + return "RadioButton" + case Switch: + return "Switch" + default: + panic("invalid ClassOp") + } +}