add pages

refactoring
This commit is contained in:
Simon Vieille 2023-08-25 09:03:49 +02:00
parent 6962664fbb
commit e2413a19be
Signed by: deblan
GPG key ID: 579388D585F70417
18 changed files with 1271 additions and 378 deletions

22
Makefile Normal file
View file

@ -0,0 +1,22 @@
CGO_ENABLED = 0
CC = go build
CFLAGS = -trimpath
LDFLAGS = all=-w -s
GCFLAGS = all=
ASMFLAGS = all=
BUILD_DIR = build
LINUX_BIN = app-latest-linux-amd64
all: build
deps:
go install github.com/GeertJohan/go.rice/rice@latest
rice embed-go
.PHONY:
build: deps
CGO_ENABLED=$(CGO_ENABLED) GOARCH=amd64 GOOS=linux $(CC) $(CFLAGS) -o $(BUILD_DIR)/$(LINUX_BIN) -ldflags="$(LDFLAGS)" -gcflags="$(GCFLAGS)" -asmflags="$(ASMFLAGS)"
watch:
gowatch -o build/app-live-linux-amd64

BIN
build/app-latest-linux-amd64 Executable file

Binary file not shown.

BIN
build/app-live-linux-amd64 Executable file

Binary file not shown.

2
go.mod
View file

