diff --git a/CHANGELOG.md b/CHANGELOG.md index 5beebec..0642256 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ ## [Unreleased] +## v3.0.0 +### Fixed +* feat: center live capture in pointer +### Changed +* refactor: change organisation +* feat: use MJPEG for live capture + ## v2.1.0 ### Added * add live on pointer block diff --git a/Makefile b/Makefile index 2c636a3..5f95f87 100644 --- a/Makefile +++ b/Makefile @@ -12,11 +12,12 @@ all: build deps: go install github.com/GeertJohan/go.rice/rice@latest - rice embed-go + cd cmd && 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)" + CGO_ENABLED=$(CGO_ENABLED) GOARCH=amd64 GOOS=linux $(CC) $(CFLAGS) -o $(BUILD_DIR)/$(LINUX_BIN) -ldflags="$(LDFLAGS)" -gcflags="$(GCFLAGS)" -asmflags="$(ASMFLAGS)" -tags=static_build ./cmd/ watch: - gowatch -o build/app-live-linux-amd64 -args='./config.yaml' + test -f cmd/rice-box.go && rm cmd/rice-box.go || true + gowatch -o build/app-live-linux-amd64 -args='./config.yaml' -p ./cmd/main.go diff --git a/main.go b/cmd/main.go similarity index 52% rename from main.go rename to cmd/main.go index 8c56943..7e6f966 100644 --- a/main.go +++ b/cmd/main.go @@ -2,25 +2,15 @@ package main import ( "crypto/subtle" - "embed" "fmt" - "html/template" "net/http" "os" rice "github.com/GeertJohan/go.rice" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" -) - -var ( - templates map[string]*template.Template - //go:embed static - staticFiles embed.FS - //go:embed views/layout views/page - views embed.FS - config Config - actions Actions + "gitnet.fr/deblan/remote-i3wm-go/internal/config" + "gitnet.fr/deblan/remote-i3wm-go/internal/handler" ) func main() { @@ -32,7 +22,7 @@ func main() { os.Exit(1) } - value, err := createConfigFromFile(os.Args[1]) + value, err := config.CreateConfigFromFile(os.Args[1]) if err != nil { fmt.Printf("Configuration error:") @@ -40,14 +30,15 @@ func main() { os.Exit(1) } - config = value + conf := value + e.Use(middleware.BasicAuth(func(username, password string, c echo.Context) (bool, error) { - if config.Server.Auth.Username == "" && config.Server.Auth.Password == "" { + if conf.Server.Auth.Username == "" && conf.Server.Auth.Password == "" { return true, nil } - isValidUsername := subtle.ConstantTimeCompare([]byte(username), []byte(config.Server.Auth.Username)) == 1 - isValidPassword := subtle.ConstantTimeCompare([]byte(password), []byte(config.Server.Auth.Password)) == 1 + isValidUsername := subtle.ConstantTimeCompare([]byte(username), []byte(conf.Server.Auth.Username)) == 1 + isValidPassword := subtle.ConstantTimeCompare([]byte(password), []byte(conf.Server.Auth.Password)) == 1 if isValidUsername && isValidPassword { return true, nil @@ -56,21 +47,23 @@ func main() { return false, nil })) - assetHandler := http.FileServer(rice.MustFindBox("static").HTTPBox()) - actions = createActions() + assetHandler := http.FileServer(rice.MustFindBox("../static").HTTPBox()) e.GET("/static/*", echo.WrapHandler(http.StripPrefix("/static/", assetHandler))) - e.GET("/manifest.webmanifest", manifestController) - e.GET("/", homeController) - e.GET("/ws", wsController) + e.GET("/manifest.webmanifest", handler.ManifestHandler) + e.GET("/ws", handler.WsHandler) + e.GET("/capture", handler.CaptureHandler) + e.GET("/", func(c echo.Context) error { + return handler.HomeHandler(c, conf) + }) - if config.Server.Tls.Enable == false { - e.Logger.Fatal(e.Start(config.Server.Listen)) + if conf.Server.Tls.Enable == false { + e.Logger.Fatal(e.Start(conf.Server.Listen)) } else { e.Logger.Fatal(e.StartTLS( - config.Server.Listen, - config.Server.Tls.CertFile, - config.Server.Tls.CertKeyFile, + conf.Server.Listen, + conf.Server.Tls.CertFile, + conf.Server.Tls.CertKeyFile, )) } } diff --git a/rice-box.go b/cmd/rice-box.go similarity index 94% rename from rice-box.go rename to cmd/rice-box.go index c8f092d..e81cf9c 100644 --- a/rice-box.go +++ b/cmd/rice-box.go @@ -18,9 +18,9 @@ func init() { } file4 := &embedded.EmbeddedFile{ Filename: "css/main.css", - FileModTime: time.Unix(1745768085, 0), + FileModTime: time.Unix(1765486408, 0), - Content: string(":root {\n --link-color: #1e3650;\n --bs-link-color: var(--link-color);\n}\n\n* {\n overscroll-behavior: contain !important;\n}\n\na {\n color: var(--link-color);\n}\n\n.btn-primary {\n background: #1e3650;\n border-color: #0e2640;\n}\n\n.nav-pills .nav-link.active {\n background: #1e3650;\n}\n\n.nav-pills .nav-link {\n padding-left: 3px;\n padding-right: 3px;\n}\n\n.nav-link {\n font-size: 10px;\n}\n\n.legend {\n color: #777;\n margin: 3px 0;\n padding: 3px 0;\n border-bottom: 1px solid #eee;\n font-size: 11px;\n text-transform: uppercase;\n}\n\n.btn-sm {\n font-size: 9px;\n}\n\n.select2 {\n min-width: 100%;\n}\n\n.line {\n height: 3px;\n}\n\n.pane {\n display: none;\n}\n\n.no-margin {\n margin: 0;\n}\n\n.no-padding {\n padding: 0;\n}\n\n.no-radius {\n border-radius: 0 !important;\n}\n\n#pointer {\n height: calc(100vh - 33px);\n bottom: 33px;\n margin: auto;\n background: #ccc;\n background-size: contain;\n background-repeat: no-repeat;\n position: absolute;\n width: calc(100% - 50px);\n -webkit-touch-callout: none;\n -webkit-user-select: none;\n -khtml-user-select: none;\n -moz-user-select: none;\n -ms-user-select: none;\n user-select: none;\n}\n\n#scrollbar {\n height: calc(100vh - 33px);\n width: 50px;\n background: #333;\n position: fixed;\n bottom: 33px;\n z-index: 100;\n right: 0;\n}\n\n#mouse {\n position: fixed;\n bottom: 83px;\n z-index: 200;\n width: calc(100vw - 50px);\n padding: 10px;\n background: #fff;\n}\n\n#pane-pointer .form-group {\n padding: 0;\n margin: 0;\n}\n\n#pointer-buttons {\n margin-top: -42px;\n width: 100%;\n z-index: 110;\n position: fixed;\n bottom: 31px;\n padding-left: 0;\n padding-right: 0;\n}\n\n#pointer-buttons .btn {\n height: 50px;\n}\n\n#disconneced {\n position: absolute;\n top: 0;\n width: 100%;\n background: #ff6161;\n color: #fff;\n padding: 5px;\n}\n\n#disconneced a {\n color: #fff;\n font-weight: bold;\n}\n\n#nav {\n border-top: 2px solid #1e3650;\n position: fixed;\n bottom: 0;\n width: 100%;\n height: 33px;\n}\n\n#shortcuts_special_keys input {\n display: none;\n}\n\n#response {\n position: absolute;\n top: 0;\n width: 100%;\n color: #fff;\n background: #748c26;\n padding: 5px;\n display: none;\n}\n\n#screenshot img {\n max-width: 100%;\n margin-top: 10px;\n cursor: pointer;\n}\n\n#mouse-screenshot-live {\n display: inline-block;\n width: 80px;\n padding-left: 5px;\n}\n\n#mouse-text-live {\n display: inline-block;\n width: calc(100% - 100px);\n}\n"), + Content: string(":root {\n --link-color: #1e3650;\n --bs-link-color: var(--link-color);\n}\n\n* {\n overscroll-behavior: contain !important;\n}\n\na {\n color: var(--link-color);\n}\n\n.btn-primary {\n background: #1e3650;\n border-color: #0e2640;\n}\n\n.nav-pills .nav-link.active {\n background: #1e3650;\n}\n\n.nav-pills .nav-link {\n padding-left: 3px;\n padding-right: 3px;\n}\n\n.nav-link {\n font-size: 10px;\n}\n\n.legend {\n color: #777;\n margin: 3px 0;\n padding: 3px 0;\n border-bottom: 1px solid #eee;\n font-size: 11px;\n text-transform: uppercase;\n}\n\n.btn-sm {\n font-size: 9px;\n}\n\n.select2 {\n min-width: 100%;\n}\n\n.line {\n height: 3px;\n}\n\n.pane {\n display: none;\n}\n\n.no-margin {\n margin: 0;\n}\n\n.no-padding {\n padding: 0;\n}\n\n.no-radius {\n border-radius: 0 !important;\n}\n\n#pointer {\n height: calc(100vh - 33px);\n bottom: 33px;\n margin: auto;\n background: #ccc;\n background-size: contain;\n background-repeat: no-repeat;\n background-position: center;\n position: absolute;\n width: calc(100% - 50px);\n -webkit-touch-callout: none;\n -webkit-user-select: none;\n -khtml-user-select: none;\n -moz-user-select: none;\n -ms-user-select: none;\n user-select: none;\n}\n\n#scrollbar {\n height: calc(100vh - 33px);\n width: 50px;\n background: #333;\n position: fixed;\n bottom: 33px;\n z-index: 100;\n right: 0;\n}\n\n#mouse {\n position: fixed;\n bottom: 83px;\n z-index: 200;\n width: calc(100vw - 50px);\n padding: 10px;\n background: #fff;\n}\n\n#pane-pointer .form-group {\n padding: 0;\n margin: 0;\n}\n\n#pointer-buttons {\n margin-top: -42px;\n width: 100%;\n z-index: 110;\n position: fixed;\n bottom: 31px;\n padding-left: 0;\n padding-right: 0;\n}\n\n#pointer-buttons .btn {\n height: 50px;\n}\n\n#disconneced {\n position: absolute;\n top: 0;\n width: 100%;\n background: #ff6161;\n color: #fff;\n padding: 5px;\n}\n\n#disconneced a {\n color: #fff;\n font-weight: bold;\n}\n\n#nav {\n border-top: 2px solid #1e3650;\n position: fixed;\n bottom: 0;\n width: 100%;\n height: 33px;\n}\n\n#shortcuts_special_keys input {\n display: none;\n}\n\n#response {\n position: absolute;\n top: 0;\n width: 100%;\n color: #fff;\n background: #748c26;\n padding: 5px;\n display: none;\n}\n\n.capture-img img {\n max-width: 100%;\n margin-top: 10px;\n cursor: pointer;\n}\n\n#mouse-screenshot-live {\n display: inline-block;\n width: 80px;\n padding-left: 5px;\n}\n\n#mouse-text-live {\n display: inline-block;\n width: calc(100% - 100px);\n}\n"), } file6 := &embedded.EmbeddedFile{ Filename: "img/icon.png", @@ -30,9 +30,9 @@ func init() { } file8 := &embedded.EmbeddedFile{ Filename: "js/main.js", - FileModTime: time.Unix(1745768085, 0), + FileModTime: time.Unix(1765740967, 0), - Content: string("let ws\nlet pointer, scroller, response, screenshotImg\nlet scrollLastTimestamp, scrollLastValue\nlet mousePosX, mousePosY, mouseInitPosX, mouseInitPosY\nlet isLive = false\nlet wsLock = false\nlet isPointerLive = false\nlet isScreenshotWaiting = null\nlet isPointerScreenshotWaiting = false\n\nfunction createWebSocketConnection() {\n const protocol = location.protocol === 'https:' ? 'wss' : 'ws'\n\n ws = new WebSocket(`${protocol}://${window.location.hostname}:${window.location.port}/ws`)\n\n ws.addEventListener('open', function(event) {\n document.querySelector('#disconneced').style.display = 'none'\n unLock()\n })\n\n ws.addEventListener('close', function(event) {\n unLock()\n document.querySelector('#disconneced').style.display = 'block'\n\n window.setTimeout(createWebSocketConnection, 5000)\n })\n \n ws.addEventListener('message', function(event) {\n unLock()\n let data = JSON.parse(event.data)\n\n if (data.type === 'response') {\n response.innerText = data.value\n response.style.display = 'block'\n\n window.setTimeout(function() {\n response.style.display = 'none'\n }, 2500)\n\n return\n }\n\n if (data.type === 'volume') {\n if (data.value.length) {\n setVolume(parseInt(data.value))\n }\n\n return\n }\n\n if (data.type === 'screenshot') {\n if (isScreenshotWaiting || isScreenshotWaiting === null) {\n if (isScreenshotWaiting) {\n isScreenshotWaiting = false\n }\n\n screenshotImg.setAttribute('src', 'data:image/png;base64, ' + data.value)\n }\n\n let pointer = document.querySelector('#pointer')\n\n if (isPointerScreenshotWaiting) {\n pointer.style.backgroundImage = `url('data:image/png;base64, ${data.value}')`\n isPointerScreenshotWaiting = false\n } else {\n pointer.style.backgroundImage = 'none'\n }\n }\n\n return\n })\n}\n\nfunction isLocked() {\n return wsLock === true\n}\n\nfunction lock() {\n wsLock = true\n}\n\nfunction unLock() {\n wsLock = false\n}\n\nfunction send(message) {\n if (isLocked()) {\n return\n }\n\n lock()\n\n ws.send(message)\n}\n\nfunction navigationClickHandler(e) {\n if (e.target.getAttribute('href') === '#') {\n return\n }\n\n Array.from(document.querySelectorAll('.pane')).forEach((item) => {\n item.style.display = 'none'\n })\n\n document.querySelector(e.target.getAttribute('href')).style.display = 'block'\n\n Array.from(document.querySelectorAll('#nav a')).forEach((item) => {\n item.classList.remove('active')\n })\n\n e.target.classList.add('active')\n}\n\nfunction buttonClickHandler(e) {\n send(e.target.getAttribute('data-msg'))\n}\n\nfunction shortcutClearClickHandler(e) {\n document.querySelector('#shortcut-key').value = ''\n\n Array.from(document.querySelectorAll('#shortcuts_special_keys input:checked')).forEach((item) => {\n item.checked = false\n item.change()\n })\n}\n\nfunction shortcutSendClickHandler(e) {\n let keys = []\n let key = document.querySelector('#shortcut-key').value\n\n Array.from(document.querySelectorAll('#shortcuts_special_keys input:checked')).forEach((item) => {\n keys.push(item.value)\n })\n\n if (keys.length) {\n if (key) {\n keys.push(key)\n }\n\n send('{\"type\":\"keys\",\"value\": \"' + (keys.join(',').replace('\"', '\\\\\"')) + '\"}')\n }\n}\n\nfunction textClearClickHandler(e) {\n document.querySelector('#text').value = ''\n}\n\nfunction textSendClickHandler(e) {\n const keys = document.querySelector('#text').value\n\n if (keys.length) {\n send('{\"type\":\"text\",\"value\": \"' + (keys.replace('\"', '\\\\\"')) + '\"}')\n }\n}\n\nfunction textKeyUpHandler(e) {\n const keys = document.querySelector('#text').value\n\n if (e.keyCode === 13) {\n send('{\"type\":\"text\",\"value\": \"' + (keys.replace('\"', '\\\\\"')) + '\"}')\n }\n}\n\nfunction liveTextKeyUpHandler(e) {\n const value = e.target.value\n\n if (e.keyCode === 8) {\n send('{\"type\":\"key\",\"value\": \"backspace\"}')\n } else if (e.keyCode === 13) {\n send('{\"type\":\"key\",\"value\": \"enter\"}')\n } else if (value.length) {\n if (value === ' ') {\n send('{\"type\":\"key\",\"value\": \"space\"}')\n } else {\n send('{\"type\":\"text\",\"value\": \"' + (value.replace('\"', '\\\\\"')) + '\"}')\n }\n\n e.target.value = ''\n }\n}\n\nfunction shortcutsSpecialKeysOnChangeHandler(e) {\n Array.from(document.querySelectorAll('#shortcuts_special_keys input:checked')).forEach((item) => {\n item.parentNode.classList.add('btn-primary')\n item.parentNode.classList.remove('btn-secondary')\n })\n\n Array.from(document.querySelectorAll('#shortcuts_special_keys input:not(:checked)')).forEach((item) => {\n item.parentNode.classList.add('btn-secondary')\n item.parentNode.classList.remove('btn-primary')\n })\n}\n\nfunction pointerClickHandler(e) {\n send('{\"type\":\"pointer\",\"click\":\"left\"}')\n}\n\nfunction scrollerTouchStartHandler(e) {\n mouseInitPosY = e.targetTouches[0].pageY\n}\n\nfunction scrollerTouchMoveHandler(e) {\n let touch = e.changedTouches[0]\n let value = ((touch.pageY - mouseInitPosY > 0) ? 'down' : 'up')\n let now = new Date().getTime()\n\n if (touch.pageY === mouseInitPosY || value === scrollLastValue && scrollLastTimestamp !== null && now - scrollLastTimestamp < 200) {\n return\n }\n\n scrollLastTimestamp = now\n scrollLastValue = value\n mouseInitPosY = touch.pageY\n\n send('{\"type\":\"scroll\",\"value\": \"' + value + '\"}')\n}\n\nfunction pointerTouchStartHandler(e) {\n const touch = e.targetTouches[0]\n mouseInitPosX = touch.pageX\n mouseInitPosY = touch.pageY\n}\n\nfunction pointerLiveHandler(e) {\n if (!e.target.checked) {\n isPointerLive = false\n isPointerScreenshotWaiting = null\n\n return\n }\n\n isPointerLive = true\n\n let doScreenshot = function() {\n if (isPointerLive) {\n if (!isPointerScreenshotWaiting) {\n isPointerScreenshotWaiting = true\n ws.send(`{\"type\":\"screenshot\",\"quality\":\"lq\",\"pointer\":true}`)\n }\n\n window.setTimeout(doScreenshot, 300)\n }\n }\n\n doScreenshot()\n}\n\nfunction pointerTouchMoveHandler(e) {\n if (e.changedTouches.length === 2) {\n return scrollerTouchMoveHandler(e)\n }\n\n const touch = e.changedTouches[0]\n mousePosX = touch.pageX\n mousePosY = touch.pageY\n\n const newX = mousePosX - mouseInitPosX\n const newY = mousePosY - mouseInitPosY\n\n mouseInitPosX = mousePosX\n mouseInitPosY = mousePosY\n\n let msg = '{\"type\":\"pointer\",\"x\": \"' + newX + '\",\"y\": \"' + newY + '\"}'\n\n ws.send(msg)\n}\n\nfunction liveHqClickHandler(e) {\n return liveClickHandler(e, 'hq')\n}\n\nfunction liveLqClickHandler(e) {\n return liveClickHandler(e, 'lq')\n}\n\nfunction liveClickHandler(e, quality) {\n if (isLive) {\n isLive = false\n isScreenshotWaiting = null\n\n document.querySelector('#live-hq').innerText = 'Live HQ'\n document.querySelector('#live-lq').innerText = 'Live LQ'\n\n return\n }\n\n isLive = true\n\n e.target.innerText = 'Stop live'\n\n let doScreenshot = function() {\n if (isLive) {\n if (!isScreenshotWaiting) {\n isScreenshotWaiting = true\n ws.send(`{\"type\":\"screenshot\",\"quality\":\"${quality}\"}`)\n }\n\n window.setTimeout(doScreenshot, 100)\n }\n }\n\n doScreenshot()\n}\n\nfunction fullscreenHandler(e) {\n let element = document.querySelector(e.target.getAttribute('data-target'))\n let isFullscreen = parseInt(e.target.getAttribute('data-fullscreen'))\n\n document.querySelector('body').classList.toggle('fullscreen', isFullscreen)\n\n if (isFullscreen) {\n e.target.setAttribute('data-fullscreen', '0')\n\n if (document.exitFullscreen) {\n document.exitFullscreen()\n } else if (document.webkitExitFullscreen) {\n document.webkitExitFullscreen()\n } else if (document.mozCancelFullScreen) {\n document.mozCancelFullScreen()\n }\n } else {\n e.target.setAttribute('data-fullscreen', '1')\n\n if (element.requestFullscreen) {\n element.requestFullscreen()\n } else if (element.webkitRequestFullscreen) {\n element.webkitRequestFullscreen()\n } else if (element.mozRequestFullScreen) {\n element.mozRequestFullScreen()\n }\n }\n}\n\nfunction documentHashHandler() {\n const hash = window.location.hash\n\n if (hash) {\n document.querySelector('a[href=\"' + hash + '\"]').click()\n } else {\n document.querySelector('#nav > li:first-child a').click()\n }\n}\n\nfunction addEventListenerOn(selector, eventName, listener) {\n if (typeof selector === 'string') {\n Array.from(document.querySelectorAll(selector)).forEach((element) => {\n element.addEventListener(eventName, listener)\n })\n } else {\n selector.addEventListener(eventName, listener)\n }\n}\n\nfunction addListeners() {\n addEventListenerOn('#nav a', 'click', navigationClickHandler)\n addEventListenerOn('button[data-msg]', 'click', buttonClickHandler)\n\n addEventListenerOn('#shortcut-clear', 'click', shortcutClearClickHandler)\n addEventListenerOn('#shortcuts_special_keys input', 'change', shortcutsSpecialKeysOnChangeHandler)\n addEventListenerOn('#shortcut-send', 'click', shortcutSendClickHandler)\n\n addEventListenerOn('#text-clear', 'click', textClearClickHandler)\n addEventListenerOn('#text-send', 'click', textSendClickHandler)\n addEventListenerOn('#text', 'keyup', textKeyUpHandler)\n addEventListenerOn('.live-text', 'keyup', liveTextKeyUpHandler)\n\n addEventListenerOn(scroller, 'touchstart', scrollerTouchStartHandler)\n addEventListenerOn(scroller, 'touchmove', scrollerTouchMoveHandler)\n\n addEventListenerOn(pointer, 'click', pointerClickHandler)\n addEventListenerOn(pointer, 'touchstart', pointerTouchStartHandler)\n addEventListenerOn(pointer, 'touchmove', pointerTouchMoveHandler)\n addEventListenerOn('#mouse-screenshot-live input', 'change', pointerLiveHandler)\n\n addEventListenerOn('#live-hq', 'click', liveHqClickHandler)\n addEventListenerOn('#live-lq', 'click', liveLqClickHandler)\n addEventListenerOn('.btn-fullscreen', 'click', fullscreenHandler)\n}\n\nfunction getVolume() {\n if (document.querySelectorAll('.volume').length) {\n try {\n send('{\"type\":\"volume\",\"value\":\"value\"}')\n } catch (e) {\n }\n\n document.querySelectorAll('.volume input[type=\"range\"]').forEach(function(input) {\n if (input.getAttribute('data-event')) {\n return\n }\n\n input.setAttribute('data-event', 'ok')\n input.addEventListener('change', (e) => {\n send(`{\"type\":\"volume\",\"value\":\"${e.target.value}\"}`)\n })\n })\n }\n\n window.setTimeout(getVolume, 2000)\n}\n\nfunction setVolume(value) {\n document.querySelectorAll('.volume').forEach(function(item) {\n item.querySelector('input[type=\"range\"]').value = value\n })\n}\n\nfunction bootstrap() {\n pointer = document.querySelector('#pointer')\n scroller = document.querySelector('#scrollbar')\n response = document.querySelector('#response')\n screenshotImg = document.querySelector('#screenshot img')\n\n shortcutsSpecialKeysOnChangeHandler()\n createWebSocketConnection()\n addListeners()\n documentHashHandler()\n getVolume()\n\n if ('serviceWorker' in navigator) {\n navigator.serviceWorker.register('/static/js/service_worker.js')\n }\n}\n\naddEventListenerOn(window, 'DOMContentLoaded', bootstrap)\n"), + Content: string("let ws\nlet pointer, scroller, response, screenshotImg\nlet scrollLastTimestamp, scrollLastValue\nlet mousePosX, mousePosY, mouseInitPosX, mouseInitPosY\nlet isLive = false\nlet wsLock = false\nlet isPointerLive = false\nlet isScreenshotWaiting = null\nlet isPointerScreenshotWaiting = false\nlet emptyImg = \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4gIJDjc3srQk8gAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAADElEQVQI12P48+cPAAXsAvVTWDc6AAAAAElFTkSuQmCC\"\n\nfunction createWebSocketConnection() {\n const protocol = location.protocol === 'https:' ? 'wss' : 'ws'\n\n ws = new WebSocket(`${protocol}://${window.location.hostname}:${window.location.port}/ws`)\n\n ws.addEventListener('open', function(event) {\n document.querySelector('#disconneced').style.display = 'none'\n unLock()\n })\n\n ws.addEventListener('close', function(event) {\n unLock()\n document.querySelector('#disconneced').style.display = 'block'\n\n window.setTimeout(createWebSocketConnection, 5000)\n })\n\n ws.addEventListener('message', function(event) {\n unLock()\n let data = JSON.parse(event.data)\n\n if (data.type === 'response') {\n response.innerText = data.value\n response.style.display = 'block'\n\n window.setTimeout(function() {\n response.style.display = 'none'\n }, 2500)\n\n return\n }\n\n if (data.type === 'volume') {\n if (data.value.length) {\n setVolume(parseInt(data.value))\n }\n\n return\n }\n })\n}\n\nfunction isLocked() {\n return wsLock === true\n}\n\nfunction lock() {\n wsLock = true\n}\n\nfunction unLock() {\n wsLock = false\n}\n\nfunction send(message) {\n if (isLocked()) {\n return\n }\n\n lock()\n\n ws.send(message)\n}\n\nfunction navigationClickHandler(e) {\n if (e.target.getAttribute('href') === '#') {\n return\n }\n\n Array.from(document.querySelectorAll('.pane')).forEach((item) => {\n item.style.display = 'none'\n })\n\n document.querySelector(e.target.getAttribute('href')).style.display = 'block'\n\n Array.from(document.querySelectorAll('#nav a')).forEach((item) => {\n item.classList.remove('active')\n })\n\n e.target.classList.add('active')\n}\n\nfunction buttonClickHandler(e) {\n send(e.target.getAttribute('data-msg'))\n}\n\nfunction shortcutClearClickHandler(e) {\n document.querySelector('#shortcut-key').value = ''\n\n Array.from(document.querySelectorAll('#shortcuts_special_keys input:checked')).forEach((item) => {\n item.checked = false\n item.change()\n })\n}\n\nfunction shortcutSendClickHandler(e) {\n let keys = []\n let key = document.querySelector('#shortcut-key').value\n\n Array.from(document.querySelectorAll('#shortcuts_special_keys input:checked')).forEach((item) => {\n keys.push(item.value)\n })\n\n if (keys.length) {\n if (key) {\n keys.push(key)\n }\n\n send('{\"type\":\"keys\",\"value\": \"' + (keys.join(',').replace('\"', '\\\\\"')) + '\"}')\n }\n}\n\nfunction textClearClickHandler(e) {\n document.querySelector('#text').value = ''\n}\n\nfunction textSendClickHandler(e) {\n const keys = document.querySelector('#text').value\n\n if (keys.length) {\n send('{\"type\":\"text\",\"value\": \"' + (keys.replace('\"', '\\\\\"')) + '\"}')\n }\n}\n\nfunction textKeyUpHandler(e) {\n const keys = document.querySelector('#text').value\n\n if (e.keyCode === 13) {\n send('{\"type\":\"text\",\"value\": \"' + (keys.replace('\"', '\\\\\"')) + '\"}')\n }\n}\n\nfunction liveTextKeyUpHandler(e) {\n const value = e.target.value\n const size = value.length\n\n const messages = []\n\n if (size > 0) {\n messages.push('{\"type\":\"text\",\"value\": \"' + (value.replace('\"', '\\\\\"')) + '\"}')\n\n if (value[size-1] === ' ') {\n messages.push('{\"type\":\"key\",\"value\":\"space\"}')\n }\n }\n\n if (e.keyCode === 8) {\n messages.push('{\"type\":\"key\",\"value\": \"backspace\"}')\n } else if (e.keyCode === 13 && size === 0) {\n messages.push('{\"type\":\"key\",\"value\": \"enter\"}')\n }\n\n send(`{\"type\":\"messages\",\"value\":[${messages.join(',')}]}`)\n\n e.target.value = ''\n}\n\nfunction shortcutsSpecialKeysOnChangeHandler(e) {\n Array.from(document.querySelectorAll('#shortcuts_special_keys input:checked')).forEach((item) => {\n item.parentNode.classList.add('btn-primary')\n item.parentNode.classList.remove('btn-secondary')\n })\n\n Array.from(document.querySelectorAll('#shortcuts_special_keys input:not(:checked)')).forEach((item) => {\n item.parentNode.classList.add('btn-secondary')\n item.parentNode.classList.remove('btn-primary')\n })\n}\n\nfunction pointerClickHandler(e) {\n send('{\"type\":\"pointer\",\"click\":\"left\"}')\n}\n\nfunction scrollerTouchStartHandler(e) {\n mouseInitPosY = e.targetTouches[0].pageY\n}\n\nfunction scrollerTouchMoveHandler(e) {\n let touch = e.changedTouches[0]\n let value = ((touch.pageY - mouseInitPosY > 0) ? 'down' : 'up')\n let now = new Date().getTime()\n\n if (touch.pageY === mouseInitPosY || value === scrollLastValue && scrollLastTimestamp !== null && now - scrollLastTimestamp < 200) {\n return\n }\n\n scrollLastTimestamp = now\n scrollLastValue = value\n mouseInitPosY = touch.pageY\n\n send('{\"type\":\"scroll\",\"value\": \"' + value + '\"}')\n}\n\nfunction pointerTouchStartHandler(e) {\n const touch = e.targetTouches[0]\n mouseInitPosX = touch.pageX\n mouseInitPosY = touch.pageY\n}\n\nfunction pointerLiveHandler(e) {\n if (!e.target.checked) {\n pointer.style.backgroundImage = \"\"\n } else {\n pointer.style.backgroundImage = `url(\"/capture?type=live&pointer=1&${Math.random()}\")`\n }\n}\n\nfunction pointerTouchMoveHandler(e) {\n if (e.changedTouches.length === 2) {\n return scrollerTouchMoveHandler(e)\n }\n\n const touch = e.changedTouches[0]\n mousePosX = touch.pageX\n mousePosY = touch.pageY\n\n const newX = mousePosX - mouseInitPosX\n const newY = mousePosY - mouseInitPosY\n\n mouseInitPosX = mousePosX\n mouseInitPosY = mousePosY\n\n let msg = '{\"type\":\"pointer\",\"x\": \"' + newX + '\",\"y\": \"' + newY + '\"}'\n\n ws.send(msg)\n}\n\nfunction capture(mode) {\n}\n\nfunction captureScreenshotClickHandler(e) {\n const img = e.target.parentNode.querySelector('.capture-img img')\n img.src = \"/capture?type=screenshot&\" + Math.random()\n}\n\nfunction captureLiveClickHandler(e) {\n const img = e.target.parentNode.querySelector('.capture-img img')\n\n if (img.src.indexOf(\"live\") > -1) {\n img.src = emptyImg\n } else {\n img.src = \"/capture?type=live&\" + Math.random()\n }\n}\n\nfunction fullscreenHandler(e) {\n const targetConf = e.target.getAttribute('data-target')\n const isFullscreen = parseInt(e.target.getAttribute('data-fullscreen'))\n const element = (targetConf === 'this')\n ? e.target\n : document.querySelector(targetConf)\n\n document.querySelector('body').classList.toggle('fullscreen', isFullscreen)\n\n if (isFullscreen) {\n e.target.setAttribute('data-fullscreen', '0')\n\n if (document.exitFullscreen) {\n document.exitFullscreen()\n } else if (document.webkitExitFullscreen) {\n document.webkitExitFullscreen()\n } else if (document.mozCancelFullScreen) {\n document.mozCancelFullScreen()\n }\n } else {\n e.target.setAttribute('data-fullscreen', '1')\n\n if (element.requestFullscreen) {\n element.requestFullscreen()\n } else if (element.webkitRequestFullscreen) {\n element.webkitRequestFullscreen()\n } else if (element.mozRequestFullScreen) {\n element.mozRequestFullScreen()\n }\n }\n}\n\nfunction documentHashHandler() {\n const hash = window.location.hash\n\n if (hash) {\n document.querySelector('a[href=\"' + hash + '\"]').click()\n } else {\n document.querySelector('#nav > li:first-child a').click()\n }\n}\n\nfunction addEventListenerOn(selector, eventName, listener) {\n if (typeof selector === 'string') {\n Array.from(document.querySelectorAll(selector)).forEach((element) => {\n element.addEventListener(eventName, listener)\n })\n } else {\n selector.addEventListener(eventName, listener)\n }\n}\n\nfunction addListeners() {\n addEventListenerOn('#nav a', 'click', navigationClickHandler)\n addEventListenerOn('button[data-msg]', 'click', buttonClickHandler)\n\n addEventListenerOn('#shortcut-clear', 'click', shortcutClearClickHandler)\n addEventListenerOn('#shortcuts_special_keys input', 'change', shortcutsSpecialKeysOnChangeHandler)\n addEventListenerOn('#shortcut-send', 'click', shortcutSendClickHandler)\n\n addEventListenerOn('#text-clear', 'click', textClearClickHandler)\n addEventListenerOn('#text-send', 'click', textSendClickHandler)\n addEventListenerOn('#text', 'keyup', textKeyUpHandler)\n\n Array.from(document.querySelectorAll('.live-text')).forEach((element) => {\n element.setAttribute('data-composing', '0')\n\n addEventListenerOn(element, 'compositionstart', (e) => {\n element.setAttribute('data-composing', '1')\n })\n\n addEventListenerOn(element, 'compositionend', (e) => {\n element.setAttribute('data-composing', '0')\n })\n\n addEventListenerOn(element, 'keyup', (e) => {\n if (element.getAttribute('data-composing') === '1') {\n return\n }\n\n liveTextKeyUpHandler(e)\n })\n })\n\n addEventListenerOn(scroller, 'touchstart', scrollerTouchStartHandler)\n addEventListenerOn(scroller, 'touchmove', scrollerTouchMoveHandler)\n\n addEventListenerOn(pointer, 'click', pointerClickHandler)\n addEventListenerOn(pointer, 'touchstart', pointerTouchStartHandler)\n addEventListenerOn(pointer, 'touchmove', pointerTouchMoveHandler)\n addEventListenerOn('#mouse-screenshot-live input', 'change', pointerLiveHandler)\n\n addEventListenerOn('.capture-live', 'click', captureLiveClickHandler)\n addEventListenerOn('.capture-screenshot', 'click', captureScreenshotClickHandler)\n\n addEventListenerOn('.btn-fullscreen', 'click', fullscreenHandler)\n}\n\nfunction getVolume() {\n if (document.querySelectorAll('.volume').length) {\n try {\n send('{\"type\":\"volume\",\"value\":\"value\"}')\n } catch (e) {\n }\n\n document.querySelectorAll('.volume input[type=\"range\"]').forEach(function(input) {\n if (input.getAttribute('data-event')) {\n return\n }\n\n input.setAttribute('data-event', 'ok')\n input.addEventListener('change', (e) => {\n send(`{\"type\":\"volume\",\"value\":\"${e.target.value}\"}`)\n })\n })\n }\n\n window.setTimeout(getVolume, 2000)\n}\n\nfunction setVolume(value) {\n document.querySelectorAll('.volume').forEach(function(item) {\n item.querySelector('input[type=\"range\"]').value = value\n })\n}\n\nfunction bootstrap() {\n pointer = document.querySelector('#pointer')\n scroller = document.querySelector('#scrollbar')\n response = document.querySelector('#response')\n screenshotImg = document.querySelector('#screenshot img')\n\n shortcutsSpecialKeysOnChangeHandler()\n createWebSocketConnection()\n addListeners()\n documentHashHandler()\n getVolume()\n\n if ('serviceWorker' in navigator) {\n navigator.serviceWorker.register('/static/js/service_worker.js')\n }\n}\n\naddEventListenerOn(window, 'DOMContentLoaded', bootstrap)\n"), } file9 := &embedded.EmbeddedFile{ Filename: "js/service_worker.js", @@ -40,16 +40,25 @@ func init() { Content: string("self.addEventListener(\"install\", (e) => {\n console.log(\"[Service Worker] Install\");\n});\n"), } + filea := &embedded.EmbeddedFile{ + Filename: "static.go", + FileModTime: time.Unix(1765486408, 0), + + Content: string("package static\n\nimport \"embed\"\n\nvar (\n\t//go:embed css/* img/* js/*\n\tFiles embed.FS\n)\n"), + } // define dirs dir1 := &embedded.EmbeddedDir{ Filename: "", - DirModTime: time.Unix(1742135553, 0), - ChildFiles: []*embedded.EmbeddedFile{}, + DirModTime: time.Unix(1765486408, 0), + ChildFiles: []*embedded.EmbeddedFile{ + filea, // "static.go" + + }, } dir2 := &embedded.EmbeddedDir{ Filename: "css", - DirModTime: time.Unix(1745768085, 0), + DirModTime: time.Unix(1765486408, 0), ChildFiles: []*embedded.EmbeddedFile{ file3, // "css/bootstrap.min.css" file4, // "css/main.css" @@ -66,7 +75,7 @@ func init() { } dir7 := &embedded.EmbeddedDir{ Filename: "js", - DirModTime: time.Unix(1745775026, 0), + DirModTime: time.Unix(1765748369, 0), ChildFiles: []*embedded.EmbeddedFile{ file8, // "js/main.js" file9, // "js/service_worker.js" @@ -86,9 +95,9 @@ func init() { dir7.ChildDirs = []*embedded.EmbeddedDir{} // register embeddedBox - embedded.RegisterEmbeddedBox(`static`, &embedded.EmbeddedBox{ - Name: `static`, - Time: time.Unix(1742135553, 0), + embedded.RegisterEmbeddedBox(`../static`, &embedded.EmbeddedBox{ + Name: `../static`, + Time: time.Unix(1765486408, 0), Dirs: map[string]*embedded.EmbeddedDir{ "": dir1, "css": dir2, @@ -101,6 +110,7 @@ func init() { "img/icon.png": file6, "js/main.js": file8, "js/service_worker.js": file9, + "static.go": filea, }, }) } diff --git a/config.yaml b/config.yaml index bf49fdb..06eb52f 100644 --- a/config.yaml +++ b/config.yaml @@ -102,7 +102,5 @@ remote: label: Volume - label: Desktop items: - - type: screenshot - label: Screenshot - - type: live_video - label: Live video + - type: capture + label: Capture diff --git a/home_controller.go b/home_controller.go deleted file mode 100644 index 8e51aad..0000000 --- a/home_controller.go +++ /dev/null @@ -1,20 +0,0 @@ -package main - -import ( - "net/http" - "time" - - "github.com/labstack/echo/v4" -) - -type HomeViewParams struct { - Config Config - Now time.Time -} - -func homeController(c echo.Context) error { - return c.HTML(http.StatusOK, view("views/page/home.html", HomeViewParams{ - Config: config, - Now: time.Now(), - })) -} diff --git a/actions.go b/internal/action/actions.go similarity index 62% rename from actions.go rename to internal/action/actions.go index ceb024e..bab8f39 100644 --- a/actions.go +++ b/internal/action/actions.go @@ -1,4 +1,4 @@ -package main +package action import ( "github.com/gorilla/websocket" @@ -8,16 +8,16 @@ type Actions struct { Functions map[string]func(ws *websocket.Conn, msg []byte) error } -func (actions Actions) add(name string, callback func(ws *websocket.Conn, msg []byte) error) { +func (actions Actions) Add(name string, callback func(ws *websocket.Conn, msg []byte) error) { actions.Functions[name] = callback } -func (actions Actions) has(name string) bool { +func (actions Actions) Has(name string) bool { _, exists := actions.Functions[name] return exists } -func (actions Actions) exec(name string, ws *websocket.Conn, msg []byte) error { +func (actions Actions) Exec(name string, ws *websocket.Conn, msg []byte) error { return actions.Functions[name](ws, msg) } diff --git a/config.go b/internal/config/config.go similarity index 93% rename from config.go rename to internal/config/config.go index 785e19e..e7176e1 100644 --- a/config.go +++ b/internal/config/config.go @@ -1,4 +1,4 @@ -package main +package config import ( "os" @@ -44,7 +44,7 @@ type Config struct { Remote []RemoteItem `yaml:"remote"` } -func createConfigFromFile(file string) (Config, error) { +func CreateConfigFromFile(file string) (Config, error) { data, err := os.ReadFile(file) value := Config{} diff --git a/internal/handler/capture.go b/internal/handler/capture.go new file mode 100644 index 0000000..e5667d6 --- /dev/null +++ b/internal/handler/capture.go @@ -0,0 +1,68 @@ +package handler + +import ( + "bytes" + "image/color" + "image/jpeg" + "math" + "net/http" + "time" + + "github.com/kbinani/screenshot" + "github.com/labstack/echo/v4" + "gitnet.fr/deblan/remote-i3wm-go/internal/pointer" +) + +func CaptureHandler(c echo.Context) error { + bounds := screenshot.GetDisplayBounds(0) + + switch c.QueryParam("type") { + case "screenshot": + if img, err := screenshot.CaptureRect(bounds); err == nil { + var buf bytes.Buffer + jpeg.Encode(&buf, img, nil) + + c.Response().Header().Set("Content-Type", "image/jpeg") + + return c.Blob(http.StatusOK, "image/jpeg", buf.Bytes()) + } + default: + c.Response().Header().Set("Content-Type", "multipart/x-mixed-replace; boundary=frame") + + for { + if img, err := screenshot.CaptureRect(bounds); err == nil { + var buf bytes.Buffer + + if c.QueryParam("pointer") == "1" { + currentX, currentY := pointer.Positions() + pointerSize := 2 * 16.0 + + pixelColor := color.RGBA{ + R: 255, + G: 0, + B: 0, + A: 255, + } + + for x := math.Max(0.0, currentX-pointerSize/2); x <= currentX+3; x++ { + for y := math.Max(0.0, currentY-pointerSize/2); y < currentY+3; y++ { + img.SetRGBA(int(x), int(y), pixelColor) + } + } + } + + jpeg.Encode(&buf, img, nil) + + _, _ = c.Response().Write([]byte("--frame\r\n")) + + c.Response().Write([]byte("Content-Type: image/jpeg\r\n\r\n")) + c.Response().Write(buf.Bytes()) + c.Response().Write([]byte("\r\n")) + + time.Sleep(33 * time.Millisecond) + } + } + } + + return nil +} diff --git a/internal/handler/home.go b/internal/handler/home.go new file mode 100644 index 0000000..8b8be6d --- /dev/null +++ b/internal/handler/home.go @@ -0,0 +1,22 @@ +package handler + +import ( + "net/http" + "time" + + "github.com/labstack/echo/v4" + "gitnet.fr/deblan/remote-i3wm-go/internal/config" + "gitnet.fr/deblan/remote-i3wm-go/internal/render" +) + +type HomeViewParams struct { + Config config.Config + Now time.Time +} + +func HomeHandler(c echo.Context, conf config.Config) error { + return c.HTML(http.StatusOK, render.View("page/home.html", HomeViewParams{ + Config: conf, + Now: time.Now(), + })) +} diff --git a/manifest_controller.go b/internal/handler/manifest.go similarity index 94% rename from manifest_controller.go rename to internal/handler/manifest.go index 3d539d1..da0dc44 100644 --- a/manifest_controller.go +++ b/internal/handler/manifest.go @@ -1,8 +1,9 @@ -package main +package handler import ( - "github.com/labstack/echo/v4" "net/http" + + "github.com/labstack/echo/v4" ) type ManifestIcon struct { @@ -23,7 +24,7 @@ type Manifest struct { Icons []ManifestIcon `json:"icons"` } -func manifestController(c echo.Context) error { +func ManifestHandler(c echo.Context) error { manifest := &Manifest{ ShortName: "RWM", Name: "Remote i3WM", diff --git a/ws_controller.go b/internal/handler/ws.go similarity index 72% rename from ws_controller.go rename to internal/handler/ws.go index 48703c2..63ca7cb 100644 --- a/ws_controller.go +++ b/internal/handler/ws.go @@ -1,13 +1,9 @@ -package main +package handler import ( - "bytes" "encoding/json" "errors" "fmt" - "image/color" - "image/jpeg" - "math" "os/exec" "regexp" "strconv" @@ -15,8 +11,9 @@ import ( "time" "github.com/gorilla/websocket" - "github.com/kbinani/screenshot" "github.com/labstack/echo/v4" + "gitnet.fr/deblan/remote-i3wm-go/internal/action" + "gitnet.fr/deblan/remote-i3wm-go/internal/pointer" ) type Message struct { @@ -61,30 +58,12 @@ func sendMessageResponse(ws *websocket.Conn, r MessageResponse) { ws.WriteMessage(websocket.TextMessage, value) } -func getPointerPosition() (float64, float64) { - 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) - } - } - - return currentX, currentY -} - -func createActions() Actions { - actions := Actions{ +func createActions() action.Actions { + actions := action.Actions{ Functions: make(map[string]func(ws *websocket.Conn, msg []byte) error), } - actions.add("pointer", func(ws *websocket.Conn, msg []byte) error { + actions.Add("pointer", func(ws *websocket.Conn, msg []byte) error { data := PointerMessageData{} json.Unmarshal([]byte(msg), &data) @@ -106,7 +85,7 @@ func createActions() Actions { return cmd.Run() } - currentX, currentY := getPointerPosition() + currentX, currentY := pointer.Positions() newX, _ := strconv.ParseFloat(data.X, 32) newY, _ := strconv.ParseFloat(data.Y, 32) @@ -119,7 +98,7 @@ func createActions() Actions { return cmd.Run() }) - actions.add("scroll", func(ws *websocket.Conn, msg []byte) error { + actions.Add("scroll", func(ws *websocket.Conn, msg []byte) error { value := getSimpleMessageValue(msg) key := "" @@ -139,7 +118,7 @@ func createActions() Actions { return nil }) - actions.add("workspace", func(ws *websocket.Conn, msg []byte) error { + actions.Add("workspace", func(ws *websocket.Conn, msg []byte) error { value := getSimpleMessageValue(msg) if value == "" { @@ -151,7 +130,7 @@ func createActions() Actions { return cmd.Run() }) - actions.add("volume", func(ws *websocket.Conn, msg []byte) error { + actions.Add("volume", func(ws *websocket.Conn, msg []byte) error { value := getSimpleMessageValue(msg) if value == "value" { @@ -204,7 +183,7 @@ func createActions() Actions { return cmd.Run() }) - actions.add("media", func(ws *websocket.Conn, msg []byte) error { + actions.Add("media", func(ws *websocket.Conn, msg []byte) error { value := getSimpleMessageValue(msg) if value == "" { @@ -264,7 +243,7 @@ func createActions() Actions { return nil }) - actions.add("keys", func(ws *websocket.Conn, msg []byte) error { + actions.Add("keys", func(ws *websocket.Conn, msg []byte) error { value := strings.TrimSpace(getSimpleMessageValue(msg)) if value == "" { @@ -298,7 +277,7 @@ func createActions() Actions { return cmd.Run() }) - actions.add("key", func(ws *websocket.Conn, msg []byte) error { + actions.Add("key", func(ws *websocket.Conn, msg []byte) error { value := strings.TrimSpace(getSimpleMessageValue(msg)) keys := make(map[string]string) @@ -323,7 +302,7 @@ func createActions() Actions { return cmd.Run() }) - actions.add("text", func(ws *websocket.Conn, msg []byte) error { + actions.Add("text", func(ws *websocket.Conn, msg []byte) error { value := strings.TrimSpace(getSimpleMessageValue(msg)) if value == "" { @@ -335,63 +314,15 @@ func createActions() Actions { return cmd.Run() }) - actions.add("screenshot", func(ws *websocket.Conn, msg []byte) 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 - } - - if data.Pointer { - currentX, currentY := getPointerPosition() - pointerSize := 2 * 16.0 - - pixelColor := color.RGBA{ - R: 255, - G: 0, - B: 0, - A: 255, - } - - for x := math.Max(0.0, currentX-pointerSize/2); x <= currentX+3; x++ { - for y := math.Max(0.0, currentY-pointerSize/2); y < currentY+3; y++ { - img.SetRGBA(int(x), int(y), pixelColor) - } - } - } - - 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 []byte) error { + actions.Add("messages", func(ws *websocket.Conn, msg []byte) 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, msg) + if actions.Has(value.Type) { + actions.Exec(value.Type, ws, msg) time.Sleep(400 * time.Millisecond) } } @@ -404,9 +335,10 @@ func createActions() Actions { var ( upgrader = websocket.Upgrader{} + actions = createActions() ) -func wsController(c echo.Context) error { +func WsHandler(c echo.Context) error { ws, err := upgrader.Upgrade(c.Response(), c.Request(), nil) if err != nil { @@ -426,8 +358,8 @@ func wsController(c echo.Context) error { message := Message{} json.Unmarshal([]byte(msg), &message) - if message.Type != "" && actions.has(message.Type) { - actions.exec(message.Type, ws, msg) + if message.Type != "" && actions.Has(message.Type) { + actions.Exec(message.Type, ws, msg) } sendMessageResponse(ws, MessageResponse{ diff --git a/internal/pointer/pointer.go b/internal/pointer/pointer.go new file mode 100644 index 0000000..68da0ca --- /dev/null +++ b/internal/pointer/pointer.go @@ -0,0 +1,25 @@ +package pointer + +import ( + "os/exec" + "strconv" + "strings" +) + +func Positions() (float64, float64) { + 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) + } + } + + return currentX, currentY +} diff --git a/internal/render/render.go b/internal/render/render.go new file mode 100644 index 0000000..e9cb0fb --- /dev/null +++ b/internal/render/render.go @@ -0,0 +1,17 @@ +package render + +import ( + "bytes" + "html/template" + + "gitnet.fr/deblan/remote-i3wm-go/templates" +) + +func View(viewName string, data any) string { + var render bytes.Buffer + + view := template.Must(template.ParseFS(templates.Views, viewName, "layout/base.html")) + view.Execute(&render, data) + + return render.String() +} diff --git a/static/css/main.css b/static/css/main.css index 357e7cf..62ab940 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -73,6 +73,7 @@ a { background: #ccc; background-size: contain; background-repeat: no-repeat; + background-position: center; position: absolute; width: calc(100% - 50px); -webkit-touch-callout: none; @@ -157,7 +158,7 @@ a { display: none; } -#screenshot img { +.capture-img img { max-width: 100%; margin-top: 10px; cursor: pointer; diff --git a/static/js/main.js b/static/js/main.js index 825b27d..b0d0b9b 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -7,6 +7,7 @@ let wsLock = false let isPointerLive = false let isScreenshotWaiting = null let isPointerScreenshotWaiting = false +let emptyImg = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4gIJDjc3srQk8gAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAADElEQVQI12P48+cPAAXsAvVTWDc6AAAAAElFTkSuQmCC" function createWebSocketConnection() { const protocol = location.protocol === 'https:' ? 'wss' : 'ws' @@ -24,7 +25,7 @@ function createWebSocketConnection() { window.setTimeout(createWebSocketConnection, 5000) }) - + ws.addEventListener('message', function(event) { unLock() let data = JSON.parse(event.data) @@ -47,27 +48,6 @@ function createWebSocketConnection() { return } - - if (data.type === 'screenshot') { - if (isScreenshotWaiting || isScreenshotWaiting === null) { - if (isScreenshotWaiting) { - isScreenshotWaiting = false - } - - screenshotImg.setAttribute('src', 'data:image/png;base64, ' + data.value) - } - - let pointer = document.querySelector('#pointer') - - if (isPointerScreenshotWaiting) { - pointer.style.backgroundImage = `url('data:image/png;base64, ${data.value}')` - isPointerScreenshotWaiting = false - } else { - pointer.style.backgroundImage = 'none' - } - } - - return }) } @@ -163,20 +143,27 @@ function textKeyUpHandler(e) { function liveTextKeyUpHandler(e) { const value = e.target.value + const size = value.length + + const messages = [] + + if (size > 0) { + messages.push('{"type":"text","value": "' + (value.replace('"', '\\"')) + '"}') + + if (value[size-1] === ' ') { + messages.push('{"type":"key","value":"space"}') + } + } if (e.keyCode === 8) { - send('{"type":"key","value": "backspace"}') - } else if (e.keyCode === 13) { - send('{"type":"key","value": "enter"}') - } else if (value.length) { - if (value === ' ') { - send('{"type":"key","value": "space"}') - } else { - send('{"type":"text","value": "' + (value.replace('"', '\\"')) + '"}') - } - - e.target.value = '' + messages.push('{"type":"key","value": "backspace"}') + } else if (e.keyCode === 13 && size === 0) { + messages.push('{"type":"key","value": "enter"}') } + + send(`{"type":"messages","value":[${messages.join(',')}]}`) + + e.target.value = '' } function shortcutsSpecialKeysOnChangeHandler(e) { @@ -223,26 +210,10 @@ function pointerTouchStartHandler(e) { function pointerLiveHandler(e) { if (!e.target.checked) { - isPointerLive = false - isPointerScreenshotWaiting = null - - return + pointer.style.backgroundImage = "" + } else { + pointer.style.backgroundImage = `url("/capture?type=live&pointer=1&${Math.random()}")` } - - isPointerLive = true - - let doScreenshot = function() { - if (isPointerLive) { - if (!isPointerScreenshotWaiting) { - isPointerScreenshotWaiting = true - ws.send(`{"type":"screenshot","quality":"lq","pointer":true}`) - } - - window.setTimeout(doScreenshot, 300) - } - } - - doScreenshot() } function pointerTouchMoveHandler(e) { @@ -265,46 +236,30 @@ function pointerTouchMoveHandler(e) { ws.send(msg) } -function liveHqClickHandler(e) { - return liveClickHandler(e, 'hq') +function capture(mode) { } -function liveLqClickHandler(e) { - return liveClickHandler(e, 'lq') +function captureScreenshotClickHandler(e) { + const img = e.target.parentNode.querySelector('.capture-img img') + img.src = "/capture?type=screenshot&" + Math.random() } -function liveClickHandler(e, quality) { - if (isLive) { - isLive = false - isScreenshotWaiting = null +function captureLiveClickHandler(e) { + const img = e.target.parentNode.querySelector('.capture-img img') - document.querySelector('#live-hq').innerText = 'Live HQ' - document.querySelector('#live-lq').innerText = 'Live LQ' - - return + if (img.src.indexOf("live") > -1) { + img.src = emptyImg + } else { + img.src = "/capture?type=live&" + Math.random() } - - isLive = true - - e.target.innerText = 'Stop live' - - let doScreenshot = function() { - if (isLive) { - if (!isScreenshotWaiting) { - isScreenshotWaiting = true - ws.send(`{"type":"screenshot","quality":"${quality}"}`) - } - - window.setTimeout(doScreenshot, 100) - } - } - - doScreenshot() } function fullscreenHandler(e) { - let element = document.querySelector(e.target.getAttribute('data-target')) - let isFullscreen = parseInt(e.target.getAttribute('data-fullscreen')) + const targetConf = e.target.getAttribute('data-target') + const isFullscreen = parseInt(e.target.getAttribute('data-fullscreen')) + const element = (targetConf === 'this') + ? e.target + : document.querySelector(targetConf) document.querySelector('body').classList.toggle('fullscreen', isFullscreen) @@ -362,7 +317,26 @@ function addListeners() { addEventListenerOn('#text-clear', 'click', textClearClickHandler) addEventListenerOn('#text-send', 'click', textSendClickHandler) addEventListenerOn('#text', 'keyup', textKeyUpHandler) - addEventListenerOn('.live-text', 'keyup', liveTextKeyUpHandler) + + Array.from(document.querySelectorAll('.live-text')).forEach((element) => { + element.setAttribute('data-composing', '0') + + addEventListenerOn(element, 'compositionstart', (e) => { + element.setAttribute('data-composing', '1') + }) + + addEventListenerOn(element, 'compositionend', (e) => { + element.setAttribute('data-composing', '0') + }) + + addEventListenerOn(element, 'keyup', (e) => { + if (element.getAttribute('data-composing') === '1') { + return + } + + liveTextKeyUpHandler(e) + }) + }) addEventListenerOn(scroller, 'touchstart', scrollerTouchStartHandler) addEventListenerOn(scroller, 'touchmove', scrollerTouchMoveHandler) @@ -372,8 +346,9 @@ function addListeners() { addEventListenerOn(pointer, 'touchmove', pointerTouchMoveHandler) addEventListenerOn('#mouse-screenshot-live input', 'change', pointerLiveHandler) - addEventListenerOn('#live-hq', 'click', liveHqClickHandler) - addEventListenerOn('#live-lq', 'click', liveLqClickHandler) + addEventListenerOn('.capture-live', 'click', captureLiveClickHandler) + addEventListenerOn('.capture-screenshot', 'click', captureScreenshotClickHandler) + addEventListenerOn('.btn-fullscreen', 'click', fullscreenHandler) } diff --git a/static/static.go b/static/static.go new file mode 100644 index 0000000..f2d832a --- /dev/null +++ b/static/static.go @@ -0,0 +1,8 @@ +package static + +import "embed" + +var ( + //go:embed css/* img/* js/* + Files embed.FS +) diff --git a/views/layout/base.html b/templates/layout/base.html similarity index 100% rename from views/layout/base.html rename to templates/layout/base.html diff --git a/views/page/home.html b/templates/page/home.html similarity index 87% rename from views/page/home.html rename to templates/page/home.html index 8780060..8aa032e 100644 --- a/views/page/home.html +++ b/templates/page/home.html @@ -30,7 +30,8 @@ {{if eq $value.Type "live_text"}}
- + +

                             
{{end}} @@ -115,7 +116,7 @@ Screen - +
@@ -124,18 +125,17 @@ {{end}} - {{if eq $value.Type "screenshot"}} + {{if eq $value.Type "capture"}}
- - -
- {{end}} - - {{if eq $value.Type "live_video"}} -
- - -
+ + +
+ +
{{end}} diff --git a/templates/templates.go b/templates/templates.go new file mode 100644 index 0000000..8d82396 --- /dev/null +++ b/templates/templates.go @@ -0,0 +1,8 @@ +package templates + +import "embed" + +var ( + //go:embed layout page + Views embed.FS +) diff --git a/utils.go b/utils.go deleted file mode 100644 index 211670c..0000000 --- a/utils.go +++ /dev/null @@ -1,20 +0,0 @@ -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) -}