@ -3,6 +3,8 @@ module gitnet.fr/deblan/remote-i3wm-go
go 1.21.0
require (
github.com/GeertJohan/go.rice v1.0.3 // indirect
github.com/daaku/go.zipexe v1.0.2 // indirect
github.com/gen2brain/shm v0.0.0-20230802011745-f2460f5984f7 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/jezek/xgb v1.1.0 // indirect

9
go.sum
View file

@ -1,9 +1,16 @@
github.com/GeertJohan/go.incremental v1.0.0/go.mod h1:6fAjUhbVuX1KcMD3c8TEgVUqmo4seqhv0i0kdATSkM0=
github.com/GeertJohan/go.rice v1.0.3 h1:k5viR+xGtIhF61125vCE1cmJ5957RQGXG6dmbaWZSmI=
github.com/GeertJohan/go.rice v1.0.3/go.mod h1:XVdrU4pW00M4ikZed5q56tPf1v2KwnIKeIdc9CBYNt4=
github.com/akavel/rsrc v0.8.0/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=
github.com/daaku/go.zipexe v1.0.2 h1:Zg55YLYTr7M9wjKn8SY/WcpuuEi+kR2u4E8RhvpyXmk=
github.com/daaku/go.zipexe v1.0.2/go.mod h1:5xWogtqlYnfBXkSB1o9xysukNP9GTvaNkqzUZbt3Bw8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gen2brain/shm v0.0.0-20230802011745-f2460f5984f7 h1:VLEKvjGJYAMCXw0/32r9io61tEXnMWDRxMk+peyRVFc=
github.com/gen2brain/shm v0.0.0-20230802011745-f2460f5984f7/go.mod h1:uF6rMu/1nvu+5DpiRLwusA6xB8zlkNoGzKn8lmYONUo=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jezek/xgb v1.1.0 h1:wnpxJzP1+rkbGclEkmwpVFQWpuE2PUGNUzP8SbfFobk=
github.com/jezek/xgb v1.1.0/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk=
github.com/kbinani/screenshot v0.0.0-20230812210009-b87d31814237 h1:YOp8St+CM/AQ9Vp4XYm4272E77MptJDHkwypQHIRl9Q=
@ -21,11 +28,13 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/nkovacs/streamquote v1.0.0/go.mod h1:BN+NaZ2CmdKqUuTUXUEm9j95B2TRbpOWpxbJYzzgUsc=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=

10
home_controller.go Normal file
View file

@ -0,0 +1,10 @@
package main
import (
"github.com/labstack/echo/v4"
"net/http"
)
func homeController(c echo.Context) error {
return c.HTML(http.StatusOK, view("views/page/home.html", nil))
}

412
main.go
View file

@ -1,400 +1,56 @@
package main
import (
"bytes"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"github.com/kbinani/screenshot"
"embed"
rice "github.com/GeertJohan/go.rice"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"golang.org/x/net/websocket"
"image/jpeg"
"html/template"
"net/http"
"os/exec"
"strconv"
"strings"
"time"
"os"
)
type Message struct {
Type string `json:type`
}
type SimpleMessageData struct {
Type string `json:type`
Value string `json:value`
}
type PointerMessageData struct {
X string `json:x`
Y string `json:y`
Click string `json:click`
}
type MessagesData struct {
Type string `json:type`
Value []SimpleMessageData `json:value`
}
type ScreenshotMessageData struct {
Quality string `json:quality`
}
type MessageResponse struct {
Type string `json:"type"`
Value string `json:"value"`
}
func getSimpleMessageValue(msg string) string {
data := SimpleMessageData{}
json.Unmarshal([]byte(msg), &data)
return data.Value
}
func sendMessageResponse(ws *websocket.Conn, r MessageResponse) {
value, _ := json.Marshal(r)
websocket.Message.Send(ws, string(value))
}
func toBase64(b []byte) string {
return base64.StdEncoding.EncodeToString(b)
}
func ws(c echo.Context) error {
var actions = Actions{
Functions: make(map[string]func(ws *websocket.Conn, msg string) error),
}
actions.add("pointer", func(ws *websocket.Conn, msg string) error {
data := PointerMessageData{}
json.Unmarshal([]byte(msg), &data)
if data.Click != "" {
keys := make(map[string]string)
keys["left"] = "1"
keys["middle"] = "2"
keys["right"] = "3"
key, exists := keys[data.Click]
if !exists {
return errors.New("Invalid value")
}
cmd := exec.Command("xdotool", "click", key)
return cmd.Run()
}
location := exec.Command("xdotool", "getmouselocation")
output, _ := location.Output()
position := string(output)
currentX := 0.0
currentY := 0.0
for key, value := range strings.Split(position, " ") {
if key == 0 {
currentX, _ = strconv.ParseFloat(strings.Replace(value, "x:", "", 1), 32)
} else if key == 1 {
currentY, _ = strconv.ParseFloat(strings.Replace(value, "y:", "", 1), 32)
}
}
newX, _ := strconv.ParseFloat(data.X, 32)
newY, _ := strconv.ParseFloat(data.Y, 32)
x := currentX + newX*2.5
y := currentY + newY*2.5
cmd := exec.Command("xdotool", "mousemove", fmt.Sprintf("%.0f", x), fmt.Sprintf("%.0f", y))
return cmd.Run()
})
actions.add("scroll", func(ws *websocket.Conn, msg string) error {
value := getSimpleMessageValue(msg)
key := ""
if value == "down" {
key = "5"
} else if value == "up" {
key = "4"
} else {
return errors.New("Invalid value")
}
for i := 0; i < 2; i++ {
cmd := exec.Command("xdotool", "click", key)
cmd.Run()
}
return nil
})
actions.add("workspace", func(ws *websocket.Conn, msg string) error {
value := getSimpleMessageValue(msg)
if value == "" {
return errors.New("Invalid value")
}
cmd := exec.Command("i3-msg", fmt.Sprintf("workspace \"%s\"", value))
return cmd.Run()
})
actions.add("volume", func(ws *websocket.Conn, msg string) error {
value := getSimpleMessageValue(msg)
if value == "" {
return errors.New("Invalid value")
}
if value == "up" {
cmd := exec.Command("amixer", "set", "Master", "2%+")
sendMessageResponse(ws, MessageResponse{
Type: "response",
Value: "Volume up",
})
return cmd.Run()
}
if value == "down" {
cmd := exec.Command("amixer", "set", "Master", "2%-")
sendMessageResponse(ws, MessageResponse{
Type: "response",
Value: "Volume down",
})
return cmd.Run()
}
cmd := exec.Command("amixer", "set", "Master", fmt.Sprintf("%s%%", value))
sendMessageResponse(ws, MessageResponse{
Type: "response",
Value: fmt.Sprintf("Volume set to %s%%", value),
})
return cmd.Run()
})
actions.add("media", func(ws *websocket.Conn, msg string) error {
value := getSimpleMessageValue(msg)
if value == "" {
return errors.New("Invalid value")
}
var arg string
if value == "playpause" {
arg = "play-pause"
} else if value == "next" {
arg = "next"
} else if value == "prev" {
arg = "previous"
} else {
return errors.New("Invalid value")
}
cmd := exec.Command("playerctl", "-p", "spotify", arg)
err := cmd.Run()
if err != nil {
return err
}
time.Sleep(400 * time.Millisecond)
cmd = exec.Command("playerctl", "-p", "spotify", "status")
output, err := cmd.Output()
value = strings.TrimSpace(string(output))
if err != nil {
return err
}
if value == "Playing" {
cmd = exec.Command("playerctl", "-p", "spotify", "metadata", "xesam:title")
output, err := cmd.Output()
value = strings.TrimSpace(string(output))
if err != nil {
return err
}
sendMessageResponse(ws, MessageResponse{
Type: "response",
Value: fmt.Sprintf("Playing: %s", value),
})
} else {
sendMessageResponse(ws, MessageResponse{
Type: "response",
Value: "Paused",
})
}
return nil
})
actions.add("keys", func(ws *websocket.Conn, msg string) error {
value := strings.TrimSpace(getSimpleMessageValue(msg))
if value == "" {
return errors.New("Invalid value")
}
keys := []string{}
for _, key := range strings.Split(value, ",") {
if key == "win" {
key = "super"
} else if key == "ctrl" {
key = "Control_L"
} else if key == "alt" {
key = "Alt_L"
}
if key != "" {
keys = append(keys, key)
}
}
if len(keys) == 0 {
return errors.New("Invalid value")
}
cmd := exec.Command("xdotool", "key", strings.Join(keys, "+"))
return cmd.Run()
})
actions.add("key", func(ws *websocket.Conn, msg string) error {
value := strings.TrimSpace(getSimpleMessageValue(msg))
keys := make(map[string]string)
keys["up"] = "Up"
keys["down"] = "Down"
keys["left"] = "Left"
keys["right"] = "Right"
keys["tab"] = "Tab"
keys["backspace"] = "BackSpace"
keys["enter"] = "Return"
keys["space"] = "space"
keys["escape"] = "Escape"
key, exists := keys[value]
if !exists {
return errors.New("Invalid value")
}
cmd := exec.Command("xdotool", "key", key)
return cmd.Run()
})
actions.add("text", func(ws *websocket.Conn, msg string) error {
value := strings.TrimSpace(getSimpleMessageValue(msg))
if value == "" {
return errors.New("Invalid value")
}
cmd := exec.Command("xdotool", "type", value)
return cmd.Run()
})
actions.add("screenshot", func(ws *websocket.Conn, msg string) error {
data := ScreenshotMessageData{}
json.Unmarshal([]byte(msg), &data)
bounds := screenshot.GetDisplayBounds(0)
img, err := screenshot.CaptureRect(bounds)
if err != nil {
return errors.New("Capture error")
}
var quality int
if data.Quality == "lq" {
quality = 10
} else {
quality = 90
}
buff := new(bytes.Buffer)
jpeg.Encode(buff, img, &jpeg.Options{Quality: quality})
sendMessageResponse(ws, MessageResponse{
Type: "screenshot",
Value: toBase64(buff.Bytes()),
})
return nil
})
actions.add("messages", func(ws *websocket.Conn, msg string) error {
data := MessagesData{}
json.Unmarshal([]byte(msg), &data)
for _, value := range data.Value {
msg, _ := json.Marshal(value)
if actions.has(value.Type) {
actions.exec(value.Type, ws, string(msg))
time.Sleep(400 * time.Millisecond)
}
}
return nil
})
websocket.Handler(func(ws *websocket.Conn) {
defer ws.Close()
for {
msg := ""
websocket.Message.Receive(ws, &msg)
message := Message{}
json.Unmarshal([]byte(msg), &message)
if message.Type != "" && actions.has(message.Type) {
actions.exec(message.Type, ws, msg)
}
}
}).ServeHTTP(c.Response(), c.Request())
return nil
}
var (
templates map[string]*template.Template
//go:embed static
staticFiles embed.FS
//go:embed views/layout views/page
views embed.FS
)
func main() {
e := echo.New()
e.HideBanner = true
RI3_USERNAME := os.Getenv("RI3_USERNAME")
RI3_PASSWORD := os.Getenv("RI3_PASSWORD")
RI3_BIND := os.Getenv("RI3_BIND")
if RI3_BIND == "" {
RI3_BIND = "0.0.0.0:4000"
}
assetHandler := http.FileServer(rice.MustFindBox("static").HTTPBox())
e.Use(middleware.BasicAuth(func(username, password string, c echo.Context) (bool, error) {
if subtle.ConstantTimeCompare([]byte(username), []byte("admin")) == 1 &&
subtle.ConstantTimeCompare([]byte(password), []byte("admin")) == 1 {
if RI3_USERNAME == "" && RI3_PASSWORD == "" {
return true, nil
}
isValidUsername := subtle.ConstantTimeCompare([]byte(username), []byte(RI3_USERNAME)) == 1
isValidPassword := subtle.ConstantTimeCompare([]byte(password), []byte(RI3_PASSWORD)) == 1
if isValidUsername && isValidPassword {
return true, nil
}
return false, nil
}))
e.GET("/", func(c echo.Context) error {
return c.String(http.StatusOK, "Hello, World!")
})
e.GET("/", echo.WrapHandler(assetHandler))
e.GET("/static/*", echo.WrapHandler(http.StripPrefix("/static/", assetHandler)))
e.GET("/", homeController)
e.GET("/ws", wsController)
e.GET("/ws", ws)
e.Logger.Fatal(e.Start(":4000"))
e.Logger.Fatal(e.Start(RI3_BIND))
}

84
rice-box.go Normal file

File diff suppressed because one or more lines are too long

7
static/bootstrap.bundle.min.js vendored Normal file

File diff suppressed because one or more lines are too long

7
static/bootstrap.min.css vendored Normal file

File diff suppressed because one or more lines are too long

7
static/bootstrap.min.js vendored Normal file

File diff suppressed because one or more lines are too long

4
static/jquery.min.js vendored Normal file

File diff suppressed because one or more lines are too long

141
static/main.css Normal file
View file

@ -0,0 +1,141 @@
a {
color: #1e3650;
}
.btn-primary {
background: #1e3650;
border-color: #0e2640;
}
.nav-pills .nav-link.active {
background: #1e3650;
}
.nav-pills .nav-link {
padding-left: 3px;
padding-right: 3px;
}
.nav-link {
font-size: 10px;
}
.legend {
color: #777;
margin: 3px 0;
padding: 3px 0;
border-bottom: 1px solid #eee;
font-size: 11px;
text-transform: uppercase;
}
.btn-sm {
font-size: 9px;
}
.select2 {
min-width: 100%;
}
.line {
height: 3px;
}
.pane {
display: none;
}
.no-margin {
margin: 0;
}
.no-padding {
padding: 0;
}
.no-radius {
border-radius: 0 !important;
}
#pointer {
height: calc(100vh - 80px);
margin: auto;
background: #ccc;
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
#scrollbar {
height: calc(100vh - 80px);
width: 50px;
background: #333;
position: absolute;
z-index: 100;
right: 0;
}
.fullscreen #scrollbar {
height: calc(100vh - 150px);
}
.fullscreen #pointer {
height: calc(100vh - 150px);
}
#pane-pointer .form-group {
padding: 0;
margin: 0;
}
#pointer-buttons {
position: absolute;
margin-top: -42px;
width: 100%;
z-index: 110;
}
#pointer-buttons .btn {
height: 50px;
}
#disconneced {
position: absolute;
top: 0;
width: 100%;
background: #ff6161;
color: #fff;
padding: 5px;
}
#disconneced a {
color: #fff;
font-weight: bold;
}
#nav {
border-bottom: 2px solid #1e3650;
}
#shortcuts_special_keys input {
display: none;
}
#response {
position: absolute;
bottom: 0;
width: 100%;
color: #fff;
background: #748c26;
padding: 5px;
display: none;
}
#screenshot img {
max-width: 100%;
margin-top: 10px;
cursor: pointer;
}

310
static/main.js Normal file
View file

@ -0,0 +1,310 @@
var ws;
var $pointer, $scroller, $response, $screenshotImg;
var scrollLastTimestamp, scrollLastValue;
var mousePosX, mousePosY, mouseInitPosX, mouseInitPosY;
var isLive = false;
var isScreenshotWaiting = false;
var createWebSocketConnection = function() {
ws = new WebSocket(`ws://${window.location.hostname}:${window.location.port}/ws`);
ws.onopen = function(event) {
$('#disconneced').fadeOut();
}
ws.onclose = function(event) {
$('#disconneced').fadeIn();
window.setTimeout(createWebSocketConnection, 5000);
}
ws.onmessage = function(event) {
var data = JSON.parse(event.data);
if (data.type === 'response') {
$response.text(data.value);
$response.fadeIn();
window.setTimeout(function() {
$response.fadeOut();
}, 2500);
} else if (data.type === 'screenshot') {
isScreenshotWaiting = false
$screenshotImg.attr('src', 'data:image/png;base64, ' + data.value);
}
}
}
var navigationClickHandler = function(e) {
if ($(this).attr('href') === '#') {
return
}
$('.pane').hide();
var target = $(this).attr('href');
$(target).show();
$('#nav a').removeClass('active');
$(this).addClass('active');
}
var buttonClickHandler = function(e) {
var msg = $(this).attr('data-msg');
ws.send(msg);
}
var shortcutClearClickHandler = function(e) {
$('#shortcut-key').val('');
$('#shortcuts_special_keys input:checked').each(function() {
$(this).prop('checked', false).trigger('change');
});
}
var shortcutSendClickHandler = function(e) {
var keys = [];
$('#shortcuts_special_keys input:checked').each(function() {
keys.push($(this).val());
});
var key = $('#shortcut-key').val();
if (keys.length) {
if (key) {
keys.push(key);
}
var msg = '{"type":"keys","value": "' + (keys.join(',').replace('"', '\\"')) + '"}';
ws.send(msg);
}
}
var textClearClickHandler = function(e) {
$('#text').val('');
}
var textSendClickHandler = function(e) {
var keys = $('#text').val();
if (keys.length) {
var msg = '{"type":"text","value": "' + (keys.replace('"', '\\"')) + '"}';
ws.send(msg);
}
}
var textKeyUpHandler = function(e) {
var keys = $('#text').val();
if (e.keyCode === 13) {
var msg = '{"type":"text","value": "' + (keys.replace('"', '\\"')) + '"}';
ws.send(msg);
}
}
var liveTextKeyUpHandler = function(e) {
var value = $(this).val();
var live = false;
if (e.keyCode === 8) {
var msg = '{"type":"key","value": "backspace"}';
ws.send(msg);
} else if (e.keyCode === 13) {
var msg = '{"type":"key","value": "enter"}';
ws.send(msg);
} else if (value.length) {
if (value === ' ') {
var msg = '{"type":"key","value": "space"}';
ws.send(msg);
} else {
var msg = '{"type":"text","value": "' + (value.replace('"', '\\"')) + '"}';
ws.send(msg);
}
$(this).val('');
}
}
var shortcutsSpecialKeysOnChangeHandler = function(e) {
$('#shortcuts_special_keys input:checked').each(function() {
$(this).parent().addClass('btn-primary').removeClass('btn-secondary');
})
$('#shortcuts_special_keys input:not(:checked)').each(function() {
$(this).parent().addClass('btn-secondary').removeClass('btn-primary');
})
}
var pointerClickHandler = function(e) {
var msg = '{"type":"pointer","click":"left"}';
ws.send(msg);
}
var scrollerTouchStartHandler = function(e) {
var touch = e.targetTouches[0];
mouseInitPosY = touch.pageY;
}
var scrollerTouchMoveHandler = function(e) {
var touch = e.changedTouches[0];
var value = ((touch.pageY - mouseInitPosY > 0) ? 'down' : 'up');
var now = new Date().getTime();
if (touch.pageY === mouseInitPosY || value === scrollLastValue && scrollLastTimestamp !== null && now - scrollLastTimestamp < 200) {
return;
}
scrollLastTimestamp = now;
scrollLastValue = value;
var msg = '{"type":"scroll","value": "' + value + '"}';
mouseInitPosY = touch.pageY;
ws.send(msg);
}
var pointerTouchStartHandler = function(e) {
var touch = e.targetTouches[0];
mouseInitPosX = touch.pageX;
mouseInitPosY = touch.pageY;
}
var pointerTouchMoveHandler = function(e) {
if (e.changedTouches.length === 2) {
return scrollerTouchMoveHandler(e);
}
var touch = e.changedTouches[0];
mousePosX = touch.pageX;
mousePosY = touch.pageY;
var newX = mousePosX - mouseInitPosX;
var newY = mousePosY - mouseInitPosY;
mouseInitPosX = mousePosX;
mouseInitPosY = mousePosY;
var msg = '{"type":"pointer","x": "' + newX + '","y": "' + newY + '"}';
ws.send(msg);
}
var liveHqClickHandler = function(e) {
return liveClickHandler(e, 'hq')
}
var liveLqClickHandler = function(e) {
return liveClickHandler(e, 'lq')
}
var liveClickHandler = function(e, quality) {
if (isLive) {
isLive = false;
isScreenshotWaiting = false;
$('#live-hq').text(`Live HQ`);
$('#live-lq').text(`Live LQ`);
return;
}
isLive = true;
$(e.target).text('Stop live');
var doScreenshot = function() {
if (isLive) {
if (!isScreenshotWaiting) {
isScreenshotWaiting = true
ws.send(`{"type":"screenshot","quality":"${quality}"}`);
}
window.setTimeout(doScreenshot, 100);
}
}
doScreenshot();
}
var fullscreenHandler = function(e) {
var element = $(e.target.getAttribute('data-target'));
var isFullscreen = parseInt($(e.target).attr('data-fullscreen'));
$('body').toggleClass('fullscreen', isFullscreen)
if (isFullscreen) {
element.attr('data-fullscreen', '0');
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen();
} else if (document.mozCancelFullScreen) {
document.mozCancelFullScreen();
}
} else {
$(e.target).attr('data-fullscreen', '1');
if (element.get(0).requestFullscreen) {
element.get(0).requestFullscreen();
} else if (element.get(0).webkitRequestFullscreen) {
element.get(0).webkitRequestFullscreen();
} else if (element.get(0).mozRequestFullScreen) {
element.get(0).mozRequestFullScreen();
}
}
}
var documentHashHandler = function() {
var hash = window.location.hash;
if (hash) {
$(hash).show();
$('a[href="' + hash + '"]').addClass('active');
} else {
$('#pane-keyboard').show();
$('#nav a').first().addClass('active');
}
}
var addListeners = function() {
$('#nav a').click(navigationClickHandler);
$('button[data-msg]').click(buttonClickHandler);
$('#shortcut-clear').click(shortcutClearClickHandler);
$('#shortcuts_special_keys input').change(shortcutsSpecialKeysOnChangeHandler);
$('#shortcut-send').click(shortcutSendClickHandler);
$('#text-clear').click(textClearClickHandler);
$('#text-send').click(textSendClickHandler);
$('#text').on('keyup', textKeyUpHandler);
$('.live-text').on('keyup', liveTextKeyUpHandler);
$scroller
.on('touchstart', scrollerTouchStartHandler)
.on('touchmove', scrollerTouchMoveHandler);
$pointer
.on('click', pointerClickHandler)
.on('touchstart', pointerTouchStartHandler)
.on('touchmove', pointerTouchMoveHandler);
$('#live-hq').click(liveHqClickHandler);
$('#live-lq').click(liveLqClickHandler);
$('.btn-fullscreen').click(fullscreenHandler)
}
var bootstrap = function() {
documentHashHandler();
shortcutsSpecialKeysOnChangeHandler();
createWebSocketConnection();
addListeners();
}
$(function() {
$pointer = $('#pointer');
$scroller = $('#scrollbar');
$response = $('#response');
$screenshotImg = $('#screenshot img');
bootstrap();
});

20
utils.go Normal file
View file

@ -0,0 +1,20 @@
package main
import (
"bytes"
"encoding/base64"
"html/template"
)
func view(viewName string, data any) string {
var render bytes.Buffer
view := template.Must(template.ParseFS(views, viewName, "views/layout/base.html"))
view.Execute(&render, data)
return render.String()
}
func toBase64(b []byte) string {
return base64.StdEncoding.EncodeToString(b)
}

26
views/layout/base.html Normal file
View file

@ -0,0 +1,26 @@
{{define "main"}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<link rel="stylesheet" href="/static/bootstrap.min.css" type="text/css">
<link rel="stylesheet" href="/static/main.css" type="text/css">
<title>Remote i3-wm</title>
</head>
<body>
{{template "content" .}}
<div id="disconneced">
You are disconnected [<a href="#" onclick="location.reload(); return false;">Refresh</a>]
</div>
<div id="response"></div>
<script src="/static/jquery.min.js"></script>
<script src="/static/bootstrap.bundle.min.js"></script>
<script src="/static/bootstrap.min.js"></script>
<script src="/static/main.js"></script>
</body>
</html>
{{end}}

217
views/page/home.html Normal file
View file

@ -0,0 +1,217 @@
{{template "main" .}}
{{define "content"}}
<div class="container-fluid no-padding">
<div class="row no-margin">
<div class="col-12 no-padding">
<ul class="nav nav-pills nav-fill" id="nav">
<li class="nav-item">
<a class="nav-link no-radius" href="#pane-keyboard">KEYBOARD</a>
</li>
<li class="nav-item">
<a class="nav-link no-radius" href="#pane-i3">I3</a>
</li>
<li class="nav-item">
<a class="nav-link no-radius" href="#pane-pointer">MOUSE</a>
</li>
<li class="nav-item">
<a class="nav-link no-radius" href="#pane-media">MEDIA</a>
</li>
<li class="nav-item">
<a class="nav-link no-radius" href="#pane-desktop">DESKTOP</a>
</li>
<li class="nav-item">
<a class="nav-link no-radius btn-fullscreen" data-target="html" href="#">💻</a>
</li>
</ul>
</div>
</div>
</div>
<div class="container-fluid">
<div id="pane-keyboard" class="pane">
<div class="row">
<div class="col-12">
<p class="legend">Live text</p>
</div>
<div class="form-group col-12">
<input type="text" class="form-control live-text" name="text">
</div>
</div>
<div class="row">
<div class="col-12">
<p class="legend">TEXT</p>
</div>
<div class="form-group col-6">
<input type="text" class="form-control" id="text">
</div>
<div class="col-6">
<button type="button" id="text-send" class="btn btn-primary">Send</button>
<button type="button" id="text-clear" class="btn btn-secondary">Clear</button>
</div>
</div>
<div class="row">
<div class="col-12">
<p class="legend">Keys</p>
</div>
<div class="col-12">
<button type="button" data-msg='{"type":"key","value":"left"}' class="btn btn-secondary"></button>
<button type="button" data-msg='{"type":"key","value":"up"}' class="btn btn-secondary"></button>
<button type="button" data-msg='{"type":"key","value":"down"}' class="btn btn-secondary"></button>
<button type="button" data-msg='{"type":"key","value":"right"}' class="btn btn-secondary"></button>
</div>
<div class="line col-12"></div>
<div class="col-12">
<button type="button" data-msg='{"type":"key","value":"escape"}' class="btn btn-secondary">Escape</button>
<button type="button" data-msg='{"type":"key","value":"tab"}' class="btn btn-secondary">TAB</button>
<button type="button" data-msg='{"type":"key","value":"backspace"}' class="btn btn-secondary">Backspace</button>
<button type="button" data-msg='{"type":"key","value":"enter"}' class="btn btn-secondary">Enter</button>
</div>
</div>
<div class="row">
<div class="col-12">
<p class="legend">Shortcuts</p>
</div>
<div class="col-9" id="shortcuts_special_keys">
<label class="btn btn-secondary" for="shortcuts_special_key_ctrl">
<input type="checkbox" value="ctrl" id="shortcuts_special_key_ctrl">
ctrl
</label>
<label class="btn btn-secondary" for="shortcuts_special_key_shift">
<input type="checkbox" value="shift" id="shortcuts_special_key_shift">
shift
</label>
<label class="btn btn-secondary" for="shortcuts_special_key_alt">
<input type="checkbox" value="alt" id="shortcuts_special_key_alt">
alt
</label>
<label class="btn btn-secondary" for="shortcuts_special_key_win">
<input type="checkbox" value="win" id="shortcuts_special_key_win">
win
</label>
</div>
<div class="form-group col-3">
<input type="text" id="shortcut-key" class="form-control" name="shortcuts_char">
</div>
<div class="col-12">
<button type="button" id="shortcut-send" class="btn btn-primary">Send</button>
<button type="button" id="shortcut-clear" class="btn btn-secondary">Clear</button>
</div>
</div>
</div>
<div id="pane-i3" class="pane">
<div class="row">
<div class="col-12">
<p class="legend">Workspaces</p>
</div>
<div class="col-12">
<button type="button" data-msg='{"type":"workspace","value":"1. IRC"}' class="btn btn-secondary btn-sm">1. IRC</button>
<button type="button" data-msg='{"type":"workspace","value":"2. WWW"}' class="btn btn-secondary btn-sm">2. WWW</button>
<button type="button" data-msg='{"type":"workspace","value":"3. MAIL"}' class="btn btn-secondary btn-sm">3. MAIL</button>
<button type="button" data-msg='{"type":"workspace","value":"6. MEDIA"}' class="btn btn-secondary btn-sm">6. MEDIA</button>
<button type="button" data-msg='{"type":"workspace","value":"7. WORK"}' class="btn btn-secondary btn-sm">7. WORK</button>
<button type="button" data-msg='{"type":"workspace","value":"8. VM"}' class="btn btn-secondary btn-sm">8. VM</button>
</div>
<div class="col-12">
<button type="button" data-msg='{"type":"workspace","value":"4"}' class="btn btn-secondary btn-sm">4</button>
<button type="button" data-msg='{"type":"workspace","value":"5"}' class="btn btn-secondary btn-sm">5</button>
<button type="button" data-msg='{"type":"workspace","value":"9"}' class="btn btn-secondary btn-sm">9</button>
<button type="button" data-msg='{"type":"workspace","value":"10"}' class="btn btn-secondary btn-sm">10</button>
<button type="button" data-msg='{"type":"workspace","value":"11"}' class="btn btn-secondary btn-sm">11</button>
<button type="button" data-msg='{"type":"workspace","value":"12"}' class="btn btn-secondary btn-sm">12</button>
</div>
</div>
<div class="row">
<div class="col-12">
<p class="legend">Software</p>
</div>
<div class="col-12">
<button type="button" data-msg='{"type":"messages","value":[{"type":"keys","value":"win,d"},{"type":"text","value":"urxvt"},{"type":"key","value":"enter"}]}' class="btn btn-secondary">urxvt</button>
<button type="button" data-msg='{"type":"keys","value":"win,d"}' class="btn btn-secondary">dmenu</button>
</div>
<div class="col-12">
<p class="legend">UI</p>
</div>
<div class="col-12">
<button type="button" data-msg='{"type":"keys","value":"win,z"}' class="btn btn-secondary">win+z</button>
<button type="button" data-msg='{"type":"keys","value":"win,x"}' class="btn btn-secondary">win+x</button>
<button type="button" data-msg='{"type":"keys","value":"win,c"}' class="btn btn-secondary">win+c</button>
<button type="button" data-msg='{"type":"messages","value":[{"type":"text","value":"zp"},{"type":"key","value":"enter"}]}' class="btn btn-secondary">zp</button>
<button type="button" data-msg='{"type":"messages","value":[{"type":"text","value":"zm"},{"type":"key","value":"enter"}]}' class="btn btn-secondary">zm</button>
</div>
<div class="line col-12"></div>
<div class="col-12">
<button type="button" data-msg='{"type":"messages","value":[{"type":"keys","value":"win,d"},{"type":"text","value":"no-screensaver"},{"type":"key","value":"enter"}]}' class="btn btn-secondary">no-screensaver[on]</button>
<button type="button" data-msg='{"type":"messages","value":[{"type":"keys","value":"win,d"},{"type":"text","value":"pkill no-screensaver"},{"type":"key","value":"enter"}]}' class="btn btn-secondary">no-screensaver[off]</button>
</div>
<div class="col-12">
<p class="legend">Movie</p>
</div>
<div class="col-12">
<button type="button" data-msg='{"type":"messages","value":[{"type":"text","value":"v;mll"},{"type":"key","value":"enter"}]}' class="btn btn-secondary">v;mll</button>
<button type="button" data-msg='{"type":"messages","value":[{"type":"text","value":"rt -l"},{"type":"key","value":"enter"}]}' class="btn btn-secondary">rt -l</button>
</div>
<div class="line col-12"></div>
<div class="col-12">
<button type="button" data-msg='{"type":"messages","value":[{"type":"text","value":"mug"},{"type":"key","value":"enter"}]}' class="btn btn-secondary">mug</button>
<button type="button" data-msg='{"type":"messages","value":[{"type":"text","value":"mup"},{"type":"key","value":"enter"}]}' class="btn btn-secondary">mup</button>
<button type="button" data-msg='{"type":"messages","value":[{"type":"text","value":"mug 1"},{"type":"key","value":"enter"}]}' class="btn btn-secondary">mug 1</button>
<button type="button" data-msg='{"type":"messages","value":[{"type":"text","value":"mup 1"},{"type":"key","value":"enter"}]}' class="btn btn-secondary">mup 1</button>
</div>
</div>
</div>
<div class="row pane" id="pane-pointer">
<div class="form-group col-12">
<input type="text" class="form-control live-text" placeholder="Live text" name="text">
</div/>
<div id="scrollbar"></div>
<div id="pointer"></div>
<div id="pointer-buttons">
<button type="button" data-msg='{"type":"pointer","click":"left"}' class="btn btn-primary no-radius col-5">&nbsp;</button><button type="button no-margin" data-msg='{"type":"pointer","click":"middle"}' class="btn btn-secondary no-radius col-2">&nbsp;</button><button type="button no-margin" data-msg='{"type":"pointer","click":"right"}' class="btn btn-primary no-radius col-5">&nbsp;</button>
</div>
</div>
<div class="row pane" id="pane-media">
<div class="col-12">
<p class="legend">Spotify</p>
</div>
<div class="col-12">
<button type="button" data-msg='{"type":"media","value":"playpause"}' class="btn btn-secondary">Play/Pause</button>
<button type="button" data-msg='{"type":"media","value":"next"}' class="btn btn-secondary">Next</button>
<button type="button" data-msg='{"type":"media","value":"prev"}' class="btn btn-secondary">Previous</button>
</div>
<div class="col-12">
<p class="legend">Volume</p>
</div>
<div class="col-12">
<button type="button" data-msg='{"type":"volume","value":"0"}' class="btn btn-secondary">0%</button>
<button type="button" data-msg='{"type":"volume","value":"25"}' class="btn btn-secondary">25%</button>
<button type="button" data-msg='{"type":"volume","value":"50"}' class="btn btn-secondary">50%</button>
<button type="button" data-msg='{"type":"volume","value":"75"}' class="btn btn-secondary">75%</button>
<button type="button" data-msg='{"type":"volume","value":"100"}' class="btn btn-secondary">100%</button>
</div>
<div class="line col-12"></div>
<div class="col-12">
<button type="button" data-msg='{"type":"volume","value":"down"}' class="btn btn-secondary">-</button>
<button type="button" data-msg='{"type":"volume","value":"up"}' class="btn btn-secondary">+</button>
</div>
</div>
<div class="row pane" id="pane-desktop">
<div class="col-12">
<p class="legend">Desktop</p>
</div>
<div class="col-12">
<button type="button" data-msg='{"type":"screenshot","quality":"hq"}' class="btn btn-sm btn-secondary">Screenshot HQ</button>
<button type="button" data-msg='{"type":"screenshot","quality":"lq"}' class="btn btn-sm btn-secondary">Screenshot LQ</button>
<button type="button" id="live-hq" class="btn btn-sm btn-secondary">Live HQ</button>
<button type="button" id="live-lq" class="btn btn-sm btn-secondary">Live LQ</button>
<div id="screenshot"><img class="btn-fullscreen" data-target="#screenshot img" src="data:image/png; base64, iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4gIJDjc3srQk8gAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAADElEQVQI12P48+cPAAXsAvVTWDc6AAAAAElFTkSuQmCC"></div>
</div>
<div class="col-12">
</div>
</div>
</div>
{{end}}

371
ws_controller.go Normal file
View file

@ -0,0 +1,371 @@
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"github.com/kbinani/screenshot"
"github.com/labstack/echo/v4"
"golang.org/x/net/websocket"
"image/jpeg"
"os/exec"
"strconv"
"strings"
"time"
)
type Message struct {
Type string `json:type`
}
type SimpleMessageData struct {
Type string `json:type`
Value string `json:value`
}
type PointerMessageData struct {
X string `json:x`
Y string `json:y`
Click string `json:click`
}
type MessagesData struct {
Type string `json:type`
Value []SimpleMessageData `json:value`
}
type ScreenshotMessageData struct {
Quality string `json:quality`
}
type MessageResponse struct {
Type string `json:"type"`
Value string `json:"value"`
}
func getSimpleMessageValue(msg string) string {
data := SimpleMessageData{}
json.Unmarshal([]byte(msg), &data)
return data.Value
}
func sendMessageResponse(ws *websocket.Conn, r MessageResponse) {
value, _ := json.Marshal(r)
websocket.Message.Send(ws, string(value))
}
func wsController(c echo.Context) error {
var actions = Actions{
Functions: make(map[string]func(ws *websocket.Conn, msg string) error),
}
actions.add("pointer", func(ws *websocket.Conn, msg string) error {
data := PointerMessageData{}
json.Unmarshal([]byte(msg), &data)
if data.Click != "" {
keys := make(map[string]string)
keys["left"] = "1"
keys["middle"] = "2"
keys["right"] = "3"
key, exists := keys[data.Click]
if !exists {
return errors.New("Invalid value")
}
cmd := exec.Command("xdotool", "click", key)
return cmd.Run()
}
location := exec.Command("xdotool", "getmouselocation")
output, _ := location.Output()
position := string(output)
currentX := 0.0
currentY := 0.0
for key, value := range strings.Split(position, " ") {
if key == 0 {
currentX, _ = strconv.ParseFloat(strings.Replace(value, "x:", "", 1), 32)
} else if key == 1 {
currentY, _ = strconv.ParseFloat(strings.Replace(value, "y:", "", 1), 32)
}
}
newX, _ := strconv.ParseFloat(data.X, 32)
newY, _ := strconv.ParseFloat(data.Y, 32)
x := currentX + newX*2.5
y := currentY + newY*2.5
cmd := exec.Command("xdotool", "mousemove", fmt.Sprintf("%.0f", x), fmt.Sprintf("%.0f", y))
return cmd.Run()
})
actions.add("scroll", func(ws *websocket.Conn, msg string) error {
value := getSimpleMessageValue(msg)
key := ""
if value == "down" {
key = "5"
} else if value == "up" {
key = "4"
} else {
return errors.New("Invalid value")
}
for i := 0; i < 2; i++ {
cmd := exec.Command("xdotool", "click", key)
cmd.Run()
}
return nil
})
actions.add("workspace", func(ws *websocket.Conn, msg string) error {
value := getSimpleMessageValue(msg)
if value == "" {
return errors.New("Invalid value")
}
cmd := exec.Command("i3-msg", fmt.Sprintf("workspace \"%s\"", value))
return cmd.Run()
})
actions.add("volume", func(ws *websocket.Conn, msg string) error {
value := getSimpleMessageValue(msg)
if value == "" {
return errors.New("Invalid value")
}
if value == "up" {
cmd := exec.Command("amixer", "set", "Master", "2%+")
sendMessageResponse(ws, MessageResponse{
Type: "response",
Value: "Volume up",
})
return cmd.Run()
}
if value == "down" {
cmd := exec.Command("amixer", "set", "Master", "2%-")
sendMessageResponse(ws, MessageResponse{
Type: "response",
Value: "Volume down",
})
return cmd.Run()
}
cmd := exec.Command("amixer", "set", "Master", fmt.Sprintf("%s%%", value))
sendMessageResponse(ws, MessageResponse{
Type: "response",
Value: fmt.Sprintf("Volume set to %s%%", value),
})
return cmd.Run()
})
actions.add("media", func(ws *websocket.Conn, msg string) error {
value := getSimpleMessageValue(msg)
if value == "" {
return errors.New("Invalid value")
}
var arg string
if value == "playpause" {
arg = "play-pause"
} else if value == "next" {
arg = "next"
} else if value == "prev" {
arg = "previous"
} else {
return errors.New("Invalid value")
}
cmd := exec.Command("playerctl", "-p", "spotify", arg)
err := cmd.Run()
if err != nil {
return err
}
time.Sleep(400 * time.Millisecond)
cmd = exec.Command("playerctl", "-p", "spotify", "status")
output, err := cmd.Output()
value = strings.TrimSpace(string(output))
if err != nil {
return err
}
if value == "Playing" {
cmd = exec.Command("playerctl", "-p", "spotify", "metadata", "xesam:title")
output, err := cmd.Output()
value = strings.TrimSpace(string(output))
if err != nil {
return err
}
sendMessageResponse(ws, MessageResponse{
Type: "response",
Value: fmt.Sprintf("Playing: %s", value),
})
} else {
sendMessageResponse(ws, MessageResponse{
Type: "response",
Value: "Paused",
})
}
return nil
})
actions.add("keys", func(ws *websocket.Conn, msg string) error {
value := strings.TrimSpace(getSimpleMessageValue(msg))
if value == "" {
return errors.New("Invalid value")
}
keys := []string{}
for _, key := range strings.Split(value, ",") {
if key == "win" {
key = "super"
} else if key == "ctrl" {
key = "Control_L"
} else if key == "alt" {
key = "Alt_L"
}
if key != "" {
keys = append(keys, key)
}
}
if len(keys) == 0 {
return errors.New("Invalid value")
}
cmd := exec.Command("xdotool", "key", strings.Join(keys, "+"))
return cmd.Run()
})
actions.add("key", func(ws *websocket.Conn, msg string) error {
value := strings.TrimSpace(getSimpleMessageValue(msg))
keys := make(map[string]string)
keys["up"] = "Up"
keys["down"] = "Down"
keys["left"] = "Left"
keys["right"] = "Right"
keys["tab"] = "Tab"
keys["backspace"] = "BackSpace"
keys["enter"] = "Return"
keys["space"] = "space"
keys["escape"] = "Escape"
key, exists := keys[value]
if !exists {
return errors.New("Invalid value")
}
cmd := exec.Command("xdotool", "key", key)
return cmd.Run()
})
actions.add("text", func(ws *websocket.Conn, msg string) error {
value := strings.TrimSpace(getSimpleMessageValue(msg))
if value == "" {
return errors.New("Invalid value")
}
cmd := exec.Command("xdotool", "type", value)
return cmd.Run()
})
actions.add("screenshot", func(ws *websocket.Conn, msg string) error {
data := ScreenshotMessageData{}
json.Unmarshal([]byte(msg), &data)
bounds := screenshot.GetDisplayBounds(0)
img, err := screenshot.CaptureRect(bounds)
if err != nil {
return errors.New("Capture error")
}
var quality int
if data.Quality == "lq" {
quality = 10
} else {
quality = 90
}
buff := new(bytes.Buffer)
jpeg.Encode(buff, img, &jpeg.Options{Quality: quality})
sendMessageResponse(ws, MessageResponse{
Type: "screenshot",
Value: toBase64(buff.Bytes()),
})
return nil
})
actions.add("messages", func(ws *websocket.Conn, msg string) error {
data := MessagesData{}
json.Unmarshal([]byte(msg), &data)
for _, value := range data.Value {
msg, _ := json.Marshal(value)
if actions.has(value.Type) {
actions.exec(value.Type, ws, string(msg))
time.Sleep(400 * time.Millisecond)
}
}
return nil
})
websocket.Handler(func(ws *websocket.Conn) {
defer ws.Close()
for {
msg := ""
websocket.Message.Receive(ws, &msg)
message := Message{}
json.Unmarshal([]byte(msg), &message)
if message.Type != "" && actions.has(message.Type) {
actions.exec(message.Type, ws, msg)
}
}
}).ServeHTTP(c.Response(), c.Request())
return nil
}