Commit 8b7ca38

bryfry <bryon@fryer.io>
2026-01-14 17:52:02
init
1 parent b9aa428
Changed files (146)
cmd
govnc
internal
pkg
input
proxy
rfb
transport
web
noVNC
app
core
vendor
cmd/govnc/main.go
@@ -0,0 +1,275 @@
+package main
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"log/slog"
+	"os"
+	"os/signal"
+	"strings"
+	"syscall"
+
+	"github.com/chzyer/readline"
+	"golang.org/x/sync/errgroup"
+
+	"goVNC/internal/cli"
+	"goVNC/pkg/input"
+	"goVNC/pkg/rfb"
+	"goVNC/pkg/transport"
+	"goVNC/pkg/web"
+)
+
+func main() {
+	if err := run(); err != nil {
+		slog.Error("fatal", slog.String("error", err.Error()))
+		os.Exit(1)
+	}
+}
+
+func run() error {
+	// Parse flags
+	cfg, err := cli.ParseFlags(os.Args[1:])
+	if err != nil {
+		return err
+	}
+
+	// Configure logging
+	setupLogging(cfg.Output.Verbose, cfg.Output.Silent)
+
+	// Validate URL
+	if cfg.URL == "" {
+		return fmt.Errorf("no URL provided. Use: govnc <URL> or set VNC_URL")
+	}
+
+	// Create context with signal handling
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+
+	// Handle signals
+	sigCh := make(chan os.Signal, 1)
+	signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
+	go func() {
+		<-sigCh
+		slog.Info("shutting down...")
+		cancel()
+	}()
+
+	// Connect to VNC server
+	slog.Info("connecting", slog.String("url", cfg.URL))
+
+	dialer := transport.NewWebSocketDialer()
+	dialOpts := cfg.Connection.ToDialOptions()
+
+	t, err := dialer.Dial(ctx, cfg.URL, dialOpts)
+	if err != nil {
+		return fmt.Errorf("connecting: %w", err)
+	}
+	defer t.Close()
+
+	// Create RFB client
+	clientCfg := cfg.VNC.ToClientConfig()
+	client := rfb.NewClient(t, clientCfg)
+
+	// Connect and handshake
+	session, err := client.Connect(ctx)
+	if err != nil {
+		return fmt.Errorf("handshake: %w", err)
+	}
+
+	slog.Info("connected",
+		slog.String("name", session.Name),
+		slog.Int("width", int(session.Width)),
+		slog.Int("height", int(session.Height)))
+
+	// Handle one-shot modes
+	if cfg.Input.TypeText != "" {
+		return handleTypeAndExit(ctx, client, cfg.Input.TypeText)
+	}
+
+	if cfg.Clipboard.ClipSend != "" {
+		return handleClipSendAndExit(ctx, client, cfg.Clipboard.ClipSend)
+	}
+
+	// Server mode
+	if cfg.Server.HTTPAddr != "" {
+		return runServerMode(ctx, client, cfg)
+	}
+
+	// Interactive mode
+	return runInteractiveMode(ctx, client, cfg, session)
+}
+
+func setupLogging(verbose int, silent bool) {
+	var level slog.Level
+	if silent {
+		level = slog.LevelError + 1 // Suppress all logs
+	} else {
+		switch verbose {
+		case 0:
+			level = slog.LevelInfo
+		case 1:
+			level = slog.LevelDebug
+		default:
+			level = slog.LevelDebug - 4 // Trace level
+		}
+	}
+
+	handler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
+		Level: level,
+	})
+	slog.SetDefault(slog.New(handler))
+}
+
+func handleTypeAndExit(ctx context.Context, client *rfb.Client, text string) error {
+	slog.Info("typing", slog.Int("len", len(text)))
+	if err := client.Type(ctx, text); err != nil {
+		return fmt.Errorf("typing: %w", err)
+	}
+	slog.Info("done")
+	return nil
+}
+
+func handleClipSendAndExit(ctx context.Context, client *rfb.Client, text string) error {
+	// Handle @file syntax
+	if strings.HasPrefix(text, "@") {
+		data, err := os.ReadFile(text[1:])
+		if err != nil {
+			return fmt.Errorf("reading file: %w", err)
+		}
+		text = string(data)
+	}
+
+	slog.Info("sending clipboard", slog.Int("len", len(text)))
+	if err := client.SetClipboard(ctx, text); err != nil {
+		return fmt.Errorf("sending clipboard: %w", err)
+	}
+	slog.Info("done")
+	return nil
+}
+
+func runServerMode(ctx context.Context, client *rfb.Client, cfg *cli.Config) error {
+	serverCfg := cfg.Server.ToServerConfig()
+	server := web.NewServer(client, serverCfg)
+
+	// Set up clipboard handler if output dir specified
+	if cfg.Clipboard.ClipOutDir != "" {
+		clipMgr, err := input.NewClipboardManager(client, &input.ClipboardConfig{
+			SaveDir: cfg.Clipboard.ClipOutDir,
+		})
+		if err != nil {
+			return fmt.Errorf("creating clipboard manager: %w", err)
+		}
+		client.OnClipboard(clipMgr.OnReceive)
+	}
+
+	g, ctx := errgroup.WithContext(ctx)
+
+	// Run VNC message listener
+	g.Go(func() error {
+		return client.Listen(ctx)
+	})
+
+	// Run web server
+	g.Go(func() error {
+		return server.ListenAndServe(ctx)
+	})
+
+	slog.Info("server mode started",
+		slog.String("http", cfg.Server.HTTPAddr),
+		slog.String("api", cfg.Server.APIPrefix))
+
+	return g.Wait()
+}
+
+func runInteractiveMode(ctx context.Context, client *rfb.Client, cfg *cli.Config, session *rfb.Session) error {
+	g, ctx := errgroup.WithContext(ctx)
+
+	// Set up clipboard manager
+	clipMgr, err := input.NewClipboardManager(client, &input.ClipboardConfig{
+		WatchDir: cfg.Clipboard.ClipInDir,
+		SaveDir:  cfg.Clipboard.ClipOutDir,
+	})
+	if err != nil {
+		return fmt.Errorf("creating clipboard manager: %w", err)
+	}
+	client.OnClipboard(clipMgr.OnReceive)
+
+	// Run VNC message listener
+	g.Go(func() error {
+		return client.Listen(ctx)
+	})
+
+	// Run clipboard watcher if configured
+	if cfg.Clipboard.ClipInDir != "" {
+		g.Go(func() error {
+			return clipMgr.Start(ctx)
+		})
+	}
+
+	// Run stdin reader
+	if cfg.Input.InputFile == "" || cfg.Input.InputFile == "-" {
+		g.Go(func() error {
+			return readStdin(ctx, client, session.Name)
+		})
+	} else if cfg.Input.InputFile != "" {
+		g.Go(func() error {
+			return readInputFile(ctx, client, cfg.Input.InputFile)
+		})
+	}
+
+	return g.Wait()
+}
+
+func readStdin(ctx context.Context, client *rfb.Client, serverName string) error {
+	prompt := serverName + "> "
+	rl, err := readline.NewEx(&readline.Config{
+		Prompt:          prompt,
+		HistoryFile:     ".govnc_history",
+		InterruptPrompt: "^C",
+		EOFPrompt:       "exit",
+	})
+	if err != nil {
+		return fmt.Errorf("creating readline: %w", err)
+	}
+	defer rl.Close()
+
+	for {
+		select {
+		case <-ctx.Done():
+			return ctx.Err()
+		default:
+		}
+
+		line, err := rl.Readline()
+		if err == readline.ErrInterrupt {
+			continue
+		}
+		if err == io.EOF {
+			return nil
+		}
+		if err != nil {
+			return fmt.Errorf("reading line: %w", err)
+		}
+
+		line = line + "\n"
+		slog.Debug("typing", slog.String("line", strings.TrimSpace(line)))
+		if err := client.Type(ctx, line); err != nil {
+			return fmt.Errorf("typing line: %w", err)
+		}
+	}
+}
+
+func readInputFile(ctx context.Context, client *rfb.Client, path string) error {
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return fmt.Errorf("reading input file: %w", err)
+	}
+
+	slog.Info("typing from file", slog.String("file", path), slog.Int("len", len(data)))
+	if err := client.Type(ctx, string(data)); err != nil {
+		return fmt.Errorf("typing: %w", err)
+	}
+
+	return nil
+}
internal/cli/config.go
@@ -0,0 +1,239 @@
+// Package cli provides CLI infrastructure for govnc.
+package cli
+
+import (
+	"net/http"
+	"time"
+
+	"goVNC/pkg/proxy"
+	"goVNC/pkg/rfb"
+	"goVNC/pkg/transport"
+	"goVNC/pkg/web"
+)
+
+// Config holds all CLI configuration.
+type Config struct {
+	// Target WebSocket URL
+	URL string
+
+	// Connection options
+	Connection ConnectionConfig
+
+	// VNC session options
+	VNC VNCConfig
+
+	// Input options
+	Input InputConfig
+
+	// Clipboard options
+	Clipboard ClipboardConfig
+
+	// Server mode options
+	Server ServerConfig
+
+	// Output options
+	Output OutputConfig
+}
+
+// ConnectionConfig holds connection-related settings.
+type ConnectionConfig struct {
+	// Headers to send with the connection (repeatable).
+	Headers http.Header
+
+	// Cookie string to send.
+	Cookie string
+
+	// UserAgent to send.
+	UserAgent string
+
+	// Proxy URL for upstream proxy.
+	Proxy string
+
+	// ConnectTimeout for connection establishment.
+	ConnectTimeout time.Duration
+
+	// Insecure skips TLS verification.
+	Insecure bool
+}
+
+// VNCConfig holds VNC-specific settings.
+type VNCConfig struct {
+	// User for VNC authentication (user:password format).
+	User string
+
+	// Password for VNC authentication.
+	Password string
+
+	// AuthNone forces no authentication.
+	AuthNone bool
+
+	// Shared requests a shared session.
+	Shared bool
+
+	// Exclusive requests an exclusive session.
+	Exclusive bool
+
+	// Encodings to request.
+	Encodings []int32
+}
+
+// InputConfig holds input-related settings.
+type InputConfig struct {
+	// InputFile to read input from (- for stdin).
+	InputFile string
+
+	// TypeText to type and exit.
+	TypeText string
+
+	// KeyDelay between keystrokes in milliseconds.
+	KeyDelay int
+}
+
+// ClipboardConfig holds clipboard-related settings.
+type ClipboardConfig struct {
+	// ClipInDir to watch for outgoing clipboard files.
+	ClipInDir string
+
+	// ClipOutDir to save incoming clipboard files.
+	ClipOutDir string
+
+	// ClipSend text to send to clipboard and exit.
+	ClipSend string
+}
+
+// ServerConfig holds server mode settings.
+type ServerConfig struct {
+	// HTTPAddr to start HTTP/WS server on.
+	HTTPAddr string
+
+	// ProxyMode for client sessions.
+	ProxyMode string
+
+	// MaxClients for concurrent WebSocket clients.
+	MaxClients int
+
+	// NoVNCPath for static noVNC files.
+	NoVNCPath string
+
+	// APIPrefix for API routes.
+	APIPrefix string
+
+	// CORSOrigin for CORS headers.
+	CORSOrigin string
+}
+
+// OutputConfig holds output-related settings.
+type OutputConfig struct {
+	// Verbose level (0-3).
+	Verbose int
+
+	// Silent suppresses output.
+	Silent bool
+
+	// OutputFile to write to.
+	OutputFile string
+}
+
+// DefaultConfig returns a Config with sensible defaults.
+func DefaultConfig() *Config {
+	return &Config{
+		Connection: ConnectionConfig{
+			Headers:        make(http.Header),
+			UserAgent:      "govnc/1.0",
+			ConnectTimeout: 30 * time.Second,
+		},
+		VNC: VNCConfig{
+			Shared:    true,
+			Encodings: rfb.DefaultEncodings(),
+		},
+		Server: ServerConfig{
+			HTTPAddr:   ":8080",
+			ProxyMode:  "shared",
+			MaxClients: 10,
+			APIPrefix:  "/api",
+			CORSOrigin: "*",
+		},
+	}
+}
+
+// ToDialOptions converts ConnectionConfig to transport.DialOptions.
+func (c *ConnectionConfig) ToDialOptions() *transport.DialOptions {
+	opts := transport.NewDialOptions()
+	opts.Headers = c.Headers.Clone()
+	if c.Cookie != "" {
+		opts.AddHeader("Cookie", c.Cookie)
+	}
+	if c.UserAgent != "" {
+		opts.AddHeader("User-Agent", c.UserAgent)
+	}
+	opts.Timeout = c.ConnectTimeout
+	opts.Proxy = c.Proxy
+	// TODO: Handle Insecure flag with TLS config
+	return opts
+}
+
+// ToHandshakeConfig converts VNCConfig to rfb.HandshakeConfig.
+func (c *VNCConfig) ToHandshakeConfig() *rfb.HandshakeConfig {
+	cfg := rfb.DefaultHandshakeConfig()
+
+	// Determine shared mode
+	if c.Exclusive {
+		cfg.SharedSession = false
+	} else if c.Shared {
+		cfg.SharedSession = true
+	}
+
+	// Set password if provided
+	if c.Password != "" {
+		cfg.Password = c.Password
+	}
+
+	// Add VNC auth if password is provided
+	if c.Password != "" && !c.AuthNone {
+		cfg.AllowedSecurityTypes = []rfb.SecurityType{
+			rfb.SecurityTypeNone,
+			rfb.SecurityTypeVNCAuth,
+		}
+	}
+
+	return cfg
+}
+
+// ToClientConfig converts VNCConfig to rfb.ClientConfig.
+func (c *VNCConfig) ToClientConfig() *rfb.ClientConfig {
+	cfg := rfb.DefaultClientConfig()
+	cfg.Handshake = c.ToHandshakeConfig()
+	if len(c.Encodings) > 0 {
+		cfg.Encodings = c.Encodings
+	}
+	return cfg
+}
+
+// ToServerConfig converts ServerConfig to web.ServerConfig.
+func (c *ServerConfig) ToServerConfig() *web.ServerConfig {
+	cfg := web.DefaultServerConfig()
+	if c.HTTPAddr != "" {
+		cfg.ListenAddr = c.HTTPAddr
+	}
+	if c.APIPrefix != "" {
+		cfg.APIPrefix = c.APIPrefix
+	}
+	if c.NoVNCPath != "" {
+		cfg.NoVNCPath = c.NoVNCPath
+	}
+	if c.CORSOrigin != "" {
+		cfg.CORSOrigin = c.CORSOrigin
+	}
+	if c.MaxClients > 0 {
+		cfg.MaxClients = c.MaxClients
+	}
+
+	switch c.ProxyMode {
+	case "isolated":
+		cfg.ProxyMode = proxy.IsolatedMode
+	default:
+		cfg.ProxyMode = proxy.SharedMode
+	}
+
+	return cfg
+}
internal/cli/flags.go
@@ -0,0 +1,258 @@
+package cli
+
+import (
+	"flag"
+	"fmt"
+	"os"
+	"strings"
+	"time"
+)
+
+// headerList is a flag.Value that collects multiple -H flags.
+type headerList []string
+
+func (h *headerList) String() string {
+	return strings.Join(*h, ", ")
+}
+
+func (h *headerList) Set(value string) error {
+	*h = append(*h, value)
+	return nil
+}
+
+// ParseFlags parses command-line flags and returns a Config.
+func ParseFlags(args []string) (*Config, error) {
+	cfg := DefaultConfig()
+
+	fs := flag.NewFlagSet("govnc", flag.ContinueOnError)
+
+	// Custom usage
+	fs.Usage = func() {
+		fmt.Fprint(os.Stderr, usage)
+	}
+
+	// Connection flags
+	var headers headerList
+	fs.Var(&headers, "H", "Add custom header (repeatable)")
+	fs.Var(&headers, "header", "Add custom header (repeatable)")
+	fs.StringVar(&cfg.Connection.Cookie, "b", "", "Send cookies")
+	fs.StringVar(&cfg.Connection.Cookie, "cookie", "", "Send cookies")
+	fs.StringVar(&cfg.Connection.UserAgent, "A", "govnc/1.0", "User-Agent header")
+	fs.StringVar(&cfg.Connection.UserAgent, "user-agent", "govnc/1.0", "User-Agent header")
+	fs.StringVar(&cfg.Connection.Proxy, "x", "", "Use proxy (http, socks5)")
+	fs.StringVar(&cfg.Connection.Proxy, "proxy", "", "Use proxy (http, socks5)")
+
+	var connectTimeout int
+	fs.IntVar(&connectTimeout, "connect-timeout", 30, "Connection timeout in seconds")
+	fs.BoolVar(&cfg.Connection.Insecure, "k", false, "Skip TLS verification")
+	fs.BoolVar(&cfg.Connection.Insecure, "insecure", false, "Skip TLS verification")
+
+	// Authentication flags
+	fs.StringVar(&cfg.VNC.User, "u", "", "VNC authentication (user:password)")
+	fs.StringVar(&cfg.VNC.User, "user", "", "VNC authentication (user:password)")
+	fs.BoolVar(&cfg.VNC.AuthNone, "auth-none", false, "Force no authentication")
+
+	// VNC flags
+	fs.BoolVar(&cfg.VNC.Shared, "shared", true, "Request shared session")
+	fs.BoolVar(&cfg.VNC.Exclusive, "exclusive", false, "Request exclusive session")
+
+	var encodings string
+	fs.StringVar(&encodings, "encoding", "", "Comma-separated encodings")
+
+	// Input flags
+	fs.StringVar(&cfg.Input.InputFile, "i", "", "Read input from file (- for stdin)")
+	fs.StringVar(&cfg.Input.InputFile, "input", "", "Read input from file (- for stdin)")
+	fs.StringVar(&cfg.Input.TypeText, "type", "", "Type string and exit")
+	fs.IntVar(&cfg.Input.KeyDelay, "delay", 0, "Delay between keystrokes in ms")
+
+	// Clipboard flags
+	fs.StringVar(&cfg.Clipboard.ClipInDir, "clip-in", "", "Watch directory for outgoing clipboard")
+	fs.StringVar(&cfg.Clipboard.ClipOutDir, "clip-out", "", "Save incoming clipboard to directory")
+	fs.StringVar(&cfg.Clipboard.ClipSend, "clip-send", "", "Send clipboard text and exit")
+
+	// Server flags
+	fs.StringVar(&cfg.Server.HTTPAddr, "http", "", "Start HTTP/WS server on address")
+	fs.StringVar(&cfg.Server.ProxyMode, "proxy-mode", "shared", "Client session mode (shared|isolated)")
+	fs.IntVar(&cfg.Server.MaxClients, "max-clients", 10, "Max concurrent WebSocket clients")
+	fs.StringVar(&cfg.Server.NoVNCPath, "novnc-path", "", "Path to noVNC static files")
+	fs.StringVar(&cfg.Server.APIPrefix, "api-prefix", "/api", "API route prefix")
+	fs.StringVar(&cfg.Server.CORSOrigin, "cors-origin", "*", "CORS allowed origin")
+
+	// Output flags
+	fs.IntVar(&cfg.Output.Verbose, "v", 0, "Verbose output (use multiple times for more)")
+	fs.BoolVar(&cfg.Output.Silent, "s", false, "Silent mode")
+	fs.BoolVar(&cfg.Output.Silent, "silent", false, "Silent mode")
+	fs.StringVar(&cfg.Output.OutputFile, "o", "", "Write output to file")
+	fs.StringVar(&cfg.Output.OutputFile, "output", "", "Write output to file")
+
+	// Help flags
+	var showHelp bool
+	fs.BoolVar(&showHelp, "h", false, "Show help")
+	fs.BoolVar(&showHelp, "help", false, "Show help")
+
+	var showVersion bool
+	fs.BoolVar(&showVersion, "version", false, "Show version")
+
+	// Parse flags
+	if err := fs.Parse(args); err != nil {
+		return nil, err
+	}
+
+	if showHelp {
+		fs.Usage()
+		os.Exit(0)
+	}
+
+	if showVersion {
+		fmt.Println("govnc version 1.0.0")
+		os.Exit(0)
+	}
+
+	// Process headers
+	for _, h := range headers {
+		parts := strings.SplitN(h, ":", 2)
+		if len(parts) == 2 {
+			cfg.Connection.Headers.Add(strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]))
+		}
+	}
+
+	// Process timeout (connectTimeout is in seconds, convert to time.Duration)
+	if connectTimeout > 0 {
+		cfg.Connection.ConnectTimeout = time.Duration(connectTimeout) * time.Second
+	}
+
+	// Process user:password
+	if cfg.VNC.User != "" {
+		parts := strings.SplitN(cfg.VNC.User, ":", 2)
+		if len(parts) == 2 {
+			cfg.VNC.Password = parts[1]
+		}
+		cfg.VNC.User = parts[0]
+	}
+
+	// Process encodings
+	if encodings != "" {
+		cfg.VNC.Encodings = parseEncodings(encodings)
+	}
+
+	// Get URL from remaining args
+	remaining := fs.Args()
+	if len(remaining) > 0 {
+		cfg.URL = remaining[0]
+	}
+
+	// Check for URL in environment
+	if cfg.URL == "" {
+		cfg.URL = os.Getenv("VNC_URL")
+	}
+
+	// Check for cookies in environment
+	if cfg.Connection.Cookie == "" {
+		cfg.Connection.Cookie = os.Getenv("VNC_COOKIES")
+	}
+
+	// Check for password in environment
+	if cfg.VNC.Password == "" {
+		cfg.VNC.Password = os.Getenv("VNC_PASSWORD")
+	}
+
+	return cfg, nil
+}
+
+// parseEncodings parses a comma-separated list of encoding names.
+func parseEncodings(s string) []int32 {
+	names := strings.Split(s, ",")
+	var encodings []int32
+	for _, name := range names {
+		name = strings.TrimSpace(strings.ToLower(name))
+		switch name {
+		case "raw":
+			encodings = append(encodings, 0)
+		case "copyrect":
+			encodings = append(encodings, 1)
+		case "rre":
+			encodings = append(encodings, 2)
+		case "hextile":
+			encodings = append(encodings, 5)
+		case "tight":
+			encodings = append(encodings, 7)
+		case "zrle":
+			encodings = append(encodings, 16)
+		}
+	}
+	return encodings
+}
+
+const usage = `govnc - VNC over WebSocket client and proxy
+
+USAGE:
+    govnc [OPTIONS] <URL>
+
+CONNECTION OPTIONS:
+    -H, --header <Header: Value>    Add custom header (repeatable)
+    -b, --cookie <data|@file>       Send cookies
+    -A, --user-agent <string>       User-Agent header (default: govnc/1.0)
+    -x, --proxy <host:port>         Use proxy (http, socks5)
+    --connect-timeout <seconds>     Connection timeout (default: 30)
+    -k, --insecure                  Skip TLS verification
+
+AUTHENTICATION:
+    -u, --user <user:password>      VNC authentication
+    --auth-none                     Force no authentication
+
+VNC OPTIONS:
+    --shared                        Request shared session (default: true)
+    --exclusive                     Request exclusive session
+    --encoding <list>               Comma-separated encodings (tight,zrle,raw,...)
+
+INPUT OPTIONS:
+    -i, --input <file|->            Read input from file (- for stdin)
+    --type <string>                 Type string and exit
+    --delay <ms>                    Delay between keystrokes
+
+CLIPBOARD OPTIONS:
+    --clip-in <dir>                 Watch directory for outgoing clipboard
+    --clip-out <dir>                Save incoming clipboard to directory
+    --clip-send <text|@file>        Send clipboard text and exit
+
+SERVER MODE:
+    --http <addr:port>              Start HTTP/WS server
+    --proxy-mode <shared|isolated>  Client session mode (default: shared)
+    --max-clients <n>               Max concurrent WebSocket clients
+    --novnc-path <path>             Path to noVNC static files
+    --api-prefix <prefix>           API route prefix (default: /api)
+    --cors-origin <origin>          CORS allowed origin (default: *)
+
+OUTPUT OPTIONS:
+    -v                              Verbose output (use multiple times)
+    -s, --silent                    Silent mode
+    -o, --output <file>             Write output to file
+
+OTHER:
+    -h, --help                      Show help
+    --version                       Show version
+
+ENVIRONMENT VARIABLES:
+    VNC_URL         Default WebSocket URL
+    VNC_COOKIES     Default cookies
+    VNC_PASSWORD    VNC password
+
+EXAMPLES:
+    # Basic interactive connection
+    govnc wss://vnc.example.com/websockify
+
+    # With auth headers
+    govnc -b "session=abc" -H "X-Auth: token" wss://vnc.example.com/ws
+
+    # Type command and exit
+    govnc --type "ls -la\n" wss://vnc.example.com/ws
+
+    # Start proxy server with noVNC web UI
+    govnc --http :8080 wss://vnc.example.com/ws
+    # Then: http://localhost:8080/noVNC/vnc.html
+
+    # Use REST API
+    curl -X PUT localhost:8080/api/keys -d '{"text":"hello\n"}'
+    curl -X PUT localhost:8080/api/clipboard -d '{"text":"copied"}'
+    curl localhost:8080/api/session
+`
pkg/input/clipboard.go
@@ -0,0 +1,212 @@
+package input
+
+import (
+	"context"
+	"fmt"
+	"log/slog"
+	"os"
+	"path/filepath"
+	"sync"
+	"time"
+
+	"github.com/fsnotify/fsnotify"
+)
+
+// ClipboardSender can send clipboard data to a VNC server.
+type ClipboardSender interface {
+	SetClipboard(ctx context.Context, text string) error
+}
+
+// ClipboardHandler is called when clipboard data is received from the server.
+type ClipboardHandler func(text string) error
+
+// ClipboardManager handles clipboard synchronization between local files and VNC.
+type ClipboardManager struct {
+	sender  ClipboardSender
+	handler ClipboardHandler
+
+	watchDir string
+	sentDir  string
+	saveDir  string
+
+	mu            sync.RWMutex
+	lastReceived  string
+	lastRecvTime  time.Time
+	watcher       *fsnotify.Watcher
+}
+
+// ClipboardConfig configures the clipboard manager.
+type ClipboardConfig struct {
+	// WatchDir is the directory to watch for outgoing clipboard files.
+	// Files placed here will be sent to the VNC server.
+	WatchDir string
+
+	// SaveDir is the directory to save incoming clipboard data.
+	// If empty, incoming clipboard is not saved to files.
+	SaveDir string
+
+	// Handler is called when clipboard data is received from the server.
+	Handler ClipboardHandler
+}
+
+// NewClipboardManager creates a new clipboard manager.
+func NewClipboardManager(sender ClipboardSender, cfg *ClipboardConfig) (*ClipboardManager, error) {
+	cm := &ClipboardManager{
+		sender:  sender,
+		handler: cfg.Handler,
+	}
+
+	if cfg.WatchDir != "" {
+		cm.watchDir = cfg.WatchDir
+		cm.sentDir = filepath.Join(cfg.WatchDir, "sent")
+
+		// Create directories
+		if err := os.MkdirAll(cm.watchDir, 0755); err != nil {
+			return nil, fmt.Errorf("creating watch dir: %w", err)
+		}
+		if err := os.MkdirAll(cm.sentDir, 0755); err != nil {
+			return nil, fmt.Errorf("creating sent dir: %w", err)
+		}
+	}
+
+	if cfg.SaveDir != "" {
+		cm.saveDir = cfg.SaveDir
+		if err := os.MkdirAll(cm.saveDir, 0755); err != nil {
+			return nil, fmt.Errorf("creating save dir: %w", err)
+		}
+	}
+
+	return cm, nil
+}
+
+// Start begins watching for clipboard files and processing them.
+func (cm *ClipboardManager) Start(ctx context.Context) error {
+	if cm.watchDir == "" {
+		// No watch directory configured, just wait for context
+		<-ctx.Done()
+		return ctx.Err()
+	}
+
+	watcher, err := fsnotify.NewWatcher()
+	if err != nil {
+		return fmt.Errorf("creating watcher: %w", err)
+	}
+	cm.watcher = watcher
+	defer watcher.Close()
+
+	if err := watcher.Add(cm.watchDir); err != nil {
+		return fmt.Errorf("watching directory: %w", err)
+	}
+
+	slog.Info("watching clipboard directory", slog.String("dir", cm.watchDir))
+
+	for {
+		select {
+		case <-ctx.Done():
+			return ctx.Err()
+
+		case event, ok := <-watcher.Events:
+			if !ok {
+				return nil
+			}
+
+			// Only process Create and Write events
+			if !event.Has(fsnotify.Create) && !event.Has(fsnotify.Write) {
+				continue
+			}
+
+			// Skip the sent/ subdirectory
+			if filepath.Dir(event.Name) != cm.watchDir {
+				continue
+			}
+
+			// Small delay to ensure file is fully written
+			time.Sleep(10 * time.Millisecond)
+
+			cm.processFile(ctx, event.Name)
+
+		case err, ok := <-watcher.Errors:
+			if !ok {
+				return nil
+			}
+			slog.Warn("watcher error", slog.String("error", err.Error()))
+		}
+	}
+}
+
+// processFile reads a file and sends its contents as clipboard data.
+func (cm *ClipboardManager) processFile(ctx context.Context, path string) {
+	info, err := os.Stat(path)
+	if err != nil || info.IsDir() {
+		return
+	}
+
+	data, err := os.ReadFile(path)
+	if err != nil {
+		slog.Warn("reading clipboard file",
+			slog.String("file", path),
+			slog.String("error", err.Error()))
+		return
+	}
+
+	slog.Info("sending clipboard from file",
+		slog.String("file", filepath.Base(path)),
+		slog.Int("len", len(data)))
+
+	if err := cm.sender.SetClipboard(ctx, string(data)); err != nil {
+		slog.Warn("sending clipboard", slog.String("error", err.Error()))
+		return
+	}
+
+	// Move to sent/ after successful send
+	sentPath := filepath.Join(cm.sentDir, filepath.Base(path))
+	if err := os.Rename(path, sentPath); err != nil {
+		slog.Warn("moving sent file", slog.String("error", err.Error()))
+	}
+}
+
+// Send sends text to the VNC clipboard.
+func (cm *ClipboardManager) Send(ctx context.Context, text string) error {
+	return cm.sender.SetClipboard(ctx, text)
+}
+
+// OnReceive handles incoming clipboard data from the server.
+// This is typically called by the RFB client's clipboard handler.
+func (cm *ClipboardManager) OnReceive(text string) {
+	cm.mu.Lock()
+	cm.lastReceived = text
+	cm.lastRecvTime = time.Now()
+	cm.mu.Unlock()
+
+	// Save to file if configured
+	if cm.saveDir != "" {
+		filename := filepath.Join(cm.saveDir, fmt.Sprintf("%x.clip", time.Now().Unix()))
+		if err := os.WriteFile(filename, []byte(text), 0644); err != nil {
+			slog.Warn("saving clipboard", slog.String("error", err.Error()))
+		} else {
+			slog.Debug("saved clipboard", slog.String("file", filename))
+		}
+	}
+
+	// Call handler if configured
+	if cm.handler != nil {
+		if err := cm.handler(text); err != nil {
+			slog.Warn("clipboard handler error", slog.String("error", err.Error()))
+		}
+	}
+}
+
+// GetLastReceived returns the last received clipboard data and timestamp.
+func (cm *ClipboardManager) GetLastReceived() (string, time.Time) {
+	cm.mu.RLock()
+	defer cm.mu.RUnlock()
+	return cm.lastReceived, cm.lastRecvTime
+}
+
+// Close stops watching and cleans up resources.
+func (cm *ClipboardManager) Close() error {
+	if cm.watcher != nil {
+		return cm.watcher.Close()
+	}
+	return nil
+}
pkg/input/keyboard.go
@@ -0,0 +1,205 @@
+// Package input provides input handling utilities for VNC clients.
+package input
+
+import (
+	"context"
+	"io"
+	"time"
+
+	"goVNC/pkg/rfb"
+)
+
+// KeySender can send key events to a VNC server.
+type KeySender interface {
+	KeyEvent(ctx context.Context, key uint32, down bool) error
+}
+
+// Keyboard handles keyboard input processing.
+type Keyboard struct {
+	sender    KeySender
+	delay     time.Duration
+}
+
+// KeyboardOption configures a Keyboard.
+type KeyboardOption func(*Keyboard)
+
+// WithKeyDelay sets a delay between keystrokes.
+func WithKeyDelay(d time.Duration) KeyboardOption {
+	return func(k *Keyboard) {
+		k.delay = d
+	}
+}
+
+// NewKeyboard creates a new keyboard handler.
+func NewKeyboard(sender KeySender, opts ...KeyboardOption) *Keyboard {
+	k := &Keyboard{
+		sender: sender,
+	}
+	for _, opt := range opts {
+		opt(k)
+	}
+	return k
+}
+
+// Press sends a key down event.
+func (k *Keyboard) Press(ctx context.Context, key uint32) error {
+	return k.sender.KeyEvent(ctx, key, true)
+}
+
+// Release sends a key up event.
+func (k *Keyboard) Release(ctx context.Context, key uint32) error {
+	return k.sender.KeyEvent(ctx, key, false)
+}
+
+// Tap presses and releases a key.
+func (k *Keyboard) Tap(ctx context.Context, key uint32) error {
+	if err := k.Press(ctx, key); err != nil {
+		return err
+	}
+	return k.Release(ctx, key)
+}
+
+// Type types a string, converting each character to key events.
+func (k *Keyboard) Type(ctx context.Context, text string) error {
+	for _, r := range text {
+		select {
+		case <-ctx.Done():
+			return ctx.Err()
+		default:
+		}
+
+		key := rfb.RuneToKeysym(r)
+		if err := k.Tap(ctx, key); err != nil {
+			return err
+		}
+
+		if k.delay > 0 {
+			time.Sleep(k.delay)
+		}
+	}
+	return nil
+}
+
+// Combo executes a key combination (e.g., Ctrl+C).
+// All modifier keys are pressed, then the final key is tapped,
+// then all modifiers are released in reverse order.
+func (k *Keyboard) Combo(ctx context.Context, keys ...uint32) error {
+	if len(keys) == 0 {
+		return nil
+	}
+
+	// Press all but last key
+	modifiers := keys[:len(keys)-1]
+	finalKey := keys[len(keys)-1]
+
+	// Press modifiers
+	for _, mod := range modifiers {
+		if err := k.Press(ctx, mod); err != nil {
+			return err
+		}
+	}
+
+	// Tap final key
+	if err := k.Tap(ctx, finalKey); err != nil {
+		return err
+	}
+
+	// Release modifiers in reverse order
+	for i := len(modifiers) - 1; i >= 0; i-- {
+		if err := k.Release(ctx, modifiers[i]); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+// Writer returns an io.Writer that types input to the VNC session.
+func (k *Keyboard) Writer(ctx context.Context) io.Writer {
+	return &keyboardWriter{
+		ctx:      ctx,
+		keyboard: k,
+	}
+}
+
+type keyboardWriter struct {
+	ctx      context.Context
+	keyboard *Keyboard
+}
+
+func (w *keyboardWriter) Write(p []byte) (int, error) {
+	err := w.keyboard.Type(w.ctx, string(p))
+	if err != nil {
+		return 0, err
+	}
+	return len(p), nil
+}
+
+// ParseKeyName converts a key name to a keysym.
+// Supports names like "ctrl", "shift", "alt", "return", "tab", etc.
+func ParseKeyName(name string) (uint32, bool) {
+	switch name {
+	case "ctrl", "control":
+		return rfb.KeyControlL, true
+	case "shift":
+		return rfb.KeyShiftL, true
+	case "alt":
+		return rfb.KeyAltL, true
+	case "meta", "super", "win", "cmd":
+		return rfb.KeySuperL, true
+	case "return", "enter":
+		return rfb.KeyReturn, true
+	case "tab":
+		return rfb.KeyTab, true
+	case "escape", "esc":
+		return rfb.KeyEscape, true
+	case "backspace":
+		return rfb.KeyBackspace, true
+	case "delete", "del":
+		return rfb.KeyDelete, true
+	case "insert", "ins":
+		return rfb.KeyInsert, true
+	case "home":
+		return rfb.KeyHome, true
+	case "end":
+		return rfb.KeyEnd, true
+	case "pageup", "pgup":
+		return rfb.KeyPageUp, true
+	case "pagedown", "pgdn":
+		return rfb.KeyPageDown, true
+	case "left":
+		return rfb.KeyLeft, true
+	case "right":
+		return rfb.KeyRight, true
+	case "up":
+		return rfb.KeyUp, true
+	case "down":
+		return rfb.KeyDown, true
+	case "f1":
+		return rfb.KeyF1, true
+	case "f2":
+		return rfb.KeyF2, true
+	case "f3":
+		return rfb.KeyF3, true
+	case "f4":
+		return rfb.KeyF4, true
+	case "f5":
+		return rfb.KeyF5, true
+	case "f6":
+		return rfb.KeyF6, true
+	case "f7":
+		return rfb.KeyF7, true
+	case "f8":
+		return rfb.KeyF8, true
+	case "f9":
+		return rfb.KeyF9, true
+	case "f10":
+		return rfb.KeyF10, true
+	case "f11":
+		return rfb.KeyF11, true
+	case "f12":
+		return rfb.KeyF12, true
+	default:
+		return 0, false
+	}
+}
pkg/proxy/mux.go
@@ -0,0 +1,228 @@
+// Package proxy provides VNC proxy functionality for multiplexing clients.
+package proxy
+
+import (
+	"context"
+	"fmt"
+	"log/slog"
+	"sync"
+
+	"goVNC/pkg/rfb"
+	"goVNC/pkg/transport"
+)
+
+// SessionMode determines how clients interact with the VNC session.
+type SessionMode int
+
+const (
+	// SharedMode - all clients share the same VNC session view and can send input.
+	SharedMode SessionMode = iota
+
+	// IsolatedMode - input is queued and processed sequentially.
+	IsolatedMode
+)
+
+// Multiplexer manages multiple WebSocket clients connected to a single VNC session.
+type Multiplexer struct {
+	vnc      *rfb.Client
+	mode     SessionMode
+
+	clients  map[string]*ClientConn
+	clientMu sync.RWMutex
+
+	// For broadcasting server messages to all clients
+	broadcast chan []byte
+
+	closed bool
+	closeMu sync.RWMutex
+}
+
+// ClientConn represents a connected client.
+type ClientConn struct {
+	ID        string
+	Transport transport.Transport
+	send      chan []byte
+	done      chan struct{}
+}
+
+// NewMultiplexer creates a new multiplexer for the given VNC client.
+func NewMultiplexer(vnc *rfb.Client, mode SessionMode) *Multiplexer {
+	return &Multiplexer{
+		vnc:       vnc,
+		mode:      mode,
+		clients:   make(map[string]*ClientConn),
+		broadcast: make(chan []byte, 100),
+	}
+}
+
+// AddClient registers a new client connection.
+func (m *Multiplexer) AddClient(id string, t transport.Transport) *ClientConn {
+	client := &ClientConn{
+		ID:        id,
+		Transport: t,
+		send:      make(chan []byte, 100),
+		done:      make(chan struct{}),
+	}
+
+	m.clientMu.Lock()
+	m.clients[id] = client
+	m.clientMu.Unlock()
+
+	slog.Info("client connected", slog.String("id", id))
+	return client
+}
+
+// RemoveClient unregisters a client.
+func (m *Multiplexer) RemoveClient(id string) {
+	m.clientMu.Lock()
+	client, ok := m.clients[id]
+	if ok {
+		delete(m.clients, id)
+		close(client.done)
+	}
+	m.clientMu.Unlock()
+
+	if ok {
+		slog.Info("client disconnected", slog.String("id", id))
+	}
+}
+
+// GetClient returns a client by ID.
+func (m *Multiplexer) GetClient(id string) (*ClientConn, bool) {
+	m.clientMu.RLock()
+	defer m.clientMu.RUnlock()
+	client, ok := m.clients[id]
+	return client, ok
+}
+
+// ClientCount returns the number of connected clients.
+func (m *Multiplexer) ClientCount() int {
+	m.clientMu.RLock()
+	defer m.clientMu.RUnlock()
+	return len(m.clients)
+}
+
+// Broadcast sends a message to all connected clients.
+func (m *Multiplexer) Broadcast(data []byte) {
+	m.clientMu.RLock()
+	defer m.clientMu.RUnlock()
+
+	for _, client := range m.clients {
+		select {
+		case client.send <- data:
+		default:
+			slog.Warn("client send buffer full", slog.String("id", client.ID))
+		}
+	}
+}
+
+// HandleClientInput processes input from a client and forwards to VNC.
+func (m *Multiplexer) HandleClientInput(ctx context.Context, clientID string, data []byte) error {
+	if len(data) == 0 {
+		return nil
+	}
+
+	// Parse the message type
+	msgType := data[0]
+
+	switch msgType {
+	case rfb.MsgTypeKeyEvent:
+		// Forward key events to VNC
+		return m.vnc.Transport().Write(ctx, data)
+
+	case rfb.MsgTypePointerEvent:
+		// Forward pointer events to VNC
+		return m.vnc.Transport().Write(ctx, data)
+
+	case rfb.MsgTypeClientCutText:
+		// Forward clipboard to VNC
+		return m.vnc.Transport().Write(ctx, data)
+
+	case rfb.MsgTypeFramebufferUpdateRequest:
+		// Don't forward FBU requests - the main client handles these
+		return nil
+
+	case rfb.MsgTypeSetEncodings:
+		// Don't forward encoding changes
+		return nil
+
+	case rfb.MsgTypeSetPixelFormat:
+		// Don't forward pixel format changes
+		return nil
+
+	default:
+		slog.Debug("unknown client message type",
+			slog.String("client", clientID),
+			slog.Int("type", int(msgType)))
+	}
+
+	return nil
+}
+
+// RunClientWriter writes messages from the send channel to a client.
+func (m *Multiplexer) RunClientWriter(ctx context.Context, client *ClientConn) error {
+	for {
+		select {
+		case <-ctx.Done():
+			return ctx.Err()
+		case <-client.done:
+			return nil
+		case data := <-client.send:
+			if err := client.Transport.Write(ctx, data); err != nil {
+				return fmt.Errorf("writing to client: %w", err)
+			}
+		}
+	}
+}
+
+// RunClientReader reads messages from a client and processes them.
+func (m *Multiplexer) RunClientReader(ctx context.Context, client *ClientConn) error {
+	for {
+		select {
+		case <-ctx.Done():
+			return ctx.Err()
+		case <-client.done:
+			return nil
+		default:
+		}
+
+		data, err := client.Transport.Read(ctx)
+		if err != nil {
+			return fmt.Errorf("reading from client: %w", err)
+		}
+
+		if err := m.HandleClientInput(ctx, client.ID, data); err != nil {
+			slog.Warn("handling client input",
+				slog.String("client", client.ID),
+				slog.String("error", err.Error()))
+		}
+	}
+}
+
+// Close shuts down the multiplexer.
+func (m *Multiplexer) Close() error {
+	m.closeMu.Lock()
+	defer m.closeMu.Unlock()
+
+	if m.closed {
+		return nil
+	}
+	m.closed = true
+
+	// Close all client connections
+	m.clientMu.Lock()
+	for id, client := range m.clients {
+		close(client.done)
+		client.Transport.Close()
+		delete(m.clients, id)
+	}
+	m.clientMu.Unlock()
+
+	close(m.broadcast)
+	return nil
+}
+
+// VNCClient returns the underlying VNC client.
+func (m *Multiplexer) VNCClient() *rfb.Client {
+	return m.vnc
+}
pkg/rfb/client.go
@@ -0,0 +1,278 @@
+package rfb
+
+import (
+	"context"
+	"fmt"
+	"log/slog"
+	"sync"
+	"time"
+
+	"goVNC/pkg/transport"
+)
+
+// MessageHandler is called when a server message is received.
+type MessageHandler func(msg *ServerMessage)
+
+// ClipboardHandler is called when clipboard data is received from the server.
+type ClipboardHandler func(text string)
+
+// Client is an RFB protocol client.
+type Client struct {
+	transport transport.Transport
+	session   *Session
+	config    *ClientConfig
+
+	// Message handlers
+	onMessage   MessageHandler
+	onClipboard ClipboardHandler
+
+	// Synchronization
+	mu       sync.RWMutex
+	closed   bool
+
+	// Last received clipboard (for API access)
+	lastClipboard     string
+	lastClipboardTime time.Time
+	clipMu            sync.RWMutex
+}
+
+// ClientConfig configures the RFB client.
+type ClientConfig struct {
+	// Handshake configuration
+	Handshake *HandshakeConfig
+
+	// Encodings to request from the server.
+	Encodings []int32
+
+	// ReadLimit is the maximum message size (default 64MB).
+	ReadLimit int64
+
+	// KeyTimeout is the timeout for key events (default 1s).
+	KeyTimeout time.Duration
+}
+
+// DefaultClientConfig returns default client configuration.
+func DefaultClientConfig() *ClientConfig {
+	return &ClientConfig{
+		Handshake:  DefaultHandshakeConfig(),
+		Encodings:  DefaultEncodings(),
+		ReadLimit:  64 * 1024 * 1024, // 64MB
+		KeyTimeout: 1 * time.Second,
+	}
+}
+
+// NewClient creates a new RFB client with the given transport.
+func NewClient(t transport.Transport, cfg *ClientConfig) *Client {
+	if cfg == nil {
+		cfg = DefaultClientConfig()
+	}
+	return &Client{
+		transport: t,
+		config:    cfg,
+	}
+}
+
+// Connect performs the RFB handshake and initializes the session.
+func (c *Client) Connect(ctx context.Context) (*Session, error) {
+	c.mu.Lock()
+	if c.closed {
+		c.mu.Unlock()
+		return nil, fmt.Errorf("client is closed")
+	}
+
+	// Perform handshake
+	session, err := Handshake(ctx, c.transport, c.config.Handshake)
+	if err != nil {
+		c.mu.Unlock()
+		return nil, fmt.Errorf("handshake failed: %w", err)
+	}
+	c.session = session
+
+	// Set read limit
+	c.transport.SetReadLimit(c.config.ReadLimit)
+
+	// Send encodings
+	if len(c.config.Encodings) > 0 {
+		msg := EncodeSetEncodings(c.config.Encodings)
+		if err := c.transport.Write(ctx, msg); err != nil {
+			c.mu.Unlock()
+			return nil, fmt.Errorf("sending encodings: %w", err)
+		}
+	}
+
+	// Release lock before calling RequestFramebuffer (which also takes the lock)
+	c.mu.Unlock()
+
+	// Request initial framebuffer
+	if err := c.RequestFramebuffer(ctx, false); err != nil {
+		return nil, fmt.Errorf("requesting initial framebuffer: %w", err)
+	}
+
+	return session, nil
+}
+
+// Session returns the current session info.
+func (c *Client) Session() *Session {
+	c.mu.RLock()
+	defer c.mu.RUnlock()
+	return c.session
+}
+
+// OnMessage sets the handler for all server messages.
+func (c *Client) OnMessage(handler MessageHandler) {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+	c.onMessage = handler
+}
+
+// OnClipboard sets the handler for clipboard messages.
+func (c *Client) OnClipboard(handler ClipboardHandler) {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+	c.onClipboard = handler
+}
+
+// Listen reads and processes server messages until the context is cancelled.
+func (c *Client) Listen(ctx context.Context) error {
+	for {
+		data, err := c.transport.Read(ctx)
+		if err != nil {
+			return fmt.Errorf("reading message: %w", err)
+		}
+
+		if len(data) == 0 {
+			slog.Warn("received empty message")
+			continue
+		}
+
+		msg, err := ParseServerMessage(data)
+		if err != nil {
+			slog.Warn("parsing message", slog.String("error", err.Error()))
+			continue
+		}
+
+		// Call message handler
+		c.mu.RLock()
+		onMessage := c.onMessage
+		onClipboard := c.onClipboard
+		c.mu.RUnlock()
+
+		if onMessage != nil {
+			onMessage(msg)
+		}
+
+		// Handle specific message types
+		switch msg.Type {
+		case MsgTypeFramebufferUpdate:
+			// Request next incremental update
+			if err := c.RequestFramebuffer(ctx, true); err != nil {
+				return fmt.Errorf("requesting framebuffer: %w", err)
+			}
+
+		case MsgTypeServerCutText:
+			cut, err := ParseServerCutText(data)
+			if err != nil {
+				slog.Warn("parsing clipboard", slog.String("error", err.Error()))
+				continue
+			}
+			// Store for API access
+			c.clipMu.Lock()
+			c.lastClipboard = cut.Text
+			c.lastClipboardTime = time.Now()
+			c.clipMu.Unlock()
+
+			if onClipboard != nil {
+				onClipboard(cut.Text)
+			}
+
+		case MsgTypeBell:
+			slog.Info("bell received")
+		}
+	}
+}
+
+// RequestFramebuffer sends a framebuffer update request.
+func (c *Client) RequestFramebuffer(ctx context.Context, incremental bool) error {
+	c.mu.RLock()
+	session := c.session
+	c.mu.RUnlock()
+
+	if session == nil {
+		return fmt.Errorf("not connected")
+	}
+
+	msg := EncodeFramebufferUpdateRequest(incremental, 0, 0, session.Width, session.Height)
+	return c.transport.Write(ctx, msg)
+}
+
+// KeyEvent sends a key press or release event.
+func (c *Client) KeyEvent(ctx context.Context, key uint32, down bool) error {
+	ctx, cancel := context.WithTimeout(ctx, c.config.KeyTimeout)
+	defer cancel()
+
+	msg := EncodeKeyEvent(down, key)
+	return c.transport.Write(ctx, msg)
+}
+
+// Tap sends a key press followed by release.
+func (c *Client) Tap(ctx context.Context, key uint32) error {
+	if err := c.KeyEvent(ctx, key, true); err != nil {
+		return err
+	}
+	return c.KeyEvent(ctx, key, false)
+}
+
+// Type sends a string as key events.
+func (c *Client) Type(ctx context.Context, text string) error {
+	for _, r := range text {
+		select {
+		case <-ctx.Done():
+			return ctx.Err()
+		default:
+		}
+
+		key := RuneToKeysym(r)
+		if err := c.Tap(ctx, key); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// PointerEvent sends a mouse/pointer event.
+func (c *Client) PointerEvent(ctx context.Context, buttonMask uint8, x, y uint16) error {
+	msg := EncodePointerEvent(buttonMask, x, y)
+	return c.transport.Write(ctx, msg)
+}
+
+// SetClipboard sends clipboard text to the server.
+func (c *Client) SetClipboard(ctx context.Context, text string) error {
+	msg := EncodeClientCutText(text)
+	return c.transport.Write(ctx, msg)
+}
+
+// GetLastClipboard returns the last received clipboard data.
+func (c *Client) GetLastClipboard() (string, time.Time) {
+	c.clipMu.RLock()
+	defer c.clipMu.RUnlock()
+	return c.lastClipboard, c.lastClipboardTime
+}
+
+// Close closes the client and transport.
+func (c *Client) Close() error {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+
+	if c.closed {
+		return nil
+	}
+	c.closed = true
+
+	return c.transport.Close()
+}
+
+// Transport returns the underlying transport.
+// This is useful for advanced operations or passing to the proxy.
+func (c *Client) Transport() transport.Transport {
+	return c.transport
+}
pkg/rfb/handshake.go
@@ -0,0 +1,237 @@
+package rfb
+
+import (
+	"context"
+	"crypto/des"
+	"fmt"
+	"strings"
+
+	"goVNC/pkg/transport"
+)
+
+const (
+	// RFBVersion is the RFB protocol version we support.
+	RFBVersion = "RFB 003.008\n"
+)
+
+// SecurityType represents an RFB security type.
+type SecurityType uint8
+
+const (
+	SecurityTypeInvalid   SecurityType = 0
+	SecurityTypeNone      SecurityType = 1
+	SecurityTypeVNCAuth   SecurityType = 2
+	SecurityTypeTight     SecurityType = 16
+	SecurityTypeVeNCrypt  SecurityType = 19
+)
+
+// HandshakeConfig configures the RFB handshake.
+type HandshakeConfig struct {
+	// SharedSession requests a shared session if true.
+	SharedSession bool
+
+	// Password for VNC authentication (SecurityTypeVNCAuth).
+	Password string
+
+	// AllowedSecurityTypes lists allowed security types.
+	// If empty, only SecurityTypeNone is allowed.
+	AllowedSecurityTypes []SecurityType
+}
+
+// DefaultHandshakeConfig returns default handshake configuration.
+func DefaultHandshakeConfig() *HandshakeConfig {
+	return &HandshakeConfig{
+		SharedSession:        true,
+		AllowedSecurityTypes: []SecurityType{SecurityTypeNone},
+	}
+}
+
+// Handshake performs the RFB protocol handshake and returns a Session.
+func Handshake(ctx context.Context, t transport.Transport, cfg *HandshakeConfig) (*Session, error) {
+	if cfg == nil {
+		cfg = DefaultHandshakeConfig()
+	}
+
+	// Step 1: Read server version
+	serverVersionData, err := t.Read(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("reading server version: %w", err)
+	}
+	serverVersion := strings.TrimSpace(string(serverVersionData))
+
+	// Step 2: Send client version
+	err = t.Write(ctx, []byte(RFBVersion))
+	if err != nil {
+		return nil, fmt.Errorf("sending client version: %w", err)
+	}
+
+	// Step 3: Read security types
+	secTypes, err := t.Read(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("reading security types: %w", err)
+	}
+
+	if len(secTypes) < 1 {
+		return nil, fmt.Errorf("empty security types message")
+	}
+
+	// Parse security types
+	numSecTypes := int(secTypes[0])
+	if numSecTypes == 0 {
+		// Version 3.3 error or failure
+		if len(secTypes) >= 5 {
+			errLen := int(secTypes[1])<<24 | int(secTypes[2])<<16 | int(secTypes[3])<<8 | int(secTypes[4])
+			if len(secTypes) >= 5+errLen {
+				return nil, fmt.Errorf("server rejected: %s", string(secTypes[5:5+errLen]))
+			}
+		}
+		return nil, fmt.Errorf("server rejected connection")
+	}
+
+	if len(secTypes) < 1+numSecTypes {
+		return nil, fmt.Errorf("security types message too short")
+	}
+
+	// Find a supported security type
+	var selectedSecurity SecurityType
+	for i := 0; i < numSecTypes; i++ {
+		offered := SecurityType(secTypes[1+i])
+		for _, allowed := range cfg.AllowedSecurityTypes {
+			if offered == allowed {
+				selectedSecurity = offered
+				break
+			}
+		}
+		if selectedSecurity != 0 {
+			break
+		}
+	}
+
+	if selectedSecurity == 0 {
+		return nil, fmt.Errorf("no supported security type (server offered: %v)", secTypes[1:1+numSecTypes])
+	}
+
+	// Step 4: Send security choice
+	err = t.Write(ctx, []byte{byte(selectedSecurity)})
+	if err != nil {
+		return nil, fmt.Errorf("sending security choice: %w", err)
+	}
+
+	// Step 5: Handle security type specific authentication
+	switch selectedSecurity {
+	case SecurityTypeNone:
+		// No authentication required
+	case SecurityTypeVNCAuth:
+		err = handleVNCAuth(ctx, t, cfg.Password)
+		if err != nil {
+			return nil, fmt.Errorf("VNC authentication: %w", err)
+		}
+	default:
+		return nil, fmt.Errorf("unsupported security type: %d", selectedSecurity)
+	}
+
+	// Step 6: Read security result
+	secResult, err := t.Read(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("reading security result: %w", err)
+	}
+
+	if len(secResult) < 4 {
+		// Some servers don't send security result for None auth in older versions
+		if selectedSecurity != SecurityTypeNone {
+			return nil, fmt.Errorf("security result too short: %d bytes", len(secResult))
+		}
+		// Treat as success for None auth
+	} else {
+		result := uint32(secResult[0])<<24 | uint32(secResult[1])<<16 | uint32(secResult[2])<<8 | uint32(secResult[3])
+		if result != 0 {
+			return nil, fmt.Errorf("security handshake failed: result=%d", result)
+		}
+	}
+
+	// Step 7: Send ClientInit
+	clientInit := byte(0)
+	if cfg.SharedSession {
+		clientInit = 1
+	}
+	err = t.Write(ctx, []byte{clientInit})
+	if err != nil {
+		return nil, fmt.Errorf("sending client init: %w", err)
+	}
+
+	// Step 8: Read ServerInit
+	serverInit, err := t.Read(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("reading server init: %w", err)
+	}
+
+	session, err := ParseServerInit(serverInit)
+	if err != nil {
+		return nil, fmt.Errorf("parsing server init: %w", err)
+	}
+	session.ServerVersion = serverVersion
+
+	return session, nil
+}
+
+// handleVNCAuth handles VNC authentication (DES challenge-response).
+func handleVNCAuth(ctx context.Context, t transport.Transport, password string) error {
+	// Read 16-byte challenge
+	challenge, err := t.Read(ctx)
+	if err != nil {
+		return fmt.Errorf("reading challenge: %w", err)
+	}
+	if len(challenge) != 16 {
+		return fmt.Errorf("unexpected challenge length: %d", len(challenge))
+	}
+
+	// Encrypt challenge with password using DES
+	response := vncAuthEncrypt(challenge, password)
+
+	// Send 16-byte response
+	err = t.Write(ctx, response)
+	if err != nil {
+		return fmt.Errorf("sending response: %w", err)
+	}
+
+	return nil
+}
+
+// vncAuthEncrypt encrypts the VNC authentication challenge.
+// VNC uses a weird DES variant where key bits are reversed.
+func vncAuthEncrypt(challenge []byte, password string) []byte {
+	// Pad or truncate password to 8 bytes
+	key := make([]byte, 8)
+	copy(key, password)
+
+	// Reverse bits in each byte of the key (VNC quirk)
+	for i := range key {
+		key[i] = reverseBits(key[i])
+	}
+
+	// Create DES cipher
+	cipher, err := des.NewCipher(key)
+	if err != nil {
+		// Should not happen with 8-byte key
+		return challenge
+	}
+
+	// Encrypt with DES in ECB mode (2 blocks of 8 bytes)
+	response := make([]byte, 16)
+	cipher.Encrypt(response[0:8], challenge[0:8])
+	cipher.Encrypt(response[8:16], challenge[8:16])
+
+	return response
+}
+
+// reverseBits reverses the bits in a byte.
+// VNC authentication requires this quirk for the DES key.
+func reverseBits(b byte) byte {
+	var result byte
+	for i := 0; i < 8; i++ {
+		if b&(1<<i) != 0 {
+			result |= 1 << (7 - i)
+		}
+	}
+	return result
+}
pkg/rfb/handshake_test.go
@@ -0,0 +1,114 @@
+package rfb
+
+import (
+	"encoding/hex"
+	"testing"
+)
+
+func TestReverseBits(t *testing.T) {
+	tests := []struct {
+		input byte
+		want  byte
+	}{
+		{0x00, 0x00},
+		{0xFF, 0xFF},
+		{0x01, 0x80},
+		{0x80, 0x01},
+		{0x0F, 0xF0},
+		{0xF0, 0x0F},
+		{0xAA, 0x55}, // 10101010 -> 01010101
+		{0x55, 0xAA}, // 01010101 -> 10101010
+	}
+
+	for _, tt := range tests {
+		got := reverseBits(tt.input)
+		if got != tt.want {
+			t.Errorf("reverseBits(%02x) = %02x, want %02x", tt.input, got, tt.want)
+		}
+	}
+}
+
+func TestVNCAuthEncrypt(t *testing.T) {
+	// Known test vectors for VNC authentication
+	// Challenge is 16 random bytes from server
+	// Password is converted to 8-byte key with reversed bits
+	// Then DES-ECB encrypt the challenge
+
+	// Test with empty password (all zeros key)
+	challenge := make([]byte, 16)
+	for i := range challenge {
+		challenge[i] = byte(i)
+	}
+
+	response := vncAuthEncrypt(challenge, "")
+	if len(response) != 16 {
+		t.Errorf("response length = %d, want 16", len(response))
+	}
+
+	// Test with a known password
+	// The response should be deterministic
+	response1 := vncAuthEncrypt(challenge, "password")
+	response2 := vncAuthEncrypt(challenge, "password")
+
+	if hex.EncodeToString(response1) != hex.EncodeToString(response2) {
+		t.Error("vncAuthEncrypt should be deterministic")
+	}
+
+	// Different passwords should produce different responses
+	response3 := vncAuthEncrypt(challenge, "different")
+	if hex.EncodeToString(response1) == hex.EncodeToString(response3) {
+		t.Error("different passwords should produce different responses")
+	}
+}
+
+func TestParseServerInit(t *testing.T) {
+	// Build a valid ServerInit message
+	// width(2) + height(2) + pixel_format(16) + name_len(4) + name
+	data := make([]byte, 24+4) // minimum + "test"
+	data[0] = 0x07             // width high byte
+	data[1] = 0x80             // width low byte (1920)
+	data[2] = 0x04             // height high byte
+	data[3] = 0x38             // height low byte (1080)
+	// pixel format at data[4:20]
+	data[4] = 32  // bits per pixel
+	data[5] = 24  // depth
+	data[6] = 0   // big endian (false)
+	data[7] = 1   // true colour
+	data[8] = 0   // red max high
+	data[9] = 255 // red max low
+	// ... rest of pixel format
+	data[20] = 0 // name len high bytes
+	data[21] = 0
+	data[22] = 0
+	data[23] = 4 // name len = 4
+	data[24] = 't'
+	data[25] = 'e'
+	data[26] = 's'
+	data[27] = 't'
+
+	session, err := ParseServerInit(data)
+	if err != nil {
+		t.Fatalf("ParseServerInit() error = %v", err)
+	}
+
+	if session.Width != 1920 {
+		t.Errorf("Width = %d, want 1920", session.Width)
+	}
+	if session.Height != 1080 {
+		t.Errorf("Height = %d, want 1080", session.Height)
+	}
+	if session.Name != "test" {
+		t.Errorf("Name = %q, want %q", session.Name, "test")
+	}
+	if session.PixelFormat.BitsPerPixel != 32 {
+		t.Errorf("BitsPerPixel = %d, want 32", session.PixelFormat.BitsPerPixel)
+	}
+}
+
+func TestParseServerInitTooShort(t *testing.T) {
+	data := make([]byte, 10) // too short
+	_, err := ParseServerInit(data)
+	if err == nil {
+		t.Error("ParseServerInit() should fail with short data")
+	}
+}
pkg/rfb/messages.go
@@ -0,0 +1,225 @@
+// Package rfb implements the RFB (Remote Framebuffer) protocol for VNC.
+package rfb
+
+import (
+	"encoding/binary"
+	"fmt"
+)
+
+// Server-to-client message types
+const (
+	MsgTypeFramebufferUpdate   = 0
+	MsgTypeSetColourMapEntries = 1
+	MsgTypeBell                = 2
+	MsgTypeServerCutText       = 3
+)
+
+// Client-to-server message types
+const (
+	MsgTypeSetPixelFormat           = 0
+	MsgTypeSetEncodings             = 2
+	MsgTypeFramebufferUpdateRequest = 3
+	MsgTypeKeyEvent                 = 4
+	MsgTypePointerEvent             = 5
+	MsgTypeClientCutText            = 6
+)
+
+// Common VNC key codes (X11 keysyms)
+const (
+	KeyBackspace = 0xff08
+	KeyTab       = 0xff09
+	KeyReturn    = 0xff0d
+	KeyEscape    = 0xff1b
+	KeyInsert    = 0xff63
+	KeyDelete    = 0xffff
+	KeyHome      = 0xff50
+	KeyEnd       = 0xff57
+	KeyPageUp    = 0xff55
+	KeyPageDown  = 0xff56
+	KeyLeft      = 0xff51
+	KeyUp        = 0xff52
+	KeyRight     = 0xff53
+	KeyDown      = 0xff54
+	KeyF1        = 0xffbe
+	KeyF2        = 0xffbf
+	KeyF3        = 0xffc0
+	KeyF4        = 0xffc1
+	KeyF5        = 0xffc2
+	KeyF6        = 0xffc3
+	KeyF7        = 0xffc4
+	KeyF8        = 0xffc5
+	KeyF9        = 0xffc6
+	KeyF10       = 0xffc7
+	KeyF11       = 0xffc8
+	KeyF12       = 0xffc9
+	KeyShiftL    = 0xffe1
+	KeyShiftR    = 0xffe2
+	KeyControlL  = 0xffe3
+	KeyControlR  = 0xffe4
+	KeyMetaL     = 0xffe7
+	KeyMetaR     = 0xffe8
+	KeyAltL      = 0xffe9
+	KeyAltR      = 0xffea
+	KeySuperL    = 0xffeb
+	KeySuperR    = 0xffec
+)
+
+// Standard encodings
+const (
+	EncodingRaw       = 0
+	EncodingCopyRect  = 1
+	EncodingRRE       = 2
+	EncodingHextile   = 5
+	EncodingZRLE      = 16
+	EncodingTight     = 7
+	EncodingTightPNG  = -260
+
+	// Pseudo-encodings
+	EncodingCursor         = -239
+	EncodingDesktopSize    = -223
+	EncodingLastRect       = -224
+	EncodingDesktopName    = -307
+	EncodingExtendedDesktopSize = -308
+)
+
+// DefaultEncodings returns the default encoding list for noVNC compatibility.
+func DefaultEncodings() []int32 {
+	return []int32{
+		EncodingTight,
+		EncodingZRLE,
+		EncodingHextile,
+		EncodingCopyRect,
+		EncodingRaw,
+		EncodingDesktopSize,
+		EncodingLastRect,
+		EncodingCursor,
+	}
+}
+
+// EncodeSetEncodings creates a SetEncodings message.
+func EncodeSetEncodings(encodings []int32) []byte {
+	msg := make([]byte, 4+4*len(encodings))
+	msg[0] = MsgTypeSetEncodings
+	// msg[1] padding
+	binary.BigEndian.PutUint16(msg[2:4], uint16(len(encodings)))
+	for i, enc := range encodings {
+		binary.BigEndian.PutUint32(msg[4+i*4:], uint32(enc))
+	}
+	return msg
+}
+
+// EncodeFramebufferUpdateRequest creates a FramebufferUpdateRequest message.
+func EncodeFramebufferUpdateRequest(incremental bool, x, y, width, height uint16) []byte {
+	msg := make([]byte, 10)
+	msg[0] = MsgTypeFramebufferUpdateRequest
+	if incremental {
+		msg[1] = 1
+	}
+	binary.BigEndian.PutUint16(msg[2:4], x)
+	binary.BigEndian.PutUint16(msg[4:6], y)
+	binary.BigEndian.PutUint16(msg[6:8], width)
+	binary.BigEndian.PutUint16(msg[8:10], height)
+	return msg
+}
+
+// EncodeKeyEvent creates a KeyEvent message.
+func EncodeKeyEvent(down bool, key uint32) []byte {
+	msg := make([]byte, 8)
+	msg[0] = MsgTypeKeyEvent
+	if down {
+		msg[1] = 1
+	}
+	// msg[2:4] padding
+	binary.BigEndian.PutUint32(msg[4:8], key)
+	return msg
+}
+
+// EncodePointerEvent creates a PointerEvent message.
+func EncodePointerEvent(buttonMask uint8, x, y uint16) []byte {
+	msg := make([]byte, 6)
+	msg[0] = MsgTypePointerEvent
+	msg[1] = buttonMask
+	binary.BigEndian.PutUint16(msg[2:4], x)
+	binary.BigEndian.PutUint16(msg[4:6], y)
+	return msg
+}
+
+// EncodeClientCutText creates a ClientCutText message.
+func EncodeClientCutText(text string) []byte {
+	msg := make([]byte, 8+len(text))
+	msg[0] = MsgTypeClientCutText
+	// msg[1:4] padding
+	binary.BigEndian.PutUint32(msg[4:8], uint32(len(text)))
+	copy(msg[8:], text)
+	return msg
+}
+
+// ServerMessage represents a parsed server-to-client message.
+type ServerMessage struct {
+	Type uint8
+	Raw  []byte
+}
+
+// FramebufferUpdate contains parsed framebuffer update data.
+type FramebufferUpdate struct {
+	NumRects uint16
+	Raw      []byte
+}
+
+// ServerCutText contains clipboard data from the server.
+type ServerCutText struct {
+	Text string
+}
+
+// ParseServerMessage parses a raw server message.
+func ParseServerMessage(data []byte) (*ServerMessage, error) {
+	if len(data) == 0 {
+		return nil, fmt.Errorf("empty message")
+	}
+	return &ServerMessage{
+		Type: data[0],
+		Raw:  data,
+	}, nil
+}
+
+// ParseFramebufferUpdate parses a FramebufferUpdate message.
+func ParseFramebufferUpdate(data []byte) (*FramebufferUpdate, error) {
+	if len(data) < 4 {
+		return nil, fmt.Errorf("framebuffer update too short: %d", len(data))
+	}
+	return &FramebufferUpdate{
+		NumRects: binary.BigEndian.Uint16(data[2:4]),
+		Raw:      data,
+	}, nil
+}
+
+// ParseServerCutText parses a ServerCutText message.
+func ParseServerCutText(data []byte) (*ServerCutText, error) {
+	if len(data) < 8 {
+		return nil, fmt.Errorf("server cut text too short: %d", len(data))
+	}
+	textLen := binary.BigEndian.Uint32(data[4:8])
+	if len(data) < 8+int(textLen) {
+		return nil, fmt.Errorf("server cut text incomplete: need %d, got %d", 8+textLen, len(data))
+	}
+	return &ServerCutText{
+		Text: string(data[8 : 8+textLen]),
+	}, nil
+}
+
+// RuneToKeysym converts a rune to a VNC keysym.
+func RuneToKeysym(r rune) uint32 {
+	switch r {
+	case '\n':
+		return KeyReturn
+	case '\t':
+		return KeyTab
+	case '\b':
+		return KeyBackspace
+	case 0x1b:
+		return KeyEscape
+	default:
+		// For ASCII characters, the keysym equals the Unicode code point
+		return uint32(r)
+	}
+}
pkg/rfb/messages_test.go
@@ -0,0 +1,160 @@
+package rfb
+
+import (
+	"testing"
+)
+
+func TestEncodeKeyEvent(t *testing.T) {
+	tests := []struct {
+		name string
+		down bool
+		key  uint32
+		want []byte
+	}{
+		{
+			name: "key down 'a'",
+			down: true,
+			key:  0x61,
+			want: []byte{0x04, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x61},
+		},
+		{
+			name: "key up 'a'",
+			down: false,
+			key:  0x61,
+			want: []byte{0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x61},
+		},
+		{
+			name: "key down Return",
+			down: true,
+			key:  KeyReturn,
+			want: []byte{0x04, 0x01, 0x00, 0x00, 0x00, 0x00, 0xff, 0x0d},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := EncodeKeyEvent(tt.down, tt.key)
+			if len(got) != len(tt.want) {
+				t.Errorf("EncodeKeyEvent() len = %d, want %d", len(got), len(tt.want))
+				return
+			}
+			for i := range got {
+				if got[i] != tt.want[i] {
+					t.Errorf("EncodeKeyEvent()[%d] = %02x, want %02x", i, got[i], tt.want[i])
+				}
+			}
+		})
+	}
+}
+
+func TestEncodeClientCutText(t *testing.T) {
+	text := "Hello"
+	got := EncodeClientCutText(text)
+
+	// Type
+	if got[0] != MsgTypeClientCutText {
+		t.Errorf("type = %d, want %d", got[0], MsgTypeClientCutText)
+	}
+
+	// Length (big-endian)
+	length := uint32(got[4])<<24 | uint32(got[5])<<16 | uint32(got[6])<<8 | uint32(got[7])
+	if length != uint32(len(text)) {
+		t.Errorf("length = %d, want %d", length, len(text))
+	}
+
+	// Text
+	gotText := string(got[8:])
+	if gotText != text {
+		t.Errorf("text = %q, want %q", gotText, text)
+	}
+}
+
+func TestRuneToKeysym(t *testing.T) {
+	tests := []struct {
+		r    rune
+		want uint32
+	}{
+		{'\n', KeyReturn},
+		{'\t', KeyTab},
+		{'\b', KeyBackspace},
+		{'a', 0x61},
+		{'A', 0x41},
+		{'1', 0x31},
+	}
+
+	for _, tt := range tests {
+		got := RuneToKeysym(tt.r)
+		if got != tt.want {
+			t.Errorf("RuneToKeysym(%q) = %#x, want %#x", tt.r, got, tt.want)
+		}
+	}
+}
+
+func TestParseServerCutText(t *testing.T) {
+	// Build a valid ServerCutText message
+	text := "clipboard content"
+	msg := make([]byte, 8+len(text))
+	msg[0] = MsgTypeServerCutText
+	// padding msg[1:4]
+	msg[4] = 0
+	msg[5] = 0
+	msg[6] = 0
+	msg[7] = byte(len(text))
+	copy(msg[8:], text)
+
+	cut, err := ParseServerCutText(msg)
+	if err != nil {
+		t.Fatalf("ParseServerCutText() error = %v", err)
+	}
+	if cut.Text != text {
+		t.Errorf("Text = %q, want %q", cut.Text, text)
+	}
+}
+
+func TestEncodeSetEncodings(t *testing.T) {
+	encodings := []int32{EncodingTight, EncodingRaw}
+	got := EncodeSetEncodings(encodings)
+
+	// Type
+	if got[0] != MsgTypeSetEncodings {
+		t.Errorf("type = %d, want %d", got[0], MsgTypeSetEncodings)
+	}
+
+	// Count
+	count := uint16(got[2])<<8 | uint16(got[3])
+	if count != uint16(len(encodings)) {
+		t.Errorf("count = %d, want %d", count, len(encodings))
+	}
+
+	// Expected length: 4 header + 4 per encoding
+	wantLen := 4 + 4*len(encodings)
+	if len(got) != wantLen {
+		t.Errorf("len = %d, want %d", len(got), wantLen)
+	}
+}
+
+func TestEncodeFramebufferUpdateRequest(t *testing.T) {
+	got := EncodeFramebufferUpdateRequest(true, 0, 0, 1920, 1080)
+
+	// Type
+	if got[0] != MsgTypeFramebufferUpdateRequest {
+		t.Errorf("type = %d, want %d", got[0], MsgTypeFramebufferUpdateRequest)
+	}
+
+	// Incremental
+	if got[1] != 1 {
+		t.Errorf("incremental = %d, want 1", got[1])
+	}
+
+	// Width
+	width := uint16(got[6])<<8 | uint16(got[7])
+	if width != 1920 {
+		t.Errorf("width = %d, want 1920", width)
+	}
+
+	// Height
+	height := uint16(got[8])<<8 | uint16(got[9])
+	if height != 1080 {
+		t.Errorf("height = %d, want 1080", height)
+	}
+}
pkg/rfb/session.go
@@ -0,0 +1,99 @@
+package rfb
+
+import (
+	"encoding/binary"
+	"fmt"
+)
+
+// Session holds the state of an RFB session after handshake.
+type Session struct {
+	// Width of the framebuffer in pixels.
+	Width uint16
+
+	// Height of the framebuffer in pixels.
+	Height uint16
+
+	// Name of the VNC server/desktop.
+	Name string
+
+	// PixelFormat describes the pixel format of the framebuffer.
+	PixelFormat PixelFormat
+
+	// ServerVersion is the RFB version string from the server.
+	ServerVersion string
+}
+
+// PixelFormat describes the format of pixel data.
+type PixelFormat struct {
+	BitsPerPixel  uint8
+	Depth         uint8
+	BigEndian     bool
+	TrueColour    bool
+	RedMax        uint16
+	GreenMax      uint16
+	BlueMax       uint16
+	RedShift      uint8
+	GreenShift    uint8
+	BlueShift     uint8
+}
+
+// ParseServerInit parses a ServerInit message and returns a Session.
+// ServerInit format: width(2) + height(2) + pixel_format(16) + name_len(4) + name
+func ParseServerInit(data []byte) (*Session, error) {
+	if len(data) < 24 {
+		return nil, fmt.Errorf("server init too short: %d bytes", len(data))
+	}
+
+	width := binary.BigEndian.Uint16(data[0:2])
+	height := binary.BigEndian.Uint16(data[2:4])
+
+	pf := PixelFormat{
+		BitsPerPixel: data[4],
+		Depth:        data[5],
+		BigEndian:    data[6] != 0,
+		TrueColour:   data[7] != 0,
+		RedMax:       binary.BigEndian.Uint16(data[8:10]),
+		GreenMax:     binary.BigEndian.Uint16(data[10:12]),
+		BlueMax:      binary.BigEndian.Uint16(data[12:14]),
+		RedShift:     data[14],
+		GreenShift:   data[15],
+		BlueShift:    data[16],
+		// data[17:20] is padding
+	}
+
+	nameLen := binary.BigEndian.Uint32(data[20:24])
+	if len(data) < 24+int(nameLen) {
+		return nil, fmt.Errorf("server init name incomplete: need %d, got %d", 24+nameLen, len(data))
+	}
+	name := string(data[24 : 24+nameLen])
+
+	return &Session{
+		Width:       width,
+		Height:      height,
+		Name:        name,
+		PixelFormat: pf,
+	}, nil
+}
+
+// EncodePixelFormat creates a SetPixelFormat message.
+func EncodePixelFormat(pf *PixelFormat) []byte {
+	msg := make([]byte, 20)
+	msg[0] = MsgTypeSetPixelFormat
+	// msg[1:4] padding
+	msg[4] = pf.BitsPerPixel
+	msg[5] = pf.Depth
+	if pf.BigEndian {
+		msg[6] = 1
+	}
+	if pf.TrueColour {
+		msg[7] = 1
+	}
+	binary.BigEndian.PutUint16(msg[8:10], pf.RedMax)
+	binary.BigEndian.PutUint16(msg[10:12], pf.GreenMax)
+	binary.BigEndian.PutUint16(msg[12:14], pf.BlueMax)
+	msg[14] = pf.RedShift
+	msg[15] = pf.GreenShift
+	msg[16] = pf.BlueShift
+	// msg[17:20] padding
+	return msg
+}
pkg/transport/transport.go
@@ -0,0 +1,78 @@
+// Package transport provides transport layer abstractions for VNC connections.
+package transport
+
+import (
+	"context"
+	"crypto/tls"
+	"net/http"
+	"time"
+)
+
+// Transport represents a connection transport for RFB protocol messages.
+// Unlike TCP streams, this interface is message-oriented to support WebSocket
+// framing where each RFB message is a discrete WebSocket message.
+type Transport interface {
+	// Read reads a single RFB message from the transport.
+	// Returns the message data or an error.
+	Read(ctx context.Context) ([]byte, error)
+
+	// Write writes a single RFB message to the transport.
+	Write(ctx context.Context, data []byte) error
+
+	// Close closes the transport connection.
+	Close() error
+
+	// SetReadLimit sets the maximum message size for reads.
+	SetReadLimit(limit int64)
+}
+
+// DialOptions configures transport connection establishment.
+type DialOptions struct {
+	// Headers to send with the connection request (for WebSocket).
+	Headers http.Header
+
+	// Cookies to send with the connection request.
+	Cookies []*http.Cookie
+
+	// Proxy URL for upstream proxy (http, https, socks5).
+	Proxy string
+
+	// Timeout for connection establishment.
+	Timeout time.Duration
+
+	// TLSConfig for TLS connections.
+	TLSConfig *tls.Config
+
+	// UserAgent to send with the connection request.
+	UserAgent string
+
+	// Subprotocols for WebSocket negotiation.
+	Subprotocols []string
+}
+
+// Dialer creates new transport connections.
+type Dialer interface {
+	// Dial establishes a new transport connection.
+	Dial(ctx context.Context, target string, opts *DialOptions) (Transport, error)
+}
+
+// NewDialOptions creates DialOptions with sensible defaults.
+func NewDialOptions() *DialOptions {
+	return &DialOptions{
+		Headers: make(http.Header),
+		Timeout: 30 * time.Second,
+	}
+}
+
+// AddHeader adds a header to the dial options.
+func (o *DialOptions) AddHeader(key, value string) {
+	if o.Headers == nil {
+		o.Headers = make(http.Header)
+	}
+	o.Headers.Add(key, value)
+}
+
+// SetCookie sets a cookie string (parsed as Cookie header).
+func (o *DialOptions) SetCookie(cookie string) {
+	o.AddHeader("Cookie", cookie)
+}
pkg/transport/websocket.go
@@ -0,0 +1,107 @@
+package transport
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"net/url"
+
+	"github.com/coder/websocket"
+)
+
+// WebSocketTransport implements Transport over WebSocket connections.
+// This is used for noVNC-style VNC connections where each RFB message
+// is sent as a discrete WebSocket binary message.
+type WebSocketTransport struct {
+	conn *websocket.Conn
+}
+
+// WebSocketDialer implements Dialer for WebSocket connections.
+type WebSocketDialer struct{}
+
+// NewWebSocketDialer creates a new WebSocket dialer.
+func NewWebSocketDialer() *WebSocketDialer {
+	return &WebSocketDialer{}
+}
+
+// Dial establishes a WebSocket connection to the target URL.
+func (d *WebSocketDialer) Dial(ctx context.Context, target string, opts *DialOptions) (Transport, error) {
+	wsURL, err := url.Parse(target)
+	if err != nil {
+		return nil, fmt.Errorf("parsing url=%q: %w", target, err)
+	}
+
+	dialOpts := &websocket.DialOptions{}
+
+	if opts != nil {
+		// Build HTTP headers
+		if opts.Headers != nil {
+			dialOpts.HTTPHeader = opts.Headers.Clone()
+		} else {
+			dialOpts.HTTPHeader = make(http.Header)
+		}
+
+		// Add User-Agent if specified
+		if opts.UserAgent != "" {
+			dialOpts.HTTPHeader.Set("User-Agent", opts.UserAgent)
+		}
+
+		// Add subprotocols if specified
+		if len(opts.Subprotocols) > 0 {
+			dialOpts.Subprotocols = opts.Subprotocols
+		}
+
+		// TODO: Support proxy via custom HTTP client
+		// TODO: Support custom TLS config
+	}
+
+	// Apply timeout to context if specified
+	if opts != nil && opts.Timeout > 0 {
+		var cancel context.CancelFunc
+		ctx, cancel = context.WithTimeout(ctx, opts.Timeout)
+		defer cancel()
+	}
+
+	conn, _, err := websocket.Dial(ctx, wsURL.String(), dialOpts)
+	if err != nil {
+		return nil, fmt.Errorf("connecting to url=%q: %w", wsURL.String(), err)
+	}
+
+	return &WebSocketTransport{conn: conn}, nil
+}
+
+// Read reads a single message from the WebSocket connection.
+func (t *WebSocketTransport) Read(ctx context.Context) ([]byte, error) {
+	_, data, err := t.conn.Read(ctx)
+	if err != nil {
+		return nil, err
+	}
+	return data, nil
+}
+
+// Write writes a single message to the WebSocket connection.
+func (t *WebSocketTransport) Write(ctx context.Context, data []byte) error {
+	return t.conn.Write(ctx, websocket.MessageBinary, data)
+}
+
+// Close closes the WebSocket connection.
+func (t *WebSocketTransport) Close() error {
+	return t.conn.CloseNow()
+}
+
+// SetReadLimit sets the maximum message size for reads.
+func (t *WebSocketTransport) SetReadLimit(limit int64) {
+	t.conn.SetReadLimit(limit)
+}
+
+// Conn returns the underlying WebSocket connection.
+// This is useful for advanced operations not exposed by the Transport interface.
+func (t *WebSocketTransport) Conn() *websocket.Conn {
+	return t.conn
+}
+
+// Compile-time interface checks
+var (
+	_ Transport = (*WebSocketTransport)(nil)
+	_ Dialer    = (*WebSocketDialer)(nil)
+)
pkg/web/noVNC/app/images/icons/Makefile
@@ -0,0 +1,42 @@
+BROWSER_SIZES := 16 24 32 48 64
+#ANDROID_SIZES := 72 96 144 192
+# FIXME: The ICO is limited to 8 icons due to a Chrome bug:
+#        https://bugs.chromium.org/p/chromium/issues/detail?id=1381393
+ANDROID_SIZES := 96 144 192
+WEB_ICON_SIZES := $(BROWSER_SIZES) $(ANDROID_SIZES)
+
+#IOS_1X_SIZES := 20 29 40 76 # No such devices exist anymore
+IOS_2X_SIZES := 40 58 80 120 152 167
+IOS_3X_SIZES := 60 87 120 180
+ALL_IOS_SIZES := $(IOS_1X_SIZES) $(IOS_2X_SIZES) $(IOS_3X_SIZES)
+
+ALL_ICONS := \
+	$(ALL_IOS_SIZES:%=novnc-ios-%.png) \
+	novnc.ico
+
+all: $(ALL_ICONS)
+
+# Our testing shows that the ICO file need to be sorted in largest to
+# smallest to get the apporpriate behviour
+WEB_ICON_SIZES_REVERSE := $(shell echo $(WEB_ICON_SIZES) | tr ' ' '\n' | sort -nr | tr '\n' ' ')
+WEB_BASE_ICONS := $(WEB_ICON_SIZES_REVERSE:%=novnc-%.png)
+.INTERMEDIATE: $(WEB_BASE_ICONS)
+
+novnc.ico: $(WEB_BASE_ICONS)
+	convert $(WEB_BASE_ICONS) "$@"
+
+# General conversion
+novnc-%.png: novnc-icon.svg
+	convert -depth 8 -background transparent \
+		-size $*x$* "$(lastword $^)" "$@"
+
+# iOS icons use their own SVG
+novnc-ios-%.png: novnc-ios-icon.svg
+	convert -depth 8 -background transparent \
+		-size $*x$* "$(lastword $^)" "$@"
+
+# The smallest sizes are generated using a different SVG
+novnc-16.png novnc-24.png novnc-32.png: novnc-icon-sm.svg
+
+clean:
+	rm -f *.png
pkg/web/noVNC/app/images/icons/novnc-icon-sm.svg
@@ -0,0 +1,163 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="16"
+   height="16"
+   viewBox="0 0 16 16"
+   id="svg2"
+   version="1.1"
+   inkscape:version="0.91 r13725"
+   sodipodi:docname="novnc-icon-sm.svg">
+  <defs
+     id="defs4" />
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageopacity="0.0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="45.254834"
+     inkscape:cx="9.722703"
+     inkscape:cy="5.5311896"
+     inkscape:document-units="px"
+     inkscape:current-layer="layer1"
+     showgrid="false"
+     units="px"
+     inkscape:object-nodes="true"
+     inkscape:snap-smooth-nodes="true"
+     inkscape:snap-midpoints="true"
+     inkscape:window-width="1920"
+     inkscape:window-height="1136"
+     inkscape:window-x="1920"
+     inkscape:window-y="27"
+     inkscape:window-maximized="1">
+    <inkscape:grid
+       type="xygrid"
+       id="grid4169" />
+  </sodipodi:namedview>
+  <metadata
+     id="metadata7">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(0,-1036.3621)">
+    <rect
+       style="opacity:1;fill:#494949;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
+       id="rect4167"
+       width="16"
+       height="15.999992"
+       x="0"
+       y="1036.3622"
+       ry="2.6666584" />
+    <path
+       style="opacity:1;fill:#313131;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
+       d="M 2.6666667,1036.3621 C 1.1893373,1036.3621 0,1037.5515 0,1039.0288 l 0,10.6666 c 0,1.4774 1.1893373,2.6667 2.6666667,2.6667 l 4,0 C 11.837333,1052.3621 16,1046.7128 16,1039.6955 l 0,-0.6667 c 0,-1.4773 -1.189337,-2.6667 -2.666667,-2.6667 l -10.6666663,0 z"
+       id="rect4173"
+       inkscape:connector-curvature="0" />
+    <g
+       id="g4381">
+      <g
+         transform="translate(0.25,0.25)"
+         style="fill:#000000;fill-opacity:1"
+         id="g4365">
+        <g
+           style="fill:#000000;fill-opacity:1"
+           id="g4367">
+          <path
+             inkscape:connector-curvature="0"
+             id="path4369"
+             style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+             d="m 4.3289754,1039.3621 c 0.1846149,0 0.3419956,0.071 0.4716623,0.2121 C 4.933546,1039.7121 5,1039.8793 5,1040.0759 l 0,3.2862 -1,0 0,-2.964 c 0,-0.024 -0.011592,-0.036 -0.034038,-0.036 l -1.931924,0 C 2.011349,1040.3621 2,1040.3741 2,1040.3981 l 0,2.964 -1,0 0,-4 z"
+             sodipodi:nodetypes="scsccsssscccs" />
+          <path
+             inkscape:connector-curvature="0"
+             id="path4371"
+             style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+             d="m 6.6710244,1039.3621 2.6579513,0 c 0.184775,0 0.3419957,0.071 0.471662,0.2121 C 9.933546,1039.7121 10,1039.8793 10,1040.0759 l 0,2.5724 c 0,0.1966 -0.066454,0.3655 -0.1993623,0.5069 -0.1296663,0.1379 -0.286887,0.2069 -0.471662,0.2069 l -2.6579513,0 c -0.184775,0 -0.3436164,-0.069 -0.4765247,-0.2069 C 6.0648334,1043.0138 6,1042.8449 6,1042.6483 l 0,-2.5724 c 0,-0.1966 0.064833,-0.3638 0.1944997,-0.5017 0.1329083,-0.1414 0.2917497,-0.2121 0.4765247,-0.2121 z m 2.2949386,1 -1.931926,0 C 7.011344,1040.3621 7,1040.3741 7,1040.3981 l 0,1.928 c 0,0.024 0.011347,0.036 0.034037,0.036 l 1.931926,0 c 0.02269,0 0.034037,-0.012 0.034037,-0.036 l 0,-1.928 c 0,-0.024 -0.011347,-0.036 -0.034037,-0.036 z"
+             sodipodi:nodetypes="sscsscsscsscssssssssss" />
+        </g>
+        <g
+           style="fill:#000000;fill-opacity:1"
+           id="g4373">
+          <path
+             inkscape:connector-curvature="0"
+             id="path4375"
+             style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+             d="m 3,1047.1121 1,-2.75 1,0 -1.5,4 -1,0 -1.5,-4 1,0 z"
+             sodipodi:nodetypes="cccccccc" />
+          <path
+             inkscape:connector-curvature="0"
+             id="path4377"
+             style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+             d="m 9,1046.8621 0,-2.5 1,0 0,4 -1,0 -2,-2.5 0,2.5 -1,0 0,-4 1,0 z"
+             sodipodi:nodetypes="ccccccccccc" />
+          <path
+             inkscape:connector-curvature="0"
+             id="path4379"
+             style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+             d="m 15,1045.3621 -2.96596,0 c -0.02269,0 -0.03404,0.012 -0.03404,0.036 l 0,1.928 c 0,0.024 0.01135,0.036 0.03404,0.036 l 2.96596,0 0,1 -3.324113,0 c -0.188017,0 -0.348479,-0.068 -0.481388,-0.2037 C 11.064833,1048.0192 11,1047.8511 11,1047.6542 l 0,-2.5842 c 0,-0.1969 0.06483,-0.3633 0.194499,-0.4991 0.132909,-0.1392 0.293371,-0.2088 0.481388,-0.2088 l 3.324113,0 z"
+             sodipodi:nodetypes="cssssccscsscscc" />
+        </g>
+      </g>
+      <g
+         id="g4356">
+        <g
+           id="g4347">
+          <path
+             sodipodi:nodetypes="scsccsssscccs"
+             d="m 4.3289754,1039.3621 c 0.1846149,0 0.3419956,0.071 0.4716623,0.2121 C 4.933546,1039.7121 5,1039.8793 5,1040.0759 l 0,3.2862 -1,0 0,-2.964 c 0,-0.024 -0.011592,-0.036 -0.034038,-0.036 l -1.931924,0 c -0.022689,0 -0.034038,0.012 -0.034038,0.036 l 0,2.964 -1,0 0,-4 z"
+             style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#008000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+             id="path4143"
+             inkscape:connector-curvature="0" />
+          <path
+             sodipodi:nodetypes="sscsscsscsscssssssssss"
+             d="m 6.6710244,1039.3621 2.6579513,0 c 0.184775,0 0.3419957,0.071 0.471662,0.2121 C 9.933546,1039.7121 10,1039.8793 10,1040.0759 l 0,2.5724 c 0,0.1966 -0.066454,0.3655 -0.1993623,0.5069 -0.1296663,0.1379 -0.286887,0.2069 -0.471662,0.2069 l -2.6579513,0 c -0.184775,0 -0.3436164,-0.069 -0.4765247,-0.2069 C 6.0648334,1043.0138 6,1042.8449 6,1042.6483 l 0,-2.5724 c 0,-0.1966 0.064833,-0.3638 0.1944997,-0.5017 0.1329083,-0.1414 0.2917497,-0.2121 0.4765247,-0.2121 z m 2.2949386,1 -1.931926,0 C 7.011344,1040.3621 7,1040.3741 7,1040.3981 l 0,1.928 c 0,0.024 0.011347,0.036 0.034037,0.036 l 1.931926,0 c 0.02269,0 0.034037,-0.012 0.034037,-0.036 l 0,-1.928 c 0,-0.024 -0.011347,-0.036 -0.034037,-0.036 z"
+             style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#008000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+             id="path4145"
+             inkscape:connector-curvature="0" />
+        </g>
+        <g
+           id="g4351">
+          <path
+             sodipodi:nodetypes="cccccccc"
+             d="m 3,1047.1121 1,-2.75 1,0 -1.5,4 -1,0 -1.5,-4 1,0 z"
+             style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#ffff00;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+             id="path4147"
+             inkscape:connector-curvature="0" />
+          <path
+             sodipodi:nodetypes="ccccccccccc"
+             d="m 9,1046.8621 0,-2.5 1,0 0,4 -1,0 -2,-2.5 0,2.5 -1,0 0,-4 1,0 z"
+             style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#ffff00;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+             id="path4149"
+             inkscape:connector-curvature="0" />
+          <path
+             sodipodi:nodetypes="cssssccscsscscc"
+             d="m 15,1045.3621 -2.96596,0 c -0.02269,0 -0.03404,0.012 -0.03404,0.036 l 0,1.928 c 0,0.024 0.01135,0.036 0.03404,0.036 l 2.96596,0 0,1 -3.324113,0 c -0.188017,0 -0.348479,-0.068 -0.481388,-0.2037 C 11.064833,1048.0192 11,1047.8511 11,1047.6542 l 0,-2.5842 c 0,-0.1969 0.06483,-0.3633 0.194499,-0.4991 0.132909,-0.1392 0.293371,-0.2088 0.481388,-0.2088 l 3.324113,0 z"
+             style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#ffff00;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+             id="path4151"
+             inkscape:connector-curvature="0" />
+        </g>
+      </g>
+    </g>
+  </g>
+</svg>
pkg/web/noVNC/app/images/icons/novnc-icon.svg
@@ -0,0 +1,163 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="48"
+   height="48"
+   viewBox="0 0 48 48.000001"
+   id="svg2"
+   version="1.1"
+   inkscape:version="0.91 r13725"
+   sodipodi:docname="novnc-icon.svg">
+  <defs
+     id="defs4" />
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageopacity="0.0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="11.313708"
+     inkscape:cx="27.187245"
+     inkscape:cy="17.700974"
+     inkscape:document-units="px"
+     inkscape:current-layer="layer1"
+     showgrid="false"
+     units="px"
+     inkscape:object-nodes="true"
+     inkscape:snap-smooth-nodes="true"
+     inkscape:snap-midpoints="true"
+     inkscape:window-width="1920"
+     inkscape:window-height="1136"
+     inkscape:window-x="1920"
+     inkscape:window-y="27"
+     inkscape:window-maximized="1">
+    <inkscape:grid
+       type="xygrid"
+       id="grid4169" />
+  </sodipodi:namedview>
+  <metadata
+     id="metadata7">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title />
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(0,-1004.3621)">
+    <rect
+       style="opacity:1;fill:#494949;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
+       id="rect4167"
+       width="48"
+       height="48"
+       x="0"
+       y="1004.3621"
+       ry="7.9999785" />
+    <path
+       style="opacity:1;fill:#313131;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
+       d="m 8,1004.3621 c -4.4319881,0 -8,3.568 -8,8 l 0,32 c 0,4.432 3.5680119,8 8,8 l 12,0 c 15.512,0 28,-16.948 28,-38 l 0,-2 c 0,-4.432 -3.568012,-8 -8,-8 l -32,0 z"
+       id="rect4173"
+       inkscape:connector-curvature="0" />
+    <g
+       id="g4300"
+       style="fill:#000000;fill-opacity:1;stroke:none"
+       transform="translate(0.5,0.5)">
+      <g
+         id="g4302"
+         style="fill:#000000;fill-opacity:1;stroke:none">
+        <path
+           sodipodi:nodetypes="scsccsssscccs"
+           d="m 11.986926,1016.3621 c 0.554325,0 1.025987,0.2121 1.414987,0.6362 0.398725,0.4138 0.600909,0.9155 0.598087,1.5052 l 0,6.8586 -2,0 0,-6.8914 c 0,-0.072 -0.03404,-0.1086 -0.102113,-0.1086 l -4.7957745,0 C 7.0340375,1018.3621 7,1018.3983 7,1018.4707 l 0,6.8914 -2,0 0,-9 z"
+           style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+           id="path4304"
+           inkscape:connector-curvature="0" />
+        <path
+           sodipodi:nodetypes="sscsscsscsscssssssssss"
+           d="m 17.013073,1016.3621 4.973854,0 c 0.554325,0 1.025987,0.2121 1.414986,0.6362 0.398725,0.4138 0.598087,0.9155 0.598087,1.5052 l 0,4.7172 c 0,0.5897 -0.199362,1.0966 -0.598087,1.5207 -0.388999,0.4138 -0.860661,0.6207 -1.414986,0.6207 l -4.973854,0 c -0.554325,0 -1.030849,-0.2069 -1.429574,-0.6207 C 15.1945,1024.3173 15,1023.8104 15,1023.2207 l 0,-4.7172 c 0,-0.5897 0.1945,-1.0914 0.583499,-1.5052 0.398725,-0.4241 0.875249,-0.6362 1.429574,-0.6362 z m 4.884815,2 -4.795776,0 c -0.06808,0 -0.102112,0.036 -0.102112,0.1086 l 0,4.7828 c 0,0.072 0.03404,0.1086 0.102112,0.1086 l 4.795776,0 c 0.06807,0 0.102112,-0.036 0.102112,-0.1086 l 0,-4.7828 c 0,-0.072 -0.03404,-0.1086 -0.102112,-0.1086 z"
+           style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+           id="path4306"
+           inkscape:connector-curvature="0" />
+      </g>
+      <g
+         id="g4308"
+         style="fill:#000000;fill-opacity:1;stroke:none">
+        <path
+           sodipodi:nodetypes="cccccccc"
+           d="m 12,1036.9177 4.768114,-8.5556 2.231886,0 -6,11 -2,0 -6,-11 2.2318854,0 z"
+           style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+           id="path4310"
+           inkscape:connector-curvature="0" />
+        <path
+           sodipodi:nodetypes="ccccccccccc"
+           d="m 29,1036.3621 0,-8 2,0 0,11 -2,0 -7,-8 0,8 -2,0 0,-11 2,0 z"
+           style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+           id="path4312"
+           inkscape:connector-curvature="0" />
+        <path
+           sodipodi:nodetypes="cssssccscsscscc"
+           d="m 43,1030.3621 -8.897887,0 c -0.06808,0 -0.102113,0.036 -0.102113,0.1069 l 0,6.7862 c 0,0.071 0.03404,0.1069 0.102113,0.1069 l 8.897887,0 0,2 -8.972339,0 c -0.56405,0 -1.045437,-0.2037 -1.444162,-0.6111 C 32.1945,1038.3334 32,1037.8292 32,1037.2385 l 0,-6.7528 c 0,-0.5907 0.1945,-1.0898 0.583499,-1.4972 0.398725,-0.4176 0.880112,-0.6264 1.444162,-0.6264 l 8.972339,0 z"
+           style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+           id="path4314"
+           inkscape:connector-curvature="0" />
+      </g>
+    </g>
+    <g
+       id="g4291"
+       style="stroke:none">
+      <g
+         id="g4282"
+         style="stroke:none">
+        <path
+           inkscape:connector-curvature="0"
+           id="path4143"
+           style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#008000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+           d="m 11.986926,1016.3621 c 0.554325,0 1.025987,0.2121 1.414987,0.6362 0.398725,0.4138 0.600909,0.9155 0.598087,1.5052 l 0,6.8586 -2,0 0,-6.8914 c 0,-0.072 -0.03404,-0.1086 -0.102113,-0.1086 l -4.7957745,0 C 7.0340375,1018.3621 7,1018.3983 7,1018.4707 l 0,6.8914 -2,0 0,-9 z"
+           sodipodi:nodetypes="scsccsssscccs" />
+        <path
+           inkscape:connector-curvature="0"
+           id="path4145"
+           style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#008000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+           d="m 17.013073,1016.3621 4.973854,0 c 0.554325,0 1.025987,0.2121 1.414986,0.6362 0.398725,0.4138 0.598087,0.9155 0.598087,1.5052 l 0,4.7172 c 0,0.5897 -0.199362,1.0966 -0.598087,1.5207 -0.388999,0.4138 -0.860661,0.6207 -1.414986,0.6207 l -4.973854,0 c -0.554325,0 -1.030849,-0.2069 -1.429574,-0.6207 C 15.1945,1024.3173 15,1023.8104 15,1023.2207 l 0,-4.7172 c 0,-0.5897 0.1945,-1.0914 0.583499,-1.5052 0.398725,-0.4241 0.875249,-0.6362 1.429574,-0.6362 z m 4.884815,2 -4.795776,0 c -0.06808,0 -0.102112,0.036 -0.102112,0.1086 l 0,4.7828 c 0,0.072 0.03404,0.1086 0.102112,0.1086 l 4.795776,0 c 0.06807,0 0.102112,-0.036 0.102112,-0.1086 l 0,-4.7828 c 0,-0.072 -0.03404,-0.1086 -0.102112,-0.1086 z"
+           sodipodi:nodetypes="sscsscsscsscssssssssss" />
+      </g>
+      <g
+         id="g4286"
+         style="stroke:none">
+        <path
+           inkscape:connector-curvature="0"
+           id="path4147"
+           style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#ffff00;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+           d="m 12,1036.9177 4.768114,-8.5556 2.231886,0 -6,11 -2,0 -6,-11 2.2318854,0 z"
+           sodipodi:nodetypes="cccccccc" />
+        <path
+           inkscape:connector-curvature="0"
+           id="path4149"
+           style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#ffff00;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+           d="m 29,1036.3621 0,-8 2,0 0,11 -2,0 -7,-8 0,8 -2,0 0,-11 2,0 z"
+           sodipodi:nodetypes="ccccccccccc" />
+        <path
+           inkscape:connector-curvature="0"
+           id="path4151"
+           style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#ffff00;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+           d="m 43,1030.3621 -8.897887,0 c -0.06808,0 -0.102113,0.036 -0.102113,0.1069 l 0,6.7862 c 0,0.071 0.03404,0.1069 0.102113,0.1069 l 8.897887,0 0,2 -8.972339,0 c -0.56405,0 -1.045437,-0.2037 -1.444162,-0.6111 C 32.1945,1038.3334 32,1037.8292 32,1037.2385 l 0,-6.7528 c 0,-0.5907 0.1945,-1.0898 0.583499,-1.4972 0.398725,-0.4176 0.880112,-0.6264 1.444162,-0.6264 l 8.972339,0 z"
+           sodipodi:nodetypes="cssssccscsscscc" />
+      </g>
+    </g>
+  </g>
+</svg>
pkg/web/noVNC/app/images/icons/novnc-ios-120.png
Binary file
pkg/web/noVNC/app/images/icons/novnc-ios-152.png
Binary file
pkg/web/noVNC/app/images/icons/novnc-ios-167.png
Binary file
pkg/web/noVNC/app/images/icons/novnc-ios-180.png
Binary file
pkg/web/noVNC/app/images/icons/novnc-ios-40.png
Binary file
pkg/web/noVNC/app/images/icons/novnc-ios-58.png
Binary file
pkg/web/noVNC/app/images/icons/novnc-ios-60.png
Binary file
pkg/web/noVNC/app/images/icons/novnc-ios-80.png
Binary file
pkg/web/noVNC/app/images/icons/novnc-ios-87.png
Binary file
pkg/web/noVNC/app/images/icons/novnc-ios-icon.svg
@@ -0,0 +1,183 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   width="48"
+   height="48"
+   viewBox="0 0 48 48.000001"
+   id="svg2"
+   version="1.1"
+   inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
+   sodipodi:docname="novnc-ios-icon.svg"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:dc="http://purl.org/dc/elements/1.1/">
+  <defs
+     id="defs4" />
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageopacity="0.0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="11.313708"
+     inkscape:cx="27.356195"
+     inkscape:cy="17.810253"
+     inkscape:document-units="px"
+     inkscape:current-layer="layer1"
+     showgrid="false"
+     units="px"
+     inkscape:object-nodes="true"
+     inkscape:snap-smooth-nodes="true"
+     inkscape:snap-midpoints="true"
+     inkscape:window-width="2560"
+     inkscape:window-height="1371"
+     inkscape:window-x="0"
+     inkscape:window-y="0"
+     inkscape:window-maximized="1"
+     inkscape:showpageshadow="2"
+     inkscape:pagecheckerboard="0"
+     inkscape:deskcolor="#d1d1d1">
+    <inkscape:grid
+       type="xygrid"
+       id="grid4169" />
+  </sodipodi:namedview>
+  <metadata
+     id="metadata7">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(0,-1004.3621)">
+    <rect
+       style="opacity:1;fill:#494949;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
+       id="rect4167"
+       width="48"
+       height="48"
+       x="0"
+       y="1004.3621"
+       inkscape:label="background" />
+    <path
+       style="opacity:1;fill:#313131;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
+       d="m 0,1004.3621 v 48 h 20 c 15.512,0 28,-16.948 28,-38 v -10 z"
+       id="rect4173"
+       inkscape:connector-curvature="0"
+       sodipodi:nodetypes="cccccc"
+       inkscape:label="darker_grey_plate" />
+    <g
+       id="g4300"
+       style="display:inline;fill:#000000;fill-opacity:1;stroke:none"
+       transform="translate(0.5,0.5)"
+       inkscape:label="shadows">
+      <g
+         id="g4302"
+         style="fill:#000000;fill-opacity:1;stroke:none"
+         inkscape:label="no">
+        <path
+           sodipodi:nodetypes="scsccsssscccs"
+           d="m 11.986926,1016.3621 c 0.554325,0 1.025987,0.2121 1.414987,0.6362 0.398725,0.4138 0.600909,0.9155 0.598087,1.5052 v 6.8586 h -2 v -6.8914 c 0,-0.072 -0.03404,-0.1086 -0.102113,-0.1086 H 7.1021125 C 7.0340375,1018.3621 7,1018.3983 7,1018.4707 v 6.8914 H 5 v -9 z"
+           style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+           id="path4304"
+           inkscape:connector-curvature="0"
+           inkscape:label="n" />
+        <path
+           sodipodi:nodetypes="sscsscsscsscssssssssss"
+           d="m 17.013073,1016.3621 h 4.973854 c 0.554325,0 1.025987,0.2121 1.414986,0.6362 0.398725,0.4138 0.598087,0.9155 0.598087,1.5052 v 4.7172 c 0,0.5897 -0.199362,1.0966 -0.598087,1.5207 -0.388999,0.4138 -0.860661,0.6207 -1.414986,0.6207 h -4.973854 c -0.554325,0 -1.030849,-0.2069 -1.429574,-0.6207 C 15.1945,1024.3173 15,1023.8104 15,1023.2207 v -4.7172 c 0,-0.5897 0.1945,-1.0914 0.583499,-1.5052 0.398725,-0.4241 0.875249,-0.6362 1.429574,-0.6362 z m 4.884815,2 h -4.795776 c -0.06808,0 -0.102112,0.036 -0.102112,0.1086 v 4.7828 c 0,0.072 0.03404,0.1086 0.102112,0.1086 h 4.795776 c 0.06807,0 0.102112,-0.036 0.102112,-0.1086 v -4.7828 c 0,-0.072 -0.03404,-0.1086 -0.102112,-0.1086 z"
+           style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+           id="path4306"
+           inkscape:connector-curvature="0"
+           inkscape:label="o" />
+      </g>
+      <g
+         id="g4308"
+         style="fill:#000000;fill-opacity:1;stroke:none"
+         inkscape:label="VNC">
+        <path
+           sodipodi:nodetypes="cccccccc"
+           d="m 12,1036.9177 4.768114,-8.5556 H 19 l -6,11 h -2 l -6,-11 h 2.2318854 z"
+           style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+           id="path4310"
+           inkscape:connector-curvature="0"
+           inkscape:label="V" />
+        <path
+           sodipodi:nodetypes="ccccccccccc"
+           d="m 29,1036.3621 v -8 h 2 v 11 h -2 l -7,-8 v 8 h -2 v -11 h 2 z"
+           style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+           id="path4312"
+           inkscape:connector-curvature="0"
+           inkscape:label="N" />
+        <path
+           sodipodi:nodetypes="cssssccscsscscc"
+           d="m 43,1030.3621 h -8.897887 c -0.06808,0 -0.102113,0.036 -0.102113,0.1069 v 6.7862 c 0,0.071 0.03404,0.1069 0.102113,0.1069 H 43 v 2 h -8.972339 c -0.56405,0 -1.045437,-0.2037 -1.444162,-0.6111 C 32.1945,1038.3334 32,1037.8292 32,1037.2385 v -6.7528 c 0,-0.5907 0.1945,-1.0898 0.583499,-1.4972 0.398725,-0.4176 0.880112,-0.6264 1.444162,-0.6264 H 43 Z"
+           style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+           id="path4314"
+           inkscape:connector-curvature="0"
+           inkscape:label="C" />
+      </g>
+    </g>
+    <g
+       id="g4291"
+       style="stroke:none"
+       inkscape:label="noVNC">
+      <g
+         id="g4282"
+         style="stroke:none"
+         inkscape:label="no">
+        <path
+           inkscape:connector-curvature="0"
+           id="path4143"
+           style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#008000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+           d="m 11.986926,1016.3621 c 0.554325,0 1.025987,0.2121 1.414987,0.6362 0.398725,0.4138 0.600909,0.9155 0.598087,1.5052 l 0,6.8586 -2,0 0,-6.8914 c 0,-0.072 -0.03404,-0.1086 -0.102113,-0.1086 l -4.7957745,0 C 7.0340375,1018.3621 7,1018.3983 7,1018.4707 l 0,6.8914 -2,0 0,-9 z"
+           sodipodi:nodetypes="scsccsssscccs"
+           inkscape:label="n" />
+        <path
+           inkscape:connector-curvature="0"
+           id="path4145"
+           style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#008000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+           d="m 17.013073,1016.3621 4.973854,0 c 0.554325,0 1.025987,0.2121 1.414986,0.6362 0.398725,0.4138 0.598087,0.9155 0.598087,1.5052 l 0,4.7172 c 0,0.5897 -0.199362,1.0966 -0.598087,1.5207 -0.388999,0.4138 -0.860661,0.6207 -1.414986,0.6207 l -4.973854,0 c -0.554325,0 -1.030849,-0.2069 -1.429574,-0.6207 C 15.1945,1024.3173 15,1023.8104 15,1023.2207 l 0,-4.7172 c 0,-0.5897 0.1945,-1.0914 0.583499,-1.5052 0.398725,-0.4241 0.875249,-0.6362 1.429574,-0.6362 z m 4.884815,2 -4.795776,0 c -0.06808,0 -0.102112,0.036 -0.102112,0.1086 l 0,4.7828 c 0,0.072 0.03404,0.1086 0.102112,0.1086 l 4.795776,0 c 0.06807,0 0.102112,-0.036 0.102112,-0.1086 l 0,-4.7828 c 0,-0.072 -0.03404,-0.1086 -0.102112,-0.1086 z"
+           sodipodi:nodetypes="sscsscsscsscssssssssss"
+           inkscape:label="o" />
+      </g>
+      <g
+         id="g4286"
+         style="stroke:none"
+         inkscape:label="VNC">
+        <path
+           inkscape:connector-curvature="0"
+           id="path4147"
+           style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#ffff00;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+           d="m 12,1036.9177 4.768114,-8.5556 2.231886,0 -6,11 -2,0 -6,-11 2.2318854,0 z"
+           sodipodi:nodetypes="cccccccc"
+           inkscape:label="V" />
+        <path
+           inkscape:connector-curvature="0"
+           id="path4149"
+           style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#ffff00;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+           d="m 29,1036.3621 0,-8 2,0 0,11 -2,0 -7,-8 0,8 -2,0 0,-11 2,0 z"
+           sodipodi:nodetypes="ccccccccccc"
+           inkscape:label="N" />
+        <path
+           inkscape:connector-curvature="0"
+           id="path4151"
+           style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#ffff00;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+           d="m 43,1030.3621 -8.897887,0 c -0.06808,0 -0.102113,0.036 -0.102113,0.1069 l 0,6.7862 c 0,0.071 0.03404,0.1069 0.102113,0.1069 l 8.897887,0 0,2 -8.972339,0 c -0.56405,0 -1.045437,-0.2037 -1.444162,-0.6111 C 32.1945,1038.3334 32,1037.8292 32,1037.2385 l 0,-6.7528 c 0,-0.5907 0.1945,-1.0898 0.583499,-1.4972 0.398725,-0.4176 0.880112,-0.6264 1.444162,-0.6264 l 8.972339,0 z"
+           sodipodi:nodetypes="cssssccscsscscc"
+           inkscape:label="C" />
+      </g>
+    </g>
+  </g>
+</svg>
pkg/web/noVNC/app/images/icons/novnc.ico
Binary file
pkg/web/noVNC/app/images/alt.svg
@@ -0,0 +1,92 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="25"
+   height="25"
+   viewBox="0 0 25 25"
+   id="svg2"
+   version="1.1"
+   inkscape:version="0.91 r13725"
+   sodipodi:docname="alt.svg"
+   inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
+   inkscape:export-xdpi="90"
+   inkscape:export-ydpi="90">
+  <defs
+     id="defs4" />
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#959595"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageopacity="0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="16"
+     inkscape:cx="18.205425"
+     inkscape:cy="17.531398"
+     inkscape:document-units="px"
+     inkscape:current-layer="layer1"
+     showgrid="false"
+     units="px"
+     inkscape:snap-bbox="true"
+     inkscape:bbox-paths="true"
+     inkscape:bbox-nodes="true"
+     inkscape:snap-bbox-edge-midpoints="true"
+     inkscape:object-paths="true"
+     showguides="true"
+     inkscape:window-width="1920"
+     inkscape:window-height="1136"
+     inkscape:window-x="1920"
+     inkscape:window-y="27"
+     inkscape:window-maximized="1"
+     inkscape:snap-smooth-nodes="true"
+     inkscape:object-nodes="true"
+     inkscape:snap-intersection-paths="true"
+     inkscape:snap-nodes="true"
+     inkscape:snap-global="true">
+    <inkscape:grid
+       type="xygrid"
+       id="grid4136" />
+  </sodipodi:namedview>
+  <metadata
+     id="metadata7">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(0,-1027.3622)">
+    <g
+       style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:48px;line-height:125%;font-family:'DejaVu Sans';-inkscape-font-specification:'Sans Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+       id="text5290">
+      <path
+         d="m 9.9560547,1042.3329 -2.9394531,0 -0.4638672,1.3281 -1.8896485,0 2.7001953,-7.29 2.241211,0 2.7001958,7.29 -1.889649,0 -0.4589843,-1.3281 z m -2.4707031,-1.3526 1.9970703,0 -0.9960938,-2.9003 -1.0009765,2.9003 z"
+         style="font-size:10px;fill:#ffffff;fill-opacity:1"
+         id="path5340" />
+      <path
+         d="m 13.188477,1036.0634 1.748046,0 0,7.5976 -1.748046,0 0,-7.5976 z"
+         style="font-size:10px;fill:#ffffff;fill-opacity:1"
+         id="path5342" />
+      <path
+         d="m 18.535156,1036.6395 0,1.5528 1.801758,0 0,1.25 -1.801758,0 0,2.3193 q 0,0.3809 0.151367,0.5176 0.151368,0.1318 0.600586,0.1318 l 0.898438,0 0,1.25 -1.499024,0 q -1.035156,0 -1.469726,-0.4297 -0.429688,-0.4345 -0.429688,-1.4697 l 0,-2.3193 -0.86914,0 0,-1.25 0.86914,0 0,-1.5528 1.748047,0 z"
+         style="font-size:10px;fill:#ffffff;fill-opacity:1"
+         id="path5344" />
+    </g>
+  </g>
+</svg>
pkg/web/noVNC/app/images/clipboard.svg
@@ -0,0 +1,106 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="25"
+   height="25"
+   viewBox="0 0 25 25"
+   id="svg2"
+   version="1.1"
+   inkscape:version="0.91 r13725"
+   sodipodi:docname="clipboard.svg"
+   inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
+   inkscape:export-xdpi="90"
+   inkscape:export-ydpi="90">
+  <defs
+     id="defs4" />
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#959595"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageopacity="0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="1"
+     inkscape:cx="15.366606"
+     inkscape:cy="16.42981"
+     inkscape:document-units="px"
+     inkscape:current-layer="layer1"
+     showgrid="false"
+     units="px"
+     inkscape:snap-bbox="true"
+     inkscape:bbox-paths="true"
+     inkscape:bbox-nodes="true"
+     inkscape:snap-bbox-edge-midpoints="true"
+     inkscape:object-paths="true"
+     showguides="true"
+     inkscape:window-width="1920"
+     inkscape:window-height="1136"
+     inkscape:window-x="1920"
+     inkscape:window-y="27"
+     inkscape:window-maximized="1"
+     inkscape:snap-smooth-nodes="true"
+     inkscape:object-nodes="true"
+     inkscape:snap-intersection-paths="true"
+     inkscape:snap-nodes="true"
+     inkscape:snap-global="true">
+    <inkscape:grid
+       type="xygrid"
+       id="grid4136" />
+  </sodipodi:namedview>
+  <metadata
+     id="metadata7">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(0,-1027.3622)">
+    <path
+       style="opacity:1;fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
+       d="M 9,6 6,6 C 5.4459889,6 5,6.4459889 5,7 l 0,13 c 0,0.554011 0.4459889,1 1,1 l 13,0 c 0.554011,0 1,-0.445989 1,-1 L 20,7 C 20,6.4459889 19.554011,6 19,6 l -3,0"
+       transform="translate(0,1027.3622)"
+       id="rect6083"
+       inkscape:connector-curvature="0"
+       sodipodi:nodetypes="cssssssssc" />
+    <rect
+       style="opacity:1;fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
+       id="rect6085"
+       width="7"
+       height="4"
+       x="9"
+       y="1031.3622"
+       ry="1.00002" />
+    <path
+       style="fill:none;fill-rule:evenodd;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.50196081"
+       d="m 8.5071212,1038.8622 7.9999998,0"
+       id="path6087"
+       inkscape:connector-curvature="0" />
+    <path
+       style="fill:none;fill-rule:evenodd;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.50196081"
+       d="m 8.5071212,1041.8622 3.9999998,0"
+       id="path6089"
+       inkscape:connector-curvature="0" />
+    <path
+       style="fill:none;fill-rule:evenodd;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.50196081"
+       d="m 8.5071212,1044.8622 5.9999998,0"
+       id="path6091"
+       inkscape:connector-curvature="0" />
+  </g>
+</svg>
pkg/web/noVNC/app/images/connect.svg
@@ -0,0 +1,96 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="25"
+   height="25"
+   viewBox="0 0 25 25"
+   id="svg2"
+   version="1.1"
+   inkscape:version="0.91 r13725"
+   sodipodi:docname="connect.svg"
+   inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
+   inkscape:export-xdpi="90"
+   inkscape:export-ydpi="90">
+  <defs
+     id="defs4" />
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#959595"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageopacity="0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="1"
+     inkscape:cx="37.14834"
+     inkscape:cy="1.9525926"
+     inkscape:document-units="px"
+     inkscape:current-layer="layer1"
+     showgrid="false"
+     units="px"
+     inkscape:snap-bbox="true"
+     inkscape:bbox-paths="true"
+     inkscape:bbox-nodes="true"
+     inkscape:snap-bbox-edge-midpoints="true"
+     inkscape:object-paths="true"
+     showguides="true"
+     inkscape:window-width="1920"
+     inkscape:window-height="1136"
+     inkscape:window-x="1920"
+     inkscape:window-y="27"
+     inkscape:window-maximized="1"
+     inkscape:snap-smooth-nodes="true"
+     inkscape:object-nodes="true"
+     inkscape:snap-intersection-paths="true"
+     inkscape:snap-nodes="true">
+    <inkscape:grid
+       type="xygrid"
+       id="grid4136" />
+  </sodipodi:namedview>
+  <metadata
+     id="metadata7">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(0,-1027.3622)">
+    <g
+       id="g5103"
+       transform="matrix(0.70710678,-0.70710678,0.70710678,0.70710678,-729.15757,315.8823)">
+      <path
+         sodipodi:nodetypes="cssssc"
+         inkscape:connector-curvature="0"
+         id="rect5096"
+         d="m 11,1040.3622 -5,0 c -1.108,0 -2,-0.892 -2,-2 l 0,-4 c 0,-1.108 0.892,-2 2,-2 l 5,0"
+         style="opacity:1;fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
+      <path
+         style="opacity:1;fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
+         d="m 14,1032.3622 5,0 c 1.108,0 2,0.892 2,2 l 0,4 c 0,1.108 -0.892,2 -2,2 l -5,0"
+         id="path5099"
+         inkscape:connector-curvature="0"
+         sodipodi:nodetypes="cssssc" />
+      <path
+         inkscape:connector-curvature="0"
+         id="path5101"
+         d="m 9,1036.3622 7,0"
+         style="fill:none;fill-rule:evenodd;stroke:#ffffff;stroke-width:2;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
+    </g>
+  </g>
+</svg>
pkg/web/noVNC/app/images/ctrl.svg
@@ -0,0 +1,96 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="25"
+   height="25"
+   viewBox="0 0 25 25"
+   id="svg2"
+   version="1.1"
+   inkscape:version="0.91 r13725"
+   sodipodi:docname="ctrl.svg"
+   inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
+   inkscape:export-xdpi="90"
+   inkscape:export-ydpi="90">
+  <defs
+     id="defs4" />
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#959595"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageopacity="0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="16"
+     inkscape:cx="18.205425"
+     inkscape:cy="17.531398"
+     inkscape:document-units="px"
+     inkscape:current-layer="layer1"
+     showgrid="false"
+     units="px"
+     inkscape:snap-bbox="true"
+     inkscape:bbox-paths="true"
+     inkscape:bbox-nodes="true"
+     inkscape:snap-bbox-edge-midpoints="true"
+     inkscape:object-paths="true"
+     showguides="true"
+     inkscape:window-width="1920"
+     inkscape:window-height="1136"
+     inkscape:window-x="1920"
+     inkscape:window-y="27"
+     inkscape:window-maximized="1"
+     inkscape:snap-smooth-nodes="true"
+     inkscape:object-nodes="true"
+     inkscape:snap-intersection-paths="true"
+     inkscape:snap-nodes="true"
+     inkscape:snap-global="true">
+    <inkscape:grid
+       type="xygrid"
+       id="grid4136" />
+  </sodipodi:namedview>
+  <metadata
+     id="metadata7">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(0,-1027.3622)">
+    <g
+       style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:48px;line-height:125%;font-family:'DejaVu Sans';-inkscape-font-specification:'Sans Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+       id="text5290">
+      <path
+         d="m 9.1210938,1043.1898 q -0.5175782,0.2686 -1.0791016,0.4053 -0.5615235,0.1367 -1.171875,0.1367 -1.8212891,0 -2.8857422,-1.0156 -1.0644531,-1.0205 -1.0644531,-2.7637 0,-1.748 1.0644531,-2.7637 1.0644531,-1.0205 2.8857422,-1.0205 0.6103515,0 1.171875,0.1368 0.5615234,0.1367 1.0791016,0.4052 l 0,1.5088 q -0.522461,-0.3564 -1.0302735,-0.5224 -0.5078125,-0.1661 -1.0693359,-0.1661 -1.0058594,0 -1.5820313,0.6446 -0.5761719,0.6445 -0.5761719,1.7773 0,1.1279 0.5761719,1.7725 0.5761719,0.6445 1.5820313,0.6445 0.5615234,0 1.0693359,-0.166 0.5078125,-0.166 1.0302735,-0.5225 l 0,1.5088 z"
+         style="font-size:10px;fill:#ffffff;fill-opacity:1"
+         id="path5370" />
+      <path
+         d="m 12.514648,1036.5687 0,1.5528 1.801758,0 0,1.25 -1.801758,0 0,2.3193 q 0,0.3809 0.151368,0.5176 0.151367,0.1318 0.600586,0.1318 l 0.898437,0 0,1.25 -1.499023,0 q -1.035157,0 -1.469727,-0.4297 -0.429687,-0.4345 -0.429687,-1.4697 l 0,-2.3193 -0.8691411,0 0,-1.25 0.8691411,0 0,-1.5528 1.748046,0 z"
+         style="font-size:10px;fill:#ffffff;fill-opacity:1"
+         id="path5372" />
+      <path
+         d="m 19.453125,1039.6107 q -0.229492,-0.1074 -0.458984,-0.1562 -0.22461,-0.054 -0.454102,-0.054 -0.673828,0 -1.040039,0.4345 -0.361328,0.4297 -0.361328,1.2354 l 0,2.5195 -1.748047,0 0,-5.4687 1.748047,0 0,0.8984 q 0.336914,-0.5371 0.771484,-0.7813 0.439453,-0.249 1.049805,-0.249 0.08789,0 0.19043,0.01 0.102539,0 0.297851,0.029 l 0.0049,1.582 z"
+         style="font-size:10px;fill:#ffffff;fill-opacity:1"
+         id="path5374" />
+      <path
+         d="m 20.332031,1035.9926 1.748047,0 0,7.5976 -1.748047,0 0,-7.5976 z"
+         style="font-size:10px;fill:#ffffff;fill-opacity:1"
+         id="path5376" />
+    </g>
+  </g>
+</svg>
pkg/web/noVNC/app/images/ctrlaltdel.svg
@@ -0,0 +1,100 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="25"
+   height="25"
+   viewBox="0 0 25 25"
+   id="svg2"
+   version="1.1"
+   inkscape:version="0.91 r13725"
+   sodipodi:docname="ctrlaltdel.svg"
+   inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
+   inkscape:export-xdpi="90"
+   inkscape:export-ydpi="90">
+  <defs
+     id="defs4" />
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#959595"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageopacity="0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="8"
+     inkscape:cx="11.135667"
+     inkscape:cy="16.407428"
+     inkscape:document-units="px"
+     inkscape:current-layer="layer1"
+     showgrid="false"
+     units="px"
+     inkscape:snap-bbox="true"
+     inkscape:bbox-paths="true"
+     inkscape:bbox-nodes="true"
+     inkscape:snap-bbox-edge-midpoints="true"
+     inkscape:object-paths="true"
+     showguides="true"
+     inkscape:window-width="1920"
+     inkscape:window-height="1136"
+     inkscape:window-x="1920"
+     inkscape:window-y="27"
+     inkscape:window-maximized="1"
+     inkscape:snap-smooth-nodes="true"
+     inkscape:object-nodes="true"
+     inkscape:snap-intersection-paths="true"
+     inkscape:snap-nodes="true"
+     inkscape:snap-global="true">
+    <inkscape:grid
+       type="xygrid"
+       id="grid4136" />
+  </sodipodi:namedview>
+  <metadata
+     id="metadata7">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(0,-1027.3622)">
+    <rect
+       style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
+       id="rect5253"
+       width="5"
+       height="5.0000172"
+       x="16"
+       y="1031.3622"
+       ry="1.0000174" />
+    <rect
+       y="1043.3622"
+       x="4"
+       height="5.0000172"
+       width="5"
+       id="rect5255"
+       style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
+       ry="1.0000174" />
+    <rect
+       style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
+       id="rect5257"
+       width="5"
+       height="5.0000172"
+       x="13"
+       y="1043.3622"
+       ry="1.0000174" />
+  </g>
+</svg>
pkg/web/noVNC/app/images/disconnect.svg
@@ -0,0 +1,94 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="25"
+   height="25"
+   viewBox="0 0 25 25"
+   id="svg2"
+   version="1.1"
+   inkscape:version="0.91 r13725"
+   sodipodi:docname="disconnect.svg"
+   inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
+   inkscape:export-xdpi="90"
+   inkscape:export-ydpi="90">
+  <defs
+     id="defs4" />
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#959595"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageopacity="0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="16"
+     inkscape:cx="25.05707"
+     inkscape:cy="11.594858"
+     inkscape:document-units="px"
+     inkscape:current-layer="layer1"
+     showgrid="false"
+     units="px"
+     inkscape:snap-bbox="true"
+     inkscape:bbox-paths="true"
+     inkscape:bbox-nodes="true"
+     inkscape:snap-bbox-edge-midpoints="true"
+     inkscape:object-paths="true"
+     showguides="true"
+     inkscape:window-width="1920"
+     inkscape:window-height="1136"
+     inkscape:window-x="1920"
+     inkscape:window-y="27"
+     inkscape:window-maximized="1"
+     inkscape:snap-smooth-nodes="true"
+     inkscape:object-nodes="true"
+     inkscape:snap-intersection-paths="true"
+     inkscape:snap-nodes="true"
+     inkscape:snap-global="false">
+    <inkscape:grid
+       type="xygrid"
+       id="grid4136" />
+  </sodipodi:namedview>
+  <metadata
+     id="metadata7">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(0,-1027.3622)">
+    <g
+       id="g5171"
+       transform="translate(-24.062499,-6.15775e-4)">
+      <path
+         id="path5110"
+         transform="translate(0,1027.3622)"
+         d="m 39.744141,3.4960938 c -0.769923,0 -1.539607,0.2915468 -2.121094,0.8730468 l -2.566406,2.5664063 1.414062,1.4140625 2.566406,-2.5664063 c 0.403974,-0.404 1.010089,-0.404 1.414063,0 l 2.828125,2.828125 c 0.40398,0.4039 0.403907,1.0101621 0,1.4140629 l -2.566406,2.566406 1.414062,1.414062 2.566406,-2.566406 c 1.163041,-1.1629 1.162968,-3.0791874 0,-4.2421874 L 41.865234,4.3691406 C 41.283747,3.7876406 40.514063,3.4960937 39.744141,3.4960938 Z M 39.017578,9.015625 a 1.0001,1.0001 0 0 0 -0.6875,0.3027344 l -0.445312,0.4453125 1.414062,1.4140621 0.445313,-0.445312 A 1.0001,1.0001 0 0 0 39.017578,9.015625 Z m -6.363281,0.7070312 a 1.0001,1.0001 0 0 0 -0.6875,0.3027348 L 28.431641,13.5625 c -1.163042,1.163 -1.16297,3.079187 0,4.242188 l 2.828125,2.828124 c 1.162974,1.163101 3.079213,1.163101 4.242187,0 l 3.535156,-3.535156 a 1.0001,1.0001 0 1 0 -1.414062,-1.414062 l -3.535156,3.535156 c -0.403974,0.404 -1.010089,0.404 -1.414063,0 l -2.828125,-2.828125 c -0.403981,-0.404 -0.403908,-1.010162 0,-1.414063 l 3.535156,-3.537109 A 1.0001,1.0001 0 0 0 32.654297,9.7226562 Z m 3.109375,2.1621098 -2.382813,2.384765 a 1.0001,1.0001 0 1 0 1.414063,1.414063 l 2.382812,-2.384766 -1.414062,-1.414062 z"
+         style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
+         inkscape:connector-curvature="0" />
+      <rect
+         transform="matrix(0.70710678,-0.70710678,0.70710678,0.70710678,0,0)"
+         y="752.29541"
+         x="-712.31262"
+         height="18.000017"
+         width="3"
+         id="rect5116"
+         style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
+    </g>
+  </g>
+</svg>
pkg/web/noVNC/app/images/drag.svg
@@ -0,0 +1,76 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="25"
+   height="25"
+   viewBox="0 0 25 25"
+   id="svg2"
+   version="1.1"
+   inkscape:version="0.91 r13725"
+   sodipodi:docname="drag.svg"
+   inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
+   inkscape:export-xdpi="90"
+   inkscape:export-ydpi="90">
+  <defs
+     id="defs4" />
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#959595"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageopacity="0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="22.627417"
+     inkscape:cx="9.8789407"
+     inkscape:cy="9.5008608"
+     inkscape:document-units="px"
+     inkscape:current-layer="layer1"
+     showgrid="true"
+     units="px"
+     inkscape:snap-bbox="true"
+     inkscape:bbox-paths="true"
+     inkscape:bbox-nodes="true"
+     inkscape:snap-bbox-edge-midpoints="true"
+     inkscape:object-paths="true"
+     showguides="false"
+     inkscape:window-width="1920"
+     inkscape:window-height="1136"
+     inkscape:window-x="1920"
+     inkscape:window-y="27"
+     inkscape:window-maximized="1">
+    <inkscape:grid
+       type="xygrid"
+       id="grid4136" />
+  </sodipodi:namedview>
+  <metadata
+     id="metadata7">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(0,-1027.3622)">
+    <path
+       style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
+       d="m 7.039733,1049.3037 c -0.4309106,-0.1233 -0.7932634,-0.4631 -0.9705434,-0.9103 -0.04922,-0.1241 -0.057118,-0.2988 -0.071321,-1.5771 l -0.015972,-1.4375 -0.328125,-0.082 c -0.7668138,-0.1927 -1.1897046,-0.4275 -1.7031253,-0.9457 -0.4586773,-0.4629 -0.6804297,-0.8433 -0.867034,-1.4875 -0.067215,-0.232 -0.068001,-0.2642 -0.078682,-3.2188 -0.012078,-3.341 -0.020337,-3.2012 0.2099452,-3.5555 0.2246623,-0.3458 0.5798271,-0.5892 0.9667343,-0.6626 0.092506,-0.017 0.531898,-0.032 0.9764271,-0.032 l 0.8082347,0 1.157e-4,1.336 c 1.125e-4,1.2779 0.00281,1.3403 0.062214,1.4378 0.091785,0.1505 0.2357707,0.226 0.4314082,0.2261 0.285389,2e-4 0.454884,-0.1352 0.5058962,-0.4042 0.019355,-0.102 0.031616,-0.982 0.031616,-2.269 0,-1.9756 0.00357,-2.1138 0.059205,-2.2926 0.1645475,-0.5287 0.6307616,-0.9246 1.19078,-1.0113 0.8000572,-0.1238 1.5711277,0.4446 1.6860387,1.2429 0.01732,0.1203 0.03177,0.8248 0.03211,1.5657 6.19e-4,1.3449 7.22e-4,1.347 0.07093,1.4499 0.108355,0.1587 0.255268,0.2248 0.46917,0.2108 0.204069,-0.013 0.316116,-0.08 0.413642,-0.2453 0.06028,-0.1024 0.06307,-0.1778 0.07862,-2.1218 0.01462,-1.8283 0.02124,-2.0285 0.07121,-2.1549 0.260673,-0.659 0.934894,-1.0527 1.621129,-0.9465 0.640523,0.099 1.152269,0.6104 1.243187,1.2421 0.01827,0.1269 0.03175,0.9943 0.03211,2.0657 l 6.19e-4,1.8469 0.07031,0.103 c 0.108355,0.1587 0.255267,0.2248 0.46917,0.2108 0.204069,-0.013 0.316115,-0.08 0.413642,-0.2453 0.05951,-0.1011 0.06329,-0.1786 0.07907,-1.6218 0.01469,-1.3438 0.02277,-1.5314 0.07121,-1.6549 0.257975,-0.6576 0.934425,-1.0527 1.620676,-0.9465 0.640522,0.099 1.152269,0.6104 1.243186,1.2421 0.0186,0.1292 0.03179,1.0759 0.03222,2.3125 7.15e-4,2.0335 0.0025,2.0966 0.06283,2.1956 0.09178,0.1505 0.235771,0.226 0.431409,0.2261 0.285388,2e-4 0.454884,-0.1352 0.505897,-0.4042 0.01874,-0.099 0.03161,-0.8192 0.03161,-1.769 0,-1.4848 0.0043,-1.6163 0.0592,-1.7926 0.164548,-0.5287 0.630762,-0.9246 1.19078,-1.0113 0.800057,-0.1238 1.571128,0.4446 1.686039,1.2429 0.04318,0.2999 0.04372,9.1764 5.78e-4,9.4531 -0.04431,0.2841 -0.217814,0.6241 -0.420069,0.8232 -0.320102,0.315 -0.63307,0.4268 -1.194973,0.4268 l -0.35281,0 -2.51e-4,1.2734 c -1.25e-4,0.7046 -0.01439,1.3642 -0.03191,1.4766 -0.06665,0.4274 -0.372966,0.8704 -0.740031,1.0702 -0.349999,0.1905 0.01748,0.18 -6.242199,0.1776 -5.3622439,0 -5.7320152,-0.01 -5.9121592,-0.057 l 1.4e-5,0 z"
+       id="path4379"
+       inkscape:connector-curvature="0" />
+  </g>
+</svg>
pkg/web/noVNC/app/images/error.svg
@@ -0,0 +1,81 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="25"
+   height="25"
+   viewBox="0 0 25 25"
+   id="svg2"
+   version="1.1"
+   inkscape:version="0.91 r13725"
+   sodipodi:docname="error.svg"
+   inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
+   inkscape:export-xdpi="90"
+   inkscape:export-ydpi="90">
+  <defs
+     id="defs4" />
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#959595"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageopacity="0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="1"
+     inkscape:cx="14.00357"
+     inkscape:cy="12.443398"
+     inkscape:document-units="px"
+     inkscape:current-layer="layer1"
+     showgrid="false"
+     units="px"
+     inkscape:snap-bbox="true"
+     inkscape:bbox-paths="true"
+     inkscape:bbox-nodes="true"
+     inkscape:snap-bbox-edge-midpoints="true"
+     inkscape:object-paths="true"
+     showguides="true"
+     inkscape:window-width="1920"
+     inkscape:window-height="1136"
+     inkscape:window-x="1920"
+     inkscape:window-y="27"
+     inkscape:window-maximized="1"
+     inkscape:snap-smooth-nodes="true"
+     inkscape:object-nodes="true"
+     inkscape:snap-intersection-paths="true"
+     inkscape:snap-nodes="true"
+     inkscape:snap-global="true">
+    <inkscape:grid
+       type="xygrid"
+       id="grid4136" />
+  </sodipodi:namedview>
+  <metadata
+     id="metadata7">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title />
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(0,-1027.3622)">
+    <path
+       style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
+       d="M 7 3 C 4.7839905 3 3 4.7839905 3 7 L 3 18 C 3 20.21601 4.7839905 22 7 22 L 18 22 C 20.21601 22 22 20.21601 22 18 L 22 7 C 22 4.7839905 20.21601 3 18 3 L 7 3 z M 7.6992188 6 A 1.6916875 1.6924297 0 0 1 8.9121094 6.5117188 L 12.5 10.101562 L 16.087891 6.5117188 A 1.6916875 1.6924297 0 0 1 17.251953 6 A 1.6916875 1.6924297 0 0 1 18.480469 8.90625 L 14.892578 12.496094 L 18.480469 16.085938 A 1.6916875 1.6924297 0 1 1 16.087891 18.478516 L 12.5 14.888672 L 8.9121094 18.478516 A 1.6916875 1.6924297 0 1 1 6.5214844 16.085938 L 10.109375 12.496094 L 6.5214844 8.90625 A 1.6916875 1.6924297 0 0 1 7.6992188 6 z "
+       transform="translate(0,1027.3622)"
+       id="rect4135" />
+  </g>
+</svg>
pkg/web/noVNC/app/images/esc.svg
@@ -0,0 +1,92 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="25"
+   height="25"
+   viewBox="0 0 25 25"
+   id="svg2"
+   version="1.1"
+   inkscape:version="0.91 r13725"
+   sodipodi:docname="esc.svg"
+   inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
+   inkscape:export-xdpi="90"
+   inkscape:export-ydpi="90">
+  <defs
+     id="defs4" />
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#959595"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageopacity="0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="16"
+     inkscape:cx="18.205425"
+     inkscape:cy="17.531398"
+     inkscape:document-units="px"
+     inkscape:current-layer="text5290"
+     showgrid="false"
+     units="px"
+     inkscape:snap-bbox="true"
+     inkscape:bbox-paths="true"
+     inkscape:bbox-nodes="true"
+     inkscape:snap-bbox-edge-midpoints="true"
+     inkscape:object-paths="true"
+     showguides="true"
+     inkscape:window-width="1920"
+     inkscape:window-height="1136"
+     inkscape:window-x="1920"
+     inkscape:window-y="27"
+     inkscape:window-maximized="1"
+     inkscape:snap-smooth-nodes="true"
+     inkscape:object-nodes="true"
+     inkscape:snap-intersection-paths="true"
+     inkscape:snap-nodes="true"
+     inkscape:snap-global="true">
+    <inkscape:grid
+       type="xygrid"
+       id="grid4136" />
+  </sodipodi:namedview>
+  <metadata
+     id="metadata7">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(0,-1027.3622)">
+    <g
+       style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:48px;line-height:125%;font-family:'DejaVu Sans';-inkscape-font-specification:'Sans Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+       id="text5290">
+      <path
+         d="m 3.9331055,1036.1464 5.0732422,0 0,1.4209 -3.1933594,0 0,1.3574 3.0029297,0 0,1.4209 -3.0029297,0 0,1.6699 3.3007812,0 0,1.4209 -5.180664,0 0,-7.29 z"
+         style="font-size:10px;fill:#ffffff;fill-opacity:1"
+         id="path5314" />
+      <path
+         d="m 14.963379,1038.1385 0,1.3282 q -0.561524,-0.2344 -1.083984,-0.3516 -0.522461,-0.1172 -0.986329,-0.1172 -0.498046,0 -0.742187,0.127 -0.239258,0.122 -0.239258,0.3808 0,0.21 0.180664,0.3223 0.185547,0.1123 0.65918,0.166 l 0.307617,0.044 q 1.342773,0.1709 1.806641,0.5615 0.463867,0.3906 0.463867,1.2256 0,0.874 -0.644531,1.3134 -0.644532,0.4395 -1.923829,0.4395 -0.541992,0 -1.123046,-0.088 -0.576172,-0.083 -1.186524,-0.2539 l 0,-1.3281 q 0.522461,0.2539 1.069336,0.3808 0.551758,0.127 1.118164,0.127 0.512695,0 0.771485,-0.1416 0.258789,-0.1416 0.258789,-0.4199 0,-0.2344 -0.180664,-0.3467 -0.175782,-0.1172 -0.708008,-0.1807 l -0.307617,-0.039 q -1.166993,-0.1465 -1.635743,-0.542 -0.46875,-0.3955 -0.46875,-1.2012 0,-0.8691 0.595703,-1.2891 0.595704,-0.4199 1.826172,-0.4199 0.483399,0 1.015625,0.073 0.532227,0.073 1.157227,0.2294 z"
+         style="font-size:10px;fill:#ffffff;fill-opacity:1"
+         id="path5316" />
+      <path
+         d="m 21.066895,1038.1385 0,1.4258 q -0.356446,-0.2441 -0.717774,-0.3613 -0.356445,-0.1172 -0.742187,-0.1172 -0.732422,0 -1.142579,0.4297 -0.405273,0.4248 -0.405273,1.1914 0,0.7666 0.405273,1.1963 0.410157,0.4248 1.142579,0.4248 0.410156,0 0.776367,-0.1221 0.371094,-0.122 0.683594,-0.3613 l 0,1.4307 q -0.410157,0.1513 -0.834961,0.2246 -0.419922,0.078 -0.844727,0.078 -1.479492,0 -2.314453,-0.7568 -0.834961,-0.7618 -0.834961,-2.1143 0,-1.3525 0.834961,-2.1094 0.834961,-0.7617 2.314453,-0.7617 0.429688,0 0.844727,0.078 0.419921,0.073 0.834961,0.2246 z"
+         style="font-size:10px;fill:#ffffff;fill-opacity:1"
+         id="path5318" />
+    </g>
+  </g>
+</svg>
pkg/web/noVNC/app/images/expander.svg
@@ -0,0 +1,69 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="9"
+   height="10"
+   viewBox="0 0 9 10"
+   id="svg2"
+   version="1.1"
+   inkscape:version="0.91 r13725"
+   sodipodi:docname="expander.svg">
+  <defs
+     id="defs4" />
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageopacity="0.0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="45.254834"
+     inkscape:cx="9.8737281"
+     inkscape:cy="6.4583132"
+     inkscape:document-units="px"
+     inkscape:current-layer="layer1"
+     showgrid="true"
+     units="px"
+     inkscape:snap-object-midpoints="false"
+     inkscape:object-nodes="true"
+     inkscape:window-width="1920"
+     inkscape:window-height="1136"
+     inkscape:window-x="0"
+     inkscape:window-y="27"
+     inkscape:window-maximized="1">
+    <inkscape:grid
+       type="xygrid"
+       id="grid4136" />
+  </sodipodi:namedview>
+  <metadata
+     id="metadata7">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(0,-1042.3622)">
+    <path
+       style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:4;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
+       d="M 2.0800781,1042.3633 A 2.0002,2.0002 0 0 0 0,1044.3613 l 0,6 a 2.0002,2.0002 0 0 0 3.0292969,1.7168 l 5,-3 a 2.0002,2.0002 0 0 0 0,-3.4316 l -5,-3 a 2.0002,2.0002 0 0 0 -0.9492188,-0.2832 z"
+       id="path4138"
+       inkscape:connector-curvature="0" />
+  </g>
+</svg>
pkg/web/noVNC/app/images/fullscreen.svg
@@ -0,0 +1,93 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="25"
+   height="25"
+   viewBox="0 0 25 25"
+   id="svg2"
+   version="1.1"
+   inkscape:version="0.91 r13725"
+   sodipodi:docname="fullscreen.svg"
+   inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
+   inkscape:export-xdpi="90"
+   inkscape:export-ydpi="90">
+  <defs
+     id="defs4" />
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#959595"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageopacity="0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="1"
+     inkscape:cx="16.400723"
+     inkscape:cy="15.083758"
+     inkscape:document-units="px"
+     inkscape:current-layer="layer1"
+     showgrid="false"
+     units="px"
+     inkscape:snap-bbox="true"
+     inkscape:bbox-paths="true"
+     inkscape:bbox-nodes="true"
+     inkscape:snap-bbox-edge-midpoints="true"
+     inkscape:object-paths="true"
+     showguides="false"
+     inkscape:window-width="1920"
+     inkscape:window-height="1136"
+     inkscape:window-x="1920"
+     inkscape:window-y="27"
+     inkscape:window-maximized="1"
+     inkscape:snap-smooth-nodes="true"
+     inkscape:object-nodes="true"
+     inkscape:snap-intersection-paths="true"
+     inkscape:snap-nodes="false">
+    <inkscape:grid
+       type="xygrid"
+       id="grid4136" />
+  </sodipodi:namedview>
+  <metadata
+     id="metadata7">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(0,-1027.3622)">
+    <rect
+       style="opacity:1;fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:2;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
+       id="rect5006"
+       width="17"
+       height="17.000017"
+       x="4"
+       y="1031.3622"
+       ry="3.0000174" />
+    <path
+       style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#ffffff;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1"
+       d="m 7.5,1044.8622 4,0 -1.5,-1.5 1.5,-1.5 -1,-1 -1.5,1.5 -1.5,-1.5 0,4 z"
+       id="path5017"
+       inkscape:connector-curvature="0" />
+    <path
+       inkscape:connector-curvature="0"
+       id="path5025"
+       d="m 17.5,1034.8622 -4,0 1.5,1.5 -1.5,1.5 1,1 1.5,-1.5 1.5,1.5 0,-4 z"
+       style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#ffffff;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
+  </g>
+</svg>
pkg/web/noVNC/app/images/handle.svg
@@ -0,0 +1,82 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="5"
+   height="6"
+   viewBox="0 0 5 6"
+   id="svg2"
+   version="1.1"
+   inkscape:version="0.91 r13725"
+   sodipodi:docname="handle.svg"
+   inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
+   inkscape:export-xdpi="90"
+   inkscape:export-ydpi="90">
+  <defs
+     id="defs4" />
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#959595"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageopacity="0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="32"
+     inkscape:cx="1.3551778"
+     inkscape:cy="8.7800329"
+     inkscape:document-units="px"
+     inkscape:current-layer="layer1"
+     showgrid="true"
+     units="px"
+     inkscape:snap-bbox="true"
+     inkscape:bbox-paths="true"
+     inkscape:bbox-nodes="true"
+     inkscape:snap-bbox-edge-midpoints="true"
+     inkscape:object-paths="true"
+     showguides="false"
+     inkscape:window-width="1920"
+     inkscape:window-height="1136"
+     inkscape:window-x="1920"
+     inkscape:window-y="27"
+     inkscape:window-maximized="1"
+     inkscape:snap-smooth-nodes="true"
+     inkscape:object-nodes="true"
+     inkscape:snap-intersection-paths="true"
+     inkscape:snap-nodes="true"
+     inkscape:snap-global="true">
+    <inkscape:grid
+       type="xygrid"
+       id="grid4136" />
+  </sodipodi:namedview>
+  <metadata
+     id="metadata7">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(0,-1046.3622)">
+    <path
+       style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#ffffff;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+       d="m 4.0000803,1049.3622 -3,-2 0,4 z"
+       id="path4247"
+       inkscape:connector-curvature="0"
+       sodipodi:nodetypes="cccc" />
+  </g>
+</svg>
pkg/web/noVNC/app/images/handle_bg.svg
@@ -0,0 +1,172 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="15"
+   height="50"
+   viewBox="0 0 15 50"
+   id="svg2"
+   version="1.1"
+   inkscape:version="0.91 r13725"
+   sodipodi:docname="handle_bg.svg"
+   inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
+   inkscape:export-xdpi="90"
+   inkscape:export-ydpi="90">
+  <defs
+     id="defs4" />
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#959595"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageopacity="0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="16"
+     inkscape:cx="-10.001409"
+     inkscape:cy="24.512566"
+     inkscape:document-units="px"
+     inkscape:current-layer="layer1"
+     showgrid="true"
+     units="px"
+     inkscape:snap-bbox="true"
+     inkscape:bbox-paths="true"
+     inkscape:bbox-nodes="true"
+     inkscape:snap-bbox-edge-midpoints="true"
+     inkscape:object-paths="true"
+     showguides="false"
+     inkscape:window-width="1920"
+     inkscape:window-height="1136"
+     inkscape:window-x="1920"
+     inkscape:window-y="27"
+     inkscape:window-maximized="1"
+     inkscape:snap-smooth-nodes="true"
+     inkscape:object-nodes="true"
+     inkscape:snap-intersection-paths="true"
+     inkscape:snap-nodes="true"
+     inkscape:snap-global="true">
+    <inkscape:grid
+       type="xygrid"
+       id="grid4136" />
+  </sodipodi:namedview>
+  <metadata
+     id="metadata7">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(0,-1002.3622)">
+    <rect
+       style="opacity:0.25;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
+       id="rect4249"
+       width="1"
+       height="1.0000174"
+       x="9.5"
+       y="1008.8622"
+       ry="1.7382812e-05" />
+    <rect
+       ry="1.7382812e-05"
+       y="1013.8622"
+       x="9.5"
+       height="1.0000174"
+       width="1"
+       id="rect4255"
+       style="opacity:0.25;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
+    <rect
+       ry="1.7382812e-05"
+       y="1008.8622"
+       x="4.5"
+       height="1.0000174"
+       width="1"
+       id="rect4261"
+       style="opacity:0.25;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
+    <rect
+       style="opacity:0.25;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
+       id="rect4263"
+       width="1"
+       height="1.0000174"
+       x="4.5"
+       y="1013.8622"
+       ry="1.7382812e-05" />
+    <rect
+       ry="1.7382812e-05"
+       y="1039.8622"
+       x="9.5"
+       height="1.0000174"
+       width="1"
+       id="rect4265"
+       style="opacity:0.25;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
+    <rect
+       style="opacity:0.25;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
+       id="rect4267"
+       width="1"
+       height="1.0000174"
+       x="9.5"
+       y="1044.8622"
+       ry="1.7382812e-05" />
+    <rect
+       style="opacity:0.25;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
+       id="rect4269"
+       width="1"
+       height="1.0000174"
+       x="4.5"
+       y="1039.8622"
+       ry="1.7382812e-05" />
+    <rect
+       ry="1.7382812e-05"
+       y="1044.8622"
+       x="4.5"
+       height="1.0000174"
+       width="1"
+       id="rect4271"
+       style="opacity:0.25;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
+    <rect
+       style="opacity:0.25;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
+       id="rect4273"
+       width="1"
+       height="1.0000174"
+       x="9.5"
+       y="1018.8622"
+       ry="1.7382812e-05" />
+    <rect
+       ry="1.7382812e-05"
+       y="1018.8622"
+       x="4.5"
+       height="1.0000174"
+       width="1"
+       id="rect4275"
+       style="opacity:0.25;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
+    <rect
+       style="opacity:0.25;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
+       id="rect4277"
+       width="1"
+       height="1.0000174"
+       x="9.5"
+       y="1034.8622"
+       ry="1.7382812e-05" />
+    <rect
+       ry="1.7382812e-05"
+       y="1034.8622"
+       x="4.5"
+       height="1.0000174"
+       width="1"
+       id="rect4279"
+       style="opacity:0.25;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
+  </g>
+</svg>
pkg/web/noVNC/app/images/info.svg
@@ -0,0 +1,81 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="25"
+   height="25"
+   viewBox="0 0 25 25"
+   id="svg2"
+   version="1.1"
+   inkscape:version="0.91 r13725"
+   sodipodi:docname="info.svg"
+   inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
+   inkscape:export-xdpi="90"
+   inkscape:export-ydpi="90">
+  <defs
+     id="defs4" />
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#959595"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageopacity="0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="1"
+     inkscape:cx="15.720838"
+     inkscape:cy="8.9111233"
+     inkscape:document-units="px"
+     inkscape:current-layer="layer1"
+     showgrid="false"
+     units="px"
+     inkscape:snap-bbox="true"
+     inkscape:bbox-paths="true"
+     inkscape:bbox-nodes="true"
+     inkscape:snap-bbox-edge-midpoints="true"
+     inkscape:object-paths="true"
+     showguides="false"
+     inkscape:window-width="1920"
+     inkscape:window-height="1136"
+     inkscape:window-x="1920"
+     inkscape:window-y="27"
+     inkscape:window-maximized="1"
+     inkscape:snap-smooth-nodes="true"
+     inkscape:object-nodes="true"
+     inkscape:snap-intersection-paths="true"
+     inkscape:snap-nodes="true"
+     inkscape:snap-global="true">
+    <inkscape:grid
+       type="xygrid"
+       id="grid4136" />
+  </sodipodi:namedview>
+  <metadata
+     id="metadata7">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title />
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(0,-1027.3622)">
+    <path
+       style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
+       d="M 12.5 3 A 9.5 9.4999914 0 0 0 3 12.5 A 9.5 9.4999914 0 0 0 12.5 22 A 9.5 9.4999914 0 0 0 22 12.5 A 9.5 9.4999914 0 0 0 12.5 3 z M 12.5 5 A 1.5 1.5000087 0 0 1 14 6.5 A 1.5 1.5000087 0 0 1 12.5 8 A 1.5 1.5000087 0 0 1 11 6.5 A 1.5 1.5000087 0 0 1 12.5 5 z M 10.521484 8.9785156 L 12.521484 8.9785156 A 1.50015 1.50015 0 0 1 14.021484 10.478516 L 14.021484 15.972656 A 1.50015 1.50015 0 0 1 14.498047 18.894531 C 14.498047 18.894531 13.74301 19.228309 12.789062 18.912109 C 12.312092 18.754109 11.776235 18.366625 11.458984 17.828125 C 11.141734 17.289525 11.021484 16.668469 11.021484 15.980469 L 11.021484 11.980469 L 10.521484 11.980469 A 1.50015 1.50015 0 1 1 10.521484 8.9804688 L 10.521484 8.9785156 z "
+       transform="translate(0,1027.3622)"
+       id="path4136" />
+  </g>
+</svg>
pkg/web/noVNC/app/images/keyboard.svg
@@ -0,0 +1,88 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="25"
+   height="25"
+   viewBox="0 0 25 25"
+   id="svg2"
+   version="1.1"
+   inkscape:version="0.91 r13725"
+   sodipodi:docname="keyboard.svg"
+   inkscape:export-filename="/home/ossman/devel/noVNC/images/keyboard.png"
+   inkscape:export-xdpi="90"
+   inkscape:export-ydpi="90">
+  <defs
+     id="defs4" />
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#717171"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageopacity="0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="1"
+     inkscape:cx="31.285341"
+     inkscape:cy="8.8028469"
+     inkscape:document-units="px"
+     inkscape:current-layer="layer1"
+     showgrid="false"
+     units="px"
+     inkscape:snap-bbox="true"
+     inkscape:bbox-paths="true"
+     inkscape:bbox-nodes="true"
+     inkscape:snap-bbox-edge-midpoints="true"
+     inkscape:snap-bbox-midpoints="false"
+     inkscape:window-width="1920"
+     inkscape:window-height="1136"
+     inkscape:window-x="1920"
+     inkscape:window-y="27"
+     inkscape:window-maximized="1"
+     inkscape:object-paths="true"
+     inkscape:snap-intersection-paths="true"
+     inkscape:object-nodes="true"
+     inkscape:snap-midpoints="true"
+     inkscape:snap-smooth-nodes="true">
+    <inkscape:grid
+       type="xygrid"
+       id="grid4136" />
+  </sodipodi:namedview>
+  <metadata
+     id="metadata7">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title />
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(0,-1027.3622)">
+    <path
+       style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
+       d="M 7,3 C 4.8012876,3 3,4.8013 3,7 3,11.166667 3,15.333333 3,19.5 3,20.8764 4.1236413,22 5.5,22 l 14,0 C 20.876358,22 22,20.8764 22,19.5 22,15.333333 22,11.166667 22,7 22,4.8013 20.198712,3 18,3 Z m 0,2 11,0 c 1.125307,0 2,0.8747 2,2 L 20,12 5,12 5,7 C 5,5.8747 5.8746931,5 7,5 Z M 6.5,14 C 6.777,14 7,14.223 7,14.5 7,14.777 6.777,15 6.5,15 6.223,15 6,14.777 6,14.5 6,14.223 6.223,14 6.5,14 Z m 2,0 C 8.777,14 9,14.223 9,14.5 9,14.777 8.777,15 8.5,15 8.223,15 8,14.777 8,14.5 8,14.223 8.223,14 8.5,14 Z m 2,0 C 10.777,14 11,14.223 11,14.5 11,14.777 10.777,15 10.5,15 10.223,15 10,14.777 10,14.5 10,14.223 10.223,14 10.5,14 Z m 2,0 C 12.777,14 13,14.223 13,14.5 13,14.777 12.777,15 12.5,15 12.223,15 12,14.777 12,14.5 12,14.223 12.223,14 12.5,14 Z m 2,0 C 14.777,14 15,14.223 15,14.5 15,14.777 14.777,15 14.5,15 14.223,15 14,14.777 14,14.5 14,14.223 14.223,14 14.5,14 Z m 2,0 C 16.777,14 17,14.223 17,14.5 17,14.777 16.777,15 16.5,15 16.223,15 16,14.777 16,14.5 16,14.223 16.223,14 16.5,14 Z m 2,0 C 18.777,14 19,14.223 19,14.5 19,14.777 18.777,15 18.5,15 18.223,15 18,14.777 18,14.5 18,14.223 18.223,14 18.5,14 Z m -13,2 C 5.777,16 6,16.223 6,16.5 6,16.777 5.777,17 5.5,17 5.223,17 5,16.777 5,16.5 5,16.223 5.223,16 5.5,16 Z m 2,0 C 7.777,16 8,16.223 8,16.5 8,16.777 7.777,17 7.5,17 7.223,17 7,16.777 7,16.5 7,16.223 7.223,16 7.5,16 Z m 2,0 C 9.777,16 10,16.223 10,16.5 10,16.777 9.777,17 9.5,17 9.223,17 9,16.777 9,16.5 9,16.223 9.223,16 9.5,16 Z m 2,0 C 11.777,16 12,16.223 12,16.5 12,16.777 11.777,17 11.5,17 11.223,17 11,16.777 11,16.5 11,16.223 11.223,16 11.5,16 Z m 2,0 C 13.777,16 14,16.223 14,16.5 14,16.777 13.777,17 13.5,17 13.223,17 13,16.777 13,16.5 13,16.223 13.223,16 13.5,16 Z m 2,0 C 15.777,16 16,16.223 16,16.5 16,16.777 15.777,17 15.5,17 15.223,17 15,16.777 15,16.5 15,16.223 15.223,16 15.5,16 Z m 2,0 C 17.777,16 18,16.223 18,16.5 18,16.777 17.777,17 17.5,17 17.223,17 17,16.777 17,16.5 17,16.223 17.223,16 17.5,16 Z m 2,0 C 19.777,16 20,16.223 20,16.5 20,16.777 19.777,17 19.5,17 19.223,17 19,16.777 19,16.5 19,16.223 19.223,16 19.5,16 Z M 6,18 c 0.554,0 1,0.446 1,1 0,0.554 -0.446,1 -1,1 -0.554,0 -1,-0.446 -1,-1 0,-0.554 0.446,-1 1,-1 z m 2.8261719,0 7.3476561,0 C 16.631643,18 17,18.368372 17,18.826172 l 0,0.347656 C 17,19.631628 16.631643,20 16.173828,20 L 8.8261719,20 C 8.3683573,20 8,19.631628 8,19.173828 L 8,18.826172 C 8,18.368372 8.3683573,18 8.8261719,18 Z m 10.1113281,0 0.125,0 C 19.581551,18 20,18.4184 20,18.9375 l 0,0.125 C 20,19.5816 19.581551,20 19.0625,20 l -0.125,0 C 18.418449,20 18,19.5816 18,19.0625 l 0,-0.125 C 18,18.4184 18.418449,18 18.9375,18 Z"
+       transform="translate(0,1027.3622)"
+       id="rect4160"
+       inkscape:connector-curvature="0"
+       sodipodi:nodetypes="sccssccsssssccssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss" />
+    <path
+       style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#ffffff;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:round;stroke-opacity:1"
+       d="m 12.499929,1033.8622 -2,2 1.500071,0 0,2 1,0 0,-2 1.499929,0 z"
+       id="path4150"
+       inkscape:connector-curvature="0"
+       sodipodi:nodetypes="cccccccc" />
+  </g>
+</svg>
pkg/web/noVNC/app/images/power.svg
@@ -0,0 +1,87 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="25"
+   height="25"
+   viewBox="0 0 25 25"
+   id="svg2"
+   version="1.1"
+   inkscape:version="0.91 r13725"
+   sodipodi:docname="power.svg"
+   inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
+   inkscape:export-xdpi="90"
+   inkscape:export-ydpi="90">
+  <defs
+     id="defs4" />
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#959595"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageopacity="0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="1"
+     inkscape:cx="9.3159849"
+     inkscape:cy="13.436208"
+     inkscape:document-units="px"
+     inkscape:current-layer="layer1"
+     showgrid="false"
+     units="px"
+     inkscape:snap-bbox="true"
+     inkscape:bbox-paths="true"
+     inkscape:bbox-nodes="true"
+     inkscape:snap-bbox-edge-midpoints="true"
+     inkscape:object-paths="true"
+     showguides="true"
+     inkscape:window-width="1920"
+     inkscape:window-height="1136"
+     inkscape:window-x="1920"
+     inkscape:window-y="27"
+     inkscape:window-maximized="1"
+     inkscape:snap-smooth-nodes="true"
+     inkscape:object-nodes="true"
+     inkscape:snap-intersection-paths="true"
+     inkscape:snap-nodes="true"
+     inkscape:snap-global="true">
+    <inkscape:grid
+       type="xygrid"
+       id="grid4136" />
+  </sodipodi:namedview>
+  <metadata
+     id="metadata7">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(0,-1027.3622)">
+    <path
+       style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
+       d="M 9 6.8183594 C 6.3418164 8.1213032 4.5 10.849161 4.5 14 C 4.5 18.4065 8.0935666 22 12.5 22 C 16.906433 22 20.5 18.4065 20.5 14 C 20.5 10.849161 18.658184 8.1213032 16 6.8183594 L 16 9.125 C 17.514327 10.211757 18.5 11.984508 18.5 14 C 18.5 17.3256 15.825553 20 12.5 20 C 9.1744469 20 6.5 17.3256 6.5 14 C 6.5 11.984508 7.4856727 10.211757 9 9.125 L 9 6.8183594 z "
+       transform="translate(0,1027.3622)"
+       id="path6140" />
+    <path
+       style="fill:none;fill-rule:evenodd;stroke:#ffffff;stroke-width:3;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+       d="m 12.5,1031.8836 0,6.4786"
+       id="path6142"
+       inkscape:connector-curvature="0"
+       sodipodi:nodetypes="cc" />
+  </g>
+</svg>
pkg/web/noVNC/app/images/settings.svg
@@ -0,0 +1,76 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="25"
+   height="25"
+   viewBox="0 0 25 25"
+   id="svg2"
+   version="1.1"
+   inkscape:version="0.91 r13725"
+   sodipodi:docname="settings.svg"
+   inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
+   inkscape:export-xdpi="90"
+   inkscape:export-ydpi="90">
+  <defs
+     id="defs4" />
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#959595"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageopacity="0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="22.627417"
+     inkscape:cx="14.69683"
+     inkscape:cy="8.8039511"
+     inkscape:document-units="px"
+     inkscape:current-layer="layer1"
+     showgrid="true"
+     units="px"
+     inkscape:snap-bbox="true"
+     inkscape:bbox-paths="true"
+     inkscape:bbox-nodes="true"
+     inkscape:snap-bbox-edge-midpoints="true"
+     inkscape:object-paths="true"
+     showguides="false"
+     inkscape:window-width="1920"
+     inkscape:window-height="1136"
+     inkscape:window-x="1920"
+     inkscape:window-y="27"
+     inkscape:window-maximized="1">
+    <inkscape:grid
+       type="xygrid"
+       id="grid4136" />
+  </sodipodi:namedview>
+  <metadata
+     id="metadata7">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(0,-1027.3622)">
+    <path
+       style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
+       d="M 11 3 L 11 5.1601562 A 7.5 7.5 0 0 0 8.3671875 6.2460938 L 6.84375 4.7226562 L 4.7226562 6.84375 L 6.2480469 8.3691406 A 7.5 7.5 0 0 0 5.1523438 11 L 3 11 L 3 14 L 5.1601562 14 A 7.5 7.5 0 0 0 6.2460938 16.632812 L 4.7226562 18.15625 L 6.84375 20.277344 L 8.3691406 18.751953 A 7.5 7.5 0 0 0 11 19.847656 L 11 22 L 14 22 L 14 19.839844 A 7.5 7.5 0 0 0 16.632812 18.753906 L 18.15625 20.277344 L 20.277344 18.15625 L 18.751953 16.630859 A 7.5 7.5 0 0 0 19.847656 14 L 22 14 L 22 11 L 19.839844 11 A 7.5 7.5 0 0 0 18.753906 8.3671875 L 20.277344 6.84375 L 18.15625 4.7226562 L 16.630859 6.2480469 A 7.5 7.5 0 0 0 14 5.1523438 L 14 3 L 11 3 z M 12.5 10 A 2.5 2.5 0 0 1 15 12.5 A 2.5 2.5 0 0 1 12.5 15 A 2.5 2.5 0 0 1 10 12.5 A 2.5 2.5 0 0 1 12.5 10 z "
+       transform="translate(0,1027.3622)"
+       id="rect4967" />
+  </g>
+</svg>
pkg/web/noVNC/app/images/tab.svg
@@ -0,0 +1,86 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="25"
+   height="25"
+   viewBox="0 0 25 25"
+   id="svg2"
+   version="1.1"
+   inkscape:version="0.91 r13725"
+   sodipodi:docname="tab.svg"
+   inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
+   inkscape:export-xdpi="90"
+   inkscape:export-ydpi="90">
+  <defs
+     id="defs4" />
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#959595"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageopacity="0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="16"
+     inkscape:cx="11.67335"
+     inkscape:cy="17.881696"
+     inkscape:document-units="px"
+     inkscape:current-layer="layer1"
+     showgrid="false"
+     units="px"
+     inkscape:snap-bbox="true"
+     inkscape:bbox-paths="true"
+     inkscape:bbox-nodes="true"
+     inkscape:snap-bbox-edge-midpoints="true"
+     inkscape:object-paths="true"
+     showguides="true"
+     inkscape:window-width="1920"
+     inkscape:window-height="1136"
+     inkscape:window-x="1920"
+     inkscape:window-y="27"
+     inkscape:window-maximized="1"
+     inkscape:snap-smooth-nodes="true"
+     inkscape:object-nodes="true"
+     inkscape:snap-intersection-paths="true"
+     inkscape:snap-nodes="true"
+     inkscape:snap-global="true">
+    <inkscape:grid
+       type="xygrid"
+       id="grid4136" />
+  </sodipodi:namedview>
+  <metadata
+     id="metadata7">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(0,-1027.3622)">
+    <path
+       style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
+       d="m 3,1031.3622 0,8 2,0 0,-4 0,-4 -2,0 z m 2,4 4,4 0,-3 13,0 0,-2 -13,0 0,-3 -4,4 z"
+       id="rect5194"
+       inkscape:connector-curvature="0" />
+    <path
+       id="path5211"
+       d="m 22,1048.3622 0,-8 -2,0 0,4 0,4 2,0 z m -2,-4 -4,-4 0,3 -13,0 0,2 13,0 0,3 4,-4 z"
+       style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
+       inkscape:connector-curvature="0" />
+  </g>
+</svg>
pkg/web/noVNC/app/images/toggleextrakeys.svg
@@ -0,0 +1,90 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="25"
+   height="25"
+   viewBox="0 0 25 25"
+   id="svg2"
+   version="1.1"
+   inkscape:version="0.91 r13725"
+   sodipodi:docname="extrakeys.svg"
+   inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
+   inkscape:export-xdpi="90"
+   inkscape:export-ydpi="90">
+  <defs
+     id="defs4" />
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#959595"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageopacity="0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="1"
+     inkscape:cx="15.234555"
+     inkscape:cy="9.9710826"
+     inkscape:document-units="px"
+     inkscape:current-layer="layer1"
+     showgrid="false"
+     units="px"
+     inkscape:snap-bbox="true"
+     inkscape:bbox-paths="true"
+     inkscape:bbox-nodes="true"
+     inkscape:snap-bbox-edge-midpoints="true"
+     inkscape:object-paths="true"
+     showguides="false"
+     inkscape:window-width="1920"
+     inkscape:window-height="1136"
+     inkscape:window-x="1920"
+     inkscape:window-y="27"
+     inkscape:window-maximized="1"
+     inkscape:snap-smooth-nodes="true"
+     inkscape:object-nodes="true"
+     inkscape:snap-intersection-paths="true"
+     inkscape:snap-nodes="false">
+    <inkscape:grid
+       type="xygrid"
+       id="grid4136" />
+  </sodipodi:namedview>
+  <metadata
+     id="metadata7">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(0,-1027.3622)">
+    <path
+       style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
+       d="m 8,1031.3622 c -2.1987124,0 -4,1.8013 -4,4 l 0,8.9996 c 0,2.1987 1.8012876,4 4,4 l 9,0 c 2.198712,0 4,-1.8013 4,-4 l 0,-8.9996 c 0,-2.1987 -1.801288,-4 -4,-4 z m 0,2 9,0 c 1.125307,0 2,0.8747 2,2 l 0,7.0005 c 0,1.1253 -0.874693,2 -2,2 l -9,0 c -1.1253069,0 -2,-0.8747 -2,-2 l 0,-7.0005 c 0,-1.1253 0.8746931,-2 2,-2 z"
+       id="rect5006"
+       inkscape:connector-curvature="0"
+       sodipodi:nodetypes="ssssssssssssssssss" />
+    <g
+       style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:10px;line-height:125%;font-family:'DejaVu Sans';-inkscape-font-specification:'Sans Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+       id="text4167"
+       transform="matrix(0.96021948,0,0,0.96021948,0.18921715,41.80659)">
+      <path
+         d="m 14.292969,1040.6791 -2.939453,0 -0.463868,1.3281 -1.889648,0 2.700195,-7.29 2.241211,0 2.700196,7.29 -1.889649,0 -0.458984,-1.3281 z m -2.470703,-1.3526 1.99707,0 -0.996094,-2.9004 -1.000976,2.9004 z"
+         id="path4172"
+         inkscape:connector-curvature="0" />
+    </g>
+  </g>
+</svg>
pkg/web/noVNC/app/images/warning.svg
@@ -0,0 +1,81 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="25"
+   height="25"
+   viewBox="0 0 25 25"
+   id="svg2"
+   version="1.1"
+   inkscape:version="0.91 r13725"
+   sodipodi:docname="warning.svg"
+   inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
+   inkscape:export-xdpi="90"
+   inkscape:export-ydpi="90">
+  <defs
+     id="defs4" />
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#959595"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageopacity="0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="1"
+     inkscape:cx="16.457343"
+     inkscape:cy="12.179552"
+     inkscape:document-units="px"
+     inkscape:current-layer="layer1"
+     showgrid="false"
+     units="px"
+     inkscape:snap-bbox="true"
+     inkscape:bbox-paths="true"
+     inkscape:bbox-nodes="true"
+     inkscape:snap-bbox-edge-midpoints="true"
+     inkscape:object-paths="true"
+     showguides="false"
+     inkscape:window-width="1920"
+     inkscape:window-height="1136"
+     inkscape:window-x="1920"
+     inkscape:window-y="27"
+     inkscape:window-maximized="1"
+     inkscape:snap-smooth-nodes="true"
+     inkscape:object-nodes="true"
+     inkscape:snap-intersection-paths="true"
+     inkscape:snap-nodes="true"
+     inkscape:snap-global="true">
+    <inkscape:grid
+       type="xygrid"
+       id="grid4136" />
+  </sodipodi:namedview>
+  <metadata
+     id="metadata7">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(0,-1027.3622)">
+    <path
+       style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:4;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
+       d="M 12.513672 3.0019531 C 11.751609 2.9919531 11.052563 3.4242687 10.710938 4.1054688 L 3.2109375 19.105469 C 2.5461937 20.435369 3.5132277 21.9999 5 22 L 20 22 C 21.486772 21.9999 22.453806 20.435369 21.789062 19.105469 L 14.289062 4.1054688 C 13.951849 3.4330688 13.265888 3.0066531 12.513672 3.0019531 z M 12.478516 6.9804688 A 1.50015 1.50015 0 0 1 14 8.5 L 14 14.5 A 1.50015 1.50015 0 1 1 11 14.5 L 11 8.5 A 1.50015 1.50015 0 0 1 12.478516 6.9804688 z M 12.5 17 A 1.5 1.5 0 0 1 14 18.5 A 1.5 1.5 0 0 1 12.5 20 A 1.5 1.5 0 0 1 11 18.5 A 1.5 1.5 0 0 1 12.5 17 z "
+       transform="translate(0,1027.3622)"
+       id="path4208" />
+  </g>
+</svg>
pkg/web/noVNC/app/images/windows.svg
@@ -0,0 +1,65 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   version="1.1"
+   id="svg2"
+   inkscape:export-ydpi="90"
+   inkscape:export-xdpi="90"
+   sodipodi:docname="windows.svg"
+   inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
+   inkscape:version="0.92.4 (unknown)"
+   x="0px"
+   y="0px"
+   viewBox="-293 384 25 25"
+   xml:space="preserve"
+   width="25"
+   height="25"><metadata
+   id="metadata21"><rdf:RDF><cc:Work
+       rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
+         rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
+   id="defs19" /><sodipodi:namedview
+   pagecolor="#959595"
+   bordercolor="#666666"
+   borderopacity="1"
+   objecttolerance="10"
+   gridtolerance="10"
+   guidetolerance="10"
+   inkscape:pageopacity="0"
+   inkscape:pageshadow="2"
+   inkscape:window-width="1920"
+   inkscape:window-height="1136"
+   id="namedview17"
+   showgrid="true"
+   inkscape:pagecheckerboard="false"
+   inkscape:zoom="32"
+   inkscape:cx="3.926913"
+   inkscape:cy="13.255959"
+   inkscape:window-x="1920"
+   inkscape:window-y="27"
+   inkscape:window-maximized="1"
+   inkscape:current-layer="svg2"><inkscape:grid
+     type="xygrid"
+     id="grid818" /></sodipodi:namedview>
+<style
+   type="text/css"
+   id="style2">
+	.st0{fill:#FFFFFF;}
+</style>
+
+<path
+   style="fill:#ffffff;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;fill-opacity:1"
+   d="M 21 4 L 11 5.1757812 L 11 12 L 21 12 L 21 4 z M 10 5.2949219 L 4 6 L 4 12 L 10 12 L 10 5.2949219 z "
+   transform="translate(-293,384)"
+   id="path853" /><path
+   id="path858"
+   d="m -272,405 -10,-1.17578 V 397 h 10 z M -283,403.70508 -289,403 v -6 h 6 z"
+   style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+   inkscape:connector-curvature="0" /></svg>
\ No newline at end of file
pkg/web/noVNC/app/locale/cs.json
@@ -0,0 +1,71 @@
+{
+    "Connecting...": "Pล™ipojenรญ...",
+    "Disconnecting...": "Odpojenรญ...",
+    "Reconnecting...": "Obnova pล™ipojenรญ...",
+    "Internal error": "Vnitล™nรญ chyba",
+    "Must set host": "Hostitel musรญ bรฝt nastavenรญ",
+    "Connected (encrypted) to ": "Pล™ipojenรญ (ลกifrovanรฉ) k ",
+    "Connected (unencrypted) to ": "Pล™ipojenรญ (neลกifrovanรฉ) k ",
+    "Something went wrong, connection is closed": "Nฤ›co se pokazilo, odpojeno",
+    "Failed to connect to server": "Chyba pล™ipojenรญ k serveru",
+    "Disconnected": "Odpojeno",
+    "New connection has been rejected with reason: ": "Novรฉ pล™ipojenรญ bylo odmรญtnuto s odลฏvodnฤ›nรญm: ",
+    "New connection has been rejected": "Novรฉ pล™ipojenรญ bylo odmรญtnuto",
+    "Password is required": "Je vyลพadovรกno heslo",
+    "noVNC encountered an error:": "noVNC narazilo na chybu:",
+    "Hide/Show the control bar": "Skrรฝt/zobrazit ovlรกdacรญ panel",
+    "Move/Drag viewport": "Pล™esunout/pล™etรกhnout vรฝล™ez",
+    "viewport drag": "pล™esun vรฝล™ezu",
+    "Active Mouse Button": "Aktivnรญ tlaฤรญtka myลกi",
+    "No mousebutton": "ลฝรกdnรฉ",
+    "Left mousebutton": "Levรฉ tlaฤรญtko myลกi",
+    "Middle mousebutton": "Prostล™ednรญ tlaฤรญtko myลกi",
+    "Right mousebutton": "Pravรฉ tlaฤรญtko myลกi",
+    "Keyboard": "Klรกvesnice",
+    "Show keyboard": "Zobrazit klรกvesnici",
+    "Extra keys": "Extra klรกvesy",
+    "Show extra keys": "Zobrazit extra klรกvesy",
+    "Ctrl": "Ctrl",
+    "Toggle Ctrl": "Pล™epnout Ctrl",
+    "Alt": "Alt",
+    "Toggle Alt": "Pล™epnout Alt",
+    "Send Tab": "Odeslat tabulรกtor",
+    "Tab": "Tab",
+    "Esc": "Esc",
+    "Send Escape": "Odeslat Esc",
+    "Ctrl+Alt+Del": "Ctrl+Alt+Del",
+    "Send Ctrl-Alt-Del": "Poslat Ctrl-Alt-Del",
+    "Shutdown/Reboot": "Vypnutรญ/Restart",
+    "Shutdown/Reboot...": "Vypnutรญ/Restart...",
+    "Power": "Napรกjenรญ",
+    "Shutdown": "Vypnout",
+    "Reboot": "Restart",
+    "Reset": "Reset",
+    "Clipboard": "Schrรกnka",
+    "Clear": "Vymazat",
+    "Fullscreen": "Celรก obrazovka",
+    "Settings": "Nastavenรญ",
+    "Shared mode": "Sdรญlenรฝ reลพim",
+    "View only": "Pouze prohlรญลพenรญ",
+    "Clip to window": "Pล™izpลฏsobit oknu",
+    "Scaling mode:": "Pล™izpลฏsobenรญ velikosti",
+    "None": "ลฝรกdnรฉ",
+    "Local scaling": "Mรญstnรญ",
+    "Remote resizing": "Vzdรกlenรฉ",
+    "Advanced": "Pokroฤilรฉ",
+    "Repeater ID:": "ID opakovaฤe",
+    "WebSocket": "WebSocket",
+    "Encrypt": "ล ifrovรกnรญ:",
+    "Host:": "Hostitel:",
+    "Port:": "Port:",
+    "Path:": "Cesta",
+    "Automatic reconnect": "Automatickรก obnova pล™ipojenรญ",
+    "Reconnect delay (ms):": "Zpoลพdฤ›nรญ pล™ipojenรญ (ms)",
+    "Show dot when no cursor": "Teฤka mรญsto chybฤ›jรญcรญho kurzoru myลกi",
+    "Logging:": "Logovรกnรญ:",
+    "Disconnect": "Odpojit",
+    "Connect": "Pล™ipojit",
+    "Password:": "Heslo",
+    "Send Password": "Odeslat heslo",
+    "Cancel": "Zruลกit"
+}
\ No newline at end of file
pkg/web/noVNC/app/locale/de.json
@@ -0,0 +1,74 @@
+{
+    "Connecting...": "Verbinden...",
+    "Disconnecting...": "Verbindung trennen...",
+    "Reconnecting...": "Verbindung wiederherstellen...",
+    "Internal error": "Interner Fehler",
+    "Must set host": "Richten Sie den Server ein",
+    "Connected (encrypted) to ": "Verbunden mit (verschlรผsselt) ",
+    "Connected (unencrypted) to ": "Verbunden mit (unverschlรผsselt) ",
+    "Something went wrong, connection is closed": "Etwas lief schief, Verbindung wurde getrennt",
+    "Disconnected": "Verbindung zum Server getrennt",
+    "New connection has been rejected with reason: ": "Verbindung wurde aus folgendem Grund abgelehnt: ",
+    "New connection has been rejected": "Verbindung wurde abgelehnt",
+    "Password is required": "Passwort ist erforderlich",
+    "noVNC encountered an error:": "Ein Fehler ist aufgetreten:",
+    "Hide/Show the control bar": "Kontrollleiste verstecken/anzeigen",
+    "Move/Drag viewport": "Ansichtsfenster verschieben/ziehen",
+    "viewport drag": "Ansichtsfenster ziehen",
+    "Active Mouse Button": "Aktive Maustaste",
+    "No mousebutton": "Keine Maustaste",
+    "Left mousebutton": "Linke Maustaste",
+    "Middle mousebutton": "Mittlere Maustaste",
+    "Right mousebutton": "Rechte Maustaste",
+    "Keyboard": "Tastatur",
+    "Show keyboard": "Tastatur anzeigen",
+    "Extra keys": "Zusatztasten",
+    "Show extra keys": "Zusatztasten anzeigen",
+    "Ctrl": "Strg",
+    "Toggle Ctrl": "Strg umschalten",
+    "Alt": "Alt",
+    "Toggle Alt": "Alt umschalten",
+    "Send Tab": "Tab senden",
+    "Tab": "Tab",
+    "Esc": "Esc",
+    "Send Escape": "Escape senden",
+    "Ctrl+Alt+Del": "Strg+Alt+Entf",
+    "Send Ctrl-Alt-Del": "Strg+Alt+Entf senden",
+    "Shutdown/Reboot": "Herunterfahren/Neustarten",
+    "Shutdown/Reboot...": "Herunterfahren/Neustarten...",
+    "Power": "Energie",
+    "Shutdown": "Herunterfahren",
+    "Reboot": "Neustarten",
+    "Reset": "Zurรผcksetzen",
+    "Clipboard": "Zwischenablage",
+    "Clear": "Lรถschen",
+    "Fullscreen": "Vollbild",
+    "Settings": "Einstellungen",
+    "Shared mode": "Geteilter Modus",
+    "View only": "Nur betrachten",
+    "Clip to window": "Auf Fenster begrenzen",
+    "Scaling mode:": "Skalierungsmodus:",
+    "None": "Keiner",
+    "Local scaling": "Lokales skalieren",
+    "Remote resizing": "Serverseitiges skalieren",
+    "Advanced": "Erweitert",
+    "Repeater ID:": "Repeater ID:",
+    "WebSocket": "WebSocket",
+    "Encrypt": "Verschlรผsselt",
+    "Host:": "Server:",
+    "Port:": "Port:",
+    "Path:": "Pfad:",
+    "Automatic reconnect": "Automatisch wiederverbinden",
+    "Reconnect delay (ms):": "Wiederverbindungsverzรถgerung (ms):",
+    "Logging:": "Protokollierung:",
+    "Disconnect": "Verbindung trennen",
+    "Connect": "Verbinden",
+    "Password:": "Passwort:",
+    "Cancel": "Abbrechen",
+    "Canvas not supported.": "Canvas nicht unterstรผtzt.",
+    "Disconnect timeout": "Zeitรผberschreitung beim Trennen",
+    "Local Downscaling": "Lokales herunterskalieren",
+    "Local Cursor": "Lokaler Mauszeiger",
+    "Forcing clipping mode since scrollbars aren't supported by IE in fullscreen": "'Clipping-Modus' aktiviert, Scrollbalken in 'IE-Vollbildmodus' werden nicht unterstรผtzt",
+    "True Color": "True Color"
+}
\ No newline at end of file
pkg/web/noVNC/app/locale/el.json
@@ -0,0 +1,100 @@
+{
+    "HTTPS is required for full functionality": "ฮคฮฟ HTTPS ฮตฮฏฮฝฮฑฮน ฮฑฯ€ฮฑฮนฯ„ฮฟฯฮผฮตฮฝฮฟ ฮณฮนฮฑ ฯ€ฮปฮฎฯฮท ฮปฮตฮนฯ„ฮฟฯ…ฯฮณฮนฮบฯŒฯ„ฮทฯ„ฮฑ",
+    "Connecting...": "ฮฃฯ…ฮฝฮดฮญฮตฯ„ฮฑฮน...",
+    "Disconnecting...": "Aฯ€ฮฟฯƒฯ…ฮฝฮดฮญฮตฯ„ฮฑฮน...",
+    "Reconnecting...": "ฮ•ฯ€ฮฑฮฝฮฑฯƒฯ…ฮฝฮดฮญฮตฯ„ฮฑฮน...",
+    "Internal error": "ฮ•ฯƒฯ‰ฯ„ฮตฯฮนฮบฯŒ ฯƒฯ†ฮฌฮปฮผฮฑ",
+    "Must set host": "ฮ ฯฮญฯ€ฮตฮน ฮฝฮฑ ฮฟฯฮนฯƒฯ„ฮตฮฏ ฮฟ ฮดฮนฮฑฮบฮฟฮผฮนฯƒฯ„ฮฎฯ‚",
+    "Connected (encrypted) to ": "ฮฃฯ…ฮฝฮดฮญฮธฮทฮบฮต (ฮบฯฯ…ฯ€ฯ„ฮฟฮณฯฮฑฯ†ฮทฮผฮญฮฝฮฑ) ฮผฮต ฯ„ฮฟ ",
+    "Connected (unencrypted) to ": "ฮฃฯ…ฮฝฮดฮญฮธฮทฮบฮต (ฮผฮท ฮบฯฯ…ฯ€ฯ„ฮฟฮณฯฮฑฯ†ฮทฮผฮญฮฝฮฑ) ฮผฮต ฯ„ฮฟ ",
+    "Something went wrong, connection is closed": "ฮšฮฌฯ„ฮน ฯ€ฮฎฮณฮต ฯƒฯ„ฯฮฑฮฒฮฌ, ฮท ฯƒฯฮฝฮดฮตฯƒฮท ฮดฮนฮฑฮบฯŒฯ€ฮทฮบฮต",
+    "Failed to connect to server": "ฮ‘ฯ€ฮฟฯ„ฯ…ฯ‡ฮฏฮฑ ฯƒฯ„ฮท ฯƒฯฮฝฮดฮตฯƒฮท ฮผฮต ฯ„ฮฟ ฮดฮนฮฑฮบฮฟฮผฮนฯƒฯ„ฮฎ",
+    "Disconnected": "ฮ‘ฯ€ฮฟฯƒฯ…ฮฝฮดฮญฮธฮทฮบฮต",
+    "New connection has been rejected with reason: ": "ฮ— ฮฝฮญฮฑ ฯƒฯฮฝฮดฮตฯƒฮท ฮฑฯ€ฮฟฯฯฮฏฯ†ฮธฮทฮบฮต ฮดฮนฯŒฯ„ฮน: ",
+    "New connection has been rejected": "ฮ— ฮฝฮญฮฑ ฯƒฯฮฝฮดฮตฯƒฮท ฮฑฯ€ฮฟฯฯฮฏฯ†ฮธฮทฮบฮต ",
+    "Credentials are required": "ฮ‘ฯ€ฮฑฮนฯ„ฮฟฯฮฝฯ„ฮฑฮน ฮดฮนฮฑฯ€ฮนฯƒฯ„ฮตฯ…ฯ„ฮฎฯฮนฮฑ",
+    "noVNC encountered an error:": "ฯ„ฮฟ noVNC ฮฑฮฝฯ„ฮนฮผฮตฯ„ฯŽฯ€ฮนฯƒฮต ฮญฮฝฮฑ ฯƒฯ†ฮฌฮปฮผฮฑ:",
+    "Hide/Show the control bar": "ฮ‘ฯ€ฯŒฮบฯฯ…ฯˆฮท/ฮ•ฮผฯ†ฮฌฮฝฮนฯƒฮท ฮณฯฮฑฮผฮผฮฎฯ‚ ฮตฮปฮญฮณฯ‡ฮฟฯ…",
+    "Drag": "ฮฃฯฯฯƒฮนฮผฮฟ",
+    "Move/Drag Viewport": "ฮœฮตฯ„ฮฑฮบฮฏฮฝฮทฯƒฮท/ฮฃฯฯฯƒฮนฮผฮฟ ฮ˜ฮตฮฑฯ„ฮฟฯ ฯ€ฮตฮดฮฏฮฟฯ…",
+    "Keyboard": "ฮ ฮปฮทฮบฯ„ฯฮฟฮปฯŒฮณฮนฮฟ",
+    "Show Keyboard": "ฮ•ฮผฯ†ฮฌฮฝฮนฯƒฮท ฮ ฮปฮทฮบฯ„ฯฮฟฮปฮฟฮณฮฏฮฟฯ…",
+    "Extra keys": "ฮ•ฯ€ฮนฯ€ฮปฮญฮฟฮฝ ฯ€ฮปฮฎฮบฯ„ฯฮฑ",
+    "Show Extra Keys": "ฮ•ฮผฯ†ฮฌฮฝฮนฯƒฮท ฮ•ฯ€ฮนฯ€ฮปฮญฮฟฮฝ ฮ ฮปฮฎฮบฯ„ฯฯ‰ฮฝ",
+    "Ctrl": "Ctrl",
+    "Toggle Ctrl": "ฮ•ฮฝฮฑฮปฮปฮฑฮณฮฎ Ctrl",
+    "Alt": "Alt",
+    "Toggle Alt": "ฮ•ฮฝฮฑฮปฮปฮฑฮณฮฎ Alt",
+    "Toggle Windows": "ฮ•ฮฝฮฑฮปฮปฮฑฮณฮฎ ฮ ฮฑฯฮฌฮธฯ…ฯฯ‰ฮฝ",
+    "Windows": "ฮ ฮฑฯฮฌฮธฯ…ฯฮฑ",
+    "Send Tab": "ฮ‘ฯ€ฮฟฯƒฯ„ฮฟฮปฮฎ Tab",
+    "Tab": "Tab",
+    "Esc": "Esc",
+    "Send Escape": "ฮ‘ฯ€ฮฟฯƒฯ„ฮฟฮปฮฎ Escape",
+    "Ctrl+Alt+Del": "Ctrl+Alt+Del",
+    "Send Ctrl-Alt-Del": "ฮ‘ฯ€ฮฟฯƒฯ„ฮฟฮปฮฎ Ctrl-Alt-Del",
+    "Shutdown/Reboot": "ฮšฮปฮตฮฏฯƒฮนฮผฮฟ/ฮ•ฯ€ฮฑฮฝฮตฮบฮบฮฏฮฝฮทฯƒฮท",
+    "Shutdown/Reboot...": "ฮšฮปฮตฮฏฯƒฮนฮผฮฟ/ฮ•ฯ€ฮฑฮฝฮตฮบฮบฮฏฮฝฮทฯƒฮท...",
+    "Power": "ฮ‘ฯ€ฮตฮฝฮตฯฮณฮฟฯ€ฮฟฮฏฮทฯƒฮท",
+    "Shutdown": "ฮšฮปฮตฮฏฯƒฮนฮผฮฟ",
+    "Reboot": "ฮ•ฯ€ฮฑฮฝฮตฮบฮบฮฏฮฝฮทฯƒฮท",
+    "Reset": "ฮ•ฯ€ฮฑฮฝฮฑฯ†ฮฟฯฮฌ",
+    "Clipboard": "ฮ ฯฯŒฯ‡ฮตฮนฯฮฟ",
+    "Edit clipboard content in the textarea below.": "ฮ•ฯ€ฮตฮพฮตฯฮณฮฑฯƒฯ„ฮตฮฏฯ„ฮต ฯ„ฮฟ ฯ€ฮตฯฮนฮตฯ‡ฯŒฮผฮตฮฝฮฟ ฯ„ฮฟฯ… ฯ€ฯฯŒฯ‡ฮตฮนฯฮฟฯ… ฯƒฯ„ฮทฮฝ ฯ€ฮตฯฮนฮฟฯ‡ฮฎ ฮบฮตฮนฮผฮญฮฝฮฟฯ… ฯ€ฮฑฯฮฑฮบฮฌฯ„ฯ‰.",
+    "Full Screen": "ฮ ฮปฮฎฯฮทฯ‚ ฮŸฮธฯŒฮฝฮท",
+    "Settings": "ฮกฯ…ฮธฮผฮฏฯƒฮตฮนฯ‚",
+    "Shared Mode": "ฮšฮฟฮนฮฝฯŒฯ‡ฯฮทฯƒฯ„ฮท ฮ›ฮตฮนฯ„ฮฟฯ…ฯฮณฮฏฮฑ",
+    "View Only": "ฮœฯŒฮฝฮฟ ฮ˜ฮญฮฑฯƒฮท",
+    "Clip to Window": "ฮ‘ฯ€ฮฟฮบฮฟฯ€ฮฎ ฯƒฯ„ฮฟ ฯŒฯฮนฮฟ ฯ„ฮฟฯ… ฮ ฮฑฯฮฌฮธฯ…ฯฮฟฯ…",
+    "Scaling Mode:": "ฮ›ฮตฮนฯ„ฮฟฯ…ฯฮณฮฏฮฑ ฮšฮปฮนฮผฮฌฮบฯ‰ฯƒฮทฯ‚:",
+    "None": "ฮšฮฑฮผฮฏฮฑ",
+    "Local Scaling": "ฮคฮฟฯ€ฮนฮบฮฎ ฮšฮปฮนฮผฮฌฮบฯ‰ฯƒฮท",
+    "Remote Resizing": "ฮ‘ฯ€ฮฟฮผฮฑฮบฯฯ…ฯƒฮผฮญฮฝฮท ฮ‘ฮปฮปฮฑฮณฮฎ ฮผฮตฮณฮญฮธฮฟฯ…ฯ‚",
+    "Advanced": "ฮ“ฮนฮฑ ฯ€ฯฮฟฯ‡ฯ‰ฯฮทฮผฮญฮฝฮฟฯ…ฯ‚",
+    "Quality:": "ฮ ฮฟฮนฯŒฯ„ฮทฯ„ฮฑ:",
+    "Compression level:": "ฮ•ฯ€ฮฏฯ€ฮตฮดฮฟ ฯƒฯ…ฮผฯ€ฮฏฮตฯƒฮทฯ‚:",
+    "Repeater ID:": "Repeater ID:",
+    "WebSocket": "WebSocket",
+    "Encrypt": "ฮšฯฯ…ฯ€ฯ„ฮฟฮณฯฮฌฯ†ฮทฯƒฮท",
+    "Host:": "ฮŒฮฝฮฟฮผฮฑ ฮดฮนฮฑฮบฮฟฮผฮนฯƒฯ„ฮฎ:",
+    "Port:": "ฮ ฯŒฯฯ„ฮฑ ฮดฮนฮฑฮบฮฟฮผฮนฯƒฯ„ฮฎ:",
+    "Path:": "ฮ”ฮนฮฑฮดฯฮฟฮผฮฎ:",
+    "Automatic Reconnect": "ฮ‘ฯ…ฯ„ฯŒฮผฮฑฯ„ฮท ฮตฯ€ฮฑฮฝฮฑฯƒฯฮฝฮดฮตฯƒฮท",
+    "Reconnect Delay (ms):": "ฮšฮฑฮธฯ…ฯƒฯ„ฮญฯฮทฯƒฮท ฮตฯ€ฮฑฮฝฮฑฯƒฯฮฝฮดฮตฯƒฮทฯ‚ (ms):",
+    "Show Dot when No Cursor": "ฮ•ฮผฯ†ฮฌฮฝฮนฯƒฮท ฮคฮตฮปฮตฮฏฮฑฯ‚ ฯŒฯ„ฮฑฮฝ ฮดฮตฮฝ ฯ…ฯ€ฮฌฯฯ‡ฮตฮน ฮ”ฯฮฟฮผฮญฮฑฯ‚",
+    "Logging:": "ฮšฮฑฯ„ฮฑฮณฯฮฑฯ†ฮฎ:",
+    "Version:": "ฮˆฮบฮดฮฟฯƒฮท:",
+    "Disconnect": "ฮ‘ฯ€ฮฟฯƒฯฮฝฮดฮตฯƒฮท",
+    "Connect": "ฮฃฯฮฝฮดฮตฯƒฮท",
+    "Server identity": "ฮคฮฑฯ…ฯ„ฯŒฯ„ฮทฯ„ฮฑ ฮ”ฮนฮฑฮบฮฟฮผฮนฯƒฯ„ฮฎ",
+    "The server has provided the following identifying information:": "ฮŸ ฮดฮนฮฑฮบฮฟฮผฮนฯƒฯ„ฮฎฯ‚ ฯ€ฮฑฯฮตฮฏฯ‡ฮต ฯ„ฮทฮฝ ฮฑฮบฯŒฮปฮฟฯ…ฮธฮท ฯ€ฮปฮทฯฮฟฯ†ฮฟฯฮฏฮฑ ฯ„ฮฑฯ…ฯ„ฮฟฯ€ฮฟฮฏฮทฯƒฮทฯ‚:",
+    "Fingerprint:": "ฮ”ฮฑฮบฯ„ฯ…ฮปฮนฮบฯŒ ฮฑฯ€ฮฟฯ„ฯฯ€ฯ‰ฮผฮฑ:",
+    "Please verify that the information is correct and press \"Approve\". Otherwise press \"Reject\".": "ฮ ฮฑฯฮฑฮบฮฑฮปฯŽ ฮตฯ€ฮฑฮปฮทฮธฮตฯฯƒฮตฯ„ฮต ฯŒฯ„ฮน ฮท ฯ€ฮปฮทฯฮฟฯ†ฮฟฯฮฏฮฑ ฮตฮฏฮฝฮฑฮน ฯƒฯ‰ฯƒฯ„ฮฎ ฮบฮฑฮน ฯ€ฮนฮญฯƒฯ„ฮต \"ฮ‘ฯ€ฮฟฮดฮฟฯ‡ฮฎ\". ฮ‘ฮปฮปฮนฯŽฯ‚ ฯ€ฮนฮญฯƒฯ„ฮต \"ฮ‘ฯ€ฯŒฯฯฮนฯˆฮท\".",
+    "Approve": "ฮ‘ฯ€ฮฟฮดฮฟฯ‡ฮฎ",
+    "Reject": "ฮ‘ฯ€ฯŒฯฯฮนฯˆฮท",
+    "Credentials": "ฮ”ฮนฮฑฯ€ฮนฯƒฯ„ฮตฯ…ฯ„ฮฎฯฮนฮฑ",
+    "Username:": "ฮšฯ‰ฮดฮนฮบฯŒฯ‚ ฮงฯฮฎฯƒฯ„ฮท:",
+    "Password:": "ฮšฯ‰ฮดฮนฮบฯŒฯ‚ ฮ ฯฯŒฯƒฮฒฮฑฯƒฮทฯ‚:",
+    "Send Credentials": "ฮ‘ฯ€ฮฟฯƒฯ„ฮฟฮปฮฎ ฮ”ฮนฮฑฯ€ฮนฯƒฯ„ฮตฯ…ฯ„ฮทฯฮฏฯ‰ฮฝ",
+    "Cancel": "ฮ‘ฮบฯฯฯ‰ฯƒฮท",
+    "Password is required": "ฮ‘ฯ€ฮฑฮนฯ„ฮตฮฏฯ„ฮฑฮน ฮฟ ฮบฯ‰ฮดฮนฮบฯŒฯ‚ ฯ€ฯฯŒฯƒฮฒฮฑฯƒฮทฯ‚",
+    "viewport drag": "ฯƒฯฯฯƒฮนฮผฮฟ ฮธฮตฮฑฯ„ฮฟฯ ฯ€ฮตฮดฮฏฮฟฯ…",
+    "Active Mouse Button": "ฮ•ฮฝฮตฯฮณฯŒ ฮ ฮปฮฎฮบฯ„ฯฮฟ ฮ ฮฟฮฝฯ„ฮนฮบฮนฮฟฯ",
+    "No mousebutton": "ฮงฯ‰ฯฮฏฯ‚ ฮ ฮปฮฎฮบฯ„ฯฮฟ ฮ ฮฟฮฝฯ„ฮนฮบฮนฮฟฯ",
+    "Left mousebutton": "ฮ‘ฯฮนฯƒฯ„ฮตฯฯŒ ฮ ฮปฮฎฮบฯ„ฯฮฟ ฮ ฮฟฮฝฯ„ฮนฮบฮนฮฟฯ",
+    "Middle mousebutton": "ฮœฮตฯƒฮฑฮฏฮฟ ฮ ฮปฮฎฮบฯ„ฯฮฟ ฮ ฮฟฮฝฯ„ฮนฮบฮนฮฟฯ",
+    "Right mousebutton": "ฮ”ฮตฮพฮฏ ฮ ฮปฮฎฮบฯ„ฯฮฟ ฮ ฮฟฮฝฯ„ฮนฮบฮนฮฟฯ",
+    "Clear": "ฮšฮฑฮธฮฌฯฮนฯƒฮผฮฑ",
+    "Canvas not supported.": "ฮ”ฮตฮฝ ฯ…ฯ€ฮฟฯƒฯ„ฮทฯฮฏฮถฮตฯ„ฮฑฮน ฯ„ฮฟ ฯƒฯ„ฮฟฮนฯ‡ฮตฮฏฮฟ Canvas",
+    "Disconnect timeout": "ฮ ฮฑฯฮญฮปฮตฯ…ฯƒฮท ฯ‡ฯฮฟฮฝฮนฮบฮฟฯ ฮฟฯฮฏฮฟฯ… ฮฑฯ€ฮฟฯƒฯฮฝฮดฮตฯƒฮทฯ‚",
+    "Local Downscaling": "ฮคฮฟฯ€ฮนฮบฮฎ ฮฃฯ…ฯฯฮฏฮบฮฝฯ‰ฯƒฮท",
+    "Local Cursor": "ฮคฮฟฯ€ฮนฮบฯŒฯ‚ ฮ”ฯฮฟฮผฮญฮฑฯ‚",
+    "Forcing clipping mode since scrollbars aren't supported by IE in fullscreen": "ฮ•ฯ†ฮฑฯฮผฮฟฮณฮฎ ฮปฮตฮนฯ„ฮฟฯ…ฯฮณฮฏฮฑฯ‚ ฮฑฯ€ฮฟฮบฮฟฯ€ฮฎฯ‚ ฮฑฯ†ฮฟฯ ฮดฮตฮฝ ฯ…ฯ€ฮฟฯƒฯ„ฮทฯฮฏฮถฮฟฮฝฯ„ฮฑฮน ฮฟฮน ฮปฯ‰ฯฮฏฮดฮตฯ‚ ฮบฯฮปฮนฯƒฮทฯ‚ ฯƒฮต ฯ€ฮปฮฎฯฮท ฮฟฮธฯŒฮฝฮท ฯƒฯ„ฮฟฮฝ IE",
+    "True Color": "ฮ ฯฮฑฮณฮผฮฑฯ„ฮนฮบฮฌ ฮงฯฯŽฮผฮฑฯ„ฮฑ",
+    "Style:": "ฮฃฯ„ฯ…ฮป:",
+    "default": "ฯ€ฯฮฟฮตฯ€ฮนฮปฮตฮณฮผฮญฮฝฮฟ",
+    "Apply": "ฮ•ฯ†ฮฑฯฮผฮฟฮณฮฎ",
+    "Connection": "ฮฃฯฮฝฮดฮตฯƒฮท",
+    "Token:": "ฮ”ฮนฮฑฮบฯฮนฯ„ฮนฮบฯŒ:",
+    "Send Password": "ฮ‘ฯ€ฮฟฯƒฯ„ฮฟฮปฮฎ ฮšฯ‰ฮดฮนฮบฮฟฯ ฮ ฯฯŒฯƒฮฒฮฑฯƒฮทฯ‚"
+}
\ No newline at end of file
pkg/web/noVNC/app/locale/es.json
@@ -0,0 +1,68 @@
+{
+    "Connecting...": "Conectando...",
+    "Connected (encrypted) to ": "Conectado (con encriptaciรณn) a",
+    "Connected (unencrypted) to ": "Conectado (sin encriptaciรณn) a",
+    "Disconnecting...": "Desconectando...",
+    "Disconnected": "Desconectado",
+    "Must set host": "Se debe configurar el host",
+    "Reconnecting...": "Reconectando...",
+    "Password is required": "La contraseรฑa es obligatoria",
+    "Disconnect timeout": "Tiempo de desconexiรณn agotado",
+    "noVNC encountered an error:": "noVNC ha encontrado un error:",
+    "Hide/Show the control bar": "Ocultar/Mostrar la barra de control",
+    "Move/Drag viewport": "Mover/Arrastrar la ventana",
+    "viewport drag": "Arrastrar la ventana",
+    "Active Mouse Button": "Botรณn activo del ratรณn",
+    "No mousebutton": "Ningรบn botรณn del ratรณn",
+    "Left mousebutton": "Botรณn izquierdo del ratรณn",
+    "Middle mousebutton": "Botรณn central del ratรณn",
+    "Right mousebutton": "Botรณn derecho del ratรณn",
+    "Keyboard": "Teclado",
+    "Show keyboard": "Mostrar teclado",
+    "Extra keys": "Teclas adicionales",
+    "Show Extra Keys": "Mostrar Teclas Adicionales",
+    "Ctrl": "Ctrl",
+    "Toggle Ctrl": "Pulsar/Soltar Ctrl",
+    "Alt": "Alt",
+    "Toggle Alt": "Pulsar/Soltar Alt",
+    "Send Tab": "Enviar Tabulaciรณn",
+    "Tab": "Tabulaciรณn",
+    "Esc": "Esc",
+    "Send Escape": "Enviar Escape",
+    "Ctrl+Alt+Del": "Ctrl+Alt+Del",
+    "Send Ctrl-Alt-Del": "Enviar Ctrl+Alt+Del",
+    "Shutdown/Reboot": "Apagar/Reiniciar",
+    "Shutdown/Reboot...": "Apagar/Reiniciar...",
+    "Power": "Encender",
+    "Shutdown": "Apagar",
+    "Reboot": "Reiniciar",
+    "Reset": "Restablecer",
+    "Clipboard": "Portapapeles",
+    "Clear": "Vaciar",
+    "Fullscreen": "Pantalla Completa",
+    "Settings": "Configuraciones",
+    "Encrypt": "Encriptar",
+    "Shared Mode": "Modo Compartido",
+    "View only": "Solo visualizaciรณn",
+    "Clip to window": "Recortar al tamaรฑo de la ventana",
+    "Scaling mode:": "Modo de escalado:",
+    "None": "Ninguno",
+    "Local Scaling": "Escalado Local",
+    "Local Downscaling": "Reducciรณn de escala local",
+    "Remote resizing": "Cambio de tamaรฑo remoto",
+    "Advanced": "Avanzado",
+    "Local Cursor": "Cursor Local",
+    "Repeater ID:": "ID del Repetidor:",
+    "WebSocket": "WebSocket",
+    "Host:": "Host:",
+    "Port:": "Puerto:",
+    "Path:": "Ruta:",
+    "Automatic reconnect": "Reconexiรณn automรกtica",
+    "Reconnect delay (ms):": "Retraso en la reconexiรณn (ms):",
+    "Logging:": "Registrando:",
+    "Disconnect": "Desconectar",
+    "Connect": "Conectar",
+    "Password:": "Contraseรฑa:",
+    "Cancel": "Cancelar",
+    "Canvas not supported.": "Canvas no soportado."
+}
\ No newline at end of file
pkg/web/noVNC/app/locale/fr.json
@@ -0,0 +1,82 @@
+{
+    "Running without HTTPS is not recommended, crashes or other issues are likely.": "Lancer sans HTTPS n'est pas recommandรฉ, crashs ou autres problรจmes en vue.",
+    "Connecting...": "En cours de connexion...",
+    "Disconnecting...": "Dรฉconnexion en cours...",
+    "Reconnecting...": "Reconnexion en cours...",
+    "Internal error": "Erreur interne",
+    "Failed to connect to server: ": "ร‰chec de connexion au serveur ",
+    "Connected (encrypted) to ": "Connectรฉ (chiffrรฉ) ร  ",
+    "Connected (unencrypted) to ": "Connectรฉ (non chiffrรฉ) ร  ",
+    "Something went wrong, connection is closed": "Quelque chose s'est mal passรฉ, la connexion a รฉtรฉ fermรฉe",
+    "Failed to connect to server": "ร‰chec de connexion au serveur",
+    "Disconnected": "Dรฉconnectรฉ",
+    "New connection has been rejected with reason: ": "Une nouvelle connexion a รฉtรฉ rejetรฉe avec motif : ",
+    "New connection has been rejected": "Une nouvelle connexion a รฉtรฉ rejetรฉe",
+    "Credentials are required": "Les identifiants sont requis",
+    "noVNC encountered an error:": "noVNC a rencontrรฉ une erreur :",
+    "Hide/Show the control bar": "Masquer/Afficher la barre de contrรดle",
+    "Drag": "Faire glisser",
+    "Move/Drag viewport": "Dรฉplacer la fenรชtre de visualisation",
+    "Keyboard": "Clavier",
+    "Show keyboard": "Afficher le clavier",
+    "Extra keys": "Touches supplรฉmentaires",
+    "Show extra keys": "Afficher les touches supplรฉmentaires",
+    "Ctrl": "Ctrl",
+    "Toggle Ctrl": "Basculer Ctrl",
+    "Alt": "Alt",
+    "Toggle Alt": "Basculer Alt",
+    "Toggle Windows": "Basculer Windows",
+    "Windows": "Fenรชtre",
+    "Send Tab": "Envoyer Tab",
+    "Tab": "Tabulation",
+    "Esc": "Esc",
+    "Send Escape": "Envoyer Escape",
+    "Ctrl+Alt+Del": "Ctrl+Alt+Del",
+    "Send Ctrl-Alt-Del": "Envoyer Ctrl-Alt-Del",
+    "Shutdown/Reboot": "Arrรชter/Redรฉmarrer",
+    "Shutdown/Reboot...": "Arrรชter/Redรฉmarrer...",
+    "Power": "Alimentation",
+    "Shutdown": "Arrรชter",
+    "Reboot": "Redรฉmarrer",
+    "Reset": "Rรฉinitialiser",
+    "Clipboard": "Presse-papiers",
+    "Edit clipboard content in the textarea below.": "Editer le contenu du presse-papier dans la zone ci-dessous.",
+    "Full screen": "Plein รฉcran",
+    "Settings": "Paramรจtres",
+    "Shared mode": "Mode partagรฉ",
+    "View only": "Afficher uniquement",
+    "Clip to window": "Ajuster ร  la fenรชtre",
+    "Scaling mode:": "Mode mise ร  l'รฉchelle :",
+    "None": "Aucun",
+    "Local scaling": "Mise ร  l'รฉchelle locale",
+    "Remote resizing": "Redimensionnement ร  distance",
+    "Advanced": "Avancรฉ",
+    "Quality:": "Qualitรฉ :",
+    "Compression level:": "Niveau de compression :",
+    "Repeater ID:": "ID Rรฉpรฉteur :",
+    "WebSocket": "WebSocket",
+    "Encrypt": "Chiffrer",
+    "Host:": "Hรดte :",
+    "Port:": "Port :",
+    "Path:": "Chemin :",
+    "Automatic reconnect": "Reconnecter automatiquement",
+    "Reconnect delay (ms):": "Dรฉlai de reconnexion (ms) :",
+    "Show dot when no cursor": "Afficher le point lorsqu'il n'y a pas de curseur",
+    "Logging:": "Se connecter :",
+    "Version:": "Version :",
+    "Disconnect": "Dรฉconnecter",
+    "Connect": "Connecter",
+    "Server identity": "Identitรฉ du serveur",
+    "The server has provided the following identifying information:": "Le serveur a fourni l'identification suivante :",
+    "Fingerprint:": "Empreinte digitale :",
+    "Please verify that the information is correct and press \"Approve\". Otherwise press \"Reject\".": "SVP, verifiez que l'information est correcte et pressez \"Accepter\". Sinon pressez \"Refuser\".",
+    "Approve": "Accepter",
+    "Reject": "Refuser",
+    "Credentials": "Envoyer les identifiants",
+    "Username:": "Nom d'utilisateur :",
+    "Password:": "Mot de passe :",
+    "Send credentials": "Envoyer les identifiants",
+    "Cancel": "Annuler",
+    "Must set host": "Doit dรฉfinir l'hรดte",
+    "Clear": "Effacer"
+}
\ No newline at end of file
pkg/web/noVNC/app/locale/hu.json
@@ -0,0 +1,80 @@
+{
+    "Running without HTTPS is not recommended, crashes or other issues are likely.": "HTTPS nรฉlkรผl futtatni nem ajรกnlott, รถsszeomlรกsok vagy mรกs problรฉmรกk vรกrhatรณk.",
+    "Connecting...": "Kapcsolรณdรกs...",
+    "Disconnecting...": "Kapcsolat bontรกsa...",
+    "Reconnecting...": "รšjrakapcsolรณdรกs...",
+    "Internal error": "Belsล‘ hiba",
+    "Failed to connect to server: ": "Nem sikerรผlt csatlakozni a szerverhez: ",
+    "Connected (encrypted) to ": "Kapcsolรณdva (titkosรญtva) ehhez: ",
+    "Connected (unencrypted) to ": "Kapcsolรณdva (titkosรญtatlanul) ehhez: ",
+    "Something went wrong, connection is closed": "Valami hiba tรถrtรฉnt, a kapcsolat lezรกrult",
+    "Failed to connect to server": "Nem sikerรผlt csatlakozni a szerverhez",
+    "Disconnected": "Kapcsolat bontva",
+    "New connection has been rejected with reason: ": "Az รบj kapcsolat elutasรญtva, indok: ",
+    "New connection has been rejected": "Az รบj kapcsolat elutasรญtva",
+    "Credentials are required": "Hitelesรญtล‘ adatok szรผksรฉgesek",
+    "noVNC encountered an error:": "A noVNC hibรกt รฉszlelt:",
+    "Hide/Show the control bar": "Vezรฉrlล‘sรกv elrejtรฉse/megjelenรญtรฉse",
+    "Drag": "Hรบzรกs",
+    "Move/Drag viewport": "Nรฉzet mozgatรกsa/hรบzรกsa",
+    "Keyboard": "Billentyลฑzet",
+    "Show keyboard": "Billentyลฑzet megjelenรญtรฉse",
+    "Extra keys": "Extra billentyลฑk",
+    "Show extra keys": "Extra billentyลฑk megjelenรญtรฉse",
+    "Ctrl": "Ctrl",
+    "Toggle Ctrl": "Ctrl lenyomรกsa/felengedรฉse",
+    "Alt": "Alt",
+    "Toggle Alt": "Alt lenyomรกsa/felengedรฉse",
+    "Toggle Windows": "Windows lenyomรกsa/felengedรฉse",
+    "Windows": "Windows",
+    "Send Tab": "Tab kรผldรฉse",
+    "Tab": "Tab",
+    "Esc": "Esc",
+    "Send Escape": "Escape kรผldรฉse",
+    "Ctrl+Alt+Del": "Ctrl+Alt+Del",
+    "Send Ctrl-Alt-Del": "Ctrl-Alt-Del kรผldรฉse",
+    "Shutdown/Reboot": "Leรกllรญtรกs/รšjraindรญtรกs",
+    "Shutdown/Reboot...": "Leรกllรญtรกs/รšjraindรญtรกs...",
+    "Power": "Bekapcsolรกs",
+    "Shutdown": "Leรกllรญtรกs",
+    "Reboot": "รšjraindรญtรกs",
+    "Reset": "Reset",
+    "Clipboard": "Vรกgรณlap",
+    "Edit clipboard content in the textarea below.": "Itt tudod mรณdosรญtani a vรกgรณlap tartalmรกt.",
+    "Full screen": "Teljes kรฉpernyล‘",
+    "Settings": "Beรกllรญtรกsok",
+    "Shared mode": "Megosztott mรณd",
+    "View only": "Csak megtekintรฉs",
+    "Clip to window": "Ablakhoz igazรญtรกs",
+    "Scaling mode:": "Mรฉretezรฉsi mรณd:",
+    "None": "Nincs",
+    "Local scaling": "Helyi mรฉretezรฉs",
+    "Remote resizing": "Tรกvoli รกtmรฉretezรฉs",
+    "Advanced": "Speciรกlis",
+    "Quality:": "Minล‘sรฉg:",
+    "Compression level:": "Tรถmรถrรญtรฉsi szint:",
+    "Repeater ID:": "Ismรฉtlล‘ azonosรญtรณ:",
+    "WebSocket": "WebSocket",
+    "Encrypt": "Titkosรญtรกs",
+    "Host:": "Hoszt:",
+    "Port:": "Port:",
+    "Path:": "รštvonal:",
+    "Automatic reconnect": "Automatikus รบjracsatlakozรกs",
+    "Reconnect delay (ms):": "รšjracsatlakozรกs kรฉsleltetรฉse (ms):",
+    "Show dot when no cursor": "Kurzor hiรกnyรกban pont mutatรกsa",
+    "Logging:": "Naplรณzรกs:",
+    "Version:": "Verziรณ:",
+    "Disconnect": "Kapcsolat bontรกsa",
+    "Connect": "Csatlakozรกs",
+    "Server identity": "Szerver azonosรญtรณ",
+    "The server has provided the following identifying information:": "A szerver a kรถvetkezล‘ azonosรญtรณ informรกciรณt adta meg:",
+    "Fingerprint:": "Ujjlenyomat:",
+    "Please verify that the information is correct and press \"Approve\". Otherwise press \"Reject\".": "Ellenล‘rizze, hogy az informรกciรณ helyes-e รฉs nyomja meg a \"Jรณvรกhagyรกs\" gombot. Ellenkezล‘ esetben nyomja meg az \"Elutasรญtรกs\" gombot.",
+    "Approve": "Jรณvรกhagyรกs",
+    "Reject": "Elutasรญtรกs",
+    "Credentials": "Hitelesรญtล‘ adatok",
+    "Username:": "Felhasznรกlรณnรฉv:",
+    "Password:": "Jelszรณ:",
+    "Send credentials": "Hitelesรญtล‘ adatok kรผldรฉse",
+    "Cancel": "Mรฉgse"
+}
\ No newline at end of file
pkg/web/noVNC/app/locale/it.json
@@ -0,0 +1,68 @@
+{
+    "Connecting...": "Connessione in corso...",
+    "Disconnecting...": "Disconnessione...",
+    "Reconnecting...": "Riconnessione...",
+    "Internal error": "Errore interno",
+    "Must set host": "Devi impostare l'host",
+    "Connected (encrypted) to ": "Connesso (crittografato) a ",
+    "Connected (unencrypted) to ": "Connesso (non crittografato) a",
+    "Something went wrong, connection is closed": "Qualcosa รจ andato storto, la connessione รจ stata chiusa",
+    "Failed to connect to server": "Impossibile connettersi al server",
+    "Disconnected": "Disconnesso",
+    "New connection has been rejected with reason: ": "La nuova connessione รจ stata rifiutata con motivo: ",
+    "New connection has been rejected": "La nuova connessione รจ stata rifiutata",
+    "Credentials are required": "Le credenziali sono obbligatorie",
+    "noVNC encountered an error:": "noVNC ha riscontrato un errore:",
+    "Hide/Show the control bar": "Nascondi/Mostra la barra di controllo",
+    "Keyboard": "Tastiera",
+    "Show keyboard": "Mostra tastiera",
+    "Extra keys": "Tasti Aggiuntivi",
+    "Show Extra Keys": "Mostra Tasti Aggiuntivi",
+    "Ctrl": "Ctrl",
+    "Toggle Ctrl": "Tieni premuto Ctrl",
+    "Alt": "Alt",
+    "Toggle Alt": "Tieni premuto Alt",
+    "Toggle Windows": "Tieni premuto Windows",
+    "Windows": "Windows",
+    "Send Tab": "Invia Tab",
+    "Tab": "Tab",
+    "Esc": "Esc",
+    "Send Escape": "Invia Esc",
+    "Ctrl+Alt+Del": "Ctrl+Alt+Canc",
+    "Send Ctrl-Alt-Del": "Invia Ctrl-Alt-Canc",
+    "Shutdown/Reboot": "Spegnimento/Riavvio",
+    "Shutdown/Reboot...": "Spegnimento/Riavvio...",
+    "Power": "Alimentazione",
+    "Shutdown": "Spegnimento",
+    "Reboot": "Riavvio",
+    "Reset": "Reset",
+    "Clipboard": "Clipboard",
+    "Clear": "Pulisci",
+    "Fullscreen": "Schermo intero",
+    "Settings": "Impostazioni",
+    "Shared mode": "Modalitร  condivisa",
+    "View Only": "Sola Visualizzazione",
+    "Scaling mode:": "Modalitร  di ridimensionamento:",
+    "None": "Nessuna",
+    "Local Scaling": "Ridimensionamento Locale",
+    "Remote Resizing": "Ridimensionamento Remoto",
+    "Advanced": "Avanzate",
+    "Quality:": "Qualitร :",
+    "Compression level:": "Livello Compressione:",
+    "Repeater ID:": "ID Ripetitore:",
+    "WebSocket": "WebSocket",
+    "Encrypt": "Crittografa",
+    "Host:": "Host:",
+    "Port:": "Porta:",
+    "Path:": "Percorso:",
+    "Automatic Reconnect": "Riconnessione Automatica",
+    "Reconnect Delay (ms):": "Ritardo Riconnessione (ms):",
+    "Show Dot when No Cursor": "Mostra Punto quando Nessun Cursore",
+    "Version:": "Versione:",
+    "Disconnect": "Disconnetti",
+    "Connect": "Connetti",
+    "Username:": "Utente:",
+    "Password:": "Password:",
+    "Send Credentials": "Invia Credenziale",
+    "Cancel": "Annulla"
+}
\ No newline at end of file
pkg/web/noVNC/app/locale/ja.json
@@ -0,0 +1,81 @@
+{
+    "Running without HTTPS is not recommended, crashes or other issues are likely.": "HTTPSๆŽฅ็ถšใชใ—ใงๅฎŸ่กŒใ™ใ‚‹ใ“ใจใฏๆŽจๅฅจใ•ใ‚Œใพใ›ใ‚“ใ€‚ใ‚ฏใƒฉใƒƒใ‚ทใƒฅใ—ใŸใ‚Šใใฎไป–ใฎๅ•้กŒใŒ็™บ็”Ÿใ—ใŸใ‚Šใ™ใ‚‹ๅฏ่ƒฝๆ€งใŒใ‚ใ‚Šใพใ™ใ€‚",
+    "Connecting...": "ๆŽฅ็ถšใ—ใฆใ„ใพใ™...",
+    "Disconnecting...": "ๅˆ‡ๆ–ญใ—ใฆใ„ใพใ™...",
+    "Reconnecting...": "ๅ†ๆŽฅ็ถšใ—ใฆใ„ใพใ™...",
+    "Internal error": "ๅ†…้ƒจใ‚จใƒฉใƒผ",
+    "Must set host": "ใƒ›ใ‚นใƒˆใ‚’่จญๅฎšใ™ใ‚‹ๅฟ…่ฆใŒใ‚ใ‚Šใพใ™",
+    "Failed to connect to server: ": "ใ‚ตใƒผใƒใƒผใธใฎๆŽฅ็ถšใซๅคฑๆ•—ใ—ใพใ—ใŸ: ",
+    "Connected (encrypted) to ": "ๆŽฅ็ถšใ—ใพใ—ใŸ (ๆš—ๅทๅŒ–ๆธˆใฟ): ",
+    "Connected (unencrypted) to ": "ๆŽฅ็ถšใ—ใพใ—ใŸ (ๆš—ๅทๅŒ–ใ•ใ‚Œใฆใ„ใพใ›ใ‚“): ",
+    "Something went wrong, connection is closed": "ๅ•้กŒใŒ็™บ็”Ÿใ—ใŸใŸใ‚ใ€ๆŽฅ็ถšใŒ้–‰ใ˜ใ‚‰ใ‚Œใพใ—ใŸ",
+    "Failed to connect to server": "ใ‚ตใƒผใƒใƒผใธใฎๆŽฅ็ถšใซๅคฑๆ•—ใ—ใพใ—ใŸ",
+    "Disconnected": "ๅˆ‡ๆ–ญใ—ใพใ—ใŸ",
+    "New connection has been rejected with reason: ": "ๆ–ฐ่ฆๆŽฅ็ถšใฏๆฌกใฎ็†็”ฑใงๆ‹’ๅฆใ•ใ‚Œใพใ—ใŸ: ",
+    "New connection has been rejected": "ๆ–ฐ่ฆๆŽฅ็ถšใฏๆ‹’ๅฆใ•ใ‚Œใพใ—ใŸ",
+    "Credentials are required": "่ณ‡ๆ ผๆƒ…ๅ ฑใŒๅฟ…่ฆใงใ™",
+    "noVNC encountered an error:": "noVNC ใงใ‚จใƒฉใƒผใŒ็™บ็”Ÿใ—ใพใ—ใŸ:",
+    "Hide/Show the control bar": "ใ‚ณใƒณใƒˆใƒญใƒผใƒซใƒใƒผใ‚’้š ใ™/่กจ็คบใ™ใ‚‹",
+    "Drag": "ใƒ‰ใƒฉใƒƒใ‚ฐ",
+    "Move/Drag viewport": "ใƒ“ใƒฅใƒผใƒใƒผใƒˆใ‚’็งปๅ‹•/ใƒ‰ใƒฉใƒƒใ‚ฐ",
+    "Keyboard": "ใ‚ญใƒผใƒœใƒผใƒ‰",
+    "Show keyboard": "ใ‚ญใƒผใƒœใƒผใƒ‰ใ‚’่กจ็คบ",
+    "Extra keys": "่ฟฝๅŠ ใ‚ญใƒผ",
+    "Show extra keys": "่ฟฝๅŠ ใ‚ญใƒผใ‚’่กจ็คบ",
+    "Ctrl": "Ctrl",
+    "Toggle Ctrl": "Ctrl ใ‚ญใƒผใ‚’ใƒˆใ‚ฐใƒซ",
+    "Alt": "Alt",
+    "Toggle Alt": "Alt ใ‚ญใƒผใ‚’ใƒˆใ‚ฐใƒซ",
+    "Toggle Windows": "Windows ใ‚ญใƒผใ‚’ใƒˆใ‚ฐใƒซ",
+    "Windows": "Windows",
+    "Send Tab": "Tab ใ‚ญใƒผใ‚’้€ไฟก",
+    "Tab": "Tab",
+    "Esc": "Esc",
+    "Send Escape": "Escape ใ‚ญใƒผใ‚’้€ไฟก",
+    "Ctrl+Alt+Del": "Ctrl+Alt+Del",
+    "Send Ctrl-Alt-Del": "Ctrl-Alt-Del ใ‚’้€ไฟก",
+    "Shutdown/Reboot": "ใ‚ทใƒฃใƒƒใƒˆใƒ€ใ‚ฆใƒณ/ๅ†่ตทๅ‹•",
+    "Shutdown/Reboot...": "ใ‚ทใƒฃใƒƒใƒˆใƒ€ใ‚ฆใƒณ/ๅ†่ตทๅ‹•...",
+    "Power": "้›ปๆบ",
+    "Shutdown": "ใ‚ทใƒฃใƒƒใƒˆใƒ€ใ‚ฆใƒณ",
+    "Reboot": "ๅ†่ตทๅ‹•",
+    "Reset": "ใƒชใ‚ปใƒƒใƒˆ",
+    "Clipboard": "ใ‚ฏใƒชใƒƒใƒ—ใƒœใƒผใƒ‰",
+    "Edit clipboard content in the textarea below.": "ไปฅไธ‹ใฎๅ…ฅๅŠ›ๆฌ„ใ‹ใ‚‰ใ‚ฏใƒชใƒƒใƒ—ใƒœใƒผใƒ‰ใฎๅ†…ๅฎนใ‚’็ทจ้›†ใงใใพใ™ใ€‚",
+    "Full screen": "ๅ…จ็”ป้ข่กจ็คบ",
+    "Settings": "่จญๅฎš",
+    "Shared mode": "ๅ…ฑๆœ‰ใƒขใƒผใƒ‰",
+    "View only": "่กจ็คบๅฐ‚็”จ",
+    "Clip to window": "ใ‚ฆใ‚ฃใƒณใƒ‰ใ‚ฆใซใ‚ฏใƒชใƒƒใƒ—",
+    "Scaling mode:": "ใ‚นใ‚ฑใƒผใƒชใƒณใ‚ฐใƒขใƒผใƒ‰:",
+    "None": "ใชใ—",
+    "Local scaling": "ใƒญใƒผใ‚ซใƒซใงใ‚นใ‚ฑใƒผใƒชใƒณใ‚ฐ",
+    "Remote resizing": "ใƒชใƒขใƒผใƒˆใงใƒชใ‚ตใ‚คใ‚บ",
+    "Advanced": "้ซ˜ๅบฆ",
+    "Quality:": "ๅ“่ณช:",
+    "Compression level:": "ๅœง็ธฎใƒฌใƒ™ใƒซ:",
+    "Repeater ID:": "ใƒชใƒ”ใƒผใ‚ฟใƒผ ID:",
+    "WebSocket": "WebSocket",
+    "Encrypt": "ๆš—ๅทๅŒ–",
+    "Host:": "ใƒ›ใ‚นใƒˆ:",
+    "Port:": "ใƒใƒผใƒˆ:",
+    "Path:": "ใƒ‘ใ‚น:",
+    "Automatic reconnect": "่‡ชๅ‹•ๅ†ๆŽฅ็ถš",
+    "Reconnect delay (ms):": "ๅ†ๆŽฅ็ถšใ™ใ‚‹้…ๅปถ (ใƒŸใƒช็ง’):",
+    "Show dot when no cursor": "ใ‚ซใƒผใ‚ฝใƒซใŒใชใ„ใจใใซใƒ‰ใƒƒใƒˆใ‚’่กจ็คบใ™ใ‚‹",
+    "Logging:": "ใƒญใ‚ฎใƒณใ‚ฐ:",
+    "Version:": "ใƒใƒผใ‚ธใƒงใƒณ:",
+    "Disconnect": "ๅˆ‡ๆ–ญ",
+    "Connect": "ๆŽฅ็ถš",
+    "Server identity": "ใ‚ตใƒผใƒใƒผใฎ่ญ˜ๅˆฅๆƒ…ๅ ฑ",
+    "The server has provided the following identifying information:": "ใ‚ตใƒผใƒใƒผใฏไปฅไธ‹ใฎ่ญ˜ๅˆฅๆƒ…ๅ ฑใ‚’ๆไพ›ใ—ใฆใ„ใพใ™:",
+    "Fingerprint:": "ใƒ•ใ‚ฃใƒณใ‚ฌใƒผใƒ—ใƒชใƒณใƒˆ:",
+    "Please verify that the information is correct and press \"Approve\". Otherwise press \"Reject\".": "ใ“ใฎๆƒ…ๅ ฑใŒๆญฃใ—ใ„ๅ ดๅˆใฏใ€Œๆ‰ฟ่ชใ€ใ‚’ใ€ใใ†ใงใชใ„ๅ ดๅˆใฏใ€Œๆ‹’ๅฆใ€ใ‚’ๆŠผใ—ใฆใใ ใ•ใ„ใ€‚",
+    "Approve": "ๆ‰ฟ่ช",
+    "Reject": "ๆ‹’ๅฆ",
+    "Credentials": "่ณ‡ๆ ผๆƒ…ๅ ฑ",
+    "Username:": "ใƒฆใƒผใ‚ถใƒผๅ:",
+    "Password:": "ใƒ‘ใ‚นใƒฏใƒผใƒ‰:",
+    "Send credentials": "่ณ‡ๆ ผๆƒ…ๅ ฑใ‚’้€ไฟก",
+    "Cancel": "ใ‚ญใƒฃใƒณใ‚ปใƒซ"
+}
\ No newline at end of file
pkg/web/noVNC/app/locale/ko.json
@@ -0,0 +1,70 @@
+{
+    "Connecting...": "์—ฐ๊ฒฐ์ค‘...",
+    "Disconnecting...": "์—ฐ๊ฒฐ ํ•ด์ œ์ค‘...",
+    "Reconnecting...": "์žฌ์—ฐ๊ฒฐ์ค‘...",
+    "Internal error": "๋‚ด๋ถ€ ์˜ค๋ฅ˜",
+    "Must set host": "ํ˜ธ์ŠคํŠธ๋Š” ์„ค์ •๋˜์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.",
+    "Connected (encrypted) to ": "๋‹ค์Œ๊ณผ (์•”ํ˜ธํ™”๋˜์–ด) ์—ฐ๊ฒฐ๋˜์—ˆ์Šต๋‹ˆ๋‹ค:",
+    "Connected (unencrypted) to ": "๋‹ค์Œ๊ณผ (์•”ํ˜ธํ™” ์—†์ด) ์—ฐ๊ฒฐ๋˜์—ˆ์Šต๋‹ˆ๋‹ค:",
+    "Something went wrong, connection is closed": "๋ฌด์–ธ๊ฐ€ ์ž˜๋ชป๋˜์—ˆ์Šต๋‹ˆ๋‹ค, ์—ฐ๊ฒฐ์ด ๋‹ซํ˜”์Šต๋‹ˆ๋‹ค.",
+    "Failed to connect to server": "์„œ๋ฒ„์— ์—ฐ๊ฒฐํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.",
+    "Disconnected": "์—ฐ๊ฒฐ์ด ํ•ด์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.",
+    "New connection has been rejected with reason: ": "์ƒˆ ์—ฐ๊ฒฐ์ด ๋‹ค์Œ ์ด์œ ๋กœ ๊ฑฐ๋ถ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค:",
+    "New connection has been rejected": "์ƒˆ ์—ฐ๊ฒฐ์ด ๊ฑฐ๋ถ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค.",
+    "Password is required": "๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.",
+    "noVNC encountered an error:": "noVNC์— ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค:",
+    "Hide/Show the control bar": "์ปจํŠธ๋กค ๋ฐ” ์ˆจ๊ธฐ๊ธฐ/๋ณด์ด๊ธฐ",
+    "Move/Drag viewport": "์›€์ง์ด๊ธฐ/๋“œ๋ž˜๊ทธ ๋ทฐํฌํŠธ",
+    "viewport drag": "๋ทฐํฌํŠธ ๋“œ๋ž˜๊ทธ",
+    "Active Mouse Button": "๋งˆ์šฐ์Šค ๋ฒ„ํŠผ ํ™œ์„ฑํ™”",
+    "No mousebutton": "๋งˆ์šฐ์Šค ๋ฒ„ํŠผ ์—†์Œ",
+    "Left mousebutton": "์™ผ์ชฝ ๋งˆ์šฐ์Šค ๋ฒ„ํŠผ",
+    "Middle mousebutton": "์ค‘๊ฐ„ ๋งˆ์šฐ์Šค ๋ฒ„ํŠผ",
+    "Right mousebutton": "์˜ค๋ฅธ์ชฝ ๋งˆ์šฐ์Šค ๋ฒ„ํŠผ",
+    "Keyboard": "ํ‚ค๋ณด๋“œ",
+    "Show keyboard": "ํ‚ค๋ณด๋“œ ๋ณด์ด๊ธฐ",
+    "Extra keys": "๊ธฐํƒ€ ํ‚ค๋“ค",
+    "Show extra keys": "๊ธฐํƒ€ ํ‚ค๋“ค ๋ณด์ด๊ธฐ",
+    "Ctrl": "Ctrl",
+    "Toggle Ctrl": "Ctrl ์ผœ๊ธฐ/๋„๊ธฐ",
+    "Alt": "Alt",
+    "Toggle Alt": "Alt ์ผœ๊ธฐ/๋„๊ธฐ",
+    "Send Tab": "Tab ๋ณด๋‚ด๊ธฐ",
+    "Tab": "Tab",
+    "Esc": "Esc",
+    "Send Escape": "Esc ๋ณด๋‚ด๊ธฐ",
+    "Ctrl+Alt+Del": "Ctrl+Alt+Del",
+    "Send Ctrl-Alt-Del": "Ctrl+Alt+Del ๋ณด๋‚ด๊ธฐ",
+    "Shutdown/Reboot": "์…ง๋‹ค์šด/๋ฆฌ๋ถ“",
+    "Shutdown/Reboot...": "์…ง๋‹ค์šด/๋ฆฌ๋ถ“...",
+    "Power": "์ „์›",
+    "Shutdown": "์…ง๋‹ค์šด",
+    "Reboot": "๋ฆฌ๋ถ“",
+    "Reset": "๋ฆฌ์…‹",
+    "Clipboard": "ํด๋ฆฝ๋ณด๋“œ",
+    "Clear": "์ง€์šฐ๊ธฐ",
+    "Fullscreen": "์ „์ฒดํ™”๋ฉด",
+    "Settings": "์„ค์ •",
+    "Shared mode": "๊ณต์œ  ๋ชจ๋“œ",
+    "View only": "๋ณด๊ธฐ ์ „์šฉ",
+    "Clip to window": "์ฐฝ์— ํด๋ฆฝ",
+    "Scaling mode:": "์Šค์ผ€์ผ๋ง ๋ชจ๋“œ:",
+    "None": "์—†์Œ",
+    "Local scaling": "๋กœ์ปฌ ์Šค์ผ€์ผ๋ง",
+    "Remote resizing": "์›๊ฒฉ ํฌ๊ธฐ ์กฐ์ ˆ",
+    "Advanced": "๊ณ ๊ธ‰",
+    "Repeater ID:": "์ค‘๊ณ„ ID",
+    "WebSocket": "์›น์†Œ์ผ“",
+    "Encrypt": "์•”ํ˜ธํ™”",
+    "Host:": "ํ˜ธ์ŠคํŠธ:",
+    "Port:": "ํฌํŠธ:",
+    "Path:": "์œ„์น˜:",
+    "Automatic reconnect": "์ž๋™ ์žฌ์—ฐ๊ฒฐ",
+    "Reconnect delay (ms):": "์žฌ์—ฐ๊ฒฐ ์ง€์—ฐ ์‹œ๊ฐ„ (ms)",
+    "Logging:": "๋กœ๊น…",
+    "Disconnect": "์—ฐ๊ฒฐ ํ•ด์ œ",
+    "Connect": "์—ฐ๊ฒฐ",
+    "Password:": "๋น„๋ฐ€๋ฒˆํ˜ธ:",
+    "Send Password": "๋น„๋ฐ€๋ฒˆํ˜ธ ์ „์†ก",
+    "Cancel": "์ทจ์†Œ"
+}
\ No newline at end of file
pkg/web/noVNC/app/locale/nl.json
@@ -0,0 +1,95 @@
+{
+    "Running without HTTPS is not recommended, crashes or other issues are likely.": "Het is niet aan te raden om zonder HTTPS te werken, crashes of andere problemen zijn dan waarschijnlijk.",
+    "Connecting...": "Aan het verbindenโ€ฆ",
+    "Disconnecting...": "Bezig om verbinding te verbreken...",
+    "Reconnecting...": "Opnieuw verbinding maken...",
+    "Internal error": "Interne fout",
+    "Failed to connect to server: ": "Verbinding maken met server is mislukt",
+    "Connected (encrypted) to ": "Verbonden (versleuteld) met ",
+    "Connected (unencrypted) to ": "Verbonden (onversleuteld) met ",
+    "Something went wrong, connection is closed": "Er iets fout gelopen, verbinding werd verbroken",
+    "Failed to connect to server": "Verbinding maken met server is mislukt",
+    "Disconnected": "Verbinding verbroken",
+    "New connection has been rejected with reason: ": "Nieuwe verbinding is geweigerd met de volgende reden: ",
+    "New connection has been rejected": "Nieuwe verbinding is geweigerd",
+    "Credentials are required": "Inloggegevens zijn nodig",
+    "noVNC encountered an error:": "noVNC heeft een fout bemerkt:",
+    "Hide/Show the control bar": "Verberg/Toon de bedieningsbalk",
+    "Drag": "Sleep",
+    "Move/Drag viewport": "Verplaats/Versleep Kijkvenster",
+    "Keyboard": "Toetsenbord",
+    "Show keyboard": "Toon Toetsenbord",
+    "Extra keys": "Extra toetsen",
+    "Show extra keys": "Toon Extra Toetsen",
+    "Ctrl": "Ctrl",
+    "Toggle Ctrl": "Ctrl omschakelen",
+    "Alt": "Alt",
+    "Toggle Alt": "Alt omschakelen",
+    "Toggle Windows": "Vensters omschakelen",
+    "Windows": "Vensters",
+    "Send Tab": "Tab Sturen",
+    "Tab": "Tab",
+    "Esc": "Esc",
+    "Send Escape": "Escape Sturen",
+    "Ctrl+Alt+Del": "Ctrl-Alt-Del",
+    "Send Ctrl-Alt-Del": "Ctrl-Alt-Del Sturen",
+    "Shutdown/Reboot": "Uitschakelen/Herstarten",
+    "Shutdown/Reboot...": "Uitschakelen/Herstarten...",
+    "Power": "Systeem",
+    "Shutdown": "Uitschakelen",
+    "Reboot": "Herstarten",
+    "Reset": "Resetten",
+    "Clipboard": "Klembord",
+    "Edit clipboard content in the textarea below.": "Edit de inhoud van het klembord in het tekstveld hieronder",
+    "Full screen": "Volledig Scherm",
+    "Settings": "Instellingen",
+    "Shared mode": "Gedeelde Modus",
+    "View only": "Alleen Kijken",
+    "Clip to window": "Randen buiten venster afsnijden",
+    "Scaling mode:": "Schaalmodus:",
+    "None": "Geen",
+    "Local scaling": "Lokaal Schalen",
+    "Remote resizing": "Op Afstand Formaat Wijzigen",
+    "Advanced": "Geavanceerd",
+    "Quality:": "Kwaliteit:",
+    "Compression level:": "Compressieniveau:",
+    "Repeater ID:": "Repeater ID:",
+    "WebSocket": "WebSocket",
+    "Encrypt": "Versleutelen",
+    "Host:": "Host:",
+    "Port:": "Poort:",
+    "Path:": "Pad:",
+    "Automatic reconnect": "Automatisch Opnieuw Verbinden",
+    "Reconnect delay (ms):": "Vertraging voor Opnieuw Verbinden (ms):",
+    "Show dot when no cursor": "Geef stip weer indien geen cursor",
+    "Logging:": "Logmeldingen:",
+    "Version:": "Versie:",
+    "Disconnect": "Verbinding verbreken",
+    "Connect": "Verbinden",
+    "Server identity": "Serveridentiteit",
+    "The server has provided the following identifying information:": "De server geeft de volgende identificerende informatie:",
+    "Fingerprint:": "Vingerafdruk:",
+    "Please verify that the information is correct and press \"Approve\". Otherwise press \"Reject\".": "Verifieer dat de informatie is correct en druk โ€œOKโ€. Druk anders op โ€œAfwijzenโ€.",
+    "Approve": "OK",
+    "Reject": "Afwijzen",
+    "Credentials": "Inloggegevens",
+    "Username:": "Gebruikersnaam:",
+    "Password:": "Wachtwoord:",
+    "Send credentials": "Stuur inloggegevens",
+    "Cancel": "Annuleren",
+    "Must set host": "Host moeten worden ingesteld",
+    "Password is required": "Wachtwoord is vereist",
+    "viewport drag": "kijkvenster slepen",
+    "Active Mouse Button": "Actieve Muisknop",
+    "No mousebutton": "Geen muisknop",
+    "Left mousebutton": "Linker muisknop",
+    "Middle mousebutton": "Middelste muisknop",
+    "Right mousebutton": "Rechter muisknop",
+    "Clear": "Wissen",
+    "Send Password": "Verzend Wachtwoord:",
+    "Disconnect timeout": "Timeout tijdens verbreken van verbinding",
+    "Local Downscaling": "Lokaal Neerschalen",
+    "Local Cursor": "Lokale Cursor",
+    "Canvas not supported.": "Canvas wordt niet ondersteund.",
+    "Forcing clipping mode since scrollbars aren't supported by IE in fullscreen": "''Clipping mode' ingeschakeld, omdat schuifbalken in volledige-scherm-modus in IE niet worden ondersteund"
+}
\ No newline at end of file
pkg/web/noVNC/app/locale/pl.json
@@ -0,0 +1,80 @@
+{
+    "Connecting...": "ลฤ…czenie...",
+    "Disconnecting...": "Rozล‚ฤ…czanie...",
+    "Reconnecting...": "ลฤ…czenie...",
+    "Internal error": "Bล‚ฤ…d wewnฤ™trzny",
+    "Must set host": "Host i port sฤ… wymagane",
+    "Connected (encrypted) to ": "Poล‚ฤ…czenie (szyfrowane) z ",
+    "Connected (unencrypted) to ": "Poล‚ฤ…czenie (nieszyfrowane) z ",
+    "Something went wrong, connection is closed": "Coล› poszล‚o ลบle, poล‚ฤ…czenie zostaล‚o zamkniฤ™te",
+    "Disconnected": "Rozล‚ฤ…czony",
+    "New connection has been rejected with reason: ": "Nowe poล‚ฤ…czenie zostaล‚o odrzucone z powodu: ",
+    "New connection has been rejected": "Nowe poล‚ฤ…czenie zostaล‚o odrzucone",
+    "Password is required": "Hasล‚o jest wymagane",
+    "noVNC encountered an error:": "noVNC napotkaล‚o bล‚ฤ…d:",
+    "Hide/Show the control bar": "Pokaลผ/Ukryj pasek ustawieล„",
+    "Move/Drag Viewport": "Ruszaj/Przeciฤ…gaj Viewport",
+    "viewport drag": "przeciฤ…gnij viewport",
+    "Active Mouse Button": "Aktywny Przycisk Myszy",
+    "No mousebutton": "Brak przycisku myszy",
+    "Left mousebutton": "Lewy przycisk myszy",
+    "Middle mousebutton": "ลšrodkowy przycisk myszy",
+    "Right mousebutton": "Prawy przycisk myszy",
+    "Keyboard": "Klawiatura",
+    "Show keyboard": "Pokaลผ klawiaturฤ™",
+    "Extra keys": "Przyciski dodatkowe",
+    "Show extra keys": "Pokaลผ przyciski dodatkowe",
+    "Ctrl": "Ctrl",
+    "Toggle Ctrl": "Przeล‚ฤ…cz Ctrl",
+    "Alt": "Alt",
+    "Toggle Alt": "Przeล‚ฤ…cz Alt",
+    "Send Tab": "Wyล›lij Tab",
+    "Tab": "Tab",
+    "Esc": "Esc",
+    "Send Escape": "Wyล›lij Escape",
+    "Ctrl+Alt+Del": "Ctrl+Alt+Del",
+    "Send Ctrl-Alt-Del": "Wyล›lij Ctrl-Alt-Del",
+    "Shutdown/Reboot": "Wyล‚ฤ…cz/Uruchom ponownie",
+    "Shutdown/Reboot...": "Wyล‚ฤ…cz/Uruchom ponownie...",
+    "Power": "Wล‚ฤ…czony",
+    "Shutdown": "Wyล‚ฤ…cz",
+    "Reboot": "Uruchom ponownie",
+    "Reset": "Resetuj",
+    "Clipboard": "Schowek",
+    "Clear": "Wyczyล›ฤ‡",
+    "Fullscreen": "Peล‚ny ekran",
+    "Settings": "Ustawienia",
+    "Shared Mode": "Tryb Wspรณล‚dzielenia",
+    "View Only": "Tylko Podglฤ…d",
+    "Clip to Window": "Przytnij do Okna",
+    "Scaling Mode:": "Tryb Skalowania:",
+    "None": "Brak",
+    "Local scaling": "Skalowanie lokalne",
+    "Remote resizing": "Skalowanie zdalne",
+    "Advanced": "Zaawansowane",
+    "Repeater ID:": "ID Repeatera:",
+    "WebSocket": "WebSocket",
+    "Encrypt": "Szyfrowanie",
+    "Host:": "Host:",
+    "Port:": "Port:",
+    "Path:": "ลšcieลผka:",
+    "Automatic reconnect": "Automatycznie wznawiaj poล‚ฤ…czenie",
+    "Reconnect delay (ms):": "Opรณลบnienie wznawiania (ms):",
+    "Logging:": "Poziom logowania:",
+    "Disconnect": "Rozล‚ฤ…cz",
+    "Connect": "Poล‚ฤ…cz",
+    "Password:": "Hasล‚o:",
+    "Cancel": "Anuluj",
+    "Canvas not supported.": "Element Canvas nie jest wspierany.",
+    "Disconnect timeout": "Timeout rozล‚ฤ…czenia",
+    "Local Downscaling": "Downscaling lokalny",
+    "Local Cursor": "Lokalny kursor",
+    "Forcing clipping mode since scrollbars aren't supported by IE in fullscreen": "Wymuszam clipping mode poniewaลผ paski przewijania nie sฤ… wspierane przez IE w trybie peล‚noekranowym",
+    "True Color": "True Color",
+    "Style:": "Styl:",
+    "default": "domyล›lny",
+    "Apply": "Zapisz",
+    "Connection": "Poล‚ฤ…czenie",
+    "Token:": "Token:",
+    "Send Password": "Wyล›lij Hasล‚o"
+}
\ No newline at end of file
pkg/web/noVNC/app/locale/pt_BR.json
@@ -0,0 +1,72 @@
+{
+    "Connecting...": "Conectando...",
+    "Disconnecting...": "Desconectando...",
+    "Reconnecting...": "Reconectando...",
+    "Internal error": "Erro interno",
+    "Must set host": "ร‰ necessรกrio definir o host",
+    "Connected (encrypted) to ": "Conectado (com criptografia) a ",
+    "Connected (unencrypted) to ": "Conectado (sem criptografia) a ",
+    "Something went wrong, connection is closed": "Algo deu errado. A conexรฃo foi encerrada.",
+    "Failed to connect to server": "Falha ao conectar-se ao servidor",
+    "Disconnected": "Desconectado",
+    "New connection has been rejected with reason: ": "A nova conexรฃo foi rejeitada pelo motivo: ",
+    "New connection has been rejected": "A nova conexรฃo foi rejeitada",
+    "Credentials are required": "Credenciais sรฃo obrigatรณrias",
+    "noVNC encountered an error:": "O noVNC encontrou um erro:",
+    "Hide/Show the control bar": "Esconder/mostrar a barra de controles",
+    "Drag": "Arrastar",
+    "Move/Drag viewport": "Mover/arrastar a janela",
+    "Keyboard": "Teclado",
+    "Show keyboard": "Mostrar teclado",
+    "Extra keys": "Teclas adicionais",
+    "Show extra keys": "Mostrar teclas adicionais",
+    "Ctrl": "Ctrl",
+    "Toggle Ctrl": "Pressionar/soltar Ctrl",
+    "Alt": "Alt",
+    "Toggle Alt": "Pressionar/soltar Alt",
+    "Toggle Windows": "Pressionar/soltar Windows",
+    "Windows": "Windows",
+    "Send Tab": "Enviar Tab",
+    "Tab": "Tab",
+    "Esc": "Esc",
+    "Send Escape": "Enviar Esc",
+    "Ctrl+Alt+Del": "Ctrl+Alt+Del",
+    "Send Ctrl-Alt-Del": "Enviar Ctrl-Alt-Del",
+    "Shutdown/Reboot": "Desligar/reiniciar",
+    "Shutdown/Reboot...": "Desligar/reiniciar...",
+    "Power": "Ligar",
+    "Shutdown": "Desligar",
+    "Reboot": "Reiniciar",
+    "Reset": "Reiniciar (forรงado)",
+    "Clipboard": "รrea de transferรชncia",
+    "Clear": "Limpar",
+    "Fullscreen": "Tela cheia",
+    "Settings": "Configuraรงรตes",
+    "Shared mode": "Modo compartilhado",
+    "View only": "Apenas visualizar",
+    "Clip to window": "Recortar ร  janela",
+    "Scaling mode:": "Modo de dimensionamento:",
+    "None": "Nenhum",
+    "Local scaling": "Local",
+    "Remote resizing": "Remoto",
+    "Advanced": "Avanรงado",
+    "Quality:": "Qualidade:",
+    "Compression level:": "Nรญvel de compressรฃo:",
+    "Repeater ID:": "ID do repetidor:",
+    "WebSocket": "WebSocket",
+    "Encrypt": "Criptografar",
+    "Host:": "Host:",
+    "Port:": "Porta:",
+    "Path:": "Caminho:",
+    "Automatic reconnect": "Reconexรฃo automรกtica",
+    "Reconnect delay (ms):": "Atraso da reconexรฃo (ms)",
+    "Show dot when no cursor": "Mostrar ponto quando nรฃo hรก cursor",
+    "Logging:": "Registros:",
+    "Version:": "Versรฃo:",
+    "Disconnect": "Desconectar",
+    "Connect": "Conectar",
+    "Username:": "Nome de usuรกrio:",
+    "Password:": "Senha:",
+    "Send credentials": "Enviar credenciais",
+    "Cancel": "Cancelar"
+}
\ No newline at end of file
pkg/web/noVNC/app/locale/README
@@ -0,0 +1,1 @@
+DO NOT MODIFY THE FILES IN THIS FOLDER, THEY ARE AUTOMATICALLY GENERATED FROM THE PO-FILES.
pkg/web/noVNC/app/locale/ru.json
@@ -0,0 +1,72 @@
+{
+    "Connecting...": "ะŸะพะดะบะปัŽั‡ะตะฝะธะต...",
+    "Disconnecting...": "ะžั‚ะบะปัŽั‡ะตะฝะธะต...",
+    "Reconnecting...": "ะŸะตั€ะตะฟะพะดะบะปัŽั‡ะตะฝะธะต...",
+    "Internal error": "ะ’ะฝัƒั‚ั€ะตะฝะฝัั ะพัˆะธะฑะบะฐ",
+    "Must set host": "ะ—ะฐะดะฐะนั‚ะต ะธะผั ัะตั€ะฒะตั€ะฐ ะธะปะธ IP",
+    "Connected (encrypted) to ": "ะŸะพะดะบะปัŽั‡ะตะฝะพ (ั ัˆะธั„ั€ะพะฒะฐะฝะธะตะผ) ะบ ",
+    "Connected (unencrypted) to ": "ะŸะพะดะบะปัŽั‡ะตะฝะพ (ะฑะตะท ัˆะธั„ั€ะพะฒะฐะฝะธั) ะบ ",
+    "Something went wrong, connection is closed": "ะงั‚ะพ-ั‚ะพ ะฟะพัˆะปะพ ะฝะต ั‚ะฐะบ, ะฟะพะดะบะปัŽั‡ะตะฝะธะต ั€ะฐะทะพั€ะฒะฐะฝะพ",
+    "Failed to connect to server": "ะžัˆะธะฑะบะฐ ะฟะพะดะบะปัŽั‡ะตะฝะธั ะบ ัะตั€ะฒะตั€ัƒ",
+    "Disconnected": "ะžั‚ะบะปัŽั‡ะตะฝะพ",
+    "New connection has been rejected with reason: ": "ะะพะฒะพะต ัะพะตะดะธะฝะตะฝะธะต ะพั‚ะบะปะพะฝะตะฝะพ ะฟะพ ะฟั€ะธั‡ะธะฝะต: ",
+    "New connection has been rejected": "ะะพะฒะพะต ัะพะตะดะธะฝะตะฝะธะต ะพั‚ะบะปะพะฝะตะฝะพ",
+    "Credentials are required": "ะขั€ะตะฑัƒัŽั‚ัั ัƒั‡ะตั‚ะฝั‹ะต ะดะฐะฝะฝั‹ะต",
+    "noVNC encountered an error:": "ะžัˆะธะฑะบะฐ noVNC: ",
+    "Hide/Show the control bar": "ะกะบั€ั‹ั‚ัŒ/ะŸะพะบะฐะทะฐั‚ัŒ ะบะพะฝั‚ั€ะพะปัŒะฝัƒัŽ ะฟะฐะฝะตะปัŒ",
+    "Drag": "ะŸะตั€ะตะผะตัั‚ะธั‚ัŒ",
+    "Move/Drag viewport": "ะŸะตั€ะตะผะตัั‚ะธั‚ัŒ ะพะบะฝะพ",
+    "Keyboard": "ะšะปะฐะฒะธะฐั‚ัƒั€ะฐ",
+    "Show keyboard": "ะŸะพะบะฐะทะฐั‚ัŒ ะบะปะฐะฒะธะฐั‚ัƒั€ัƒ",
+    "Extra keys": "ะ”ะพะฟะพะปะฝะธั‚ะตะปัŒะฝั‹ะต ะšะฝะพะฟะบะธ",
+    "Show Extra Keys": "ะŸะพะบะฐะทะฐั‚ัŒ ะ”ะพะฟะพะปะฝะธั‚ะตะปัŒะฝั‹ะต ะšะฝะพะฟะบะธ",
+    "Ctrl": "Ctrl",
+    "Toggle Ctrl": "ะ—ะฐะถะฐั‚ัŒ Ctrl",
+    "Alt": "Alt",
+    "Toggle Alt": "ะ—ะฐะถะฐั‚ัŒ Alt",
+    "Toggle Windows": "ะ—ะฐะถะฐั‚ัŒ Windows",
+    "Windows": "ะ’ะบะปะฐะดะบะฐ",
+    "Send Tab": "ะŸะตั€ะตะดะฐั‚ัŒ ะฝะฐะถะฐั‚ะธะต Tab",
+    "Tab": "Tab",
+    "Esc": "Esc",
+    "Send Escape": "ะŸะตั€ะตะดะฐั‚ัŒ ะฝะฐะถะฐั‚ะธะต Escape",
+    "Ctrl+Alt+Del": "Ctrl+Alt+Del",
+    "Send Ctrl-Alt-Del": "ะŸะตั€ะตะดะฐั‚ัŒ ะฝะฐะถะฐั‚ะธะต Ctrl-Alt-Del",
+    "Shutdown/Reboot": "ะ’ั‹ะบะปัŽั‡ะธั‚ัŒ/ะŸะตั€ะตะทะฐะณั€ัƒะทะธั‚ัŒ",
+    "Shutdown/Reboot...": "ะ’ั‹ะบะปัŽั‡ะธั‚ัŒ/ะŸะตั€ะตะทะฐะณั€ัƒะทะธั‚ัŒ...",
+    "Power": "ะŸะธั‚ะฐะฝะธะต",
+    "Shutdown": "ะ’ั‹ะบะปัŽั‡ะธั‚ัŒ",
+    "Reboot": "ะŸะตั€ะตะทะฐะณั€ัƒะทะธั‚ัŒ",
+    "Reset": "ะกะฑั€ะพั",
+    "Clipboard": "ะ‘ัƒั„ะตั€ ะพะฑะผะตะฝะฐ",
+    "Clear": "ะžั‡ะธัั‚ะธั‚ัŒ",
+    "Fullscreen": "ะ’ะพ ะฒะตััŒ ัะบั€ะฐะฝ",
+    "Settings": "ะะฐัั‚ั€ะพะนะบะธ",
+    "Shared mode": "ะžะฑั‰ะธะน ั€ะตะถะธะผ",
+    "View Only": "ะขะพะปัŒะบะพ ะŸั€ะพัะผะพั‚ั€",
+    "Clip to window": "ะ’ ะพะบะฝะพ",
+    "Scaling mode:": "ะœะฐััˆั‚ะฐะฑ:",
+    "None": "ะะตั‚",
+    "Local scaling": "ะ›ะพะบะฐะปัŒะฝั‹ะน ะผะฐััˆั‚ะฐะฑ",
+    "Remote resizing": "ะฃะดะฐะปะตะฝะฝะฐั ะฟะตั€ะตะฝะฐัั‚ั€ะพะนะบะฐ ั€ะฐะทะผะตั€ะฐ",
+    "Advanced": "ะ”ะพะฟะพะปะฝะธั‚ะตะปัŒะฝะพ",
+    "Quality:": "ะšะฐั‡ะตัั‚ะฒะพ",
+    "Compression level:": "ะฃั€ะพะฒะตะฝัŒ ะกะถะฐั‚ะธั",
+    "Repeater ID:": "ะ˜ะดะตะฝั‚ะธั„ะธะบะฐั‚ะพั€ ID:",
+    "WebSocket": "WebSocket",
+    "Encrypt": "ะจะธั„ั€ะพะฒะฐะฝะธะต",
+    "Host:": "ะกะตั€ะฒะตั€:",
+    "Port:": "ะŸะพั€ั‚:",
+    "Path:": "ะŸัƒั‚ัŒ:",
+    "Automatic reconnect": "ะะฒั‚ะพะผะฐั‚ะธั‡ะตัะบะพะต ะฟะตั€ะตะฟะพะดะบะปัŽั‡ะตะฝะธะต",
+    "Reconnect delay (ms):": "ะ—ะฐะดะตั€ะถะบะฐ ะฟะตั€ะตะฟะพะดะบะปัŽั‡ะตะฝะธั (ะผั):",
+    "Show dot when no cursor": "ะŸะพะบะฐะทะฐั‚ัŒ ั‚ะพั‡ะบัƒ ะฒะผะตัั‚ะพ ะบัƒั€ัะพั€ะฐ",
+    "Logging:": "ะ›ะพะณ:",
+    "Version:": "ะ’ะตั€ัะธั",
+    "Disconnect": "ะžั‚ะบะปัŽั‡ะตะฝะธะต",
+    "Connect": "ะŸะพะดะบะปัŽั‡ะตะฝะธะต",
+    "Username:": "ะ˜ะผั ะŸะพะปัŒะทะพะฒะฐั‚ะตะปั",
+    "Password:": "ะŸะฐั€ะพะปัŒ:",
+    "Send Credentials": "ะŸะตั€ะตะดะฐั‡ะฐ ะฃั‡ะตั‚ะฝั‹ั… ะ”ะฐะฝะฝั‹ั…",
+    "Cancel": "ะ’ั‹ั…ะพะด"
+}
\ No newline at end of file
pkg/web/noVNC/app/locale/sv.json
@@ -0,0 +1,83 @@
+{
+    "Running without HTTPS is not recommended, crashes or other issues are likely.": "Det รคr ej rekommenderat att kรถra utan HTTPS, krascher och andra problem รคr troliga.",
+    "Connecting...": "Ansluter...",
+    "Disconnecting...": "Kopplar ner...",
+    "Reconnecting...": "ร…teransluter...",
+    "Internal error": "Internt fel",
+    "Failed to connect to server: ": "Misslyckades att ansluta till servern: ",
+    "Connected (encrypted) to ": "Ansluten (krypterat) till ",
+    "Connected (unencrypted) to ": "Ansluten (okrypterat) till ",
+    "Something went wrong, connection is closed": "Nรฅgot gick fel, anslutningen avslutades",
+    "Failed to connect to server": "Misslyckades att ansluta till servern",
+    "Disconnected": "Frรฅnkopplad",
+    "New connection has been rejected with reason: ": "Ny anslutning har blivit nekad med fรถljande skรคl: ",
+    "New connection has been rejected": "Ny anslutning har blivit nekad",
+    "Credentials are required": "Anvรคndaruppgifter krรคvs",
+    "noVNC encountered an error:": "noVNC stรถtte pรฅ ett problem:",
+    "Hide/Show the control bar": "Gรถm/Visa kontrollbaren",
+    "Drag": "Dra",
+    "Move/Drag viewport": "Flytta/Dra vyn",
+    "Keyboard": "Tangentbord",
+    "Show keyboard": "Visa tangentbord",
+    "Extra keys": "Extraknappar",
+    "Show extra keys": "Visa extraknappar",
+    "Ctrl": "Ctrl",
+    "Toggle Ctrl": "Vรคxla Ctrl",
+    "Alt": "Alt",
+    "Toggle Alt": "Vรคxla Alt",
+    "Toggle Windows": "Vรคxla Windows",
+    "Windows": "Windows",
+    "Send Tab": "Skicka Tab",
+    "Tab": "Tab",
+    "Esc": "Esc",
+    "Send Escape": "Skicka Escape",
+    "Ctrl+Alt+Del": "Ctrl+Alt+Del",
+    "Send Ctrl-Alt-Del": "Skicka Ctrl-Alt-Del",
+    "Shutdown/Reboot": "Stรคng av/Boota om",
+    "Shutdown/Reboot...": "Stรคng av/Boota om...",
+    "Power": "Strรถm",
+    "Shutdown": "Stรคng av",
+    "Reboot": "Boota om",
+    "Reset": "ร…terstรคll",
+    "Clipboard": "Urklipp",
+    "Edit clipboard content in the textarea below.": "Redigera urklippets innehรฅll i fรคltet nedan.",
+    "Full screen": "Fullskรคrm",
+    "Settings": "Instรคllningar",
+    "Shared mode": "Delat lรคge",
+    "View only": "Endast visning",
+    "Clip to window": "Begrรคnsa till fรถnster",
+    "Scaling mode:": "Skalningslรคge:",
+    "None": "Ingen",
+    "Local scaling": "Lokal skalning",
+    "Remote resizing": "ร„ndra storlek",
+    "Advanced": "Avancerat",
+    "Quality:": "Kvalitet:",
+    "Compression level:": "Kompressionsnivรฅ:",
+    "Repeater ID:": "Repeater-ID:",
+    "WebSocket": "WebSocket",
+    "Encrypt": "Kryptera",
+    "Host:": "Vรคrd:",
+    "Port:": "Port:",
+    "Path:": "Sรถkvรคg:",
+    "Automatic reconnect": "Automatisk รฅteranslutning",
+    "Reconnect delay (ms):": "Fรถrdrรถjning (ms):",
+    "Show dot when no cursor": "Visa prick nรคr ingen muspekare finns",
+    "Logging:": "Loggning:",
+    "Version:": "Version:",
+    "Disconnect": "Koppla frรฅn",
+    "Connect": "Anslut",
+    "Server identity": "Server-identitet",
+    "The server has provided the following identifying information:": "Servern har gett fรถljande identifierande information:",
+    "Fingerprint:": "Fingeravtryck:",
+    "Please verify that the information is correct and press \"Approve\". Otherwise press \"Reject\".": "Kontrollera att informationen รคr korrekt och tryck sedan \"Godkรคnn\". Tryck annars \"Neka\".",
+    "Approve": "Godkรคnn",
+    "Reject": "Neka",
+    "Credentials": "Anvรคndaruppgifter",
+    "Username:": "Anvรคndarnamn:",
+    "Password:": "Lรถsenord:",
+    "Send credentials": "Skicka anvรคndaruppgifter",
+    "Cancel": "Avbryt",
+    "Must set host": "Du mรฅste specifiera en vรคrd",
+    "HTTPS is required for full functionality": "HTTPS krรคvs fรถr full funktionalitet",
+    "Clear": "Rensa"
+}
\ No newline at end of file
pkg/web/noVNC/app/locale/tr.json
@@ -0,0 +1,69 @@
+{
+    "Connecting...": "BaฤŸlanฤฑyor...",
+    "Disconnecting...": "BaฤŸlantฤฑ kesiliyor...",
+    "Reconnecting...": "Yeniden baฤŸlantฤฑ kuruluyor...",
+    "Internal error": "ฤฐรง hata",
+    "Must set host": "Sunucuyu kur",
+    "Connected (encrypted) to ": "BaฤŸlฤฑ (ลŸifrelenmiลŸ)",
+    "Connected (unencrypted) to ": "BaฤŸlandฤฑ (ลŸifrelenmemiลŸ)",
+    "Something went wrong, connection is closed": "Bir ลŸeyler ters gitti, baฤŸlantฤฑ kesildi",
+    "Disconnected": "BaฤŸlantฤฑ kesildi",
+    "New connection has been rejected with reason: ": "BaฤŸlantฤฑ aลŸaฤŸฤฑdaki nedenlerden dolayฤฑ reddedildi: ",
+    "New connection has been rejected": "BaฤŸlantฤฑ reddedildi",
+    "Password is required": "ลžifre gerekli",
+    "noVNC encountered an error:": "Bir hata oluลŸtu:",
+    "Hide/Show the control bar": "Denetim masasฤฑnฤฑ Gizle/Gรถster",
+    "Move/Drag Viewport": "Gรถrรผnรผmรผ TaลŸฤฑ/Sรผrรผkle",
+    "viewport drag": "Gรถrรผntรผ penceresini sรผrรผkle",
+    "Active Mouse Button": "Aktif Fare DรผฤŸmesi",
+    "No mousebutton": "Fare dรผฤŸmesi yok",
+    "Left mousebutton": "Farenin sol dรผฤŸmesi",
+    "Middle mousebutton": "Farenin orta dรผฤŸmesi",
+    "Right mousebutton": "Farenin saฤŸ dรผฤŸmesi",
+    "Keyboard": "Klavye",
+    "Show Keyboard": "Klavye Dรผzenini Gรถster",
+    "Extra keys": "Ekstra tuลŸlar",
+    "Show extra keys": "Ekstra tuลŸlarฤฑ gรถster",
+    "Ctrl": "Ctrl",
+    "Toggle Ctrl": "Ctrl DeฤŸiลŸtir ",
+    "Alt": "Alt",
+    "Toggle Alt": "Alt DeฤŸiลŸtir",
+    "Send Tab": "Sekme Gรถnder",
+    "Tab": "Sekme",
+    "Esc": "Esc",
+    "Send Escape": "BoลŸluk Gรถnder",
+    "Ctrl+Alt+Del": "Ctrl + Alt + Del",
+    "Send Ctrl-Alt-Del": "Ctrl-Alt-Del Gรถnder",
+    "Shutdown/Reboot": "Kapat/Yeniden BaลŸlat",
+    "Shutdown/Reboot...": "Kapat/Yeniden BaลŸlat...",
+    "Power": "Gรผรง",
+    "Shutdown": "Kapat",
+    "Reboot": "Yeniden BaลŸlat",
+    "Reset": "Sฤฑfฤฑrla",
+    "Clipboard": "Pano",
+    "Clear": "Temizle",
+    "Fullscreen": "Tam Ekran",
+    "Settings": "Ayarlar",
+    "Shared Mode": "PaylaลŸฤฑm Modu",
+    "View Only": "Sadece Gรถrรผntรผle",
+    "Clip to Window": "Pencereye Tฤฑkla",
+    "Scaling Mode:": "ร–lรงekleme Modu:",
+    "None": "Bilinmeyen",
+    "Local Scaling": "Yerel ร–lรงeklendirme",
+    "Remote Resizing": "Uzaktan Yeniden Boyutlandฤฑrma",
+    "Advanced": "GeliลŸmiลŸ",
+    "Repeater ID:": "Tekralayฤฑcฤฑ ID:",
+    "WebSocket": "WebSocket",
+    "Encrypt": "ลžifrele",
+    "Host:": "Ana makine:",
+    "Port:": "Port:",
+    "Path:": "Yol:",
+    "Automatic Reconnect": "Otomatik Yeniden BaฤŸlan",
+    "Reconnect Delay (ms):": "Yeniden BaฤŸlanma Sรผreci (ms):",
+    "Logging:": "GiriลŸ yapฤฑlฤฑyor:",
+    "Disconnect": "Bagฬ†lantฤฑyฤฑ Kes",
+    "Connect": "Bagฬ†lan",
+    "Password:": "Parola:",
+    "Cancel": "Vazgeรง",
+    "Canvas not supported.": "Tuval desteklenmiyor."
+}
\ No newline at end of file
pkg/web/noVNC/app/locale/uk.json
@@ -0,0 +1,81 @@
+{
+    "Running without HTTPS is not recommended, crashes or other issues are likely.": "ะ ะพะฑะพั‚ะฐ ะฑะตะท HTTPS ะฝะต ั€ะตะบะพะผะตะฝะดัƒั”ั‚ัŒัั, ะนะผะพะฒั–ั€ะฝั– ะทะฑะพั— ั‡ะธ ั–ะฝัˆั– ะฟั€ะพะฑะปะตะผะธ.",
+    "Connecting...": "ะ—'ั”ะดะฝะฐะฝะฝั...",
+    "Disconnecting...": "ะ’ั–ะด'ั”ะดะฝะฐะฝะฝั...",
+    "Reconnecting...": "ะŸะตั€ะตะท'ั”ะดะฝะฐะฝะฝั...",
+    "Internal error": "ะ’ะฝัƒั‚ั€ั–ัˆะฝั ะฟะพะผะธะปะบะฐ",
+    "Failed to connect to server: ": "ะะต ะฒะดะฐะปะพัั ะท'ั”ะดะฝะฐั‚ะธัั ะท ัะตั€ะฒะตั€ะพะผ: ",
+    "Connected (encrypted) to ": "ะ—'ั”ะดะฝะฐะฝะพ (ะท ัˆะธั„ั€ัƒะฒะฐะฝะฝัะผ) ะท ",
+    "Connected (unencrypted) to ": "ะ—'ั”ะดะฝะฐะฝะพ (ะฑะตะท ัˆะธั„ั€ัƒะฒะฐะฝะฝั) ะท ",
+    "Something went wrong, connection is closed": "ะฉะพััŒ ะฟั–ัˆะปะพ ะฝะต ั‚ะฐะบ, ะท'ั”ะดะฝะฐะฝะฝั ะทะฐะบั€ะธั‚ะพ",
+    "Failed to connect to server": "ะะต ะฒะดะฐะปะพัั ะท'ั”ะดะฝะฐั‚ะธัั ะท ัะตั€ะฒะตั€ะพะผ",
+    "Disconnected": "ะ’ั–ะด'ั”ะดะฝะฐะฝะพ",
+    "New connection has been rejected with reason: ": "ะะพะฒะต ะท'ั”ะดะฝะฐะฝะฝั ะฒั–ะดั…ะธะปะตะฝะพ. ะŸั€ะธั‡ะธะฝะฐ: ",
+    "New connection has been rejected": "ะะพะฒะต ะท'ั”ะดะฝะฐะฝะฝั ะฒั–ะดั…ะธะปะตะฝะพ",
+    "Are you sure you want to disconnect the session?": "ะขะพั‡ะฝะพ ะฒั–ะด'ั”ะดะฝะฐั‚ะธ ัะตะฐะฝั?",
+    "Credentials are required": "ะขั€ะตะฑะฐ ะพัะพะฑะพะฒั– ะดะฐะฝั–",
+    "noVNC encountered an error:": "ะŸะพะผะธะปะบะฐ noVNC:",
+    "Hide/Show the control bar": "ะกั…ะพะฒะฐั‚ะธ/ะฟะพะบะฐะทะฐั‚ะธ ะฟะฐะฝะตะปัŒ ะบะตั€ัƒะฒะฐะฝะฝั",
+    "Drag": "ะŸะพััƒะฝัƒั‚ะธ",
+    "Move/Drag viewport": "ะ—ะผั–ัั‚ะธั‚ะธ ะพะฑะปะฐัั‚ัŒ ะพะณะปัะดัƒ",
+    "Keyboard": "ะšะปะฐะฒั–ะฐั‚ัƒั€ะฐ",
+    "Show keyboard": "ะŸะพะบะฐะทะฐั‚ะธ ะบะปะฐะฒั–ะฐั‚ัƒั€ัƒ",
+    "Extra keys": "ะ”ะพะดะฐั‚ะบะพะฒั– ะบะปะฐะฒั–ัˆั–",
+    "Show extra keys": "ะŸะพะบะฐะทะฐั‚ะธ ะดะพะดะฐั‚ะบะพะฒั– ะบะปะฐะฒั–ัˆั–",
+    "Ctrl": "Ctrl",
+    "Toggle Ctrl": "ะ—ะฐั‚ะธัะฝัƒั‚ะธ Ctrl",
+    "Alt": "Alt",
+    "Toggle Alt": "ะ—ะฐั‚ะธัะฝัƒั‚ะธ Alt",
+    "Toggle Windows": "ะ—ะฐั‚ะธัะฝัƒั‚ะธ Windows",
+    "Windows": "Windows",
+    "Send Tab": "ะะฐั‚ะธัะฝัƒั‚ะธ Tab",
+    "Tab": "Tab",
+    "Esc": "Esc",
+    "Send Escape": "ะะฐั‚ะธัะฝัƒั‚ะธ Escape",
+    "Ctrl+Alt+Del": "Ctrl+Alt+Del",
+    "Send Ctrl-Alt-Del": "ะะฐั‚ะธัะฝัƒั‚ะธ Ctrl+Alt+Del",
+    "Shutdown/Reboot": "ะ’ะธะผะบะฝัƒั‚ะธ/ะฟะตั€ะตะทะฐะฒะฐะฝั‚ะฐะถะธั‚ะธ",
+    "Shutdown/Reboot...": "ะ’ะธะผะบะฝัƒั‚ะธ/ะฟะตั€ะตะทะฐะฒะฐะฝั‚ะฐะถะธั‚ะธ...",
+    "Power": "ะ–ะธะฒะปะตะฝะฝั",
+    "Shutdown": "ะ’ะธะผะบะฝัƒั‚ะธ",
+    "Reboot": "ะŸะตั€ะตะทะฐะฒะฐะฝั‚ะฐะถะธั‚ะธ",
+    "Reset": "ะกะบะธะฝัƒั‚ะธ",
+    "Clipboard": "ะ‘ัƒั„ะตั€ ะพะฑะผั–ะฝัƒ",
+    "Edit clipboard content in the textarea below.": "ะ ะตะดะฐะณัƒะนั‚ะต ะฒะผั–ัั‚ ะฑัƒั„ะตั€ะฐ ะพะฑะผั–ะฝัƒ ะฒ ั‚ะตะบัั‚ะพะฒั–ะน ะทะพะฝั– ะฒะฝะธะทัƒ.",
+    "Full screen": "ะŸะพะฒะฝะธะน ะตะบั€ะฐะฝ",
+    "Settings": "ะŸะฐั€ะฐะผะตั‚ั€ะธ",
+    "Shared mode": "ะกะฟั–ะปัŒะฝะธะน ั€ะตะถะธะผ",
+    "View only": "ะ›ะธัˆะต ะฟะตั€ะตะณะปัะด",
+    "Clip to window": "ะ”ะพ ั€ะพะทะผั–ั€ั–ะฒ ะฒั–ะบะฝะฐ",
+    "Scaling mode:": "ะ ะตะถะธะผ ะผะฐััˆั‚ะฐะฑัƒะฒะฐะฝะฝั:",
+    "None": "ะ’ะธะผะบะฝะตะฝะพ",
+    "Local scaling": "ะ›ะพะบะฐะปัŒะฝะต ะผะฐััˆั‚ะฐะฑัƒะฒะฐะฝะฝั",
+    "Remote resizing": "ะ’ั–ะดะดะฐะปะตะฝะต ะผะฐััˆั‚ะฐะฑัƒะฒะฐะฝะฝั",
+    "Advanced": "ะ”ะพะดะฐั‚ะบะพะฒะพ",
+    "Quality:": "ะฏะบั–ัั‚ัŒ:",
+    "Compression level:": "ะ ั–ะฒะตะฝัŒ ัั‚ะธัะฝะตะฝะฝั:",
+    "Repeater ID:": "ะ†ะดะตะฝั‚ะธั„ั–ะบะฐั‚ะพั€ ั€ะตะฟั–ั‚ะตั€ะฐ:",
+    "WebSocket": "WebSocket",
+    "Encrypt": "ะจะธั„ั€ัƒะฒะฐะฝะฝั",
+    "Host:": "ะกะตั€ะฒะตั€:",
+    "Port:": "ะŸะพั€ั‚:",
+    "Path:": "ะจะปัั…:",
+    "Automatic reconnect": "ะะฒั‚ะพะผะฐั‚ะธั‡ะฝะต ะฟะตั€ะตะท'ั”ะดะฝะฐะฝะฝั",
+    "Reconnect delay (ms):": "ะ—ะฐั‚ั€ะธะผะบะฐ ะฟะตั€ะตะท'ั”ะดะฝะฐะฝะฝั (ะผั):",
+    "Show dot when no cursor": "ะŸะพะบะฐะทัƒะฒะฐั‚ะธ ะบั€ะฐะฟะบัƒ, ะบะพะปะธ ะฝะตะผะฐ ะบัƒั€ัะพั€ะฐ",
+    "Logging:": "ะ–ัƒั€ะฝะฐะป:",
+    "Version:": "ะ’ะตั€ัั–ั:",
+    "Disconnect": "ะ’ั–ะด'ั”ะดะฝะฐั‚ะธ",
+    "Connect": "ะ—'ั”ะดะฝะฐั‚ะธ",
+    "Server identity": "ะ†ะดะตะฝั‚ะธั„ั–ะบะฐั†ั–ั ัะตั€ะฒะตั€ะฐ",
+    "The server has provided the following identifying information:": "ะกะตั€ะฒะตั€ ะฝะฐะดะฐั” ั‚ะฐะบั– ั–ะดะตะฝั‚ะธั„ั–ะบะฐั†ั–ะนะฝั– ะดะฐะฝั–:",
+    "Fingerprint:": "ะ’ั–ะดะฑะธั‚ะพะบ:",
+    "Please verify that the information is correct and press \"Approve\". Otherwise press \"Reject\".": "ะŸะตั€ะตะฒั–ั€ั‚ะต, ั‡ะธ ะดะฐะฝั– ะบะพั€ะตะบั‚ะฝั–, ะน ะฝะฐั‚ะธัะฝั–ั‚ัŒ ยซะกั…ะฒะฐะปะธั‚ะธยป. ะ†ะฝะฐะบัˆะต ะฝะฐั‚ะธัะฝั–ั‚ัŒ ยซะ’ั–ะดั…ะธะปะธั‚ะธยป.",
+    "Approve": "ะกั…ะฒะฐะปะธั‚ะธ",
+    "Reject": "ะ’ั–ะดั…ะธะปะธั‚ะธ",
+    "Credentials": "ะžัะพะฑะพะฒั– ะดะฐะฝั–",
+    "Username:": "ะšะพั€ะธัั‚ัƒะฒะฐั†ัŒะบะต ั–ะผ'ั:",
+    "Password:": "ะŸะฐั€ะพะปัŒ:",
+    "Send credentials": "ะะฐะดั–ัะปะฐั‚ะธ ะพัะพะฑะพะฒั– ะดะฐะฝั–",
+    "Cancel": "ะกะบะฐััƒะฒะฐั‚ะธ"
+}
\ No newline at end of file
pkg/web/noVNC/app/locale/zh_CN.json
@@ -0,0 +1,93 @@
+{
+    "Running without HTTPS is not recommended, crashes or other issues are likely.": "ไธๅปบ่ฎฎๅœจๆฒกๆœ‰ HTTPS ็š„ๆƒ…ๅ†ตไธ‹่ฟ่กŒ๏ผŒๅฏ่ƒฝไผšๅ‡บ็Žฐๅดฉๆบƒๆˆ–ๅ‡บ็Žฐๅ…ถไป–้—ฎ้ข˜ใ€‚",
+    "Connecting...": "่ฟžๆŽฅไธญ...",
+    "Disconnecting...": "ๆญฃๅœจๆ–ญๅผ€่ฟžๆŽฅ...",
+    "Reconnecting...": "้‡ๆ–ฐ่ฟžๆŽฅไธญ...",
+    "Internal error": "ๅ†…้ƒจ้”™่ฏฏ",
+    "Must set host": "ๅฟ…้กป่ฎพ็ฝฎไธปๆœบ",
+    "Failed to connect to server: ": "ๆ— ๆณ•่ฟžๆŽฅๅˆฐๆœๅŠกๅ™จ๏ผš",
+    "Connected (encrypted) to ": "ๅทฒ่ฟžๆŽฅ๏ผˆๅทฒๅŠ ๅฏ†๏ผ‰ๅˆฐ",
+    "Connected (unencrypted) to ": "ๅทฒ่ฟžๆŽฅ๏ผˆๆœชๅŠ ๅฏ†๏ผ‰ๅˆฐ",
+    "Something went wrong, connection is closed": "ๅ‡บไบ†็‚น้—ฎ้ข˜๏ผŒ่ฟžๆŽฅๅทฒๅ…ณ้—ญ",
+    "Failed to connect to server": "ๆ— ๆณ•่ฟžๆŽฅๅˆฐๆœๅŠกๅ™จ",
+    "Disconnected": "ๅทฒๆ–ญๅผ€่ฟžๆŽฅ",
+    "New connection has been rejected with reason: ": "ๆ–ฐ่ฟžๆŽฅ่ขซๆ‹’็ป๏ผŒๅŽŸๅ› ๅฆ‚ไธ‹๏ผš",
+    "New connection has been rejected": "ๆ–ฐ่ฟžๆŽฅๅทฒ่ขซๆ‹’็ป",
+    "Credentials are required": "้œ€่ฆๅ‡ญ่ฏ",
+    "noVNC encountered an error:": "noVNC ้‡ๅˆฐไธ€ไธช้”™่ฏฏ๏ผš",
+    "Hide/Show the control bar": "ๆ˜พ็คบ/้š่—ๆŽงๅˆถๆ ",
+    "Drag": "ๆ‹–ๅŠจ",
+    "Move/Drag viewport": "็งปๅŠจ/ๆ‹–ๅŠจ็ช—ๅฃ",
+    "Keyboard": "้”ฎ็›˜",
+    "Show keyboard": "ๆ˜พ็คบ้”ฎ็›˜",
+    "Extra keys": "้ขๅค–ๆŒ‰้”ฎ",
+    "Show extra keys": "ๆ˜พ็คบ้ขๅค–ๆŒ‰้”ฎ",
+    "Ctrl": "Ctrl",
+    "Toggle Ctrl": "ๅˆ‡ๆข Ctrl",
+    "Alt": "Alt",
+    "Toggle Alt": "ๅˆ‡ๆข Alt",
+    "Toggle Windows": "ๅˆ‡ๆข็ช—ๅฃ",
+    "Windows": "็ช—ๅฃ",
+    "Send Tab": "ๅ‘้€ Tab ้”ฎ",
+    "Tab": "Tab",
+    "Esc": "Esc",
+    "Send Escape": "ๅ‘้€ Escape ้”ฎ",
+    "Ctrl+Alt+Del": "Ctrl+Alt+Del",
+    "Send Ctrl-Alt-Del": "ๅ‘้€ Ctrl+Alt+Del ้”ฎ",
+    "Shutdown/Reboot": "ๅ…ณๆœบ/้‡ๅฏ",
+    "Shutdown/Reboot...": "ๅ…ณๆœบ/้‡ๅฏ...",
+    "Power": "็”ตๆบ",
+    "Shutdown": "ๅ…ณๆœบ",
+    "Reboot": "้‡ๅฏ",
+    "Reset": "้‡็ฝฎ",
+    "Clipboard": "ๅ‰ช่ดดๆฟ",
+    "Edit clipboard content in the textarea below.": "ๅœจไธ‹้ข็š„ๆ–‡ๆœฌๅŒบๅŸŸไธญ็ผ–่พ‘ๅ‰ช่ดดๆฟๅ†…ๅฎนใ€‚",
+    "Full screen": "ๅ…จๅฑ",
+    "Settings": "่ฎพ็ฝฎ",
+    "Shared mode": "ๅˆ†ไบซๆจกๅผ",
+    "View only": "ไป…ๆŸฅ็œ‹",
+    "Clip to window": "้™ๅˆถ/่ฃๅˆ‡็ช—ๅฃๅคงๅฐ",
+    "Scaling mode:": "็ผฉๆ”พๆจกๅผ๏ผš",
+    "None": "ๆ— ",
+    "Local scaling": "ๆœฌๅœฐ็ผฉๆ”พ",
+    "Remote resizing": "่ฟœ็จ‹่ฐƒๆ•ดๅคงๅฐ",
+    "Advanced": "้ซ˜็บง",
+    "Quality:": "ๅ“่ดจ๏ผš",
+    "Compression level:": "ๅŽ‹็ผฉ็บงๅˆซ๏ผš",
+    "Repeater ID:": "ไธญ็ปง็ซ™ ID",
+    "WebSocket": "WebSocket",
+    "Encrypt": "ๅŠ ๅฏ†",
+    "Host:": "ไธปๆœบ๏ผš",
+    "Port:": "็ซฏๅฃ๏ผš",
+    "Path:": "่ทฏๅพ„๏ผš",
+    "Automatic reconnect": "่‡ชๅŠจ้‡ๆ–ฐ่ฟžๆŽฅ",
+    "Reconnect delay (ms):": "้‡ๆ–ฐ่ฟžๆŽฅ้—ด้š” (ms)๏ผš",
+    "Show dot when no cursor": "ๆ— ๅ…‰ๆ ‡ๆ—ถๆ˜พ็คบ็‚น",
+    "Logging:": "ๆ—ฅๅฟ—็บงๅˆซ๏ผš",
+    "Version:": "็‰ˆๆœฌ๏ผš",
+    "Disconnect": "ๆ–ญๅผ€่ฟžๆŽฅ",
+    "Connect": "่ฟžๆŽฅ",
+    "Server identity": "ๆœๅŠกๅ™จ่บซไปฝ",
+    "The server has provided the following identifying information:": "ๆœๅŠกๅ™จๆไพ›ไบ†ไปฅไธ‹่ฏ†ๅˆซไฟกๆฏ๏ผš",
+    "Fingerprint:": "ๆŒ‡็บน๏ผš",
+    "Please verify that the information is correct and press \"Approve\". Otherwise press \"Reject\".": "่ฏทๆ ธๅฎžไฟกๆฏๆ˜ฏๅฆๆญฃ็กฎ๏ผŒๅนถๆŒ‰ โ€œๅŒๆ„โ€๏ผŒๅฆๅˆ™ๆŒ‰ โ€œๆ‹’็ปโ€ใ€‚",
+    "Approve": "ๅŒๆ„",
+    "Reject": "ๆ‹’็ป",
+    "Credentials": "ๅ‡ญ่ฏ",
+    "Username:": "็”จๆˆทๅ:",
+    "Password:": "ๅฏ†็ ๏ผš",
+    "Send credentials": "ๅ‘้€ๅ‡ญ่ฏ",
+    "Cancel": "ๅ–ๆถˆ",
+    "Password is required": "่ฏทๆไพ›ๅฏ†็ ",
+    "Disconnect timeout": "่ถ…ๆ—ถๆ–ญๅผ€",
+    "viewport drag": "็ช—ๅฃๆ‹–ๅŠจ",
+    "Active Mouse Button": "ๅฏๅŠจ้ผ ๆ ‡ๆŒ‰้”ฎ",
+    "No mousebutton": "็ฆ็”จ้ผ ๆ ‡ๆŒ‰้”ฎ",
+    "Left mousebutton": "้ผ ๆ ‡ๅทฆ้”ฎ",
+    "Middle mousebutton": "้ผ ๆ ‡ไธญ้”ฎ",
+    "Right mousebutton": "้ผ ๆ ‡ๅณ้”ฎ",
+    "Clear": "ๆธ…้™ค",
+    "Local Downscaling": "้™ไฝŽๆœฌๅœฐๅฐบๅฏธ",
+    "Local Cursor": "ๆœฌๅœฐๅ…‰ๆ ‡",
+    "Canvas not supported.": "ไธๆ”ฏๆŒ Canvasใ€‚"
+}
\ No newline at end of file
pkg/web/noVNC/app/locale/zh_TW.json
@@ -0,0 +1,69 @@
+{
+    "Connecting...": "้€ฃ็ทšไธญ...",
+    "Disconnecting...": "ๆญฃๅœจไธญๆ–ท้€ฃ็ทš...",
+    "Reconnecting...": "้‡ๆ–ฐ้€ฃ็ทšไธญ...",
+    "Internal error": "ๅ…ง้ƒจ้Œฏ่ชค",
+    "Must set host": "่ซ‹ๆไพ›ไธปๆฉŸ่ณ‡่จŠ",
+    "Connected (encrypted) to ": "ๅทฒๅŠ ๅฏ†้€ฃ็ทšๅˆฐ",
+    "Connected (unencrypted) to ": "ๆœชๅŠ ๅฏ†้€ฃ็ทšๅˆฐ",
+    "Something went wrong, connection is closed": "็™ผ็”Ÿ้Œฏ่ชค๏ผŒ้€ฃ็ทšๅทฒ้—œ้–‰",
+    "Failed to connect to server": "็„กๆณ•้€ฃ็ทšๅˆฐไผบๆœๅ™จ",
+    "Disconnected": "้€ฃ็ทšๅทฒไธญๆ–ท",
+    "New connection has been rejected with reason: ": "้€ฃ็ทš่ขซๆ‹’็ต•๏ผŒๅŽŸๅ› ๏ผš",
+    "New connection has been rejected": "้€ฃ็ทš่ขซๆ‹’็ต•",
+    "Password is required": "่ซ‹ๆไพ›ๅฏ†็ขผ",
+    "noVNC encountered an error:": "noVNC ้‡ๅˆฐไธ€ๅ€‹้Œฏ่ชค๏ผš",
+    "Hide/Show the control bar": "้กฏ็คบ/้šฑ่—ๆŽงๅˆถๅˆ—",
+    "Move/Drag viewport": "ๆ‹–ๆ”พ้กฏ็คบ็ฏ„ๅœ",
+    "viewport drag": "้กฏ็คบ็ฏ„ๅœๆ‹–ๆ”พ",
+    "Active Mouse Button": "ๅ•Ÿ็”จๆป‘้ผ ๆŒ‰้ต",
+    "No mousebutton": "็„กๆป‘้ผ ๆŒ‰้ต",
+    "Left mousebutton": "ๆป‘้ผ ๅทฆ้ต",
+    "Middle mousebutton": "ๆป‘้ผ ไธญ้ต",
+    "Right mousebutton": "ๆป‘้ผ ๅณ้ต",
+    "Keyboard": "้ต็›ค",
+    "Show keyboard": "้กฏ็คบ้ต็›ค",
+    "Extra keys": "้กๅค–ๆŒ‰้ต",
+    "Show extra keys": "้กฏ็คบ้กๅค–ๆŒ‰้ต",
+    "Ctrl": "Ctrl",
+    "Toggle Ctrl": "ๅˆ‡ๆ› Ctrl",
+    "Alt": "Alt",
+    "Toggle Alt": "ๅˆ‡ๆ› Alt",
+    "Send Tab": "้€ๅ‡บ Tab ้ต",
+    "Tab": "Tab",
+    "Esc": "Esc",
+    "Send Escape": "้€ๅ‡บ Escape ้ต",
+    "Ctrl+Alt+Del": "Ctrl-Alt-Del",
+    "Send Ctrl-Alt-Del": "้€ๅ‡บ Ctrl-Alt-Del ๅฟซๆท้ต",
+    "Shutdown/Reboot": "้—œๆฉŸ/้‡ๆ–ฐๅ•Ÿๅ‹•",
+    "Shutdown/Reboot...": "้—œๆฉŸ/้‡ๆ–ฐๅ•Ÿๅ‹•...",
+    "Power": "้›ปๆบ",
+    "Shutdown": "้—œๆฉŸ",
+    "Reboot": "้‡ๆ–ฐๅ•Ÿๅ‹•",
+    "Reset": "้‡่จญ",
+    "Clipboard": "ๅ‰ช่ฒผ็ฐฟ",
+    "Clear": "ๆธ…้™ค",
+    "Fullscreen": "ๅ…จ่žขๅน•",
+    "Settings": "่จญๅฎš",
+    "Shared mode": "ๅˆ†ไบซๆจกๅผ",
+    "View only": "ๅƒ…ๆชข่ฆ–",
+    "Clip to window": "้™ๅˆถ/่ฃๅˆ‡่ฆ–็ช—ๅคงๅฐ",
+    "Scaling mode:": "็ธฎๆ”พๆจกๅผ๏ผš",
+    "None": "็„ก",
+    "Local scaling": "ๆœฌๆฉŸ็ธฎๆ”พ",
+    "Remote resizing": "้ ็ซฏ่ชฟๆ•ดๅคงๅฐ",
+    "Advanced": "้€ฒ้šŽ",
+    "Repeater ID:": "ไธญ็นผ็ซ™ ID",
+    "WebSocket": "WebSocket",
+    "Encrypt": "ๅŠ ๅฏ†",
+    "Host:": "ไธปๆฉŸ๏ผš",
+    "Port:": "้€ฃๆŽฅๅŸ ๏ผš",
+    "Path:": "่ทฏๅพ‘๏ผš",
+    "Automatic reconnect": "่‡ชๅ‹•้‡ๆ–ฐ้€ฃ็ทš",
+    "Reconnect delay (ms):": "้‡ๆ–ฐ้€ฃ็ทš้–“้š” (ms)๏ผš",
+    "Logging:": "ๆ—ฅ่ชŒ็ดšๅˆฅ๏ผš",
+    "Disconnect": "ไธญๆ–ท้€ฃ็ทš",
+    "Connect": "้€ฃ็ทš",
+    "Password:": "ๅฏ†็ขผ๏ผš",
+    "Cancel": "ๅ–ๆถˆ"
+}
\ No newline at end of file
pkg/web/noVNC/app/sounds/bell.mp3
Binary file
pkg/web/noVNC/app/sounds/bell.oga
Binary file
pkg/web/noVNC/app/sounds/CREDITS
@@ -0,0 +1,4 @@
+bell
+        Copyright: Dr. Richard Boulanger et al
+        URL: http://www.archive.org/details/Berklee44v12
+        License: CC-BY Attribution 3.0 Unported
pkg/web/noVNC/app/styles/base.css
@@ -0,0 +1,927 @@
+/*
+ * noVNC base CSS
+ * Copyright (C) 2019 The noVNC authors
+ * noVNC is licensed under the MPL 2.0 (see LICENSE.txt)
+ * This file is licensed under the 2-Clause BSD license (see LICENSE.txt).
+ */
+
+/*
+ * Z index layers:
+ *
+ * 0: Main screen
+ * 10: Control bar
+ * 50: Transition blocker
+ * 60: Connection popups
+ * 100: Status bar
+ * ...
+ * 1000: Javascript crash
+ * ...
+ * 10000: Max (used for polyfills)
+ */
+
+/*
+ * State variables (set on :root):
+ *
+ * noVNC_loading: Page is still loading
+ * noVNC_connecting: Connecting to server
+ * noVNC_reconnecting: Re-establishing a connection
+ * noVNC_connected: Connected to server (most common state)
+ * noVNC_disconnecting: Disconnecting from server
+ */
+
+:root {
+    font-family: sans-serif;
+    line-height: 1.6;
+}
+
+body {
+    margin:0;
+    padding:0;
+    /*Background image with light grey curve.*/
+    background-color:#494949;
+    background-repeat:no-repeat;
+    background-position:right bottom;
+    height:100%;
+    touch-action: none;
+}
+
+html {
+    height:100%;
+}
+
+.noVNC_only_touch.noVNC_hidden {
+    display: none;
+}
+
+.noVNC_disabled {
+    color: var(--novnc-grey);
+}
+
+/* ----------------------------------------
+ * Spinner
+ * ----------------------------------------
+ */
+
+.noVNC_spinner {
+    position: relative;
+}
+.noVNC_spinner, .noVNC_spinner::before, .noVNC_spinner::after {
+    width: 10px;
+    height: 10px;
+    border-radius: 2px;
+    box-shadow: -60px 10px 0 rgba(255, 255, 255, 0);
+    animation: noVNC_spinner 1.0s linear infinite;
+}
+.noVNC_spinner::before {
+    content: "";
+    position: absolute;
+    left: 0px;
+    top: 0px;
+    animation-delay: -0.1s;
+}
+.noVNC_spinner::after {
+    content: "";
+    position: absolute;
+    top: 0px;
+    left: 0px;
+    animation-delay: 0.1s;
+}
+@keyframes noVNC_spinner {
+    0% { box-shadow: -60px 10px 0 rgba(255, 255, 255, 0); width: 20px; }
+    25% { box-shadow: 20px 10px 0 rgba(255, 255, 255, 1); width: 10px; }
+    50% { box-shadow: 60px 10px 0 rgba(255, 255, 255, 0); width: 10px; }
+}
+
+/* ----------------------------------------
+ * WebKit centering hacks
+ * ----------------------------------------
+ */
+
+.noVNC_center {
+    /*
+     * This is a workaround because webkit misrenders transforms and
+     * uses non-integer coordinates, resulting in blurry content.
+     * Ideally we'd use "top: 50%; transform: translateY(-50%);" on
+     * the objects instead.
+     */
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    position: fixed;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    pointer-events: none;
+}
+.noVNC_center > * {
+    pointer-events: auto;
+}
+.noVNC_vcenter {
+    display: flex !important;
+    flex-direction: column;
+    justify-content: center;
+    position: fixed;
+    top: 0;
+    left: 0;
+    height: 100%;
+    margin: 0 !important;
+    padding: 0 !important;
+    pointer-events: none;
+}
+.noVNC_vcenter > * {
+    pointer-events: auto;
+}
+
+/* ----------------------------------------
+ * Layering
+ * ----------------------------------------
+ */
+
+.noVNC_connect_layer {
+    z-index: 60;
+}
+
+/* ----------------------------------------
+ * Fallback error
+ * ----------------------------------------
+ */
+
+#noVNC_fallback_error {
+    z-index: 1000;
+    visibility: hidden;
+    /* Put a dark background in front of everything but the error,
+       and don't let mouse events pass through */
+    background: rgba(0, 0, 0, 0.8);
+    pointer-events: all;
+}
+#noVNC_fallback_error.noVNC_open {
+    visibility: visible;
+}
+
+#noVNC_fallback_error > div {
+    max-width: calc(100vw - 30px - 30px);
+    max-height: calc(100vh - 30px - 30px);
+    overflow: auto;
+
+    padding: 15px;
+
+    transition: 0.5s ease-in-out;
+
+    transform: translateY(-50px);
+    opacity: 0;
+
+    text-align: center;
+    font-weight: bold;
+    color: #fff;
+
+    border-radius: 12px;
+    box-shadow: 6px 6px 0px rgba(0, 0, 0, 0.5);
+    background: rgba(200,55,55,0.8);
+}
+#noVNC_fallback_error.noVNC_open > div {
+    transform: translateY(0);
+    opacity: 1;
+}
+
+#noVNC_fallback_errormsg {
+    font-weight: normal;
+}
+
+#noVNC_fallback_errormsg .noVNC_message {
+    display: inline-block;
+    text-align: left;
+    font-family: monospace;
+    white-space: pre-wrap;
+}
+
+#noVNC_fallback_error .noVNC_location {
+    font-style: italic;
+    font-size: 0.8em;
+    color: rgba(255, 255, 255, 0.8);
+}
+
+#noVNC_fallback_error .noVNC_stack {
+    padding: 10px;
+    margin: 10px;
+    font-size: 0.8em;
+    text-align: left;
+    font-family: monospace;
+    white-space: pre;
+    border: 1px solid rgba(0, 0, 0, 0.5);
+    background: rgba(0, 0, 0, 0.2);
+    overflow: auto;
+}
+
+/* ----------------------------------------
+ * Control bar
+ * ----------------------------------------
+ */
+
+#noVNC_control_bar_anchor {
+    /* The anchor is needed to get z-stacking to work */
+    position: fixed;
+    z-index: 10;
+
+    transition: 0.5s ease-in-out;
+
+    /* Edge misrenders animations wihthout this */
+    transform: translateX(0);
+}
+:root.noVNC_connected #noVNC_control_bar_anchor.noVNC_idle {
+    opacity: 0.8;
+}
+#noVNC_control_bar_anchor.noVNC_right {
+    left: auto;
+    right: 0;
+}
+
+#noVNC_control_bar {
+    position: relative;
+    left: -100%;
+
+    transition: 0.5s ease-in-out;
+
+    background-color: var(--novnc-blue);
+    border-radius: 0 12px 12px 0;
+
+    user-select: none;
+    -webkit-user-select: none;
+    -webkit-touch-callout: none; /* Disable iOS image long-press popup */
+}
+#noVNC_control_bar.noVNC_open {
+    box-shadow: 6px 6px 0px rgba(0, 0, 0, 0.5);
+    left: 0;
+}
+#noVNC_control_bar::before {
+    /* This extra element is to get a proper shadow */
+    content: "";
+    position: absolute;
+    z-index: -1;
+    height: 100%;
+    width: 30px;
+    left: -30px;
+    transition: box-shadow 0.5s ease-in-out;
+}
+#noVNC_control_bar.noVNC_open::before {
+    box-shadow: 6px 6px 0px rgba(0, 0, 0, 0.5);
+}
+.noVNC_right #noVNC_control_bar {
+    left: 100%;
+    border-radius: 12px 0 0 12px;
+}
+.noVNC_right #noVNC_control_bar.noVNC_open {
+    left: 0;
+}
+.noVNC_right #noVNC_control_bar::before {
+    visibility: hidden;
+}
+
+#noVNC_control_bar_handle {
+    position: absolute;
+    left: -15px;
+    top: 0;
+    transform: translateY(35px);
+    width: calc(100% + 30px);
+    height: 50px;
+    z-index: -1;
+    cursor: pointer;
+    border-radius: 6px;
+    background-color: var(--novnc-darkblue);
+    background-image: url("../images/handle_bg.svg");
+    background-repeat: no-repeat;
+    background-position: right;
+    box-shadow: 3px 3px 0px rgba(0, 0, 0, 0.5);
+}
+#noVNC_control_bar_handle:after {
+    content: "";
+    transition: transform 0.5s ease-in-out;
+    background: url("../images/handle.svg");
+    position: absolute;
+    top: 22px; /* (50px-6px)/2 */
+    right: 5px;
+    width: 5px;
+    height: 6px;
+}
+#noVNC_control_bar.noVNC_open #noVNC_control_bar_handle:after {
+    transform: translateX(1px) rotate(180deg);
+}
+:root:not(.noVNC_connected) #noVNC_control_bar_handle {
+    display: none;
+}
+.noVNC_right #noVNC_control_bar_handle {
+    background-position: left;
+}
+.noVNC_right #noVNC_control_bar_handle:after {
+    left: 5px;
+    right: 0;
+    transform: translateX(1px) rotate(180deg);
+}
+.noVNC_right #noVNC_control_bar.noVNC_open #noVNC_control_bar_handle:after {
+    transform: none;
+}
+/* Larger touch area for the handle, used when a touch screen is available */
+#noVNC_control_bar_handle div {
+    position: absolute;
+    right: -35px;
+    top: 0;
+    width: 50px;
+    height: 100%;
+    display: none;
+}
+@media (any-pointer: coarse) {
+    #noVNC_control_bar_handle div {
+        display: initial;
+    }
+}
+.noVNC_right #noVNC_control_bar_handle div {
+    left: -35px;
+    right: auto;
+}
+
+#noVNC_control_bar > .noVNC_scroll {
+    max-height: 100vh; /* Chrome is buggy with 100% */
+    overflow-x: hidden;
+    overflow-y: auto;
+    padding: 0 10px;
+}
+
+#noVNC_control_bar > .noVNC_scroll > * {
+    display: block;
+    margin: 10px auto;
+}
+
+/* Control bar hint */
+#noVNC_hint_anchor {
+    position: fixed;
+    right: -50px;
+    left: auto;
+}
+#noVNC_control_bar_anchor.noVNC_right + #noVNC_hint_anchor {
+    left: -50px;
+    right: auto;
+}
+#noVNC_control_bar_hint {
+    position: relative;
+    transform: scale(0);
+    width: 100px;
+    height: 50%;
+    max-height: 600px;
+
+    visibility: hidden;
+    opacity: 0;
+    transition: 0.2s ease-in-out;
+    background: transparent;
+    box-shadow: 0 0 10px black, inset 0 0 10px 10px var(--novnc-darkblue);
+    border-radius: 12px;
+    transition-delay: 0s;
+}
+#noVNC_control_bar_hint.noVNC_active {
+    visibility: visible;
+    opacity: 1;
+    transition-delay: 0.2s;
+    transform: scale(1);
+}
+#noVNC_control_bar_hint.noVNC_notransition {
+    transition: none !important;
+}
+
+/* Control bar buttons */
+#noVNC_control_bar .noVNC_button {
+    min-width: unset;
+    padding: 4px 4px;
+    vertical-align: middle;
+    border:1px solid rgba(255, 255, 255, 0.2);
+    border-radius: 6px;
+    background-color: transparent;
+}
+#noVNC_control_bar .noVNC_button.noVNC_selected {
+    border-color: rgba(0, 0, 0, 0.8);
+    background-color: rgba(0, 0, 0, 0.5);
+}
+#noVNC_control_bar .noVNC_button.noVNC_hidden {
+    display: none !important;
+}
+
+/* Panels */
+.noVNC_panel {
+    transform: translateX(25px);
+
+    transition: 0.5s ease-in-out;
+
+    box-sizing: border-box; /* so max-width don't have to care about padding */
+    max-width: calc(100vw - 75px - 25px); /* minus left and right margins */
+    max-height: 100vh; /* Chrome is buggy with 100% */
+    overflow-x: hidden;
+    overflow-y: auto;
+
+    visibility: hidden;
+    opacity: 0;
+
+    padding: 15px;
+
+    background: #fff;
+    border-radius: 12px;
+    color: #000;
+    border: 2px solid #E0E0E0;
+    box-shadow: 6px 6px 0px rgba(0, 0, 0, 0.5);
+}
+.noVNC_panel.noVNC_open {
+    visibility: visible;
+    opacity: 1;
+    transform: translateX(75px);
+}
+.noVNC_right .noVNC_vcenter {
+    left: auto;
+    right: 0;
+}
+.noVNC_right .noVNC_panel {
+    transform: translateX(-25px);
+}
+.noVNC_right .noVNC_panel.noVNC_open {
+    transform: translateX(-75px);
+}
+
+.noVNC_panel > * {
+    display: block;
+    margin: 10px auto;
+}
+.noVNC_panel > *:first-child {
+    margin-top: 0 !important;
+}
+.noVNC_panel > *:last-child {
+    margin-bottom: 0 !important;
+}
+
+.noVNC_panel hr {
+    border: none;
+    border-top: 1px solid var(--novnc-lightgrey);
+    width: 100%; /* <hr> inside a flexbox will otherwise be 0px wide */
+}
+
+.noVNC_panel label {
+    display: block;
+    white-space: nowrap;
+    margin: 5px;
+}
+@media (max-width: 540px) {
+    /* Allow wrapping on small screens */
+    .noVNC_panel label {
+        white-space: unset;
+    }
+}
+
+.noVNC_panel li {
+    margin: 5px;
+}
+
+.noVNC_panel .noVNC_heading {
+    background-color: var(--novnc-blue);
+    border-radius: 6px;
+    padding: 5px 8px;
+    /* Compensate for padding in image */
+    padding-right: 11px;
+    display: flex;
+    align-items: center;
+    gap: 6px;
+    color: white;
+    font-size: 20px;
+    font-weight: bold;
+    white-space: nowrap;
+}
+.noVNC_panel .noVNC_heading img {
+    vertical-align: bottom;
+}
+
+.noVNC_panel form {
+    display: flex;
+    flex-direction: column;
+    gap: 12px
+}
+
+.noVNC_panel .button_row {
+    margin-top: 10px;
+    display: flex;
+    gap: 10px;
+    justify-content: space-between;
+}
+.noVNC_panel .button_row *:only-child {
+    margin-left: auto; /* Align single buttons to the right */
+}
+
+/* Expanders */
+.noVNC_expander {
+    cursor: pointer;
+}
+.noVNC_expander::before {
+    content: url("../images/expander.svg");
+    display: inline-block;
+    margin-right: 5px;
+    transition: 0.2s ease-in-out;
+}
+.noVNC_expander.noVNC_open::before {
+    transform: rotateZ(90deg);
+}
+.noVNC_expander ~ * {
+    margin: 5px;
+    margin-left: 10px;
+    padding: 5px;
+    background: rgba(0, 0, 0, 0.04);
+    border-radius: 6px;
+}
+.noVNC_expander:not(.noVNC_open) ~ * {
+    display: none;
+}
+
+/* Control bar content */
+
+#noVNC_control_bar .noVNC_logo {
+    font-size: 13px;
+}
+
+.noVNC_logo + hr {
+    /* Remove all but top border */
+    border: none;
+    border-top: 1px solid rgba(255, 255, 255, 0.2);
+}
+
+:root:not(.noVNC_connected) #noVNC_view_drag_button {
+    display: none;
+}
+
+/* noVNC Touch Device only buttons */
+:root:not(.noVNC_connected) #noVNC_mobile_buttons {
+    display: none;
+}
+@media not all and (any-pointer: coarse) {
+    /* FIXME: The button for the virtual keyboard is the only button in this
+              group of "mobile buttons". It is bad to assume that no touch
+              devices have physical keyboards available. Hopefully we can get
+              a media query for this:
+              https://github.com/w3c/csswg-drafts/issues/3871 */
+    :root.noVNC_connected #noVNC_mobile_buttons {
+        display: none;
+    }
+}
+
+/* Extra manual keys */
+:root:not(.noVNC_connected) #noVNC_toggle_extra_keys_button {
+    display: none;
+}
+
+#noVNC_modifiers {
+    background-color: var(--novnc-darkgrey);
+    border: none;
+    padding: 10px;
+}
+
+/* Shutdown/Reboot */
+:root:not(.noVNC_connected) #noVNC_power_button {
+    display: none;
+}
+#noVNC_power {
+}
+#noVNC_power_buttons {
+    display: none;
+}
+
+#noVNC_power input[type=button] {
+    width: 100%;
+}
+
+/* Clipboard */
+:root:not(.noVNC_connected) #noVNC_clipboard_button {
+    display: none;
+}
+#noVNC_clipboard_text {
+    width: 360px;
+    min-width: 150px;
+    height: 160px;
+    min-height: 70px;
+
+    box-sizing: border-box;
+    max-width: 100%;
+    /* minus approximate height of title, height of subtitle, and margin */
+    max-height: calc(100vh - 10em - 25px);
+}
+
+/* Settings */
+#noVNC_settings {
+}
+#noVNC_settings ul {
+    list-style: none;
+    padding: 0px;
+}
+#noVNC_settings button,
+#noVNC_settings select,
+#noVNC_settings textarea,
+#noVNC_settings input:not([type=checkbox]):not([type=radio]) {
+    margin-left: 6px;
+    /* Prevent inputs in settings from being too wide */
+    max-width: calc(100% - 6px - var(--input-xpadding) * 2);
+}
+
+#noVNC_setting_port {
+    width: 80px;
+}
+#noVNC_setting_path {
+    width: 100px;
+}
+
+/* Version */
+
+.noVNC_version_wrapper {
+    font-size: small;
+}
+
+.noVNC_version {
+    margin-left: 1rem;
+}
+
+/* Connection controls */
+:root:not(.noVNC_connected) #noVNC_disconnect_button {
+    display: none;
+}
+
+/* ----------------------------------------
+ * Status dialog
+ * ----------------------------------------
+ */
+
+#noVNC_status {
+    position: fixed;
+    top: 0;
+    left: 0;
+    width: 100%;
+    z-index: 100;
+    transform: translateY(-100%);
+
+    cursor: pointer;
+
+    transition: 0.5s ease-in-out;
+
+    visibility: hidden;
+    opacity: 0;
+
+    padding: 5px;
+
+    display: flex;
+    flex-direction: row;
+    justify-content: center;
+    align-content: center;
+
+    line-height: 1.6;
+    word-wrap: break-word;
+    color: #fff;
+
+    border-bottom: 1px solid rgba(0, 0, 0, 0.9);
+}
+#noVNC_status.noVNC_open {
+    transform: translateY(0);
+    visibility: visible;
+    opacity: 1;
+}
+
+#noVNC_status::before {
+    content: "";
+    display: inline-block;
+    width: 25px;
+    height: 25px;
+    margin-right: 5px;
+}
+
+#noVNC_status.noVNC_status_normal {
+    background: rgba(128,128,128,0.9);
+}
+#noVNC_status.noVNC_status_normal::before {
+    content: url("../images/info.svg") " ";
+}
+#noVNC_status.noVNC_status_error {
+    background: rgba(200,55,55,0.9);
+}
+#noVNC_status.noVNC_status_error::before {
+    content: url("../images/error.svg") " ";
+}
+#noVNC_status.noVNC_status_warn {
+    background: rgba(180,180,30,0.9);
+}
+#noVNC_status.noVNC_status_warn::before {
+    content: url("../images/warning.svg") " ";
+}
+
+/* ----------------------------------------
+ * Connect dialog
+ * ----------------------------------------
+ */
+
+#noVNC_connect_dlg {
+    transition: 0.5s ease-in-out;
+
+    transform: scale(0, 0);
+    visibility: hidden;
+    opacity: 0;
+}
+#noVNC_connect_dlg.noVNC_open {
+    transform: scale(1, 1);
+    visibility: visible;
+    opacity: 1;
+}
+#noVNC_connect_dlg .noVNC_logo {
+    transition: 0.5s ease-in-out;
+    padding: 10px;
+    margin-bottom: 10px;
+
+    font-size: 80px;
+    text-align: center;
+
+    border-radius: 6px;
+}
+@media (max-width: 440px) {
+    #noVNC_connect_dlg {
+        max-width: calc(100vw - 100px);
+    }
+    #noVNC_connect_dlg .noVNC_logo {
+        font-size: calc(25vw - 30px);
+    }
+}
+#noVNC_connect_dlg div {
+    padding: 18px;
+
+    background-color: var(--novnc-darkgrey);
+    border-radius: 12px;
+    text-align: center;
+    font-size: 20px;
+
+    box-shadow: 6px 6px 0px rgba(0, 0, 0, 0.5);
+}
+#noVNC_connect_button {
+    width: 100%;
+    padding: 6px 30px;
+    cursor: pointer;
+    border-color: transparent;
+    border-radius: 12px;
+    background-color: var(--novnc-blue);
+    color: white;
+
+    display: flex;
+    justify-content: center;
+    place-items: center;
+    gap: 4px;
+}
+
+#noVNC_connect_button img {
+    vertical-align: bottom;
+    height: 1.3em;
+}
+
+/* ----------------------------------------
+ * Server verification dialog
+ * ----------------------------------------
+ */
+
+#noVNC_verify_server_dlg {
+    position: relative;
+
+    transform: translateY(-50px);
+}
+#noVNC_verify_server_dlg.noVNC_open {
+    transform: translateY(0);
+}
+#noVNC_fingerprint_block {
+    margin: 10px;
+}
+
+/* ----------------------------------------
+ * Password dialog
+ * ----------------------------------------
+ */
+
+#noVNC_credentials_dlg {
+    position: relative;
+
+    transform: translateY(-50px);
+}
+#noVNC_credentials_dlg.noVNC_open {
+    transform: translateY(0);
+}
+#noVNC_username_block.noVNC_hidden,
+#noVNC_password_block.noVNC_hidden {
+    display: none;
+}
+
+
+/* ----------------------------------------
+ * Main area
+ * ----------------------------------------
+ */
+
+/* Transition screen */
+#noVNC_transition {
+    transition: 0.5s ease-in-out;
+
+    display: flex;
+    opacity: 0;
+    visibility: hidden;
+
+    position: fixed;
+    top: 0;
+    left: 0;
+    bottom: 0;
+    right: 0;
+
+    color: white;
+    background: rgba(0, 0, 0, 0.5);
+    z-index: 50;
+
+    /*display: flex;*/
+    align-items: center;
+    justify-content: center;
+    flex-direction: column;
+}
+:root.noVNC_loading #noVNC_transition,
+:root.noVNC_connecting #noVNC_transition,
+:root.noVNC_disconnecting #noVNC_transition,
+:root.noVNC_reconnecting #noVNC_transition {
+    opacity: 1;
+    visibility: visible;
+}
+:root:not(.noVNC_reconnecting) #noVNC_cancel_reconnect_button {
+    display: none;
+}
+#noVNC_transition_text {
+    font-size: 1.5em;
+}
+
+/* Main container */
+#noVNC_container {
+    width: 100%;
+    height: 100%;
+    background-color: #313131;
+    border-bottom-right-radius: 800px 600px;
+    /*border-top-left-radius: 800px 600px;*/
+
+    /* If selection isn't disabled, long-pressing stuff in the sidebar
+       can accidentally select the container or the canvas. This can
+       happen when attempting to move the handle. */
+    user-select: none;
+    -webkit-user-select: none;
+}
+
+#noVNC_keyboardinput {
+    width: 1px;
+    height: 1px;
+    background-color: #fff;
+    color: #fff;
+    border: 0;
+    position: absolute;
+    left: -40px;
+    z-index: -1;
+    ime-mode: disabled;
+}
+
+/*Default noVNC logo.*/
+/* From: http://fonts.googleapis.com/css?family=Orbitron:700 */
+@font-face {
+    font-family: 'Orbitron';
+    font-style: normal;
+    font-weight: 700;
+    src: local('?'), url('Orbitron700.woff') format('woff'),
+                     url('Orbitron700.ttf') format('truetype');
+}
+
+.noVNC_logo {
+    color: var(--novnc-yellow);
+    font-family: 'Orbitron', 'OrbitronTTF', sans-serif;
+    line-height: 0.9;
+    text-shadow: 0.1em 0.1em 0 black;
+}
+.noVNC_logo span{
+    color: var(--novnc-green);
+}
+
+#noVNC_bell {
+    display: none;
+}
+
+/* ----------------------------------------
+ * Media sizing
+ * ----------------------------------------
+ */
+
+@media screen and (max-width: 640px){
+    #noVNC_logo {
+        font-size: 150px;
+    }
+}
+
+@media screen and (min-width: 321px) and (max-width: 480px) {
+    #noVNC_logo {
+        font-size: 110px;
+    }
+}
+
+@media screen and (max-width: 320px) {
+    #noVNC_logo {
+        font-size: 90px;
+    }
+}
pkg/web/noVNC/app/styles/constants.css
@@ -0,0 +1,30 @@
+/*
+ * noVNC general CSS constant variables
+ * Copyright (C) 2025 The noVNC authors
+ * noVNC is licensed under the MPL 2.0 (see LICENSE.txt)
+ * This file is licensed under the 2-Clause BSD license (see LICENSE.txt).
+ */
+
+/* ---------- COLORS ----------- */
+
+:root {
+    --novnc-grey: rgb(128, 128, 128);
+    --novnc-lightgrey: rgb(192, 192, 192);
+    --novnc-darkgrey: rgb(92, 92, 92);
+
+    /* Transparent to make button colors adapt to the background */
+    --novnc-buttongrey: rgba(192, 192, 192, 0.5);
+
+    --novnc-blue: rgb(110, 132, 163);
+    --novnc-lightblue: rgb(74, 144, 217);
+    --novnc-darkblue: rgb(83, 99, 122);
+
+    --novnc-green: rgb(0, 128, 0);
+    --novnc-yellow: rgb(255, 255, 0);
+}
+
+/* ------ MISC PROPERTIES ------ */
+
+:root {
+    --input-xpadding: 1em;
+}
pkg/web/noVNC/app/styles/input.css
@@ -0,0 +1,628 @@
+/*
+ * noVNC general input element CSS
+ * Copyright (C) 2025 The noVNC authors
+ * noVNC is licensed under the MPL 2.0 (see LICENSE.txt)
+ * This file is licensed under the 2-Clause BSD license (see LICENSE.txt).
+ */
+
+/* ------- SHARED BETWEEN INPUT ELEMENTS -------- */
+
+input,
+textarea,
+button,
+select,
+input::file-selector-button {
+    padding: 0.5em var(--input-xpadding);
+    border-radius: 6px;
+    appearance: none;
+    text-overflow: ellipsis;
+
+    /* Respect standard font settings */
+    font: inherit;
+    line-height: 1.6;
+}
+input:disabled,
+textarea:disabled,
+button:disabled,
+select:disabled,
+label[disabled] {
+    opacity: 0.4;
+}
+
+input:focus-visible,
+textarea:focus-visible,
+button:focus-visible,
+select:focus-visible,
+input:focus-visible::file-selector-button {
+    outline: 2px solid var(--novnc-lightblue);
+    outline-offset: 1px;
+}
+
+/* ------- TEXT INPUT -------- */
+
+input:not([type]),
+input[type=date],
+input[type=datetime-local],
+input[type=email],
+input[type=month],
+input[type=number],
+input[type=password],
+input[type=search],
+input[type=tel],
+input[type=text],
+input[type=time],
+input[type=url],
+input[type=week],
+textarea {
+    border: 1px solid var(--novnc-lightgrey);
+    /* Account for borders on text inputs, buttons dont have borders */
+    padding: calc(0.5em - 1px) var(--input-xpadding);
+}
+input:not([type]):focus-visible,
+input[type=date]:focus-visible,
+input[type=datetime-local]:focus-visible,
+input[type=email]:focus-visible,
+input[type=month]:focus-visible,
+input[type=number]:focus-visible,
+input[type=password]:focus-visible,
+input[type=search]:focus-visible,
+input[type=tel]:focus-visible,
+input[type=text]:focus-visible,
+input[type=time]:focus-visible,
+input[type=url]:focus-visible,
+input[type=week]:focus-visible,
+textarea:focus-visible {
+    outline-offset: -1px;
+}
+
+textarea {
+    margin: unset; /* Remove Firefox's built in margin */
+    /* Prevent layout from shifting when scrollbars show */
+    scrollbar-gutter: stable;
+    /* Make textareas show at minimum one line. This does not work when
+       using box-sizing border-box, in which case, vertical padding and
+       border width needs to be taken into account. */
+    min-height: 1lh;
+    vertical-align: baseline; /* Firefox gives "text-bottom" by default */
+}
+
+/* ------- NUMBER PICKERS ------- */
+
+/* We can't style the number spinner buttons:
+   https://github.com/w3c/csswg-drafts/issues/8777 */
+input[type=number]::-webkit-inner-spin-button,
+input[type=number]::-webkit-outer-spin-button {
+    /* Get rid of increase/decrease buttons in WebKit */
+    appearance: none;
+}
+input[type=number] {
+    /* Get rid of increase/decrease buttons in Firefox */
+    appearance: textfield;
+}
+
+/* ------- BUTTON ACTIVATIONS -------- */
+
+/* A color overlay that depends on the activation level. The level can then be
+   set for different states on an element, for example hover and click on a
+   <button>. */
+input, button, select, option,
+input::file-selector-button,
+.button-activations {
+    --button-activation-level: 0;
+    /* Note that CSS variables aren't functions, beware when inheriting */
+    --button-activation-alpha: calc(0.08 * var(--button-activation-level));
+    /* FIXME: We want the image() function instead of the linear-gradient()
+              function below. But it's not supported in the browsers yet. */
+    --button-activation-overlay:
+        linear-gradient(rgba(0, 0, 0, var(--button-activation-alpha))
+        100%, transparent);
+    --button-activation-overlay-light:
+        linear-gradient(rgba(255, 255, 255, calc(0.23 * var(--button-activation-level)))
+        100%, transparent);
+}
+.button-activations {
+    background-image: var(--button-activation-overlay);
+
+    /* Disable Chrome's touch tap highlight to avoid conflicts with overlay */
+    -webkit-tap-highlight-color: transparent;
+}
+/* When we want the light overlay on activations instead.
+   This is best used on elements with darker backgrounds. */
+.button-activations.light-overlay {
+    background-image: var(--button-activation-overlay-light);
+    /* Can't use the normal blend mode since that gives washed out colors. */
+    /* FIXME: For elements with these activation overlays we'd like only
+              the luminosity to change. The proprty "background-blend-mode" set
+              to "luminosity" sounds good, but it doesn't work as intended,
+              see: https://bugzilla.mozilla.org/show_bug.cgi?id=1806417 */
+    background-blend-mode: overlay;
+}
+
+input:hover, button:hover, select:hover, option:hover,
+input::file-selector-button:hover,
+.button-activations:hover {
+    --button-activation-level: 1;
+}
+/* Unfortunately we have to disable the :hover effect on touch devices,
+   otherwise the style lingers after tapping the button. */
+@media (any-pointer: coarse) {
+    input:hover, button:hover, select:hover, option:hover,
+    input::file-selector-button:hover,
+    .button-activations:hover {
+        --button-activation-level: 0;
+    }
+}
+input:active, button:active, select:active, option:active,
+input::file-selector-button:active,
+.button-activations:active {
+    --button-activation-level: 2;
+}
+input:disabled, button:disabled, select:disabled, select:disabled option,
+input:disabled::file-selector-button,
+.button-activations:disabled {
+    --button-activation-level: 0;
+}
+
+/* ------- BUTTONS -------- */
+
+input[type=button],
+input[type=color],
+input[type=image],
+input[type=reset],
+input[type=submit],
+input::file-selector-button,
+button,
+select {
+    min-width: 8em;
+    border: none;
+    color: black;
+    font-weight: bold;
+    background-color: var(--novnc-buttongrey);
+    background-image: var(--button-activation-overlay);
+    cursor: pointer;
+    /* Disable Chrome's touch tap highlight */
+    -webkit-tap-highlight-color: transparent;
+}
+input[type=button]:disabled,
+input[type=color]:disabled,
+input[type=image]:disabled,
+input[type=reset]:disabled,
+input[type=submit]:disabled,
+input:disabled::file-selector-button,
+button:disabled,
+select:disabled {
+    /* See Firefox bug:
+       https://bugzilla.mozilla.org/show_bug.cgi?id=1798304 */
+    cursor: default;
+}
+
+input[type=button],
+input[type=color],
+input[type=reset],
+input[type=submit] {
+    /* Workaround for text-overflow bugs in Firefox and Chromium:
+        https://bugzilla.mozilla.org/show_bug.cgi?id=1800077
+        https://bugs.chromium.org/p/chromium/issues/detail?id=1383144 */
+    overflow: clip;
+}
+
+/* ------- COLOR PICKERS ------- */
+
+input[type=color] {
+    min-width: unset;
+    box-sizing: content-box;
+    width: 1.4em;
+    height: 1.4em;
+}
+input[type=color]::-webkit-color-swatch-wrapper {
+    padding: 0;
+}
+/* -webkit-color-swatch & -moz-color-swatch cant be in a selector list:
+   https://bugs.chromium.org/p/chromium/issues/detail?id=1154623 */
+input[type=color]::-webkit-color-swatch {
+    border: none;
+    border-radius: 6px;
+}
+input[type=color]::-moz-color-swatch {
+    border: none;
+    border-radius: 6px;
+}
+
+/* -- SHARED BETWEEN CHECKBOXES, RADIOBUTTONS AND THE TOGGLE CLASS -- */
+
+input[type=radio],
+input[type=checkbox] {
+    display: inline-flex;
+    justify-content: center;
+    align-items: center;
+    background-color: var(--novnc-buttongrey);
+    background-image: var(--button-activation-overlay);
+    /* Disable Chrome's touch tap highlight to avoid conflicts with overlay */
+    -webkit-tap-highlight-color: transparent;
+    width: 16px;
+    --checkradio-height: 16px;
+    height: var(--checkradio-height);
+    padding: 0;
+    margin: 0 6px 0 0;
+    /* Don't have transitions for outline in order to be consistent
+       with other elements */
+    transition: all 0.2s, outline-color 0s, outline-offset 0s;
+
+    /* A transparent outline in order to work around a graphical clipping issue
+       in WebKit. See bug: https://bugs.webkit.org/show_bug.cgi?id=256003 */
+    outline: 1px solid transparent;
+    position: relative; /* Since ::before & ::after are absolute positioned */
+
+    /* We want to align with the middle of capital letters, this requires
+       a workaround. The default behavior is to align the bottom of the element
+       on top of the text baseline, this is too far up.
+       We want to push the element down half the difference in height between
+       it and a capital X. In our font, the height of a capital "X" is 0.698em.
+     */
+    vertical-align: calc(0px - (var(--checkradio-height) - 0.698em) / 2);
+    /* FIXME: Could write 1cap instead of 0.698em, but it's only supported in
+              Firefox as of 2023 */
+    /* FIXME: We probably want to use round() here, see bug 8148 */
+}
+input[type=radio]:focus-visible,
+input[type=checkbox]:focus-visible {
+    outline-color: var(--novnc-lightblue);
+}
+input[type=checkbox]::before,
+input[type=checkbox]:not(.toggle)::after,
+input[type=radio]::before,
+input[type=radio]::after {
+    content: "";
+    display: block; /* width & height doesn't work on inline elements */
+    transition: inherit;
+    /* Let's prevent the pseudo-elements from taking up layout space so that
+       the ::before and ::after pseudo-elements can be in the same place. This
+       is also required for vertical-align: baseline to work like we want it to
+       on radio/checkboxes. If the pseudo-elements take up layout space, the
+       baseline of text inside them will be used instead. */
+    position: absolute;
+}
+input[type=checkbox]:not(.toggle)::after,
+input[type=radio]::after {
+    width: 10px;
+    height: 2px;
+    background-color: transparent;
+    border-radius: 2px;
+}
+
+/* ------- CHECKBOXES ------- */
+
+input[type=checkbox]:not(.toggle) {
+    border-radius: 4px;
+}
+input[type=checkbox]:not(.toggle):checked,
+input[type=checkbox]:not(.toggle):indeterminate {
+    background-color: var(--novnc-blue);
+    background-image: var(--button-activation-overlay-light);
+    background-blend-mode: overlay;
+}
+input[type=checkbox]:not(.toggle)::before {
+    width: 25%;
+    height: 55%;
+    border-style: solid;
+    border-color: transparent;
+    border-width: 0 2px 2px 0;
+    border-radius: 1px;
+    transform: translateY(-1px) rotate(35deg);
+}
+input[type=checkbox]:not(.toggle):checked::before {
+    border-color: white;
+}
+input[type=checkbox]:not(.toggle):indeterminate::after {
+    background-color: white;
+}
+
+/* ------- RADIO BUTTONS ------- */
+
+input[type=radio] {
+    border-radius: 50%;
+    border: 1px solid transparent; /* To ensure a smooth transition */
+}
+input[type=radio]:checked {
+    border: 4px solid var(--novnc-blue);
+    background-color: white;
+    /* button-activation-overlay should be removed from the radio
+       element to not interfere with button-activation-overlay-light
+       that is set on the ::before element. */
+    background-image: none;
+}
+input[type=radio]::before {
+    width: inherit;
+    height: inherit;
+    border-radius: inherit;
+    /* We can achieve the highlight overlay effect on border colors by
+       setting button-activation-overlay-light on an element that stays
+       on top (z-axis) of the element with a border. */
+    background-image: var(--button-activation-overlay-light);
+    mix-blend-mode: overlay;
+    opacity: 0;
+}
+input[type=radio]:checked::before {
+    opacity: 1;
+}
+input[type=radio]:indeterminate::after {
+    background-color: black;
+}
+
+/* ------- TOGGLE SWITCHES ------- */
+
+/* These are meant to be used instead of checkboxes in some cases. If all of
+   the following critera are true you should use a toggle switch:
+
+    * The choice is a simple ON/OFF or ENABLE/DISABLE
+    * The choice doesn't give the feeling of "I agree" or "I confirm"
+    * There are not multiple related & grouped options
+ */
+
+input[type=checkbox].toggle {
+    display: inline-block;
+    --checkradio-height: 18px; /* Height value used in calc, see above */
+    width: 31px;
+    cursor: pointer;
+    user-select: none;
+    -webkit-user-select: none;
+    border-radius: 9px;
+}
+input[type=checkbox].toggle:disabled {
+    cursor: default;
+}
+input[type=checkbox].toggle:indeterminate {
+    background-color: var(--novnc-buttongrey);
+    background-image: var(--button-activation-overlay);
+}
+input[type=checkbox].toggle:checked {
+    background-color: var(--novnc-blue);
+    background-image: var(--button-activation-overlay-light);
+    background-blend-mode: overlay;
+}
+input[type=checkbox].toggle::before {
+    --circle-diameter: 10px;
+    --circle-offset: 4px;
+    width: var(--circle-diameter);
+    height: var(--circle-diameter);
+    top: var(--circle-offset);
+    left: var(--circle-offset);
+    background: white;
+    border-radius: 6px;
+}
+input[type=checkbox].toggle:checked::before {
+    left: calc(100% - var(--circle-offset) - var(--circle-diameter));
+}
+input[type=checkbox].toggle:indeterminate::before {
+    left: calc(50% - var(--circle-diameter) / 2);
+}
+
+/* ------- RANGE SLIDERS ------- */
+
+input[type=range] {
+    border: unset;
+    border-radius: 8px;
+    height: 15px;
+    padding: 0;
+    background: transparent;
+    /* Needed to get properly rounded corners on -moz-range-progress
+       when the thumb is all the way to the right. Without overflow
+       hidden, the pointy edges of the progress track shows to the
+       right of the thumb. */
+    overflow: hidden;
+}
+@supports selector(::-webkit-slider-thumb) {
+    input[type=range] {
+        /* Needs a fixed width to match clip-path */
+        width: 125px;
+        /* overflow: hidden is not ideal for hiding the left part of the box
+           shadow of -webkit-slider-thumb since it doesn't match the smaller
+           border-radius of the progress track. The below clip-path has two
+           circular sides to make the ends of the track have correctly rounded
+           corners. The clip path shape looks something like this:
+
+                  +-------------------------------+
+              /---|                               |---\
+             |                                         |
+              \---|                               |---/
+                  +-------------------------------+
+
+           The larger middle part of the clip path is made to have room for the
+           thumb. By using margins on the track, we prevent the thumb from
+           touching the ends of the track.
+         */
+        clip-path: path(' \
+         M 4.5 3 \
+         L 4.5 0 \
+         L 120.5 0 \
+         L 120.5 3 \
+         A 1 1 0 0 1 120.5 12 \
+         L 120.5 15 \
+         L 4.5 15 \
+         L 4.5 12 \
+         A 1 1 0 0 1 4.5 3 \
+        ');
+    }
+}
+input[type=range]:hover {
+    cursor: grab;
+}
+input[type=range]:active {
+    cursor: grabbing;
+}
+input[type=range]:disabled {
+    cursor: default;
+}
+input[type=range]:focus-visible {
+    clip-path: none; /* Otherwise it hides the outline */
+}
+/* -webkit-slider.. & -moz-range.. cant be in selector lists:
+   https://bugs.chromium.org/p/chromium/issues/detail?id=1154623 */
+input[type=range]::-webkit-slider-runnable-track {
+    background-color: var(--novnc-buttongrey);
+    height: 7px;
+    border-radius: 4px;
+    margin: 0 3px;
+}
+input[type=range]::-moz-range-track {
+    background-color: var(--novnc-buttongrey);
+    height: 7px;
+    border-radius: 4px;
+}
+input[type=range]::-moz-range-progress {
+    background-color: var(--novnc-blue);
+    height: 9px;
+    /* Needs rounded corners only on the left side. Otherwise the rounding of
+       the progress track starts before the thumb, when the thumb is close to
+       the left edge. */
+    border-radius: 5px 0 0 5px;
+}
+input[type=range]::-webkit-slider-thumb {
+    appearance: none;
+    width: 15px;
+    height: 15px;
+    border-radius: 50%;
+    background-color: white;
+    background-image: var(--button-activation-overlay);
+    /* Disable Chrome's touch tap highlight to avoid conflicts with overlay */
+    -webkit-tap-highlight-color: transparent;
+    border: 3px solid var(--novnc-blue);
+    margin-top: -4px; /* (track height / 2) - (thumb height /2) */
+
+    /* Since there is no way to style the left part of the range track in
+       webkit, we add a large shadow (1000px wide) to the left of the thumb and
+       then crop it with a clip-path shaped like this:
+                              ___
+        +-------------------/     \
+        |      progress     |Thumb|
+        +-------------------\ ___ /
+
+        The large left part of the shadow is clipped by another clip-path on on
+        the main range input element. */
+    /* FIXME: We can remove the box shadow workaround when this is standardized:
+              https://github.com/w3c/csswg-drafts/issues/4410 */
+
+    box-shadow: calc(-100vw - 8px) 0 0 100vw var(--novnc-blue);
+    clip-path: path(' \
+     M -1000 3 \
+     L 3 3 \
+     L 15 7.5 \
+     A 1 1 0 0 1 0 7.5 \
+     A 1 1 0 0 1 15 7.5 \
+     L 3 12 \
+     L -1000 12 Z \
+    ');
+}
+input[type=range]::-moz-range-thumb {
+    appearance: none;
+    width: 15px;
+    height: 15px;
+    border-radius: 50%;
+    box-sizing: border-box;
+    background-color: white;
+    background-image: var(--button-activation-overlay);
+    border: 3px solid var(--novnc-blue);
+    margin-top: -7px;
+}
+
+/* ------- FILE CHOOSERS ------- */
+
+input[type=file] {
+    background-image: none;
+    border: none;
+}
+input::file-selector-button {
+    margin-right: 6px;
+}
+input[type=file]:focus-visible {
+    outline: none; /* We outline the button instead of the entire element */
+}
+
+/* ------- SELECT BUTTONS ------- */
+
+select {
+    --select-arrow: url('data:image/svg+xml;utf8, \
+        <svg width="11" height="6" version="1.1" viewBox="0 0 11 6" \
+             xmlns="http://www.w3.org/2000/svg"> \
+            <path d="m10.5.5-5 5-5-5" fill="none" \
+                  stroke="black" stroke-width="1.5" \
+                  stroke-linecap="round" stroke-linejoin="round"/> \
+        </svg>');
+
+    /* FIXME: A bug in Firefox, requires a workaround for the background:
+              https://bugzilla.mozilla.org/show_bug.cgi?id=1810958 */
+    /* The dropdown list will show the select element's background above and
+       below the options in Firefox. We want the entire dropdown to be white. */
+    background-color: white;
+    /* However, we don't want the select element to actually show a white
+       background, so let's place a gradient above it with the color we want. */
+    --grey-background: linear-gradient(var(--novnc-buttongrey) 100%,
+                                       transparent);
+    background-image:
+        var(--select-arrow),
+        var(--button-activation-overlay),
+        var(--grey-background);
+    background-position: calc(100% - var(--input-xpadding)), left top, left top;
+    background-repeat: no-repeat;
+    padding-right: calc(2*var(--input-xpadding) + 11px);
+    overflow: auto;
+}
+/* FIXME: :active isn't set when the <select> is opened in Firefox:
+          https://bugzilla.mozilla.org/show_bug.cgi?id=1805406 */
+select:active {
+    /* Rotated arrow */
+    background-image: url('data:image/svg+xml;utf8, \
+        <svg width="11" height="6" version="1.1" viewBox="0 0 11 6" \
+             xmlns="http://www.w3.org/2000/svg" transform="rotate(180)"> \
+            <path d="m10.5.5-5 5-5-5" fill="none" \
+                  stroke="black" stroke-width="1.5" \
+                  stroke-linecap="round" stroke-linejoin="round"/> \
+        </svg>'),
+        var(--button-activation-overlay),
+        var(--grey-background);
+}
+select:disabled {
+    background-image:
+        var(--select-arrow),
+        var(--grey-background);
+}
+/* Note that styling for <option> doesn't work in all browsers
+   since its often drawn directly by the OS. We are generally very
+   limited in what we can change here. */
+option {
+    /* Prevent Chrome from inheriting background-color from the <select> */
+    background-color: white;
+    color: black;
+    font-weight: normal;
+    background-image: var(--button-activation-overlay);
+}
+option:checked {
+    background-color: var(--novnc-lightgrey);
+}
+/* Change the look when the <select> isn't used as a dropdown. When "size"
+   or "multiple" are set, these elements behaves more like lists. */
+select[size]:not([size="1"]), select[multiple] {
+    background-color: white;
+    background-image: unset; /* Don't show the arrow and other gradients */
+    border: 1px solid var(--novnc-lightgrey);
+    padding: 0;
+    font-weight: normal; /* Without this, options get bold font in WebKit. */
+
+    /* As an exception to the "list"-look, multi-selects in Chrome on Android,
+       and Safari on iOS, are unfortunately designed to be shown as a single
+       line. We can mitigate this inconsistency by at least fixing the height
+       here. By setting a min-height that matches other input elements, it
+       doesn't look too much out of place:
+         (1px border * 2) + (6.5px padding * 2) + 24px line-height = 39px */
+    min-height: 39px;
+}
+select[size]:not([size="1"]):focus-visible,
+select[multiple]:focus-visible {
+    /* Text input style focus-visible highlight */
+    outline-offset: -1px;
+}
+select[size]:not([size="1"]) option, select[multiple] option {
+    overflow: hidden;
+    text-overflow: ellipsis;
+    padding: 4px var(--input-xpadding);
+}
pkg/web/noVNC/app/styles/Orbitron700.ttf
Binary file
pkg/web/noVNC/app/styles/Orbitron700.woff
Binary file
pkg/web/noVNC/app/error-handler.js
@@ -0,0 +1,79 @@
+/*
+ * noVNC: HTML5 VNC client
+ * Copyright (C) 2019 The noVNC authors
+ * Licensed under MPL 2.0 (see LICENSE.txt)
+ *
+ * See README.md for usage and integration instructions.
+ */
+
+// Fallback for all uncaught errors
+function handleError(event, err) {
+    try {
+        const msg = document.getElementById('noVNC_fallback_errormsg');
+
+        // Work around Firefox bug:
+        // https://bugzilla.mozilla.org/show_bug.cgi?id=1685038
+        if (event.message === "ResizeObserver loop completed with undelivered notifications.") {
+            return false;
+        }
+
+        // Only show the initial error
+        if (msg.hasChildNodes()) {
+            return false;
+        }
+
+        let div = document.createElement("div");
+        div.classList.add('noVNC_message');
+        div.appendChild(document.createTextNode(event.message));
+        msg.appendChild(div);
+
+        if (event.filename) {
+            div = document.createElement("div");
+            div.className = 'noVNC_location';
+            let text = event.filename;
+            if (event.lineno !== undefined) {
+                text += ":" + event.lineno;
+                if (event.colno !== undefined) {
+                    text += ":" + event.colno;
+                }
+            }
+            div.appendChild(document.createTextNode(text));
+            msg.appendChild(div);
+        }
+
+        if (err && err.stack) {
+            div = document.createElement("div");
+            div.className = 'noVNC_stack';
+            div.appendChild(document.createTextNode(err.stack));
+            msg.appendChild(div);
+        }
+
+        document.getElementById('noVNC_fallback_error')
+            .classList.add("noVNC_open");
+
+    } catch (exc) {
+        document.write("noVNC encountered an error.");
+    }
+
+    // Try to disable keyboard interaction, best effort
+    try {
+        // Remove focus from the currently focused element in order to
+        // prevent keyboard interaction from continuing
+        if (document.activeElement) { document.activeElement.blur(); }
+
+        // Don't let any element be focusable when showing the error
+        let keyboardFocusable = 'a[href], button, input, textarea, select, details, [tabindex]';
+        document.querySelectorAll(keyboardFocusable).forEach((elem) => {
+            elem.setAttribute("tabindex", "-1");
+        });
+    } catch (exc) {
+        // Do nothing
+    }
+
+    // Don't return true since this would prevent the error
+    // from being printed to the browser console.
+    return false;
+}
+
+window.addEventListener('error', evt => handleError(evt, evt.error));
+window.addEventListener('unhandledrejection', evt => handleError(evt.reason, evt.reason));
pkg/web/noVNC/app/localization.js
@@ -0,0 +1,206 @@
+/*
+ * noVNC: HTML5 VNC client
+ * Copyright (C) 2018 The noVNC authors
+ * Licensed under MPL 2.0 (see LICENSE.txt)
+ *
+ * See README.md for usage and integration instructions.
+ */
+
+/*
+ * Localization utilities
+ */
+
+export class Localizer {
+    constructor() {
+        // Currently configured language
+        this.language = 'en';
+
+        // Current dictionary of translations
+        this._dictionary = undefined;
+    }
+
+    // Configure suitable language based on user preferences
+    async setup(supportedLanguages, baseURL) {
+        this.language = 'en'; // Default: US English
+        this._dictionary = undefined;
+
+        this._setupLanguage(supportedLanguages);
+        await this._setupDictionary(baseURL);
+    }
+
+    _setupLanguage(supportedLanguages) {
+        /*
+         * Navigator.languages only available in Chrome (32+) and FireFox (32+)
+         * Fall back to navigator.language for other browsers
+         */
+        let userLanguages;
+        if (typeof window.navigator.languages == 'object') {
+            userLanguages = window.navigator.languages;
+        } else {
+            userLanguages = [navigator.language || navigator.userLanguage];
+        }
+
+        for (let i = 0;i < userLanguages.length;i++) {
+            const userLang = userLanguages[i]
+                .toLowerCase()
+                .replace("_", "-")
+                .split("-");
+
+            // First pass: perfect match
+            for (let j = 0; j < supportedLanguages.length; j++) {
+                const supLang = supportedLanguages[j]
+                    .toLowerCase()
+                    .replace("_", "-")
+                    .split("-");
+
+                if (userLang[0] !== supLang[0]) {
+                    continue;
+                }
+                if (userLang[1] !== supLang[1]) {
+                    continue;
+                }
+
+                this.language = supportedLanguages[j];
+                return;
+            }
+
+            // Second pass: English fallback
+            if (userLang[0] === 'en') {
+                return;
+            }
+
+            // Third pass pass: other fallback
+            for (let j = 0;j < supportedLanguages.length;j++) {
+                const supLang = supportedLanguages[j]
+                    .toLowerCase()
+                    .replace("_", "-")
+                    .split("-");
+
+                if (userLang[0] !== supLang[0]) {
+                    continue;
+                }
+                if (supLang[1] !== undefined) {
+                    continue;
+                }
+
+                this.language = supportedLanguages[j];
+                return;
+            }
+        }
+    }
+
+    async _setupDictionary(baseURL) {
+        if (baseURL) {
+            if (!baseURL.endsWith("/")) {
+                baseURL = baseURL + "/";
+            }
+        } else {
+            baseURL = "";
+        }
+
+        if (this.language === "en") {
+            return;
+        }
+
+        let response = await fetch(baseURL + this.language + ".json");
+        if (!response.ok) {
+            throw Error("" + response.status + " " + response.statusText);
+        }
+
+        this._dictionary = await response.json();
+    }
+
+    // Retrieve localised text
+    get(id) {
+        if (typeof this._dictionary !== 'undefined' &&
+            this._dictionary[id]) {
+            return this._dictionary[id];
+        } else {
+            return id;
+        }
+    }
+
+    // Traverses the DOM and translates relevant fields
+    // See https://html.spec.whatwg.org/multipage/dom.html#attr-translate
+    translateDOM() {
+        const self = this;
+
+        function process(elem, enabled) {
+            function isAnyOf(searchElement, items) {
+                return items.indexOf(searchElement) !== -1;
+            }
+
+            function translateString(str) {
+                // We assume surrounding whitespace, and whitespace around line
+                // breaks is just for source formatting
+                str = str.split("\n").map(s => s.trim()).join(" ").trim();
+                return self.get(str);
+            }
+
+            function translateAttribute(elem, attr) {
+                const str = translateString(elem.getAttribute(attr));
+                elem.setAttribute(attr, str);
+            }
+
+            function translateTextNode(node) {
+                const str = translateString(node.data);
+                node.data = str;
+            }
+
+            if (elem.hasAttribute("translate")) {
+                if (isAnyOf(elem.getAttribute("translate"), ["", "yes"])) {
+                    enabled = true;
+                } else if (isAnyOf(elem.getAttribute("translate"), ["no"])) {
+                    enabled = false;
+                }
+            }
+
+            if (enabled) {
+                if (elem.hasAttribute("abbr") &&
+                    elem.tagName === "TH") {
+                    translateAttribute(elem, "abbr");
+                }
+                if (elem.hasAttribute("alt") &&
+                    isAnyOf(elem.tagName, ["AREA", "IMG", "INPUT"])) {
+                    translateAttribute(elem, "alt");
+                }
+                if (elem.hasAttribute("download") &&
+                    isAnyOf(elem.tagName, ["A", "AREA"])) {
+                    translateAttribute(elem, "download");
+                }
+                if (elem.hasAttribute("label") &&
+                    isAnyOf(elem.tagName, ["MENUITEM", "MENU", "OPTGROUP",
+                                           "OPTION", "TRACK"])) {
+                    translateAttribute(elem, "label");
+                }
+                // FIXME: Should update "lang"
+                if (elem.hasAttribute("placeholder") &&
+                    isAnyOf(elem.tagName, ["INPUT", "TEXTAREA"])) {
+                    translateAttribute(elem, "placeholder");
+                }
+                if (elem.hasAttribute("title")) {
+                    translateAttribute(elem, "title");
+                }
+                if (elem.hasAttribute("value") &&
+                    elem.tagName === "INPUT" &&
+                    isAnyOf(elem.getAttribute("type"), ["reset", "button", "submit"])) {
+                    translateAttribute(elem, "value");
+                }
+            }
+
+            for (let i = 0; i < elem.childNodes.length; i++) {
+                const node = elem.childNodes[i];
+                if (node.nodeType === node.ELEMENT_NODE) {
+                    process(node, enabled);
+                } else if (node.nodeType === node.TEXT_NODE && enabled) {
+                    translateTextNode(node);
+                }
+            }
+        }
+
+        process(document.body, true);
+    }
+}
+
+export const l10n = new Localizer();
+export default l10n.get.bind(l10n);
pkg/web/noVNC/app/ui.js
@@ -0,0 +1,1875 @@
+/*
+ * noVNC: HTML5 VNC client
+ * Copyright (C) 2019 The noVNC authors
+ * Licensed under MPL 2.0 (see LICENSE.txt)
+ *
+ * See README.md for usage and integration instructions.
+ */
+
+import * as Log from '../core/util/logging.js';
+import _, { l10n } from './localization.js';
+import { isTouchDevice, isMac, isIOS, isAndroid, isChromeOS, isSafari,
+         hasScrollbarGutter, dragThreshold, browserAsyncClipboardSupport }
+    from '../core/util/browser.js';
+import { setCapture, getPointerEvent } from '../core/util/events.js';
+import KeyTable from "../core/input/keysym.js";
+import keysyms from "../core/input/keysymdef.js";
+import Keyboard from "../core/input/keyboard.js";
+import RFB from "../core/rfb.js";
+import WakeLockManager from './wakelock.js';
+import * as WebUtil from "./webutil.js";
+
+const PAGE_TITLE = "noVNC";
+
+const LINGUAS = ["cs", "de", "el", "es", "fr", "hr", "hu", "it", "ja", "ko", "nl", "pl", "pt_BR", "ru", "sv", "tr", "uk", "zh_CN", "zh_TW"];
+
+const UI = {
+
+    customSettings: {},
+
+    connected: false,
+    desktopName: "",
+
+    statusTimeout: null,
+    hideKeyboardTimeout: null,
+    idleControlbarTimeout: null,
+    closeControlbarTimeout: null,
+
+    controlbarGrabbed: false,
+    controlbarDrag: false,
+    controlbarMouseDownClientY: 0,
+    controlbarMouseDownOffsetY: 0,
+
+    lastKeyboardinput: null,
+    defaultKeyboardinputLen: 100,
+
+    inhibitReconnect: true,
+    reconnectCallback: null,
+    reconnectPassword: null,
+
+    wakeLockManager: new WakeLockManager(),
+
+    async start(options={}) {
+        UI.customSettings = options.settings || {};
+        if (UI.customSettings.defaults === undefined) {
+            UI.customSettings.defaults = {};
+        }
+        if (UI.customSettings.mandatory === undefined) {
+            UI.customSettings.mandatory = {};
+        }
+
+        // Set up translations
+        try {
+            await l10n.setup(LINGUAS, "app/locale/");
+        } catch (err) {
+            Log.Error("Failed to load translations: " + err);
+        }
+
+        // Initialize setting storage
+        await WebUtil.initSettings();
+
+        // Wait for the page to load
+        if (document.readyState !== "interactive" && document.readyState !== "complete") {
+            await new Promise((resolve, reject) => {
+                document.addEventListener('DOMContentLoaded', resolve);
+            });
+        }
+
+        UI.initSettings();
+
+        // Translate the DOM
+        l10n.translateDOM();
+
+        // We rely on modern APIs which might not be available in an
+        // insecure context
+        if (!window.isSecureContext) {
+            // FIXME: This gets hidden when connecting
+            UI.showStatus(_("Running without HTTPS is not recommended, crashes or other issues are likely."), 'error');
+        }
+
+        // Try to fetch version number
+        try {
+            let response = await fetch('./package.json');
+            if (!response.ok) {
+                throw Error("" + response.status + " " + response.statusText);
+            }
+
+            let packageInfo = await response.json();
+            Array.from(document.getElementsByClassName('noVNC_version')).forEach(el => el.innerText = packageInfo.version);
+        } catch (err) {
+            Log.Error("Couldn't fetch package.json: " + err);
+            Array.from(document.getElementsByClassName('noVNC_version_wrapper'))
+                .concat(Array.from(document.getElementsByClassName('noVNC_version_separator')))
+                .forEach(el => el.style.display = 'none');
+        }
+
+        // Adapt the interface for touch screen devices
+        if (isTouchDevice) {
+            // Remove the address bar
+            setTimeout(() => window.scrollTo(0, 1), 100);
+        }
+
+        // Restore control bar position
+        if (WebUtil.readSetting('controlbar_pos') === 'right') {
+            UI.toggleControlbarSide();
+        }
+
+        UI.initFullscreen();
+
+        // Setup event handlers
+        UI.addControlbarHandlers();
+        UI.addTouchSpecificHandlers();
+        UI.addExtraKeysHandlers();
+        UI.addMachineHandlers();
+        UI.addConnectionControlHandlers();
+        UI.addClipboardHandlers();
+        UI.addSettingsHandlers();
+        document.getElementById("noVNC_status")
+            .addEventListener('click', UI.hideStatus);
+
+        // Bootstrap fallback input handler
+        UI.keyboardinputReset();
+
+        UI.openControlbar();
+
+        UI.updateVisualState('init');
+
+        document.documentElement.classList.remove("noVNC_loading");
+
+        let autoconnect = UI.getSetting('autoconnect');
+        if (autoconnect === 'true' || autoconnect == '1') {
+            autoconnect = true;
+            UI.connect();
+        } else {
+            autoconnect = false;
+            // Show the connect panel on first load unless autoconnecting
+            UI.openConnectPanel();
+        }
+    },
+
+    initFullscreen() {
+        // Only show the button if fullscreen is properly supported
+        // * Safari doesn't support alphanumerical input while in fullscreen
+        if (!isSafari() &&
+            (document.documentElement.requestFullscreen ||
+             document.documentElement.mozRequestFullScreen ||
+             document.documentElement.webkitRequestFullscreen ||
+             document.body.msRequestFullscreen)) {
+            document.getElementById('noVNC_fullscreen_button')
+                .classList.remove("noVNC_hidden");
+            UI.addFullscreenHandlers();
+        }
+    },
+
+    initSettings() {
+        // Logging selection dropdown
+        const llevels = ['error', 'warn', 'info', 'debug'];
+        for (let i = 0; i < llevels.length; i += 1) {
+            UI.addOption(document.getElementById('noVNC_setting_logging'), llevels[i], llevels[i]);
+        }
+
+        // Settings with immediate effects
+        UI.initSetting('logging', 'warn');
+        UI.updateLogging();
+
+        UI.setupSettingLabels();
+
+        /* Populate the controls if defaults are provided in the URL */
+        UI.initSetting('host', '');
+        UI.initSetting('port', 0);
+        UI.initSetting('encrypt', (window.location.protocol === "https:"));
+        UI.initSetting('password');
+        UI.initSetting('autoconnect', false);
+        UI.initSetting('view_clip', false);
+        UI.initSetting('resize', 'off');
+        UI.initSetting('quality', 6);
+        UI.initSetting('compression', 2);
+        UI.initSetting('shared', true);
+        UI.initSetting('bell', 'on');
+        UI.initSetting('view_only', false);
+        UI.initSetting('show_dot', false);
+        UI.initSetting('path', 'websockify');
+        UI.initSetting('repeaterID', '');
+        UI.initSetting('reconnect', false);
+        UI.initSetting('reconnect_delay', 5000);
+        UI.initSetting('keep_device_awake', false);
+    },
+    // Adds a link to the label elements on the corresponding input elements
+    setupSettingLabels() {
+        const labels = document.getElementsByTagName('LABEL');
+        for (let i = 0; i < labels.length; i++) {
+            const htmlFor = labels[i].htmlFor;
+            if (htmlFor != '') {
+                const elem = document.getElementById(htmlFor);
+                if (elem) elem.label = labels[i];
+            } else {
+                // If 'for' isn't set, use the first input element child
+                const children = labels[i].children;
+                for (let j = 0; j < children.length; j++) {
+                    if (children[j].form !== undefined) {
+                        children[j].label = labels[i];
+                        break;
+                    }
+                }
+            }
+        }
+    },
+
+/* ------^-------
+*     /INIT
+* ==============
+* EVENT HANDLERS
+* ------v------*/
+
+    addControlbarHandlers() {
+        document.getElementById("noVNC_control_bar")
+            .addEventListener('mousemove', UI.activateControlbar);
+        document.getElementById("noVNC_control_bar")
+            .addEventListener('mouseup', UI.activateControlbar);
+        document.getElementById("noVNC_control_bar")
+            .addEventListener('mousedown', UI.activateControlbar);
+        document.getElementById("noVNC_control_bar")
+            .addEventListener('keydown', UI.activateControlbar);
+
+        document.getElementById("noVNC_control_bar")
+            .addEventListener('mousedown', UI.keepControlbar);
+        document.getElementById("noVNC_control_bar")
+            .addEventListener('keydown', UI.keepControlbar);
+
+        document.getElementById("noVNC_view_drag_button")
+            .addEventListener('click', UI.toggleViewDrag);
+
+        document.getElementById("noVNC_control_bar_handle")
+            .addEventListener('mousedown', UI.controlbarHandleMouseDown);
+        document.getElementById("noVNC_control_bar_handle")
+            .addEventListener('mouseup', UI.controlbarHandleMouseUp);
+        document.getElementById("noVNC_control_bar_handle")
+            .addEventListener('mousemove', UI.dragControlbarHandle);
+        // resize events aren't available for elements
+        window.addEventListener('resize', UI.updateControlbarHandle);
+
+        const exps = document.getElementsByClassName("noVNC_expander");
+        for (let i = 0;i < exps.length;i++) {
+            exps[i].addEventListener('click', UI.toggleExpander);
+        }
+    },
+
+    addTouchSpecificHandlers() {
+        document.getElementById("noVNC_keyboard_button")
+            .addEventListener('click', UI.toggleVirtualKeyboard);
+
+        UI.touchKeyboard = new Keyboard(document.getElementById('noVNC_keyboardinput'));
+        UI.touchKeyboard.onkeyevent = UI.keyEvent;
+        UI.touchKeyboard.grab();
+        document.getElementById("noVNC_keyboardinput")
+            .addEventListener('input', UI.keyInput);
+        document.getElementById("noVNC_keyboardinput")
+            .addEventListener('focus', UI.onfocusVirtualKeyboard);
+        document.getElementById("noVNC_keyboardinput")
+            .addEventListener('blur', UI.onblurVirtualKeyboard);
+        document.getElementById("noVNC_keyboardinput")
+            .addEventListener('submit', () => false);
+
+        document.documentElement
+            .addEventListener('mousedown', UI.keepVirtualKeyboard, true);
+
+        document.getElementById("noVNC_control_bar")
+            .addEventListener('touchstart', UI.activateControlbar);
+        document.getElementById("noVNC_control_bar")
+            .addEventListener('touchmove', UI.activateControlbar);
+        document.getElementById("noVNC_control_bar")
+            .addEventListener('touchend', UI.activateControlbar);
+        document.getElementById("noVNC_control_bar")
+            .addEventListener('input', UI.activateControlbar);
+
+        document.getElementById("noVNC_control_bar")
+            .addEventListener('touchstart', UI.keepControlbar);
+        document.getElementById("noVNC_control_bar")
+            .addEventListener('input', UI.keepControlbar);
+
+        document.getElementById("noVNC_control_bar_handle")
+            .addEventListener('touchstart', UI.controlbarHandleMouseDown);
+        document.getElementById("noVNC_control_bar_handle")
+            .addEventListener('touchend', UI.controlbarHandleMouseUp);
+        document.getElementById("noVNC_control_bar_handle")
+            .addEventListener('touchmove', UI.dragControlbarHandle);
+    },
+
+    addExtraKeysHandlers() {
+        document.getElementById("noVNC_toggle_extra_keys_button")
+            .addEventListener('click', UI.toggleExtraKeys);
+        document.getElementById("noVNC_toggle_ctrl_button")
+            .addEventListener('click', UI.toggleCtrl);
+        document.getElementById("noVNC_toggle_windows_button")
+            .addEventListener('click', UI.toggleWindows);
+        document.getElementById("noVNC_toggle_alt_button")
+            .addEventListener('click', UI.toggleAlt);
+        document.getElementById("noVNC_send_tab_button")
+            .addEventListener('click', UI.sendTab);
+        document.getElementById("noVNC_send_esc_button")
+            .addEventListener('click', UI.sendEsc);
+        document.getElementById("noVNC_send_ctrl_alt_del_button")
+            .addEventListener('click', UI.sendCtrlAltDel);
+    },
+
+    addMachineHandlers() {
+        document.getElementById("noVNC_shutdown_button")
+            .addEventListener('click', () => UI.rfb.machineShutdown());
+        document.getElementById("noVNC_reboot_button")
+            .addEventListener('click', () => UI.rfb.machineReboot());
+        document.getElementById("noVNC_reset_button")
+            .addEventListener('click', () => UI.rfb.machineReset());
+        document.getElementById("noVNC_power_button")
+            .addEventListener('click', UI.togglePowerPanel);
+    },
+
+    addConnectionControlHandlers() {
+        document.getElementById("noVNC_disconnect_button")
+            .addEventListener('click', UI.disconnect);
+        document.getElementById("noVNC_connect_button")
+            .addEventListener('click', UI.connect);
+        document.getElementById("noVNC_cancel_reconnect_button")
+            .addEventListener('click', UI.cancelReconnect);
+
+        document.getElementById("noVNC_approve_server_button")
+            .addEventListener('click', UI.approveServer);
+        document.getElementById("noVNC_reject_server_button")
+            .addEventListener('click', UI.rejectServer);
+        document.getElementById("noVNC_credentials_button")
+            .addEventListener('click', UI.setCredentials);
+    },
+
+    addClipboardHandlers() {
+        document.getElementById("noVNC_clipboard_button")
+            .addEventListener('click', UI.toggleClipboardPanel);
+        document.getElementById("noVNC_clipboard_text")
+            .addEventListener('change', UI.clipboardSend);
+    },
+
+    // Add a call to save settings when the element changes,
+    // unless the optional parameter changeFunc is used instead.
+    addSettingChangeHandler(name, changeFunc) {
+        const settingElem = document.getElementById("noVNC_setting_" + name);
+        if (changeFunc === undefined) {
+            changeFunc = () => UI.saveSetting(name);
+        }
+        settingElem.addEventListener('change', changeFunc);
+    },
+
+    addSettingsHandlers() {
+        document.getElementById("noVNC_settings_button")
+            .addEventListener('click', UI.toggleSettingsPanel);
+
+        UI.addSettingChangeHandler('encrypt');
+        UI.addSettingChangeHandler('resize');
+        UI.addSettingChangeHandler('resize', UI.applyResizeMode);
+        UI.addSettingChangeHandler('resize', UI.updateViewClip);
+        UI.addSettingChangeHandler('quality');
+        UI.addSettingChangeHandler('quality', UI.updateQuality);
+        UI.addSettingChangeHandler('compression');
+        UI.addSettingChangeHandler('compression', UI.updateCompression);
+        UI.addSettingChangeHandler('view_clip');
+        UI.addSettingChangeHandler('view_clip', UI.updateViewClip);
+        UI.addSettingChangeHandler('shared');
+        UI.addSettingChangeHandler('view_only');
+        UI.addSettingChangeHandler('view_only', UI.updateViewOnly);
+        UI.addSettingChangeHandler('show_dot');
+        UI.addSettingChangeHandler('show_dot', UI.updateShowDotCursor);
+        UI.addSettingChangeHandler('keep_device_awake');
+        UI.addSettingChangeHandler('keep_device_awake', UI.updateRequestWakelock);
+        UI.addSettingChangeHandler('host');
+        UI.addSettingChangeHandler('port');
+        UI.addSettingChangeHandler('path');
+        UI.addSettingChangeHandler('repeaterID');
+        UI.addSettingChangeHandler('logging');
+        UI.addSettingChangeHandler('logging', UI.updateLogging);
+        UI.addSettingChangeHandler('reconnect');
+        UI.addSettingChangeHandler('reconnect_delay');
+    },
+
+    addFullscreenHandlers() {
+        document.getElementById("noVNC_fullscreen_button")
+            .addEventListener('click', UI.toggleFullscreen);
+
+        window.addEventListener('fullscreenchange', UI.updateFullscreenButton);
+        window.addEventListener('mozfullscreenchange', UI.updateFullscreenButton);
+        window.addEventListener('webkitfullscreenchange', UI.updateFullscreenButton);
+        window.addEventListener('msfullscreenchange', UI.updateFullscreenButton);
+    },
+
+/* ------^-------
+ * /EVENT HANDLERS
+ * ==============
+ *     VISUAL
+ * ------v------*/
+
+    // Disable/enable controls depending on connection state
+    updateVisualState(state) {
+
+        document.documentElement.classList.remove("noVNC_connecting");
+        document.documentElement.classList.remove("noVNC_connected");
+        document.documentElement.classList.remove("noVNC_disconnecting");
+        document.documentElement.classList.remove("noVNC_reconnecting");
+
+        const transitionElem = document.getElementById("noVNC_transition_text");
+        switch (state) {
+            case 'init':
+                break;
+            case 'connecting':
+                transitionElem.textContent = _("Connecting...");
+                document.documentElement.classList.add("noVNC_connecting");
+                break;
+            case 'connected':
+                document.documentElement.classList.add("noVNC_connected");
+                break;
+            case 'disconnecting':
+                transitionElem.textContent = _("Disconnecting...");
+                document.documentElement.classList.add("noVNC_disconnecting");
+                break;
+            case 'disconnected':
+                break;
+            case 'reconnecting':
+                transitionElem.textContent = _("Reconnecting...");
+                document.documentElement.classList.add("noVNC_reconnecting");
+                break;
+            default:
+                Log.Error("Invalid visual state: " + state);
+                UI.showStatus(_("Internal error"), 'error');
+                return;
+        }
+
+        if (UI.connected) {
+            UI.updateViewClip();
+
+            UI.disableSetting('encrypt');
+            UI.disableSetting('shared');
+            UI.disableSetting('host');
+            UI.disableSetting('port');
+            UI.disableSetting('path');
+            UI.disableSetting('repeaterID');
+
+            // Hide the controlbar after 2 seconds
+            UI.closeControlbarTimeout = setTimeout(UI.closeControlbar, 2000);
+        } else {
+            UI.enableSetting('encrypt');
+            UI.enableSetting('shared');
+            UI.enableSetting('host');
+            UI.enableSetting('port');
+            UI.enableSetting('path');
+            UI.enableSetting('repeaterID');
+            UI.updatePowerButton();
+            UI.keepControlbar();
+        }
+
+        // State change closes dialogs as they may not be relevant
+        // anymore
+        UI.closeAllPanels();
+        document.getElementById('noVNC_verify_server_dlg')
+            .classList.remove('noVNC_open');
+        document.getElementById('noVNC_credentials_dlg')
+            .classList.remove('noVNC_open');
+    },
+
+    showStatus(text, statusType, time) {
+        const statusElem = document.getElementById('noVNC_status');
+
+        if (typeof statusType === 'undefined') {
+            statusType = 'normal';
+        }
+
+        // Don't overwrite more severe visible statuses and never
+        // errors. Only shows the first error.
+        if (statusElem.classList.contains("noVNC_open")) {
+            if (statusElem.classList.contains("noVNC_status_error")) {
+                return;
+            }
+            if (statusElem.classList.contains("noVNC_status_warn") &&
+                statusType === 'normal') {
+                return;
+            }
+        }
+
+        clearTimeout(UI.statusTimeout);
+
+        switch (statusType) {
+            case 'error':
+                statusElem.classList.remove("noVNC_status_warn");
+                statusElem.classList.remove("noVNC_status_normal");
+                statusElem.classList.add("noVNC_status_error");
+                break;
+            case 'warning':
+            case 'warn':
+                statusElem.classList.remove("noVNC_status_error");
+                statusElem.classList.remove("noVNC_status_normal");
+                statusElem.classList.add("noVNC_status_warn");
+                break;
+            case 'normal':
+            case 'info':
+            default:
+                statusElem.classList.remove("noVNC_status_error");
+                statusElem.classList.remove("noVNC_status_warn");
+                statusElem.classList.add("noVNC_status_normal");
+                break;
+        }
+
+        statusElem.textContent = text;
+        statusElem.classList.add("noVNC_open");
+
+        // If no time was specified, show the status for 1.5 seconds
+        if (typeof time === 'undefined') {
+            time = 1500;
+        }
+
+        // Error messages do not timeout
+        if (statusType !== 'error') {
+            UI.statusTimeout = window.setTimeout(UI.hideStatus, time);
+        }
+    },
+
+    hideStatus() {
+        clearTimeout(UI.statusTimeout);
+        document.getElementById('noVNC_status').classList.remove("noVNC_open");
+    },
+
+    activateControlbar(event) {
+        clearTimeout(UI.idleControlbarTimeout);
+        // We manipulate the anchor instead of the actual control
+        // bar in order to avoid creating new a stacking group
+        document.getElementById('noVNC_control_bar_anchor')
+            .classList.remove("noVNC_idle");
+        UI.idleControlbarTimeout = window.setTimeout(UI.idleControlbar, 2000);
+    },
+
+    idleControlbar() {
+        // Don't fade if a child of the control bar has focus
+        if (document.getElementById('noVNC_control_bar')
+            .contains(document.activeElement) && document.hasFocus()) {
+            UI.activateControlbar();
+            return;
+        }
+
+        document.getElementById('noVNC_control_bar_anchor')
+            .classList.add("noVNC_idle");
+    },
+
+    keepControlbar() {
+        clearTimeout(UI.closeControlbarTimeout);
+    },
+
+    openControlbar() {
+        document.getElementById('noVNC_control_bar')
+            .classList.add("noVNC_open");
+    },
+
+    closeControlbar() {
+        UI.closeAllPanels();
+        document.getElementById('noVNC_control_bar')
+            .classList.remove("noVNC_open");
+        UI.rfb.focus();
+    },
+
+    toggleControlbar() {
+        if (document.getElementById('noVNC_control_bar')
+            .classList.contains("noVNC_open")) {
+            UI.closeControlbar();
+        } else {
+            UI.openControlbar();
+        }
+    },
+
+    toggleControlbarSide() {
+        // Temporarily disable animation, if bar is displayed, to avoid weird
+        // movement. The transitionend-event will not fire when display=none.
+        const bar = document.getElementById('noVNC_control_bar');
+        const barDisplayStyle = window.getComputedStyle(bar).display;
+        if (barDisplayStyle !== 'none') {
+            bar.style.transitionDuration = '0s';
+            bar.addEventListener('transitionend', () => bar.style.transitionDuration = '');
+        }
+
+        const anchor = document.getElementById('noVNC_control_bar_anchor');
+        if (anchor.classList.contains("noVNC_right")) {
+            WebUtil.writeSetting('controlbar_pos', 'left');
+            anchor.classList.remove("noVNC_right");
+        } else {
+            WebUtil.writeSetting('controlbar_pos', 'right');
+            anchor.classList.add("noVNC_right");
+        }
+
+        // Consider this a movement of the handle
+        UI.controlbarDrag = true;
+
+        // The user has "followed" hint, let's hide it until the next drag
+        UI.showControlbarHint(false, false);
+    },
+
+    showControlbarHint(show, animate=true) {
+        const hint = document.getElementById('noVNC_control_bar_hint');
+
+        if (animate) {
+            hint.classList.remove("noVNC_notransition");
+        } else {
+            hint.classList.add("noVNC_notransition");
+        }
+
+        if (show) {
+            hint.classList.add("noVNC_active");
+        } else {
+            hint.classList.remove("noVNC_active");
+        }
+    },
+
+    dragControlbarHandle(e) {
+        if (!UI.controlbarGrabbed) return;
+
+        const ptr = getPointerEvent(e);
+
+        const anchor = document.getElementById('noVNC_control_bar_anchor');
+        if (ptr.clientX < (window.innerWidth * 0.1)) {
+            if (anchor.classList.contains("noVNC_right")) {
+                UI.toggleControlbarSide();
+            }
+        } else if (ptr.clientX > (window.innerWidth * 0.9)) {
+            if (!anchor.classList.contains("noVNC_right")) {
+                UI.toggleControlbarSide();
+            }
+        }
+
+        if (!UI.controlbarDrag) {
+            const dragDistance = Math.abs(ptr.clientY - UI.controlbarMouseDownClientY);
+
+            if (dragDistance < dragThreshold) return;
+
+            UI.controlbarDrag = true;
+        }
+
+        const eventY = ptr.clientY - UI.controlbarMouseDownOffsetY;
+
+        UI.moveControlbarHandle(eventY);
+
+        e.preventDefault();
+        e.stopPropagation();
+        UI.keepControlbar();
+        UI.activateControlbar();
+    },
+
+    // Move the handle but don't allow any position outside the bounds
+    moveControlbarHandle(viewportRelativeY) {
+        const handle = document.getElementById("noVNC_control_bar_handle");
+        const handleHeight = handle.getBoundingClientRect().height;
+        const controlbarBounds = document.getElementById("noVNC_control_bar")
+            .getBoundingClientRect();
+        const margin = 10;
+
+        // These heights need to be non-zero for the below logic to work
+        if (handleHeight === 0 || controlbarBounds.height === 0) {
+            return;
+        }
+
+        let newY = viewportRelativeY;
+
+        // Check if the coordinates are outside the control bar
+        if (newY < controlbarBounds.top + margin) {
+            // Force coordinates to be below the top of the control bar
+            newY = controlbarBounds.top + margin;
+
+        } else if (newY > controlbarBounds.top +
+                   controlbarBounds.height - handleHeight - margin) {
+            // Force coordinates to be above the bottom of the control bar
+            newY = controlbarBounds.top +
+                controlbarBounds.height - handleHeight - margin;
+        }
+
+        // Corner case: control bar too small for stable position
+        if (controlbarBounds.height < (handleHeight + margin * 2)) {
+            newY = controlbarBounds.top +
+                (controlbarBounds.height - handleHeight) / 2;
+        }
+
+        // The transform needs coordinates that are relative to the parent
+        const parentRelativeY = newY - controlbarBounds.top;
+        handle.style.transform = "translateY(" + parentRelativeY + "px)";
+    },
+
+    updateControlbarHandle() {
+        // Since the control bar is fixed on the viewport and not the page,
+        // the move function expects coordinates relative the the viewport.
+        const handle = document.getElementById("noVNC_control_bar_handle");
+        const handleBounds = handle.getBoundingClientRect();
+        UI.moveControlbarHandle(handleBounds.top);
+    },
+
+    controlbarHandleMouseUp(e) {
+        if ((e.type == "mouseup") && (e.button != 0)) return;
+
+        // mouseup and mousedown on the same place toggles the controlbar
+        if (UI.controlbarGrabbed && !UI.controlbarDrag) {
+            UI.toggleControlbar();
+            e.preventDefault();
+            e.stopPropagation();
+            UI.keepControlbar();
+            UI.activateControlbar();
+        }
+        UI.controlbarGrabbed = false;
+        UI.showControlbarHint(false);
+    },
+
+    controlbarHandleMouseDown(e) {
+        if ((e.type == "mousedown") && (e.button != 0)) return;
+
+        const ptr = getPointerEvent(e);
+
+        const handle = document.getElementById("noVNC_control_bar_handle");
+        const bounds = handle.getBoundingClientRect();
+
+        // Touch events have implicit capture
+        if (e.type === "mousedown") {
+            setCapture(handle);
+        }
+
+        UI.controlbarGrabbed = true;
+        UI.controlbarDrag = false;
+
+        UI.showControlbarHint(true);
+
+        UI.controlbarMouseDownClientY = ptr.clientY;
+        UI.controlbarMouseDownOffsetY = ptr.clientY - bounds.top;
+        e.preventDefault();
+        e.stopPropagation();
+        UI.keepControlbar();
+        UI.activateControlbar();
+    },
+
+    toggleExpander(e) {
+        if (this.classList.contains("noVNC_open")) {
+            this.classList.remove("noVNC_open");
+        } else {
+            this.classList.add("noVNC_open");
+        }
+    },
+
+/* ------^-------
+ *    /VISUAL
+ * ==============
+ *    SETTINGS
+ * ------v------*/
+
+    // Initial page load read/initialization of settings
+    initSetting(name, defVal) {
+        // Has the user overridden the default value?
+        if (name in UI.customSettings.defaults) {
+            defVal = UI.customSettings.defaults[name];
+        }
+        // Check Query string followed by cookie
+        let val = WebUtil.getConfigVar(name);
+        if (val === null) {
+            val = WebUtil.readSetting(name, defVal);
+        }
+        WebUtil.setSetting(name, val);
+        UI.updateSetting(name);
+        // Has the user forced a value?
+        if (name in UI.customSettings.mandatory) {
+            val = UI.customSettings.mandatory[name];
+            UI.forceSetting(name, val);
+        }
+        return val;
+    },
+
+    // Set the new value, update and disable form control setting
+    forceSetting(name, val) {
+        WebUtil.setSetting(name, val);
+        UI.updateSetting(name);
+        UI.disableSetting(name);
+    },
+
+    // Update cookie and form control setting. If value is not set, then
+    // updates from control to current cookie setting.
+    updateSetting(name) {
+
+        // Update the settings control
+        let value = UI.getSetting(name);
+
+        const ctrl = document.getElementById('noVNC_setting_' + name);
+        if (ctrl === null) {
+            return;
+        }
+
+        if (ctrl.type === 'checkbox') {
+            ctrl.checked = value;
+        } else if (typeof ctrl.options !== 'undefined') {
+            for (let i = 0; i < ctrl.options.length; i += 1) {
+                if (ctrl.options[i].value === value) {
+                    ctrl.selectedIndex = i;
+                    break;
+                }
+            }
+        } else {
+            ctrl.value = value;
+        }
+    },
+
+    // Save control setting to cookie
+    saveSetting(name) {
+        const ctrl = document.getElementById('noVNC_setting_' + name);
+        let val;
+        if (ctrl.type === 'checkbox') {
+            val = ctrl.checked;
+        } else if (typeof ctrl.options !== 'undefined') {
+            val = ctrl.options[ctrl.selectedIndex].value;
+        } else {
+            val = ctrl.value;
+        }
+        WebUtil.writeSetting(name, val);
+        //Log.Debug("Setting saved '" + name + "=" + val + "'");
+        return val;
+    },
+
+    // Read form control compatible setting from cookie
+    getSetting(name) {
+        const ctrl = document.getElementById('noVNC_setting_' + name);
+        let val = WebUtil.readSetting(name);
+        if (typeof val !== 'undefined' && val !== null &&
+            ctrl !== null && ctrl.type === 'checkbox') {
+            if (val.toString().toLowerCase() in {'0': 1, 'no': 1, 'false': 1}) {
+                val = false;
+            } else {
+                val = true;
+            }
+        }
+        return val;
+    },
+
+    // These helpers compensate for the lack of parent-selectors and
+    // previous-sibling-selectors in CSS which are needed when we want to
+    // disable the labels that belong to disabled input elements.
+    disableSetting(name) {
+        const ctrl = document.getElementById('noVNC_setting_' + name);
+        if (ctrl !== null) {
+            ctrl.disabled = true;
+            if (ctrl.label !== undefined) {
+                ctrl.label.classList.add('noVNC_disabled');
+            }
+        }
+    },
+
+    enableSetting(name) {
+        const ctrl = document.getElementById('noVNC_setting_' + name);
+        if (ctrl !== null) {
+            ctrl.disabled = false;
+            if (ctrl.label !== undefined) {
+                ctrl.label.classList.remove('noVNC_disabled');
+            }
+        }
+    },
+
+/* ------^-------
+ *   /SETTINGS
+ * ==============
+ *    PANELS
+ * ------v------*/
+
+    closeAllPanels() {
+        UI.closeSettingsPanel();
+        UI.closePowerPanel();
+        UI.closeClipboardPanel();
+        UI.closeExtraKeys();
+    },
+
+/* ------^-------
+ *   /PANELS
+ * ==============
+ * SETTINGS (panel)
+ * ------v------*/
+
+    openSettingsPanel() {
+        UI.closeAllPanels();
+        UI.openControlbar();
+
+        // Refresh UI elements from saved cookies
+        UI.updateSetting('encrypt');
+        UI.updateSetting('view_clip');
+        UI.updateSetting('resize');
+        UI.updateSetting('quality');
+        UI.updateSetting('compression');
+        UI.updateSetting('shared');
+        UI.updateSetting('view_only');
+        UI.updateSetting('path');
+        UI.updateSetting('repeaterID');
+        UI.updateSetting('logging');
+        UI.updateSetting('reconnect');
+        UI.updateSetting('reconnect_delay');
+
+        document.getElementById('noVNC_settings')
+            .classList.add("noVNC_open");
+        document.getElementById('noVNC_settings_button')
+            .classList.add("noVNC_selected");
+    },
+
+    closeSettingsPanel() {
+        document.getElementById('noVNC_settings')
+            .classList.remove("noVNC_open");
+        document.getElementById('noVNC_settings_button')
+            .classList.remove("noVNC_selected");
+    },
+
+    toggleSettingsPanel() {
+        if (document.getElementById('noVNC_settings')
+            .classList.contains("noVNC_open")) {
+            UI.closeSettingsPanel();
+        } else {
+            UI.openSettingsPanel();
+        }
+    },
+
+/* ------^-------
+ *   /SETTINGS
+ * ==============
+ *     POWER
+ * ------v------*/
+
+    openPowerPanel() {
+        UI.closeAllPanels();
+        UI.openControlbar();
+
+        document.getElementById('noVNC_power')
+            .classList.add("noVNC_open");
+        document.getElementById('noVNC_power_button')
+            .classList.add("noVNC_selected");
+    },
+
+    closePowerPanel() {
+        document.getElementById('noVNC_power')
+            .classList.remove("noVNC_open");
+        document.getElementById('noVNC_power_button')
+            .classList.remove("noVNC_selected");
+    },
+
+    togglePowerPanel() {
+        if (document.getElementById('noVNC_power')
+            .classList.contains("noVNC_open")) {
+            UI.closePowerPanel();
+        } else {
+            UI.openPowerPanel();
+        }
+    },
+
+    // Disable/enable power button
+    updatePowerButton() {
+        if (UI.connected &&
+            UI.rfb.capabilities.power &&
+            !UI.rfb.viewOnly) {
+            document.getElementById('noVNC_power_button')
+                .classList.remove("noVNC_hidden");
+        } else {
+            document.getElementById('noVNC_power_button')
+                .classList.add("noVNC_hidden");
+            // Close power panel if open
+            UI.closePowerPanel();
+        }
+    },
+
+/* ------^-------
+ *    /POWER
+ * ==============
+ *   CLIPBOARD
+ * ------v------*/
+
+    openClipboardPanel() {
+        UI.closeAllPanels();
+        UI.openControlbar();
+
+        document.getElementById('noVNC_clipboard')
+            .classList.add("noVNC_open");
+        document.getElementById('noVNC_clipboard_button')
+            .classList.add("noVNC_selected");
+    },
+
+    closeClipboardPanel() {
+        document.getElementById('noVNC_clipboard')
+            .classList.remove("noVNC_open");
+        document.getElementById('noVNC_clipboard_button')
+            .classList.remove("noVNC_selected");
+    },
+
+    toggleClipboardPanel() {
+        if (document.getElementById('noVNC_clipboard')
+            .classList.contains("noVNC_open")) {
+            UI.closeClipboardPanel();
+        } else {
+            UI.openClipboardPanel();
+        }
+    },
+
+    clipboardReceive(e) {
+        Log.Debug(">> UI.clipboardReceive: " + e.detail.text.substr(0, 40) + "...");
+        document.getElementById('noVNC_clipboard_text').value = e.detail.text;
+        Log.Debug("<< UI.clipboardReceive");
+    },
+
+    clipboardSend() {
+        const text = document.getElementById('noVNC_clipboard_text').value;
+        Log.Debug(">> UI.clipboardSend: " + text.substr(0, 40) + "...");
+        UI.rfb.clipboardPasteFrom(text);
+        Log.Debug("<< UI.clipboardSend");
+    },
+
+/* ------^-------
+ *  /CLIPBOARD
+ * ==============
+ *  CONNECTION
+ * ------v------*/
+
+    openConnectPanel() {
+        document.getElementById('noVNC_connect_dlg')
+            .classList.add("noVNC_open");
+    },
+
+    closeConnectPanel() {
+        document.getElementById('noVNC_connect_dlg')
+            .classList.remove("noVNC_open");
+    },
+
+    connect(event, password) {
+
+        // Ignore when rfb already exists
+        if (typeof UI.rfb !== 'undefined') {
+            return;
+        }
+
+        const host = UI.getSetting('host');
+        const port = UI.getSetting('port');
+        const path = UI.getSetting('path');
+
+        if (typeof password === 'undefined') {
+            password = UI.getSetting('password');
+            UI.reconnectPassword = password;
+        }
+
+        if (password === null) {
+            password = undefined;
+        }
+
+        UI.hideStatus();
+
+        UI.closeConnectPanel();
+
+        UI.updateVisualState('connecting');
+
+        let url;
+
+        if (host) {
+            url = new URL("https://" + host);
+
+            url.protocol = UI.getSetting('encrypt') ? 'wss:' : 'ws:';
+            if (port) {
+                url.port = port;
+            }
+
+            // "./" is needed to force URL() to interpret the path-variable as
+            // a path and not as an URL. This is relevant if for example path
+            // starts with more than one "/", in which case it would be
+            // interpreted as a host name instead.
+            url = new URL("./" + path, url);
+        } else {
+            // Current (May 2024) browsers support relative WebSocket
+            // URLs natively, but we need to support older browsers for
+            // some time.
+            url = new URL(path, location.href);
+            url.protocol = (window.location.protocol === "https:") ? 'wss:' : 'ws:';
+        }
+
+        if (UI.getSetting('keep_device_awake')) {
+            UI.wakeLockManager.acquire();
+        }
+
+        try {
+            UI.rfb = new RFB(document.getElementById('noVNC_container'),
+                             url.href,
+                             { shared: UI.getSetting('shared'),
+                               repeaterID: UI.getSetting('repeaterID'),
+                               credentials: { password: password } });
+        } catch (exc) {
+            Log.Error("Failed to connect to server: " + exc);
+            UI.updateVisualState('disconnected');
+            UI.showStatus(_("Failed to connect to server: ") + exc, 'error');
+            return;
+        }
+
+        UI.rfb.addEventListener("connect", UI.connectFinished);
+        UI.rfb.addEventListener("disconnect", UI.disconnectFinished);
+        UI.rfb.addEventListener("serververification", UI.serverVerify);
+        UI.rfb.addEventListener("credentialsrequired", UI.credentials);
+        UI.rfb.addEventListener("securityfailure", UI.securityFailed);
+        UI.rfb.addEventListener("clippingviewport", UI.updateViewDrag);
+        UI.rfb.addEventListener("capabilities", UI.updatePowerButton);
+        UI.rfb.addEventListener("clipboard", UI.clipboardReceive);
+        UI.rfb.addEventListener("bell", UI.bell);
+        UI.rfb.addEventListener("desktopname", UI.updateDesktopName);
+        UI.rfb.clipViewport = UI.getSetting('view_clip');
+        UI.rfb.scaleViewport = UI.getSetting('resize') === 'scale';
+        UI.rfb.resizeSession = UI.getSetting('resize') === 'remote';
+        UI.rfb.qualityLevel = parseInt(UI.getSetting('quality'));
+        UI.rfb.compressionLevel = parseInt(UI.getSetting('compression'));
+        UI.rfb.showDotCursor = UI.getSetting('show_dot');
+
+        UI.updateViewOnly(); // requires UI.rfb
+        UI.updateClipboard();
+    },
+
+    disconnect() {
+        UI.rfb.disconnect();
+
+        UI.connected = false;
+
+        // Disable automatic reconnecting
+        UI.inhibitReconnect = true;
+
+        UI.updateVisualState('disconnecting');
+
+        // Don't display the connection settings until we're actually disconnected
+    },
+
+    reconnect() {
+        UI.reconnectCallback = null;
+
+        // if reconnect has been disabled in the meantime, do nothing.
+        if (UI.inhibitReconnect) {
+            return;
+        }
+
+        UI.connect(null, UI.reconnectPassword);
+    },
+
+    cancelReconnect() {
+        if (UI.reconnectCallback !== null) {
+            clearTimeout(UI.reconnectCallback);
+            UI.reconnectCallback = null;
+        }
+
+        UI.updateVisualState('disconnected');
+
+        UI.openControlbar();
+        UI.openConnectPanel();
+    },
+
+    connectFinished(e) {
+        UI.connected = true;
+        UI.inhibitReconnect = false;
+
+        let msg;
+        if (UI.getSetting('encrypt')) {
+            msg = _("Connected (encrypted) to ") + UI.desktopName;
+        } else {
+            msg = _("Connected (unencrypted) to ") + UI.desktopName;
+        }
+        UI.showStatus(msg);
+        UI.updateVisualState('connected');
+
+        UI.updateBeforeUnload();
+
+        // Do this last because it can only be used on rendered elements
+        UI.rfb.focus();
+    },
+
+    disconnectFinished(e) {
+        const wasConnected = UI.connected;
+
+        // This variable is ideally set when disconnection starts, but
+        // when the disconnection isn't clean or if it is initiated by
+        // the server, we need to do it here as well since
+        // UI.disconnect() won't be used in those cases.
+        UI.connected = false;
+
+        UI.rfb = undefined;
+        UI.wakeLockManager.release();
+
+        if (!e.detail.clean) {
+            UI.updateVisualState('disconnected');
+            if (wasConnected) {
+                UI.showStatus(_("Something went wrong, connection is closed"),
+                              'error');
+            } else {
+                UI.showStatus(_("Failed to connect to server"), 'error');
+            }
+        }
+        // If reconnecting is allowed process it now
+        if (UI.getSetting('reconnect', false) === true && !UI.inhibitReconnect) {
+            UI.updateVisualState('reconnecting');
+
+            const delay = parseInt(UI.getSetting('reconnect_delay'));
+            UI.reconnectCallback = setTimeout(UI.reconnect, delay);
+            return;
+        } else {
+            UI.updateVisualState('disconnected');
+            UI.showStatus(_("Disconnected"), 'normal');
+        }
+
+        UI.updateBeforeUnload();
+
+        document.title = PAGE_TITLE;
+
+        UI.openControlbar();
+        UI.openConnectPanel();
+    },
+
+    securityFailed(e) {
+        let msg = "";
+        // On security failures we might get a string with a reason
+        // directly from the server. Note that we can't control if
+        // this string is translated or not.
+        if ('reason' in e.detail) {
+            msg = _("New connection has been rejected with reason: ") +
+                e.detail.reason;
+        } else {
+            msg = _("New connection has been rejected");
+        }
+        UI.showStatus(msg, 'error');
+    },
+
+    handleBeforeUnload(e) {
+        // Trigger a "Leave site?" warning prompt before closing the
+        // page. Modern browsers (Oct 2025) accept either (or both)
+        // preventDefault() or a nonempty returnValue, though the latter is
+        // considered legacy. The custom string is ignored by modern browsers,
+        // which display a native message, but older browsers will show it.
+        e.preventDefault();
+        e.returnValue = _("Are you sure you want to disconnect the session?");
+    },
+
+    updateBeforeUnload() {
+        // Remove first to avoid adding duplicates
+        window.removeEventListener("beforeunload", UI.handleBeforeUnload);
+        if (!UI.rfb?.viewOnly && UI.connected) {
+            window.addEventListener("beforeunload", UI.handleBeforeUnload);
+        }
+    },
+
+/* ------^-------
+ *  /CONNECTION
+ * ==============
+ * SERVER VERIFY
+ * ------v------*/
+
+    async serverVerify(e) {
+        const type = e.detail.type;
+        if (type === 'RSA') {
+            const publickey = e.detail.publickey;
+            let fingerprint = await window.crypto.subtle.digest("SHA-1", publickey);
+            // The same fingerprint format as RealVNC
+            fingerprint = Array.from(new Uint8Array(fingerprint).slice(0, 8)).map(
+                x => x.toString(16).padStart(2, '0')).join('-');
+            document.getElementById('noVNC_verify_server_dlg').classList.add('noVNC_open');
+            document.getElementById('noVNC_fingerprint').innerHTML = fingerprint;
+        }
+    },
+
+    approveServer(e) {
+        e.preventDefault();
+        document.getElementById('noVNC_verify_server_dlg').classList.remove('noVNC_open');
+        UI.rfb.approveServer();
+    },
+
+    rejectServer(e) {
+        e.preventDefault();
+        document.getElementById('noVNC_verify_server_dlg').classList.remove('noVNC_open');
+        UI.disconnect();
+    },
+
+/* ------^-------
+ * /SERVER VERIFY
+ * ==============
+ *   PASSWORD
+ * ------v------*/
+
+    credentials(e) {
+        // FIXME: handle more types
+
+        document.getElementById("noVNC_username_block").classList.remove("noVNC_hidden");
+        document.getElementById("noVNC_password_block").classList.remove("noVNC_hidden");
+
+        let inputFocus = "none";
+        if (e.detail.types.indexOf("username") === -1) {
+            document.getElementById("noVNC_username_block").classList.add("noVNC_hidden");
+        } else {
+            inputFocus = inputFocus === "none" ? "noVNC_username_input" : inputFocus;
+        }
+        if (e.detail.types.indexOf("password") === -1) {
+            document.getElementById("noVNC_password_block").classList.add("noVNC_hidden");
+        } else {
+            inputFocus = inputFocus === "none" ? "noVNC_password_input" : inputFocus;
+        }
+        document.getElementById('noVNC_credentials_dlg')
+            .classList.add('noVNC_open');
+
+        setTimeout(() => document
+            .getElementById(inputFocus).focus(), 100);
+
+        Log.Warn("Server asked for credentials");
+        UI.showStatus(_("Credentials are required"), "warning");
+    },
+
+    setCredentials(e) {
+        // Prevent actually submitting the form
+        e.preventDefault();
+
+        let inputElemUsername = document.getElementById('noVNC_username_input');
+        const username = inputElemUsername.value;
+
+        let inputElemPassword = document.getElementById('noVNC_password_input');
+        const password = inputElemPassword.value;
+        // Clear the input after reading the password
+        inputElemPassword.value = "";
+
+        UI.rfb.sendCredentials({ username: username, password: password });
+        UI.reconnectPassword = password;
+        document.getElementById('noVNC_credentials_dlg')
+            .classList.remove('noVNC_open');
+    },
+
+/* ------^-------
+ *  /PASSWORD
+ * ==============
+ *   FULLSCREEN
+ * ------v------*/
+
+    toggleFullscreen() {
+        if (document.fullscreenElement || // alternative standard method
+            document.mozFullScreenElement || // currently working methods
+            document.webkitFullscreenElement ||
+            document.msFullscreenElement) {
+            if (document.exitFullscreen) {
+                document.exitFullscreen();
+            } else if (document.mozCancelFullScreen) {
+                document.mozCancelFullScreen();
+            } else if (document.webkitExitFullscreen) {
+                document.webkitExitFullscreen();
+            } else if (document.msExitFullscreen) {
+                document.msExitFullscreen();
+            }
+        } else {
+            if (document.documentElement.requestFullscreen) {
+                document.documentElement.requestFullscreen();
+            } else if (document.documentElement.mozRequestFullScreen) {
+                document.documentElement.mozRequestFullScreen();
+            } else if (document.documentElement.webkitRequestFullscreen) {
+                document.documentElement.webkitRequestFullscreen(Element.ALLOW_KEYBOARD_INPUT);
+            } else if (document.body.msRequestFullscreen) {
+                document.body.msRequestFullscreen();
+            }
+        }
+        UI.updateFullscreenButton();
+    },
+
+    updateFullscreenButton() {
+        if (document.fullscreenElement || // alternative standard method
+            document.mozFullScreenElement || // currently working methods
+            document.webkitFullscreenElement ||
+            document.msFullscreenElement ) {
+            document.getElementById('noVNC_fullscreen_button')
+                .classList.add("noVNC_selected");
+        } else {
+            document.getElementById('noVNC_fullscreen_button')
+                .classList.remove("noVNC_selected");
+        }
+    },
+
+/* ------^-------
+ *  /FULLSCREEN
+ * ==============
+ *     RESIZE
+ * ------v------*/
+
+    // Apply remote resizing or local scaling
+    applyResizeMode() {
+        if (!UI.rfb) return;
+
+        UI.rfb.scaleViewport = UI.getSetting('resize') === 'scale';
+        UI.rfb.resizeSession = UI.getSetting('resize') === 'remote';
+    },
+
+/* ------^-------
+ *    /RESIZE
+ * ==============
+ * VIEW CLIPPING
+ * ------v------*/
+
+    // Update viewport clipping property for the connection. The normal
+    // case is to get the value from the setting. There are special cases
+    // for when the viewport is scaled or when a touch device is used.
+    updateViewClip() {
+        if (!UI.rfb) return;
+
+        const scaling = UI.getSetting('resize') === 'scale';
+
+        // Some platforms have overlay scrollbars that are difficult
+        // to use in our case, which means we have to force panning
+        // FIXME: Working scrollbars can still be annoying to use with
+        //        touch, so we should ideally be able to have both
+        //        panning and scrollbars at the same time
+
+        let brokenScrollbars = false;
+
+        if (!hasScrollbarGutter) {
+            if (isIOS() || isAndroid() || isMac() || isChromeOS()) {
+                brokenScrollbars = true;
+            }
+        }
+
+        if (scaling) {
+            // Can't be clipping if viewport is scaled to fit
+            UI.forceSetting('view_clip', false);
+            UI.rfb.clipViewport  = false;
+        } else if (brokenScrollbars) {
+            UI.forceSetting('view_clip', true);
+            UI.rfb.clipViewport = true;
+        } else {
+            UI.enableSetting('view_clip');
+            UI.rfb.clipViewport = UI.getSetting('view_clip');
+        }
+
+        // Changing the viewport may change the state of
+        // the dragging button
+        UI.updateViewDrag();
+    },
+
+/* ------^-------
+ * /VIEW CLIPPING
+ * ==============
+ *    VIEWDRAG
+ * ------v------*/
+
+    toggleViewDrag() {
+        if (!UI.rfb) return;
+
+        UI.rfb.dragViewport = !UI.rfb.dragViewport;
+        UI.updateViewDrag();
+    },
+
+    updateViewDrag() {
+        if (!UI.connected) return;
+
+        const viewDragButton = document.getElementById('noVNC_view_drag_button');
+
+        if ((!UI.rfb.clipViewport || !UI.rfb.clippingViewport) &&
+            UI.rfb.dragViewport) {
+            // We are no longer clipping the viewport. Make sure
+            // viewport drag isn't active when it can't be used.
+            UI.rfb.dragViewport = false;
+        }
+
+        if (UI.rfb.dragViewport) {
+            viewDragButton.classList.add("noVNC_selected");
+        } else {
+            viewDragButton.classList.remove("noVNC_selected");
+        }
+
+        if (UI.rfb.clipViewport) {
+            viewDragButton.classList.remove("noVNC_hidden");
+        } else {
+            viewDragButton.classList.add("noVNC_hidden");
+        }
+
+        viewDragButton.disabled = !UI.rfb.clippingViewport;
+    },
+
+/* ------^-------
+ *   /VIEWDRAG
+ * ==============
+ *    QUALITY
+ * ------v------*/
+
+    updateQuality() {
+        if (!UI.rfb) return;
+
+        UI.rfb.qualityLevel = parseInt(UI.getSetting('quality'));
+    },
+
+/* ------^-------
+ *   /QUALITY
+ * ==============
+ *  COMPRESSION
+ * ------v------*/
+
+    updateCompression() {
+        if (!UI.rfb) return;
+
+        UI.rfb.compressionLevel = parseInt(UI.getSetting('compression'));
+    },
+
+/* ------^-------
+ *  /COMPRESSION
+ * ==============
+ *    KEYBOARD
+ * ------v------*/
+
+    showVirtualKeyboard() {
+        if (!isTouchDevice) return;
+
+        const input = document.getElementById('noVNC_keyboardinput');
+
+        if (document.activeElement == input) return;
+
+        input.focus();
+
+        try {
+            const l = input.value.length;
+            // Move the caret to the end
+            input.setSelectionRange(l, l);
+        } catch (err) {
+            // setSelectionRange is undefined in Google Chrome
+        }
+    },
+
+    hideVirtualKeyboard() {
+        if (!isTouchDevice) return;
+
+        const input = document.getElementById('noVNC_keyboardinput');
+
+        if (document.activeElement != input) return;
+
+        input.blur();
+    },
+
+    toggleVirtualKeyboard() {
+        if (document.getElementById('noVNC_keyboard_button')
+            .classList.contains("noVNC_selected")) {
+            UI.hideVirtualKeyboard();
+        } else {
+            UI.showVirtualKeyboard();
+        }
+    },
+
+    onfocusVirtualKeyboard(event) {
+        document.getElementById('noVNC_keyboard_button')
+            .classList.add("noVNC_selected");
+        if (UI.rfb) {
+            UI.rfb.focusOnClick = false;
+        }
+    },
+
+    onblurVirtualKeyboard(event) {
+        document.getElementById('noVNC_keyboard_button')
+            .classList.remove("noVNC_selected");
+        if (UI.rfb) {
+            UI.rfb.focusOnClick = true;
+        }
+    },
+
+    keepVirtualKeyboard(event) {
+        const input = document.getElementById('noVNC_keyboardinput');
+
+        // Only prevent focus change if the virtual keyboard is active
+        if (document.activeElement != input) {
+            return;
+        }
+
+        // Only allow focus to move to other elements that need
+        // focus to function properly
+        if (event.target.form !== undefined) {
+            switch (event.target.type) {
+                case 'text':
+                case 'email':
+                case 'search':
+                case 'password':
+                case 'tel':
+                case 'url':
+                case 'textarea':
+                case 'select-one':
+                case 'select-multiple':
+                    return;
+            }
+        }
+
+        event.preventDefault();
+    },
+
+    keyboardinputReset() {
+        const kbi = document.getElementById('noVNC_keyboardinput');
+        kbi.value = new Array(UI.defaultKeyboardinputLen).join("_");
+        UI.lastKeyboardinput = kbi.value;
+    },
+
+    keyEvent(keysym, code, down) {
+        if (!UI.rfb) return;
+
+        UI.rfb.sendKey(keysym, code, down);
+    },
+
+    // When normal keyboard events are left uncought, use the input events from
+    // the keyboardinput element instead and generate the corresponding key events.
+    // This code is required since some browsers on Android are inconsistent in
+    // sending keyCodes in the normal keyboard events when using on screen keyboards.
+    keyInput(event) {
+
+        if (!UI.rfb) return;
+
+        const newValue = event.target.value;
+
+        if (!UI.lastKeyboardinput) {
+            UI.keyboardinputReset();
+        }
+        const oldValue = UI.lastKeyboardinput;
+
+        let newLen;
+        try {
+            // Try to check caret position since whitespace at the end
+            // will not be considered by value.length in some browsers
+            newLen = Math.max(event.target.selectionStart, newValue.length);
+        } catch (err) {
+            // selectionStart is undefined in Google Chrome
+            newLen = newValue.length;
+        }
+        const oldLen = oldValue.length;
+
+        let inputs = newLen - oldLen;
+        let backspaces = inputs < 0 ? -inputs : 0;
+
+        // Compare the old string with the new to account for
+        // text-corrections or other input that modify existing text
+        for (let i = 0; i < Math.min(oldLen, newLen); i++) {
+            if (newValue.charAt(i) != oldValue.charAt(i)) {
+                inputs = newLen - i;
+                backspaces = oldLen - i;
+                break;
+            }
+        }
+
+        // Send the key events
+        for (let i = 0; i < backspaces; i++) {
+            UI.rfb.sendKey(KeyTable.XK_BackSpace, "Backspace");
+        }
+        for (let i = newLen - inputs; i < newLen; i++) {
+            UI.rfb.sendKey(keysyms.lookup(newValue.charCodeAt(i)));
+        }
+
+        // Control the text content length in the keyboardinput element
+        if (newLen > 2 * UI.defaultKeyboardinputLen) {
+            UI.keyboardinputReset();
+        } else if (newLen < 1) {
+            // There always have to be some text in the keyboardinput
+            // element with which backspace can interact.
+            UI.keyboardinputReset();
+            // This sometimes causes the keyboard to disappear for a second
+            // but it is required for the android keyboard to recognize that
+            // text has been added to the field
+            event.target.blur();
+            // This has to be ran outside of the input handler in order to work
+            setTimeout(event.target.focus.bind(event.target), 0);
+        } else {
+            UI.lastKeyboardinput = newValue;
+        }
+    },
+
+/* ------^-------
+ *   /KEYBOARD
+ * ==============
+ *   EXTRA KEYS
+ * ------v------*/
+
+    openExtraKeys() {
+        UI.closeAllPanels();
+        UI.openControlbar();
+
+        document.getElementById('noVNC_modifiers')
+            .classList.add("noVNC_open");
+        document.getElementById('noVNC_toggle_extra_keys_button')
+            .classList.add("noVNC_selected");
+    },
+
+    closeExtraKeys() {
+        document.getElementById('noVNC_modifiers')
+            .classList.remove("noVNC_open");
+        document.getElementById('noVNC_toggle_extra_keys_button')
+            .classList.remove("noVNC_selected");
+    },
+
+    toggleExtraKeys() {
+        if (document.getElementById('noVNC_modifiers')
+            .classList.contains("noVNC_open")) {
+            UI.closeExtraKeys();
+        } else  {
+            UI.openExtraKeys();
+        }
+    },
+
+    sendEsc() {
+        UI.sendKey(KeyTable.XK_Escape, "Escape");
+    },
+
+    sendTab() {
+        UI.sendKey(KeyTable.XK_Tab, "Tab");
+    },
+
+    toggleCtrl() {
+        const btn = document.getElementById('noVNC_toggle_ctrl_button');
+        if (btn.classList.contains("noVNC_selected")) {
+            UI.sendKey(KeyTable.XK_Control_L, "ControlLeft", false);
+            btn.classList.remove("noVNC_selected");
+        } else {
+            UI.sendKey(KeyTable.XK_Control_L, "ControlLeft", true);
+            btn.classList.add("noVNC_selected");
+        }
+    },
+
+    toggleWindows() {
+        const btn = document.getElementById('noVNC_toggle_windows_button');
+        if (btn.classList.contains("noVNC_selected")) {
+            UI.sendKey(KeyTable.XK_Super_L, "MetaLeft", false);
+            btn.classList.remove("noVNC_selected");
+        } else {
+            UI.sendKey(KeyTable.XK_Super_L, "MetaLeft", true);
+            btn.classList.add("noVNC_selected");
+        }
+    },
+
+    toggleAlt() {
+        const btn = document.getElementById('noVNC_toggle_alt_button');
+        if (btn.classList.contains("noVNC_selected")) {
+            UI.sendKey(KeyTable.XK_Alt_L, "AltLeft", false);
+            btn.classList.remove("noVNC_selected");
+        } else {
+            UI.sendKey(KeyTable.XK_Alt_L, "AltLeft", true);
+            btn.classList.add("noVNC_selected");
+        }
+    },
+
+    sendCtrlAltDel() {
+        UI.rfb.sendCtrlAltDel();
+        // See below
+        UI.rfb.focus();
+        UI.idleControlbar();
+    },
+
+    sendKey(keysym, code, down) {
+        UI.rfb.sendKey(keysym, code, down);
+
+        // Move focus to the screen in order to be able to use the
+        // keyboard right after these extra keys.
+        // The exception is when a virtual keyboard is used, because
+        // if we focus the screen the virtual keyboard would be closed.
+        // In this case we focus our special virtual keyboard input
+        // element instead.
+        if (document.getElementById('noVNC_keyboard_button')
+            .classList.contains("noVNC_selected")) {
+            document.getElementById('noVNC_keyboardinput').focus();
+        } else {
+            UI.rfb.focus();
+        }
+        // fade out the controlbar to highlight that
+        // the focus has been moved to the screen
+        UI.idleControlbar();
+    },
+
+/* ------^-------
+ *   /EXTRA KEYS
+ * ==============
+ *     MISC
+ * ------v------*/
+
+    updateViewOnly() {
+        if (!UI.rfb) return;
+        UI.rfb.viewOnly = UI.getSetting('view_only');
+
+        UI.updateBeforeUnload();
+
+        // Hide input related buttons in view only mode
+        if (UI.rfb.viewOnly) {
+            document.getElementById('noVNC_keyboard_button')
+                .classList.add('noVNC_hidden');
+            document.getElementById('noVNC_toggle_extra_keys_button')
+                .classList.add('noVNC_hidden');
+            document.getElementById('noVNC_clipboard_button')
+                .classList.add('noVNC_hidden');
+        } else {
+            document.getElementById('noVNC_keyboard_button')
+                .classList.remove('noVNC_hidden');
+            document.getElementById('noVNC_toggle_extra_keys_button')
+                .classList.remove('noVNC_hidden');
+            document.getElementById('noVNC_clipboard_button')
+                .classList.remove('noVNC_hidden');
+        }
+    },
+
+    updateClipboard() {
+        browserAsyncClipboardSupport()
+            .then((support) => {
+                if (support === 'unsupported') {
+                    // Use fallback clipboard panel
+                    return;
+                }
+                if (support === 'denied' || support === 'available') {
+                    UI.closeClipboardPanel();
+                    document.getElementById('noVNC_clipboard_button')
+                        .classList.add('noVNC_hidden');
+                    document.getElementById('noVNC_clipboard_button')
+                        .removeEventListener('click', UI.toggleClipboardPanel);
+                    document.getElementById('noVNC_clipboard_text')
+                        .removeEventListener('change', UI.clipboardSend);
+                    if (UI.rfb) {
+                        UI.rfb.removeEventListener('clipboard', UI.clipboardReceive);
+                    }
+                }
+            })
+            .catch(() => {
+                // Treat as unsupported
+            });
+    },
+
+    updateShowDotCursor() {
+        if (!UI.rfb) return;
+        UI.rfb.showDotCursor = UI.getSetting('show_dot');
+    },
+
+    updateLogging() {
+        WebUtil.initLogging(UI.getSetting('logging'));
+    },
+
+    updateDesktopName(e) {
+        UI.desktopName = e.detail.name;
+        // Display the desktop name in the document title
+        document.title = e.detail.name + " - " + PAGE_TITLE;
+    },
+
+    updateRequestWakelock() {
+        if (!UI.rfb) return;
+        if (UI.getSetting('keep_device_awake')) {
+            UI.wakeLockManager.acquire();
+        } else {
+            UI.wakeLockManager.release();
+        }
+    },
+
+
+    bell(e) {
+        if (UI.getSetting('bell') === 'on') {
+            const promise = document.getElementById('noVNC_bell').play();
+            // The standards disagree on the return value here
+            if (promise) {
+                promise.catch((e) => {
+                    if (e.name === "NotAllowedError") {
+                        // Ignore when the browser doesn't let us play audio.
+                        // It is common that the browsers require audio to be
+                        // initiated from a user action.
+                    } else {
+                        Log.Error("Unable to play bell: " + e);
+                    }
+                });
+            }
+        }
+    },
+
+    //Helper to add options to dropdown.
+    addOption(selectbox, text, value) {
+        const optn = document.createElement("OPTION");
+        optn.text = text;
+        optn.value = value;
+        selectbox.options.add(optn);
+    },
+
+/* ------^-------
+ *    /MISC
+ * ==============
+ */
+};
+
+export default UI;
pkg/web/noVNC/app/wakelock.js
@@ -0,0 +1,199 @@
+/*
+ * noVNC: HTML5 VNC client
+ * Copyright (C) 2025 The noVNC authors
+ * Licensed under MPL 2.0 or any later version (see LICENSE.txt)
+ *
+ * Wrapper around the `navigator.wakeLock` api that handles reacquiring the
+ * lock on visiblility changes.
+ *
+ * The `acquire` and `release` methods may be called any number of times. The
+ * most recent call dictates the desired end-state (if `acquire` was most
+ * recently called, then we will try to acquire and hold the wake lock).
+ */
+
+import * as Log from '../core/util/logging.js';
+
+const _STATES = {
+    /* No wake lock.
+     *
+     * Can transition to:
+     *  - AWAITING_VISIBLE: `acquire` called when document is hidden.
+     *  - ACQUIRING: `acquire` called.
+     *  - ERROR: `acquired` called when the api is not available.
+     */
+    RELEASED: 'released',
+    /* Wake lock requested, waiting for browser.
+     *
+     * Can transition to:
+     *  - ACQUIRED: success
+     *  - ACQUIRING_WANT_RELEASE: `release` called while waiting
+     *  - ERROR
+     */
+    ACQUIRING: 'acquiring',
+    /* Wake lock requested, release called, still waiting for browser.
+     *
+     * Can transition to:
+     *  - ACQUIRING: `acquire` called (but promise has not resolved yet)
+     *  - RELEASED: success
+     */
+    ACQUIRING_WANT_RELEASE: 'releasing',
+    /* Wake lock held.
+     *
+     * Can transition to:
+     *  - AWAITING_VISIBLE: wakelock lost due to visibility change
+     *  - RELEASED: success
+     */
+    ACQUIRED: 'acquired',
+    /* Caller wants wakelock, but we can not get it due to visibility.
+     *
+     * Can transition to:
+     *  - ACQUIRING: document is now visible, attempting to get wakelock.
+     *  - RELEASED: when release is called.
+     */
+    AWAITING_VISIBLE: 'awaiting_visible',
+    /* An error has occurred.
+     *
+     * Can transition to:
+     *  - RELEASED: will happen immediately.
+     */
+    ERROR: 'error',
+};
+
+class TestOnlyWakeLockManagerStateChangeEvent extends Event {
+    constructor(oldState, newState) {
+        super("testOnlyStateChange");
+        this.oldState = oldState;
+        this.newState = newState;
+    }
+}
+
+export default class WakeLockManager extends EventTarget {
+    constructor() {
+        super();
+
+        this._state = _STATES.RELEASED;
+        this._wakelock = null;
+
+        this._eventHandlers = {
+            wakelockAcquired: this._wakelockAcquired.bind(this),
+            wakelockReleased: this._wakelockReleased.bind(this),
+            documentVisibilityChange: this._documentVisibilityChange.bind(this),
+        };
+    }
+
+    acquire() {
+        switch (this._state) {
+            case _STATES.ACQUIRING_WANT_RELEASE:
+                // We are currently waiting to acquire the wakelock. While
+                // waiting, `release()` was called. By transitioning back to
+                // ACQUIRING, we will keep the lock after we receive it.
+                this._transitionTo(_STATES.ACQUIRING);
+                break;
+            case _STATES.AWAITING_VISIBLE:
+            case _STATES.ACQUIRING:
+            case _STATES.ACQUIRED:
+                break;
+            case _STATES.ERROR:
+            case _STATES.RELEASED:
+                if (document.hidden) {
+                    // We can not acquire the wakelock while the document is
+                    // hidden (eg, not the active tab). Wait until it is
+                    // visible, then acquire the wakelock.
+                    this._awaitVisible();
+                    break;
+                }
+                this._acquireWakelockNow();
+                break;
+        }
+    }
+
+    release() {
+        switch (this._state) {
+            case _STATES.ERROR:
+            case _STATES.RELEASED:
+            case _STATES.ACQUIRING_WANT_RELEASE:
+                break;
+            case _STATES.ACQUIRING:
+                // We are have requested (but not yet received) the wakelock.
+                // Give it up as soon as we acquire it.
+                this._transitionTo(_STATES.ACQUIRING_WANT_RELEASE);
+                break;
+            case _STATES.ACQUIRED:
+                // We remove the event listener first, as we don't want to be
+                // notified about this release (it is expected).
+                this._wakelock.removeEventListener("release", this._eventHandlers.wakelockReleased);
+                this._wakelock.release();
+                this._wakelock = null;
+                this._transitionTo(_STATES.RELEASED);
+                break;
+            case _STATES.AWAITING_VISIBLE:
+                // We don't currently have the lock, but are waiting for the
+                // document to become visible. By removing the event listener,
+                // we will not attempt to get the wakelock in the future.
+                document.removeEventListener("visibilitychange", this._eventHandlers.documentVisibilityChange);
+                this._transitionTo(_STATES.RELEASED);
+                break;
+        }
+    }
+
+    _transitionTo(newState) {
+        let oldState = this._state;
+        Log.Debug(`WakelockManager transitioning ${oldState} -> ${newState}`);
+        this._state = newState;
+        this.dispatchEvent(new TestOnlyWakeLockManagerStateChangeEvent(oldState, newState));
+    }
+
+    _awaitVisible() {
+        document.addEventListener("visibilitychange", this._eventHandlers.documentVisibilityChange);
+        this._transitionTo(_STATES.AWAITING_VISIBLE);
+    }
+
+    _acquireWakelockNow() {
+        if (!("wakeLock" in navigator)) {
+            Log.Warn("Unable to request wakeLock, Browser does not have wakeLock api");
+            this._transitionTo(_STATES.ERROR);
+            this._transitionTo(_STATES.RELEASED);
+            return;
+        }
+        navigator.wakeLock.request("screen")
+            .then(this._eventHandlers.wakelockAcquired)
+            .catch((err) => {
+                Log.Warn("Error occurred while acquiring wakelock: " + err);
+                this._transitionTo(_STATES.ERROR);
+                this._transitionTo(_STATES.RELEASED);
+            });
+        this._transitionTo(_STATES.ACQUIRING);
+    }
+
+
+    _wakelockAcquired(wakelock) {
+        if (this._state === _STATES.ACQUIRING_WANT_RELEASE) {
+            // We were requested to release the wakelock while we were trying to
+            // acquire it. Now that we have acquired it, immediately release it.
+            wakelock.release();
+            this._transitionTo(_STATES.RELEASED);
+            return;
+        }
+        this._wakelock = wakelock;
+        this._wakelock.addEventListener("release", this._eventHandlers.wakelockReleased);
+        this._transitionTo(_STATES.ACQUIRED);
+    }
+
+    _wakelockReleased(event) {
+        this._wakelock = null;
+        if (document.visibilityState === "visible") {
+            Log.Warn("Lost wakelock, but document is still visible. Not reacquiring");
+            this._transitionTo(_STATES.RELEASED);
+            return;
+        }
+        this._awaitVisible();
+    }
+
+    _documentVisibilityChange(event) {
+        if (document.visibilityState !== "visible") {
+            return;
+        }
+        document.removeEventListener("visibilitychange", this._eventHandlers.documentVisibilityChange);
+        this._acquireWakelockNow();
+    }
+}
pkg/web/noVNC/app/webutil.js
@@ -0,0 +1,250 @@
+/*
+ * noVNC: HTML5 VNC client
+ * Copyright (C) 2019 The noVNC authors
+ * Licensed under MPL 2.0 (see LICENSE.txt)
+ *
+ * See README.md for usage and integration instructions.
+ */
+
+import * as Log from '../core/util/logging.js';
+
+// init log level reading the logging HTTP param
+export function initLogging(level) {
+    "use strict";
+    if (typeof level !== "undefined") {
+        Log.initLogging(level);
+    } else {
+        const param = document.location.href.match(/logging=([A-Za-z0-9._-]*)/);
+        Log.initLogging(param || undefined);
+    }
+}
+
+// Read a query string variable
+// A URL with a query parameter can look like this (But will most probably get logged on the http server):
+// https://www.example.com?myqueryparam=myvalue
+//
+// For privacy (Using a hastag #, the parameters will not be sent to the server)
+// the url can be requested in the following way:
+// https://www.example.com#myqueryparam=myvalue&password=secretvalue
+//
+// Even mixing public and non public parameters will work:
+// https://www.example.com?nonsecretparam=example.com#password=secretvalue
+export function getQueryVar(name, defVal) {
+    "use strict";
+    const re = new RegExp('.*[?&]' + name + '=([^&#]*)'),
+          match = document.location.href.match(re);
+    if (typeof defVal === 'undefined') { defVal = null; }
+
+    if (match) {
+        return decodeURIComponent(match[1]);
+    }
+
+    return defVal;
+}
+
+// Read a hash fragment variable
+export function getHashVar(name, defVal) {
+    "use strict";
+    const re = new RegExp('.*[&#]' + name + '=([^&]*)'),
+          match = document.location.hash.match(re);
+    if (typeof defVal === 'undefined') { defVal = null; }
+
+    if (match) {
+        return decodeURIComponent(match[1]);
+    }
+
+    return defVal;
+}
+
+// Read a variable from the fragment or the query string
+// Fragment takes precedence
+export function getConfigVar(name, defVal) {
+    "use strict";
+    const val = getHashVar(name);
+
+    if (val === null) {
+        return getQueryVar(name, defVal);
+    }
+
+    return val;
+}
+
+/*
+ * Cookie handling. Dervied from: http://www.quirksmode.org/js/cookies.html
+ */
+
+// No days means only for this browser session
+export function createCookie(name, value, days) {
+    "use strict";
+    let date, expires;
+    if (days) {
+        date = new Date();
+        date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
+        expires = "; expires=" + date.toGMTString();
+    } else {
+        expires = "";
+    }
+
+    let secure;
+    if (document.location.protocol === "https:") {
+        secure = "; secure";
+    } else {
+        secure = "";
+    }
+    document.cookie = name + "=" + value + expires + "; path=/" + secure;
+}
+
+export function readCookie(name, defaultValue) {
+    "use strict";
+    const nameEQ = name + "=";
+    const ca = document.cookie.split(';');
+
+    for (let i = 0; i < ca.length; i += 1) {
+        let c = ca[i];
+        while (c.charAt(0) === ' ') {
+            c = c.substring(1, c.length);
+        }
+        if (c.indexOf(nameEQ) === 0) {
+            return c.substring(nameEQ.length, c.length);
+        }
+    }
+
+    return (typeof defaultValue !== 'undefined') ? defaultValue : null;
+}
+
+export function eraseCookie(name) {
+    "use strict";
+    createCookie(name, "", -1);
+}
+
+/*
+ * Setting handling.
+ */
+
+let settings = {};
+
+export function initSettings() {
+    if (!window.chrome || !window.chrome.storage) {
+        settings = {};
+        return Promise.resolve();
+    }
+
+    return new Promise(resolve => window.chrome.storage.sync.get(resolve))
+        .then((cfg) => { settings = cfg; });
+}
+
+// Update the settings cache, but do not write to permanent storage
+export function setSetting(name, value) {
+    settings[name] = value;
+}
+
+// No days means only for this browser session
+export function writeSetting(name, value) {
+    "use strict";
+    if (settings[name] === value) return;
+    settings[name] = value;
+    if (window.chrome && window.chrome.storage) {
+        window.chrome.storage.sync.set(settings);
+    } else {
+        localStorageSet(name, value);
+    }
+}
+
+export function readSetting(name, defaultValue) {
+    "use strict";
+    let value;
+    if ((name in settings) || (window.chrome && window.chrome.storage)) {
+        value = settings[name];
+    } else {
+        value = localStorageGet(name);
+        settings[name] = value;
+    }
+    if (typeof value === "undefined") {
+        value = null;
+    }
+
+    if (value === null && typeof defaultValue !== "undefined") {
+        return defaultValue;
+    }
+
+    return value;
+}
+
+export function eraseSetting(name) {
+    "use strict";
+    // Deleting here means that next time the setting is read when using local
+    // storage, it will be pulled from local storage again.
+    // If the setting in local storage is changed (e.g. in another tab)
+    // between this delete and the next read, it could lead to an unexpected
+    // value change.
+    delete settings[name];
+    if (window.chrome && window.chrome.storage) {
+        window.chrome.storage.sync.remove(name);
+    } else {
+        localStorageRemove(name);
+    }
+}
+
+let loggedMsgs = [];
+function logOnce(msg, level = "warn") {
+    if (!loggedMsgs.includes(msg)) {
+        switch (level) {
+            case "error":
+                Log.Error(msg);
+                break;
+            case "warn":
+                Log.Warn(msg);
+                break;
+            case "debug":
+                Log.Debug(msg);
+                break;
+            default:
+                Log.Info(msg);
+        }
+        loggedMsgs.push(msg);
+    }
+}
+
+let cookiesMsg = "Couldn't access noVNC settings, are cookies disabled?";
+
+function localStorageGet(name) {
+    let r;
+    try {
+        r = localStorage.getItem(name);
+    } catch (e) {
+        if (e instanceof DOMException) {
+            logOnce(cookiesMsg);
+            logOnce("'localStorage.getItem(" + name + ")' failed: " + e,
+                    "debug");
+        } else {
+            throw e;
+        }
+    }
+    return r;
+}
+function localStorageSet(name, value) {
+    try {
+        localStorage.setItem(name, value);
+    } catch (e) {
+        if (e instanceof DOMException) {
+            logOnce(cookiesMsg);
+            logOnce("'localStorage.setItem(" + name + "," + value +
+                    ")' failed: " + e, "debug");
+        } else {
+            throw e;
+        }
+    }
+}
+function localStorageRemove(name) {
+    try {
+        localStorage.removeItem(name);
+    } catch (e) {
+        if (e instanceof DOMException) {
+            logOnce(cookiesMsg);
+            logOnce("'localStorage.removeItem(" + name + ")' failed: " + e,
+                    "debug");
+        } else {
+            throw e;
+        }
+    }
+}
pkg/web/noVNC/core/crypto/aes.js
@@ -0,0 +1,178 @@
+export class AESECBCipher {
+    constructor() {
+        this._key = null;
+    }
+
+    get algorithm() {
+        return { name: "AES-ECB" };
+    }
+
+    static async importKey(key, _algorithm, extractable, keyUsages) {
+        const cipher = new AESECBCipher;
+        await cipher._importKey(key, extractable, keyUsages);
+        return cipher;
+    }
+
+    async _importKey(key, extractable, keyUsages) {
+        this._key = await window.crypto.subtle.importKey(
+            "raw", key, {name: "AES-CBC"}, extractable, keyUsages);
+    }
+
+    async encrypt(_algorithm, plaintext) {
+        const x = new Uint8Array(plaintext);
+        if (x.length % 16 !== 0 || this._key === null) {
+            return null;
+        }
+        const n = x.length / 16;
+        for (let i = 0; i < n; i++) {
+            const y = new Uint8Array(await window.crypto.subtle.encrypt({
+                name: "AES-CBC",
+                iv: new Uint8Array(16),
+            }, this._key, x.slice(i * 16, i * 16 + 16))).slice(0, 16);
+            x.set(y, i * 16);
+        }
+        return x;
+    }
+}
+
+export class AESEAXCipher {
+    constructor() {
+        this._rawKey = null;
+        this._ctrKey = null;
+        this._cbcKey = null;
+        this._zeroBlock = new Uint8Array(16);
+        this._prefixBlock0 = this._zeroBlock;
+        this._prefixBlock1 = new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]);
+        this._prefixBlock2 = new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2]);
+    }
+
+    get algorithm() {
+        return { name: "AES-EAX" };
+    }
+
+    async _encryptBlock(block) {
+        const encrypted = await window.crypto.subtle.encrypt({
+            name: "AES-CBC",
+            iv: this._zeroBlock,
+        }, this._cbcKey, block);
+        return new Uint8Array(encrypted).slice(0, 16);
+    }
+
+    async _initCMAC() {
+        const k1 = await this._encryptBlock(this._zeroBlock);
+        const k2 = new Uint8Array(16);
+        const v = k1[0] >>> 6;
+        for (let i = 0; i < 15; i++) {
+            k2[i] = (k1[i + 1] >> 6) | (k1[i] << 2);
+            k1[i] = (k1[i + 1] >> 7) | (k1[i] << 1);
+        }
+        const lut = [0x0, 0x87, 0x0e, 0x89];
+        k2[14] ^= v >>> 1;
+        k2[15] = (k1[15] << 2) ^ lut[v];
+        k1[15] = (k1[15] << 1) ^ lut[v >> 1];
+        this._k1 = k1;
+        this._k2 = k2;
+    }
+
+    async _encryptCTR(data, counter) {
+        const encrypted = await window.crypto.subtle.encrypt({
+            name: "AES-CTR",
+            counter: counter,
+            length: 128
+        }, this._ctrKey, data);
+        return new Uint8Array(encrypted);
+    }
+
+    async _decryptCTR(data, counter) {
+        const decrypted = await window.crypto.subtle.decrypt({
+            name: "AES-CTR",
+            counter: counter,
+            length: 128
+        }, this._ctrKey, data);
+        return new Uint8Array(decrypted);
+    }
+
+    async _computeCMAC(data, prefixBlock) {
+        if (prefixBlock.length !== 16) {
+            return null;
+        }
+        const n = Math.floor(data.length / 16);
+        const m = Math.ceil(data.length / 16);
+        const r = data.length - n * 16;
+        const cbcData = new Uint8Array((m + 1) * 16);
+        cbcData.set(prefixBlock);
+        cbcData.set(data, 16);
+        if (r === 0) {
+            for (let i = 0; i < 16; i++) {
+                cbcData[n * 16 + i] ^= this._k1[i];
+            }
+        } else {
+            cbcData[(n + 1) * 16 + r] = 0x80;
+            for (let i = 0; i < 16; i++) {
+                cbcData[(n + 1) * 16 + i] ^= this._k2[i];
+            }
+        }
+        let cbcEncrypted = await window.crypto.subtle.encrypt({
+            name: "AES-CBC",
+            iv: this._zeroBlock,
+        }, this._cbcKey, cbcData);
+
+        cbcEncrypted = new Uint8Array(cbcEncrypted);
+        const mac = cbcEncrypted.slice(cbcEncrypted.length - 32, cbcEncrypted.length - 16);
+        return mac;
+    }
+
+    static async importKey(key, _algorithm, _extractable, _keyUsages) {
+        const cipher = new AESEAXCipher;
+        await cipher._importKey(key);
+        return cipher;
+    }
+
+    async _importKey(key) {
+        this._rawKey = key;
+        this._ctrKey = await window.crypto.subtle.importKey(
+            "raw", key, {name: "AES-CTR"}, false, ["encrypt", "decrypt"]);
+        this._cbcKey = await window.crypto.subtle.importKey(
+            "raw", key, {name: "AES-CBC"}, false, ["encrypt"]);
+        await this._initCMAC();
+    }
+
+    async encrypt(algorithm, message) {
+        const ad = algorithm.additionalData;
+        const nonce = algorithm.iv;
+        const nCMAC = await this._computeCMAC(nonce, this._prefixBlock0);
+        const encrypted = await this._encryptCTR(message, nCMAC);
+        const adCMAC = await this._computeCMAC(ad, this._prefixBlock1);
+        const mac = await this._computeCMAC(encrypted, this._prefixBlock2);
+        for (let i = 0; i < 16; i++) {
+            mac[i] ^= nCMAC[i] ^ adCMAC[i];
+        }
+        const res = new Uint8Array(16 + encrypted.length);
+        res.set(encrypted);
+        res.set(mac, encrypted.length);
+        return res;
+    }
+
+    async decrypt(algorithm, data) {
+        const encrypted = data.slice(0, data.length - 16);
+        const ad = algorithm.additionalData;
+        const nonce = algorithm.iv;
+        const mac = data.slice(data.length - 16);
+        const nCMAC = await this._computeCMAC(nonce, this._prefixBlock0);
+        const adCMAC = await this._computeCMAC(ad, this._prefixBlock1);
+        const computedMac = await this._computeCMAC(encrypted, this._prefixBlock2);
+        for (let i = 0; i < 16; i++) {
+            computedMac[i] ^= nCMAC[i] ^ adCMAC[i];
+        }
+        if (computedMac.length !== mac.length) {
+            return null;
+        }
+        for (let i = 0; i < mac.length; i++) {
+            if (computedMac[i] !== mac[i]) {
+                return null;
+            }
+        }
+        const res = await this._decryptCTR(encrypted, nCMAC);
+        return res;
+    }
+}
pkg/web/noVNC/core/crypto/bigint.js
@@ -0,0 +1,34 @@
+export function modPow(b, e, m) {
+    let r = 1n;
+    b = b % m;
+    while (e > 0n) {
+        if ((e & 1n) === 1n) {
+            r = (r * b) % m;
+        }
+        e = e >> 1n;
+        b = (b * b) % m;
+    }
+    return r;
+}
+
+export function bigIntToU8Array(bigint, padLength=0) {
+    let hex = bigint.toString(16);
+    if (padLength === 0) {
+        padLength = Math.ceil(hex.length / 2);
+    }
+    hex = hex.padStart(padLength * 2, '0');
+    const length = hex.length / 2;
+    const arr = new Uint8Array(length);
+    for (let i = 0; i < length; i++) {
+        arr[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
+    }
+    return arr;
+}
+
+export function u8ArrayToBigInt(arr) {
+    let hex = '0x';
+    for (let i = 0; i < arr.length; i++) {
+        hex += arr[i].toString(16).padStart(2, '0');
+    }
+    return BigInt(hex);
+}
pkg/web/noVNC/core/crypto/crypto.js
@@ -0,0 +1,90 @@
+import { AESECBCipher, AESEAXCipher } from "./aes.js";
+import { DESCBCCipher, DESECBCipher } from "./des.js";
+import { RSACipher } from "./rsa.js";
+import { DHCipher } from "./dh.js";
+import { MD5 } from "./md5.js";
+
+// A single interface for the cryptographic algorithms not supported by SubtleCrypto.
+// Both synchronous and asynchronous implmentations are allowed.
+class LegacyCrypto {
+    constructor() {
+        this._algorithms = {
+            "AES-ECB": AESECBCipher,
+            "AES-EAX": AESEAXCipher,
+            "DES-ECB": DESECBCipher,
+            "DES-CBC": DESCBCCipher,
+            "RSA-PKCS1-v1_5": RSACipher,
+            "DH": DHCipher,
+            "MD5": MD5,
+        };
+    }
+
+    encrypt(algorithm, key, data) {
+        if (key.algorithm.name !== algorithm.name) {
+            throw new Error("algorithm does not match");
+        }
+        if (typeof key.encrypt !== "function") {
+            throw new Error("key does not support encryption");
+        }
+        return key.encrypt(algorithm, data);
+    }
+
+    decrypt(algorithm, key, data) {
+        if (key.algorithm.name !== algorithm.name) {
+            throw new Error("algorithm does not match");
+        }
+        if (typeof key.decrypt !== "function") {
+            throw new Error("key does not support encryption");
+        }
+        return key.decrypt(algorithm, data);
+    }
+
+    importKey(format, keyData, algorithm, extractable, keyUsages) {
+        if (format !== "raw") {
+            throw new Error("key format is not supported");
+        }
+        const alg = this._algorithms[algorithm.name];
+        if (typeof alg === "undefined" || typeof alg.importKey !== "function") {
+            throw new Error("algorithm is not supported");
+        }
+        return alg.importKey(keyData, algorithm, extractable, keyUsages);
+    }
+
+    generateKey(algorithm, extractable, keyUsages) {
+        const alg = this._algorithms[algorithm.name];
+        if (typeof alg === "undefined" || typeof alg.generateKey !== "function") {
+            throw new Error("algorithm is not supported");
+        }
+        return alg.generateKey(algorithm, extractable, keyUsages);
+    }
+
+    exportKey(format, key) {
+        if (format !== "raw") {
+            throw new Error("key format is not supported");
+        }
+        if (typeof key.exportKey !== "function") {
+            throw new Error("key does not support exportKey");
+        }
+        return key.exportKey();
+    }
+
+    digest(algorithm, data) {
+        const alg = this._algorithms[algorithm];
+        if (typeof alg !== "function") {
+            throw new Error("algorithm is not supported");
+        }
+        return alg(data);
+    }
+
+    deriveBits(algorithm, key, length) {
+        if (key.algorithm.name !== algorithm.name) {
+            throw new Error("algorithm does not match");
+        }
+        if (typeof key.deriveBits !== "function") {
+            throw new Error("key does not support deriveBits");
+        }
+        return key.deriveBits(algorithm, length);
+    }
+}
+
+export default new LegacyCrypto;
pkg/web/noVNC/core/crypto/des.js
@@ -0,0 +1,330 @@
+/*
+ * Ported from Flashlight VNC ActionScript implementation:
+ *     http://www.wizhelp.com/flashlight-vnc/
+ *
+ * Full attribution follows:
+ *
+ * -------------------------------------------------------------------------
+ *
+ * This DES class has been extracted from package Acme.Crypto for use in VNC.
+ * The unnecessary odd parity code has been removed.
+ *
+ * These changes are:
+ *  Copyright (C) 1999 AT&T Laboratories Cambridge.  All Rights Reserved.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+ *
+
+ * DesCipher - the DES encryption method
+ *
+ * The meat of this code is by Dave Zimmerman <dzimm@widget.com>, and is:
+ *
+ * Copyright (c) 1996 Widget Workshop, Inc. All Rights Reserved.
+ *
+ * Permission to use, copy, modify, and distribute this software
+ * and its documentation for NON-COMMERCIAL or COMMERCIAL purposes and
+ * without fee is hereby granted, provided that this copyright notice is kept
+ * intact.
+ *
+ * WIDGET WORKSHOP MAKES NO REPRESENTATIONS OR WARRANTIES ABOUT THE SUITABILITY
+ * OF THE SOFTWARE, EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+ * TO THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+ * PARTICULAR PURPOSE, OR NON-INFRINGEMENT. WIDGET WORKSHOP SHALL NOT BE LIABLE
+ * FOR ANY DAMAGES SUFFERED BY LICENSEE AS A RESULT OF USING, MODIFYING OR
+ * DISTRIBUTING THIS SOFTWARE OR ITS DERIVATIVES.
+ *
+ * THIS SOFTWARE IS NOT DESIGNED OR INTENDED FOR USE OR RESALE AS ON-LINE
+ * CONTROL EQUIPMENT IN HAZARDOUS ENVIRONMENTS REQUIRING FAIL-SAFE
+ * PERFORMANCE, SUCH AS IN THE OPERATION OF NUCLEAR FACILITIES, AIRCRAFT
+ * NAVIGATION OR COMMUNICATION SYSTEMS, AIR TRAFFIC CONTROL, DIRECT LIFE
+ * SUPPORT MACHINES, OR WEAPONS SYSTEMS, IN WHICH THE FAILURE OF THE
+ * SOFTWARE COULD LEAD DIRECTLY TO DEATH, PERSONAL INJURY, OR SEVERE
+ * PHYSICAL OR ENVIRONMENTAL DAMAGE ("HIGH RISK ACTIVITIES").  WIDGET WORKSHOP
+ * SPECIFICALLY DISCLAIMS ANY EXPRESS OR IMPLIED WARRANTY OF FITNESS FOR
+ * HIGH RISK ACTIVITIES.
+ *
+ *
+ * The rest is:
+ *
+ * Copyright (C) 1996 by Jef Poskanzer <jef@acme.com>.  All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ * 1. Redistributions of source code must retain the above copyright
+ *    notice, this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright
+ *    notice, this list of conditions and the following disclaimer in the
+ *    documentation and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED.  IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
+ * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
+ * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+ * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+ * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
+ * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
+ * SUCH DAMAGE.
+ *
+ * Visit the ACME Labs Java page for up-to-date versions of this and other
+ * fine Java utilities: http://www.acme.com/java/
+ */
+
+/* eslint-disable comma-spacing */
+
+// Tables, permutations, S-boxes, etc.
+const PC2 = [13,16,10,23, 0, 4, 2,27,14, 5,20, 9,22,18,11, 3,
+             25, 7,15, 6,26,19,12, 1,40,51,30,36,46,54,29,39,
+             50,44,32,47,43,48,38,55,33,52,45,41,49,35,28,31 ],
+      totrot = [ 1, 2, 4, 6, 8,10,12,14,15,17,19,21,23,25,27,28];
+
+const z = 0x0;
+let a,b,c,d,e,f;
+a=1<<16; b=1<<24; c=a|b; d=1<<2; e=1<<10; f=d|e;
+const SP1 = [c|e,z|z,a|z,c|f,c|d,a|f,z|d,a|z,z|e,c|e,c|f,z|e,b|f,c|d,b|z,z|d,
+             z|f,b|e,b|e,a|e,a|e,c|z,c|z,b|f,a|d,b|d,b|d,a|d,z|z,z|f,a|f,b|z,
+             a|z,c|f,z|d,c|z,c|e,b|z,b|z,z|e,c|d,a|z,a|e,b|d,z|e,z|d,b|f,a|f,
+             c|f,a|d,c|z,b|f,b|d,z|f,a|f,c|e,z|f,b|e,b|e,z|z,a|d,a|e,z|z,c|d];
+a=1<<20; b=1<<31; c=a|b; d=1<<5; e=1<<15; f=d|e;
+const SP2 = [c|f,b|e,z|e,a|f,a|z,z|d,c|d,b|f,b|d,c|f,c|e,b|z,b|e,a|z,z|d,c|d,
+             a|e,a|d,b|f,z|z,b|z,z|e,a|f,c|z,a|d,b|d,z|z,a|e,z|f,c|e,c|z,z|f,
+             z|z,a|f,c|d,a|z,b|f,c|z,c|e,z|e,c|z,b|e,z|d,c|f,a|f,z|d,z|e,b|z,
+             z|f,c|e,a|z,b|d,a|d,b|f,b|d,a|d,a|e,z|z,b|e,z|f,b|z,c|d,c|f,a|e];
+a=1<<17; b=1<<27; c=a|b; d=1<<3; e=1<<9; f=d|e;
+const SP3 = [z|f,c|e,z|z,c|d,b|e,z|z,a|f,b|e,a|d,b|d,b|d,a|z,c|f,a|d,c|z,z|f,
+             b|z,z|d,c|e,z|e,a|e,c|z,c|d,a|f,b|f,a|e,a|z,b|f,z|d,c|f,z|e,b|z,
+             c|e,b|z,a|d,z|f,a|z,c|e,b|e,z|z,z|e,a|d,c|f,b|e,b|d,z|e,z|z,c|d,
+             b|f,a|z,b|z,c|f,z|d,a|f,a|e,b|d,c|z,b|f,z|f,c|z,a|f,z|d,c|d,a|e];
+a=1<<13; b=1<<23; c=a|b; d=1<<0; e=1<<7; f=d|e;
+const SP4 = [c|d,a|f,a|f,z|e,c|e,b|f,b|d,a|d,z|z,c|z,c|z,c|f,z|f,z|z,b|e,b|d,
+             z|d,a|z,b|z,c|d,z|e,b|z,a|d,a|e,b|f,z|d,a|e,b|e,a|z,c|e,c|f,z|f,
+             b|e,b|d,c|z,c|f,z|f,z|z,z|z,c|z,a|e,b|e,b|f,z|d,c|d,a|f,a|f,z|e,
+             c|f,z|f,z|d,a|z,b|d,a|d,c|e,b|f,a|d,a|e,b|z,c|d,z|e,b|z,a|z,c|e];
+a=1<<25; b=1<<30; c=a|b; d=1<<8; e=1<<19; f=d|e;
+const SP5 = [z|d,a|f,a|e,c|d,z|e,z|d,b|z,a|e,b|f,z|e,a|d,b|f,c|d,c|e,z|f,b|z,
+             a|z,b|e,b|e,z|z,b|d,c|f,c|f,a|d,c|e,b|d,z|z,c|z,a|f,a|z,c|z,z|f,
+             z|e,c|d,z|d,a|z,b|z,a|e,c|d,b|f,a|d,b|z,c|e,a|f,b|f,z|d,a|z,c|e,
+             c|f,z|f,c|z,c|f,a|e,z|z,b|e,c|z,z|f,a|d,b|d,z|e,z|z,b|e,a|f,b|d];
+a=1<<22; b=1<<29; c=a|b; d=1<<4; e=1<<14; f=d|e;
+const SP6 = [b|d,c|z,z|e,c|f,c|z,z|d,c|f,a|z,b|e,a|f,a|z,b|d,a|d,b|e,b|z,z|f,
+             z|z,a|d,b|f,z|e,a|e,b|f,z|d,c|d,c|d,z|z,a|f,c|e,z|f,a|e,c|e,b|z,
+             b|e,z|d,c|d,a|e,c|f,a|z,z|f,b|d,a|z,b|e,b|z,z|f,b|d,c|f,a|e,c|z,
+             a|f,c|e,z|z,c|d,z|d,z|e,c|z,a|f,z|e,a|d,b|f,z|z,c|e,b|z,a|d,b|f];
+a=1<<21; b=1<<26; c=a|b; d=1<<1; e=1<<11; f=d|e;
+const SP7 = [a|z,c|d,b|f,z|z,z|e,b|f,a|f,c|e,c|f,a|z,z|z,b|d,z|d,b|z,c|d,z|f,
+             b|e,a|f,a|d,b|e,b|d,c|z,c|e,a|d,c|z,z|e,z|f,c|f,a|e,z|d,b|z,a|e,
+             b|z,a|e,a|z,b|f,b|f,c|d,c|d,z|d,a|d,b|z,b|e,a|z,c|e,z|f,a|f,c|e,
+             z|f,b|d,c|f,c|z,a|e,z|z,z|d,c|f,z|z,a|f,c|z,z|e,b|d,b|e,z|e,a|d];
+a=1<<18; b=1<<28; c=a|b; d=1<<6; e=1<<12; f=d|e;
+const SP8 = [b|f,z|e,a|z,c|f,b|z,b|f,z|d,b|z,a|d,c|z,c|f,a|e,c|e,a|f,z|e,z|d,
+             c|z,b|d,b|e,z|f,a|e,a|d,c|d,c|e,z|f,z|z,z|z,c|d,b|d,b|e,a|f,a|z,
+             a|f,a|z,c|e,z|e,z|d,c|d,z|e,a|f,b|e,z|d,b|d,c|z,c|d,b|z,a|z,b|f,
+             z|z,c|f,a|d,b|d,c|z,b|e,b|f,z|z,c|f,a|e,a|e,z|f,z|f,a|d,b|z,c|e];
+
+/* eslint-enable comma-spacing */
+
+class DES {
+    constructor(password) {
+        this.keys = [];
+
+        // Set the key.
+        const pc1m = [], pcr = [], kn = [];
+
+        for (let j = 0, l = 56; j < 56; ++j, l -= 8) {
+            l += l < -5 ? 65 : l < -3 ? 31 : l < -1 ? 63 : l === 27 ? 35 : 0; // PC1
+            const m = l & 0x7;
+            pc1m[j] = ((password[l >>> 3] & (1<<m)) !== 0) ? 1: 0;
+        }
+
+        for (let i = 0; i < 16; ++i) {
+            const m = i << 1;
+            const n = m + 1;
+            kn[m] = kn[n] = 0;
+            for (let o = 28; o < 59; o += 28) {
+                for (let j = o - 28; j < o; ++j) {
+                    const l = j + totrot[i];
+                    pcr[j] = l < o ? pc1m[l] : pc1m[l - 28];
+                }
+            }
+            for (let j = 0; j < 24; ++j) {
+                if (pcr[PC2[j]] !== 0) {
+                    kn[m] |= 1 << (23 - j);
+                }
+                if (pcr[PC2[j + 24]] !== 0) {
+                    kn[n] |= 1 << (23 - j);
+                }
+            }
+        }
+
+        // cookey
+        for (let i = 0, rawi = 0, KnLi = 0; i < 16; ++i) {
+            const raw0 = kn[rawi++];
+            const raw1 = kn[rawi++];
+            this.keys[KnLi] = (raw0 & 0x00fc0000) << 6;
+            this.keys[KnLi] |= (raw0 & 0x00000fc0) << 10;
+            this.keys[KnLi] |= (raw1 & 0x00fc0000) >>> 10;
+            this.keys[KnLi] |= (raw1 & 0x00000fc0) >>> 6;
+            ++KnLi;
+            this.keys[KnLi] = (raw0 & 0x0003f000) << 12;
+            this.keys[KnLi] |= (raw0 & 0x0000003f) << 16;
+            this.keys[KnLi] |= (raw1 & 0x0003f000) >>> 4;
+            this.keys[KnLi] |= (raw1 & 0x0000003f);
+            ++KnLi;
+        }
+    }
+
+    // Encrypt 8 bytes of text
+    enc8(text) {
+        const b = text.slice();
+        let i = 0, l, r, x; // left, right, accumulator
+
+        // Squash 8 bytes to 2 ints
+        l = b[i++]<<24 | b[i++]<<16 | b[i++]<<8 | b[i++];
+        r = b[i++]<<24 | b[i++]<<16 | b[i++]<<8 | b[i++];
+
+        x = ((l >>> 4) ^ r) & 0x0f0f0f0f;
+        r ^= x;
+        l ^= (x << 4);
+        x = ((l >>> 16) ^ r) & 0x0000ffff;
+        r ^= x;
+        l ^= (x << 16);
+        x = ((r >>> 2) ^ l) & 0x33333333;
+        l ^= x;
+        r ^= (x << 2);
+        x = ((r >>> 8) ^ l) & 0x00ff00ff;
+        l ^= x;
+        r ^= (x << 8);
+        r = (r << 1) | ((r >>> 31) & 1);
+        x = (l ^ r) & 0xaaaaaaaa;
+        l ^= x;
+        r ^= x;
+        l = (l << 1) | ((l >>> 31) & 1);
+
+        for (let i = 0, keysi = 0; i < 8; ++i) {
+            x = (r << 28) | (r >>> 4);
+            x ^= this.keys[keysi++];
+            let fval =  SP7[x & 0x3f];
+            fval |= SP5[(x >>> 8) & 0x3f];
+            fval |= SP3[(x >>> 16) & 0x3f];
+            fval |= SP1[(x >>> 24) & 0x3f];
+            x = r ^ this.keys[keysi++];
+            fval |= SP8[x & 0x3f];
+            fval |= SP6[(x >>> 8) & 0x3f];
+            fval |= SP4[(x >>> 16) & 0x3f];
+            fval |= SP2[(x >>> 24) & 0x3f];
+            l ^= fval;
+            x = (l << 28) | (l >>> 4);
+            x ^= this.keys[keysi++];
+            fval =  SP7[x & 0x3f];
+            fval |= SP5[(x >>> 8) & 0x3f];
+            fval |= SP3[(x >>> 16) & 0x3f];
+            fval |= SP1[(x >>> 24) & 0x3f];
+            x = l ^ this.keys[keysi++];
+            fval |= SP8[x & 0x0000003f];
+            fval |= SP6[(x >>> 8) & 0x3f];
+            fval |= SP4[(x >>> 16) & 0x3f];
+            fval |= SP2[(x >>> 24) & 0x3f];
+            r ^= fval;
+        }
+
+        r = (r << 31) | (r >>> 1);
+        x = (l ^ r) & 0xaaaaaaaa;
+        l ^= x;
+        r ^= x;
+        l = (l << 31) | (l >>> 1);
+        x = ((l >>> 8) ^ r) & 0x00ff00ff;
+        r ^= x;
+        l ^= (x << 8);
+        x = ((l >>> 2) ^ r) & 0x33333333;
+        r ^= x;
+        l ^= (x << 2);
+        x = ((r >>> 16) ^ l) & 0x0000ffff;
+        l ^= x;
+        r ^= (x << 16);
+        x = ((r >>> 4) ^ l) & 0x0f0f0f0f;
+        l ^= x;
+        r ^= (x << 4);
+
+        // Spread ints to bytes
+        x = [r, l];
+        for (i = 0; i < 8; i++) {
+            b[i] = (x[i>>>2] >>> (8 * (3 - (i % 4)))) % 256;
+            if (b[i] < 0) { b[i] += 256; } // unsigned
+        }
+        return b;
+    }
+}
+
+export class DESECBCipher {
+    constructor() {
+        this._cipher = null;
+    }
+
+    get algorithm() {
+        return { name: "DES-ECB" };
+    }
+
+    static importKey(key, _algorithm, _extractable, _keyUsages) {
+        const cipher = new DESECBCipher;
+        cipher._importKey(key);
+        return cipher;
+    }
+
+    _importKey(key, _extractable, _keyUsages) {
+        this._cipher = new DES(key);
+    }
+
+    encrypt(_algorithm, plaintext) {
+        const x = new Uint8Array(plaintext);
+        if (x.length % 8 !== 0 || this._cipher === null) {
+            return null;
+        }
+        const n = x.length / 8;
+        for (let i = 0; i < n; i++) {
+            x.set(this._cipher.enc8(x.slice(i * 8, i * 8 + 8)), i * 8);
+        }
+        return x;
+    }
+}
+
+export class DESCBCCipher {
+    constructor() {
+        this._cipher = null;
+    }
+
+    get algorithm() {
+        return { name: "DES-CBC" };
+    }
+
+    static importKey(key, _algorithm, _extractable, _keyUsages) {
+        const cipher = new DESCBCCipher;
+        cipher._importKey(key);
+        return cipher;
+    }
+
+    _importKey(key) {
+        this._cipher = new DES(key);
+    }
+
+    encrypt(algorithm, plaintext) {
+        const x = new Uint8Array(plaintext);
+        let y = new Uint8Array(algorithm.iv);
+        if (x.length % 8 !== 0 || this._cipher === null) {
+            return null;
+        }
+        const n = x.length / 8;
+        for (let i = 0; i < n; i++) {
+            for (let j = 0; j < 8; j++) {
+                y[j] ^= plaintext[i * 8 + j];
+            }
+            y = this._cipher.enc8(y);
+            x.set(y, i * 8);
+        }
+        return x;
+    }
+}
pkg/web/noVNC/core/crypto/dh.js
@@ -0,0 +1,55 @@
+import { modPow, bigIntToU8Array, u8ArrayToBigInt } from "./bigint.js";
+
+class DHPublicKey {
+    constructor(key) {
+        this._key = key;
+    }
+
+    get algorithm() {
+        return { name: "DH" };
+    }
+
+    exportKey() {
+        return this._key;
+    }
+}
+
+export class DHCipher {
+    constructor() {
+        this._g = null;
+        this._p = null;
+        this._gBigInt = null;
+        this._pBigInt = null;
+        this._privateKey = null;
+    }
+
+    get algorithm() {
+        return { name: "DH" };
+    }
+
+    static generateKey(algorithm, _extractable) {
+        const cipher = new DHCipher;
+        cipher._generateKey(algorithm);
+        return { privateKey: cipher, publicKey: new DHPublicKey(cipher._publicKey) };
+    }
+
+    _generateKey(algorithm) {
+        const g = algorithm.g;
+        const p = algorithm.p;
+        this._keyBytes = p.length;
+        this._gBigInt = u8ArrayToBigInt(g);
+        this._pBigInt = u8ArrayToBigInt(p);
+        this._privateKey = window.crypto.getRandomValues(new Uint8Array(this._keyBytes));
+        this._privateKeyBigInt = u8ArrayToBigInt(this._privateKey);
+        this._publicKey = bigIntToU8Array(modPow(
+            this._gBigInt, this._privateKeyBigInt, this._pBigInt), this._keyBytes);
+    }
+
+    deriveBits(algorithm, length) {
+        const bytes = Math.ceil(length / 8);
+        const pkey = new Uint8Array(algorithm.public);
+        const len = bytes > this._keyBytes ? bytes : this._keyBytes;
+        const secret = modPow(u8ArrayToBigInt(pkey), this._privateKeyBigInt, this._pBigInt);
+        return bigIntToU8Array(secret, len).slice(0, len);
+    }
+}
pkg/web/noVNC/core/crypto/md5.js
@@ -0,0 +1,82 @@
+/*
+ * noVNC: HTML5 VNC client
+ * Copyright (C) 2021 The noVNC authors
+ * Licensed under MPL 2.0 (see LICENSE.txt)
+ *
+ * See README.md for usage and integration instructions.
+ */
+
+/*
+ * Performs MD5 hashing on an array of bytes, returns an array of bytes
+ */
+
+export async function MD5(d) {
+    let s = "";
+    for (let i = 0; i < d.length; i++) {
+        s += String.fromCharCode(d[i]);
+    }
+    return M(V(Y(X(s), 8 * s.length)));
+}
+
+function M(d) {
+    let f = new Uint8Array(d.length);
+    for (let i=0;i<d.length;i++) {
+        f[i] = d.charCodeAt(i);
+    }
+    return f;
+}
+
+function X(d) {
+    let r = Array(d.length >> 2);
+    for (let m = 0; m < r.length; m++) r[m] = 0;
+    for (let m = 0; m < 8 * d.length; m += 8) r[m >> 5] |= (255 & d.charCodeAt(m / 8)) << m % 32;
+    return r;
+}
+
+function V(d) {
+    let r = "";
+    for (let m = 0; m < 32 * d.length; m += 8) r += String.fromCharCode(d[m >> 5] >>> m % 32 & 255);
+    return r;
+}
+
+function Y(d, g) {
+    d[g >> 5] |= 128 << g % 32, d[14 + (g + 64 >>> 9 << 4)] = g;
+    let m = 1732584193, f = -271733879, r = -1732584194, i = 271733878;
+    for (let n = 0; n < d.length; n += 16) {
+        let h = m,
+            t = f,
+            g = r,
+            e = i;
+        f = ii(f = ii(f = ii(f = ii(f = hh(f = hh(f = hh(f = hh(f = gg(f = gg(f = gg(f = gg(f = ff(f = ff(f = ff(f = ff(f, r = ff(r, i = ff(i, m = ff(m, f, r, i, d[n + 0], 7, -680876936), f, r, d[n + 1], 12, -389564586), m, f, d[n + 2], 17, 606105819), i, m, d[n + 3], 22, -1044525330), r = ff(r, i = ff(i, m = ff(m, f, r, i, d[n + 4], 7, -176418897), f, r, d[n + 5], 12, 1200080426), m, f, d[n + 6], 17, -1473231341), i, m, d[n + 7], 22, -45705983), r = ff(r, i = ff(i, m = ff(m, f, r, i, d[n + 8], 7, 1770035416), f, r, d[n + 9], 12, -1958414417), m, f, d[n + 10], 17, -42063), i, m, d[n + 11], 22, -1990404162), r = ff(r, i = ff(i, m = ff(m, f, r, i, d[n + 12], 7, 1804603682), f, r, d[n + 13], 12, -40341101), m, f, d[n + 14], 17, -1502002290), i, m, d[n + 15], 22, 1236535329), r = gg(r, i = gg(i, m = gg(m, f, r, i, d[n + 1], 5, -165796510), f, r, d[n + 6], 9, -1069501632), m, f, d[n + 11], 14, 643717713), i, m, d[n + 0], 20, -373897302), r = gg(r, i = gg(i, m = gg(m, f, r, i, d[n + 5], 5, -701558691), f, r, d[n + 10], 9, 38016083), m, f, d[n + 15], 14, -660478335), i, m, d[n + 4], 20, -405537848), r = gg(r, i = gg(i, m = gg(m, f, r, i, d[n + 9], 5, 568446438), f, r, d[n + 14], 9, -1019803690), m, f, d[n + 3], 14, -187363961), i, m, d[n + 8], 20, 1163531501), r = gg(r, i = gg(i, m = gg(m, f, r, i, d[n + 13], 5, -1444681467), f, r, d[n + 2], 9, -51403784), m, f, d[n + 7], 14, 1735328473), i, m, d[n + 12], 20, -1926607734), r = hh(r, i = hh(i, m = hh(m, f, r, i, d[n + 5], 4, -378558), f, r, d[n + 8], 11, -2022574463), m, f, d[n + 11], 16, 1839030562), i, m, d[n + 14], 23, -35309556), r = hh(r, i = hh(i, m = hh(m, f, r, i, d[n + 1], 4, -1530992060), f, r, d[n + 4], 11, 1272893353), m, f, d[n + 7], 16, -155497632), i, m, d[n + 10], 23, -1094730640), r = hh(r, i = hh(i, m = hh(m, f, r, i, d[n + 13], 4, 681279174), f, r, d[n + 0], 11, -358537222), m, f, d[n + 3], 16, -722521979), i, m, d[n + 6], 23, 76029189), r = hh(r, i = hh(i, m = hh(m, f, r, i, d[n + 9], 4, -640364487), f, r, d[n + 12], 11, -421815835), m, f, d[n + 15], 16, 530742520), i, m, d[n + 2], 23, -995338651), r = ii(r, i = ii(i, m = ii(m, f, r, i, d[n + 0], 6, -198630844), f, r, d[n + 7], 10, 1126891415), m, f, d[n + 14], 15, -1416354905), i, m, d[n + 5], 21, -57434055), r = ii(r, i = ii(i, m = ii(m, f, r, i, d[n + 12], 6, 1700485571), f, r, d[n + 3], 10, -1894986606), m, f, d[n + 10], 15, -1051523), i, m, d[n + 1], 21, -2054922799), r = ii(r, i = ii(i, m = ii(m, f, r, i, d[n + 8], 6, 1873313359), f, r, d[n + 15], 10, -30611744), m, f, d[n + 6], 15, -1560198380), i, m, d[n + 13], 21, 1309151649), r = ii(r, i = ii(i, m = ii(m, f, r, i, d[n + 4], 6, -145523070), f, r, d[n + 11], 10, -1120210379), m, f, d[n + 2], 15, 718787259), i, m, d[n + 9], 21, -343485551), m = add(m, h), f = add(f, t), r = add(r, g), i = add(i, e);
+    }
+    return Array(m, f, r, i);
+}
+
+function cmn(d, g, m, f, r, i) {
+    return add(rol(add(add(g, d), add(f, i)), r), m);
+}
+
+function ff(d, g, m, f, r, i, n) {
+    return cmn(g & m | ~g & f, d, g, r, i, n);
+}
+
+function gg(d, g, m, f, r, i, n) {
+    return cmn(g & f | m & ~f, d, g, r, i, n);
+}
+
+function hh(d, g, m, f, r, i, n) {
+    return cmn(g ^ m ^ f, d, g, r, i, n);
+}
+
+function ii(d, g, m, f, r, i, n) {
+    return cmn(m ^ (g | ~f), d, g, r, i, n);
+}
+
+function add(d, g) {
+    let m = (65535 & d) + (65535 & g);
+    return (d >> 16) + (g >> 16) + (m >> 16) << 16 | 65535 & m;
+}
+
+function rol(d, g) {
+    return d << g | d >>> 32 - g;
+}
pkg/web/noVNC/core/crypto/rsa.js
@@ -0,0 +1,132 @@
+import Base64 from "../base64.js";
+import { modPow, bigIntToU8Array, u8ArrayToBigInt } from "./bigint.js";
+
+export class RSACipher {
+    constructor() {
+        this._keyLength = 0;
+        this._keyBytes = 0;
+        this._n = null;
+        this._e = null;
+        this._d = null;
+        this._nBigInt = null;
+        this._eBigInt = null;
+        this._dBigInt = null;
+        this._extractable = false;
+    }
+
+    get algorithm() {
+        return { name: "RSA-PKCS1-v1_5" };
+    }
+
+    _base64urlDecode(data) {
+        data = data.replace(/-/g, "+").replace(/_/g, "/");
+        data = data.padEnd(Math.ceil(data.length / 4) * 4, "=");
+        return Base64.decode(data);
+    }
+
+    _padArray(arr, length) {
+        const res = new Uint8Array(length);
+        res.set(arr, length - arr.length);
+        return res;
+    }
+
+    static async generateKey(algorithm, extractable, _keyUsages) {
+        const cipher = new RSACipher;
+        await cipher._generateKey(algorithm, extractable);
+        return { privateKey: cipher };
+    }
+
+    async _generateKey(algorithm, extractable) {
+        this._keyLength = algorithm.modulusLength;
+        this._keyBytes = Math.ceil(this._keyLength / 8);
+        const key = await window.crypto.subtle.generateKey(
+            {
+                name: "RSA-OAEP",
+                modulusLength: algorithm.modulusLength,
+                publicExponent: algorithm.publicExponent,
+                hash: {name: "SHA-256"},
+            },
+            true, ["encrypt", "decrypt"]);
+        const privateKey = await window.crypto.subtle.exportKey("jwk", key.privateKey);
+        this._n = this._padArray(this._base64urlDecode(privateKey.n), this._keyBytes);
+        this._nBigInt = u8ArrayToBigInt(this._n);
+        this._e = this._padArray(this._base64urlDecode(privateKey.e), this._keyBytes);
+        this._eBigInt = u8ArrayToBigInt(this._e);
+        this._d = this._padArray(this._base64urlDecode(privateKey.d), this._keyBytes);
+        this._dBigInt = u8ArrayToBigInt(this._d);
+        this._extractable = extractable;
+    }
+
+    static async importKey(key, _algorithm, extractable, keyUsages) {
+        if (keyUsages.length !== 1 || keyUsages[0] !== "encrypt") {
+            throw new Error("only support importing RSA public key");
+        }
+        const cipher = new RSACipher;
+        await cipher._importKey(key, extractable);
+        return cipher;
+    }
+
+    async _importKey(key, extractable) {
+        const n = key.n;
+        const e = key.e;
+        if (n.length !== e.length) {
+            throw new Error("the sizes of modulus and public exponent do not match");
+        }
+        this._keyBytes = n.length;
+        this._keyLength = this._keyBytes * 8;
+        this._n = new Uint8Array(this._keyBytes);
+        this._e = new Uint8Array(this._keyBytes);
+        this._n.set(n);
+        this._e.set(e);
+        this._nBigInt = u8ArrayToBigInt(this._n);
+        this._eBigInt = u8ArrayToBigInt(this._e);
+        this._extractable = extractable;
+    }
+
+    async encrypt(_algorithm, message) {
+        if (message.length > this._keyBytes - 11) {
+            return null;
+        }
+        const ps = new Uint8Array(this._keyBytes - message.length - 3);
+        window.crypto.getRandomValues(ps);
+        for (let i = 0; i < ps.length; i++) {
+            ps[i] = Math.floor(ps[i] * 254 / 255 + 1);
+        }
+        const em = new Uint8Array(this._keyBytes);
+        em[1] = 0x02;
+        em.set(ps, 2);
+        em.set(message, ps.length + 3);
+        const emBigInt = u8ArrayToBigInt(em);
+        const c = modPow(emBigInt, this._eBigInt, this._nBigInt);
+        return bigIntToU8Array(c, this._keyBytes);
+    }
+
+    async decrypt(_algorithm, message) {
+        if (message.length !== this._keyBytes) {
+            return null;
+        }
+        const msgBigInt = u8ArrayToBigInt(message);
+        const emBigInt = modPow(msgBigInt, this._dBigInt, this._nBigInt);
+        const em = bigIntToU8Array(emBigInt, this._keyBytes);
+        if (em[0] !== 0x00 || em[1] !== 0x02) {
+            return null;
+        }
+        let i = 2;
+        for (; i < em.length; i++) {
+            if (em[i] === 0x00) {
+                break;
+            }
+        }
+        if (i === em.length) {
+            return null;
+        }
+        return em.slice(i + 1, em.length);
+    }
+
+    async exportKey() {
+        if (!this._extractable) {
+            throw new Error("key is not extractable");
+        }
+        return { n: this._n, e: this._e, d: this._d };
+    }
+}
pkg/web/noVNC/core/decoders/copyrect.js
@@ -0,0 +1,27 @@
+/*
+ * noVNC: HTML5 VNC client
+ * Copyright (C) 2019 The noVNC authors
+ * Licensed under MPL 2.0 (see LICENSE.txt)
+ *
+ * See README.md for usage and integration instructions.
+ *
+ */
+
+export default class CopyRectDecoder {
+    decodeRect(x, y, width, height, sock, display, depth) {
+        if (sock.rQwait("COPYRECT", 4)) {
+            return false;
+        }
+
+        let deltaX = sock.rQshift16();
+        let deltaY = sock.rQshift16();
+
+        if ((width === 0) || (height === 0)) {
+            return true;
+        }
+
+        display.copyImage(deltaX, deltaY, x, y, width, height);
+
+        return true;
+    }
+}
pkg/web/noVNC/core/decoders/h264.js
@@ -0,0 +1,321 @@
+/*
+ * noVNC: HTML5 VNC client
+ * Copyright (C) 2024 The noVNC authors
+ * Licensed under MPL 2.0 (see LICENSE.txt)
+ *
+ * See README.md for usage and integration instructions.
+ *
+ */
+
+import * as Log from '../util/logging.js';
+
+export class H264Parser {
+    constructor(data) {
+        this._data = data;
+        this._index = 0;
+        this.profileIdc = null;
+        this.constraintSet = null;
+        this.levelIdc = null;
+    }
+
+    _getStartSequenceLen(index) {
+        let data = this._data;
+        if (data[index + 0] == 0 && data[index + 1] == 0 && data[index + 2] == 0 && data[index + 3] == 1) {
+            return 4;
+        }
+        if (data[index + 0] == 0 && data[index + 1] == 0 && data[index + 2] == 1) {
+            return 3;
+        }
+        return 0;
+    }
+
+    _indexOfNextNalUnit(index) {
+        let data = this._data;
+        for (let i = index; i < data.length; ++i) {
+            if (this._getStartSequenceLen(i) != 0) {
+                return i;
+            }
+        }
+        return -1;
+    }
+
+    _parseSps(index) {
+        this.profileIdc = this._data[index];
+        this.constraintSet = this._data[index + 1];
+        this.levelIdc = this._data[index + 2];
+    }
+
+    _parseNalUnit(index) {
+        const firstByte = this._data[index];
+        if (firstByte & 0x80) {
+            throw new Error('H264 parsing sanity check failed, forbidden zero bit is set');
+        }
+        const unitType = firstByte & 0x1f;
+
+        switch (unitType) {
+            case 1: // coded slice, non-idr
+                return { slice: true };
+            case 5: // coded slice, idr
+                return { slice: true, key: true };
+            case 6: // sei
+                return {};
+            case 7: // sps
+                this._parseSps(index + 1);
+                return {};
+            case 8: // pps
+                return {};
+            default:
+                Log.Warn("Unhandled unit type: ", unitType);
+                break;
+        }
+        return {};
+    }
+
+    parse() {
+        const startIndex = this._index;
+        let isKey = false;
+
+        while (this._index < this._data.length) {
+            const startSequenceLen = this._getStartSequenceLen(this._index);
+            if (startSequenceLen == 0) {
+                throw new Error('Invalid start sequence in bit stream');
+            }
+
+            const { slice, key } = this._parseNalUnit(this._index + startSequenceLen);
+
+            let nextIndex = this._indexOfNextNalUnit(this._index + startSequenceLen);
+            if (nextIndex == -1) {
+                this._index = this._data.length;
+            } else {
+                this._index = nextIndex;
+            }
+
+            if (key) {
+                isKey = true;
+            }
+            if (slice) {
+                break;
+            }
+        }
+
+        if (startIndex === this._index) {
+            return null;
+        }
+
+        return {
+            frame: this._data.subarray(startIndex, this._index),
+            key: isKey,
+        };
+    }
+}
+
+export class H264Context {
+    constructor(width, height) {
+        this.lastUsed = 0;
+        this._width = width;
+        this._height = height;
+        this._profileIdc = null;
+        this._constraintSet = null;
+        this._levelIdc = null;
+        this._decoder = null;
+        this._pendingFrames = [];
+    }
+
+    _handleFrame(frame) {
+        let pending = this._pendingFrames.shift();
+        if (pending === undefined) {
+            throw new Error("Pending frame queue empty when receiving frame from decoder");
+        }
+
+        if (pending.timestamp != frame.timestamp) {
+            throw new Error("Video frame timestamp mismatch. Expected " +
+                frame.timestamp + " but but got " + pending.timestamp);
+        }
+
+        pending.frame = frame;
+        pending.ready = true;
+        pending.resolve();
+
+        if (!pending.keep) {
+            frame.close();
+        }
+    }
+
+    _handleError(e) {
+        throw new Error("Failed to decode frame: " + e.message);
+    }
+
+    _configureDecoder(profileIdc, constraintSet, levelIdc) {
+        if (this._decoder === null || this._decoder.state === 'closed') {
+            this._decoder = new VideoDecoder({
+                output: frame => this._handleFrame(frame),
+                error: e => this._handleError(e),
+            });
+        }
+        const codec = 'avc1.' +
+            profileIdc.toString(16).padStart(2, '0') +
+            constraintSet.toString(16).padStart(2, '0') +
+            levelIdc.toString(16).padStart(2, '0');
+        this._decoder.configure({
+            codec: codec,
+            codedWidth: this._width,
+            codedHeight: this._height,
+            optimizeForLatency: true,
+        });
+    }
+
+    _preparePendingFrame(timestamp) {
+        let pending = {
+            timestamp: timestamp,
+            promise: null,
+            resolve: null,
+            frame: null,
+            ready: false,
+            keep: false,
+        };
+        pending.promise = new Promise((resolve) => {
+            pending.resolve = resolve;
+        });
+        this._pendingFrames.push(pending);
+
+        return pending;
+    }
+
+    decode(payload) {
+        let parser = new H264Parser(payload);
+        let result = null;
+
+        // Ideally, this timestamp should come from the server, but we'll just
+        // approximate it instead.
+        let timestamp = Math.round(window.performance.now() * 1e3);
+
+        while (true) {
+            let encodedFrame = parser.parse();
+            if (encodedFrame === null) {
+                break;
+            }
+
+            if (parser.profileIdc !== null) {
+                self._profileIdc = parser.profileIdc;
+                self._constraintSet = parser.constraintSet;
+                self._levelIdc = parser.levelIdc;
+            }
+
+            if (this._decoder === null || this._decoder.state !== 'configured') {
+                if (!encodedFrame.key) {
+                    Log.Warn("Missing key frame. Can't decode until one arrives");
+                    continue;
+                }
+                if (self._profileIdc === null) {
+                    Log.Warn('Cannot config decoder. Have not received SPS and PPS yet.');
+                    continue;
+                }
+                this._configureDecoder(self._profileIdc, self._constraintSet,
+                                       self._levelIdc);
+            }
+
+            result = this._preparePendingFrame(timestamp);
+
+            const chunk = new EncodedVideoChunk({
+                timestamp: timestamp,
+                type: encodedFrame.key ? 'key' : 'delta',
+                data: encodedFrame.frame,
+            });
+
+            try {
+                this._decoder.decode(chunk);
+            } catch (e) {
+                Log.Warn("Failed to decode:", e);
+            }
+        }
+
+        // We only keep last frame of each payload
+        if (result !== null) {
+            result.keep = true;
+        }
+
+        return result;
+    }
+}
+
+export default class H264Decoder {
+    constructor() {
+        this._tick = 0;
+        this._contexts = {};
+    }
+
+    _contextId(x, y, width, height) {
+        return [x, y, width, height].join(',');
+    }
+
+    _findOldestContextId() {
+        let oldestTick = Number.MAX_VALUE;
+        let oldestKey = undefined;
+        for (const [key, value] of Object.entries(this._contexts)) {
+            if (value.lastUsed < oldestTick) {
+                oldestTick = value.lastUsed;
+                oldestKey = key;
+            }
+        }
+        return oldestKey;
+    }
+
+    _createContext(x, y, width, height) {
+        const maxContexts = 64;
+        if (Object.keys(this._contexts).length >= maxContexts) {
+            let oldestContextId = this._findOldestContextId();
+            delete this._contexts[oldestContextId];
+        }
+        let context = new H264Context(width, height);
+        this._contexts[this._contextId(x, y, width, height)] = context;
+        return context;
+    }
+
+    _getContext(x, y, width, height) {
+        let context = this._contexts[this._contextId(x, y, width, height)];
+        return context !== undefined ? context : this._createContext(x, y, width, height);
+    }
+
+    _resetContext(x, y, width, height) {
+        delete this._contexts[this._contextId(x, y, width, height)];
+    }
+
+    _resetAllContexts() {
+        this._contexts = {};
+    }
+
+    decodeRect(x, y, width, height, sock, display, depth) {
+        const resetContextFlag = 1;
+        const resetAllContextsFlag = 2;
+
+        if (sock.rQwait("h264 header", 8)) {
+            return false;
+        }
+
+        const length = sock.rQshift32();
+        const flags = sock.rQshift32();
+
+        if (sock.rQwait("h264 payload", length, 8)) {
+            return false;
+        }
+
+        if (flags & resetAllContextsFlag) {
+            this._resetAllContexts();
+        } else if (flags & resetContextFlag) {
+            this._resetContext(x, y, width, height);
+        }
+
+        let context = this._getContext(x, y, width, height);
+        context.lastUsed = this._tick++;
+
+        if (length !== 0) {
+            let payload = sock.rQshiftBytes(length, false);
+            let frame = context.decode(payload);
+            if (frame !== null) {
+                display.videoFrame(x, y, width, height, frame);
+            }
+        }
+
+        return true;
+    }
+}
pkg/web/noVNC/core/decoders/hextile.js
@@ -0,0 +1,181 @@
+/*
+ * noVNC: HTML5 VNC client
+ * Copyright (C) 2019 The noVNC authors
+ * Licensed under MPL 2.0 (see LICENSE.txt)
+ *
+ * See README.md for usage and integration instructions.
+ *
+ */
+
+import * as Log from '../util/logging.js';
+
+export default class HextileDecoder {
+    constructor() {
+        this._tiles = 0;
+        this._lastsubencoding = 0;
+        this._tileBuffer = new Uint8Array(16 * 16 * 4);
+    }
+
+    decodeRect(x, y, width, height, sock, display, depth) {
+        if (this._tiles === 0) {
+            this._tilesX = Math.ceil(width / 16);
+            this._tilesY = Math.ceil(height / 16);
+            this._totalTiles = this._tilesX * this._tilesY;
+            this._tiles = this._totalTiles;
+        }
+
+        while (this._tiles > 0) {
+            let bytes = 1;
+
+            if (sock.rQwait("HEXTILE", bytes)) {
+                return false;
+            }
+
+            let subencoding = sock.rQpeek8();
+            if (subencoding > 30) {  // Raw
+                throw new Error("Illegal hextile subencoding (subencoding: " +
+                            subencoding + ")");
+            }
+
+            const currTile = this._totalTiles - this._tiles;
+            const tileX = currTile % this._tilesX;
+            const tileY = Math.floor(currTile / this._tilesX);
+            const tx = x + tileX * 16;
+            const ty = y + tileY * 16;
+            const tw = Math.min(16, (x + width) - tx);
+            const th = Math.min(16, (y + height) - ty);
+
+            // Figure out how much we are expecting
+            if (subencoding & 0x01) {  // Raw
+                bytes += tw * th * 4;
+            } else {
+                if (subencoding & 0x02) {  // Background
+                    bytes += 4;
+                }
+                if (subencoding & 0x04) {  // Foreground
+                    bytes += 4;
+                }
+                if (subencoding & 0x08) {  // AnySubrects
+                    bytes++;  // Since we aren't shifting it off
+
+                    if (sock.rQwait("HEXTILE", bytes)) {
+                        return false;
+                    }
+
+                    let subrects = sock.rQpeekBytes(bytes).at(-1);
+                    if (subencoding & 0x10) {  // SubrectsColoured
+                        bytes += subrects * (4 + 2);
+                    } else {
+                        bytes += subrects * 2;
+                    }
+                }
+            }
+
+            if (sock.rQwait("HEXTILE", bytes)) {
+                return false;
+            }
+
+            // We know the encoding and have a whole tile
+            sock.rQshift8();
+            if (subencoding === 0) {
+                if (this._lastsubencoding & 0x01) {
+                    // Weird: ignore blanks are RAW
+                    Log.Debug("     Ignoring blank after RAW");
+                } else {
+                    display.fillRect(tx, ty, tw, th, this._background);
+                }
+            } else if (subencoding & 0x01) {  // Raw
+                let pixels = tw * th;
+                let data = sock.rQshiftBytes(pixels * 4, false);
+                // Max sure the image is fully opaque
+                for (let i = 0;i <  pixels;i++) {
+                    data[i * 4 + 3] = 255;
+                }
+                display.blitImage(tx, ty, tw, th, data, 0);
+            } else {
+                if (subencoding & 0x02) {  // Background
+                    this._background = new Uint8Array(sock.rQshiftBytes(4));
+                }
+                if (subencoding & 0x04) {  // Foreground
+                    this._foreground = new Uint8Array(sock.rQshiftBytes(4));
+                }
+
+                this._startTile(tx, ty, tw, th, this._background);
+                if (subencoding & 0x08) {  // AnySubrects
+                    let subrects = sock.rQshift8();
+
+                    for (let s = 0; s < subrects; s++) {
+                        let color;
+                        if (subencoding & 0x10) {  // SubrectsColoured
+                            color = sock.rQshiftBytes(4);
+                        } else {
+                            color = this._foreground;
+                        }
+                        const xy = sock.rQshift8();
+                        const sx = (xy >> 4);
+                        const sy = (xy & 0x0f);
+
+                        const wh = sock.rQshift8();
+                        const sw = (wh >> 4) + 1;
+                        const sh = (wh & 0x0f) + 1;
+
+                        this._subTile(sx, sy, sw, sh, color);
+                    }
+                }
+                this._finishTile(display);
+            }
+            this._lastsubencoding = subencoding;
+            this._tiles--;
+        }
+
+        return true;
+    }
+
+    // start updating a tile
+    _startTile(x, y, width, height, color) {
+        this._tileX = x;
+        this._tileY = y;
+        this._tileW = width;
+        this._tileH = height;
+
+        const red = color[0];
+        const green = color[1];
+        const blue = color[2];
+
+        const data = this._tileBuffer;
+        for (let i = 0; i < width * height * 4; i += 4) {
+            data[i]     = red;
+            data[i + 1] = green;
+            data[i + 2] = blue;
+            data[i + 3] = 255;
+        }
+    }
+
+    // update sub-rectangle of the current tile
+    _subTile(x, y, w, h, color) {
+        const red = color[0];
+        const green = color[1];
+        const blue = color[2];
+        const xend = x + w;
+        const yend = y + h;
+
+        const data = this._tileBuffer;
+        const width = this._tileW;
+        for (let j = y; j < yend; j++) {
+            for (let i = x; i < xend; i++) {
+                const p = (i + (j * width)) * 4;
+                data[p]     = red;
+                data[p + 1] = green;
+                data[p + 2] = blue;
+                data[p + 3] = 255;
+            }
+        }
+    }
+
+    // draw the current tile to the screen
+    _finishTile(display) {
+        display.blitImage(this._tileX, this._tileY,
+                          this._tileW, this._tileH,
+                          this._tileBuffer, 0);
+    }
+}
pkg/web/noVNC/core/decoders/jpeg.js
@@ -0,0 +1,161 @@
+/*
+ * noVNC: HTML5 VNC client
+ * Copyright (C) 2019 The noVNC authors
+ * Licensed under MPL 2.0 (see LICENSE.txt)
+ *
+ * See README.md for usage and integration instructions.
+ *
+ */
+
+export default class JPEGDecoder {
+    constructor() {
+        // RealVNC will reuse the quantization tables
+        // and Huffman tables, so we need to cache them.
+        this._cachedQuantTables = [];
+        this._cachedHuffmanTables = [];
+
+        this._segments = [];
+    }
+
+    decodeRect(x, y, width, height, sock, display, depth) {
+        // A rect of JPEG encodings is simply a JPEG file
+        while (true) {
+            let segment = this._readSegment(sock);
+            if (segment === null) {
+                return false;
+            }
+            this._segments.push(segment);
+            // End of image?
+            if (segment[1] === 0xD9) {
+                break;
+            }
+        }
+
+        let huffmanTables = [];
+        let quantTables = [];
+        for (let segment of this._segments) {
+            let type = segment[1];
+            if (type === 0xC4) {
+                // Huffman tables
+                huffmanTables.push(segment);
+            } else if (type === 0xDB) {
+                // Quantization tables
+                quantTables.push(segment);
+            }
+        }
+
+        const sofIndex = this._segments.findIndex(
+            x => x[1] == 0xC0 || x[1] == 0xC2
+        );
+        if (sofIndex == -1) {
+            throw new Error("Illegal JPEG image without SOF");
+        }
+
+        if (quantTables.length === 0) {
+            this._segments.splice(sofIndex+1, 0,
+                                  ...this._cachedQuantTables);
+        }
+        if (huffmanTables.length === 0) {
+            this._segments.splice(sofIndex+1, 0,
+                                  ...this._cachedHuffmanTables);
+        }
+
+        let length = 0;
+        for (let segment of this._segments) {
+            length += segment.length;
+        }
+
+        let data = new Uint8Array(length);
+        length = 0;
+        for (let segment of this._segments) {
+            data.set(segment, length);
+            length += segment.length;
+        }
+
+        display.imageRect(x, y, width, height, "image/jpeg", data);
+
+        if (huffmanTables.length !== 0) {
+            this._cachedHuffmanTables = huffmanTables;
+        }
+        if (quantTables.length !== 0) {
+            this._cachedQuantTables = quantTables;
+        }
+
+        this._segments = [];
+
+        return true;
+    }
+
+    _readSegment(sock) {
+        if (sock.rQwait("JPEG", 2)) {
+            return null;
+        }
+
+        let marker = sock.rQshift8();
+        if (marker != 0xFF) {
+            throw new Error("Illegal JPEG marker received (byte: " +
+                               marker + ")");
+        }
+        let type = sock.rQshift8();
+        if (type >= 0xD0 && type <= 0xD9 || type == 0x01) {
+            // No length after marker
+            return new Uint8Array([marker, type]);
+        }
+
+        if (sock.rQwait("JPEG", 2, 2)) {
+            return null;
+        }
+
+        let length = sock.rQshift16();
+        if (length < 2) {
+            throw new Error("Illegal JPEG length received (length: " +
+                               length + ")");
+        }
+
+        if (sock.rQwait("JPEG", length-2, 4)) {
+            return null;
+        }
+
+        let extra = 0;
+        if (type === 0xDA) {
+            // start of scan
+            if (sock.rQwait("JPEG", length-2 + 2, 4)) {
+                return null;
+            }
+
+            let len = sock.rQlen();
+            let data = sock.rQpeekBytes(len, false);
+
+            while (true) {
+                let idx = data.indexOf(0xFF, length-2+extra);
+                if (idx === -1) {
+                    sock.rQwait("JPEG", Infinity, 4);
+                    return null;
+                }
+
+                if (idx === len-1) {
+                    sock.rQwait("JPEG", Infinity, 4);
+                    return null;
+                }
+
+                if (data.at(idx+1) === 0x00 ||
+                    (data.at(idx+1) >= 0xD0 && data.at(idx+1) <= 0xD7)) {
+                    extra = idx+2 - (length-2);
+                    continue;
+                }
+
+                extra = idx - (length-2);
+                break;
+            }
+        }
+
+        let segment = new Uint8Array(2 + length + extra);
+        segment[0] = marker;
+        segment[1] = type;
+        segment[2] = length >> 8;
+        segment[3] = length;
+        segment.set(sock.rQshiftBytes(length-2+extra, false), 4);
+
+        return segment;
+    }
+}
pkg/web/noVNC/core/decoders/raw.js
@@ -0,0 +1,59 @@
+/*
+ * noVNC: HTML5 VNC client
+ * Copyright (C) 2019 The noVNC authors
+ * Licensed under MPL 2.0 (see LICENSE.txt)
+ *
+ * See README.md for usage and integration instructions.
+ *
+ */
+
+export default class RawDecoder {
+    constructor() {
+        this._lines = 0;
+    }
+
+    decodeRect(x, y, width, height, sock, display, depth) {
+        if ((width === 0) || (height === 0)) {
+            return true;
+        }
+
+        if (this._lines === 0) {
+            this._lines = height;
+        }
+
+        const pixelSize = depth == 8 ? 1 : 4;
+        const bytesPerLine = width * pixelSize;
+
+        while (this._lines > 0) {
+            if (sock.rQwait("RAW", bytesPerLine)) {
+                return false;
+            }
+
+            const curY = y + (height - this._lines);
+
+            let data = sock.rQshiftBytes(bytesPerLine, false);
+
+            // Convert data if needed
+            if (depth == 8) {
+                const newdata = new Uint8Array(width * 4);
+                for (let i = 0; i < width; i++) {
+                    newdata[i * 4 + 0] = ((data[i] >> 0) & 0x3) * 255 / 3;
+                    newdata[i * 4 + 1] = ((data[i] >> 2) & 0x3) * 255 / 3;
+                    newdata[i * 4 + 2] = ((data[i] >> 4) & 0x3) * 255 / 3;
+                    newdata[i * 4 + 3] = 255;
+                }
+                data = newdata;
+            }
+
+            // Max sure the image is fully opaque
+            for (let i = 0; i < width; i++) {
+                data[i * 4 + 3] = 255;
+            }
+
+            display.blitImage(x, curY, width, 1, data, 0);
+            this._lines--;
+        }
+
+        return true;
+    }
+}
pkg/web/noVNC/core/decoders/rre.js
@@ -0,0 +1,44 @@
+/*
+ * noVNC: HTML5 VNC client
+ * Copyright (C) 2019 The noVNC authors
+ * Licensed under MPL 2.0 (see LICENSE.txt)
+ *
+ * See README.md for usage and integration instructions.
+ *
+ */
+
+export default class RREDecoder {
+    constructor() {
+        this._subrects = 0;
+    }
+
+    decodeRect(x, y, width, height, sock, display, depth) {
+        if (this._subrects === 0) {
+            if (sock.rQwait("RRE", 4 + 4)) {
+                return false;
+            }
+
+            this._subrects = sock.rQshift32();
+
+            let color = sock.rQshiftBytes(4);  // Background
+            display.fillRect(x, y, width, height, color);
+        }
+
+        while (this._subrects > 0) {
+            if (sock.rQwait("RRE", 4 + 8)) {
+                return false;
+            }
+
+            let color = sock.rQshiftBytes(4);
+            let sx = sock.rQshift16();
+            let sy = sock.rQshift16();
+            let swidth = sock.rQshift16();
+            let sheight = sock.rQshift16();
+            display.fillRect(x + sx, y + sy, swidth, sheight, color);
+
+            this._subrects--;
+        }
+
+        return true;
+    }
+}
pkg/web/noVNC/core/decoders/tight.js
@@ -0,0 +1,393 @@
+/*
+ * noVNC: HTML5 VNC client
+ * Copyright (C) 2019 The noVNC authors
+ * (c) 2012 Michael Tinglof, Joe Balaz, Les Piech (Mercuri.ca)
+ * Licensed under MPL 2.0 (see LICENSE.txt)
+ *
+ * See README.md for usage and integration instructions.
+ *
+ */
+
+import * as Log from '../util/logging.js';
+import Inflator from "../inflator.js";
+
+export default class TightDecoder {
+    constructor() {
+        this._ctl = null;
+        this._filter = null;
+        this._numColors = 0;
+        this._palette = new Uint8Array(1024);  // 256 * 4 (max palette size * max bytes-per-pixel)
+        this._len = 0;
+
+        this._zlibs = [];
+        for (let i = 0; i < 4; i++) {
+            this._zlibs[i] = new Inflator();
+        }
+    }
+
+    decodeRect(x, y, width, height, sock, display, depth) {
+        if (this._ctl === null) {
+            if (sock.rQwait("TIGHT compression-control", 1)) {
+                return false;
+            }
+
+            this._ctl = sock.rQshift8();
+
+            // Reset streams if the server requests it
+            for (let i = 0; i < 4; i++) {
+                if ((this._ctl >> i) & 1) {
+                    this._zlibs[i].reset();
+                    Log.Info("Reset zlib stream " + i);
+                }
+            }
+
+            // Figure out filter
+            this._ctl = this._ctl >> 4;
+        }
+
+        let ret;
+
+        if (this._ctl === 0x08) {
+            ret = this._fillRect(x, y, width, height,
+                                 sock, display, depth);
+        } else if (this._ctl === 0x09) {
+            ret = this._jpegRect(x, y, width, height,
+                                 sock, display, depth);
+        } else if (this._ctl === 0x0A) {
+            ret = this._pngRect(x, y, width, height,
+                                sock, display, depth);
+        } else if ((this._ctl & 0x08) == 0) {
+            ret = this._basicRect(this._ctl, x, y, width, height,
+                                  sock, display, depth);
+        } else {
+            throw new Error("Illegal tight compression received (ctl: " +
+                                   this._ctl + ")");
+        }
+
+        if (ret) {
+            this._ctl = null;
+        }
+
+        return ret;
+    }
+
+    _fillRect(x, y, width, height, sock, display, depth) {
+        if (sock.rQwait("TIGHT", 3)) {
+            return false;
+        }
+
+        let pixel = sock.rQshiftBytes(3);
+        display.fillRect(x, y, width, height, pixel, false);
+
+        return true;
+    }
+
+    _jpegRect(x, y, width, height, sock, display, depth) {
+        let data = this._readData(sock);
+        if (data === null) {
+            return false;
+        }
+
+        display.imageRect(x, y, width, height, "image/jpeg", data);
+
+        return true;
+    }
+
+    _pngRect(x, y, width, height, sock, display, depth) {
+        throw new Error("PNG received in standard Tight rect");
+    }
+
+    _basicRect(ctl, x, y, width, height, sock, display, depth) {
+        if (this._filter === null) {
+            if (ctl & 0x4) {
+                if (sock.rQwait("TIGHT", 1)) {
+                    return false;
+                }
+
+                this._filter = sock.rQshift8();
+            } else {
+                // Implicit CopyFilter
+                this._filter = 0;
+            }
+        }
+
+        let streamId = ctl & 0x3;
+
+        let ret;
+
+        switch (this._filter) {
+            case 0: // CopyFilter
+                ret = this._copyFilter(streamId, x, y, width, height,
+                                       sock, display, depth);
+                break;
+            case 1: // PaletteFilter
+                ret = this._paletteFilter(streamId, x, y, width, height,
+                                          sock, display, depth);
+                break;
+            case 2: // GradientFilter
+                ret = this._gradientFilter(streamId, x, y, width, height,
+                                           sock, display, depth);
+                break;
+            default:
+                throw new Error("Illegal tight filter received (ctl: " +
+                                       this._filter + ")");
+        }
+
+        if (ret) {
+            this._filter = null;
+        }
+
+        return ret;
+    }
+
+    _copyFilter(streamId, x, y, width, height, sock, display, depth) {
+        const uncompressedSize = width * height * 3;
+        let data;
+
+        if (uncompressedSize === 0) {
+            return true;
+        }
+
+        if (uncompressedSize < 12) {
+            if (sock.rQwait("TIGHT", uncompressedSize)) {
+                return false;
+            }
+
+            data = sock.rQshiftBytes(uncompressedSize);
+        } else {
+            data = this._readData(sock);
+            if (data === null) {
+                return false;
+            }
+
+            this._zlibs[streamId].setInput(data);
+            data = this._zlibs[streamId].inflate(uncompressedSize);
+            this._zlibs[streamId].setInput(null);
+        }
+
+        let rgbx = new Uint8Array(width * height * 4);
+        for (let i = 0, j = 0; i < width * height * 4; i += 4, j += 3) {
+            rgbx[i]     = data[j];
+            rgbx[i + 1] = data[j + 1];
+            rgbx[i + 2] = data[j + 2];
+            rgbx[i + 3] = 255;  // Alpha
+        }
+
+        display.blitImage(x, y, width, height, rgbx, 0, false);
+
+        return true;
+    }
+
+    _paletteFilter(streamId, x, y, width, height, sock, display, depth) {
+        if (this._numColors === 0) {
+            if (sock.rQwait("TIGHT palette", 1)) {
+                return false;
+            }
+
+            const numColors = sock.rQpeek8() + 1;
+            const paletteSize = numColors * 3;
+
+            if (sock.rQwait("TIGHT palette", 1 + paletteSize)) {
+                return false;
+            }
+
+            this._numColors = numColors;
+            sock.rQskipBytes(1);
+
+            sock.rQshiftTo(this._palette, paletteSize);
+        }
+
+        const bpp = (this._numColors <= 2) ? 1 : 8;
+        const rowSize = Math.floor((width * bpp + 7) / 8);
+        const uncompressedSize = rowSize * height;
+
+        let data;
+
+        if (uncompressedSize === 0) {
+            return true;
+        }
+
+        if (uncompressedSize < 12) {
+            if (sock.rQwait("TIGHT", uncompressedSize)) {
+                return false;
+            }
+
+            data = sock.rQshiftBytes(uncompressedSize);
+        } else {
+            data = this._readData(sock);
+            if (data === null) {
+                return false;
+            }
+
+            this._zlibs[streamId].setInput(data);
+            data = this._zlibs[streamId].inflate(uncompressedSize);
+            this._zlibs[streamId].setInput(null);
+        }
+
+        // Convert indexed (palette based) image data to RGB
+        if (this._numColors == 2) {
+            this._monoRect(x, y, width, height, data, this._palette, display);
+        } else {
+            this._paletteRect(x, y, width, height, data, this._palette, display);
+        }
+
+        this._numColors = 0;
+
+        return true;
+    }
+
+    _monoRect(x, y, width, height, data, palette, display) {
+        // Convert indexed (palette based) image data to RGB
+        // TODO: reduce number of calculations inside loop
+        const dest = this._getScratchBuffer(width * height * 4);
+        const w = Math.floor((width + 7) / 8);
+        const w1 = Math.floor(width / 8);
+
+        for (let y = 0; y < height; y++) {
+            let dp, sp, x;
+            for (x = 0; x < w1; x++) {
+                for (let b = 7; b >= 0; b--) {
+                    dp = (y * width + x * 8 + 7 - b) * 4;
+                    sp = (data[y * w + x] >> b & 1) * 3;
+                    dest[dp]     = palette[sp];
+                    dest[dp + 1] = palette[sp + 1];
+                    dest[dp + 2] = palette[sp + 2];
+                    dest[dp + 3] = 255;
+                }
+            }
+
+            for (let b = 7; b >= 8 - width % 8; b--) {
+                dp = (y * width + x * 8 + 7 - b) * 4;
+                sp = (data[y * w + x] >> b & 1) * 3;
+                dest[dp]     = palette[sp];
+                dest[dp + 1] = palette[sp + 1];
+                dest[dp + 2] = palette[sp + 2];
+                dest[dp + 3] = 255;
+            }
+        }
+
+        display.blitImage(x, y, width, height, dest, 0, false);
+    }
+
+    _paletteRect(x, y, width, height, data, palette, display) {
+        // Convert indexed (palette based) image data to RGB
+        const dest = this._getScratchBuffer(width * height * 4);
+        const total = width * height * 4;
+        for (let i = 0, j = 0; i < total; i += 4, j++) {
+            const sp = data[j] * 3;
+            dest[i]     = palette[sp];
+            dest[i + 1] = palette[sp + 1];
+            dest[i + 2] = palette[sp + 2];
+            dest[i + 3] = 255;
+        }
+
+        display.blitImage(x, y, width, height, dest, 0, false);
+    }
+
+    _gradientFilter(streamId, x, y, width, height, sock, display, depth) {
+        // assume the TPIXEL is 3 bytes long
+        const uncompressedSize = width * height * 3;
+        let data;
+
+        if (uncompressedSize === 0) {
+            return true;
+        }
+
+        if (uncompressedSize < 12) {
+            if (sock.rQwait("TIGHT", uncompressedSize)) {
+                return false;
+            }
+
+            data = sock.rQshiftBytes(uncompressedSize);
+        } else {
+            data = this._readData(sock);
+            if (data === null) {
+                return false;
+            }
+
+            this._zlibs[streamId].setInput(data);
+            data = this._zlibs[streamId].inflate(uncompressedSize);
+            this._zlibs[streamId].setInput(null);
+        }
+
+        let rgbx = new Uint8Array(4 * width * height);
+
+        let rgbxIndex = 0, dataIndex = 0;
+        let left = new Uint8Array(3);
+        for (let x = 0; x < width; x++) {
+            for (let c = 0; c < 3; c++) {
+                const prediction = left[c];
+                const value = data[dataIndex++] + prediction;
+                rgbx[rgbxIndex++] = value;
+                left[c] = value;
+            }
+            rgbx[rgbxIndex++] = 255;
+        }
+
+        let upperIndex = 0;
+        let upper = new Uint8Array(3),
+            upperleft = new Uint8Array(3);
+        for (let y = 1; y < height; y++) {
+            left.fill(0);
+            upperleft.fill(0);
+            for (let x = 0; x < width; x++) {
+                for (let c = 0; c < 3; c++) {
+                    upper[c] = rgbx[upperIndex++];
+                    let prediction = left[c] + upper[c] - upperleft[c];
+                    if (prediction < 0) {
+                        prediction = 0;
+                    } else if (prediction > 255) {
+                        prediction = 255;
+                    }
+                    const value = data[dataIndex++] + prediction;
+                    rgbx[rgbxIndex++] = value;
+                    upperleft[c] = upper[c];
+                    left[c] = value;
+                }
+                rgbx[rgbxIndex++] = 255;
+                upperIndex++;
+            }
+        }
+
+        display.blitImage(x, y, width, height, rgbx, 0, false);
+
+        return true;
+    }
+
+    _readData(sock) {
+        if (this._len === 0) {
+            if (sock.rQwait("TIGHT", 3)) {
+                return null;
+            }
+
+            let byte;
+
+            byte = sock.rQshift8();
+            this._len = byte & 0x7f;
+            if (byte & 0x80) {
+                byte = sock.rQshift8();
+                this._len |= (byte & 0x7f) << 7;
+                if (byte & 0x80) {
+                    byte = sock.rQshift8();
+                    this._len |= byte << 14;
+                }
+            }
+        }
+
+        if (sock.rQwait("TIGHT", this._len)) {
+            return null;
+        }
+
+        let data = sock.rQshiftBytes(this._len, false);
+        this._len = 0;
+
+        return data;
+    }
+
+    _getScratchBuffer(size) {
+        if (!this._scratchBuffer || (this._scratchBuffer.length < size)) {
+            this._scratchBuffer = new Uint8Array(size);
+        }
+        return this._scratchBuffer;
+    }
+}
pkg/web/noVNC/core/decoders/tightpng.js
@@ -0,0 +1,27 @@
+/*
+ * noVNC: HTML5 VNC client
+ * Copyright (C) 2019 The noVNC authors
+ * Licensed under MPL 2.0 (see LICENSE.txt)
+ *
+ * See README.md for usage and integration instructions.
+ *
+ */
+
+import TightDecoder from './tight.js';
+
+export default class TightPNGDecoder extends TightDecoder {
+    _pngRect(x, y, width, height, sock, display, depth) {
+        let data = this._readData(sock);
+        if (data === null) {
+            return false;
+        }
+
+        display.imageRect(x, y, width, height, "image/png", data);
+
+        return true;
+    }
+
+    _basicRect(ctl, x, y, width, height, sock, display, depth) {
+        throw new Error("BasicCompression received in TightPNG rect");
+    }
+}
pkg/web/noVNC/core/decoders/zlib.js
@@ -0,0 +1,51 @@
+/*
+ * noVNC: HTML5 VNC client
+ * Copyright (C) 2024 The noVNC authors
+ * Licensed under MPL 2.0 (see LICENSE.txt)
+ *
+ * See README.md for usage and integration instructions.
+ *
+ */
+
+import Inflator from "../inflator.js";
+
+export default class ZlibDecoder {
+    constructor() {
+        this._zlib = new Inflator();
+        this._length = 0;
+    }
+
+    decodeRect(x, y, width, height, sock, display, depth) {
+        if ((width === 0) || (height === 0)) {
+            return true;
+        }
+
+        if (this._length === 0) {
+            if (sock.rQwait("ZLIB", 4)) {
+                return false;
+            }
+
+            this._length = sock.rQshift32();
+        }
+
+        if (sock.rQwait("ZLIB", this._length)) {
+            return false;
+        }
+
+        let data = new Uint8Array(sock.rQshiftBytes(this._length, false));
+        this._length = 0;
+
+        this._zlib.setInput(data);
+        data = this._zlib.inflate(width * height * 4);
+        this._zlib.setInput(null);
+
+        // Max sure the image is fully opaque
+        for (let i = 0; i < width * height; i++) {
+            data[i * 4 + 3] = 255;
+        }
+
+        display.blitImage(x, y, width, height, data, 0);
+
+        return true;
+    }
+}
pkg/web/noVNC/core/decoders/zrle.js
@@ -0,0 +1,185 @@
+/*
+ * noVNC: HTML5 VNC client
+ * Copyright (C) 2021 The noVNC authors
+ * Licensed under MPL 2.0 (see LICENSE.txt)
+ *
+ * See README.md for usage and integration instructions.
+ *
+ */
+
+import Inflate from "../inflator.js";
+
+const ZRLE_TILE_WIDTH = 64;
+const ZRLE_TILE_HEIGHT = 64;
+
+export default class ZRLEDecoder {
+    constructor() {
+        this._length = 0;
+        this._inflator = new Inflate();
+
+        this._pixelBuffer = new Uint8Array(ZRLE_TILE_WIDTH * ZRLE_TILE_HEIGHT * 4);
+        this._tileBuffer = new Uint8Array(ZRLE_TILE_WIDTH * ZRLE_TILE_HEIGHT * 4);
+    }
+
+    decodeRect(x, y, width, height, sock, display, depth) {
+        if (this._length === 0) {
+            if (sock.rQwait("ZLib data length", 4)) {
+                return false;
+            }
+            this._length = sock.rQshift32();
+        }
+        if (sock.rQwait("Zlib data", this._length)) {
+            return false;
+        }
+
+        const data = sock.rQshiftBytes(this._length, false);
+
+        this._inflator.setInput(data);
+
+        for (let ty = y; ty < y + height; ty += ZRLE_TILE_HEIGHT) {
+            let th = Math.min(ZRLE_TILE_HEIGHT, y + height - ty);
+
+            for (let tx = x; tx < x + width; tx += ZRLE_TILE_WIDTH) {
+                let tw = Math.min(ZRLE_TILE_WIDTH, x + width - tx);
+
+                const tileSize = tw * th;
+                const subencoding = this._inflator.inflate(1)[0];
+                if (subencoding === 0) {
+                    // raw data
+                    const data = this._readPixels(tileSize);
+                    display.blitImage(tx, ty, tw, th, data, 0, false);
+                } else if (subencoding === 1) {
+                    // solid
+                    const background = this._readPixels(1);
+                    display.fillRect(tx, ty, tw, th, [background[0], background[1], background[2]]);
+                } else if (subencoding >= 2 && subencoding <= 16) {
+                    const data = this._decodePaletteTile(subencoding, tileSize, tw, th);
+                    display.blitImage(tx, ty, tw, th, data, 0, false);
+                } else if (subencoding === 128) {
+                    const data = this._decodeRLETile(tileSize);
+                    display.blitImage(tx, ty, tw, th, data, 0, false);
+                } else if (subencoding >= 130 && subencoding <= 255) {
+                    const data = this._decodeRLEPaletteTile(subencoding - 128, tileSize);
+                    display.blitImage(tx, ty, tw, th, data, 0, false);
+                } else {
+                    throw new Error('Unknown subencoding: ' + subencoding);
+                }
+            }
+        }
+        this._length = 0;
+        return true;
+    }
+
+    _getBitsPerPixelInPalette(paletteSize) {
+        if (paletteSize <= 2) {
+            return 1;
+        } else if (paletteSize <= 4) {
+            return 2;
+        } else if (paletteSize <= 16) {
+            return 4;
+        }
+    }
+
+    _readPixels(pixels) {
+        let data = this._pixelBuffer;
+        const buffer = this._inflator.inflate(3*pixels);
+        for (let i = 0, j = 0; i < pixels*4; i += 4, j += 3) {
+            data[i]     = buffer[j];
+            data[i + 1] = buffer[j + 1];
+            data[i + 2] = buffer[j + 2];
+            data[i + 3] = 255;  // Add the Alpha
+        }
+        return data;
+    }
+
+    _decodePaletteTile(paletteSize, tileSize, tilew, tileh) {
+        const data = this._tileBuffer;
+        const palette = this._readPixels(paletteSize);
+        const bitsPerPixel = this._getBitsPerPixelInPalette(paletteSize);
+        const mask = (1 << bitsPerPixel) - 1;
+
+        let offset = 0;
+        let encoded = this._inflator.inflate(1)[0];
+
+        for (let y=0; y<tileh; y++) {
+            let shift = 8-bitsPerPixel;
+            for (let x=0; x<tilew; x++) {
+                if (shift<0) {
+                    shift=8-bitsPerPixel;
+                    encoded = this._inflator.inflate(1)[0];
+                }
+                let indexInPalette = (encoded>>shift) & mask;
+
+                data[offset] = palette[indexInPalette * 4];
+                data[offset + 1] = palette[indexInPalette * 4 + 1];
+                data[offset + 2] = palette[indexInPalette * 4 + 2];
+                data[offset + 3] = palette[indexInPalette * 4 + 3];
+                offset += 4;
+                shift-=bitsPerPixel;
+            }
+            if (shift<8-bitsPerPixel && y<tileh-1) {
+                encoded =  this._inflator.inflate(1)[0];
+            }
+        }
+        return data;
+    }
+
+    _decodeRLETile(tileSize) {
+        const data = this._tileBuffer;
+        let i = 0;
+        while (i < tileSize) {
+            const pixel = this._readPixels(1);
+            const length = this._readRLELength();
+            for (let j = 0; j < length; j++) {
+                data[i * 4] = pixel[0];
+                data[i * 4 + 1] = pixel[1];
+                data[i * 4 + 2] = pixel[2];
+                data[i * 4 + 3] = pixel[3];
+                i++;
+            }
+        }
+        return data;
+    }
+
+    _decodeRLEPaletteTile(paletteSize, tileSize) {
+        const data = this._tileBuffer;
+
+        // palette
+        const palette = this._readPixels(paletteSize);
+
+        let offset = 0;
+        while (offset < tileSize) {
+            let indexInPalette = this._inflator.inflate(1)[0];
+            let length = 1;
+            if (indexInPalette >= 128) {
+                indexInPalette -= 128;
+                length = this._readRLELength();
+            }
+            if (indexInPalette > paletteSize) {
+                throw new Error('Too big index in palette: ' + indexInPalette + ', palette size: ' + paletteSize);
+            }
+            if (offset + length > tileSize) {
+                throw new Error('Too big rle length in palette mode: ' + length + ', allowed length is: ' + (tileSize - offset));
+            }
+
+            for (let j = 0; j < length; j++) {
+                data[offset * 4] = palette[indexInPalette * 4];
+                data[offset * 4 + 1] = palette[indexInPalette * 4 + 1];
+                data[offset * 4 + 2] = palette[indexInPalette * 4 + 2];
+                data[offset * 4 + 3] = palette[indexInPalette * 4 + 3];
+                offset++;
+            }
+        }
+        return data;
+    }
+
+    _readRLELength() {
+        let length = 0;
+        let current = 0;
+        do {
+            current = this._inflator.inflate(1)[0];
+            length += current;
+        } while (current === 255);
+        return length + 1;
+    }
+}
pkg/web/noVNC/core/input/domkeytable.js
@@ -0,0 +1,311 @@
+/*
+ * noVNC: HTML5 VNC client
+ * Copyright (C) 2018 The noVNC authors
+ * Licensed under MPL 2.0 or any later version (see LICENSE.txt)
+ */
+
+import KeyTable from "./keysym.js";
+
+/*
+ * Mapping between HTML key values and VNC/X11 keysyms for "special"
+ * keys that cannot be handled via their Unicode codepoint.
+ *
+ * See https://www.w3.org/TR/uievents-key/ for possible values.
+ */
+
+const DOMKeyTable = {};
+
+function addStandard(key, standard) {
+    if (standard === undefined) throw new Error("Undefined keysym for key \"" + key + "\"");
+    if (key in DOMKeyTable) throw new Error("Duplicate entry for key \"" + key + "\"");
+    DOMKeyTable[key] = [standard, standard, standard, standard];
+}
+
+function addLeftRight(key, left, right) {
+    if (left === undefined) throw new Error("Undefined keysym for key \"" + key + "\"");
+    if (right === undefined) throw new Error("Undefined keysym for key \"" + key + "\"");
+    if (key in DOMKeyTable) throw new Error("Duplicate entry for key \"" + key + "\"");
+    DOMKeyTable[key] = [left, left, right, left];
+}
+
+function addNumpad(key, standard, numpad) {
+    if (standard === undefined) throw new Error("Undefined keysym for key \"" + key + "\"");
+    if (numpad === undefined) throw new Error("Undefined keysym for key \"" + key + "\"");
+    if (key in DOMKeyTable) throw new Error("Duplicate entry for key \"" + key + "\"");
+    DOMKeyTable[key] = [standard, standard, standard, numpad];
+}
+
+// 3.2. Modifier Keys
+
+addLeftRight("Alt", KeyTable.XK_Alt_L, KeyTable.XK_Alt_R);
+addStandard("AltGraph", KeyTable.XK_ISO_Level3_Shift);
+addStandard("CapsLock", KeyTable.XK_Caps_Lock);
+addLeftRight("Control", KeyTable.XK_Control_L, KeyTable.XK_Control_R);
+// - Fn
+// - FnLock
+addLeftRight("Meta", KeyTable.XK_Super_L, KeyTable.XK_Super_R);
+addStandard("NumLock", KeyTable.XK_Num_Lock);
+addStandard("ScrollLock", KeyTable.XK_Scroll_Lock);
+addLeftRight("Shift", KeyTable.XK_Shift_L, KeyTable.XK_Shift_R);
+// - Symbol
+// - SymbolLock
+// - Hyper
+// - Super
+
+// 3.3. Whitespace Keys
+
+addNumpad("Enter", KeyTable.XK_Return, KeyTable.XK_KP_Enter);
+addStandard("Tab", KeyTable.XK_Tab);
+addNumpad(" ", KeyTable.XK_space, KeyTable.XK_KP_Space);
+
+// 3.4. Navigation Keys
+
+addNumpad("ArrowDown", KeyTable.XK_Down, KeyTable.XK_KP_Down);
+addNumpad("ArrowLeft", KeyTable.XK_Left, KeyTable.XK_KP_Left);
+addNumpad("ArrowRight", KeyTable.XK_Right, KeyTable.XK_KP_Right);
+addNumpad("ArrowUp", KeyTable.XK_Up, KeyTable.XK_KP_Up);
+addNumpad("End", KeyTable.XK_End, KeyTable.XK_KP_End);
+addNumpad("Home", KeyTable.XK_Home, KeyTable.XK_KP_Home);
+addNumpad("PageDown", KeyTable.XK_Next, KeyTable.XK_KP_Next);
+addNumpad("PageUp", KeyTable.XK_Prior, KeyTable.XK_KP_Prior);
+
+// 3.5. Editing Keys
+
+addStandard("Backspace", KeyTable.XK_BackSpace);
+// Browsers send "Clear" for the numpad 5 without NumLock because
+// Windows uses VK_Clear for that key. But Unix expects KP_Begin for
+// that scenario.
+addNumpad("Clear", KeyTable.XK_Clear, KeyTable.XK_KP_Begin);
+addStandard("Copy", KeyTable.XF86XK_Copy);
+// - CrSel
+addStandard("Cut", KeyTable.XF86XK_Cut);
+addNumpad("Delete", KeyTable.XK_Delete, KeyTable.XK_KP_Delete);
+// - EraseEof
+// - ExSel
+addNumpad("Insert", KeyTable.XK_Insert, KeyTable.XK_KP_Insert);
+addStandard("Paste", KeyTable.XF86XK_Paste);
+addStandard("Redo", KeyTable.XK_Redo);
+addStandard("Undo", KeyTable.XK_Undo);
+
+// 3.6. UI Keys
+
+// - Accept
+// - Again (could just be XK_Redo)
+// - Attn
+addStandard("Cancel", KeyTable.XK_Cancel);
+addStandard("ContextMenu", KeyTable.XK_Menu);
+addStandard("Escape", KeyTable.XK_Escape);
+addStandard("Execute", KeyTable.XK_Execute);
+addStandard("Find", KeyTable.XK_Find);
+addStandard("Help", KeyTable.XK_Help);
+addStandard("Pause", KeyTable.XK_Pause);
+// - Play
+// - Props
+addStandard("Select", KeyTable.XK_Select);
+addStandard("ZoomIn", KeyTable.XF86XK_ZoomIn);
+addStandard("ZoomOut", KeyTable.XF86XK_ZoomOut);
+
+// 3.7. Device Keys
+
+addStandard("BrightnessDown", KeyTable.XF86XK_MonBrightnessDown);
+addStandard("BrightnessUp", KeyTable.XF86XK_MonBrightnessUp);
+addStandard("Eject", KeyTable.XF86XK_Eject);
+addStandard("LogOff", KeyTable.XF86XK_LogOff);
+addStandard("Power", KeyTable.XF86XK_PowerOff);
+addStandard("PowerOff", KeyTable.XF86XK_PowerDown);
+addStandard("PrintScreen", KeyTable.XK_Print);
+addStandard("Hibernate", KeyTable.XF86XK_Hibernate);
+addStandard("Standby", KeyTable.XF86XK_Standby);
+addStandard("WakeUp", KeyTable.XF86XK_WakeUp);
+
+// 3.8. IME and Composition Keys
+
+addStandard("AllCandidates", KeyTable.XK_MultipleCandidate);
+addStandard("Alphanumeric", KeyTable.XK_Eisu_toggle);
+addStandard("CodeInput", KeyTable.XK_Codeinput);
+addStandard("Compose", KeyTable.XK_Multi_key);
+addStandard("Convert", KeyTable.XK_Henkan);
+// - Dead
+// - FinalMode
+addStandard("GroupFirst", KeyTable.XK_ISO_First_Group);
+addStandard("GroupLast", KeyTable.XK_ISO_Last_Group);
+addStandard("GroupNext", KeyTable.XK_ISO_Next_Group);
+addStandard("GroupPrevious", KeyTable.XK_ISO_Prev_Group);
+// - ModeChange (XK_Mode_switch is often used for AltGr)
+// - NextCandidate
+addStandard("NonConvert", KeyTable.XK_Muhenkan);
+addStandard("PreviousCandidate", KeyTable.XK_PreviousCandidate);
+// - Process
+addStandard("SingleCandidate", KeyTable.XK_SingleCandidate);
+addStandard("HangulMode", KeyTable.XK_Hangul);
+addStandard("HanjaMode", KeyTable.XK_Hangul_Hanja);
+addStandard("JunjaMode", KeyTable.XK_Hangul_Jeonja);
+addStandard("Eisu", KeyTable.XK_Eisu_toggle);
+addStandard("Hankaku", KeyTable.XK_Hankaku);
+addStandard("Hiragana", KeyTable.XK_Hiragana);
+addStandard("HiraganaKatakana", KeyTable.XK_Hiragana_Katakana);
+addStandard("KanaMode", KeyTable.XK_Kana_Shift); // could also be _Kana_Lock
+addStandard("KanjiMode", KeyTable.XK_Kanji);
+addStandard("Katakana", KeyTable.XK_Katakana);
+addStandard("Romaji", KeyTable.XK_Romaji);
+addStandard("Zenkaku", KeyTable.XK_Zenkaku);
+addStandard("ZenkakuHankaku", KeyTable.XK_Zenkaku_Hankaku);
+
+// 3.9. General-Purpose Function Keys
+
+addStandard("F1", KeyTable.XK_F1);
+addStandard("F2", KeyTable.XK_F2);
+addStandard("F3", KeyTable.XK_F3);
+addStandard("F4", KeyTable.XK_F4);
+addStandard("F5", KeyTable.XK_F5);
+addStandard("F6", KeyTable.XK_F6);
+addStandard("F7", KeyTable.XK_F7);
+addStandard("F8", KeyTable.XK_F8);
+addStandard("F9", KeyTable.XK_F9);
+addStandard("F10", KeyTable.XK_F10);
+addStandard("F11", KeyTable.XK_F11);
+addStandard("F12", KeyTable.XK_F12);
+addStandard("F13", KeyTable.XK_F13);
+addStandard("F14", KeyTable.XK_F14);
+addStandard("F15", KeyTable.XK_F15);
+addStandard("F16", KeyTable.XK_F16);
+addStandard("F17", KeyTable.XK_F17);
+addStandard("F18", KeyTable.XK_F18);
+addStandard("F19", KeyTable.XK_F19);
+addStandard("F20", KeyTable.XK_F20);
+addStandard("F21", KeyTable.XK_F21);
+addStandard("F22", KeyTable.XK_F22);
+addStandard("F23", KeyTable.XK_F23);
+addStandard("F24", KeyTable.XK_F24);
+addStandard("F25", KeyTable.XK_F25);
+addStandard("F26", KeyTable.XK_F26);
+addStandard("F27", KeyTable.XK_F27);
+addStandard("F28", KeyTable.XK_F28);
+addStandard("F29", KeyTable.XK_F29);
+addStandard("F30", KeyTable.XK_F30);
+addStandard("F31", KeyTable.XK_F31);
+addStandard("F32", KeyTable.XK_F32);
+addStandard("F33", KeyTable.XK_F33);
+addStandard("F34", KeyTable.XK_F34);
+addStandard("F35", KeyTable.XK_F35);
+// - Soft1...
+
+// 3.10. Multimedia Keys
+
+// - ChannelDown
+// - ChannelUp
+addStandard("Close", KeyTable.XF86XK_Close);
+addStandard("MailForward", KeyTable.XF86XK_MailForward);
+addStandard("MailReply", KeyTable.XF86XK_Reply);
+addStandard("MailSend", KeyTable.XF86XK_Send);
+// - MediaClose
+addStandard("MediaFastForward", KeyTable.XF86XK_AudioForward);
+addStandard("MediaPause", KeyTable.XF86XK_AudioPause);
+addStandard("MediaPlay", KeyTable.XF86XK_AudioPlay);
+// - MediaPlayPause
+addStandard("MediaRecord", KeyTable.XF86XK_AudioRecord);
+addStandard("MediaRewind", KeyTable.XF86XK_AudioRewind);
+addStandard("MediaStop", KeyTable.XF86XK_AudioStop);
+addStandard("MediaTrackNext", KeyTable.XF86XK_AudioNext);
+addStandard("MediaTrackPrevious", KeyTable.XF86XK_AudioPrev);
+addStandard("New", KeyTable.XF86XK_New);
+addStandard("Open", KeyTable.XF86XK_Open);
+addStandard("Print", KeyTable.XK_Print);
+addStandard("Save", KeyTable.XF86XK_Save);
+addStandard("SpellCheck", KeyTable.XF86XK_Spell);
+
+// 3.11. Multimedia Numpad Keys
+
+// - Key11
+// - Key12
+
+// 3.12. Audio Keys
+
+// - AudioBalanceLeft
+// - AudioBalanceRight
+// - AudioBassBoostDown
+// - AudioBassBoostToggle
+// - AudioBassBoostUp
+// - AudioFaderFront
+// - AudioFaderRear
+// - AudioSurroundModeNext
+// - AudioTrebleDown
+// - AudioTrebleUp
+addStandard("AudioVolumeDown", KeyTable.XF86XK_AudioLowerVolume);
+addStandard("AudioVolumeUp", KeyTable.XF86XK_AudioRaiseVolume);
+addStandard("AudioVolumeMute", KeyTable.XF86XK_AudioMute);
+// - MicrophoneToggle
+// - MicrophoneVolumeDown
+// - MicrophoneVolumeUp
+addStandard("MicrophoneVolumeMute", KeyTable.XF86XK_AudioMicMute);
+
+// 3.13. Speech Keys
+
+// - SpeechCorrectionList
+// - SpeechInputToggle
+
+// 3.14. Application Keys
+
+addStandard("LaunchApplication1", KeyTable.XF86XK_MyComputer);
+addStandard("LaunchApplication2", KeyTable.XF86XK_Calculator);
+addStandard("LaunchCalendar", KeyTable.XF86XK_Calendar);
+// - LaunchContacts
+addStandard("LaunchMail", KeyTable.XF86XK_Mail);
+addStandard("LaunchMediaPlayer", KeyTable.XF86XK_AudioMedia);
+addStandard("LaunchMusicPlayer", KeyTable.XF86XK_Music);
+addStandard("LaunchPhone", KeyTable.XF86XK_Phone);
+addStandard("LaunchScreenSaver", KeyTable.XF86XK_ScreenSaver);
+addStandard("LaunchSpreadsheet", KeyTable.XF86XK_Excel);
+addStandard("LaunchWebBrowser", KeyTable.XF86XK_WWW);
+addStandard("LaunchWebCam", KeyTable.XF86XK_WebCam);
+addStandard("LaunchWordProcessor", KeyTable.XF86XK_Word);
+
+// 3.15. Browser Keys
+
+addStandard("BrowserBack", KeyTable.XF86XK_Back);
+addStandard("BrowserFavorites", KeyTable.XF86XK_Favorites);
+addStandard("BrowserForward", KeyTable.XF86XK_Forward);
+addStandard("BrowserHome", KeyTable.XF86XK_HomePage);
+addStandard("BrowserRefresh", KeyTable.XF86XK_Refresh);
+addStandard("BrowserSearch", KeyTable.XF86XK_Search);
+addStandard("BrowserStop", KeyTable.XF86XK_Stop);
+
+// 3.16. Mobile Phone Keys
+
+// - A whole bunch...
+
+// 3.17. TV Keys
+
+// - A whole bunch...
+
+// 3.18. Media Controller Keys
+
+// - A whole bunch...
+addStandard("Dimmer", KeyTable.XF86XK_BrightnessAdjust);
+addStandard("MediaAudioTrack", KeyTable.XF86XK_AudioCycleTrack);
+addStandard("RandomToggle", KeyTable.XF86XK_AudioRandomPlay);
+addStandard("SplitScreenToggle", KeyTable.XF86XK_SplitScreen);
+addStandard("Subtitle", KeyTable.XF86XK_Subtitle);
+addStandard("VideoModeNext", KeyTable.XF86XK_Next_VMode);
+
+// Extra: Numpad
+
+addNumpad("=", KeyTable.XK_equal, KeyTable.XK_KP_Equal);
+addNumpad("+", KeyTable.XK_plus, KeyTable.XK_KP_Add);
+addNumpad("-", KeyTable.XK_minus, KeyTable.XK_KP_Subtract);
+addNumpad("*", KeyTable.XK_asterisk, KeyTable.XK_KP_Multiply);
+addNumpad("/", KeyTable.XK_slash, KeyTable.XK_KP_Divide);
+addNumpad(".", KeyTable.XK_period, KeyTable.XK_KP_Decimal);
+addNumpad(",", KeyTable.XK_comma, KeyTable.XK_KP_Separator);
+addNumpad("0", KeyTable.XK_0, KeyTable.XK_KP_0);
+addNumpad("1", KeyTable.XK_1, KeyTable.XK_KP_1);
+addNumpad("2", KeyTable.XK_2, KeyTable.XK_KP_2);
+addNumpad("3", KeyTable.XK_3, KeyTable.XK_KP_3);
+addNumpad("4", KeyTable.XK_4, KeyTable.XK_KP_4);
+addNumpad("5", KeyTable.XK_5, KeyTable.XK_KP_5);
+addNumpad("6", KeyTable.XK_6, KeyTable.XK_KP_6);
+addNumpad("7", KeyTable.XK_7, KeyTable.XK_KP_7);
+addNumpad("8", KeyTable.XK_8, KeyTable.XK_KP_8);
+addNumpad("9", KeyTable.XK_9, KeyTable.XK_KP_9);
+
+export default DOMKeyTable;
pkg/web/noVNC/core/input/fixedkeys.js
@@ -0,0 +1,129 @@
+/*
+ * noVNC: HTML5 VNC client
+ * Copyright (C) 2018 The noVNC authors
+ * Licensed under MPL 2.0 or any later version (see LICENSE.txt)
+ */
+
+/*
+ * Fallback mapping between HTML key codes (physical keys) and
+ * HTML key values. This only works for keys that don't vary
+ * between layouts. We also omit those who manage fine by mapping the
+ * Unicode representation.
+ *
+ * See https://www.w3.org/TR/uievents-code/ for possible codes.
+ * See https://www.w3.org/TR/uievents-key/ for possible values.
+ */
+
+/* eslint-disable key-spacing */
+
+export default {
+
+// 3.1.1.1. Writing System Keys
+
+    'Backspace':        'Backspace',
+
+// 3.1.1.2. Functional Keys
+
+    'AltLeft':          'Alt',
+    'AltRight':         'Alt', // This could also be 'AltGraph'
+    'CapsLock':         'CapsLock',
+    'ContextMenu':      'ContextMenu',
+    'ControlLeft':      'Control',
+    'ControlRight':     'Control',
+    'Enter':            'Enter',
+    'MetaLeft':         'Meta',
+    'MetaRight':        'Meta',
+    'ShiftLeft':        'Shift',
+    'ShiftRight':       'Shift',
+    'Tab':              'Tab',
+    // FIXME: Japanese/Korean keys
+
+// 3.1.2. Control Pad Section
+
+    'Delete':           'Delete',
+    'End':              'End',
+    'Help':             'Help',
+    'Home':             'Home',
+    'Insert':           'Insert',
+    'PageDown':         'PageDown',
+    'PageUp':           'PageUp',
+
+// 3.1.3. Arrow Pad Section
+
+    'ArrowDown':        'ArrowDown',
+    'ArrowLeft':        'ArrowLeft',
+    'ArrowRight':       'ArrowRight',
+    'ArrowUp':          'ArrowUp',
+
+// 3.1.4. Numpad Section
+
+    'NumLock':          'NumLock',
+    'NumpadBackspace':  'Backspace',
+    'NumpadClear':      'Clear',
+
+// 3.1.5. Function Section
+
+    'Escape':           'Escape',
+    'F1':               'F1',
+    'F2':               'F2',
+    'F3':               'F3',
+    'F4':               'F4',
+    'F5':               'F5',
+    'F6':               'F6',
+    'F7':               'F7',
+    'F8':               'F8',
+    'F9':               'F9',
+    'F10':              'F10',
+    'F11':              'F11',
+    'F12':              'F12',
+    'F13':              'F13',
+    'F14':              'F14',
+    'F15':              'F15',
+    'F16':              'F16',
+    'F17':              'F17',
+    'F18':              'F18',
+    'F19':              'F19',
+    'F20':              'F20',
+    'F21':              'F21',
+    'F22':              'F22',
+    'F23':              'F23',
+    'F24':              'F24',
+    'F25':              'F25',
+    'F26':              'F26',
+    'F27':              'F27',
+    'F28':              'F28',
+    'F29':              'F29',
+    'F30':              'F30',
+    'F31':              'F31',
+    'F32':              'F32',
+    'F33':              'F33',
+    'F34':              'F34',
+    'F35':              'F35',
+    'PrintScreen':      'PrintScreen',
+    'ScrollLock':       'ScrollLock',
+    'Pause':            'Pause',
+
+// 3.1.6. Media Keys
+
+    'BrowserBack':      'BrowserBack',
+    'BrowserFavorites': 'BrowserFavorites',
+    'BrowserForward':   'BrowserForward',
+    'BrowserHome':      'BrowserHome',
+    'BrowserRefresh':   'BrowserRefresh',
+    'BrowserSearch':    'BrowserSearch',
+    'BrowserStop':      'BrowserStop',
+    'Eject':            'Eject',
+    'LaunchApp1':       'LaunchMyComputer',
+    'LaunchApp2':       'LaunchCalendar',
+    'LaunchMail':       'LaunchMail',
+    'MediaPlayPause':   'MediaPlay',
+    'MediaStop':        'MediaStop',
+    'MediaTrackNext':   'MediaTrackNext',
+    'MediaTrackPrevious': 'MediaTrackPrevious',
+    'Power':            'Power',
+    'Sleep':            'Sleep',
+    'AudioVolumeDown':  'AudioVolumeDown',
+    'AudioVolumeMute':  'AudioVolumeMute',
+    'AudioVolumeUp':    'AudioVolumeUp',
+    'WakeUp':           'WakeUp',
+};
pkg/web/noVNC/core/input/gesturehandler.js
@@ -0,0 +1,567 @@
+/*
+ * noVNC: HTML5 VNC client
+ * Copyright (C) 2020 The noVNC authors
+ * Licensed under MPL 2.0 (see LICENSE.txt)
+ *
+ * See README.md for usage and integration instructions.
+ *
+ */
+
+const GH_NOGESTURE = 0;
+const GH_ONETAP    = 1;
+const GH_TWOTAP    = 2;
+const GH_THREETAP  = 4;
+const GH_DRAG      = 8;
+const GH_LONGPRESS = 16;
+const GH_TWODRAG   = 32;
+const GH_PINCH     = 64;
+
+const GH_INITSTATE = 127;
+
+const GH_MOVE_THRESHOLD = 50;
+const GH_ANGLE_THRESHOLD = 90; // Degrees
+
+// Timeout when waiting for gestures (ms)
+const GH_MULTITOUCH_TIMEOUT = 250;
+
+// Maximum time between press and release for a tap (ms)
+const GH_TAP_TIMEOUT = 1000;
+
+// Timeout when waiting for longpress (ms)
+const GH_LONGPRESS_TIMEOUT = 1000;
+
+// Timeout when waiting to decide between PINCH and TWODRAG (ms)
+const GH_TWOTOUCH_TIMEOUT = 50;
+
+export default class GestureHandler {
+    constructor() {
+        this._target = null;
+
+        this._state = GH_INITSTATE;
+
+        this._tracked = [];
+        this._ignored = [];
+
+        this._waitingRelease = false;
+        this._releaseStart = 0.0;
+
+        this._longpressTimeoutId = null;
+        this._twoTouchTimeoutId = null;
+
+        this._boundEventHandler = this._eventHandler.bind(this);
+    }
+
+    attach(target) {
+        this.detach();
+
+        this._target = target;
+        this._target.addEventListener('touchstart',
+                                      this._boundEventHandler);
+        this._target.addEventListener('touchmove',
+                                      this._boundEventHandler);
+        this._target.addEventListener('touchend',
+                                      this._boundEventHandler);
+        this._target.addEventListener('touchcancel',
+                                      this._boundEventHandler);
+    }
+
+    detach() {
+        if (!this._target) {
+            return;
+        }
+
+        this._stopLongpressTimeout();
+        this._stopTwoTouchTimeout();
+
+        this._target.removeEventListener('touchstart',
+                                         this._boundEventHandler);
+        this._target.removeEventListener('touchmove',
+                                         this._boundEventHandler);
+        this._target.removeEventListener('touchend',
+                                         this._boundEventHandler);
+        this._target.removeEventListener('touchcancel',
+                                         this._boundEventHandler);
+        this._target = null;
+    }
+
+    _eventHandler(e) {
+        let fn;
+
+        e.stopPropagation();
+        e.preventDefault();
+
+        switch (e.type) {
+            case 'touchstart':
+                fn = this._touchStart;
+                break;
+            case 'touchmove':
+                fn = this._touchMove;
+                break;
+            case 'touchend':
+            case 'touchcancel':
+                fn = this._touchEnd;
+                break;
+        }
+
+        for (let i = 0; i < e.changedTouches.length; i++) {
+            let touch = e.changedTouches[i];
+            fn.call(this, touch.identifier, touch.clientX, touch.clientY);
+        }
+    }
+
+    _touchStart(id, x, y) {
+        // Ignore any new touches if there is already an active gesture,
+        // or we're in a cleanup state
+        if (this._hasDetectedGesture() || (this._state === GH_NOGESTURE)) {
+            this._ignored.push(id);
+            return;
+        }
+
+        // Did it take too long between touches that we should no longer
+        // consider this a single gesture?
+        if ((this._tracked.length > 0) &&
+            ((Date.now() - this._tracked[0].started) > GH_MULTITOUCH_TIMEOUT)) {
+            this._state = GH_NOGESTURE;
+            this._ignored.push(id);
+            return;
+        }
+
+        // If we're waiting for fingers to release then we should no longer
+        // recognize new touches
+        if (this._waitingRelease) {
+            this._state = GH_NOGESTURE;
+            this._ignored.push(id);
+            return;
+        }
+
+        this._tracked.push({
+            id: id,
+            started: Date.now(),
+            active: true,
+            firstX: x,
+            firstY: y,
+            lastX: x,
+            lastY: y,
+            angle: 0
+        });
+
+        switch (this._tracked.length) {
+            case 1:
+                this._startLongpressTimeout();
+                break;
+
+            case 2:
+                this._state &= ~(GH_ONETAP | GH_DRAG | GH_LONGPRESS);
+                this._stopLongpressTimeout();
+                break;
+
+            case 3:
+                this._state &= ~(GH_TWOTAP | GH_TWODRAG | GH_PINCH);
+                break;
+
+            default:
+                this._state = GH_NOGESTURE;
+        }
+    }
+
+    _touchMove(id, x, y) {
+        let touch = this._tracked.find(t => t.id === id);
+
+        // If this is an update for a touch we're not tracking, ignore it
+        if (touch === undefined) {
+            return;
+        }
+
+        // Update the touches last position with the event coordinates
+        touch.lastX = x;
+        touch.lastY = y;
+
+        let deltaX = x - touch.firstX;
+        let deltaY = y - touch.firstY;
+
+        // Update angle when the touch has moved
+        if ((touch.firstX !== touch.lastX) ||
+            (touch.firstY !== touch.lastY)) {
+            touch.angle = Math.atan2(deltaY, deltaX) * 180 / Math.PI;
+        }
+
+        if (!this._hasDetectedGesture()) {
+            // Ignore moves smaller than the minimum threshold
+            if (Math.hypot(deltaX, deltaY) < GH_MOVE_THRESHOLD) {
+                return;
+            }
+
+            // Can't be a tap or long press as we've seen movement
+            this._state &= ~(GH_ONETAP | GH_TWOTAP | GH_THREETAP | GH_LONGPRESS);
+            this._stopLongpressTimeout();
+
+            if (this._tracked.length !== 1) {
+                this._state &= ~(GH_DRAG);
+            }
+            if (this._tracked.length !== 2) {
+                this._state &= ~(GH_TWODRAG | GH_PINCH);
+            }
+
+            // We need to figure out which of our different two touch gestures
+            // this might be
+            if (this._tracked.length === 2) {
+
+                // The other touch is the one where the id doesn't match
+                let prevTouch = this._tracked.find(t => t.id !== id);
+
+                // How far the previous touch point has moved since start
+                let prevDeltaMove = Math.hypot(prevTouch.firstX - prevTouch.lastX,
+                                               prevTouch.firstY - prevTouch.lastY);
+
+                // We know that the current touch moved far enough,
+                // but unless both touches moved further than their
+                // threshold we don't want to disqualify any gestures
+                if (prevDeltaMove > GH_MOVE_THRESHOLD) {
+
+                    // The angle difference between the direction of the touch points
+                    let deltaAngle = Math.abs(touch.angle - prevTouch.angle);
+                    deltaAngle = Math.abs(((deltaAngle + 180) % 360) - 180);
+
+                    // PINCH or TWODRAG can be eliminated depending on the angle
+                    if (deltaAngle > GH_ANGLE_THRESHOLD) {
+                        this._state &= ~GH_TWODRAG;
+                    } else {
+                        this._state &= ~GH_PINCH;
+                    }
+
+                    if (this._isTwoTouchTimeoutRunning()) {
+                        this._stopTwoTouchTimeout();
+                    }
+                } else if (!this._isTwoTouchTimeoutRunning()) {
+                    // We can't determine the gesture right now, let's
+                    // wait and see if more events are on their way
+                    this._startTwoTouchTimeout();
+                }
+            }
+
+            if (!this._hasDetectedGesture()) {
+                return;
+            }
+
+            this._pushEvent('gesturestart');
+        }
+
+        this._pushEvent('gesturemove');
+    }
+
+    _touchEnd(id, x, y) {
+        // Check if this is an ignored touch
+        if (this._ignored.indexOf(id) !== -1) {
+            // Remove this touch from ignored
+            this._ignored.splice(this._ignored.indexOf(id), 1);
+
+            // And reset the state if there are no more touches
+            if ((this._ignored.length === 0) &&
+                (this._tracked.length === 0)) {
+                this._state = GH_INITSTATE;
+                this._waitingRelease = false;
+            }
+            return;
+        }
+
+        // We got a touchend before the timer triggered,
+        // this cannot result in a gesture anymore.
+        if (!this._hasDetectedGesture() &&
+            this._isTwoTouchTimeoutRunning()) {
+            this._stopTwoTouchTimeout();
+            this._state = GH_NOGESTURE;
+        }
+
+        // Some gestures don't trigger until a touch is released
+        if (!this._hasDetectedGesture()) {
+            // Can't be a gesture that relies on movement
+            this._state &= ~(GH_DRAG | GH_TWODRAG | GH_PINCH);
+            // Or something that relies on more time
+            this._state &= ~GH_LONGPRESS;
+            this._stopLongpressTimeout();
+
+            if (!this._waitingRelease) {
+                this._releaseStart = Date.now();
+                this._waitingRelease = true;
+
+                // Can't be a tap that requires more touches than we current have
+                switch (this._tracked.length) {
+                    case 1:
+                        this._state &= ~(GH_TWOTAP | GH_THREETAP);
+                        break;
+
+                    case 2:
+                        this._state &= ~(GH_ONETAP | GH_THREETAP);
+                        break;
+                }
+            }
+        }
+
+        // Waiting for all touches to release? (i.e. some tap)
+        if (this._waitingRelease) {
+            // Were all touches released at roughly the same time?
+            if ((Date.now() - this._releaseStart) > GH_MULTITOUCH_TIMEOUT) {
+                this._state = GH_NOGESTURE;
+            }
+
+            // Did too long time pass between press and release?
+            if (this._tracked.some(t => (Date.now() - t.started) > GH_TAP_TIMEOUT)) {
+                this._state = GH_NOGESTURE;
+            }
+
+            let touch = this._tracked.find(t => t.id === id);
+            touch.active = false;
+
+            // Are we still waiting for more releases?
+            if (this._hasDetectedGesture()) {
+                this._pushEvent('gesturestart');
+            } else {
+                // Have we reached a dead end?
+                if (this._state !== GH_NOGESTURE) {
+                    return;
+                }
+            }
+        }
+
+        if (this._hasDetectedGesture()) {
+            this._pushEvent('gestureend');
+        }
+
+        // Ignore any remaining touches until they are ended
+        for (let i = 0; i < this._tracked.length; i++) {
+            if (this._tracked[i].active) {
+                this._ignored.push(this._tracked[i].id);
+            }
+        }
+        this._tracked = [];
+
+        this._state = GH_NOGESTURE;
+
+        // Remove this touch from ignored if it's in there
+        if (this._ignored.indexOf(id) !== -1) {
+            this._ignored.splice(this._ignored.indexOf(id), 1);
+        }
+
+        // We reset the state if ignored is empty
+        if ((this._ignored.length === 0)) {
+            this._state = GH_INITSTATE;
+            this._waitingRelease = false;
+        }
+    }
+
+    _hasDetectedGesture() {
+        if (this._state === GH_NOGESTURE) {
+            return false;
+        }
+        // Check to see if the bitmask value is a power of 2
+        // (i.e. only one bit set). If it is, we have a state.
+        if (this._state & (this._state - 1)) {
+            return false;
+        }
+
+        // For taps we also need to have all touches released
+        // before we've fully detected the gesture
+        if (this._state & (GH_ONETAP | GH_TWOTAP | GH_THREETAP)) {
+            if (this._tracked.some(t => t.active)) {
+                return false;
+            }
+        }
+
+        return true;
+    }
+
+    _startLongpressTimeout() {
+        this._stopLongpressTimeout();
+        this._longpressTimeoutId = setTimeout(() => this._longpressTimeout(),
+                                              GH_LONGPRESS_TIMEOUT);
+    }
+
+    _stopLongpressTimeout() {
+        clearTimeout(this._longpressTimeoutId);
+        this._longpressTimeoutId = null;
+    }
+
+    _longpressTimeout() {
+        if (this._hasDetectedGesture()) {
+            throw new Error("A longpress gesture failed, conflict with a different gesture");
+        }
+
+        this._state = GH_LONGPRESS;
+        this._pushEvent('gesturestart');
+    }
+
+    _startTwoTouchTimeout() {
+        this._stopTwoTouchTimeout();
+        this._twoTouchTimeoutId = setTimeout(() => this._twoTouchTimeout(),
+                                             GH_TWOTOUCH_TIMEOUT);
+    }
+
+    _stopTwoTouchTimeout() {
+        clearTimeout(this._twoTouchTimeoutId);
+        this._twoTouchTimeoutId = null;
+    }
+
+    _isTwoTouchTimeoutRunning() {
+        return this._twoTouchTimeoutId !== null;
+    }
+
+    _twoTouchTimeout() {
+        if (this._tracked.length === 0) {
+            throw new Error("A pinch or two drag gesture failed, no tracked touches");
+        }
+
+        // How far each touch point has moved since start
+        let avgM = this._getAverageMovement();
+        let avgMoveH = Math.abs(avgM.x);
+        let avgMoveV = Math.abs(avgM.y);
+
+        // The difference in the distance between where
+        // the touch points started and where they are now
+        let avgD = this._getAverageDistance();
+        let deltaTouchDistance = Math.abs(Math.hypot(avgD.first.x, avgD.first.y) -
+                                          Math.hypot(avgD.last.x, avgD.last.y));
+
+        if ((avgMoveV < deltaTouchDistance) &&
+            (avgMoveH < deltaTouchDistance)) {
+            this._state = GH_PINCH;
+        } else {
+            this._state = GH_TWODRAG;
+        }
+
+        this._pushEvent('gesturestart');
+        this._pushEvent('gesturemove');
+    }
+
+    _pushEvent(type) {
+        let detail = { type: this._stateToGesture(this._state) };
+
+        // For most gesture events the current (average) position is the
+        // most useful
+        let avg = this._getPosition();
+        let pos = avg.last;
+
+        // However we have a slight distance to detect gestures, so for the
+        // first gesture event we want to use the first positions we saw
+        if (type === 'gesturestart') {
+            pos = avg.first;
+        }
+
+        // For these gestures, we always want the event coordinates
+        // to be where the gesture began, not the current touch location.
+        switch (this._state) {
+            case GH_TWODRAG:
+            case GH_PINCH:
+                pos = avg.first;
+                break;
+        }
+
+        detail['clientX'] = pos.x;
+        detail['clientY'] = pos.y;
+
+        // FIXME: other coordinates?
+
+        // Some gestures also have a magnitude
+        if (this._state === GH_PINCH) {
+            let distance = this._getAverageDistance();
+            if (type === 'gesturestart') {
+                detail['magnitudeX'] = distance.first.x;
+                detail['magnitudeY'] = distance.first.y;
+            } else {
+                detail['magnitudeX'] = distance.last.x;
+                detail['magnitudeY'] = distance.last.y;
+            }
+        } else if (this._state === GH_TWODRAG) {
+            if (type === 'gesturestart') {
+                detail['magnitudeX'] = 0.0;
+                detail['magnitudeY'] = 0.0;
+            } else {
+                let movement = this._getAverageMovement();
+                detail['magnitudeX'] = movement.x;
+                detail['magnitudeY'] = movement.y;
+            }
+        }
+
+        let gev = new CustomEvent(type, { detail: detail });
+        this._target.dispatchEvent(gev);
+    }
+
+    _stateToGesture(state) {
+        switch (state) {
+            case GH_ONETAP:
+                return 'onetap';
+            case GH_TWOTAP:
+                return 'twotap';
+            case GH_THREETAP:
+                return 'threetap';
+            case GH_DRAG:
+                return 'drag';
+            case GH_LONGPRESS:
+                return 'longpress';
+            case GH_TWODRAG:
+                return 'twodrag';
+            case GH_PINCH:
+                return 'pinch';
+        }
+
+        throw new Error("Unknown gesture state: " + state);
+    }
+
+    _getPosition() {
+        if (this._tracked.length === 0) {
+            throw new Error("Failed to get gesture position, no tracked touches");
+        }
+
+        let size = this._tracked.length;
+        let fx = 0, fy = 0, lx = 0, ly = 0;
+
+        for (let i = 0; i < this._tracked.length; i++) {
+            fx += this._tracked[i].firstX;
+            fy += this._tracked[i].firstY;
+            lx += this._tracked[i].lastX;
+            ly += this._tracked[i].lastY;
+        }
+
+        return { first: { x: fx / size,
+                          y: fy / size },
+                 last: { x: lx / size,
+                         y: ly / size } };
+    }
+
+    _getAverageMovement() {
+        if (this._tracked.length === 0) {
+            throw new Error("Failed to get gesture movement, no tracked touches");
+        }
+
+        let totalH, totalV;
+        totalH = totalV = 0;
+        let size = this._tracked.length;
+
+        for (let i = 0; i < this._tracked.length; i++) {
+            totalH += this._tracked[i].lastX - this._tracked[i].firstX;
+            totalV += this._tracked[i].lastY - this._tracked[i].firstY;
+        }
+
+        return { x: totalH / size,
+                 y: totalV / size };
+    }
+
+    _getAverageDistance() {
+        if (this._tracked.length === 0) {
+            throw new Error("Failed to get gesture distance, no tracked touches");
+        }
+
+        // Distance between the first and last tracked touches
+
+        let first = this._tracked[0];
+        let last = this._tracked[this._tracked.length - 1];
+
+        let fdx = Math.abs(last.firstX - first.firstX);
+        let fdy = Math.abs(last.firstY - first.firstY);
+
+        let ldx = Math.abs(last.lastX - first.lastX);
+        let ldy = Math.abs(last.lastY - first.lastY);
+
+        return { first: { x: fdx, y: fdy },
+                 last: { x: ldx, y: ldy } };
+    }
+}
pkg/web/noVNC/core/input/keyboard.js
@@ -0,0 +1,294 @@
+/*
+ * noVNC: HTML5 VNC client
+ * Copyright (C) 2019 The noVNC authors
+ * Licensed under MPL 2.0 or any later version (see LICENSE.txt)
+ */
+
+import * as Log from '../util/logging.js';
+import { stopEvent } from '../util/events.js';
+import * as KeyboardUtil from "./util.js";
+import KeyTable from "./keysym.js";
+import * as browser from "../util/browser.js";
+
+//
+// Keyboard event handler
+//
+
+export default class Keyboard {
+    constructor(target) {
+        this._target = target || null;
+
+        this._keyDownList = {};         // List of depressed keys
+                                        // (even if they are happy)
+        this._altGrArmed = false;       // Windows AltGr detection
+
+        // keep these here so we can refer to them later
+        this._eventHandlers = {
+            'keyup': this._handleKeyUp.bind(this),
+            'keydown': this._handleKeyDown.bind(this),
+            'blur': this._allKeysUp.bind(this),
+        };
+
+        // ===== EVENT HANDLERS =====
+
+        this.onkeyevent = () => {}; // Handler for key press/release
+    }
+
+    // ===== PRIVATE METHODS =====
+
+    _sendKeyEvent(keysym, code, down, numlock = null, capslock = null) {
+        if (down) {
+            this._keyDownList[code] = keysym;
+        } else {
+            // Do we really think this key is down?
+            if (!(code in this._keyDownList)) {
+                return;
+            }
+            delete this._keyDownList[code];
+        }
+
+        Log.Debug("onkeyevent " + (down ? "down" : "up") +
+                  ", keysym: " + keysym, ", code: " + code +
+                  ", numlock: " + numlock + ", capslock: " + capslock);
+        this.onkeyevent(keysym, code, down, numlock, capslock);
+    }
+
+    _getKeyCode(e) {
+        const code = KeyboardUtil.getKeycode(e);
+        if (code !== 'Unidentified') {
+            return code;
+        }
+
+        // Unstable, but we don't have anything else to go on
+        if (e.keyCode) {
+            // 229 is used for composition events
+            if (e.keyCode !== 229) {
+                return 'Platform' + e.keyCode;
+            }
+        }
+
+        // A precursor to the final DOM3 standard. Unfortunately it
+        // is not layout independent, so it is as bad as using keyCode
+        if (e.keyIdentifier) {
+            // Non-character key?
+            if (e.keyIdentifier.substr(0, 2) !== 'U+') {
+                return e.keyIdentifier;
+            }
+
+            const codepoint = parseInt(e.keyIdentifier.substr(2), 16);
+            const char = String.fromCharCode(codepoint).toUpperCase();
+
+            return 'Platform' + char.charCodeAt();
+        }
+
+        return 'Unidentified';
+    }
+
+    _handleKeyDown(e) {
+        const code = this._getKeyCode(e);
+        let keysym = KeyboardUtil.getKeysym(e);
+        let numlock = e.getModifierState('NumLock');
+        let capslock = e.getModifierState('CapsLock');
+
+        // getModifierState for NumLock is not supported on mac and ios and always returns false.
+        // Set to null to indicate unknown/unsupported instead.
+        if (browser.isMac() || browser.isIOS()) {
+            numlock = null;
+        }
+
+        // Windows doesn't have a proper AltGr, but handles it using
+        // fake Ctrl+Alt. However the remote end might not be Windows,
+        // so we need to merge those in to a single AltGr event. We
+        // detect this case by seeing the two key events directly after
+        // each other with a very short time between them (<50ms).
+        if (this._altGrArmed) {
+            this._altGrArmed = false;
+            clearTimeout(this._altGrTimeout);
+
+            if ((code === "AltRight") &&
+                ((e.timeStamp - this._altGrCtrlTime) < 50)) {
+                // FIXME: We fail to detect this if either Ctrl key is
+                //        first manually pressed as Windows then no
+                //        longer sends the fake Ctrl down event. It
+                //        does however happily send real Ctrl events
+                //        even when AltGr is already down. Some
+                //        browsers detect this for us though and set the
+                //        key to "AltGraph".
+                keysym = KeyTable.XK_ISO_Level3_Shift;
+            } else {
+                this._sendKeyEvent(KeyTable.XK_Control_L, "ControlLeft", true, numlock, capslock);
+            }
+        }
+
+        // We cannot handle keys we cannot track, but we also need
+        // to deal with virtual keyboards which omit key info
+        if (code === 'Unidentified') {
+            if (keysym) {
+                // If it's a virtual keyboard then it should be
+                // sufficient to just send press and release right
+                // after each other
+                this._sendKeyEvent(keysym, code, true, numlock, capslock);
+                this._sendKeyEvent(keysym, code, false, numlock, capslock);
+            }
+
+            stopEvent(e);
+            return;
+        }
+
+        // Alt behaves more like AltGraph on macOS, so shuffle the
+        // keys around a bit to make things more sane for the remote
+        // server. This method is used by RealVNC and TigerVNC (and
+        // possibly others).
+        if (browser.isMac() || browser.isIOS()) {
+            switch (keysym) {
+                case KeyTable.XK_Super_L:
+                    keysym = KeyTable.XK_Alt_L;
+                    break;
+                case KeyTable.XK_Super_R:
+                    keysym = KeyTable.XK_Super_L;
+                    break;
+                case KeyTable.XK_Alt_L:
+                    keysym = KeyTable.XK_Mode_switch;
+                    break;
+                case KeyTable.XK_Alt_R:
+                    keysym = KeyTable.XK_ISO_Level3_Shift;
+                    break;
+            }
+        }
+
+        // Is this key already pressed? If so, then we must use the
+        // same keysym or we'll confuse the server
+        if (code in this._keyDownList) {
+            keysym = this._keyDownList[code];
+        }
+
+        // macOS doesn't send proper key releases if a key is pressed
+        // while meta is held down
+        if ((browser.isMac() || browser.isIOS()) &&
+            (e.metaKey && code !== 'MetaLeft' && code !== 'MetaRight')) {
+            this._sendKeyEvent(keysym, code, true, numlock, capslock);
+            this._sendKeyEvent(keysym, code, false, numlock, capslock);
+            stopEvent(e);
+            return;
+        }
+
+        // macOS doesn't send proper key events for modifiers, only
+        // state change events. That gets extra confusing for CapsLock
+        // which toggles on each press, but not on release. So pretend
+        // it was a quick press and release of the button.
+        if ((browser.isMac() || browser.isIOS()) && (code === 'CapsLock')) {
+            this._sendKeyEvent(KeyTable.XK_Caps_Lock, 'CapsLock', true, numlock, capslock);
+            this._sendKeyEvent(KeyTable.XK_Caps_Lock, 'CapsLock', false, numlock, capslock);
+            stopEvent(e);
+            return;
+        }
+
+        // Windows doesn't send proper key releases for a bunch of
+        // Japanese IM keys so we have to fake the release right away
+        const jpBadKeys = [ KeyTable.XK_Zenkaku_Hankaku,
+                            KeyTable.XK_Eisu_toggle,
+                            KeyTable.XK_Katakana,
+                            KeyTable.XK_Hiragana,
+                            KeyTable.XK_Romaji ];
+        if (browser.isWindows() && jpBadKeys.includes(keysym)) {
+            this._sendKeyEvent(keysym, code, true, numlock, capslock);
+            this._sendKeyEvent(keysym, code, false, numlock, capslock);
+            stopEvent(e);
+            return;
+        }
+
+        stopEvent(e);
+
+        // Possible start of AltGr sequence? (see above)
+        if ((code === "ControlLeft") && browser.isWindows() &&
+            !("ControlLeft" in this._keyDownList)) {
+            this._altGrArmed = true;
+            this._altGrTimeout = setTimeout(this._interruptAltGrSequence.bind(this), 100);
+            this._altGrCtrlTime = e.timeStamp;
+            return;
+        }
+
+        this._sendKeyEvent(keysym, code, true, numlock, capslock);
+    }
+
+    _handleKeyUp(e) {
+        stopEvent(e);
+
+        const code = this._getKeyCode(e);
+
+        // We can't get a release in the middle of an AltGr sequence, so
+        // abort that detection
+        this._interruptAltGrSequence();
+
+        // See comment in _handleKeyDown()
+        if ((browser.isMac() || browser.isIOS()) && (code === 'CapsLock')) {
+            this._sendKeyEvent(KeyTable.XK_Caps_Lock, 'CapsLock', true);
+            this._sendKeyEvent(KeyTable.XK_Caps_Lock, 'CapsLock', false);
+            return;
+        }
+
+        this._sendKeyEvent(this._keyDownList[code], code, false);
+
+        // Windows has a rather nasty bug where it won't send key
+        // release events for a Shift button if the other Shift is still
+        // pressed
+        if (browser.isWindows() && ((code === 'ShiftLeft') ||
+                                    (code === 'ShiftRight'))) {
+            if ('ShiftRight' in this._keyDownList) {
+                this._sendKeyEvent(this._keyDownList['ShiftRight'],
+                                   'ShiftRight', false);
+            }
+            if ('ShiftLeft' in this._keyDownList) {
+                this._sendKeyEvent(this._keyDownList['ShiftLeft'],
+                                   'ShiftLeft', false);
+            }
+        }
+    }
+
+    _interruptAltGrSequence() {
+        if (this._altGrArmed) {
+            this._altGrArmed = false;
+            clearTimeout(this._altGrTimeout);
+            this._sendKeyEvent(KeyTable.XK_Control_L, "ControlLeft", true);
+        }
+    }
+
+    _allKeysUp() {
+        Log.Debug(">> Keyboard.allKeysUp");
+
+        // Prevent control key being processed after losing focus.
+        this._interruptAltGrSequence();
+
+        for (let code in this._keyDownList) {
+            this._sendKeyEvent(this._keyDownList[code], code, false);
+        }
+        Log.Debug("<< Keyboard.allKeysUp");
+    }
+
+    // ===== PUBLIC METHODS =====
+
+    grab() {
+        //Log.Debug(">> Keyboard.grab");
+
+        this._target.addEventListener('keydown', this._eventHandlers.keydown);
+        this._target.addEventListener('keyup', this._eventHandlers.keyup);
+
+        // Release (key up) if window loses focus
+        window.addEventListener('blur', this._eventHandlers.blur);
+
+        //Log.Debug("<< Keyboard.grab");
+    }
+
+    ungrab() {
+        //Log.Debug(">> Keyboard.ungrab");
+
+        this._target.removeEventListener('keydown', this._eventHandlers.keydown);
+        this._target.removeEventListener('keyup', this._eventHandlers.keyup);
+        window.removeEventListener('blur', this._eventHandlers.blur);
+
+        // Release (key up) all keys that are in a down state
+        this._allKeysUp();
+
+        //Log.Debug(">> Keyboard.ungrab");
+    }
+}
pkg/web/noVNC/core/input/keysym.js
@@ -0,0 +1,616 @@
+/* eslint-disable key-spacing */
+
+export default {
+    XK_VoidSymbol:                  0xffffff, /* Void symbol */
+
+    XK_BackSpace:                   0xff08, /* Back space, back char */
+    XK_Tab:                         0xff09,
+    XK_Linefeed:                    0xff0a, /* Linefeed, LF */
+    XK_Clear:                       0xff0b,
+    XK_Return:                      0xff0d, /* Return, enter */
+    XK_Pause:                       0xff13, /* Pause, hold */
+    XK_Scroll_Lock:                 0xff14,
+    XK_Sys_Req:                     0xff15,
+    XK_Escape:                      0xff1b,
+    XK_Delete:                      0xffff, /* Delete, rubout */
+
+    /* International & multi-key character composition */
+
+    XK_Multi_key:                   0xff20, /* Multi-key character compose */
+    XK_Codeinput:                   0xff37,
+    XK_SingleCandidate:             0xff3c,
+    XK_MultipleCandidate:           0xff3d,
+    XK_PreviousCandidate:           0xff3e,
+
+    /* Japanese keyboard support */
+
+    XK_Kanji:                       0xff21, /* Kanji, Kanji convert */
+    XK_Muhenkan:                    0xff22, /* Cancel Conversion */
+    XK_Henkan_Mode:                 0xff23, /* Start/Stop Conversion */
+    XK_Henkan:                      0xff23, /* Alias for Henkan_Mode */
+    XK_Romaji:                      0xff24, /* to Romaji */
+    XK_Hiragana:                    0xff25, /* to Hiragana */
+    XK_Katakana:                    0xff26, /* to Katakana */
+    XK_Hiragana_Katakana:           0xff27, /* Hiragana/Katakana toggle */
+    XK_Zenkaku:                     0xff28, /* to Zenkaku */
+    XK_Hankaku:                     0xff29, /* to Hankaku */
+    XK_Zenkaku_Hankaku:             0xff2a, /* Zenkaku/Hankaku toggle */
+    XK_Touroku:                     0xff2b, /* Add to Dictionary */
+    XK_Massyo:                      0xff2c, /* Delete from Dictionary */
+    XK_Kana_Lock:                   0xff2d, /* Kana Lock */
+    XK_Kana_Shift:                  0xff2e, /* Kana Shift */
+    XK_Eisu_Shift:                  0xff2f, /* Alphanumeric Shift */
+    XK_Eisu_toggle:                 0xff30, /* Alphanumeric toggle */
+    XK_Kanji_Bangou:                0xff37, /* Codeinput */
+    XK_Zen_Koho:                    0xff3d, /* Multiple/All Candidate(s) */
+    XK_Mae_Koho:                    0xff3e, /* Previous Candidate */
+
+    /* Cursor control & motion */
+
+    XK_Home:                        0xff50,
+    XK_Left:                        0xff51, /* Move left, left arrow */
+    XK_Up:                          0xff52, /* Move up, up arrow */
+    XK_Right:                       0xff53, /* Move right, right arrow */
+    XK_Down:                        0xff54, /* Move down, down arrow */
+    XK_Prior:                       0xff55, /* Prior, previous */
+    XK_Page_Up:                     0xff55,
+    XK_Next:                        0xff56, /* Next */
+    XK_Page_Down:                   0xff56,
+    XK_End:                         0xff57, /* EOL */
+    XK_Begin:                       0xff58, /* BOL */
+
+
+    /* Misc functions */
+
+    XK_Select:                      0xff60, /* Select, mark */
+    XK_Print:                       0xff61,
+    XK_Execute:                     0xff62, /* Execute, run, do */
+    XK_Insert:                      0xff63, /* Insert, insert here */
+    XK_Undo:                        0xff65,
+    XK_Redo:                        0xff66, /* Redo, again */
+    XK_Menu:                        0xff67,
+    XK_Find:                        0xff68, /* Find, search */
+    XK_Cancel:                      0xff69, /* Cancel, stop, abort, exit */
+    XK_Help:                        0xff6a, /* Help */
+    XK_Break:                       0xff6b,
+    XK_Mode_switch:                 0xff7e, /* Character set switch */
+    XK_script_switch:               0xff7e, /* Alias for mode_switch */
+    XK_Num_Lock:                    0xff7f,
+
+    /* Keypad functions, keypad numbers cleverly chosen to map to ASCII */
+
+    XK_KP_Space:                    0xff80, /* Space */
+    XK_KP_Tab:                      0xff89,
+    XK_KP_Enter:                    0xff8d, /* Enter */
+    XK_KP_F1:                       0xff91, /* PF1, KP_A, ... */
+    XK_KP_F2:                       0xff92,
+    XK_KP_F3:                       0xff93,
+    XK_KP_F4:                       0xff94,
+    XK_KP_Home:                     0xff95,
+    XK_KP_Left:                     0xff96,
+    XK_KP_Up:                       0xff97,
+    XK_KP_Right:                    0xff98,
+    XK_KP_Down:                     0xff99,
+    XK_KP_Prior:                    0xff9a,
+    XK_KP_Page_Up:                  0xff9a,
+    XK_KP_Next:                     0xff9b,
+    XK_KP_Page_Down:                0xff9b,
+    XK_KP_End:                      0xff9c,
+    XK_KP_Begin:                    0xff9d,
+    XK_KP_Insert:                   0xff9e,
+    XK_KP_Delete:                   0xff9f,
+    XK_KP_Equal:                    0xffbd, /* Equals */
+    XK_KP_Multiply:                 0xffaa,
+    XK_KP_Add:                      0xffab,
+    XK_KP_Separator:                0xffac, /* Separator, often comma */
+    XK_KP_Subtract:                 0xffad,
+    XK_KP_Decimal:                  0xffae,
+    XK_KP_Divide:                   0xffaf,
+
+    XK_KP_0:                        0xffb0,
+    XK_KP_1:                        0xffb1,
+    XK_KP_2:                        0xffb2,
+    XK_KP_3:                        0xffb3,
+    XK_KP_4:                        0xffb4,
+    XK_KP_5:                        0xffb5,
+    XK_KP_6:                        0xffb6,
+    XK_KP_7:                        0xffb7,
+    XK_KP_8:                        0xffb8,
+    XK_KP_9:                        0xffb9,
+
+    /*
+     * Auxiliary functions; note the duplicate definitions for left and right
+     * function keys;  Sun keyboards and a few other manufacturers have such
+     * function key groups on the left and/or right sides of the keyboard.
+     * We've not found a keyboard with more than 35 function keys total.
+     */
+
+    XK_F1:                          0xffbe,
+    XK_F2:                          0xffbf,
+    XK_F3:                          0xffc0,
+    XK_F4:                          0xffc1,
+    XK_F5:                          0xffc2,
+    XK_F6:                          0xffc3,
+    XK_F7:                          0xffc4,
+    XK_F8:                          0xffc5,
+    XK_F9:                          0xffc6,
+    XK_F10:                         0xffc7,
+    XK_F11:                         0xffc8,
+    XK_L1:                          0xffc8,
+    XK_F12:                         0xffc9,
+    XK_L2:                          0xffc9,
+    XK_F13:                         0xffca,
+    XK_L3:                          0xffca,
+    XK_F14:                         0xffcb,
+    XK_L4:                          0xffcb,
+    XK_F15:                         0xffcc,
+    XK_L5:                          0xffcc,
+    XK_F16:                         0xffcd,
+    XK_L6:                          0xffcd,
+    XK_F17:                         0xffce,
+    XK_L7:                          0xffce,
+    XK_F18:                         0xffcf,
+    XK_L8:                          0xffcf,
+    XK_F19:                         0xffd0,
+    XK_L9:                          0xffd0,
+    XK_F20:                         0xffd1,
+    XK_L10:                         0xffd1,
+    XK_F21:                         0xffd2,
+    XK_R1:                          0xffd2,
+    XK_F22:                         0xffd3,
+    XK_R2:                          0xffd3,
+    XK_F23:                         0xffd4,
+    XK_R3:                          0xffd4,
+    XK_F24:                         0xffd5,
+    XK_R4:                          0xffd5,
+    XK_F25:                         0xffd6,
+    XK_R5:                          0xffd6,
+    XK_F26:                         0xffd7,
+    XK_R6:                          0xffd7,
+    XK_F27:                         0xffd8,
+    XK_R7:                          0xffd8,
+    XK_F28:                         0xffd9,
+    XK_R8:                          0xffd9,
+    XK_F29:                         0xffda,
+    XK_R9:                          0xffda,
+    XK_F30:                         0xffdb,
+    XK_R10:                         0xffdb,
+    XK_F31:                         0xffdc,
+    XK_R11:                         0xffdc,
+    XK_F32:                         0xffdd,
+    XK_R12:                         0xffdd,
+    XK_F33:                         0xffde,
+    XK_R13:                         0xffde,
+    XK_F34:                         0xffdf,
+    XK_R14:                         0xffdf,
+    XK_F35:                         0xffe0,
+    XK_R15:                         0xffe0,
+
+    /* Modifiers */
+
+    XK_Shift_L:                     0xffe1, /* Left shift */
+    XK_Shift_R:                     0xffe2, /* Right shift */
+    XK_Control_L:                   0xffe3, /* Left control */
+    XK_Control_R:                   0xffe4, /* Right control */
+    XK_Caps_Lock:                   0xffe5, /* Caps lock */
+    XK_Shift_Lock:                  0xffe6, /* Shift lock */
+
+    XK_Meta_L:                      0xffe7, /* Left meta */
+    XK_Meta_R:                      0xffe8, /* Right meta */
+    XK_Alt_L:                       0xffe9, /* Left alt */
+    XK_Alt_R:                       0xffea, /* Right alt */
+    XK_Super_L:                     0xffeb, /* Left super */
+    XK_Super_R:                     0xffec, /* Right super */
+    XK_Hyper_L:                     0xffed, /* Left hyper */
+    XK_Hyper_R:                     0xffee, /* Right hyper */
+
+    /*
+     * Keyboard (XKB) Extension function and modifier keys
+     * (from Appendix C of "The X Keyboard Extension: Protocol Specification")
+     * Byte 3 = 0xfe
+     */
+
+    XK_ISO_Level3_Shift:            0xfe03, /* AltGr */
+    XK_ISO_Next_Group:              0xfe08,
+    XK_ISO_Prev_Group:              0xfe0a,
+    XK_ISO_First_Group:             0xfe0c,
+    XK_ISO_Last_Group:              0xfe0e,
+
+    /*
+     * Latin 1
+     * (ISO/IEC 8859-1: Unicode U+0020..U+00FF)
+     * Byte 3: 0
+     */
+
+    XK_space:                       0x0020, /* U+0020 SPACE */
+    XK_exclam:                      0x0021, /* U+0021 EXCLAMATION MARK */
+    XK_quotedbl:                    0x0022, /* U+0022 QUOTATION MARK */
+    XK_numbersign:                  0x0023, /* U+0023 NUMBER SIGN */
+    XK_dollar:                      0x0024, /* U+0024 DOLLAR SIGN */
+    XK_percent:                     0x0025, /* U+0025 PERCENT SIGN */
+    XK_ampersand:                   0x0026, /* U+0026 AMPERSAND */
+    XK_apostrophe:                  0x0027, /* U+0027 APOSTROPHE */
+    XK_quoteright:                  0x0027, /* deprecated */
+    XK_parenleft:                   0x0028, /* U+0028 LEFT PARENTHESIS */
+    XK_parenright:                  0x0029, /* U+0029 RIGHT PARENTHESIS */
+    XK_asterisk:                    0x002a, /* U+002A ASTERISK */
+    XK_plus:                        0x002b, /* U+002B PLUS SIGN */
+    XK_comma:                       0x002c, /* U+002C COMMA */
+    XK_minus:                       0x002d, /* U+002D HYPHEN-MINUS */
+    XK_period:                      0x002e, /* U+002E FULL STOP */
+    XK_slash:                       0x002f, /* U+002F SOLIDUS */
+    XK_0:                           0x0030, /* U+0030 DIGIT ZERO */
+    XK_1:                           0x0031, /* U+0031 DIGIT ONE */
+    XK_2:                           0x0032, /* U+0032 DIGIT TWO */
+    XK_3:                           0x0033, /* U+0033 DIGIT THREE */
+    XK_4:                           0x0034, /* U+0034 DIGIT FOUR */
+    XK_5:                           0x0035, /* U+0035 DIGIT FIVE */
+    XK_6:                           0x0036, /* U+0036 DIGIT SIX */
+    XK_7:                           0x0037, /* U+0037 DIGIT SEVEN */
+    XK_8:                           0x0038, /* U+0038 DIGIT EIGHT */
+    XK_9:                           0x0039, /* U+0039 DIGIT NINE */
+    XK_colon:                       0x003a, /* U+003A COLON */
+    XK_semicolon:                   0x003b, /* U+003B SEMICOLON */
+    XK_less:                        0x003c, /* U+003C LESS-THAN SIGN */
+    XK_equal:                       0x003d, /* U+003D EQUALS SIGN */
+    XK_greater:                     0x003e, /* U+003E GREATER-THAN SIGN */
+    XK_question:                    0x003f, /* U+003F QUESTION MARK */
+    XK_at:                          0x0040, /* U+0040 COMMERCIAL AT */
+    XK_A:                           0x0041, /* U+0041 LATIN CAPITAL LETTER A */
+    XK_B:                           0x0042, /* U+0042 LATIN CAPITAL LETTER B */
+    XK_C:                           0x0043, /* U+0043 LATIN CAPITAL LETTER C */
+    XK_D:                           0x0044, /* U+0044 LATIN CAPITAL LETTER D */
+    XK_E:                           0x0045, /* U+0045 LATIN CAPITAL LETTER E */
+    XK_F:                           0x0046, /* U+0046 LATIN CAPITAL LETTER F */
+    XK_G:                           0x0047, /* U+0047 LATIN CAPITAL LETTER G */
+    XK_H:                           0x0048, /* U+0048 LATIN CAPITAL LETTER H */
+    XK_I:                           0x0049, /* U+0049 LATIN CAPITAL LETTER I */
+    XK_J:                           0x004a, /* U+004A LATIN CAPITAL LETTER J */
+    XK_K:                           0x004b, /* U+004B LATIN CAPITAL LETTER K */
+    XK_L:                           0x004c, /* U+004C LATIN CAPITAL LETTER L */
+    XK_M:                           0x004d, /* U+004D LATIN CAPITAL LETTER M */
+    XK_N:                           0x004e, /* U+004E LATIN CAPITAL LETTER N */
+    XK_O:                           0x004f, /* U+004F LATIN CAPITAL LETTER O */
+    XK_P:                           0x0050, /* U+0050 LATIN CAPITAL LETTER P */
+    XK_Q:                           0x0051, /* U+0051 LATIN CAPITAL LETTER Q */
+    XK_R:                           0x0052, /* U+0052 LATIN CAPITAL LETTER R */
+    XK_S:                           0x0053, /* U+0053 LATIN CAPITAL LETTER S */
+    XK_T:                           0x0054, /* U+0054 LATIN CAPITAL LETTER T */
+    XK_U:                           0x0055, /* U+0055 LATIN CAPITAL LETTER U */
+    XK_V:                           0x0056, /* U+0056 LATIN CAPITAL LETTER V */
+    XK_W:                           0x0057, /* U+0057 LATIN CAPITAL LETTER W */
+    XK_X:                           0x0058, /* U+0058 LATIN CAPITAL LETTER X */
+    XK_Y:                           0x0059, /* U+0059 LATIN CAPITAL LETTER Y */
+    XK_Z:                           0x005a, /* U+005A LATIN CAPITAL LETTER Z */
+    XK_bracketleft:                 0x005b, /* U+005B LEFT SQUARE BRACKET */
+    XK_backslash:                   0x005c, /* U+005C REVERSE SOLIDUS */
+    XK_bracketright:                0x005d, /* U+005D RIGHT SQUARE BRACKET */
+    XK_asciicircum:                 0x005e, /* U+005E CIRCUMFLEX ACCENT */
+    XK_underscore:                  0x005f, /* U+005F LOW LINE */
+    XK_grave:                       0x0060, /* U+0060 GRAVE ACCENT */
+    XK_quoteleft:                   0x0060, /* deprecated */
+    XK_a:                           0x0061, /* U+0061 LATIN SMALL LETTER A */
+    XK_b:                           0x0062, /* U+0062 LATIN SMALL LETTER B */
+    XK_c:                           0x0063, /* U+0063 LATIN SMALL LETTER C */
+    XK_d:                           0x0064, /* U+0064 LATIN SMALL LETTER D */
+    XK_e:                           0x0065, /* U+0065 LATIN SMALL LETTER E */
+    XK_f:                           0x0066, /* U+0066 LATIN SMALL LETTER F */
+    XK_g:                           0x0067, /* U+0067 LATIN SMALL LETTER G */
+    XK_h:                           0x0068, /* U+0068 LATIN SMALL LETTER H */
+    XK_i:                           0x0069, /* U+0069 LATIN SMALL LETTER I */
+    XK_j:                           0x006a, /* U+006A LATIN SMALL LETTER J */
+    XK_k:                           0x006b, /* U+006B LATIN SMALL LETTER K */
+    XK_l:                           0x006c, /* U+006C LATIN SMALL LETTER L */
+    XK_m:                           0x006d, /* U+006D LATIN SMALL LETTER M */
+    XK_n:                           0x006e, /* U+006E LATIN SMALL LETTER N */
+    XK_o:                           0x006f, /* U+006F LATIN SMALL LETTER O */
+    XK_p:                           0x0070, /* U+0070 LATIN SMALL LETTER P */
+    XK_q:                           0x0071, /* U+0071 LATIN SMALL LETTER Q */
+    XK_r:                           0x0072, /* U+0072 LATIN SMALL LETTER R */
+    XK_s:                           0x0073, /* U+0073 LATIN SMALL LETTER S */
+    XK_t:                           0x0074, /* U+0074 LATIN SMALL LETTER T */
+    XK_u:                           0x0075, /* U+0075 LATIN SMALL LETTER U */
+    XK_v:                           0x0076, /* U+0076 LATIN SMALL LETTER V */
+    XK_w:                           0x0077, /* U+0077 LATIN SMALL LETTER W */
+    XK_x:                           0x0078, /* U+0078 LATIN SMALL LETTER X */
+    XK_y:                           0x0079, /* U+0079 LATIN SMALL LETTER Y */
+    XK_z:                           0x007a, /* U+007A LATIN SMALL LETTER Z */
+    XK_braceleft:                   0x007b, /* U+007B LEFT CURLY BRACKET */
+    XK_bar:                         0x007c, /* U+007C VERTICAL LINE */
+    XK_braceright:                  0x007d, /* U+007D RIGHT CURLY BRACKET */
+    XK_asciitilde:                  0x007e, /* U+007E TILDE */
+
+    XK_nobreakspace:                0x00a0, /* U+00A0 NO-BREAK SPACE */
+    XK_exclamdown:                  0x00a1, /* U+00A1 INVERTED EXCLAMATION MARK */
+    XK_cent:                        0x00a2, /* U+00A2 CENT SIGN */
+    XK_sterling:                    0x00a3, /* U+00A3 POUND SIGN */
+    XK_currency:                    0x00a4, /* U+00A4 CURRENCY SIGN */
+    XK_yen:                         0x00a5, /* U+00A5 YEN SIGN */
+    XK_brokenbar:                   0x00a6, /* U+00A6 BROKEN BAR */
+    XK_section:                     0x00a7, /* U+00A7 SECTION SIGN */
+    XK_diaeresis:                   0x00a8, /* U+00A8 DIAERESIS */
+    XK_copyright:                   0x00a9, /* U+00A9 COPYRIGHT SIGN */
+    XK_ordfeminine:                 0x00aa, /* U+00AA FEMININE ORDINAL INDICATOR */
+    XK_guillemotleft:               0x00ab, /* U+00AB LEFT-POINTING DOUBLE ANGLE QUOTATION MARK */
+    XK_notsign:                     0x00ac, /* U+00AC NOT SIGN */
+    XK_hyphen:                      0x00ad, /* U+00AD SOFT HYPHEN */
+    XK_registered:                  0x00ae, /* U+00AE REGISTERED SIGN */
+    XK_macron:                      0x00af, /* U+00AF MACRON */
+    XK_degree:                      0x00b0, /* U+00B0 DEGREE SIGN */
+    XK_plusminus:                   0x00b1, /* U+00B1 PLUS-MINUS SIGN */
+    XK_twosuperior:                 0x00b2, /* U+00B2 SUPERSCRIPT TWO */
+    XK_threesuperior:               0x00b3, /* U+00B3 SUPERSCRIPT THREE */
+    XK_acute:                       0x00b4, /* U+00B4 ACUTE ACCENT */
+    XK_mu:                          0x00b5, /* U+00B5 MICRO SIGN */
+    XK_paragraph:                   0x00b6, /* U+00B6 PILCROW SIGN */
+    XK_periodcentered:              0x00b7, /* U+00B7 MIDDLE DOT */
+    XK_cedilla:                     0x00b8, /* U+00B8 CEDILLA */
+    XK_onesuperior:                 0x00b9, /* U+00B9 SUPERSCRIPT ONE */
+    XK_masculine:                   0x00ba, /* U+00BA MASCULINE ORDINAL INDICATOR */
+    XK_guillemotright:              0x00bb, /* U+00BB RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK */
+    XK_onequarter:                  0x00bc, /* U+00BC VULGAR FRACTION ONE QUARTER */
+    XK_onehalf:                     0x00bd, /* U+00BD VULGAR FRACTION ONE HALF */
+    XK_threequarters:               0x00be, /* U+00BE VULGAR FRACTION THREE QUARTERS */
+    XK_questiondown:                0x00bf, /* U+00BF INVERTED QUESTION MARK */
+    XK_Agrave:                      0x00c0, /* U+00C0 LATIN CAPITAL LETTER A WITH GRAVE */
+    XK_Aacute:                      0x00c1, /* U+00C1 LATIN CAPITAL LETTER A WITH ACUTE */
+    XK_Acircumflex:                 0x00c2, /* U+00C2 LATIN CAPITAL LETTER A WITH CIRCUMFLEX */
+    XK_Atilde:                      0x00c3, /* U+00C3 LATIN CAPITAL LETTER A WITH TILDE */
+    XK_Adiaeresis:                  0x00c4, /* U+00C4 LATIN CAPITAL LETTER A WITH DIAERESIS */
+    XK_Aring:                       0x00c5, /* U+00C5 LATIN CAPITAL LETTER A WITH RING ABOVE */
+    XK_AE:                          0x00c6, /* U+00C6 LATIN CAPITAL LETTER AE */
+    XK_Ccedilla:                    0x00c7, /* U+00C7 LATIN CAPITAL LETTER C WITH CEDILLA */
+    XK_Egrave:                      0x00c8, /* U+00C8 LATIN CAPITAL LETTER E WITH GRAVE */
+    XK_Eacute:                      0x00c9, /* U+00C9 LATIN CAPITAL LETTER E WITH ACUTE */
+    XK_Ecircumflex:                 0x00ca, /* U+00CA LATIN CAPITAL LETTER E WITH CIRCUMFLEX */
+    XK_Ediaeresis:                  0x00cb, /* U+00CB LATIN CAPITAL LETTER E WITH DIAERESIS */
+    XK_Igrave:                      0x00cc, /* U+00CC LATIN CAPITAL LETTER I WITH GRAVE */
+    XK_Iacute:                      0x00cd, /* U+00CD LATIN CAPITAL LETTER I WITH ACUTE */
+    XK_Icircumflex:                 0x00ce, /* U+00CE LATIN CAPITAL LETTER I WITH CIRCUMFLEX */
+    XK_Idiaeresis:                  0x00cf, /* U+00CF LATIN CAPITAL LETTER I WITH DIAERESIS */
+    XK_ETH:                         0x00d0, /* U+00D0 LATIN CAPITAL LETTER ETH */
+    XK_Eth:                         0x00d0, /* deprecated */
+    XK_Ntilde:                      0x00d1, /* U+00D1 LATIN CAPITAL LETTER N WITH TILDE */
+    XK_Ograve:                      0x00d2, /* U+00D2 LATIN CAPITAL LETTER O WITH GRAVE */
+    XK_Oacute:                      0x00d3, /* U+00D3 LATIN CAPITAL LETTER O WITH ACUTE */
+    XK_Ocircumflex:                 0x00d4, /* U+00D4 LATIN CAPITAL LETTER O WITH CIRCUMFLEX */
+    XK_Otilde:                      0x00d5, /* U+00D5 LATIN CAPITAL LETTER O WITH TILDE */
+    XK_Odiaeresis:                  0x00d6, /* U+00D6 LATIN CAPITAL LETTER O WITH DIAERESIS */
+    XK_multiply:                    0x00d7, /* U+00D7 MULTIPLICATION SIGN */
+    XK_Oslash:                      0x00d8, /* U+00D8 LATIN CAPITAL LETTER O WITH STROKE */
+    XK_Ooblique:                    0x00d8, /* U+00D8 LATIN CAPITAL LETTER O WITH STROKE */
+    XK_Ugrave:                      0x00d9, /* U+00D9 LATIN CAPITAL LETTER U WITH GRAVE */
+    XK_Uacute:                      0x00da, /* U+00DA LATIN CAPITAL LETTER U WITH ACUTE */
+    XK_Ucircumflex:                 0x00db, /* U+00DB LATIN CAPITAL LETTER U WITH CIRCUMFLEX */
+    XK_Udiaeresis:                  0x00dc, /* U+00DC LATIN CAPITAL LETTER U WITH DIAERESIS */
+    XK_Yacute:                      0x00dd, /* U+00DD LATIN CAPITAL LETTER Y WITH ACUTE */
+    XK_THORN:                       0x00de, /* U+00DE LATIN CAPITAL LETTER THORN */
+    XK_Thorn:                       0x00de, /* deprecated */
+    XK_ssharp:                      0x00df, /* U+00DF LATIN SMALL LETTER SHARP S */
+    XK_agrave:                      0x00e0, /* U+00E0 LATIN SMALL LETTER A WITH GRAVE */
+    XK_aacute:                      0x00e1, /* U+00E1 LATIN SMALL LETTER A WITH ACUTE */
+    XK_acircumflex:                 0x00e2, /* U+00E2 LATIN SMALL LETTER A WITH CIRCUMFLEX */
+    XK_atilde:                      0x00e3, /* U+00E3 LATIN SMALL LETTER A WITH TILDE */
+    XK_adiaeresis:                  0x00e4, /* U+00E4 LATIN SMALL LETTER A WITH DIAERESIS */
+    XK_aring:                       0x00e5, /* U+00E5 LATIN SMALL LETTER A WITH RING ABOVE */
+    XK_ae:                          0x00e6, /* U+00E6 LATIN SMALL LETTER AE */
+    XK_ccedilla:                    0x00e7, /* U+00E7 LATIN SMALL LETTER C WITH CEDILLA */
+    XK_egrave:                      0x00e8, /* U+00E8 LATIN SMALL LETTER E WITH GRAVE */
+    XK_eacute:                      0x00e9, /* U+00E9 LATIN SMALL LETTER E WITH ACUTE */
+    XK_ecircumflex:                 0x00ea, /* U+00EA LATIN SMALL LETTER E WITH CIRCUMFLEX */
+    XK_ediaeresis:                  0x00eb, /* U+00EB LATIN SMALL LETTER E WITH DIAERESIS */
+    XK_igrave:                      0x00ec, /* U+00EC LATIN SMALL LETTER I WITH GRAVE */
+    XK_iacute:                      0x00ed, /* U+00ED LATIN SMALL LETTER I WITH ACUTE */
+    XK_icircumflex:                 0x00ee, /* U+00EE LATIN SMALL LETTER I WITH CIRCUMFLEX */
+    XK_idiaeresis:                  0x00ef, /* U+00EF LATIN SMALL LETTER I WITH DIAERESIS */
+    XK_eth:                         0x00f0, /* U+00F0 LATIN SMALL LETTER ETH */
+    XK_ntilde:                      0x00f1, /* U+00F1 LATIN SMALL LETTER N WITH TILDE */
+    XK_ograve:                      0x00f2, /* U+00F2 LATIN SMALL LETTER O WITH GRAVE */
+    XK_oacute:                      0x00f3, /* U+00F3 LATIN SMALL LETTER O WITH ACUTE */
+    XK_ocircumflex:                 0x00f4, /* U+00F4 LATIN SMALL LETTER O WITH CIRCUMFLEX */
+    XK_otilde:                      0x00f5, /* U+00F5 LATIN SMALL LETTER O WITH TILDE */
+    XK_odiaeresis:                  0x00f6, /* U+00F6 LATIN SMALL LETTER O WITH DIAERESIS */
+    XK_division:                    0x00f7, /* U+00F7 DIVISION SIGN */
+    XK_oslash:                      0x00f8, /* U+00F8 LATIN SMALL LETTER O WITH STROKE */
+    XK_ooblique:                    0x00f8, /* U+00F8 LATIN SMALL LETTER O WITH STROKE */
+    XK_ugrave:                      0x00f9, /* U+00F9 LATIN SMALL LETTER U WITH GRAVE */
+    XK_uacute:                      0x00fa, /* U+00FA LATIN SMALL LETTER U WITH ACUTE */
+    XK_ucircumflex:                 0x00fb, /* U+00FB LATIN SMALL LETTER U WITH CIRCUMFLEX */
+    XK_udiaeresis:                  0x00fc, /* U+00FC LATIN SMALL LETTER U WITH DIAERESIS */
+    XK_yacute:                      0x00fd, /* U+00FD LATIN SMALL LETTER Y WITH ACUTE */
+    XK_thorn:                       0x00fe, /* U+00FE LATIN SMALL LETTER THORN */
+    XK_ydiaeresis:                  0x00ff, /* U+00FF LATIN SMALL LETTER Y WITH DIAERESIS */
+
+    /*
+     * Korean
+     * Byte 3 = 0x0e
+     */
+
+    XK_Hangul:                      0xff31, /* Hangul start/stop(toggle) */
+    XK_Hangul_Hanja:                0xff34, /* Start Hangul->Hanja Conversion */
+    XK_Hangul_Jeonja:               0xff38, /* Jeonja mode */
+
+    /*
+     * XFree86 vendor specific keysyms.
+     *
+     * The XFree86 keysym range is 0x10080001 - 0x1008FFFF.
+     */
+
+    XF86XK_ModeLock:                0x1008FF01,
+    XF86XK_MonBrightnessUp:         0x1008FF02,
+    XF86XK_MonBrightnessDown:       0x1008FF03,
+    XF86XK_KbdLightOnOff:           0x1008FF04,
+    XF86XK_KbdBrightnessUp:         0x1008FF05,
+    XF86XK_KbdBrightnessDown:       0x1008FF06,
+    XF86XK_Standby:                 0x1008FF10,
+    XF86XK_AudioLowerVolume:        0x1008FF11,
+    XF86XK_AudioMute:               0x1008FF12,
+    XF86XK_AudioRaiseVolume:        0x1008FF13,
+    XF86XK_AudioPlay:               0x1008FF14,
+    XF86XK_AudioStop:               0x1008FF15,
+    XF86XK_AudioPrev:               0x1008FF16,
+    XF86XK_AudioNext:               0x1008FF17,
+    XF86XK_HomePage:                0x1008FF18,
+    XF86XK_Mail:                    0x1008FF19,
+    XF86XK_Start:                   0x1008FF1A,
+    XF86XK_Search:                  0x1008FF1B,
+    XF86XK_AudioRecord:             0x1008FF1C,
+    XF86XK_Calculator:              0x1008FF1D,
+    XF86XK_Memo:                    0x1008FF1E,
+    XF86XK_ToDoList:                0x1008FF1F,
+    XF86XK_Calendar:                0x1008FF20,
+    XF86XK_PowerDown:               0x1008FF21,
+    XF86XK_ContrastAdjust:          0x1008FF22,
+    XF86XK_RockerUp:                0x1008FF23,
+    XF86XK_RockerDown:              0x1008FF24,
+    XF86XK_RockerEnter:             0x1008FF25,
+    XF86XK_Back:                    0x1008FF26,
+    XF86XK_Forward:                 0x1008FF27,
+    XF86XK_Stop:                    0x1008FF28,
+    XF86XK_Refresh:                 0x1008FF29,
+    XF86XK_PowerOff:                0x1008FF2A,
+    XF86XK_WakeUp:                  0x1008FF2B,
+    XF86XK_Eject:                   0x1008FF2C,
+    XF86XK_ScreenSaver:             0x1008FF2D,
+    XF86XK_WWW:                     0x1008FF2E,
+    XF86XK_Sleep:                   0x1008FF2F,
+    XF86XK_Favorites:               0x1008FF30,
+    XF86XK_AudioPause:              0x1008FF31,
+    XF86XK_AudioMedia:              0x1008FF32,
+    XF86XK_MyComputer:              0x1008FF33,
+    XF86XK_VendorHome:              0x1008FF34,
+    XF86XK_LightBulb:               0x1008FF35,
+    XF86XK_Shop:                    0x1008FF36,
+    XF86XK_History:                 0x1008FF37,
+    XF86XK_OpenURL:                 0x1008FF38,
+    XF86XK_AddFavorite:             0x1008FF39,
+    XF86XK_HotLinks:                0x1008FF3A,
+    XF86XK_BrightnessAdjust:        0x1008FF3B,
+    XF86XK_Finance:                 0x1008FF3C,
+    XF86XK_Community:               0x1008FF3D,
+    XF86XK_AudioRewind:             0x1008FF3E,
+    XF86XK_BackForward:             0x1008FF3F,
+    XF86XK_Launch0:                 0x1008FF40,
+    XF86XK_Launch1:                 0x1008FF41,
+    XF86XK_Launch2:                 0x1008FF42,
+    XF86XK_Launch3:                 0x1008FF43,
+    XF86XK_Launch4:                 0x1008FF44,
+    XF86XK_Launch5:                 0x1008FF45,
+    XF86XK_Launch6:                 0x1008FF46,
+    XF86XK_Launch7:                 0x1008FF47,
+    XF86XK_Launch8:                 0x1008FF48,
+    XF86XK_Launch9:                 0x1008FF49,
+    XF86XK_LaunchA:                 0x1008FF4A,
+    XF86XK_LaunchB:                 0x1008FF4B,
+    XF86XK_LaunchC:                 0x1008FF4C,
+    XF86XK_LaunchD:                 0x1008FF4D,
+    XF86XK_LaunchE:                 0x1008FF4E,
+    XF86XK_LaunchF:                 0x1008FF4F,
+    XF86XK_ApplicationLeft:         0x1008FF50,
+    XF86XK_ApplicationRight:        0x1008FF51,
+    XF86XK_Book:                    0x1008FF52,
+    XF86XK_CD:                      0x1008FF53,
+    XF86XK_Calculater:              0x1008FF54,
+    XF86XK_Clear:                   0x1008FF55,
+    XF86XK_Close:                   0x1008FF56,
+    XF86XK_Copy:                    0x1008FF57,
+    XF86XK_Cut:                     0x1008FF58,
+    XF86XK_Display:                 0x1008FF59,
+    XF86XK_DOS:                     0x1008FF5A,
+    XF86XK_Documents:               0x1008FF5B,
+    XF86XK_Excel:                   0x1008FF5C,
+    XF86XK_Explorer:                0x1008FF5D,
+    XF86XK_Game:                    0x1008FF5E,
+    XF86XK_Go:                      0x1008FF5F,
+    XF86XK_iTouch:                  0x1008FF60,
+    XF86XK_LogOff:                  0x1008FF61,
+    XF86XK_Market:                  0x1008FF62,
+    XF86XK_Meeting:                 0x1008FF63,
+    XF86XK_MenuKB:                  0x1008FF65,
+    XF86XK_MenuPB:                  0x1008FF66,
+    XF86XK_MySites:                 0x1008FF67,
+    XF86XK_New:                     0x1008FF68,
+    XF86XK_News:                    0x1008FF69,
+    XF86XK_OfficeHome:              0x1008FF6A,
+    XF86XK_Open:                    0x1008FF6B,
+    XF86XK_Option:                  0x1008FF6C,
+    XF86XK_Paste:                   0x1008FF6D,
+    XF86XK_Phone:                   0x1008FF6E,
+    XF86XK_Q:                       0x1008FF70,
+    XF86XK_Reply:                   0x1008FF72,
+    XF86XK_Reload:                  0x1008FF73,
+    XF86XK_RotateWindows:           0x1008FF74,
+    XF86XK_RotationPB:              0x1008FF75,
+    XF86XK_RotationKB:              0x1008FF76,
+    XF86XK_Save:                    0x1008FF77,
+    XF86XK_ScrollUp:                0x1008FF78,
+    XF86XK_ScrollDown:              0x1008FF79,
+    XF86XK_ScrollClick:             0x1008FF7A,
+    XF86XK_Send:                    0x1008FF7B,
+    XF86XK_Spell:                   0x1008FF7C,
+    XF86XK_SplitScreen:             0x1008FF7D,
+    XF86XK_Support:                 0x1008FF7E,
+    XF86XK_TaskPane:                0x1008FF7F,
+    XF86XK_Terminal:                0x1008FF80,
+    XF86XK_Tools:                   0x1008FF81,
+    XF86XK_Travel:                  0x1008FF82,
+    XF86XK_UserPB:                  0x1008FF84,
+    XF86XK_User1KB:                 0x1008FF85,
+    XF86XK_User2KB:                 0x1008FF86,
+    XF86XK_Video:                   0x1008FF87,
+    XF86XK_WheelButton:             0x1008FF88,
+    XF86XK_Word:                    0x1008FF89,
+    XF86XK_Xfer:                    0x1008FF8A,
+    XF86XK_ZoomIn:                  0x1008FF8B,
+    XF86XK_ZoomOut:                 0x1008FF8C,
+    XF86XK_Away:                    0x1008FF8D,
+    XF86XK_Messenger:               0x1008FF8E,
+    XF86XK_WebCam:                  0x1008FF8F,
+    XF86XK_MailForward:             0x1008FF90,
+    XF86XK_Pictures:                0x1008FF91,
+    XF86XK_Music:                   0x1008FF92,
+    XF86XK_Battery:                 0x1008FF93,
+    XF86XK_Bluetooth:               0x1008FF94,
+    XF86XK_WLAN:                    0x1008FF95,
+    XF86XK_UWB:                     0x1008FF96,
+    XF86XK_AudioForward:            0x1008FF97,
+    XF86XK_AudioRepeat:             0x1008FF98,
+    XF86XK_AudioRandomPlay:         0x1008FF99,
+    XF86XK_Subtitle:                0x1008FF9A,
+    XF86XK_AudioCycleTrack:         0x1008FF9B,
+    XF86XK_CycleAngle:              0x1008FF9C,
+    XF86XK_FrameBack:               0x1008FF9D,
+    XF86XK_FrameForward:            0x1008FF9E,
+    XF86XK_Time:                    0x1008FF9F,
+    XF86XK_Select:                  0x1008FFA0,
+    XF86XK_View:                    0x1008FFA1,
+    XF86XK_TopMenu:                 0x1008FFA2,
+    XF86XK_Red:                     0x1008FFA3,
+    XF86XK_Green:                   0x1008FFA4,
+    XF86XK_Yellow:                  0x1008FFA5,
+    XF86XK_Blue:                    0x1008FFA6,
+    XF86XK_Suspend:                 0x1008FFA7,
+    XF86XK_Hibernate:               0x1008FFA8,
+    XF86XK_TouchpadToggle:          0x1008FFA9,
+    XF86XK_TouchpadOn:              0x1008FFB0,
+    XF86XK_TouchpadOff:             0x1008FFB1,
+    XF86XK_AudioMicMute:            0x1008FFB2,
+    XF86XK_Switch_VT_1:             0x1008FE01,
+    XF86XK_Switch_VT_2:             0x1008FE02,
+    XF86XK_Switch_VT_3:             0x1008FE03,
+    XF86XK_Switch_VT_4:             0x1008FE04,
+    XF86XK_Switch_VT_5:             0x1008FE05,
+    XF86XK_Switch_VT_6:             0x1008FE06,
+    XF86XK_Switch_VT_7:             0x1008FE07,
+    XF86XK_Switch_VT_8:             0x1008FE08,
+    XF86XK_Switch_VT_9:             0x1008FE09,
+    XF86XK_Switch_VT_10:            0x1008FE0A,
+    XF86XK_Switch_VT_11:            0x1008FE0B,
+    XF86XK_Switch_VT_12:            0x1008FE0C,
+    XF86XK_Ungrab:                  0x1008FE20,
+    XF86XK_ClearGrab:               0x1008FE21,
+    XF86XK_Next_VMode:              0x1008FE22,
+    XF86XK_Prev_VMode:              0x1008FE23,
+    XF86XK_LogWindowTree:           0x1008FE24,
+    XF86XK_LogGrabInfo:             0x1008FE25,
+};
pkg/web/noVNC/core/input/keysymdef.js
@@ -0,0 +1,688 @@
+/*
+ * Mapping from Unicode codepoints to X11/RFB keysyms
+ *
+ * This file was automatically generated from keysymdef.h
+ * DO NOT EDIT!
+ */
+
+/* Functions at the bottom */
+
+const codepoints = {
+    0x0100: 0x03c0, // XK_Amacron
+    0x0101: 0x03e0, // XK_amacron
+    0x0102: 0x01c3, // XK_Abreve
+    0x0103: 0x01e3, // XK_abreve
+    0x0104: 0x01a1, // XK_Aogonek
+    0x0105: 0x01b1, // XK_aogonek
+    0x0106: 0x01c6, // XK_Cacute
+    0x0107: 0x01e6, // XK_cacute
+    0x0108: 0x02c6, // XK_Ccircumflex
+    0x0109: 0x02e6, // XK_ccircumflex
+    0x010a: 0x02c5, // XK_Cabovedot
+    0x010b: 0x02e5, // XK_cabovedot
+    0x010c: 0x01c8, // XK_Ccaron
+    0x010d: 0x01e8, // XK_ccaron
+    0x010e: 0x01cf, // XK_Dcaron
+    0x010f: 0x01ef, // XK_dcaron
+    0x0110: 0x01d0, // XK_Dstroke
+    0x0111: 0x01f0, // XK_dstroke
+    0x0112: 0x03aa, // XK_Emacron
+    0x0113: 0x03ba, // XK_emacron
+    0x0116: 0x03cc, // XK_Eabovedot
+    0x0117: 0x03ec, // XK_eabovedot
+    0x0118: 0x01ca, // XK_Eogonek
+    0x0119: 0x01ea, // XK_eogonek
+    0x011a: 0x01cc, // XK_Ecaron
+    0x011b: 0x01ec, // XK_ecaron
+    0x011c: 0x02d8, // XK_Gcircumflex
+    0x011d: 0x02f8, // XK_gcircumflex
+    0x011e: 0x02ab, // XK_Gbreve
+    0x011f: 0x02bb, // XK_gbreve
+    0x0120: 0x02d5, // XK_Gabovedot
+    0x0121: 0x02f5, // XK_gabovedot
+    0x0122: 0x03ab, // XK_Gcedilla
+    0x0123: 0x03bb, // XK_gcedilla
+    0x0124: 0x02a6, // XK_Hcircumflex
+    0x0125: 0x02b6, // XK_hcircumflex
+    0x0126: 0x02a1, // XK_Hstroke
+    0x0127: 0x02b1, // XK_hstroke
+    0x0128: 0x03a5, // XK_Itilde
+    0x0129: 0x03b5, // XK_itilde
+    0x012a: 0x03cf, // XK_Imacron
+    0x012b: 0x03ef, // XK_imacron
+    0x012e: 0x03c7, // XK_Iogonek
+    0x012f: 0x03e7, // XK_iogonek
+    0x0130: 0x02a9, // XK_Iabovedot
+    0x0131: 0x02b9, // XK_idotless
+    0x0134: 0x02ac, // XK_Jcircumflex
+    0x0135: 0x02bc, // XK_jcircumflex
+    0x0136: 0x03d3, // XK_Kcedilla
+    0x0137: 0x03f3, // XK_kcedilla
+    0x0138: 0x03a2, // XK_kra
+    0x0139: 0x01c5, // XK_Lacute
+    0x013a: 0x01e5, // XK_lacute
+    0x013b: 0x03a6, // XK_Lcedilla
+    0x013c: 0x03b6, // XK_lcedilla
+    0x013d: 0x01a5, // XK_Lcaron
+    0x013e: 0x01b5, // XK_lcaron
+    0x0141: 0x01a3, // XK_Lstroke
+    0x0142: 0x01b3, // XK_lstroke
+    0x0143: 0x01d1, // XK_Nacute
+    0x0144: 0x01f1, // XK_nacute
+    0x0145: 0x03d1, // XK_Ncedilla
+    0x0146: 0x03f1, // XK_ncedilla
+    0x0147: 0x01d2, // XK_Ncaron
+    0x0148: 0x01f2, // XK_ncaron
+    0x014a: 0x03bd, // XK_ENG
+    0x014b: 0x03bf, // XK_eng
+    0x014c: 0x03d2, // XK_Omacron
+    0x014d: 0x03f2, // XK_omacron
+    0x0150: 0x01d5, // XK_Odoubleacute
+    0x0151: 0x01f5, // XK_odoubleacute
+    0x0152: 0x13bc, // XK_OE
+    0x0153: 0x13bd, // XK_oe
+    0x0154: 0x01c0, // XK_Racute
+    0x0155: 0x01e0, // XK_racute
+    0x0156: 0x03a3, // XK_Rcedilla
+    0x0157: 0x03b3, // XK_rcedilla
+    0x0158: 0x01d8, // XK_Rcaron
+    0x0159: 0x01f8, // XK_rcaron
+    0x015a: 0x01a6, // XK_Sacute
+    0x015b: 0x01b6, // XK_sacute
+    0x015c: 0x02de, // XK_Scircumflex
+    0x015d: 0x02fe, // XK_scircumflex
+    0x015e: 0x01aa, // XK_Scedilla
+    0x015f: 0x01ba, // XK_scedilla
+    0x0160: 0x01a9, // XK_Scaron
+    0x0161: 0x01b9, // XK_scaron
+    0x0162: 0x01de, // XK_Tcedilla
+    0x0163: 0x01fe, // XK_tcedilla
+    0x0164: 0x01ab, // XK_Tcaron
+    0x0165: 0x01bb, // XK_tcaron
+    0x0166: 0x03ac, // XK_Tslash
+    0x0167: 0x03bc, // XK_tslash
+    0x0168: 0x03dd, // XK_Utilde
+    0x0169: 0x03fd, // XK_utilde
+    0x016a: 0x03de, // XK_Umacron
+    0x016b: 0x03fe, // XK_umacron
+    0x016c: 0x02dd, // XK_Ubreve
+    0x016d: 0x02fd, // XK_ubreve
+    0x016e: 0x01d9, // XK_Uring
+    0x016f: 0x01f9, // XK_uring
+    0x0170: 0x01db, // XK_Udoubleacute
+    0x0171: 0x01fb, // XK_udoubleacute
+    0x0172: 0x03d9, // XK_Uogonek
+    0x0173: 0x03f9, // XK_uogonek
+    0x0178: 0x13be, // XK_Ydiaeresis
+    0x0179: 0x01ac, // XK_Zacute
+    0x017a: 0x01bc, // XK_zacute
+    0x017b: 0x01af, // XK_Zabovedot
+    0x017c: 0x01bf, // XK_zabovedot
+    0x017d: 0x01ae, // XK_Zcaron
+    0x017e: 0x01be, // XK_zcaron
+    0x0192: 0x08f6, // XK_function
+    0x01d2: 0x10001d1, // XK_Ocaron
+    0x02c7: 0x01b7, // XK_caron
+    0x02d8: 0x01a2, // XK_breve
+    0x02d9: 0x01ff, // XK_abovedot
+    0x02db: 0x01b2, // XK_ogonek
+    0x02dd: 0x01bd, // XK_doubleacute
+    0x0385: 0x07ae, // XK_Greek_accentdieresis
+    0x0386: 0x07a1, // XK_Greek_ALPHAaccent
+    0x0388: 0x07a2, // XK_Greek_EPSILONaccent
+    0x0389: 0x07a3, // XK_Greek_ETAaccent
+    0x038a: 0x07a4, // XK_Greek_IOTAaccent
+    0x038c: 0x07a7, // XK_Greek_OMICRONaccent
+    0x038e: 0x07a8, // XK_Greek_UPSILONaccent
+    0x038f: 0x07ab, // XK_Greek_OMEGAaccent
+    0x0390: 0x07b6, // XK_Greek_iotaaccentdieresis
+    0x0391: 0x07c1, // XK_Greek_ALPHA
+    0x0392: 0x07c2, // XK_Greek_BETA
+    0x0393: 0x07c3, // XK_Greek_GAMMA
+    0x0394: 0x07c4, // XK_Greek_DELTA
+    0x0395: 0x07c5, // XK_Greek_EPSILON
+    0x0396: 0x07c6, // XK_Greek_ZETA
+    0x0397: 0x07c7, // XK_Greek_ETA
+    0x0398: 0x07c8, // XK_Greek_THETA
+    0x0399: 0x07c9, // XK_Greek_IOTA
+    0x039a: 0x07ca, // XK_Greek_KAPPA
+    0x039b: 0x07cb, // XK_Greek_LAMDA
+    0x039c: 0x07cc, // XK_Greek_MU
+    0x039d: 0x07cd, // XK_Greek_NU
+    0x039e: 0x07ce, // XK_Greek_XI
+    0x039f: 0x07cf, // XK_Greek_OMICRON
+    0x03a0: 0x07d0, // XK_Greek_PI
+    0x03a1: 0x07d1, // XK_Greek_RHO
+    0x03a3: 0x07d2, // XK_Greek_SIGMA
+    0x03a4: 0x07d4, // XK_Greek_TAU
+    0x03a5: 0x07d5, // XK_Greek_UPSILON
+    0x03a6: 0x07d6, // XK_Greek_PHI
+    0x03a7: 0x07d7, // XK_Greek_CHI
+    0x03a8: 0x07d8, // XK_Greek_PSI
+    0x03a9: 0x07d9, // XK_Greek_OMEGA
+    0x03aa: 0x07a5, // XK_Greek_IOTAdieresis
+    0x03ab: 0x07a9, // XK_Greek_UPSILONdieresis
+    0x03ac: 0x07b1, // XK_Greek_alphaaccent
+    0x03ad: 0x07b2, // XK_Greek_epsilonaccent
+    0x03ae: 0x07b3, // XK_Greek_etaaccent
+    0x03af: 0x07b4, // XK_Greek_iotaaccent
+    0x03b0: 0x07ba, // XK_Greek_upsilonaccentdieresis
+    0x03b1: 0x07e1, // XK_Greek_alpha
+    0x03b2: 0x07e2, // XK_Greek_beta
+    0x03b3: 0x07e3, // XK_Greek_gamma
+    0x03b4: 0x07e4, // XK_Greek_delta
+    0x03b5: 0x07e5, // XK_Greek_epsilon
+    0x03b6: 0x07e6, // XK_Greek_zeta
+    0x03b7: 0x07e7, // XK_Greek_eta
+    0x03b8: 0x07e8, // XK_Greek_theta
+    0x03b9: 0x07e9, // XK_Greek_iota
+    0x03ba: 0x07ea, // XK_Greek_kappa
+    0x03bb: 0x07eb, // XK_Greek_lamda
+    0x03bc: 0x07ec, // XK_Greek_mu
+    0x03bd: 0x07ed, // XK_Greek_nu
+    0x03be: 0x07ee, // XK_Greek_xi
+    0x03bf: 0x07ef, // XK_Greek_omicron
+    0x03c0: 0x07f0, // XK_Greek_pi
+    0x03c1: 0x07f1, // XK_Greek_rho
+    0x03c2: 0x07f3, // XK_Greek_finalsmallsigma
+    0x03c3: 0x07f2, // XK_Greek_sigma
+    0x03c4: 0x07f4, // XK_Greek_tau
+    0x03c5: 0x07f5, // XK_Greek_upsilon
+    0x03c6: 0x07f6, // XK_Greek_phi
+    0x03c7: 0x07f7, // XK_Greek_chi
+    0x03c8: 0x07f8, // XK_Greek_psi
+    0x03c9: 0x07f9, // XK_Greek_omega
+    0x03ca: 0x07b5, // XK_Greek_iotadieresis
+    0x03cb: 0x07b9, // XK_Greek_upsilondieresis
+    0x03cc: 0x07b7, // XK_Greek_omicronaccent
+    0x03cd: 0x07b8, // XK_Greek_upsilonaccent
+    0x03ce: 0x07bb, // XK_Greek_omegaaccent
+    0x0401: 0x06b3, // XK_Cyrillic_IO
+    0x0402: 0x06b1, // XK_Serbian_DJE
+    0x0403: 0x06b2, // XK_Macedonia_GJE
+    0x0404: 0x06b4, // XK_Ukrainian_IE
+    0x0405: 0x06b5, // XK_Macedonia_DSE
+    0x0406: 0x06b6, // XK_Ukrainian_I
+    0x0407: 0x06b7, // XK_Ukrainian_YI
+    0x0408: 0x06b8, // XK_Cyrillic_JE
+    0x0409: 0x06b9, // XK_Cyrillic_LJE
+    0x040a: 0x06ba, // XK_Cyrillic_NJE
+    0x040b: 0x06bb, // XK_Serbian_TSHE
+    0x040c: 0x06bc, // XK_Macedonia_KJE
+    0x040e: 0x06be, // XK_Byelorussian_SHORTU
+    0x040f: 0x06bf, // XK_Cyrillic_DZHE
+    0x0410: 0x06e1, // XK_Cyrillic_A
+    0x0411: 0x06e2, // XK_Cyrillic_BE
+    0x0412: 0x06f7, // XK_Cyrillic_VE
+    0x0413: 0x06e7, // XK_Cyrillic_GHE
+    0x0414: 0x06e4, // XK_Cyrillic_DE
+    0x0415: 0x06e5, // XK_Cyrillic_IE
+    0x0416: 0x06f6, // XK_Cyrillic_ZHE
+    0x0417: 0x06fa, // XK_Cyrillic_ZE
+    0x0418: 0x06e9, // XK_Cyrillic_I
+    0x0419: 0x06ea, // XK_Cyrillic_SHORTI
+    0x041a: 0x06eb, // XK_Cyrillic_KA
+    0x041b: 0x06ec, // XK_Cyrillic_EL
+    0x041c: 0x06ed, // XK_Cyrillic_EM
+    0x041d: 0x06ee, // XK_Cyrillic_EN
+    0x041e: 0x06ef, // XK_Cyrillic_O
+    0x041f: 0x06f0, // XK_Cyrillic_PE
+    0x0420: 0x06f2, // XK_Cyrillic_ER
+    0x0421: 0x06f3, // XK_Cyrillic_ES
+    0x0422: 0x06f4, // XK_Cyrillic_TE
+    0x0423: 0x06f5, // XK_Cyrillic_U
+    0x0424: 0x06e6, // XK_Cyrillic_EF
+    0x0425: 0x06e8, // XK_Cyrillic_HA
+    0x0426: 0x06e3, // XK_Cyrillic_TSE
+    0x0427: 0x06fe, // XK_Cyrillic_CHE
+    0x0428: 0x06fb, // XK_Cyrillic_SHA
+    0x0429: 0x06fd, // XK_Cyrillic_SHCHA
+    0x042a: 0x06ff, // XK_Cyrillic_HARDSIGN
+    0x042b: 0x06f9, // XK_Cyrillic_YERU
+    0x042c: 0x06f8, // XK_Cyrillic_SOFTSIGN
+    0x042d: 0x06fc, // XK_Cyrillic_E
+    0x042e: 0x06e0, // XK_Cyrillic_YU
+    0x042f: 0x06f1, // XK_Cyrillic_YA
+    0x0430: 0x06c1, // XK_Cyrillic_a
+    0x0431: 0x06c2, // XK_Cyrillic_be
+    0x0432: 0x06d7, // XK_Cyrillic_ve
+    0x0433: 0x06c7, // XK_Cyrillic_ghe
+    0x0434: 0x06c4, // XK_Cyrillic_de
+    0x0435: 0x06c5, // XK_Cyrillic_ie
+    0x0436: 0x06d6, // XK_Cyrillic_zhe
+    0x0437: 0x06da, // XK_Cyrillic_ze
+    0x0438: 0x06c9, // XK_Cyrillic_i
+    0x0439: 0x06ca, // XK_Cyrillic_shorti
+    0x043a: 0x06cb, // XK_Cyrillic_ka
+    0x043b: 0x06cc, // XK_Cyrillic_el
+    0x043c: 0x06cd, // XK_Cyrillic_em
+    0x043d: 0x06ce, // XK_Cyrillic_en
+    0x043e: 0x06cf, // XK_Cyrillic_o
+    0x043f: 0x06d0, // XK_Cyrillic_pe
+    0x0440: 0x06d2, // XK_Cyrillic_er
+    0x0441: 0x06d3, // XK_Cyrillic_es
+    0x0442: 0x06d4, // XK_Cyrillic_te
+    0x0443: 0x06d5, // XK_Cyrillic_u
+    0x0444: 0x06c6, // XK_Cyrillic_ef
+    0x0445: 0x06c8, // XK_Cyrillic_ha
+    0x0446: 0x06c3, // XK_Cyrillic_tse
+    0x0447: 0x06de, // XK_Cyrillic_che
+    0x0448: 0x06db, // XK_Cyrillic_sha
+    0x0449: 0x06dd, // XK_Cyrillic_shcha
+    0x044a: 0x06df, // XK_Cyrillic_hardsign
+    0x044b: 0x06d9, // XK_Cyrillic_yeru
+    0x044c: 0x06d8, // XK_Cyrillic_softsign
+    0x044d: 0x06dc, // XK_Cyrillic_e
+    0x044e: 0x06c0, // XK_Cyrillic_yu
+    0x044f: 0x06d1, // XK_Cyrillic_ya
+    0x0451: 0x06a3, // XK_Cyrillic_io
+    0x0452: 0x06a1, // XK_Serbian_dje
+    0x0453: 0x06a2, // XK_Macedonia_gje
+    0x0454: 0x06a4, // XK_Ukrainian_ie
+    0x0455: 0x06a5, // XK_Macedonia_dse
+    0x0456: 0x06a6, // XK_Ukrainian_i
+    0x0457: 0x06a7, // XK_Ukrainian_yi
+    0x0458: 0x06a8, // XK_Cyrillic_je
+    0x0459: 0x06a9, // XK_Cyrillic_lje
+    0x045a: 0x06aa, // XK_Cyrillic_nje
+    0x045b: 0x06ab, // XK_Serbian_tshe
+    0x045c: 0x06ac, // XK_Macedonia_kje
+    0x045e: 0x06ae, // XK_Byelorussian_shortu
+    0x045f: 0x06af, // XK_Cyrillic_dzhe
+    0x0490: 0x06bd, // XK_Ukrainian_GHE_WITH_UPTURN
+    0x0491: 0x06ad, // XK_Ukrainian_ghe_with_upturn
+    0x05d0: 0x0ce0, // XK_hebrew_aleph
+    0x05d1: 0x0ce1, // XK_hebrew_bet
+    0x05d2: 0x0ce2, // XK_hebrew_gimel
+    0x05d3: 0x0ce3, // XK_hebrew_dalet
+    0x05d4: 0x0ce4, // XK_hebrew_he
+    0x05d5: 0x0ce5, // XK_hebrew_waw
+    0x05d6: 0x0ce6, // XK_hebrew_zain
+    0x05d7: 0x0ce7, // XK_hebrew_chet
+    0x05d8: 0x0ce8, // XK_hebrew_tet
+    0x05d9: 0x0ce9, // XK_hebrew_yod
+    0x05da: 0x0cea, // XK_hebrew_finalkaph
+    0x05db: 0x0ceb, // XK_hebrew_kaph
+    0x05dc: 0x0cec, // XK_hebrew_lamed
+    0x05dd: 0x0ced, // XK_hebrew_finalmem
+    0x05de: 0x0cee, // XK_hebrew_mem
+    0x05df: 0x0cef, // XK_hebrew_finalnun
+    0x05e0: 0x0cf0, // XK_hebrew_nun
+    0x05e1: 0x0cf1, // XK_hebrew_samech
+    0x05e2: 0x0cf2, // XK_hebrew_ayin
+    0x05e3: 0x0cf3, // XK_hebrew_finalpe
+    0x05e4: 0x0cf4, // XK_hebrew_pe
+    0x05e5: 0x0cf5, // XK_hebrew_finalzade
+    0x05e6: 0x0cf6, // XK_hebrew_zade
+    0x05e7: 0x0cf7, // XK_hebrew_qoph
+    0x05e8: 0x0cf8, // XK_hebrew_resh
+    0x05e9: 0x0cf9, // XK_hebrew_shin
+    0x05ea: 0x0cfa, // XK_hebrew_taw
+    0x060c: 0x05ac, // XK_Arabic_comma
+    0x061b: 0x05bb, // XK_Arabic_semicolon
+    0x061f: 0x05bf, // XK_Arabic_question_mark
+    0x0621: 0x05c1, // XK_Arabic_hamza
+    0x0622: 0x05c2, // XK_Arabic_maddaonalef
+    0x0623: 0x05c3, // XK_Arabic_hamzaonalef
+    0x0624: 0x05c4, // XK_Arabic_hamzaonwaw
+    0x0625: 0x05c5, // XK_Arabic_hamzaunderalef
+    0x0626: 0x05c6, // XK_Arabic_hamzaonyeh
+    0x0627: 0x05c7, // XK_Arabic_alef
+    0x0628: 0x05c8, // XK_Arabic_beh
+    0x0629: 0x05c9, // XK_Arabic_tehmarbuta
+    0x062a: 0x05ca, // XK_Arabic_teh
+    0x062b: 0x05cb, // XK_Arabic_theh
+    0x062c: 0x05cc, // XK_Arabic_jeem
+    0x062d: 0x05cd, // XK_Arabic_hah
+    0x062e: 0x05ce, // XK_Arabic_khah
+    0x062f: 0x05cf, // XK_Arabic_dal
+    0x0630: 0x05d0, // XK_Arabic_thal
+    0x0631: 0x05d1, // XK_Arabic_ra
+    0x0632: 0x05d2, // XK_Arabic_zain
+    0x0633: 0x05d3, // XK_Arabic_seen
+    0x0634: 0x05d4, // XK_Arabic_sheen
+    0x0635: 0x05d5, // XK_Arabic_sad
+    0x0636: 0x05d6, // XK_Arabic_dad
+    0x0637: 0x05d7, // XK_Arabic_tah
+    0x0638: 0x05d8, // XK_Arabic_zah
+    0x0639: 0x05d9, // XK_Arabic_ain
+    0x063a: 0x05da, // XK_Arabic_ghain
+    0x0640: 0x05e0, // XK_Arabic_tatweel
+    0x0641: 0x05e1, // XK_Arabic_feh
+    0x0642: 0x05e2, // XK_Arabic_qaf
+    0x0643: 0x05e3, // XK_Arabic_kaf
+    0x0644: 0x05e4, // XK_Arabic_lam
+    0x0645: 0x05e5, // XK_Arabic_meem
+    0x0646: 0x05e6, // XK_Arabic_noon
+    0x0647: 0x05e7, // XK_Arabic_ha
+    0x0648: 0x05e8, // XK_Arabic_waw
+    0x0649: 0x05e9, // XK_Arabic_alefmaksura
+    0x064a: 0x05ea, // XK_Arabic_yeh
+    0x064b: 0x05eb, // XK_Arabic_fathatan
+    0x064c: 0x05ec, // XK_Arabic_dammatan
+    0x064d: 0x05ed, // XK_Arabic_kasratan
+    0x064e: 0x05ee, // XK_Arabic_fatha
+    0x064f: 0x05ef, // XK_Arabic_damma
+    0x0650: 0x05f0, // XK_Arabic_kasra
+    0x0651: 0x05f1, // XK_Arabic_shadda
+    0x0652: 0x05f2, // XK_Arabic_sukun
+    0x0e01: 0x0da1, // XK_Thai_kokai
+    0x0e02: 0x0da2, // XK_Thai_khokhai
+    0x0e03: 0x0da3, // XK_Thai_khokhuat
+    0x0e04: 0x0da4, // XK_Thai_khokhwai
+    0x0e05: 0x0da5, // XK_Thai_khokhon
+    0x0e06: 0x0da6, // XK_Thai_khorakhang
+    0x0e07: 0x0da7, // XK_Thai_ngongu
+    0x0e08: 0x0da8, // XK_Thai_chochan
+    0x0e09: 0x0da9, // XK_Thai_choching
+    0x0e0a: 0x0daa, // XK_Thai_chochang
+    0x0e0b: 0x0dab, // XK_Thai_soso
+    0x0e0c: 0x0dac, // XK_Thai_chochoe
+    0x0e0d: 0x0dad, // XK_Thai_yoying
+    0x0e0e: 0x0dae, // XK_Thai_dochada
+    0x0e0f: 0x0daf, // XK_Thai_topatak
+    0x0e10: 0x0db0, // XK_Thai_thothan
+    0x0e11: 0x0db1, // XK_Thai_thonangmontho
+    0x0e12: 0x0db2, // XK_Thai_thophuthao
+    0x0e13: 0x0db3, // XK_Thai_nonen
+    0x0e14: 0x0db4, // XK_Thai_dodek
+    0x0e15: 0x0db5, // XK_Thai_totao
+    0x0e16: 0x0db6, // XK_Thai_thothung
+    0x0e17: 0x0db7, // XK_Thai_thothahan
+    0x0e18: 0x0db8, // XK_Thai_thothong
+    0x0e19: 0x0db9, // XK_Thai_nonu
+    0x0e1a: 0x0dba, // XK_Thai_bobaimai
+    0x0e1b: 0x0dbb, // XK_Thai_popla
+    0x0e1c: 0x0dbc, // XK_Thai_phophung
+    0x0e1d: 0x0dbd, // XK_Thai_fofa
+    0x0e1e: 0x0dbe, // XK_Thai_phophan
+    0x0e1f: 0x0dbf, // XK_Thai_fofan
+    0x0e20: 0x0dc0, // XK_Thai_phosamphao
+    0x0e21: 0x0dc1, // XK_Thai_moma
+    0x0e22: 0x0dc2, // XK_Thai_yoyak
+    0x0e23: 0x0dc3, // XK_Thai_rorua
+    0x0e24: 0x0dc4, // XK_Thai_ru
+    0x0e25: 0x0dc5, // XK_Thai_loling
+    0x0e26: 0x0dc6, // XK_Thai_lu
+    0x0e27: 0x0dc7, // XK_Thai_wowaen
+    0x0e28: 0x0dc8, // XK_Thai_sosala
+    0x0e29: 0x0dc9, // XK_Thai_sorusi
+    0x0e2a: 0x0dca, // XK_Thai_sosua
+    0x0e2b: 0x0dcb, // XK_Thai_hohip
+    0x0e2c: 0x0dcc, // XK_Thai_lochula
+    0x0e2d: 0x0dcd, // XK_Thai_oang
+    0x0e2e: 0x0dce, // XK_Thai_honokhuk
+    0x0e2f: 0x0dcf, // XK_Thai_paiyannoi
+    0x0e30: 0x0dd0, // XK_Thai_saraa
+    0x0e31: 0x0dd1, // XK_Thai_maihanakat
+    0x0e32: 0x0dd2, // XK_Thai_saraaa
+    0x0e33: 0x0dd3, // XK_Thai_saraam
+    0x0e34: 0x0dd4, // XK_Thai_sarai
+    0x0e35: 0x0dd5, // XK_Thai_saraii
+    0x0e36: 0x0dd6, // XK_Thai_saraue
+    0x0e37: 0x0dd7, // XK_Thai_sarauee
+    0x0e38: 0x0dd8, // XK_Thai_sarau
+    0x0e39: 0x0dd9, // XK_Thai_sarauu
+    0x0e3a: 0x0dda, // XK_Thai_phinthu
+    0x0e3f: 0x0ddf, // XK_Thai_baht
+    0x0e40: 0x0de0, // XK_Thai_sarae
+    0x0e41: 0x0de1, // XK_Thai_saraae
+    0x0e42: 0x0de2, // XK_Thai_sarao
+    0x0e43: 0x0de3, // XK_Thai_saraaimaimuan
+    0x0e44: 0x0de4, // XK_Thai_saraaimaimalai
+    0x0e45: 0x0de5, // XK_Thai_lakkhangyao
+    0x0e46: 0x0de6, // XK_Thai_maiyamok
+    0x0e47: 0x0de7, // XK_Thai_maitaikhu
+    0x0e48: 0x0de8, // XK_Thai_maiek
+    0x0e49: 0x0de9, // XK_Thai_maitho
+    0x0e4a: 0x0dea, // XK_Thai_maitri
+    0x0e4b: 0x0deb, // XK_Thai_maichattawa
+    0x0e4c: 0x0dec, // XK_Thai_thanthakhat
+    0x0e4d: 0x0ded, // XK_Thai_nikhahit
+    0x0e50: 0x0df0, // XK_Thai_leksun
+    0x0e51: 0x0df1, // XK_Thai_leknung
+    0x0e52: 0x0df2, // XK_Thai_leksong
+    0x0e53: 0x0df3, // XK_Thai_leksam
+    0x0e54: 0x0df4, // XK_Thai_leksi
+    0x0e55: 0x0df5, // XK_Thai_lekha
+    0x0e56: 0x0df6, // XK_Thai_lekhok
+    0x0e57: 0x0df7, // XK_Thai_lekchet
+    0x0e58: 0x0df8, // XK_Thai_lekpaet
+    0x0e59: 0x0df9, // XK_Thai_lekkao
+    0x2002: 0x0aa2, // XK_enspace
+    0x2003: 0x0aa1, // XK_emspace
+    0x2004: 0x0aa3, // XK_em3space
+    0x2005: 0x0aa4, // XK_em4space
+    0x2007: 0x0aa5, // XK_digitspace
+    0x2008: 0x0aa6, // XK_punctspace
+    0x2009: 0x0aa7, // XK_thinspace
+    0x200a: 0x0aa8, // XK_hairspace
+    0x2012: 0x0abb, // XK_figdash
+    0x2013: 0x0aaa, // XK_endash
+    0x2014: 0x0aa9, // XK_emdash
+    0x2015: 0x07af, // XK_Greek_horizbar
+    0x2017: 0x0cdf, // XK_hebrew_doublelowline
+    0x2018: 0x0ad0, // XK_leftsinglequotemark
+    0x2019: 0x0ad1, // XK_rightsinglequotemark
+    0x201a: 0x0afd, // XK_singlelowquotemark
+    0x201c: 0x0ad2, // XK_leftdoublequotemark
+    0x201d: 0x0ad3, // XK_rightdoublequotemark
+    0x201e: 0x0afe, // XK_doublelowquotemark
+    0x2020: 0x0af1, // XK_dagger
+    0x2021: 0x0af2, // XK_doubledagger
+    0x2022: 0x0ae6, // XK_enfilledcircbullet
+    0x2025: 0x0aaf, // XK_doubbaselinedot
+    0x2026: 0x0aae, // XK_ellipsis
+    0x2030: 0x0ad5, // XK_permille
+    0x2032: 0x0ad6, // XK_minutes
+    0x2033: 0x0ad7, // XK_seconds
+    0x2038: 0x0afc, // XK_caret
+    0x203e: 0x047e, // XK_overline
+    0x20a9: 0x0eff, // XK_Korean_Won
+    0x20ac: 0x20ac, // XK_EuroSign
+    0x2105: 0x0ab8, // XK_careof
+    0x2116: 0x06b0, // XK_numerosign
+    0x2117: 0x0afb, // XK_phonographcopyright
+    0x211e: 0x0ad4, // XK_prescription
+    0x2122: 0x0ac9, // XK_trademark
+    0x2153: 0x0ab0, // XK_onethird
+    0x2154: 0x0ab1, // XK_twothirds
+    0x2155: 0x0ab2, // XK_onefifth
+    0x2156: 0x0ab3, // XK_twofifths
+    0x2157: 0x0ab4, // XK_threefifths
+    0x2158: 0x0ab5, // XK_fourfifths
+    0x2159: 0x0ab6, // XK_onesixth
+    0x215a: 0x0ab7, // XK_fivesixths
+    0x215b: 0x0ac3, // XK_oneeighth
+    0x215c: 0x0ac4, // XK_threeeighths
+    0x215d: 0x0ac5, // XK_fiveeighths
+    0x215e: 0x0ac6, // XK_seveneighths
+    0x2190: 0x08fb, // XK_leftarrow
+    0x2191: 0x08fc, // XK_uparrow
+    0x2192: 0x08fd, // XK_rightarrow
+    0x2193: 0x08fe, // XK_downarrow
+    0x21d2: 0x08ce, // XK_implies
+    0x21d4: 0x08cd, // XK_ifonlyif
+    0x2202: 0x08ef, // XK_partialderivative
+    0x2207: 0x08c5, // XK_nabla
+    0x2218: 0x0bca, // XK_jot
+    0x221a: 0x08d6, // XK_radical
+    0x221d: 0x08c1, // XK_variation
+    0x221e: 0x08c2, // XK_infinity
+    0x2227: 0x08de, // XK_logicaland
+    0x2228: 0x08df, // XK_logicalor
+    0x2229: 0x08dc, // XK_intersection
+    0x222a: 0x08dd, // XK_union
+    0x222b: 0x08bf, // XK_integral
+    0x2234: 0x08c0, // XK_therefore
+    0x223c: 0x08c8, // XK_approximate
+    0x2243: 0x08c9, // XK_similarequal
+    0x2245: 0x1002248, // XK_approxeq
+    0x2260: 0x08bd, // XK_notequal
+    0x2261: 0x08cf, // XK_identical
+    0x2264: 0x08bc, // XK_lessthanequal
+    0x2265: 0x08be, // XK_greaterthanequal
+    0x2282: 0x08da, // XK_includedin
+    0x2283: 0x08db, // XK_includes
+    0x22a2: 0x0bfc, // XK_righttack
+    0x22a3: 0x0bdc, // XK_lefttack
+    0x22a4: 0x0bc2, // XK_downtack
+    0x22a5: 0x0bce, // XK_uptack
+    0x2308: 0x0bd3, // XK_upstile
+    0x230a: 0x0bc4, // XK_downstile
+    0x2315: 0x0afa, // XK_telephonerecorder
+    0x2320: 0x08a4, // XK_topintegral
+    0x2321: 0x08a5, // XK_botintegral
+    0x2395: 0x0bcc, // XK_quad
+    0x239b: 0x08ab, // XK_topleftparens
+    0x239d: 0x08ac, // XK_botleftparens
+    0x239e: 0x08ad, // XK_toprightparens
+    0x23a0: 0x08ae, // XK_botrightparens
+    0x23a1: 0x08a7, // XK_topleftsqbracket
+    0x23a3: 0x08a8, // XK_botleftsqbracket
+    0x23a4: 0x08a9, // XK_toprightsqbracket
+    0x23a6: 0x08aa, // XK_botrightsqbracket
+    0x23a8: 0x08af, // XK_leftmiddlecurlybrace
+    0x23ac: 0x08b0, // XK_rightmiddlecurlybrace
+    0x23b7: 0x08a1, // XK_leftradical
+    0x23ba: 0x09ef, // XK_horizlinescan1
+    0x23bb: 0x09f0, // XK_horizlinescan3
+    0x23bc: 0x09f2, // XK_horizlinescan7
+    0x23bd: 0x09f3, // XK_horizlinescan9
+    0x2409: 0x09e2, // XK_ht
+    0x240a: 0x09e5, // XK_lf
+    0x240b: 0x09e9, // XK_vt
+    0x240c: 0x09e3, // XK_ff
+    0x240d: 0x09e4, // XK_cr
+    0x2423: 0x0aac, // XK_signifblank
+    0x2424: 0x09e8, // XK_nl
+    0x2500: 0x08a3, // XK_horizconnector
+    0x2502: 0x08a6, // XK_vertconnector
+    0x250c: 0x08a2, // XK_topleftradical
+    0x2510: 0x09eb, // XK_uprightcorner
+    0x2514: 0x09ed, // XK_lowleftcorner
+    0x2518: 0x09ea, // XK_lowrightcorner
+    0x251c: 0x09f4, // XK_leftt
+    0x2524: 0x09f5, // XK_rightt
+    0x252c: 0x09f7, // XK_topt
+    0x2534: 0x09f6, // XK_bott
+    0x253c: 0x09ee, // XK_crossinglines
+    0x2592: 0x09e1, // XK_checkerboard
+    0x25aa: 0x0ae7, // XK_enfilledsqbullet
+    0x25ab: 0x0ae1, // XK_enopensquarebullet
+    0x25ac: 0x0adb, // XK_filledrectbullet
+    0x25ad: 0x0ae2, // XK_openrectbullet
+    0x25ae: 0x0adf, // XK_emfilledrect
+    0x25af: 0x0acf, // XK_emopenrectangle
+    0x25b2: 0x0ae8, // XK_filledtribulletup
+    0x25b3: 0x0ae3, // XK_opentribulletup
+    0x25b6: 0x0add, // XK_filledrighttribullet
+    0x25b7: 0x0acd, // XK_rightopentriangle
+    0x25bc: 0x0ae9, // XK_filledtribulletdown
+    0x25bd: 0x0ae4, // XK_opentribulletdown
+    0x25c0: 0x0adc, // XK_filledlefttribullet
+    0x25c1: 0x0acc, // XK_leftopentriangle
+    0x25c6: 0x09e0, // XK_soliddiamond
+    0x25cb: 0x0ace, // XK_emopencircle
+    0x25cf: 0x0ade, // XK_emfilledcircle
+    0x25e6: 0x0ae0, // XK_enopencircbullet
+    0x2606: 0x0ae5, // XK_openstar
+    0x260e: 0x0af9, // XK_telephone
+    0x2613: 0x0aca, // XK_signaturemark
+    0x261c: 0x0aea, // XK_leftpointer
+    0x261e: 0x0aeb, // XK_rightpointer
+    0x2640: 0x0af8, // XK_femalesymbol
+    0x2642: 0x0af7, // XK_malesymbol
+    0x2663: 0x0aec, // XK_club
+    0x2665: 0x0aee, // XK_heart
+    0x2666: 0x0aed, // XK_diamond
+    0x266d: 0x0af6, // XK_musicalflat
+    0x266f: 0x0af5, // XK_musicalsharp
+    0x2713: 0x0af3, // XK_checkmark
+    0x2717: 0x0af4, // XK_ballotcross
+    0x271d: 0x0ad9, // XK_latincross
+    0x2720: 0x0af0, // XK_maltesecross
+    0x27e8: 0x0abc, // XK_leftanglebracket
+    0x27e9: 0x0abe, // XK_rightanglebracket
+    0x3001: 0x04a4, // XK_kana_comma
+    0x3002: 0x04a1, // XK_kana_fullstop
+    0x300c: 0x04a2, // XK_kana_openingbracket
+    0x300d: 0x04a3, // XK_kana_closingbracket
+    0x309b: 0x04de, // XK_voicedsound
+    0x309c: 0x04df, // XK_semivoicedsound
+    0x30a1: 0x04a7, // XK_kana_a
+    0x30a2: 0x04b1, // XK_kana_A
+    0x30a3: 0x04a8, // XK_kana_i
+    0x30a4: 0x04b2, // XK_kana_I
+    0x30a5: 0x04a9, // XK_kana_u
+    0x30a6: 0x04b3, // XK_kana_U
+    0x30a7: 0x04aa, // XK_kana_e
+    0x30a8: 0x04b4, // XK_kana_E
+    0x30a9: 0x04ab, // XK_kana_o
+    0x30aa: 0x04b5, // XK_kana_O
+    0x30ab: 0x04b6, // XK_kana_KA
+    0x30ad: 0x04b7, // XK_kana_KI
+    0x30af: 0x04b8, // XK_kana_KU
+    0x30b1: 0x04b9, // XK_kana_KE
+    0x30b3: 0x04ba, // XK_kana_KO
+    0x30b5: 0x04bb, // XK_kana_SA
+    0x30b7: 0x04bc, // XK_kana_SHI
+    0x30b9: 0x04bd, // XK_kana_SU
+    0x30bb: 0x04be, // XK_kana_SE
+    0x30bd: 0x04bf, // XK_kana_SO
+    0x30bf: 0x04c0, // XK_kana_TA
+    0x30c1: 0x04c1, // XK_kana_CHI
+    0x30c3: 0x04af, // XK_kana_tsu
+    0x30c4: 0x04c2, // XK_kana_TSU
+    0x30c6: 0x04c3, // XK_kana_TE
+    0x30c8: 0x04c4, // XK_kana_TO
+    0x30ca: 0x04c5, // XK_kana_NA
+    0x30cb: 0x04c6, // XK_kana_NI
+    0x30cc: 0x04c7, // XK_kana_NU
+    0x30cd: 0x04c8, // XK_kana_NE
+    0x30ce: 0x04c9, // XK_kana_NO
+    0x30cf: 0x04ca, // XK_kana_HA
+    0x30d2: 0x04cb, // XK_kana_HI
+    0x30d5: 0x04cc, // XK_kana_FU
+    0x30d8: 0x04cd, // XK_kana_HE
+    0x30db: 0x04ce, // XK_kana_HO
+    0x30de: 0x04cf, // XK_kana_MA
+    0x30df: 0x04d0, // XK_kana_MI
+    0x30e0: 0x04d1, // XK_kana_MU
+    0x30e1: 0x04d2, // XK_kana_ME
+    0x30e2: 0x04d3, // XK_kana_MO
+    0x30e3: 0x04ac, // XK_kana_ya
+    0x30e4: 0x04d4, // XK_kana_YA
+    0x30e5: 0x04ad, // XK_kana_yu
+    0x30e6: 0x04d5, // XK_kana_YU
+    0x30e7: 0x04ae, // XK_kana_yo
+    0x30e8: 0x04d6, // XK_kana_YO
+    0x30e9: 0x04d7, // XK_kana_RA
+    0x30ea: 0x04d8, // XK_kana_RI
+    0x30eb: 0x04d9, // XK_kana_RU
+    0x30ec: 0x04da, // XK_kana_RE
+    0x30ed: 0x04db, // XK_kana_RO
+    0x30ef: 0x04dc, // XK_kana_WA
+    0x30f2: 0x04a6, // XK_kana_WO
+    0x30f3: 0x04dd, // XK_kana_N
+    0x30fb: 0x04a5, // XK_kana_conjunctive
+    0x30fc: 0x04b0, // XK_prolongedsound
+};
+
+export default {
+    lookup(u) {
+        // Latin-1 is one-to-one mapping
+        if ((u >= 0x20) && (u <= 0xff)) {
+            return u;
+        }
+
+        // Lookup table (fairly random)
+        const keysym = codepoints[u];
+        if (keysym !== undefined) {
+            return keysym;
+        }
+
+        // General mapping as final fallback
+        return 0x01000000 | u;
+    },
+};
pkg/web/noVNC/core/input/util.js
@@ -0,0 +1,191 @@
+import KeyTable from "./keysym.js";
+import keysyms from "./keysymdef.js";
+import vkeys from "./vkeys.js";
+import fixedkeys from "./fixedkeys.js";
+import DOMKeyTable from "./domkeytable.js";
+import * as browser from "../util/browser.js";
+
+// Get 'KeyboardEvent.code', handling legacy browsers
+export function getKeycode(evt) {
+    // Are we getting proper key identifiers?
+    // (unfortunately Firefox and Chrome are crappy here and gives
+    // us an empty string on some platforms, rather than leaving it
+    // undefined)
+    if (evt.code) {
+        // Mozilla isn't fully in sync with the spec yet
+        switch (evt.code) {
+            case 'OSLeft': return 'MetaLeft';
+            case 'OSRight': return 'MetaRight';
+        }
+
+        return evt.code;
+    }
+
+    // The de-facto standard is to use Windows Virtual-Key codes
+    // in the 'keyCode' field for non-printable characters
+    if (evt.keyCode in vkeys) {
+        let code = vkeys[evt.keyCode];
+
+        // macOS has messed up this code for some reason
+        if (browser.isMac() && (code === 'ContextMenu')) {
+            code = 'MetaRight';
+        }
+
+        // The keyCode doesn't distinguish between left and right
+        // for the standard modifiers
+        if (evt.location === 2) {
+            switch (code) {
+                case 'ShiftLeft': return 'ShiftRight';
+                case 'ControlLeft': return 'ControlRight';
+                case 'AltLeft': return 'AltRight';
+            }
+        }
+
+        // Nor a bunch of the numpad keys
+        if (evt.location === 3) {
+            switch (code) {
+                case 'Delete': return 'NumpadDecimal';
+                case 'Insert': return 'Numpad0';
+                case 'End': return 'Numpad1';
+                case 'ArrowDown': return 'Numpad2';
+                case 'PageDown': return 'Numpad3';
+                case 'ArrowLeft': return 'Numpad4';
+                case 'ArrowRight': return 'Numpad6';
+                case 'Home': return 'Numpad7';
+                case 'ArrowUp': return 'Numpad8';
+                case 'PageUp': return 'Numpad9';
+                case 'Enter': return 'NumpadEnter';
+            }
+        }
+
+        return code;
+    }
+
+    return 'Unidentified';
+}
+
+// Get 'KeyboardEvent.key', handling legacy browsers
+export function getKey(evt) {
+    // Are we getting a proper key value?
+    if ((evt.key !== undefined) && (evt.key !== 'Unidentified')) {
+        // Mozilla isn't fully in sync with the spec yet
+        switch (evt.key) {
+            case 'OS': return 'Meta';
+            case 'LaunchMyComputer': return 'LaunchApplication1';
+            case 'LaunchCalculator': return 'LaunchApplication2';
+        }
+
+        // iOS leaks some OS names
+        switch (evt.key) {
+            case 'UIKeyInputUpArrow': return 'ArrowUp';
+            case 'UIKeyInputDownArrow': return 'ArrowDown';
+            case 'UIKeyInputLeftArrow': return 'ArrowLeft';
+            case 'UIKeyInputRightArrow': return 'ArrowRight';
+            case 'UIKeyInputEscape': return 'Escape';
+        }
+
+        // Broken behaviour in Chrome
+        if ((evt.key === '\x00') && (evt.code === 'NumpadDecimal')) {
+            return 'Delete';
+        }
+
+        return evt.key;
+    }
+
+    // Try to deduce it based on the physical key
+    const code = getKeycode(evt);
+    if (code in fixedkeys) {
+        return fixedkeys[code];
+    }
+
+    // If that failed, then see if we have a printable character
+    if (evt.charCode) {
+        return String.fromCharCode(evt.charCode);
+    }
+
+    // At this point we have nothing left to go on
+    return 'Unidentified';
+}
+
+// Get the most reliable keysym value we can get from a key event
+export function getKeysym(evt) {
+    const key = getKey(evt);
+
+    if (key === 'Unidentified') {
+        return null;
+    }
+
+    // First look up special keys
+    if (key in DOMKeyTable) {
+        let location = evt.location;
+
+        // Safari screws up location for the right cmd key
+        if ((key === 'Meta') && (location === 0)) {
+            location = 2;
+        }
+
+        // And for Clear
+        if ((key === 'Clear') && (location === 3)) {
+            let code = getKeycode(evt);
+            if (code === 'NumLock') {
+                location = 0;
+            }
+        }
+
+        if ((location === undefined) || (location > 3)) {
+            location = 0;
+        }
+
+        // The original Meta key now gets confused with the Windows key
+        // https://bugs.chromium.org/p/chromium/issues/detail?id=1020141
+        // https://bugzilla.mozilla.org/show_bug.cgi?id=1232918
+        if (key === 'Meta') {
+            let code = getKeycode(evt);
+            if (code === 'AltLeft') {
+                return KeyTable.XK_Meta_L;
+            } else if (code === 'AltRight') {
+                return KeyTable.XK_Meta_R;
+            }
+        }
+
+        // macOS has Clear instead of NumLock, but the remote system is
+        // probably not macOS, so lying here is probably best...
+        if (key === 'Clear') {
+            let code = getKeycode(evt);
+            if (code === 'NumLock') {
+                return KeyTable.XK_Num_Lock;
+            }
+        }
+
+        // Windows sends alternating symbols for some keys when using a
+        // Japanese layout. We have no way of synchronising with the IM
+        // running on the remote system, so we send some combined keysym
+        // instead and hope for the best.
+        if (browser.isWindows()) {
+            switch (key) {
+                case 'Zenkaku':
+                case 'Hankaku':
+                    return KeyTable.XK_Zenkaku_Hankaku;
+                case 'Romaji':
+                case 'KanaMode':
+                    return KeyTable.XK_Romaji;
+            }
+        }
+
+        return DOMKeyTable[key][location];
+    }
+
+    // Now we need to look at the Unicode symbol instead
+
+    // Special key? (FIXME: Should have been caught earlier)
+    if (key.length !== 1) {
+        return null;
+    }
+
+    const codepoint = key.charCodeAt();
+    if (codepoint) {
+        return keysyms.lookup(codepoint);
+    }
+
+    return null;
+}
pkg/web/noVNC/core/input/vkeys.js
@@ -0,0 +1,116 @@
+/*
+ * noVNC: HTML5 VNC client
+ * Copyright (C) 2018 The noVNC authors
+ * Licensed under MPL 2.0 or any later version (see LICENSE.txt)
+ */
+
+/*
+ * Mapping between Microsoftยฎ Windowsยฎ Virtual-Key codes and
+ * HTML key codes.
+ */
+
+export default {
+    0x08: 'Backspace',
+    0x09: 'Tab',
+    0x0a: 'NumpadClear',
+    0x0d: 'Enter',
+    0x10: 'ShiftLeft',
+    0x11: 'ControlLeft',
+    0x12: 'AltLeft',
+    0x13: 'Pause',
+    0x14: 'CapsLock',
+    0x15: 'Lang1',
+    0x19: 'Lang2',
+    0x1b: 'Escape',
+    0x1c: 'Convert',
+    0x1d: 'NonConvert',
+    0x20: 'Space',
+    0x21: 'PageUp',
+    0x22: 'PageDown',
+    0x23: 'End',
+    0x24: 'Home',
+    0x25: 'ArrowLeft',
+    0x26: 'ArrowUp',
+    0x27: 'ArrowRight',
+    0x28: 'ArrowDown',
+    0x29: 'Select',
+    0x2c: 'PrintScreen',
+    0x2d: 'Insert',
+    0x2e: 'Delete',
+    0x2f: 'Help',
+    0x30: 'Digit0',
+    0x31: 'Digit1',
+    0x32: 'Digit2',
+    0x33: 'Digit3',
+    0x34: 'Digit4',
+    0x35: 'Digit5',
+    0x36: 'Digit6',
+    0x37: 'Digit7',
+    0x38: 'Digit8',
+    0x39: 'Digit9',
+    0x5b: 'MetaLeft',
+    0x5c: 'MetaRight',
+    0x5d: 'ContextMenu',
+    0x5f: 'Sleep',
+    0x60: 'Numpad0',
+    0x61: 'Numpad1',
+    0x62: 'Numpad2',
+    0x63: 'Numpad3',
+    0x64: 'Numpad4',
+    0x65: 'Numpad5',
+    0x66: 'Numpad6',
+    0x67: 'Numpad7',
+    0x68: 'Numpad8',
+    0x69: 'Numpad9',
+    0x6a: 'NumpadMultiply',
+    0x6b: 'NumpadAdd',
+    0x6c: 'NumpadDecimal',
+    0x6d: 'NumpadSubtract',
+    0x6e: 'NumpadDecimal', // Duplicate, because buggy on Windows
+    0x6f: 'NumpadDivide',
+    0x70: 'F1',
+    0x71: 'F2',
+    0x72: 'F3',
+    0x73: 'F4',
+    0x74: 'F5',
+    0x75: 'F6',
+    0x76: 'F7',
+    0x77: 'F8',
+    0x78: 'F9',
+    0x79: 'F10',
+    0x7a: 'F11',
+    0x7b: 'F12',
+    0x7c: 'F13',
+    0x7d: 'F14',
+    0x7e: 'F15',
+    0x7f: 'F16',
+    0x80: 'F17',
+    0x81: 'F18',
+    0x82: 'F19',
+    0x83: 'F20',
+    0x84: 'F21',
+    0x85: 'F22',
+    0x86: 'F23',
+    0x87: 'F24',
+    0x90: 'NumLock',
+    0x91: 'ScrollLock',
+    0xa6: 'BrowserBack',
+    0xa7: 'BrowserForward',
+    0xa8: 'BrowserRefresh',
+    0xa9: 'BrowserStop',
+    0xaa: 'BrowserSearch',
+    0xab: 'BrowserFavorites',
+    0xac: 'BrowserHome',
+    0xad: 'AudioVolumeMute',
+    0xae: 'AudioVolumeDown',
+    0xaf: 'AudioVolumeUp',
+    0xb0: 'MediaTrackNext',
+    0xb1: 'MediaTrackPrevious',
+    0xb2: 'MediaStop',
+    0xb3: 'MediaPlayPause',
+    0xb4: 'LaunchMail',
+    0xb5: 'MediaSelect',
+    0xb6: 'LaunchApp1',
+    0xb7: 'LaunchApp2',
+    0xe1: 'AltRight', // Only when it is AltGraph
+};
pkg/web/noVNC/core/input/xtscancodes.js
@@ -0,0 +1,173 @@
+/*
+ * This file is auto-generated from keymaps.csv
+ * Database checksum sha256(76d68c10e97d37fe2ea459e210125ae41796253fb217e900bf2983ade13a7920)
+ * To re-generate, run:
+ *   keymap-gen code-map --lang=js keymaps.csv html atset1
+*/
+export default {
+  "Again": 0xe005, /* html:Again (Again) -> linux:129 (KEY_AGAIN) -> atset1:57349 */
+  "AltLeft": 0x38, /* html:AltLeft (AltLeft) -> linux:56 (KEY_LEFTALT) -> atset1:56 */
+  "AltRight": 0xe038, /* html:AltRight (AltRight) -> linux:100 (KEY_RIGHTALT) -> atset1:57400 */
+  "ArrowDown": 0xe050, /* html:ArrowDown (ArrowDown) -> linux:108 (KEY_DOWN) -> atset1:57424 */
+  "ArrowLeft": 0xe04b, /* html:ArrowLeft (ArrowLeft) -> linux:105 (KEY_LEFT) -> atset1:57419 */
+  "ArrowRight": 0xe04d, /* html:ArrowRight (ArrowRight) -> linux:106 (KEY_RIGHT) -> atset1:57421 */
+  "ArrowUp": 0xe048, /* html:ArrowUp (ArrowUp) -> linux:103 (KEY_UP) -> atset1:57416 */
+  "AudioVolumeDown": 0xe02e, /* html:AudioVolumeDown (AudioVolumeDown) -> linux:114 (KEY_VOLUMEDOWN) -> atset1:57390 */
+  "AudioVolumeMute": 0xe020, /* html:AudioVolumeMute (AudioVolumeMute) -> linux:113 (KEY_MUTE) -> atset1:57376 */
+  "AudioVolumeUp": 0xe030, /* html:AudioVolumeUp (AudioVolumeUp) -> linux:115 (KEY_VOLUMEUP) -> atset1:57392 */
+  "Backquote": 0x29, /* html:Backquote (Backquote) -> linux:41 (KEY_GRAVE) -> atset1:41 */
+  "Backslash": 0x2b, /* html:Backslash (Backslash) -> linux:43 (KEY_BACKSLASH) -> atset1:43 */
+  "Backspace": 0xe, /* html:Backspace (Backspace) -> linux:14 (KEY_BACKSPACE) -> atset1:14 */
+  "BracketLeft": 0x1a, /* html:BracketLeft (BracketLeft) -> linux:26 (KEY_LEFTBRACE) -> atset1:26 */
+  "BracketRight": 0x1b, /* html:BracketRight (BracketRight) -> linux:27 (KEY_RIGHTBRACE) -> atset1:27 */
+  "BrowserBack": 0xe06a, /* html:BrowserBack (BrowserBack) -> linux:158 (KEY_BACK) -> atset1:57450 */
+  "BrowserFavorites": 0xe066, /* html:BrowserFavorites (BrowserFavorites) -> linux:156 (KEY_BOOKMARKS) -> atset1:57446 */
+  "BrowserForward": 0xe069, /* html:BrowserForward (BrowserForward) -> linux:159 (KEY_FORWARD) -> atset1:57449 */
+  "BrowserHome": 0xe032, /* html:BrowserHome (BrowserHome) -> linux:172 (KEY_HOMEPAGE) -> atset1:57394 */
+  "BrowserRefresh": 0xe067, /* html:BrowserRefresh (BrowserRefresh) -> linux:173 (KEY_REFRESH) -> atset1:57447 */
+  "BrowserSearch": 0xe065, /* html:BrowserSearch (BrowserSearch) -> linux:217 (KEY_SEARCH) -> atset1:57445 */
+  "BrowserStop": 0xe068, /* html:BrowserStop (BrowserStop) -> linux:128 (KEY_STOP) -> atset1:57448 */
+  "CapsLock": 0x3a, /* html:CapsLock (CapsLock) -> linux:58 (KEY_CAPSLOCK) -> atset1:58 */
+  "Comma": 0x33, /* html:Comma (Comma) -> linux:51 (KEY_COMMA) -> atset1:51 */
+  "ContextMenu": 0xe05d, /* html:ContextMenu (ContextMenu) -> linux:127 (KEY_COMPOSE) -> atset1:57437 */
+  "ControlLeft": 0x1d, /* html:ControlLeft (ControlLeft) -> linux:29 (KEY_LEFTCTRL) -> atset1:29 */
+  "ControlRight": 0xe01d, /* html:ControlRight (ControlRight) -> linux:97 (KEY_RIGHTCTRL) -> atset1:57373 */
+  "Convert": 0x79, /* html:Convert (Convert) -> linux:92 (KEY_HENKAN) -> atset1:121 */
+  "Copy": 0xe078, /* html:Copy (Copy) -> linux:133 (KEY_COPY) -> atset1:57464 */
+  "Cut": 0xe03c, /* html:Cut (Cut) -> linux:137 (KEY_CUT) -> atset1:57404 */
+  "Delete": 0xe053, /* html:Delete (Delete) -> linux:111 (KEY_DELETE) -> atset1:57427 */
+  "Digit0": 0xb, /* html:Digit0 (Digit0) -> linux:11 (KEY_0) -> atset1:11 */
+  "Digit1": 0x2, /* html:Digit1 (Digit1) -> linux:2 (KEY_1) -> atset1:2 */
+  "Digit2": 0x3, /* html:Digit2 (Digit2) -> linux:3 (KEY_2) -> atset1:3 */
+  "Digit3": 0x4, /* html:Digit3 (Digit3) -> linux:4 (KEY_3) -> atset1:4 */
+  "Digit4": 0x5, /* html:Digit4 (Digit4) -> linux:5 (KEY_4) -> atset1:5 */
+  "Digit5": 0x6, /* html:Digit5 (Digit5) -> linux:6 (KEY_5) -> atset1:6 */
+  "Digit6": 0x7, /* html:Digit6 (Digit6) -> linux:7 (KEY_6) -> atset1:7 */
+  "Digit7": 0x8, /* html:Digit7 (Digit7) -> linux:8 (KEY_7) -> atset1:8 */
+  "Digit8": 0x9, /* html:Digit8 (Digit8) -> linux:9 (KEY_8) -> atset1:9 */
+  "Digit9": 0xa, /* html:Digit9 (Digit9) -> linux:10 (KEY_9) -> atset1:10 */
+  "Eject": 0xe07d, /* html:Eject (Eject) -> linux:162 (KEY_EJECTCLOSECD) -> atset1:57469 */
+  "End": 0xe04f, /* html:End (End) -> linux:107 (KEY_END) -> atset1:57423 */
+  "Enter": 0x1c, /* html:Enter (Enter) -> linux:28 (KEY_ENTER) -> atset1:28 */
+  "Equal": 0xd, /* html:Equal (Equal) -> linux:13 (KEY_EQUAL) -> atset1:13 */
+  "Escape": 0x1, /* html:Escape (Escape) -> linux:1 (KEY_ESC) -> atset1:1 */
+  "F1": 0x3b, /* html:F1 (F1) -> linux:59 (KEY_F1) -> atset1:59 */
+  "F10": 0x44, /* html:F10 (F10) -> linux:68 (KEY_F10) -> atset1:68 */
+  "F11": 0x57, /* html:F11 (F11) -> linux:87 (KEY_F11) -> atset1:87 */
+  "F12": 0x58, /* html:F12 (F12) -> linux:88 (KEY_F12) -> atset1:88 */
+  "F13": 0x5d, /* html:F13 (F13) -> linux:183 (KEY_F13) -> atset1:93 */
+  "F14": 0x5e, /* html:F14 (F14) -> linux:184 (KEY_F14) -> atset1:94 */
+  "F15": 0x5f, /* html:F15 (F15) -> linux:185 (KEY_F15) -> atset1:95 */
+  "F16": 0x55, /* html:F16 (F16) -> linux:186 (KEY_F16) -> atset1:85 */
+  "F17": 0xe003, /* html:F17 (F17) -> linux:187 (KEY_F17) -> atset1:57347 */
+  "F18": 0xe077, /* html:F18 (F18) -> linux:188 (KEY_F18) -> atset1:57463 */
+  "F19": 0xe004, /* html:F19 (F19) -> linux:189 (KEY_F19) -> atset1:57348 */
+  "F2": 0x3c, /* html:F2 (F2) -> linux:60 (KEY_F2) -> atset1:60 */
+  "F20": 0x5a, /* html:F20 (F20) -> linux:190 (KEY_F20) -> atset1:90 */
+  "F21": 0x74, /* html:F21 (F21) -> linux:191 (KEY_F21) -> atset1:116 */
+  "F22": 0xe079, /* html:F22 (F22) -> linux:192 (KEY_F22) -> atset1:57465 */
+  "F23": 0x6d, /* html:F23 (F23) -> linux:193 (KEY_F23) -> atset1:109 */
+  "F24": 0x6f, /* html:F24 (F24) -> linux:194 (KEY_F24) -> atset1:111 */
+  "F3": 0x3d, /* html:F3 (F3) -> linux:61 (KEY_F3) -> atset1:61 */
+  "F4": 0x3e, /* html:F4 (F4) -> linux:62 (KEY_F4) -> atset1:62 */
+  "F5": 0x3f, /* html:F5 (F5) -> linux:63 (KEY_F5) -> atset1:63 */
+  "F6": 0x40, /* html:F6 (F6) -> linux:64 (KEY_F6) -> atset1:64 */
+  "F7": 0x41, /* html:F7 (F7) -> linux:65 (KEY_F7) -> atset1:65 */
+  "F8": 0x42, /* html:F8 (F8) -> linux:66 (KEY_F8) -> atset1:66 */
+  "F9": 0x43, /* html:F9 (F9) -> linux:67 (KEY_F9) -> atset1:67 */
+  "Find": 0xe041, /* html:Find (Find) -> linux:136 (KEY_FIND) -> atset1:57409 */
+  "Help": 0xe075, /* html:Help (Help) -> linux:138 (KEY_HELP) -> atset1:57461 */
+  "Hiragana": 0x77, /* html:Hiragana (Lang4) -> linux:91 (KEY_HIRAGANA) -> atset1:119 */
+  "Home": 0xe047, /* html:Home (Home) -> linux:102 (KEY_HOME) -> atset1:57415 */
+  "Insert": 0xe052, /* html:Insert (Insert) -> linux:110 (KEY_INSERT) -> atset1:57426 */
+  "IntlBackslash": 0x56, /* html:IntlBackslash (IntlBackslash) -> linux:86 (KEY_102ND) -> atset1:86 */
+  "IntlRo": 0x73, /* html:IntlRo (IntlRo) -> linux:89 (KEY_RO) -> atset1:115 */
+  "IntlYen": 0x7d, /* html:IntlYen (IntlYen) -> linux:124 (KEY_YEN) -> atset1:125 */
+  "KanaMode": 0x70, /* html:KanaMode (KanaMode) -> linux:93 (KEY_KATAKANAHIRAGANA) -> atset1:112 */
+  "Katakana": 0x78, /* html:Katakana (Lang3) -> linux:90 (KEY_KATAKANA) -> atset1:120 */
+  "KeyA": 0x1e, /* html:KeyA (KeyA) -> linux:30 (KEY_A) -> atset1:30 */
+  "KeyB": 0x30, /* html:KeyB (KeyB) -> linux:48 (KEY_B) -> atset1:48 */
+  "KeyC": 0x2e, /* html:KeyC (KeyC) -> linux:46 (KEY_C) -> atset1:46 */
+  "KeyD": 0x20, /* html:KeyD (KeyD) -> linux:32 (KEY_D) -> atset1:32 */
+  "KeyE": 0x12, /* html:KeyE (KeyE) -> linux:18 (KEY_E) -> atset1:18 */
+  "KeyF": 0x21, /* html:KeyF (KeyF) -> linux:33 (KEY_F) -> atset1:33 */
+  "KeyG": 0x22, /* html:KeyG (KeyG) -> linux:34 (KEY_G) -> atset1:34 */
+  "KeyH": 0x23, /* html:KeyH (KeyH) -> linux:35 (KEY_H) -> atset1:35 */
+  "KeyI": 0x17, /* html:KeyI (KeyI) -> linux:23 (KEY_I) -> atset1:23 */
+  "KeyJ": 0x24, /* html:KeyJ (KeyJ) -> linux:36 (KEY_J) -> atset1:36 */
+  "KeyK": 0x25, /* html:KeyK (KeyK) -> linux:37 (KEY_K) -> atset1:37 */
+  "KeyL": 0x26, /* html:KeyL (KeyL) -> linux:38 (KEY_L) -> atset1:38 */
+  "KeyM": 0x32, /* html:KeyM (KeyM) -> linux:50 (KEY_M) -> atset1:50 */
+  "KeyN": 0x31, /* html:KeyN (KeyN) -> linux:49 (KEY_N) -> atset1:49 */
+  "KeyO": 0x18, /* html:KeyO (KeyO) -> linux:24 (KEY_O) -> atset1:24 */
+  "KeyP": 0x19, /* html:KeyP (KeyP) -> linux:25 (KEY_P) -> atset1:25 */
+  "KeyQ": 0x10, /* html:KeyQ (KeyQ) -> linux:16 (KEY_Q) -> atset1:16 */
+  "KeyR": 0x13, /* html:KeyR (KeyR) -> linux:19 (KEY_R) -> atset1:19 */
+  "KeyS": 0x1f, /* html:KeyS (KeyS) -> linux:31 (KEY_S) -> atset1:31 */
+  "KeyT": 0x14, /* html:KeyT (KeyT) -> linux:20 (KEY_T) -> atset1:20 */
+  "KeyU": 0x16, /* html:KeyU (KeyU) -> linux:22 (KEY_U) -> atset1:22 */
+  "KeyV": 0x2f, /* html:KeyV (KeyV) -> linux:47 (KEY_V) -> atset1:47 */
+  "KeyW": 0x11, /* html:KeyW (KeyW) -> linux:17 (KEY_W) -> atset1:17 */
+  "KeyX": 0x2d, /* html:KeyX (KeyX) -> linux:45 (KEY_X) -> atset1:45 */
+  "KeyY": 0x15, /* html:KeyY (KeyY) -> linux:21 (KEY_Y) -> atset1:21 */
+  "KeyZ": 0x2c, /* html:KeyZ (KeyZ) -> linux:44 (KEY_Z) -> atset1:44 */
+  "Lang1": 0x72, /* html:Lang1 (Lang1) -> linux:122 (KEY_HANGEUL) -> atset1:114 */
+  "Lang2": 0x71, /* html:Lang2 (Lang2) -> linux:123 (KEY_HANJA) -> atset1:113 */
+  "Lang3": 0x78, /* html:Lang3 (Lang3) -> linux:90 (KEY_KATAKANA) -> atset1:120 */
+  "Lang4": 0x77, /* html:Lang4 (Lang4) -> linux:91 (KEY_HIRAGANA) -> atset1:119 */
+  "Lang5": 0x76, /* html:Lang5 (Lang5) -> linux:85 (KEY_ZENKAKUHANKAKU) -> atset1:118 */
+  "LaunchApp1": 0xe06b, /* html:LaunchApp1 (LaunchApp1) -> linux:157 (KEY_COMPUTER) -> atset1:57451 */
+  "LaunchApp2": 0xe021, /* html:LaunchApp2 (LaunchApp2) -> linux:140 (KEY_CALC) -> atset1:57377 */
+  "LaunchMail": 0xe06c, /* html:LaunchMail (LaunchMail) -> linux:155 (KEY_MAIL) -> atset1:57452 */
+  "MediaPlayPause": 0xe022, /* html:MediaPlayPause (MediaPlayPause) -> linux:164 (KEY_PLAYPAUSE) -> atset1:57378 */
+  "MediaSelect": 0xe06d, /* html:MediaSelect (MediaSelect) -> linux:226 (KEY_MEDIA) -> atset1:57453 */
+  "MediaStop": 0xe024, /* html:MediaStop (MediaStop) -> linux:166 (KEY_STOPCD) -> atset1:57380 */
+  "MediaTrackNext": 0xe019, /* html:MediaTrackNext (MediaTrackNext) -> linux:163 (KEY_NEXTSONG) -> atset1:57369 */
+  "MediaTrackPrevious": 0xe010, /* html:MediaTrackPrevious (MediaTrackPrevious) -> linux:165 (KEY_PREVIOUSSONG) -> atset1:57360 */
+  "MetaLeft": 0xe05b, /* html:MetaLeft (MetaLeft) -> linux:125 (KEY_LEFTMETA) -> atset1:57435 */
+  "MetaRight": 0xe05c, /* html:MetaRight (MetaRight) -> linux:126 (KEY_RIGHTMETA) -> atset1:57436 */
+  "Minus": 0xc, /* html:Minus (Minus) -> linux:12 (KEY_MINUS) -> atset1:12 */
+  "NonConvert": 0x7b, /* html:NonConvert (NonConvert) -> linux:94 (KEY_MUHENKAN) -> atset1:123 */
+  "NumLock": 0x45, /* html:NumLock (NumLock) -> linux:69 (KEY_NUMLOCK) -> atset1:69 */
+  "Numpad0": 0x52, /* html:Numpad0 (Numpad0) -> linux:82 (KEY_KP0) -> atset1:82 */
+  "Numpad1": 0x4f, /* html:Numpad1 (Numpad1) -> linux:79 (KEY_KP1) -> atset1:79 */
+  "Numpad2": 0x50, /* html:Numpad2 (Numpad2) -> linux:80 (KEY_KP2) -> atset1:80 */
+  "Numpad3": 0x51, /* html:Numpad3 (Numpad3) -> linux:81 (KEY_KP3) -> atset1:81 */
+  "Numpad4": 0x4b, /* html:Numpad4 (Numpad4) -> linux:75 (KEY_KP4) -> atset1:75 */
+  "Numpad5": 0x4c, /* html:Numpad5 (Numpad5) -> linux:76 (KEY_KP5) -> atset1:76 */
+  "Numpad6": 0x4d, /* html:Numpad6 (Numpad6) -> linux:77 (KEY_KP6) -> atset1:77 */
+  "Numpad7": 0x47, /* html:Numpad7 (Numpad7) -> linux:71 (KEY_KP7) -> atset1:71 */
+  "Numpad8": 0x48, /* html:Numpad8 (Numpad8) -> linux:72 (KEY_KP8) -> atset1:72 */
+  "Numpad9": 0x49, /* html:Numpad9 (Numpad9) -> linux:73 (KEY_KP9) -> atset1:73 */
+  "NumpadAdd": 0x4e, /* html:NumpadAdd (NumpadAdd) -> linux:78 (KEY_KPPLUS) -> atset1:78 */
+  "NumpadComma": 0x7e, /* html:NumpadComma (NumpadComma) -> linux:121 (KEY_KPCOMMA) -> atset1:126 */
+  "NumpadDecimal": 0x53, /* html:NumpadDecimal (NumpadDecimal) -> linux:83 (KEY_KPDOT) -> atset1:83 */
+  "NumpadDivide": 0xe035, /* html:NumpadDivide (NumpadDivide) -> linux:98 (KEY_KPSLASH) -> atset1:57397 */
+  "NumpadEnter": 0xe01c, /* html:NumpadEnter (NumpadEnter) -> linux:96 (KEY_KPENTER) -> atset1:57372 */
+  "NumpadEqual": 0x59, /* html:NumpadEqual (NumpadEqual) -> linux:117 (KEY_KPEQUAL) -> atset1:89 */
+  "NumpadMultiply": 0x37, /* html:NumpadMultiply (NumpadMultiply) -> linux:55 (KEY_KPASTERISK) -> atset1:55 */
+  "NumpadParenLeft": 0xe076, /* html:NumpadParenLeft (NumpadParenLeft) -> linux:179 (KEY_KPLEFTPAREN) -> atset1:57462 */
+  "NumpadParenRight": 0xe07b, /* html:NumpadParenRight (NumpadParenRight) -> linux:180 (KEY_KPRIGHTPAREN) -> atset1:57467 */
+  "NumpadSubtract": 0x4a, /* html:NumpadSubtract (NumpadSubtract) -> linux:74 (KEY_KPMINUS) -> atset1:74 */
+  "Open": 0x64, /* html:Open (Open) -> linux:134 (KEY_OPEN) -> atset1:100 */
+  "PageDown": 0xe051, /* html:PageDown (PageDown) -> linux:109 (KEY_PAGEDOWN) -> atset1:57425 */
+  "PageUp": 0xe049, /* html:PageUp (PageUp) -> linux:104 (KEY_PAGEUP) -> atset1:57417 */
+  "Paste": 0x65, /* html:Paste (Paste) -> linux:135 (KEY_PASTE) -> atset1:101 */
+  "Pause": 0xe046, /* html:Pause (Pause) -> linux:119 (KEY_PAUSE) -> atset1:57414 */
+  "Period": 0x34, /* html:Period (Period) -> linux:52 (KEY_DOT) -> atset1:52 */
+  "Power": 0xe05e, /* html:Power (Power) -> linux:116 (KEY_POWER) -> atset1:57438 */
+  "PrintScreen": 0x54, /* html:PrintScreen (PrintScreen) -> linux:99 (KEY_SYSRQ) -> atset1:84 */
+  "Props": 0xe006, /* html:Props (Props) -> linux:130 (KEY_PROPS) -> atset1:57350 */
+  "Quote": 0x28, /* html:Quote (Quote) -> linux:40 (KEY_APOSTROPHE) -> atset1:40 */
+  "ScrollLock": 0x46, /* html:ScrollLock (ScrollLock) -> linux:70 (KEY_SCROLLLOCK) -> atset1:70 */
+  "Semicolon": 0x27, /* html:Semicolon (Semicolon) -> linux:39 (KEY_SEMICOLON) -> atset1:39 */
+  "ShiftLeft": 0x2a, /* html:ShiftLeft (ShiftLeft) -> linux:42 (KEY_LEFTSHIFT) -> atset1:42 */
+  "ShiftRight": 0x36, /* html:ShiftRight (ShiftRight) -> linux:54 (KEY_RIGHTSHIFT) -> atset1:54 */
+  "Slash": 0x35, /* html:Slash (Slash) -> linux:53 (KEY_SLASH) -> atset1:53 */
+  "Sleep": 0xe05f, /* html:Sleep (Sleep) -> linux:142 (KEY_SLEEP) -> atset1:57439 */
+  "Space": 0x39, /* html:Space (Space) -> linux:57 (KEY_SPACE) -> atset1:57 */
+  "Suspend": 0xe025, /* html:Suspend (Suspend) -> linux:205 (KEY_SUSPEND) -> atset1:57381 */
+  "Tab": 0xf, /* html:Tab (Tab) -> linux:15 (KEY_TAB) -> atset1:15 */
+  "Undo": 0xe007, /* html:Undo (Undo) -> linux:131 (KEY_UNDO) -> atset1:57351 */
+  "WakeUp": 0xe063, /* html:WakeUp (WakeUp) -> linux:143 (KEY_WAKEUP) -> atset1:57443 */
+};
pkg/web/noVNC/core/util/browser.js
@@ -0,0 +1,266 @@
+/*
+ * noVNC: HTML5 VNC client
+ * Copyright (C) 2019 The noVNC authors
+ * Licensed under MPL 2.0 (see LICENSE.txt)
+ *
+ * See README.md for usage and integration instructions.
+ *
+ * Browser feature support detection
+ */
+
+import * as Log from './logging.js';
+import Base64 from '../base64.js';
+
+// Async clipboard detection
+
+/* Evaluates if there is browser support for the async clipboard API and
+ * relevant clipboard permissions. Returns 'unsupported' if permission states
+ * cannot be resolved. On the other hand, detecting 'granted' or 'prompt'
+ * permission states for both read and write indicates full API support with no
+ * imposed native browser paste prompt. Conversely, detecting 'denied' indicates
+ * the user elected to disable clipboard.
+ */
+export async function browserAsyncClipboardSupport() {
+    if (!(navigator?.permissions?.query &&
+          navigator?.clipboard?.writeText &&
+          navigator?.clipboard?.readText)) {
+        return 'unsupported';
+    }
+    try {
+        const writePerm = await navigator.permissions.query(
+            {name: "clipboard-write", allowWithoutGesture: true});
+        const readPerm = await navigator.permissions.query(
+            {name: "clipboard-read",  allowWithoutGesture: false});
+        if (writePerm.state === "denied" || readPerm.state  === "denied") {
+            return 'denied';
+        }
+        if ((writePerm.state === "granted" || writePerm.state === "prompt") &&
+            (readPerm.state  === "granted" || readPerm.state  === "prompt")) {
+            return 'available';
+        }
+    } catch {
+        return 'unsupported';
+    }
+    return 'unsupported';
+}
+
+// Touch detection
+export let isTouchDevice = ('ontouchstart' in document.documentElement) ||
+                                 // required for Chrome debugger
+                                 (document.ontouchstart !== undefined) ||
+                                 // required for MS Surface
+                                 (navigator.maxTouchPoints > 0) ||
+                                 (navigator.msMaxTouchPoints > 0);
+window.addEventListener('touchstart', function onFirstTouch() {
+    isTouchDevice = true;
+    window.removeEventListener('touchstart', onFirstTouch, false);
+}, false);
+
+
+// The goal is to find a certain physical width, the devicePixelRatio
+// brings us a bit closer but is not optimal.
+export let dragThreshold = 10 * (window.devicePixelRatio || 1);
+
+let _supportsCursorURIs = false;
+
+try {
+    const target = document.createElement('canvas');
+    target.style.cursor = 'url("data:image/x-icon;base64,AAACAAEACAgAAAIAAgA4AQAAFgAAACgAAAAIAAAAEAAAAAEAIAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAD/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////AAAAAAAAAAAAAAAAAAAAAA==") 2 2, default';
+
+    if (target.style.cursor.indexOf("url") === 0) {
+        Log.Info("Data URI scheme cursor supported");
+        _supportsCursorURIs = true;
+    } else {
+        Log.Warn("Data URI scheme cursor not supported");
+    }
+} catch (exc) {
+    Log.Error("Data URI scheme cursor test exception: " + exc);
+}
+
+export const supportsCursorURIs = _supportsCursorURIs;
+
+let _hasScrollbarGutter = true;
+try {
+    // Create invisible container
+    const container = document.createElement('div');
+    container.style.visibility = 'hidden';
+    container.style.overflow = 'scroll'; // forcing scrollbars
+    document.body.appendChild(container);
+
+    // Create a div and place it in the container
+    const child = document.createElement('div');
+    container.appendChild(child);
+
+    // Calculate the difference between the container's full width
+    // and the child's width - the difference is the scrollbars
+    const scrollbarWidth = (container.offsetWidth - child.offsetWidth);
+
+    // Clean up
+    container.parentNode.removeChild(container);
+
+    _hasScrollbarGutter = scrollbarWidth != 0;
+} catch (exc) {
+    Log.Error("Scrollbar test exception: " + exc);
+}
+export const hasScrollbarGutter = _hasScrollbarGutter;
+
+export let supportsWebCodecsH264Decode = false;
+
+async function _checkWebCodecsH264DecodeSupport() {
+    if (!('VideoDecoder' in window)) {
+        return false;
+    }
+
+    // We'll need to make do with some placeholders here
+    const config = {
+        codec: 'avc1.42401f',
+        codedWidth: 1920,
+        codedHeight: 1080,
+        optimizeForLatency: true,
+    };
+
+    let support = await VideoDecoder.isConfigSupported(config);
+    if (!support.supported) {
+        return false;
+    }
+
+    // Firefox incorrectly reports supports for H.264 under some
+    // circumstances, so we need to actually test a real frame
+    // https://bugzilla.mozilla.org/show_bug.cgi?id=1932392
+
+    const data = new Uint8Array(Base64.decode(
+        'AAAAAWdCwBTZnpuAgICgAAADACAAAAZB4oVNAAAAAWjJYyyAAAABBgX//4Hc' +
+        'Rem95tlIt5Ys2CDZI+7veDI2NCAtIGNvcmUgMTY0IHIzMTA4IDMxZTE5Zjkg' +
+        'LSBILjI2NC9NUEVHLTQgQVZDIGNvZGVjIC0gQ29weWxlZnQgMjAwMy0yMDIz' +
+        'IC0gaHR0cDovL3d3dy52aWRlb2xhbi5vcmcveDI2NC5odG1sIC0gb3B0aW9u' +
+        'czogY2FiYWM9MCByZWY9NSBkZWJsb2NrPTE6MDowIGFuYWx5c2U9MHgxOjB4' +
+        'MTExIG1lPWhleCBzdWJtZT04IHBzeT0xIHBzeV9yZD0xLjAwOjAuMDAgbWl4' +
+        'ZWRfcmVmPTEgbWVfcmFuZ2U9MTYgY2hyb21hX21lPTEgdHJlbGxpcz0yIDh4' +
+        'OGRjdD0wIGNxbT0wIGRlYWR6b25lPTIxLDExIGZhc3RfcHNraXA9MSBjaHJv' +
+        'bWFfcXBfb2Zmc2V0PS0yIHRocmVhZHM9MSBsb29rYWhlYWRfdGhyZWFkcz0x' +
+        'IHNsaWNlZF90aHJlYWRzPTAgbnI9MCBkZWNpbWF0ZT0xIGludGVybGFjZWQ9' +
+        'MCBibHVyYXlfY29tcGF0PTAgY29uc3RyYWluZWRfaW50cmE9MCBiZnJhbWVz' +
+        'PTAgd2VpZ2h0cD0wIGtleWludD1pbmZpbml0ZSBrZXlpbnRfbWluPTI1IHNj' +
+        'ZW5lY3V0PTQwIGludHJhX3JlZnJlc2g9MCByY19sb29rYWhlYWQ9NTAgcmM9' +
+        'YWJyIG1idHJlZT0xIGJpdHJhdGU9NDAwIHJhdGV0b2w9MS4wIHFjb21wPTAu' +
+        'NjAgcXBtaW49MCBxcG1heD02OSBxcHN0ZXA9NCBpcF9yYXRpbz0xLjQwIGFx' +
+        'PTE6MS4wMACAAAABZYiEBrxmKAAPVccAAS044AA5DRJMnkycJk4TPw=='));
+
+    let gotframe = false;
+    let error = null;
+
+    let decoder = new VideoDecoder({
+        output: (frame) => { gotframe = true; frame.close(); },
+        error: (e) => { error = e; },
+    });
+    let chunk = new EncodedVideoChunk({
+        timestamp: 0,
+        type: 'key',
+        data: data,
+    });
+
+    decoder.configure(config);
+    decoder.decode(chunk);
+    try {
+        await decoder.flush();
+    } catch (e) {
+        // Firefox incorrectly throws an exception here
+        // https://bugzilla.mozilla.org/show_bug.cgi?id=1932566
+        error = e;
+    }
+
+    // Firefox fails to deliver the error on Windows, so we need to
+    // check if we got a frame instead
+    // https://bugzilla.mozilla.org/show_bug.cgi?id=1932579
+    if (!gotframe) {
+        return false;
+    }
+
+    if (error !== null) {
+        return false;
+    }
+
+    return true;
+}
+supportsWebCodecsH264Decode = await _checkWebCodecsH264DecodeSupport();
+
+/*
+ * The functions for detection of platforms and browsers below are exported
+ * but the use of these should be minimized as much as possible.
+ *
+ * It's better to use feature detection than platform detection.
+ */
+
+/* OS */
+
+export function isMac() {
+    return !!(/mac/i).exec(navigator.platform);
+}
+
+export function isWindows() {
+    return !!(/win/i).exec(navigator.platform);
+}
+
+export function isIOS() {
+    return (!!(/ipad/i).exec(navigator.platform) ||
+            !!(/iphone/i).exec(navigator.platform) ||
+            !!(/ipod/i).exec(navigator.platform));
+}
+
+export function isAndroid() {
+    /* Android sets navigator.platform to Linux :/ */
+    return !!navigator.userAgent.match('Android ');
+}
+
+export function isChromeOS() {
+    /* ChromeOS sets navigator.platform to Linux :/ */
+    return !!navigator.userAgent.match(' CrOS ');
+}
+
+/* Browser */
+
+export function isSafari() {
+    return !!navigator.userAgent.match('Safari/...') &&
+           !navigator.userAgent.match('Chrome/...') &&
+           !navigator.userAgent.match('Chromium/...') &&
+           !navigator.userAgent.match('Epiphany/...');
+}
+
+export function isFirefox() {
+    return !!navigator.userAgent.match('Firefox/...') &&
+           !navigator.userAgent.match('Seamonkey/...');
+}
+
+export function isChrome() {
+    return !!navigator.userAgent.match('Chrome/...') &&
+           !navigator.userAgent.match('Chromium/...') &&
+           !navigator.userAgent.match('Edg/...') &&
+           !navigator.userAgent.match('OPR/...');
+}
+
+export function isChromium() {
+    return !!navigator.userAgent.match('Chromium/...');
+}
+
+export function isOpera() {
+    return !!navigator.userAgent.match('OPR/...');
+}
+
+export function isEdge() {
+    return !!navigator.userAgent.match('Edg/...');
+}
+
+/* Engine */
+
+export function isGecko() {
+    return !!navigator.userAgent.match('Gecko/...');
+}
+
+export function isWebKit() {
+    return !!navigator.userAgent.match('AppleWebKit/...') &&
+           !navigator.userAgent.match('Chrome/...');
+}
+
+export function isBlink() {
+    return !!navigator.userAgent.match('Chrome/...');
+}
pkg/web/noVNC/core/util/cursor.js
@@ -0,0 +1,249 @@
+/*
+ * noVNC: HTML5 VNC client
+ * Copyright (C) 2019 The noVNC authors
+ * Licensed under MPL 2.0 or any later version (see LICENSE.txt)
+ */
+
+import { supportsCursorURIs, isTouchDevice } from './browser.js';
+
+const useFallback = !supportsCursorURIs || isTouchDevice;
+
+export default class Cursor {
+    constructor() {
+        this._target = null;
+
+        this._canvas = document.createElement('canvas');
+
+        if (useFallback) {
+            this._canvas.style.position = 'fixed';
+            this._canvas.style.zIndex = '65535';
+            this._canvas.style.pointerEvents = 'none';
+            // Safari on iOS can select the cursor image
+            // https://bugs.webkit.org/show_bug.cgi?id=249223
+            this._canvas.style.userSelect = 'none';
+            this._canvas.style.WebkitUserSelect = 'none';
+            // Can't use "display" because of Firefox bug #1445997
+            this._canvas.style.visibility = 'hidden';
+        }
+
+        this._position = { x: 0, y: 0 };
+        this._hotSpot = { x: 0, y: 0 };
+
+        this._eventHandlers = {
+            'mouseover': this._handleMouseOver.bind(this),
+            'mouseleave': this._handleMouseLeave.bind(this),
+            'mousemove': this._handleMouseMove.bind(this),
+            'mouseup': this._handleMouseUp.bind(this),
+        };
+    }
+
+    attach(target) {
+        if (this._target) {
+            this.detach();
+        }
+
+        this._target = target;
+
+        if (useFallback) {
+            document.body.appendChild(this._canvas);
+
+            const options = { capture: true, passive: true };
+            this._target.addEventListener('mouseover', this._eventHandlers.mouseover, options);
+            this._target.addEventListener('mouseleave', this._eventHandlers.mouseleave, options);
+            this._target.addEventListener('mousemove', this._eventHandlers.mousemove, options);
+            this._target.addEventListener('mouseup', this._eventHandlers.mouseup, options);
+        }
+
+        this.clear();
+    }
+
+    detach() {
+        if (!this._target) {
+            return;
+        }
+
+        if (useFallback) {
+            const options = { capture: true, passive: true };
+            this._target.removeEventListener('mouseover', this._eventHandlers.mouseover, options);
+            this._target.removeEventListener('mouseleave', this._eventHandlers.mouseleave, options);
+            this._target.removeEventListener('mousemove', this._eventHandlers.mousemove, options);
+            this._target.removeEventListener('mouseup', this._eventHandlers.mouseup, options);
+
+            if (document.contains(this._canvas)) {
+                document.body.removeChild(this._canvas);
+            }
+        }
+
+        this._target = null;
+    }
+
+    change(rgba, hotx, hoty, w, h) {
+        if ((w === 0) || (h === 0)) {
+            this.clear();
+            return;
+        }
+
+        this._position.x = this._position.x + this._hotSpot.x - hotx;
+        this._position.y = this._position.y + this._hotSpot.y - hoty;
+        this._hotSpot.x = hotx;
+        this._hotSpot.y = hoty;
+
+        let ctx = this._canvas.getContext('2d');
+
+        this._canvas.width = w;
+        this._canvas.height = h;
+
+        let img = new ImageData(new Uint8ClampedArray(rgba), w, h);
+        ctx.clearRect(0, 0, w, h);
+        ctx.putImageData(img, 0, 0);
+
+        if (useFallback) {
+            this._updatePosition();
+        } else {
+            let url = this._canvas.toDataURL();
+            this._target.style.cursor = 'url(' + url + ')' + hotx + ' ' + hoty + ', default';
+        }
+    }
+
+    clear() {
+        this._target.style.cursor = 'none';
+        this._canvas.width = 0;
+        this._canvas.height = 0;
+        this._position.x = this._position.x + this._hotSpot.x;
+        this._position.y = this._position.y + this._hotSpot.y;
+        this._hotSpot.x = 0;
+        this._hotSpot.y = 0;
+    }
+
+    // Mouse events might be emulated, this allows
+    // moving the cursor in such cases
+    move(clientX, clientY) {
+        if (!useFallback) {
+            return;
+        }
+        // clientX/clientY are relative the _visual viewport_,
+        // but our position is relative the _layout viewport_,
+        // so try to compensate when we can
+        if (window.visualViewport) {
+            this._position.x = clientX + window.visualViewport.offsetLeft;
+            this._position.y = clientY + window.visualViewport.offsetTop;
+        } else {
+            this._position.x = clientX;
+            this._position.y = clientY;
+        }
+        this._updatePosition();
+        let target = document.elementFromPoint(clientX, clientY);
+        this._updateVisibility(target);
+    }
+
+    _handleMouseOver(event) {
+        // This event could be because we're entering the target, or
+        // moving around amongst its sub elements. Let the move handler
+        // sort things out.
+        this._handleMouseMove(event);
+    }
+
+    _handleMouseLeave(event) {
+        // Check if we should show the cursor on the element we are leaving to
+        this._updateVisibility(event.relatedTarget);
+    }
+
+    _handleMouseMove(event) {
+        this._updateVisibility(event.target);
+
+        this._position.x = event.clientX - this._hotSpot.x;
+        this._position.y = event.clientY - this._hotSpot.y;
+
+        this._updatePosition();
+    }
+
+    _handleMouseUp(event) {
+        // We might get this event because of a drag operation that
+        // moved outside of the target. Check what's under the cursor
+        // now and adjust visibility based on that.
+        let target = document.elementFromPoint(event.clientX, event.clientY);
+        this._updateVisibility(target);
+
+        // Captures end with a mouseup but we can't know the event order of
+        // mouseup vs releaseCapture.
+        //
+        // In the cases when releaseCapture comes first, the code above is
+        // enough.
+        //
+        // In the cases when the mouseup comes first, we need wait for the
+        // browser to flush all events and then check again if the cursor
+        // should be visible.
+        if (this._captureIsActive()) {
+            window.setTimeout(() => {
+                // We might have detached at this point
+                if (!this._target) {
+                    return;
+                }
+                // Refresh the target from elementFromPoint since queued events
+                // might have altered the DOM
+                target = document.elementFromPoint(event.clientX,
+                                                   event.clientY);
+                this._updateVisibility(target);
+            }, 0);
+        }
+    }
+
+    _showCursor() {
+        if (this._canvas.style.visibility === 'hidden') {
+            this._canvas.style.visibility = '';
+        }
+    }
+
+    _hideCursor() {
+        if (this._canvas.style.visibility !== 'hidden') {
+            this._canvas.style.visibility = 'hidden';
+        }
+    }
+
+    // Should we currently display the cursor?
+    // (i.e. are we over the target, or a child of the target without a
+    // different cursor set)
+    _shouldShowCursor(target) {
+        if (!target) {
+            return false;
+        }
+        // Easy case
+        if (target === this._target) {
+            return true;
+        }
+        // Other part of the DOM?
+        if (!this._target.contains(target)) {
+            return false;
+        }
+        // Has the child its own cursor?
+        // FIXME: How can we tell that a sub element has an
+        //        explicit "cursor: none;"?
+        if (window.getComputedStyle(target).cursor !== 'none') {
+            return false;
+        }
+        return true;
+    }
+
+    _updateVisibility(target) {
+        // When the cursor target has capture we want to show the cursor.
+        // So, if a capture is active - look at the captured element instead.
+        if (this._captureIsActive()) {
+            target = document.captureElement;
+        }
+        if (this._shouldShowCursor(target)) {
+            this._showCursor();
+        } else {
+            this._hideCursor();
+        }
+    }
+
+    _updatePosition() {
+        this._canvas.style.left = this._position.x + "px";
+        this._canvas.style.top = this._position.y + "px";
+    }
+
+    _captureIsActive() {
+        return document.captureElement &&
+            document.documentElement.contains(document.captureElement);
+    }
+}
pkg/web/noVNC/core/util/element.js
@@ -0,0 +1,32 @@
+/*
+ * noVNC: HTML5 VNC client
+ * Copyright (C) 2020 The noVNC authors
+ * Licensed under MPL 2.0 (see LICENSE.txt)
+ *
+ * See README.md for usage and integration instructions.
+ */
+
+/*
+ * HTML element utility functions
+ */
+
+export function clientToElement(x, y, elem) {
+    const bounds = elem.getBoundingClientRect();
+    let pos = { x: 0, y: 0 };
+    // Clip to target bounds
+    if (x < bounds.left) {
+        pos.x = 0;
+    } else if (x >= bounds.right) {
+        pos.x = bounds.width - 1;
+    } else {
+        pos.x = x - bounds.left;
+    }
+    if (y < bounds.top) {
+        pos.y = 0;
+    } else if (y >= bounds.bottom) {
+        pos.y = bounds.height - 1;
+    } else {
+        pos.y = y - bounds.top;
+    }
+    return pos;
+}
pkg/web/noVNC/core/util/events.js
@@ -0,0 +1,138 @@
+/*
+ * noVNC: HTML5 VNC client
+ * Copyright (C) 2018 The noVNC authors
+ * Licensed under MPL 2.0 (see LICENSE.txt)
+ *
+ * See README.md for usage and integration instructions.
+ */
+
+/*
+ * Cross-browser event and position routines
+ */
+
+export function getPointerEvent(e) {
+    return e.changedTouches ? e.changedTouches[0] : e.touches ? e.touches[0] : e;
+}
+
+export function stopEvent(e) {
+    e.stopPropagation();
+    e.preventDefault();
+}
+
+// Emulate Element.setCapture() when not supported
+let _captureRecursion = false;
+let _elementForUnflushedEvents = null;
+document.captureElement = null;
+function _captureProxy(e) {
+    // Recursion protection as we'll see our own event
+    if (_captureRecursion) return;
+
+    // Clone the event as we cannot dispatch an already dispatched event
+    const newEv = new e.constructor(e.type, e);
+
+    _captureRecursion = true;
+    if (document.captureElement) {
+        document.captureElement.dispatchEvent(newEv);
+    } else {
+        _elementForUnflushedEvents.dispatchEvent(newEv);
+    }
+    _captureRecursion = false;
+
+    // Avoid double events
+    e.stopPropagation();
+
+    // Respect the wishes of the redirected event handlers
+    if (newEv.defaultPrevented) {
+        e.preventDefault();
+    }
+
+    // Implicitly release the capture on button release
+    if (e.type === "mouseup") {
+        releaseCapture();
+    }
+}
+
+// Follow cursor style of target element
+function _capturedElemChanged() {
+    const proxyElem = document.getElementById("noVNC_mouse_capture_elem");
+    proxyElem.style.cursor = window.getComputedStyle(document.captureElement).cursor;
+}
+
+const _captureObserver = new MutationObserver(_capturedElemChanged);
+
+export function setCapture(target) {
+    if (target.setCapture) {
+
+        target.setCapture();
+        document.captureElement = target;
+    } else {
+        // Release any existing capture in case this method is
+        // called multiple times without coordination
+        releaseCapture();
+
+        let proxyElem = document.getElementById("noVNC_mouse_capture_elem");
+
+        if (proxyElem === null) {
+            proxyElem = document.createElement("div");
+            proxyElem.id = "noVNC_mouse_capture_elem";
+            proxyElem.style.position = "fixed";
+            proxyElem.style.top = "0px";
+            proxyElem.style.left = "0px";
+            proxyElem.style.width = "100%";
+            proxyElem.style.height = "100%";
+            proxyElem.style.zIndex = 10000;
+            proxyElem.style.display = "none";
+            document.body.appendChild(proxyElem);
+
+            // This is to make sure callers don't get confused by having
+            // our blocking element as the target
+            proxyElem.addEventListener('contextmenu', _captureProxy);
+
+            proxyElem.addEventListener('mousemove', _captureProxy);
+            proxyElem.addEventListener('mouseup', _captureProxy);
+        }
+
+        document.captureElement = target;
+
+        // Track cursor and get initial cursor
+        _captureObserver.observe(target, {attributes: true});
+        _capturedElemChanged();
+
+        proxyElem.style.display = "";
+
+        // We listen to events on window in order to keep tracking if it
+        // happens to leave the viewport
+        window.addEventListener('mousemove', _captureProxy);
+        window.addEventListener('mouseup', _captureProxy);
+    }
+}
+
+export function releaseCapture() {
+    if (document.releaseCapture) {
+
+        document.releaseCapture();
+        document.captureElement = null;
+
+    } else {
+        if (!document.captureElement) {
+            return;
+        }
+
+        // There might be events already queued. The event proxy needs
+        // access to the captured element for these queued events.
+        // E.g. contextmenu (right-click) in Microsoft Edge
+        //
+        // Before removing the capturedElem pointer we save it to a
+        // temporary variable that the unflushed events can use.
+        _elementForUnflushedEvents = document.captureElement;
+        document.captureElement = null;
+
+        _captureObserver.disconnect();
+
+        const proxyElem = document.getElementById("noVNC_mouse_capture_elem");
+        proxyElem.style.display = "none";
+
+        window.removeEventListener('mousemove', _captureProxy);
+        window.removeEventListener('mouseup', _captureProxy);
+    }
+}
pkg/web/noVNC/core/util/eventtarget.js
@@ -0,0 +1,35 @@
+/*
+ * noVNC: HTML5 VNC client
+ * Copyright (C) 2019 The noVNC authors
+ * Licensed under MPL 2.0 (see LICENSE.txt)
+ *
+ * See README.md for usage and integration instructions.
+ */
+
+export default class EventTargetMixin {
+    constructor() {
+        this._listeners = new Map();
+    }
+
+    addEventListener(type, callback) {
+        if (!this._listeners.has(type)) {
+            this._listeners.set(type, new Set());
+        }
+        this._listeners.get(type).add(callback);
+    }
+
+    removeEventListener(type, callback) {
+        if (this._listeners.has(type)) {
+            this._listeners.get(type).delete(callback);
+        }
+    }
+
+    dispatchEvent(event) {
+        if (!this._listeners.has(event.type)) {
+            return true;
+        }
+        this._listeners.get(event.type)
+            .forEach(callback => callback.call(this, event));
+        return !event.defaultPrevented;
+    }
+}
pkg/web/noVNC/core/util/int.js
@@ -0,0 +1,15 @@
+/*
+ * noVNC: HTML5 VNC client
+ * Copyright (C) 2020 The noVNC authors
+ * Licensed under MPL 2.0 (see LICENSE.txt)
+ *
+ * See README.md for usage and integration instructions.
+ */
+
+export function toUnsigned32bit(toConvert) {
+    return toConvert >>> 0;
+}
+
+export function toSigned32bit(toConvert) {
+    return toConvert | 0;
+}
pkg/web/noVNC/core/util/logging.js
@@ -0,0 +1,56 @@
+/*
+ * noVNC: HTML5 VNC client
+ * Copyright (C) 2019 The noVNC authors
+ * Licensed under MPL 2.0 (see LICENSE.txt)
+ *
+ * See README.md for usage and integration instructions.
+ */
+
+/*
+ * Logging/debug routines
+ */
+
+let _logLevel = 'warn';
+
+let Debug = () => {};
+let Info = () => {};
+let Warn = () => {};
+let Error = () => {};
+
+export function initLogging(level) {
+    if (typeof level === 'undefined') {
+        level = _logLevel;
+    } else {
+        _logLevel = level;
+    }
+
+    Debug = Info = Warn = Error = () => {};
+
+    if (typeof window.console !== "undefined") {
+        /* eslint-disable no-console, no-fallthrough */
+        switch (level) {
+            case 'debug':
+                Debug = console.debug.bind(window.console);
+            case 'info':
+                Info  = console.info.bind(window.console);
+            case 'warn':
+                Warn  = console.warn.bind(window.console);
+            case 'error':
+                Error = console.error.bind(window.console);
+            case 'none':
+                break;
+            default:
+                throw new window.Error("invalid logging type '" + level + "'");
+        }
+        /* eslint-enable no-console, no-fallthrough */
+    }
+}
+
+export function getLogging() {
+    return _logLevel;
+}
+
+export { Debug, Info, Warn, Error };
+
+// Initialize logging level
+initLogging();
pkg/web/noVNC/core/util/strings.js
@@ -0,0 +1,28 @@
+/*
+ * noVNC: HTML5 VNC client
+ * Copyright (C) 2019 The noVNC authors
+ * Licensed under MPL 2.0 (see LICENSE.txt)
+ *
+ * See README.md for usage and integration instructions.
+ */
+
+// Decode from UTF-8
+export function decodeUTF8(utf8string, allowLatin1=false) {
+    try {
+        return decodeURIComponent(escape(utf8string));
+    } catch (e) {
+        if (e instanceof URIError) {
+            if (allowLatin1) {
+                // If we allow Latin1 we can ignore any decoding fails
+                // and in these cases return the original string
+                return utf8string;
+            }
+        }
+        throw e;
+    }
+}
+
+// Encode to UTF-8
+export function encodeUTF8(DOMString) {
+    return unescape(encodeURIComponent(DOMString));
+}
pkg/web/noVNC/core/base64.js
@@ -0,0 +1,104 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// From: http://hg.mozilla.org/mozilla-central/raw-file/ec10630b1a54/js/src/devtools/jint/sunspider/string-base64.js
+
+import * as Log from './util/logging.js';
+
+export default {
+    /* Convert data (an array of integers) to a Base64 string. */
+    toBase64Table: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='.split(''),
+    base64Pad: '=',
+
+    encode(data) {
+        "use strict";
+        let result = '';
+        const length = data.length;
+        const lengthpad = (length % 3);
+        // Convert every three bytes to 4 ascii characters.
+
+        for (let i = 0; i < (length - 2); i += 3) {
+            result += this.toBase64Table[data[i] >> 2];
+            result += this.toBase64Table[((data[i] & 0x03) << 4) + (data[i + 1] >> 4)];
+            result += this.toBase64Table[((data[i + 1] & 0x0f) << 2) + (data[i + 2] >> 6)];
+            result += this.toBase64Table[data[i + 2] & 0x3f];
+        }
+
+        // Convert the remaining 1 or 2 bytes, pad out to 4 characters.
+        const j = length - lengthpad;
+        if (lengthpad === 2) {
+            result += this.toBase64Table[data[j] >> 2];
+            result += this.toBase64Table[((data[j] & 0x03) << 4) + (data[j + 1] >> 4)];
+            result += this.toBase64Table[(data[j + 1] & 0x0f) << 2];
+            result += this.toBase64Table[64];
+        } else if (lengthpad === 1) {
+            result += this.toBase64Table[data[j] >> 2];
+            result += this.toBase64Table[(data[j] & 0x03) << 4];
+            result += this.toBase64Table[64];
+            result += this.toBase64Table[64];
+        }
+
+        return result;
+    },
+
+    /* Convert Base64 data to a string */
+    /* eslint-disable comma-spacing */
+    toBinaryTable: [
+        -1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,-1,
+        -1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,-1,
+        -1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,62, -1,-1,-1,63,
+        52,53,54,55, 56,57,58,59, 60,61,-1,-1, -1, 0,-1,-1,
+        -1, 0, 1, 2,  3, 4, 5, 6,  7, 8, 9,10, 11,12,13,14,
+        15,16,17,18, 19,20,21,22, 23,24,25,-1, -1,-1,-1,-1,
+        -1,26,27,28, 29,30,31,32, 33,34,35,36, 37,38,39,40,
+        41,42,43,44, 45,46,47,48, 49,50,51,-1, -1,-1,-1,-1
+    ],
+    /* eslint-enable comma-spacing */
+
+    decode(data, offset = 0) {
+        let dataLength = data.indexOf('=') - offset;
+        if (dataLength < 0) { dataLength = data.length - offset; }
+
+        /* Every four characters is 3 resulting numbers */
+        const resultLength = (dataLength >> 2) * 3 + Math.floor((dataLength % 4) / 1.5);
+        const result = new Array(resultLength);
+
+        // Convert one by one.
+
+        let leftbits = 0; // number of bits decoded, but yet to be appended
+        let leftdata = 0; // bits decoded, but yet to be appended
+        for (let idx = 0, i = offset; i < data.length; i++) {
+            const c = this.toBinaryTable[data.charCodeAt(i) & 0x7f];
+            const padding = (data.charAt(i) === this.base64Pad);
+            // Skip illegal characters and whitespace
+            if (c === -1) {
+                Log.Error("Illegal character code " + data.charCodeAt(i) + " at position " + i);
+                continue;
+            }
+
+            // Collect data into leftdata, update bitcount
+            leftdata = (leftdata << 6) | c;
+            leftbits += 6;
+
+            // If we have 8 or more bits, append 8 bits to the result
+            if (leftbits >= 8) {
+                leftbits -= 8;
+                // Append if not padding.
+                if (!padding) {
+                    result[idx++] = (leftdata >> leftbits) & 0xff;
+                }
+                leftdata &= (1 << leftbits) - 1;
+            }
+        }
+
+        // If there are any bits left, the base64 string was corrupted
+        if (leftbits) {
+            const err = new Error('Corrupted base64 string');
+            err.name = 'Base64-Error';
+            throw err;
+        }
+
+        return result;
+    }
+}; /* End of Base64 namespace */
pkg/web/noVNC/core/clipboard.js
@@ -0,0 +1,72 @@
+/*
+ * noVNC: HTML5 VNC client
+ * Copyright (c) 2025 The noVNC authors
+ * Licensed under MPL 2.0 or any later version (see LICENSE.txt)
+ */
+
+import * as Log from './util/logging.js';
+import { browserAsyncClipboardSupport } from './util/browser.js';
+
+export default class AsyncClipboard {
+    constructor(target) {
+        this._target = target || null;
+
+        this._isAvailable = null;
+
+        this._eventHandlers = {
+            'focus': this._handleFocus.bind(this),
+        };
+
+        // ===== EVENT HANDLERS =====
+
+        this.onpaste = () => {};
+    }
+
+    // ===== PRIVATE METHODS =====
+
+    async _ensureAvailable() {
+        if (this._isAvailable !== null) return this._isAvailable;
+        try {
+            const status = await browserAsyncClipboardSupport();
+            this._isAvailable = (status === 'available');
+        } catch {
+            this._isAvailable = false;
+        }
+        return this._isAvailable;
+    }
+
+    async _handleFocus(event) {
+        if (!(await this._ensureAvailable())) return;
+        try {
+            const text = await navigator.clipboard.readText();
+            this.onpaste(text);
+        } catch (error) {
+            Log.Error("Clipboard read failed: ", error);
+        }
+    }
+
+    // ===== PUBLIC METHODS =====
+
+    writeClipboard(text) {
+        // Can lazily check cached availability
+        if (!this._isAvailable) return false;
+        navigator.clipboard.writeText(text)
+            .catch(error => Log.Error("Clipboard write failed: ", error));
+        return true;
+    }
+
+    grab() {
+        if (!this._target) return;
+        this._ensureAvailable()
+            .then((isAvailable) => {
+                if (isAvailable) {
+                    this._target.addEventListener('focus', this._eventHandlers.focus);
+                }
+            });
+    }
+
+    ungrab() {
+        if (!this._target) return;
+        this._target.removeEventListener('focus', this._eventHandlers.focus);
+    }
+}
pkg/web/noVNC/core/deflator.js
@@ -0,0 +1,84 @@
+/*
+ * noVNC: HTML5 VNC client
+ * Copyright (C) 2020 The noVNC authors
+ * Licensed under MPL 2.0 (see LICENSE.txt)
+ *
+ * See README.md for usage and integration instructions.
+ */
+
+import { deflateInit, deflate } from "../vendor/pako/lib/zlib/deflate.js";
+import { Z_FULL_FLUSH, Z_DEFAULT_COMPRESSION } from "../vendor/pako/lib/zlib/deflate.js";
+import ZStream from "../vendor/pako/lib/zlib/zstream.js";
+
+export default class Deflator {
+    constructor() {
+        this.strm = new ZStream();
+        this.chunkSize = 1024 * 10 * 10;
+        this.outputBuffer = new Uint8Array(this.chunkSize);
+
+        deflateInit(this.strm, Z_DEFAULT_COMPRESSION);
+    }
+
+    deflate(inData) {
+        /* eslint-disable camelcase */
+        this.strm.input = inData;
+        this.strm.avail_in = this.strm.input.length;
+        this.strm.next_in = 0;
+        this.strm.output = this.outputBuffer;
+        this.strm.avail_out = this.chunkSize;
+        this.strm.next_out = 0;
+        /* eslint-enable camelcase */
+
+        let lastRet = deflate(this.strm, Z_FULL_FLUSH);
+        let outData = new Uint8Array(this.strm.output.buffer, 0, this.strm.next_out);
+
+        if (lastRet < 0) {
+            throw new Error("zlib deflate failed");
+        }
+
+        if (this.strm.avail_in > 0) {
+            // Read chunks until done
+
+            let chunks = [outData];
+            let totalLen = outData.length;
+            do {
+                /* eslint-disable camelcase */
+                this.strm.output = new Uint8Array(this.chunkSize);
+                this.strm.next_out = 0;
+                this.strm.avail_out = this.chunkSize;
+                /* eslint-enable camelcase */
+
+                lastRet = deflate(this.strm, Z_FULL_FLUSH);
+
+                if (lastRet < 0) {
+                    throw new Error("zlib deflate failed");
+                }
+
+                let chunk = new Uint8Array(this.strm.output.buffer, 0, this.strm.next_out);
+                totalLen += chunk.length;
+                chunks.push(chunk);
+            } while (this.strm.avail_in > 0);
+
+            // Combine chunks into a single data
+
+            let newData = new Uint8Array(totalLen);
+            let offset = 0;
+
+            for (let i = 0; i < chunks.length; i++) {
+                newData.set(chunks[i], offset);
+                offset += chunks[i].length;
+            }
+
+            outData = newData;
+        }
+
+        /* eslint-disable camelcase */
+        this.strm.input = null;
+        this.strm.avail_in = 0;
+        this.strm.next_in = 0;
+        /* eslint-enable camelcase */
+
+        return outData;
+    }
+
+}
pkg/web/noVNC/core/display.js
@@ -0,0 +1,578 @@
+/*
+ * noVNC: HTML5 VNC client
+ * Copyright (C) 2019 The noVNC authors
+ * Licensed under MPL 2.0 (see LICENSE.txt)
+ *
+ * See README.md for usage and integration instructions.
+ */
+
+import * as Log from './util/logging.js';
+import Base64 from "./base64.js";
+import { toSigned32bit } from './util/int.js';
+
+export default class Display {
+    constructor(target) {
+        this._drawCtx = null;
+
+        this._renderQ = [];  // queue drawing actions for in-order rendering
+        this._flushPromise = null;
+
+        // the full frame buffer (logical canvas) size
+        this._fbWidth = 0;
+        this._fbHeight = 0;
+
+        this._prevDrawStyle = "";
+
+        Log.Debug(">> Display.constructor");
+
+        // The visible canvas
+        this._target = target;
+
+        if (!this._target) {
+            throw new Error("Target must be set");
+        }
+
+        if (typeof this._target === 'string') {
+            throw new Error('target must be a DOM element');
+        }
+
+        if (!this._target.getContext) {
+            throw new Error("no getContext method");
+        }
+
+        this._targetCtx = this._target.getContext('2d');
+
+        // the visible canvas viewport (i.e. what actually gets seen)
+        this._viewportLoc = { 'x': 0, 'y': 0, 'w': this._target.width, 'h': this._target.height };
+
+        // The hidden canvas, where we do the actual rendering
+        this._backbuffer = document.createElement('canvas');
+        this._drawCtx = this._backbuffer.getContext('2d');
+
+        this._damageBounds = { left: 0, top: 0,
+                               right: this._backbuffer.width,
+                               bottom: this._backbuffer.height };
+
+        Log.Debug("User Agent: " + navigator.userAgent);
+
+        Log.Debug("<< Display.constructor");
+
+        // ===== PROPERTIES =====
+
+        this._scale = 1.0;
+        this._clipViewport = false;
+    }
+
+    // ===== PROPERTIES =====
+
+    get scale() { return this._scale; }
+    set scale(scale) {
+        this._rescale(scale);
+    }
+
+    get clipViewport() { return this._clipViewport; }
+    set clipViewport(viewport) {
+        this._clipViewport = viewport;
+        // May need to readjust the viewport dimensions
+        const vp = this._viewportLoc;
+        this.viewportChangeSize(vp.w, vp.h);
+        this.viewportChangePos(0, 0);
+    }
+
+    get width() {
+        return this._fbWidth;
+    }
+
+    get height() {
+        return this._fbHeight;
+    }
+
+    // ===== PUBLIC METHODS =====
+
+    viewportChangePos(deltaX, deltaY) {
+        const vp = this._viewportLoc;
+        deltaX = Math.floor(deltaX);
+        deltaY = Math.floor(deltaY);
+
+        if (!this._clipViewport) {
+            deltaX = -vp.w;  // clamped later of out of bounds
+            deltaY = -vp.h;
+        }
+
+        const vx2 = vp.x + vp.w - 1;
+        const vy2 = vp.y + vp.h - 1;
+
+        // Position change
+
+        if (deltaX < 0 && vp.x + deltaX < 0) {
+            deltaX = -vp.x;
+        }
+        if (vx2 + deltaX >= this._fbWidth) {
+            deltaX -= vx2 + deltaX - this._fbWidth + 1;
+        }
+
+        if (vp.y + deltaY < 0) {
+            deltaY = -vp.y;
+        }
+        if (vy2 + deltaY >= this._fbHeight) {
+            deltaY -= (vy2 + deltaY - this._fbHeight + 1);
+        }
+
+        if (deltaX === 0 && deltaY === 0) {
+            return;
+        }
+        Log.Debug("viewportChange deltaX: " + deltaX + ", deltaY: " + deltaY);
+
+        vp.x += deltaX;
+        vp.y += deltaY;
+
+        this._damage(vp.x, vp.y, vp.w, vp.h);
+
+        this.flip();
+    }
+
+    viewportChangeSize(width, height) {
+
+        if (!this._clipViewport ||
+            typeof(width) === "undefined" ||
+            typeof(height) === "undefined") {
+
+            Log.Debug("Setting viewport to full display region");
+            width = this._fbWidth;
+            height = this._fbHeight;
+        }
+
+        width = Math.floor(width);
+        height = Math.floor(height);
+
+        if (width > this._fbWidth) {
+            width = this._fbWidth;
+        }
+        if (height > this._fbHeight) {
+            height = this._fbHeight;
+        }
+
+        const vp = this._viewportLoc;
+        if (vp.w !== width || vp.h !== height) {
+            vp.w = width;
+            vp.h = height;
+
+            const canvas = this._target;
+            canvas.width = width;
+            canvas.height = height;
+
+            // The position might need to be updated if we've grown
+            this.viewportChangePos(0, 0);
+
+            this._damage(vp.x, vp.y, vp.w, vp.h);
+            this.flip();
+
+            // Update the visible size of the target canvas
+            this._rescale(this._scale);
+        }
+    }
+
+    absX(x) {
+        if (this._scale === 0) {
+            return 0;
+        }
+        return toSigned32bit(x / this._scale + this._viewportLoc.x);
+    }
+
+    absY(y) {
+        if (this._scale === 0) {
+            return 0;
+        }
+        return toSigned32bit(y / this._scale + this._viewportLoc.y);
+    }
+
+    resize(width, height) {
+        this._prevDrawStyle = "";
+
+        this._fbWidth = width;
+        this._fbHeight = height;
+
+        const canvas = this._backbuffer;
+        if (canvas.width !== width || canvas.height !== height) {
+
+            // We have to save the canvas data since changing the size will clear it
+            let saveImg = null;
+            if (canvas.width > 0 && canvas.height > 0) {
+                saveImg = this._drawCtx.getImageData(0, 0, canvas.width, canvas.height);
+            }
+
+            if (canvas.width !== width) {
+                canvas.width = width;
+            }
+            if (canvas.height !== height) {
+                canvas.height = height;
+            }
+
+            if (saveImg) {
+                this._drawCtx.putImageData(saveImg, 0, 0);
+            }
+        }
+
+        // Readjust the viewport as it may be incorrectly sized
+        // and positioned
+        const vp = this._viewportLoc;
+        this.viewportChangeSize(vp.w, vp.h);
+        this.viewportChangePos(0, 0);
+    }
+
+    getImageData() {
+        return this._drawCtx.getImageData(0, 0, this.width, this.height);
+    }
+
+    toDataURL(type, encoderOptions) {
+        return this._backbuffer.toDataURL(type, encoderOptions);
+    }
+
+    toBlob(callback, type, quality) {
+        return this._backbuffer.toBlob(callback, type, quality);
+    }
+
+    // Track what parts of the visible canvas that need updating
+    _damage(x, y, w, h) {
+        if (x < this._damageBounds.left) {
+            this._damageBounds.left = x;
+        }
+        if (y < this._damageBounds.top) {
+            this._damageBounds.top = y;
+        }
+        if ((x + w) > this._damageBounds.right) {
+            this._damageBounds.right = x + w;
+        }
+        if ((y + h) > this._damageBounds.bottom) {
+            this._damageBounds.bottom = y + h;
+        }
+    }
+
+    // Update the visible canvas with the contents of the
+    // rendering canvas
+    flip(fromQueue) {
+        if (this._renderQ.length !== 0 && !fromQueue) {
+            this._renderQPush({
+                'type': 'flip'
+            });
+        } else {
+            let x = this._damageBounds.left;
+            let y = this._damageBounds.top;
+            let w = this._damageBounds.right - x;
+            let h = this._damageBounds.bottom - y;
+
+            let vx = x - this._viewportLoc.x;
+            let vy = y - this._viewportLoc.y;
+
+            if (vx < 0) {
+                w += vx;
+                x -= vx;
+                vx = 0;
+            }
+            if (vy < 0) {
+                h += vy;
+                y -= vy;
+                vy = 0;
+            }
+
+            if ((vx + w) > this._viewportLoc.w) {
+                w = this._viewportLoc.w - vx;
+            }
+            if ((vy + h) > this._viewportLoc.h) {
+                h = this._viewportLoc.h - vy;
+            }
+
+            if ((w > 0) && (h > 0)) {
+                // FIXME: We may need to disable image smoothing here
+                //        as well (see copyImage()), but we haven't
+                //        noticed any problem yet.
+                this._targetCtx.drawImage(this._backbuffer,
+                                          x, y, w, h,
+                                          vx, vy, w, h);
+            }
+
+            this._damageBounds.left = this._damageBounds.top = 65535;
+            this._damageBounds.right = this._damageBounds.bottom = 0;
+        }
+    }
+
+    pending() {
+        return this._renderQ.length > 0;
+    }
+
+    flush() {
+        if (this._renderQ.length === 0) {
+            return Promise.resolve();
+        } else {
+            if (this._flushPromise === null) {
+                this._flushPromise = new Promise((resolve) => {
+                    this._flushResolve = resolve;
+                });
+            }
+            return this._flushPromise;
+        }
+    }
+
+    fillRect(x, y, width, height, color, fromQueue) {
+        if (this._renderQ.length !== 0 && !fromQueue) {
+            this._renderQPush({
+                'type': 'fill',
+                'x': x,
+                'y': y,
+                'width': width,
+                'height': height,
+                'color': color
+            });
+        } else {
+            this._setFillColor(color);
+            this._drawCtx.fillRect(x, y, width, height);
+            this._damage(x, y, width, height);
+        }
+    }
+
+    copyImage(oldX, oldY, newX, newY, w, h, fromQueue) {
+        if (this._renderQ.length !== 0 && !fromQueue) {
+            this._renderQPush({
+                'type': 'copy',
+                'oldX': oldX,
+                'oldY': oldY,
+                'x': newX,
+                'y': newY,
+                'width': w,
+                'height': h,
+            });
+        } else {
+            // Due to this bug among others [1] we need to disable the image-smoothing to
+            // avoid getting a blur effect when copying data.
+            //
+            // 1. https://bugzilla.mozilla.org/show_bug.cgi?id=1194719
+            //
+            // We need to set these every time since all properties are reset
+            // when the the size is changed
+            this._drawCtx.mozImageSmoothingEnabled = false;
+            this._drawCtx.webkitImageSmoothingEnabled = false;
+            this._drawCtx.msImageSmoothingEnabled = false;
+            this._drawCtx.imageSmoothingEnabled = false;
+
+            this._drawCtx.drawImage(this._backbuffer,
+                                    oldX, oldY, w, h,
+                                    newX, newY, w, h);
+            this._damage(newX, newY, w, h);
+        }
+    }
+
+    imageRect(x, y, width, height, mime, arr) {
+        /* The internal logic cannot handle empty images, so bail early */
+        if ((width === 0) || (height === 0)) {
+            return;
+        }
+
+        const img = new Image();
+        img.src = "data: " + mime + ";base64," + Base64.encode(arr);
+
+        this._renderQPush({
+            'type': 'img',
+            'img': img,
+            'x': x,
+            'y': y,
+            'width': width,
+            'height': height
+        });
+    }
+
+    videoFrame(x, y, width, height, frame) {
+        this._renderQPush({
+            'type': 'frame',
+            'frame': frame,
+            'x': x,
+            'y': y,
+            'width': width,
+            'height': height
+        });
+    }
+
+    blitImage(x, y, width, height, arr, offset, fromQueue) {
+        if (this._renderQ.length !== 0 && !fromQueue) {
+            // NB(directxman12): it's technically more performant here to use preallocated arrays,
+            // but it's a lot of extra work for not a lot of payoff -- if we're using the render queue,
+            // this probably isn't getting called *nearly* as much
+            const newArr = new Uint8Array(width * height * 4);
+            newArr.set(new Uint8Array(arr.buffer, 0, newArr.length));
+            this._renderQPush({
+                'type': 'blit',
+                'data': newArr,
+                'x': x,
+                'y': y,
+                'width': width,
+                'height': height,
+            });
+        } else {
+            // NB(directxman12): arr must be an Type Array view
+            let data = new Uint8ClampedArray(arr.buffer,
+                                             arr.byteOffset + offset,
+                                             width * height * 4);
+            let img = new ImageData(data, width, height);
+            this._drawCtx.putImageData(img, x, y);
+            this._damage(x, y, width, height);
+        }
+    }
+
+    drawImage(img, ...args) {
+        this._drawCtx.drawImage(img, ...args);
+
+        if (args.length <= 4) {
+            const [x, y] = args;
+            this._damage(x, y, img.width, img.height);
+        } else {
+            const [,, sw, sh, dx, dy] = args;
+            this._damage(dx, dy, sw, sh);
+        }
+    }
+
+    autoscale(containerWidth, containerHeight) {
+        let scaleRatio;
+
+        if (containerWidth === 0 || containerHeight === 0) {
+            scaleRatio = 0;
+
+        } else {
+
+            const vp = this._viewportLoc;
+            const targetAspectRatio = containerWidth / containerHeight;
+            const fbAspectRatio = vp.w / vp.h;
+
+            if (fbAspectRatio >= targetAspectRatio) {
+                scaleRatio = containerWidth / vp.w;
+            } else {
+                scaleRatio = containerHeight / vp.h;
+            }
+        }
+
+        this._rescale(scaleRatio);
+    }
+
+    // ===== PRIVATE METHODS =====
+
+    _rescale(factor) {
+        this._scale = factor;
+        const vp = this._viewportLoc;
+
+        // NB(directxman12): If you set the width directly, or set the
+        //                   style width to a number, the canvas is cleared.
+        //                   However, if you set the style width to a string
+        //                   ('NNNpx'), the canvas is scaled without clearing.
+        const width = factor * vp.w + 'px';
+        const height = factor * vp.h + 'px';
+
+        if ((this._target.style.width !== width) ||
+            (this._target.style.height !== height)) {
+            this._target.style.width = width;
+            this._target.style.height = height;
+        }
+    }
+
+    _setFillColor(color) {
+        const newStyle = 'rgb(' + color[0] + ',' + color[1] + ',' + color[2] + ')';
+        if (newStyle !== this._prevDrawStyle) {
+            this._drawCtx.fillStyle = newStyle;
+            this._prevDrawStyle = newStyle;
+        }
+    }
+
+    _renderQPush(action) {
+        this._renderQ.push(action);
+        if (this._renderQ.length === 1) {
+            // If this can be rendered immediately it will be, otherwise
+            // the scanner will wait for the relevant event
+            this._scanRenderQ();
+        }
+    }
+
+    _resumeRenderQ() {
+        // "this" is the object that is ready, not the
+        // display object
+        this.removeEventListener('load', this._noVNCDisplay._resumeRenderQ);
+        this._noVNCDisplay._scanRenderQ();
+    }
+
+    _scanRenderQ() {
+        let ready = true;
+        while (ready && this._renderQ.length > 0) {
+            const a = this._renderQ[0];
+            switch (a.type) {
+                case 'flip':
+                    this.flip(true);
+                    break;
+                case 'copy':
+                    this.copyImage(a.oldX, a.oldY, a.x, a.y, a.width, a.height, true);
+                    break;
+                case 'fill':
+                    this.fillRect(a.x, a.y, a.width, a.height, a.color, true);
+                    break;
+                case 'blit':
+                    this.blitImage(a.x, a.y, a.width, a.height, a.data, 0, true);
+                    break;
+                case 'img':
+                    if (a.img.complete) {
+                        if (a.img.width !== a.width || a.img.height !== a.height) {
+                            Log.Error("Decoded image has incorrect dimensions. Got " +
+                                      a.img.width + "x" + a.img.height + ". Expected " +
+                                      a.width + "x" + a.height + ".");
+                            return;
+                        }
+                        this.drawImage(a.img, a.x, a.y);
+                        // This helps the browser free the memory right
+                        // away, rather than ballooning
+                        a.img.src = "";
+                    } else {
+                        a.img._noVNCDisplay = this;
+                        a.img.addEventListener('load', this._resumeRenderQ);
+                        // We need to wait for this image to 'load'
+                        // to keep things in-order
+                        ready = false;
+                    }
+                    break;
+                case 'frame':
+                    if (a.frame.ready) {
+                        // The encoded frame may be larger than the rect due to
+                        // limitations of the encoder, so we need to crop the
+                        // frame.
+                        let frame = a.frame.frame;
+                        if (frame.codedWidth < a.width || frame.codedHeight < a.height) {
+                            Log.Warn("Decoded video frame does not cover its full rectangle area. Expecting at least " +
+                                      a.width + "x" + a.height + " but got " +
+                                      frame.codedWidth + "x" + frame.codedHeight);
+                        }
+                        const sx = 0;
+                        const sy = 0;
+                        const sw = a.width;
+                        const sh = a.height;
+                        const dx = a.x;
+                        const dy = a.y;
+                        const dw = sw;
+                        const dh = sh;
+                        this.drawImage(frame, sx, sy, sw, sh, dx, dy, dw, dh);
+                        frame.close();
+                    } else {
+                        let display = this;
+                        a.frame.promise.then(() => {
+                            display._scanRenderQ();
+                        });
+                        ready = false;
+                    }
+                    break;
+            }
+
+            if (ready) {
+                this._renderQ.shift();
+            }
+        }
+
+        if (this._renderQ.length === 0 &&
+            this._flushPromise !== null) {
+            this._flushResolve();
+            this._flushPromise = null;
+            this._flushResolve = null;
+        }
+    }
+}
pkg/web/noVNC/core/encodings.js
@@ -0,0 +1,54 @@
+/*
+ * noVNC: HTML5 VNC client
+ * Copyright (C) 2019 The noVNC authors
+ * Licensed under MPL 2.0 (see LICENSE.txt)
+ *
+ * See README.md for usage and integration instructions.
+ */
+
+export const encodings = {
+    encodingRaw: 0,
+    encodingCopyRect: 1,
+    encodingRRE: 2,
+    encodingHextile: 5,
+    encodingZlib: 6,
+    encodingTight: 7,
+    encodingZRLE: 16,
+    encodingTightPNG: -260,
+    encodingJPEG: 21,
+    encodingH264: 50,
+
+    pseudoEncodingQualityLevel9: -23,
+    pseudoEncodingQualityLevel0: -32,
+    pseudoEncodingDesktopSize: -223,
+    pseudoEncodingLastRect: -224,
+    pseudoEncodingCursor: -239,
+    pseudoEncodingQEMUExtendedKeyEvent: -258,
+    pseudoEncodingQEMULedEvent: -261,
+    pseudoEncodingDesktopName: -307,
+    pseudoEncodingExtendedDesktopSize: -308,
+    pseudoEncodingXvp: -309,
+    pseudoEncodingFence: -312,
+    pseudoEncodingContinuousUpdates: -313,
+    pseudoEncodingExtendedMouseButtons: -316,
+    pseudoEncodingCompressLevel9: -247,
+    pseudoEncodingCompressLevel0: -256,
+    pseudoEncodingVMwareCursor: 0x574d5664,
+    pseudoEncodingExtendedClipboard: 0xc0a1e5ce
+};
+
+export function encodingName(num) {
+    switch (num) {
+        case encodings.encodingRaw:      return "Raw";
+        case encodings.encodingCopyRect: return "CopyRect";
+        case encodings.encodingRRE:      return "RRE";
+        case encodings.encodingHextile:  return "Hextile";
+        case encodings.encodingZlib:     return "Zlib";
+        case encodings.encodingTight:    return "Tight";
+        case encodings.encodingZRLE:     return "ZRLE";
+        case encodings.encodingTightPNG: return "TightPNG";
+        case encodings.encodingJPEG:     return "JPEG";
+        case encodings.encodingH264:     return "H.264";
+        default:                         return "[unknown encoding " + num + "]";
+    }
+}
pkg/web/noVNC/core/inflator.js
@@ -0,0 +1,65 @@
+/*
+ * noVNC: HTML5 VNC client
+ * Copyright (C) 2020 The noVNC authors
+ * Licensed under MPL 2.0 (see LICENSE.txt)
+ *
+ * See README.md for usage and integration instructions.
+ */
+
+import { inflateInit, inflate, inflateReset } from "../vendor/pako/lib/zlib/inflate.js";
+import ZStream from "../vendor/pako/lib/zlib/zstream.js";
+
+export default class Inflate {
+    constructor() {
+        this.strm = new ZStream();
+        this.chunkSize = 1024 * 10 * 10;
+        this.strm.output = new Uint8Array(this.chunkSize);
+
+        inflateInit(this.strm);
+    }
+
+    setInput(data) {
+        if (!data) {
+            //FIXME: flush remaining data.
+            /* eslint-disable camelcase */
+            this.strm.input = null;
+            this.strm.avail_in = 0;
+            this.strm.next_in = 0;
+        } else {
+            this.strm.input = data;
+            this.strm.avail_in = this.strm.input.length;
+            this.strm.next_in = 0;
+            /* eslint-enable camelcase */
+        }
+    }
+
+    inflate(expected) {
+        // resize our output buffer if it's too small
+        // (we could just use multiple chunks, but that would cause an extra
+        // allocation each time to flatten the chunks)
+        if (expected > this.chunkSize) {
+            this.chunkSize = expected;
+            this.strm.output = new Uint8Array(this.chunkSize);
+        }
+
+        /* eslint-disable camelcase */
+        this.strm.next_out = 0;
+        this.strm.avail_out = expected;
+        /* eslint-enable camelcase */
+
+        let ret = inflate(this.strm, 0); // Flush argument not used.
+        if (ret < 0) {
+            throw new Error("zlib inflate failed");
+        }
+
+        if (this.strm.next_out != expected) {
+            throw new Error("Incomplete zlib block");
+        }
+
+        return new Uint8Array(this.strm.output.buffer, 0, this.strm.next_out);
+    }
+
+    reset() {
+        inflateReset(this.strm);
+    }
+}
pkg/web/noVNC/core/ra2.js
@@ -0,0 +1,312 @@
+import { encodeUTF8 } from './util/strings.js';
+import EventTargetMixin from './util/eventtarget.js';
+import legacyCrypto from './crypto/crypto.js';
+
+class RA2Cipher {
+    constructor() {
+        this._cipher = null;
+        this._counter = new Uint8Array(16);
+    }
+
+    async setKey(key) {
+        this._cipher = await legacyCrypto.importKey(
+            "raw", key, { name: "AES-EAX" }, false, ["encrypt, decrypt"]);
+    }
+
+    async makeMessage(message) {
+        const ad = new Uint8Array([(message.length & 0xff00) >>> 8, message.length & 0xff]);
+        const encrypted = await legacyCrypto.encrypt({
+            name: "AES-EAX",
+            iv: this._counter,
+            additionalData: ad,
+        }, this._cipher, message);
+        for (let i = 0; i < 16 && this._counter[i]++ === 255; i++);
+        const res = new Uint8Array(message.length + 2 + 16);
+        res.set(ad);
+        res.set(encrypted, 2);
+        return res;
+    }
+
+    async receiveMessage(length, encrypted) {
+        const ad = new Uint8Array([(length & 0xff00) >>> 8, length & 0xff]);
+        const res = await legacyCrypto.decrypt({
+            name: "AES-EAX",
+            iv: this._counter,
+            additionalData: ad,
+        }, this._cipher, encrypted);
+        for (let i = 0; i < 16 && this._counter[i]++ === 255; i++);
+        return res;
+    }
+}
+
+export default class RSAAESAuthenticationState extends EventTargetMixin {
+    constructor(sock, getCredentials) {
+        super();
+        this._hasStarted = false;
+        this._checkSock = null;
+        this._checkCredentials = null;
+        this._approveServerResolve = null;
+        this._sockReject = null;
+        this._credentialsReject = null;
+        this._approveServerReject = null;
+        this._sock = sock;
+        this._getCredentials = getCredentials;
+    }
+
+    _waitSockAsync(len) {
+        return new Promise((resolve, reject) => {
+            const hasData = () => !this._sock.rQwait('RA2', len);
+            if (hasData()) {
+                resolve();
+            } else {
+                this._checkSock = () => {
+                    if (hasData()) {
+                        resolve();
+                        this._checkSock = null;
+                        this._sockReject = null;
+                    }
+                };
+                this._sockReject = reject;
+            }
+        });
+    }
+
+    _waitApproveKeyAsync() {
+        return new Promise((resolve, reject) => {
+            this._approveServerResolve = resolve;
+            this._approveServerReject = reject;
+        });
+    }
+
+    _waitCredentialsAsync(subtype) {
+        const hasCredentials = () => {
+            if (subtype === 1 && this._getCredentials().username !== undefined &&
+                this._getCredentials().password !== undefined) {
+                return true;
+            } else if (subtype === 2 && this._getCredentials().password !== undefined) {
+                return true;
+            }
+            return false;
+        };
+        return new Promise((resolve, reject) => {
+            if (hasCredentials()) {
+                resolve();
+            } else {
+                this._checkCredentials = () => {
+                    if (hasCredentials()) {
+                        resolve();
+                        this._checkCredentials = null;
+                        this._credentialsReject = null;
+                    }
+                };
+                this._credentialsReject = reject;
+            }
+        });
+    }
+
+    checkInternalEvents() {
+        if (this._checkSock !== null) {
+            this._checkSock();
+        }
+        if (this._checkCredentials !== null) {
+            this._checkCredentials();
+        }
+    }
+
+    approveServer() {
+        if (this._approveServerResolve !== null) {
+            this._approveServerResolve();
+            this._approveServerResolve = null;
+        }
+    }
+
+    disconnect() {
+        if (this._sockReject !== null) {
+            this._sockReject(new Error("disconnect normally"));
+            this._sockReject = null;
+        }
+        if (this._credentialsReject !== null) {
+            this._credentialsReject(new Error("disconnect normally"));
+            this._credentialsReject = null;
+        }
+        if (this._approveServerReject !== null) {
+            this._approveServerReject(new Error("disconnect normally"));
+            this._approveServerReject = null;
+        }
+    }
+
+    async negotiateRA2neAuthAsync() {
+        this._hasStarted = true;
+        // 1: Receive server public key
+        await this._waitSockAsync(4);
+        const serverKeyLengthBuffer = this._sock.rQpeekBytes(4);
+        const serverKeyLength = this._sock.rQshift32();
+        if (serverKeyLength < 1024) {
+            throw new Error("RA2: server public key is too short: " + serverKeyLength);
+        } else if (serverKeyLength > 8192) {
+            throw new Error("RA2: server public key is too long: " + serverKeyLength);
+        }
+        const serverKeyBytes = Math.ceil(serverKeyLength / 8);
+        await this._waitSockAsync(serverKeyBytes * 2);
+        const serverN = this._sock.rQshiftBytes(serverKeyBytes);
+        const serverE = this._sock.rQshiftBytes(serverKeyBytes);
+        const serverRSACipher = await legacyCrypto.importKey(
+            "raw", { n: serverN, e: serverE }, { name: "RSA-PKCS1-v1_5" }, false, ["encrypt"]);
+        const serverPublickey = new Uint8Array(4 + serverKeyBytes * 2);
+        serverPublickey.set(serverKeyLengthBuffer);
+        serverPublickey.set(serverN, 4);
+        serverPublickey.set(serverE, 4 + serverKeyBytes);
+
+        // verify server public key
+        let approveKey = this._waitApproveKeyAsync();
+        this.dispatchEvent(new CustomEvent("serververification", {
+            detail: { type: "RSA", publickey: serverPublickey }
+        }));
+        await approveKey;
+
+        // 2: Send client public key
+        const clientKeyLength = 2048;
+        const clientKeyBytes = Math.ceil(clientKeyLength / 8);
+        const clientRSACipher = (await legacyCrypto.generateKey({
+            name: "RSA-PKCS1-v1_5",
+            modulusLength: clientKeyLength,
+            publicExponent: new Uint8Array([1, 0, 1]),
+        }, true, ["encrypt"])).privateKey;
+        const clientExportedRSAKey = await legacyCrypto.exportKey("raw", clientRSACipher);
+        const clientN = clientExportedRSAKey.n;
+        const clientE = clientExportedRSAKey.e;
+        const clientPublicKey = new Uint8Array(4 + clientKeyBytes * 2);
+        clientPublicKey[0] = (clientKeyLength & 0xff000000) >>> 24;
+        clientPublicKey[1] = (clientKeyLength & 0xff0000) >>> 16;
+        clientPublicKey[2] = (clientKeyLength & 0xff00) >>> 8;
+        clientPublicKey[3] = clientKeyLength & 0xff;
+        clientPublicKey.set(clientN, 4);
+        clientPublicKey.set(clientE, 4 + clientKeyBytes);
+        this._sock.sQpushBytes(clientPublicKey);
+        this._sock.flush();
+
+        // 3: Send client random
+        const clientRandom = new Uint8Array(16);
+        window.crypto.getRandomValues(clientRandom);
+        const clientEncryptedRandom = await legacyCrypto.encrypt(
+            { name: "RSA-PKCS1-v1_5" }, serverRSACipher, clientRandom);
+        const clientRandomMessage = new Uint8Array(2 + serverKeyBytes);
+        clientRandomMessage[0] = (serverKeyBytes & 0xff00) >>> 8;
+        clientRandomMessage[1] = serverKeyBytes & 0xff;
+        clientRandomMessage.set(clientEncryptedRandom, 2);
+        this._sock.sQpushBytes(clientRandomMessage);
+        this._sock.flush();
+
+        // 4: Receive server random
+        await this._waitSockAsync(2);
+        if (this._sock.rQshift16() !== clientKeyBytes) {
+            throw new Error("RA2: wrong encrypted message length");
+        }
+        const serverEncryptedRandom = this._sock.rQshiftBytes(clientKeyBytes);
+        const serverRandom = await legacyCrypto.decrypt(
+            { name: "RSA-PKCS1-v1_5" }, clientRSACipher, serverEncryptedRandom);
+        if (serverRandom === null || serverRandom.length !== 16) {
+            throw new Error("RA2: corrupted server encrypted random");
+        }
+
+        // 5: Compute session keys and set ciphers
+        let clientSessionKey = new Uint8Array(32);
+        let serverSessionKey = new Uint8Array(32);
+        clientSessionKey.set(serverRandom);
+        clientSessionKey.set(clientRandom, 16);
+        serverSessionKey.set(clientRandom);
+        serverSessionKey.set(serverRandom, 16);
+        clientSessionKey = await window.crypto.subtle.digest("SHA-1", clientSessionKey);
+        clientSessionKey = new Uint8Array(clientSessionKey).slice(0, 16);
+        serverSessionKey = await window.crypto.subtle.digest("SHA-1", serverSessionKey);
+        serverSessionKey = new Uint8Array(serverSessionKey).slice(0, 16);
+        const clientCipher = new RA2Cipher();
+        await clientCipher.setKey(clientSessionKey);
+        const serverCipher = new RA2Cipher();
+        await serverCipher.setKey(serverSessionKey);
+
+        // 6: Compute and exchange hashes
+        let serverHash = new Uint8Array(8 + serverKeyBytes * 2 + clientKeyBytes * 2);
+        let clientHash = new Uint8Array(8 + serverKeyBytes * 2 + clientKeyBytes * 2);
+        serverHash.set(serverPublickey);
+        serverHash.set(clientPublicKey, 4 + serverKeyBytes * 2);
+        clientHash.set(clientPublicKey);
+        clientHash.set(serverPublickey, 4 + clientKeyBytes * 2);
+        serverHash = await window.crypto.subtle.digest("SHA-1", serverHash);
+        clientHash = await window.crypto.subtle.digest("SHA-1", clientHash);
+        serverHash = new Uint8Array(serverHash);
+        clientHash = new Uint8Array(clientHash);
+        this._sock.sQpushBytes(await clientCipher.makeMessage(clientHash));
+        this._sock.flush();
+        await this._waitSockAsync(2 + 20 + 16);
+        if (this._sock.rQshift16() !== 20) {
+            throw new Error("RA2: wrong server hash");
+        }
+        const serverHashReceived = await serverCipher.receiveMessage(
+            20, this._sock.rQshiftBytes(20 + 16));
+        if (serverHashReceived === null) {
+            throw new Error("RA2: failed to authenticate the message");
+        }
+        for (let i = 0; i < 20; i++) {
+            if (serverHashReceived[i] !== serverHash[i]) {
+                throw new Error("RA2: wrong server hash");
+            }
+        }
+
+        // 7: Receive subtype
+        await this._waitSockAsync(2 + 1 + 16);
+        if (this._sock.rQshift16() !== 1) {
+            throw new Error("RA2: wrong subtype");
+        }
+        let subtype = (await serverCipher.receiveMessage(
+            1, this._sock.rQshiftBytes(1 + 16)));
+        if (subtype === null) {
+            throw new Error("RA2: failed to authenticate the message");
+        }
+        subtype = subtype[0];
+        let waitCredentials = this._waitCredentialsAsync(subtype);
+        if (subtype === 1) {
+            if (this._getCredentials().username === undefined ||
+                this._getCredentials().password === undefined) {
+                this.dispatchEvent(new CustomEvent(
+                    "credentialsrequired",
+                    { detail: { types: ["username", "password"] } }));
+            }
+        } else if (subtype === 2) {
+            if (this._getCredentials().password === undefined) {
+                this.dispatchEvent(new CustomEvent(
+                    "credentialsrequired",
+                    { detail: { types: ["password"] } }));
+            }
+        } else {
+            throw new Error("RA2: wrong subtype");
+        }
+        await waitCredentials;
+        let username;
+        if (subtype === 1) {
+            username = encodeUTF8(this._getCredentials().username).slice(0, 255);
+        } else {
+            username = "";
+        }
+        const password = encodeUTF8(this._getCredentials().password).slice(0, 255);
+        const credentials = new Uint8Array(username.length + password.length + 2);
+        credentials[0] = username.length;
+        credentials[username.length + 1] = password.length;
+        for (let i = 0; i < username.length; i++) {
+            credentials[i + 1] = username.charCodeAt(i);
+        }
+        for (let i = 0; i < password.length; i++) {
+            credentials[username.length + 2 + i] = password.charCodeAt(i);
+        }
+        this._sock.sQpushBytes(await clientCipher.makeMessage(credentials));
+        this._sock.flush();
+    }
+
+    get hasStarted() {
+        return this._hasStarted;
+    }
+
+    set hasStarted(s) {
+        this._hasStarted = s;
+    }
+}
pkg/web/noVNC/core/rfb.js
@@ -0,0 +1,3426 @@
+/*
+ * noVNC: HTML5 VNC client
+ * Copyright (C) 2020 The noVNC authors
+ * Licensed under MPL 2.0 (see LICENSE.txt)
+ *
+ * See README.md for usage and integration instructions.
+ *
+ */
+
+import { toUnsigned32bit, toSigned32bit } from './util/int.js';
+import * as Log from './util/logging.js';
+import { encodeUTF8, decodeUTF8 } from './util/strings.js';
+import { dragThreshold, supportsWebCodecsH264Decode } from './util/browser.js';
+import { clientToElement } from './util/element.js';
+import { setCapture } from './util/events.js';
+import EventTargetMixin from './util/eventtarget.js';
+import Display from "./display.js";
+import AsyncClipboard from "./clipboard.js";
+import Inflator from "./inflator.js";
+import Deflator from "./deflator.js";
+import Keyboard from "./input/keyboard.js";
+import GestureHandler from "./input/gesturehandler.js";
+import Cursor from "./util/cursor.js";
+import Websock from "./websock.js";
+import KeyTable from "./input/keysym.js";
+import XtScancode from "./input/xtscancodes.js";
+import { encodings } from "./encodings.js";
+import RSAAESAuthenticationState from "./ra2.js";
+import legacyCrypto from "./crypto/crypto.js";
+
+import RawDecoder from "./decoders/raw.js";
+import CopyRectDecoder from "./decoders/copyrect.js";
+import RREDecoder from "./decoders/rre.js";
+import HextileDecoder from "./decoders/hextile.js";
+import ZlibDecoder from './decoders/zlib.js';
+import TightDecoder from "./decoders/tight.js";
+import TightPNGDecoder from "./decoders/tightpng.js";
+import ZRLEDecoder from "./decoders/zrle.js";
+import JPEGDecoder from "./decoders/jpeg.js";
+import H264Decoder from "./decoders/h264.js";
+
+// How many seconds to wait for a disconnect to finish
+const DISCONNECT_TIMEOUT = 3;
+const DEFAULT_BACKGROUND = 'rgb(40, 40, 40)';
+
+// Minimum wait (ms) between two mouse moves
+const MOUSE_MOVE_DELAY = 17;
+
+// Wheel thresholds
+const WHEEL_STEP = 50; // Pixels needed for one step
+const WHEEL_LINE_HEIGHT = 19; // Assumed pixels for one line step
+
+// Gesture thresholds
+const GESTURE_ZOOMSENS = 75;
+const GESTURE_SCRLSENS = 50;
+const DOUBLE_TAP_TIMEOUT = 1000;
+const DOUBLE_TAP_THRESHOLD = 50;
+
+// Security types
+const securityTypeNone              = 1;
+const securityTypeVNCAuth           = 2;
+const securityTypeRA2ne             = 6;
+const securityTypeTight             = 16;
+const securityTypeVeNCrypt          = 19;
+const securityTypeXVP               = 22;
+const securityTypeARD               = 30;
+const securityTypeMSLogonII         = 113;
+
+// Special Tight security types
+const securityTypeUnixLogon         = 129;
+
+// VeNCrypt security types
+const securityTypePlain             = 256;
+
+// Extended clipboard pseudo-encoding formats
+const extendedClipboardFormatText   = 1;
+/*eslint-disable no-unused-vars */
+const extendedClipboardFormatRtf    = 1 << 1;
+const extendedClipboardFormatHtml   = 1 << 2;
+const extendedClipboardFormatDib    = 1 << 3;
+const extendedClipboardFormatFiles  = 1 << 4;
+/*eslint-enable */
+
+// Extended clipboard pseudo-encoding actions
+const extendedClipboardActionCaps    = 1 << 24;
+const extendedClipboardActionRequest = 1 << 25;
+const extendedClipboardActionPeek    = 1 << 26;
+const extendedClipboardActionNotify  = 1 << 27;
+const extendedClipboardActionProvide = 1 << 28;
+
+export default class RFB extends EventTargetMixin {
+    constructor(target, urlOrChannel, options) {
+        if (!target) {
+            throw new Error("Must specify target");
+        }
+        if (!urlOrChannel) {
+            throw new Error("Must specify URL, WebSocket or RTCDataChannel");
+        }
+
+        // We rely on modern APIs which might not be available in an
+        // insecure context
+        if (!window.isSecureContext) {
+            Log.Error("noVNC requires a secure context (TLS). Expect crashes!");
+        }
+
+        super();
+
+        this._target = target;
+
+        if (typeof urlOrChannel === "string") {
+            this._url = urlOrChannel;
+        } else {
+            this._url = null;
+            this._rawChannel = urlOrChannel;
+        }
+
+        // Connection details
+        options = options || {};
+        this._rfbCredentials = options.credentials || {};
+        this._shared = 'shared' in options ? !!options.shared : true;
+        this._repeaterID = options.repeaterID || '';
+        this._wsProtocols = options.wsProtocols || [];
+
+        // Internal state
+        this._rfbConnectionState = '';
+        this._rfbInitState = '';
+        this._rfbAuthScheme = -1;
+        this._rfbCleanDisconnect = true;
+        this._rfbRSAAESAuthenticationState = null;
+
+        // Server capabilities
+        this._rfbVersion = 0;
+        this._rfbMaxVersion = 3.8;
+        this._rfbTightVNC = false;
+        this._rfbVeNCryptState = 0;
+        this._rfbXvpVer = 0;
+
+        this._fbWidth = 0;
+        this._fbHeight = 0;
+
+        this._fbName = "";
+
+        this._capabilities = { power: false };
+
+        this._supportsFence = false;
+
+        this._supportsContinuousUpdates = false;
+        this._enabledContinuousUpdates = false;
+
+        this._supportsSetDesktopSize = false;
+        this._screenID = 0;
+        this._screenFlags = 0;
+        this._pendingRemoteResize = false;
+        this._lastResize = 0;
+
+        this._qemuExtKeyEventSupported = false;
+
+        this._extendedPointerEventSupported = false;
+
+        this._clipboardText = null;
+        this._clipboardServerCapabilitiesActions = {};
+        this._clipboardServerCapabilitiesFormats = {};
+
+        // Internal objects
+        this._sock = null;              // Websock object
+        this._display = null;           // Display object
+        this._flushing = false;         // Display flushing state
+        this._asyncClipboard = null;    // Async clipboard object
+        this._keyboard = null;          // Keyboard input handler object
+        this._gestures = null;          // Gesture input handler object
+        this._resizeObserver = null;    // Resize observer object
+
+        // Timers
+        this._disconnTimer = null;      // disconnection timer
+        this._resizeTimeout = null;     // resize rate limiting
+        this._mouseMoveTimer = null;
+
+        // Decoder states
+        this._decoders = {};
+
+        this._FBU = {
+            rects: 0,
+            x: 0,
+            y: 0,
+            width: 0,
+            height: 0,
+            encoding: null,
+        };
+
+        // Mouse state
+        this._mousePos = {};
+        this._mouseButtonMask = 0;
+        this._mouseLastMoveTime = 0;
+        this._viewportDragging = false;
+        this._viewportDragPos = {};
+        this._viewportHasMoved = false;
+        this._accumulatedWheelDeltaX = 0;
+        this._accumulatedWheelDeltaY = 0;
+
+        // Gesture state
+        this._gestureLastTapTime = null;
+        this._gestureFirstDoubleTapEv = null;
+        this._gestureLastMagnitudeX = 0;
+        this._gestureLastMagnitudeY = 0;
+
+        // Bound event handlers
+        this._eventHandlers = {
+            focusCanvas: this._focusCanvas.bind(this),
+            handleResize: this._handleResize.bind(this),
+            handleMouse: this._handleMouse.bind(this),
+            handleWheel: this._handleWheel.bind(this),
+            handleGesture: this._handleGesture.bind(this),
+            handleRSAAESCredentialsRequired: this._handleRSAAESCredentialsRequired.bind(this),
+            handleRSAAESServerVerification: this._handleRSAAESServerVerification.bind(this),
+        };
+
+        // main setup
+        Log.Debug(">> RFB.constructor");
+
+        // Create DOM elements
+        this._screen = document.createElement('div');
+        this._screen.style.display = 'flex';
+        this._screen.style.width = '100%';
+        this._screen.style.height = '100%';
+        this._screen.style.overflow = 'auto';
+        this._screen.style.background = DEFAULT_BACKGROUND;
+        this._canvas = document.createElement('canvas');
+        this._canvas.style.margin = 'auto';
+        // Some browsers add an outline on focus
+        this._canvas.style.outline = 'none';
+        this._canvas.width = 0;
+        this._canvas.height = 0;
+        this._canvas.tabIndex = -1;
+        this._screen.appendChild(this._canvas);
+
+        // Cursor
+        this._cursor = new Cursor();
+
+        // XXX: TightVNC 2.8.11 sends no cursor at all until Windows changes
+        // it. Result: no cursor at all until a window border or an edit field
+        // is hit blindly. But there are also VNC servers that draw the cursor
+        // in the framebuffer and don't send the empty local cursor. There is
+        // no way to satisfy both sides.
+        //
+        // The spec is unclear on this "initial cursor" issue. Many other
+        // viewers (TigerVNC, RealVNC, Remmina) display an arrow as the
+        // initial cursor instead.
+        this._cursorImage = RFB.cursors.none;
+
+        // populate decoder array with objects
+        this._decoders[encodings.encodingRaw] = new RawDecoder();
+        this._decoders[encodings.encodingCopyRect] = new CopyRectDecoder();
+        this._decoders[encodings.encodingRRE] = new RREDecoder();
+        this._decoders[encodings.encodingHextile] = new HextileDecoder();
+        this._decoders[encodings.encodingZlib] = new ZlibDecoder();
+        this._decoders[encodings.encodingTight] = new TightDecoder();
+        this._decoders[encodings.encodingTightPNG] = new TightPNGDecoder();
+        this._decoders[encodings.encodingZRLE] = new ZRLEDecoder();
+        this._decoders[encodings.encodingJPEG] = new JPEGDecoder();
+        this._decoders[encodings.encodingH264] = new H264Decoder();
+
+        // NB: nothing that needs explicit teardown should be done
+        // before this point, since this can throw an exception
+        try {
+            this._display = new Display(this._canvas);
+        } catch (exc) {
+            Log.Error("Display exception: " + exc);
+            throw exc;
+        }
+
+        this._asyncClipboard = new AsyncClipboard(this._canvas);
+        this._asyncClipboard.onpaste = this.clipboardPasteFrom.bind(this);
+
+        this._keyboard = new Keyboard(this._canvas);
+        this._keyboard.onkeyevent = this._handleKeyEvent.bind(this);
+        this._remoteCapsLock = null; // Null indicates unknown or irrelevant
+        this._remoteNumLock = null;
+
+        this._gestures = new GestureHandler();
+
+        this._sock = new Websock();
+        this._sock.on('open', this._socketOpen.bind(this));
+        this._sock.on('close', this._socketClose.bind(this));
+        this._sock.on('message', this._handleMessage.bind(this));
+        this._sock.on('error', this._socketError.bind(this));
+
+        this._expectedClientWidth = null;
+        this._expectedClientHeight = null;
+        this._resizeObserver = new ResizeObserver(this._eventHandlers.handleResize);
+
+        // All prepared, kick off the connection
+        this._updateConnectionState('connecting');
+
+        Log.Debug("<< RFB.constructor");
+
+        // ===== PROPERTIES =====
+
+        this.dragViewport = false;
+        this.focusOnClick = true;
+
+        this._viewOnly = false;
+        this._clipViewport = false;
+        this._clippingViewport = false;
+        this._scaleViewport = false;
+        this._resizeSession = false;
+
+        this._showDotCursor = false;
+
+        this._qualityLevel = 6;
+        this._compressionLevel = 2;
+    }
+
+    // ===== PROPERTIES =====
+
+    get viewOnly() { return this._viewOnly; }
+    set viewOnly(viewOnly) {
+        this._viewOnly = viewOnly;
+
+        if (this._rfbConnectionState === "connecting" ||
+            this._rfbConnectionState === "connected") {
+            if (viewOnly) {
+                this._keyboard.ungrab();
+                this._asyncClipboard.ungrab();
+            } else {
+                this._keyboard.grab();
+                this._asyncClipboard.grab();
+            }
+        }
+    }
+
+    get capabilities() { return this._capabilities; }
+
+    get clippingViewport() { return this._clippingViewport; }
+    _setClippingViewport(on) {
+        if (on === this._clippingViewport) {
+            return;
+        }
+        this._clippingViewport = on;
+        this.dispatchEvent(new CustomEvent("clippingviewport",
+                                           { detail: this._clippingViewport }));
+    }
+
+    get touchButton() { return 0; }
+    set touchButton(button) { Log.Warn("Using old API!"); }
+
+    get clipViewport() { return this._clipViewport; }
+    set clipViewport(viewport) {
+        this._clipViewport = viewport;
+        this._updateClip();
+    }
+
+    get scaleViewport() { return this._scaleViewport; }
+    set scaleViewport(scale) {
+        this._scaleViewport = scale;
+        // Scaling trumps clipping, so we may need to adjust
+        // clipping when enabling or disabling scaling
+        if (scale && this._clipViewport) {
+            this._updateClip();
+        }
+        this._updateScale();
+        if (!scale && this._clipViewport) {
+            this._updateClip();
+        }
+    }
+
+    get resizeSession() { return this._resizeSession; }
+    set resizeSession(resize) {
+        this._resizeSession = resize;
+        if (resize) {
+            this._requestRemoteResize();
+        }
+    }
+
+    get showDotCursor() { return this._showDotCursor; }
+    set showDotCursor(show) {
+        this._showDotCursor = show;
+        this._refreshCursor();
+    }
+
+    get background() { return this._screen.style.background; }
+    set background(cssValue) { this._screen.style.background = cssValue; }
+
+    get qualityLevel() {
+        return this._qualityLevel;
+    }
+    set qualityLevel(qualityLevel) {
+        if (!Number.isInteger(qualityLevel) || qualityLevel < 0 || qualityLevel > 9) {
+            Log.Error("qualityLevel must be an integer between 0 and 9");
+            return;
+        }
+
+        if (this._qualityLevel === qualityLevel) {
+            return;
+        }
+
+        this._qualityLevel = qualityLevel;
+
+        if (this._rfbConnectionState === 'connected') {
+            this._sendEncodings();
+        }
+    }
+
+    get compressionLevel() {
+        return this._compressionLevel;
+    }
+    set compressionLevel(compressionLevel) {
+        if (!Number.isInteger(compressionLevel) || compressionLevel < 0 || compressionLevel > 9) {
+            Log.Error("compressionLevel must be an integer between 0 and 9");
+            return;
+        }
+
+        if (this._compressionLevel === compressionLevel) {
+            return;
+        }
+
+        this._compressionLevel = compressionLevel;
+
+        if (this._rfbConnectionState === 'connected') {
+            this._sendEncodings();
+        }
+    }
+
+    // ===== PUBLIC METHODS =====
+
+    disconnect() {
+        this._updateConnectionState('disconnecting');
+        this._sock.off('error');
+        this._sock.off('message');
+        this._sock.off('open');
+        if (this._rfbRSAAESAuthenticationState !== null) {
+            this._rfbRSAAESAuthenticationState.disconnect();
+        }
+    }
+
+    approveServer() {
+        if (this._rfbRSAAESAuthenticationState !== null) {
+            this._rfbRSAAESAuthenticationState.approveServer();
+        }
+    }
+
+    sendCredentials(creds) {
+        this._rfbCredentials = creds;
+        this._resumeAuthentication();
+    }
+
+    sendCtrlAltDel() {
+        if (this._rfbConnectionState !== 'connected' || this._viewOnly) { return; }
+        Log.Info("Sending Ctrl-Alt-Del");
+
+        this.sendKey(KeyTable.XK_Control_L, "ControlLeft", true);
+        this.sendKey(KeyTable.XK_Alt_L, "AltLeft", true);
+        this.sendKey(KeyTable.XK_Delete, "Delete", true);
+        this.sendKey(KeyTable.XK_Delete, "Delete", false);
+        this.sendKey(KeyTable.XK_Alt_L, "AltLeft", false);
+        this.sendKey(KeyTable.XK_Control_L, "ControlLeft", false);
+    }
+
+    machineShutdown() {
+        this._xvpOp(1, 2);
+    }
+
+    machineReboot() {
+        this._xvpOp(1, 3);
+    }
+
+    machineReset() {
+        this._xvpOp(1, 4);
+    }
+
+    // Send a key press. If 'down' is not specified then send a down key
+    // followed by an up key.
+    sendKey(keysym, code, down) {
+        if (this._rfbConnectionState !== 'connected' || this._viewOnly) { return; }
+
+        if (down === undefined) {
+            this.sendKey(keysym, code, true);
+            this.sendKey(keysym, code, false);
+            return;
+        }
+
+        const scancode = XtScancode[code];
+
+        if (this._qemuExtKeyEventSupported && scancode) {
+            // 0 is NoSymbol
+            keysym = keysym || 0;
+
+            Log.Info("Sending key (" + (down ? "down" : "up") + "): keysym " + keysym + ", scancode " + scancode);
+
+            RFB.messages.QEMUExtendedKeyEvent(this._sock, keysym, down, scancode);
+        } else {
+            if (!keysym) {
+                return;
+            }
+            Log.Info("Sending keysym (" + (down ? "down" : "up") + "): " + keysym);
+            RFB.messages.keyEvent(this._sock, keysym, down ? 1 : 0);
+        }
+    }
+
+    focus(options) {
+        this._canvas.focus(options);
+    }
+
+    blur() {
+        this._canvas.blur();
+    }
+
+    clipboardPasteFrom(text) {
+        if (this._rfbConnectionState !== 'connected' || this._viewOnly) { return; }
+
+        if (this._clipboardServerCapabilitiesFormats[extendedClipboardFormatText] &&
+            this._clipboardServerCapabilitiesActions[extendedClipboardActionNotify]) {
+
+            this._clipboardText = text;
+            RFB.messages.extendedClipboardNotify(this._sock, [extendedClipboardFormatText]);
+        } else {
+            let length, i;
+            let data;
+
+            length = 0;
+            // eslint-disable-next-line no-unused-vars
+            for (let codePoint of text) {
+                length++;
+            }
+
+            data = new Uint8Array(length);
+
+            i = 0;
+            for (let codePoint of text) {
+                let code = codePoint.codePointAt(0);
+
+                /* Only ISO 8859-1 is supported */
+                if (code > 0xff) {
+                    code = 0x3f; // '?'
+                }
+
+                data[i++] = code;
+            }
+
+            RFB.messages.clientCutText(this._sock, data);
+        }
+    }
+
+    getImageData() {
+        return this._display.getImageData();
+    }
+
+    toDataURL(type, encoderOptions) {
+        return this._display.toDataURL(type, encoderOptions);
+    }
+
+    toBlob(callback, type, quality) {
+        return this._display.toBlob(callback, type, quality);
+    }
+
+    // ===== PRIVATE METHODS =====
+
+    _connect() {
+        Log.Debug(">> RFB.connect");
+
+        if (this._url) {
+            Log.Info(`connecting to ${this._url}`);
+            this._sock.open(this._url, this._wsProtocols);
+        } else {
+            Log.Info(`attaching ${this._rawChannel} to Websock`);
+            this._sock.attach(this._rawChannel);
+
+            if (this._sock.readyState === 'closed') {
+                throw Error("Cannot use already closed WebSocket/RTCDataChannel");
+            }
+
+            if (this._sock.readyState === 'open') {
+                // FIXME: _socketOpen() can in theory call _fail(), which
+                //        isn't allowed this early, but I'm not sure that can
+                //        happen without a bug messing up our state variables
+                this._socketOpen();
+            }
+        }
+
+        // Make our elements part of the page
+        this._target.appendChild(this._screen);
+
+        this._gestures.attach(this._canvas);
+
+        this._cursor.attach(this._canvas);
+        this._refreshCursor();
+
+        // Monitor size changes of the screen element
+        this._resizeObserver.observe(this._screen);
+
+        // Always grab focus on some kind of click event
+        this._canvas.addEventListener("mousedown", this._eventHandlers.focusCanvas);
+        this._canvas.addEventListener("touchstart", this._eventHandlers.focusCanvas);
+
+        // Mouse events
+        this._canvas.addEventListener('mousedown', this._eventHandlers.handleMouse);
+        this._canvas.addEventListener('mouseup', this._eventHandlers.handleMouse);
+        this._canvas.addEventListener('mousemove', this._eventHandlers.handleMouse);
+        // Prevent middle-click pasting (see handler for why we bind to document)
+        this._canvas.addEventListener('click', this._eventHandlers.handleMouse);
+        // preventDefault() on mousedown doesn't stop this event for some
+        // reason so we have to explicitly block it
+        this._canvas.addEventListener('contextmenu', this._eventHandlers.handleMouse);
+
+        // Wheel events
+        this._canvas.addEventListener("wheel", this._eventHandlers.handleWheel);
+
+        // Gesture events
+        this._canvas.addEventListener("gesturestart", this._eventHandlers.handleGesture);
+        this._canvas.addEventListener("gesturemove", this._eventHandlers.handleGesture);
+        this._canvas.addEventListener("gestureend", this._eventHandlers.handleGesture);
+
+        Log.Debug("<< RFB.connect");
+    }
+
+    _disconnect() {
+        Log.Debug(">> RFB.disconnect");
+        this._cursor.detach();
+        this._canvas.removeEventListener("gesturestart", this._eventHandlers.handleGesture);
+        this._canvas.removeEventListener("gesturemove", this._eventHandlers.handleGesture);
+        this._canvas.removeEventListener("gestureend", this._eventHandlers.handleGesture);
+        this._canvas.removeEventListener("wheel", this._eventHandlers.handleWheel);
+        this._canvas.removeEventListener('mousedown', this._eventHandlers.handleMouse);
+        this._canvas.removeEventListener('mouseup', this._eventHandlers.handleMouse);
+        this._canvas.removeEventListener('mousemove', this._eventHandlers.handleMouse);
+        this._canvas.removeEventListener('click', this._eventHandlers.handleMouse);
+        this._canvas.removeEventListener('contextmenu', this._eventHandlers.handleMouse);
+        this._canvas.removeEventListener("mousedown", this._eventHandlers.focusCanvas);
+        this._canvas.removeEventListener("touchstart", this._eventHandlers.focusCanvas);
+        this._resizeObserver.disconnect();
+        this._keyboard.ungrab();
+        this._gestures.detach();
+        this._sock.close();
+        try {
+            this._target.removeChild(this._screen);
+        } catch (e) {
+            if (e.name === 'NotFoundError') {
+                // Some cases where the initial connection fails
+                // can disconnect before the _screen is created
+            } else {
+                throw e;
+            }
+        }
+        clearTimeout(this._resizeTimeout);
+        clearTimeout(this._mouseMoveTimer);
+        Log.Debug("<< RFB.disconnect");
+    }
+
+    _socketOpen() {
+        if ((this._rfbConnectionState === 'connecting') &&
+            (this._rfbInitState === '')) {
+            this._rfbInitState = 'ProtocolVersion';
+            Log.Debug("Starting VNC handshake");
+        } else {
+            this._fail("Unexpected server connection while " +
+                       this._rfbConnectionState);
+        }
+    }
+
+    _socketClose(e) {
+        Log.Debug("WebSocket on-close event");
+        let msg = "";
+        if (e.code) {
+            msg = "(code: " + e.code;
+            if (e.reason) {
+                msg += ", reason: " + e.reason;
+            }
+            msg += ")";
+        }
+        switch (this._rfbConnectionState) {
+            case 'connecting':
+                this._fail("Connection closed " + msg);
+                break;
+            case 'connected':
+                // Handle disconnects that were initiated server-side
+                this._updateConnectionState('disconnecting');
+                this._updateConnectionState('disconnected');
+                break;
+            case 'disconnecting':
+                // Normal disconnection path
+                this._updateConnectionState('disconnected');
+                break;
+            case 'disconnected':
+                this._fail("Unexpected server disconnect " +
+                           "when already disconnected " + msg);
+                break;
+            default:
+                this._fail("Unexpected server disconnect before connecting " +
+                           msg);
+                break;
+        }
+        this._sock.off('close');
+        // Delete reference to raw channel to allow cleanup.
+        this._rawChannel = null;
+    }
+
+    _socketError(e) {
+        Log.Warn("WebSocket on-error event");
+    }
+
+    _focusCanvas(event) {
+        if (!this.focusOnClick) {
+            return;
+        }
+
+        this.focus({ preventScroll: true });
+    }
+
+    _setDesktopName(name) {
+        this._fbName = name;
+        this.dispatchEvent(new CustomEvent(
+            "desktopname",
+            { detail: { name: this._fbName } }));
+    }
+
+    _saveExpectedClientSize() {
+        this._expectedClientWidth = this._screen.clientWidth;
+        this._expectedClientHeight = this._screen.clientHeight;
+    }
+
+    _currentClientSize() {
+        return [this._screen.clientWidth, this._screen.clientHeight];
+    }
+
+    _clientHasExpectedSize() {
+        const [currentWidth, currentHeight] = this._currentClientSize();
+        return currentWidth == this._expectedClientWidth &&
+            currentHeight == this._expectedClientHeight;
+    }
+
+    // Handle browser window resizes
+    _handleResize() {
+        // Don't change anything if the client size is already as expected
+        if (this._clientHasExpectedSize()) {
+            return;
+        }
+        // If the window resized then our screen element might have
+        // as well. Update the viewport dimensions.
+        window.requestAnimationFrame(() => {
+            this._updateClip();
+            this._updateScale();
+            this._saveExpectedClientSize();
+        });
+
+        // Request changing the resolution of the remote display to
+        // the size of the local browser viewport.
+        this._requestRemoteResize();
+    }
+
+    // Update state of clipping in Display object, and make sure the
+    // configured viewport matches the current screen size
+    _updateClip() {
+        const curClip = this._display.clipViewport;
+        let newClip = this._clipViewport;
+
+        if (this._scaleViewport) {
+            // Disable viewport clipping if we are scaling
+            newClip = false;
+        }
+
+        if (curClip !== newClip) {
+            this._display.clipViewport = newClip;
+        }
+
+        if (newClip) {
+            // When clipping is enabled, the screen is limited to
+            // the size of the container.
+            const size = this._screenSize();
+            this._display.viewportChangeSize(size.w, size.h);
+            this._fixScrollbars();
+            this._setClippingViewport(size.w < this._display.width ||
+                                      size.h < this._display.height);
+        } else {
+            this._setClippingViewport(false);
+        }
+
+        // When changing clipping we might show or hide scrollbars.
+        // This causes the expected client dimensions to change.
+        if (curClip !== newClip) {
+            this._saveExpectedClientSize();
+        }
+    }
+
+    _updateScale() {
+        if (!this._scaleViewport) {
+            this._display.scale = 1.0;
+        } else {
+            const size = this._screenSize();
+            this._display.autoscale(size.w, size.h);
+        }
+        this._fixScrollbars();
+    }
+
+    // Requests a change of remote desktop size. This message is an extension
+    // and may only be sent if we have received an ExtendedDesktopSize message
+    _requestRemoteResize() {
+        if (!this._resizeSession) {
+            return;
+        }
+        if (this._viewOnly) {
+            return;
+        }
+        if (!this._supportsSetDesktopSize) {
+            return;
+        }
+
+        // Rate limit to one pending resize at a time
+        if (this._pendingRemoteResize) {
+            return;
+        }
+
+        // And no more than once every 100ms
+        if ((Date.now() - this._lastResize) < 100) {
+            clearTimeout(this._resizeTimeout);
+            this._resizeTimeout = setTimeout(this._requestRemoteResize.bind(this),
+                                             100 - (Date.now() - this._lastResize));
+            return;
+        }
+        this._resizeTimeout = null;
+
+        const size = this._screenSize();
+
+        // Do we actually change anything?
+        if (size.w === this._fbWidth && size.h === this._fbHeight) {
+            return;
+        }
+
+        this._pendingRemoteResize = true;
+        this._lastResize = Date.now();
+        RFB.messages.setDesktopSize(this._sock,
+                                    Math.floor(size.w), Math.floor(size.h),
+                                    this._screenID, this._screenFlags);
+
+        Log.Debug('Requested new desktop size: ' +
+                   size.w + 'x' + size.h);
+    }
+
+    // Gets the the size of the available screen
+    _screenSize() {
+        let r = this._screen.getBoundingClientRect();
+        return { w: r.width, h: r.height };
+    }
+
+    _fixScrollbars() {
+        // This is a hack because Safari on macOS screws up the calculation
+        // for when scrollbars are needed. We get scrollbars when making the
+        // browser smaller, despite remote resize being enabled. So to fix it
+        // we temporarily toggle them off and on.
+        const orig = this._screen.style.overflow;
+        this._screen.style.overflow = 'hidden';
+        // Force Safari to recalculate the layout by asking for
+        // an element's dimensions
+        this._screen.getBoundingClientRect();
+        this._screen.style.overflow = orig;
+    }
+
+    /*
+     * Connection states:
+     *   connecting
+     *   connected
+     *   disconnecting
+     *   disconnected - permanent state
+     */
+    _updateConnectionState(state) {
+        const oldstate = this._rfbConnectionState;
+
+        if (state === oldstate) {
+            Log.Debug("Already in state '" + state + "', ignoring");
+            return;
+        }
+
+        // The 'disconnected' state is permanent for each RFB object
+        if (oldstate === 'disconnected') {
+            Log.Error("Tried changing state of a disconnected RFB object");
+            return;
+        }
+
+        // Ensure proper transitions before doing anything
+        switch (state) {
+            case 'connected':
+                if (oldstate !== 'connecting') {
+                    Log.Error("Bad transition to connected state, " +
+                               "previous connection state: " + oldstate);
+                    return;
+                }
+                break;
+
+            case 'disconnected':
+                if (oldstate !== 'disconnecting') {
+                    Log.Error("Bad transition to disconnected state, " +
+                               "previous connection state: " + oldstate);
+                    return;
+                }
+                break;
+
+            case 'connecting':
+                if (oldstate !== '') {
+                    Log.Error("Bad transition to connecting state, " +
+                               "previous connection state: " + oldstate);
+                    return;
+                }
+                break;
+
+            case 'disconnecting':
+                if (oldstate !== 'connected' && oldstate !== 'connecting') {
+                    Log.Error("Bad transition to disconnecting state, " +
+                               "previous connection state: " + oldstate);
+                    return;
+                }
+                break;
+
+            default:
+                Log.Error("Unknown connection state: " + state);
+                return;
+        }
+
+        // State change actions
+
+        this._rfbConnectionState = state;
+
+        Log.Debug("New state '" + state + "', was '" + oldstate + "'.");
+
+        if (this._disconnTimer && state !== 'disconnecting') {
+            Log.Debug("Clearing disconnect timer");
+            clearTimeout(this._disconnTimer);
+            this._disconnTimer = null;
+
+            // make sure we don't get a double event
+            this._sock.off('close');
+        }
+
+        switch (state) {
+            case 'connecting':
+                this._connect();
+                break;
+
+            case 'connected':
+                this.dispatchEvent(new CustomEvent("connect", { detail: {} }));
+                break;
+
+            case 'disconnecting':
+                this._disconnect();
+
+                this._disconnTimer = setTimeout(() => {
+                    Log.Error("Disconnection timed out.");
+                    this._updateConnectionState('disconnected');
+                }, DISCONNECT_TIMEOUT * 1000);
+                break;
+
+            case 'disconnected':
+                this.dispatchEvent(new CustomEvent(
+                    "disconnect", { detail:
+                                    { clean: this._rfbCleanDisconnect } }));
+                break;
+        }
+    }
+
+    /* Print errors and disconnect
+     *
+     * The parameter 'details' is used for information that
+     * should be logged but not sent to the user interface.
+     */
+    _fail(details) {
+        switch (this._rfbConnectionState) {
+            case 'disconnecting':
+                Log.Error("Failed when disconnecting: " + details);
+                break;
+            case 'connected':
+                Log.Error("Failed while connected: " + details);
+                break;
+            case 'connecting':
+                Log.Error("Failed when connecting: " + details);
+                break;
+            default:
+                Log.Error("RFB failure: " + details);
+                break;
+        }
+        this._rfbCleanDisconnect = false; //This is sent to the UI
+
+        // Transition to disconnected without waiting for socket to close
+        this._updateConnectionState('disconnecting');
+        this._updateConnectionState('disconnected');
+
+        return false;
+    }
+
+    _setCapability(cap, val) {
+        this._capabilities[cap] = val;
+        this.dispatchEvent(new CustomEvent("capabilities",
+                                           { detail: { capabilities: this._capabilities } }));
+    }
+
+    _handleMessage() {
+        if (this._sock.rQwait("message", 1)) {
+            Log.Warn("handleMessage called on an empty receive queue");
+            return;
+        }
+
+        switch (this._rfbConnectionState) {
+            case 'disconnected':
+                Log.Error("Got data while disconnected");
+                break;
+            case 'connected':
+                while (true) {
+                    if (this._flushing) {
+                        break;
+                    }
+                    if (!this._normalMsg()) {
+                        break;
+                    }
+                    if (this._sock.rQwait("message", 1)) {
+                        break;
+                    }
+                }
+                break;
+            case 'connecting':
+                while (this._rfbConnectionState === 'connecting') {
+                    if (!this._initMsg()) {
+                        break;
+                    }
+                }
+                break;
+            default:
+                Log.Error("Got data while in an invalid state");
+                break;
+        }
+    }
+
+    _handleKeyEvent(keysym, code, down, numlock, capslock) {
+        // If remote state of capslock is known, and it doesn't match the local led state of
+        // the keyboard, we send a capslock keypress first to bring it into sync.
+        // If we just pressed CapsLock, or we toggled it remotely due to it being out of sync
+        // we clear the remote state so that we don't send duplicate or spurious fixes,
+        // since it may take some time to receive the new remote CapsLock state.
+        if (code == 'CapsLock' && down) {
+            this._remoteCapsLock = null;
+        }
+        if (this._remoteCapsLock !== null && capslock !== null && this._remoteCapsLock !== capslock && down) {
+            Log.Debug("Fixing remote caps lock");
+
+            this.sendKey(KeyTable.XK_Caps_Lock, 'CapsLock', true);
+            this.sendKey(KeyTable.XK_Caps_Lock, 'CapsLock', false);
+            // We clear the remote capsLock state when we do this to prevent issues with doing this twice
+            // before we receive an update of the the remote state.
+            this._remoteCapsLock = null;
+        }
+
+        // Logic for numlock is exactly the same.
+        if (code == 'NumLock' && down) {
+            this._remoteNumLock = null;
+        }
+        if (this._remoteNumLock !== null && numlock !== null && this._remoteNumLock !== numlock && down) {
+            Log.Debug("Fixing remote num lock");
+            this.sendKey(KeyTable.XK_Num_Lock, 'NumLock', true);
+            this.sendKey(KeyTable.XK_Num_Lock, 'NumLock', false);
+            this._remoteNumLock = null;
+        }
+        this.sendKey(keysym, code, down);
+    }
+
+    static _convertButtonMask(buttons) {
+        /* The bits in MouseEvent.buttons property correspond
+         * to the following mouse buttons:
+         *     0: Left
+         *     1: Right
+         *     2: Middle
+         *     3: Back
+         *     4: Forward
+         *
+         * These bits needs to be converted to what they are defined as
+         * in the RFB protocol.
+         */
+
+        const buttonMaskMap = {
+            0: 1 << 0, // Left
+            1: 1 << 2, // Right
+            2: 1 << 1, // Middle
+            3: 1 << 7, // Back
+            4: 1 << 8, // Forward
+        };
+
+        let bmask = 0;
+        for (let i = 0; i < 5; i++) {
+            if (buttons & (1 << i)) {
+                bmask |= buttonMaskMap[i];
+            }
+        }
+        return bmask;
+    }
+
+    _handleMouse(ev) {
+        /*
+         * We don't check connection status or viewOnly here as the
+         * mouse events might be used to control the viewport
+         */
+
+        if (ev.type === 'click') {
+            /*
+             * Note: This is only needed for the 'click' event as it fails
+             *       to fire properly for the target element so we have
+             *       to listen on the document element instead.
+             */
+            if (ev.target !== this._canvas) {
+                return;
+            }
+        }
+
+        // FIXME: if we're in view-only and not dragging,
+        //        should we stop events?
+        ev.stopPropagation();
+        ev.preventDefault();
+
+        if ((ev.type === 'click') || (ev.type === 'contextmenu')) {
+            return;
+        }
+
+        let pos = clientToElement(ev.clientX, ev.clientY,
+                                  this._canvas);
+
+        let bmask = RFB._convertButtonMask(ev.buttons);
+
+        let down = ev.type == 'mousedown';
+        switch (ev.type) {
+            case 'mousedown':
+            case 'mouseup':
+                if (this.dragViewport) {
+                    if (down && !this._viewportDragging) {
+                        this._viewportDragging = true;
+                        this._viewportDragPos = {'x': pos.x, 'y': pos.y};
+                        this._viewportHasMoved = false;
+
+                        this._flushMouseMoveTimer(pos.x, pos.y);
+
+                        // Skip sending mouse events, instead save the current
+                        // mouse mask so we can send it later.
+                        this._mouseButtonMask = bmask;
+                        break;
+                    } else {
+                        this._viewportDragging = false;
+
+                        // If we actually performed a drag then we are done
+                        // here and should not send any mouse events
+                        if (this._viewportHasMoved) {
+                            this._mouseButtonMask = bmask;
+                            break;
+                        }
+                        // Otherwise we treat this as a mouse click event.
+                        // Send the previously saved button mask, followed
+                        // by the current button mask at the end of this
+                        // function.
+                        this._sendMouse(pos.x, pos.y,  this._mouseButtonMask);
+                    }
+                }
+                if (down) {
+                    setCapture(this._canvas);
+                }
+                this._handleMouseButton(pos.x, pos.y, bmask);
+                break;
+            case 'mousemove':
+                if (this._viewportDragging) {
+                    const deltaX = this._viewportDragPos.x - pos.x;
+                    const deltaY = this._viewportDragPos.y - pos.y;
+
+                    if (this._viewportHasMoved || (Math.abs(deltaX) > dragThreshold ||
+                                                   Math.abs(deltaY) > dragThreshold)) {
+                        this._viewportHasMoved = true;
+
+                        this._viewportDragPos = {'x': pos.x, 'y': pos.y};
+                        this._display.viewportChangePos(deltaX, deltaY);
+                    }
+
+                    // Skip sending mouse events
+                    break;
+                }
+                this._handleMouseMove(pos.x, pos.y);
+                break;
+        }
+    }
+
+    _handleMouseButton(x, y, bmask) {
+        // Flush waiting move event first
+        this._flushMouseMoveTimer(x, y);
+
+        this._mouseButtonMask = bmask;
+        this._sendMouse(x, y, this._mouseButtonMask);
+    }
+
+    _handleMouseMove(x, y) {
+        this._mousePos = { 'x': x, 'y': y };
+
+        // Limit many mouse move events to one every MOUSE_MOVE_DELAY ms
+        if (this._mouseMoveTimer == null) {
+
+            const timeSinceLastMove = Date.now() - this._mouseLastMoveTime;
+            if (timeSinceLastMove > MOUSE_MOVE_DELAY) {
+                this._sendMouse(x, y, this._mouseButtonMask);
+                this._mouseLastMoveTime = Date.now();
+            } else {
+                // Too soon since the latest move, wait the remaining time
+                this._mouseMoveTimer = setTimeout(() => {
+                    this._handleDelayedMouseMove();
+                }, MOUSE_MOVE_DELAY - timeSinceLastMove);
+            }
+        }
+    }
+
+    _handleDelayedMouseMove() {
+        this._mouseMoveTimer = null;
+        this._sendMouse(this._mousePos.x, this._mousePos.y,
+                        this._mouseButtonMask);
+        this._mouseLastMoveTime = Date.now();
+    }
+
+    _sendMouse(x, y, mask) {
+        if (this._rfbConnectionState !== 'connected') { return; }
+        if (this._viewOnly) { return; } // View only, skip mouse events
+
+        // Highest bit in mask is never sent to the server
+        if (mask & 0x8000) {
+            throw new Error("Illegal mouse button mask (mask: " + mask + ")");
+        }
+
+        let extendedMouseButtons = mask & 0x7f80;
+
+        if (this._extendedPointerEventSupported && extendedMouseButtons) {
+            RFB.messages.extendedPointerEvent(this._sock, this._display.absX(x),
+                                              this._display.absY(y), mask);
+        } else {
+            RFB.messages.pointerEvent(this._sock, this._display.absX(x),
+                                      this._display.absY(y), mask);
+        }
+    }
+
+    _handleWheel(ev) {
+        if (this._rfbConnectionState !== 'connected') { return; }
+        if (this._viewOnly) { return; } // View only, skip mouse events
+
+        ev.stopPropagation();
+        ev.preventDefault();
+
+        let pos = clientToElement(ev.clientX, ev.clientY,
+                                  this._canvas);
+
+        let bmask = RFB._convertButtonMask(ev.buttons);
+        let dX = ev.deltaX;
+        let dY = ev.deltaY;
+
+        // Pixel units unless it's non-zero.
+        // Note that if deltamode is line or page won't matter since we aren't
+        // sending the mouse wheel delta to the server anyway.
+        // The difference between pixel and line can be important however since
+        // we have a threshold that can be smaller than the line height.
+        if (ev.deltaMode !== 0) {
+            dX *= WHEEL_LINE_HEIGHT;
+            dY *= WHEEL_LINE_HEIGHT;
+        }
+
+        // Mouse wheel events are sent in steps over VNC. This means that the VNC
+        // protocol can't handle a wheel event with specific distance or speed.
+        // Therefor, if we get a lot of small mouse wheel events we combine them.
+        this._accumulatedWheelDeltaX += dX;
+        this._accumulatedWheelDeltaY += dY;
+
+
+        // Generate a mouse wheel step event when the accumulated delta
+        // for one of the axes is large enough.
+        if (Math.abs(this._accumulatedWheelDeltaX) >= WHEEL_STEP) {
+            if (this._accumulatedWheelDeltaX < 0) {
+                this._handleMouseButton(pos.x, pos.y, bmask | 1 << 5);
+                this._handleMouseButton(pos.x, pos.y, bmask);
+            } else if (this._accumulatedWheelDeltaX > 0) {
+                this._handleMouseButton(pos.x, pos.y, bmask | 1 << 6);
+                this._handleMouseButton(pos.x, pos.y, bmask);
+            }
+
+            this._accumulatedWheelDeltaX = 0;
+        }
+        if (Math.abs(this._accumulatedWheelDeltaY) >= WHEEL_STEP) {
+            if (this._accumulatedWheelDeltaY < 0) {
+                this._handleMouseButton(pos.x, pos.y, bmask | 1 << 3);
+                this._handleMouseButton(pos.x, pos.y, bmask);
+            } else if (this._accumulatedWheelDeltaY > 0) {
+                this._handleMouseButton(pos.x, pos.y, bmask | 1 << 4);
+                this._handleMouseButton(pos.x, pos.y, bmask);
+            }
+
+            this._accumulatedWheelDeltaY = 0;
+        }
+    }
+
+    _fakeMouseMove(ev, elementX, elementY) {
+        this._handleMouseMove(elementX, elementY);
+        this._cursor.move(ev.detail.clientX, ev.detail.clientY);
+    }
+
+    _handleTapEvent(ev, bmask) {
+        let pos = clientToElement(ev.detail.clientX, ev.detail.clientY,
+                                  this._canvas);
+
+        // If the user quickly taps multiple times we assume they meant to
+        // hit the same spot, so slightly adjust coordinates
+
+        if ((this._gestureLastTapTime !== null) &&
+            ((Date.now() - this._gestureLastTapTime) < DOUBLE_TAP_TIMEOUT) &&
+            (this._gestureFirstDoubleTapEv.detail.type === ev.detail.type)) {
+            let dx = this._gestureFirstDoubleTapEv.detail.clientX - ev.detail.clientX;
+            let dy = this._gestureFirstDoubleTapEv.detail.clientY - ev.detail.clientY;
+            let distance = Math.hypot(dx, dy);
+
+            if (distance < DOUBLE_TAP_THRESHOLD) {
+                pos = clientToElement(this._gestureFirstDoubleTapEv.detail.clientX,
+                                      this._gestureFirstDoubleTapEv.detail.clientY,
+                                      this._canvas);
+            } else {
+                this._gestureFirstDoubleTapEv = ev;
+            }
+        } else {
+            this._gestureFirstDoubleTapEv = ev;
+        }
+        this._gestureLastTapTime = Date.now();
+
+        this._fakeMouseMove(this._gestureFirstDoubleTapEv, pos.x, pos.y);
+        this._handleMouseButton(pos.x, pos.y, bmask);
+        this._handleMouseButton(pos.x, pos.y, 0x0);
+    }
+
+    _handleGesture(ev) {
+        let magnitude;
+
+        let pos = clientToElement(ev.detail.clientX, ev.detail.clientY,
+                                  this._canvas);
+        switch (ev.type) {
+            case 'gesturestart':
+                switch (ev.detail.type) {
+                    case 'onetap':
+                        this._handleTapEvent(ev, 0x1);
+                        break;
+                    case 'twotap':
+                        this._handleTapEvent(ev, 0x4);
+                        break;
+                    case 'threetap':
+                        this._handleTapEvent(ev, 0x2);
+                        break;
+                    case 'drag':
+                        if (this.dragViewport) {
+                            this._viewportHasMoved = false;
+                            this._viewportDragging = true;
+                            this._viewportDragPos = {'x': pos.x, 'y': pos.y};
+                        } else {
+                            this._fakeMouseMove(ev, pos.x, pos.y);
+                            this._handleMouseButton(pos.x, pos.y, 0x1);
+                        }
+                        break;
+                    case 'longpress':
+                        if (this.dragViewport) {
+                            // If dragViewport is true, we need to wait to see
+                            // if we have dragged outside the threshold before
+                            // sending any events to the server.
+                            this._viewportHasMoved = false;
+                            this._viewportDragPos = {'x': pos.x, 'y': pos.y};
+                        } else {
+                            this._fakeMouseMove(ev, pos.x, pos.y);
+                            this._handleMouseButton(pos.x, pos.y, 0x4);
+                        }
+                        break;
+                    case 'twodrag':
+                        this._gestureLastMagnitudeX = ev.detail.magnitudeX;
+                        this._gestureLastMagnitudeY = ev.detail.magnitudeY;
+                        this._fakeMouseMove(ev, pos.x, pos.y);
+                        break;
+                    case 'pinch':
+                        this._gestureLastMagnitudeX = Math.hypot(ev.detail.magnitudeX,
+                                                                 ev.detail.magnitudeY);
+                        this._fakeMouseMove(ev, pos.x, pos.y);
+                        break;
+                }
+                break;
+
+            case 'gesturemove':
+                switch (ev.detail.type) {
+                    case 'onetap':
+                    case 'twotap':
+                    case 'threetap':
+                        break;
+                    case 'drag':
+                    case 'longpress':
+                        if (this.dragViewport) {
+                            this._viewportDragging = true;
+                            const deltaX = this._viewportDragPos.x - pos.x;
+                            const deltaY = this._viewportDragPos.y - pos.y;
+
+                            if (this._viewportHasMoved || (Math.abs(deltaX) > dragThreshold ||
+                                                           Math.abs(deltaY) > dragThreshold)) {
+                                this._viewportHasMoved = true;
+
+                                this._viewportDragPos = {'x': pos.x, 'y': pos.y};
+                                this._display.viewportChangePos(deltaX, deltaY);
+                            }
+                        } else {
+                            this._fakeMouseMove(ev, pos.x, pos.y);
+                        }
+                        break;
+                    case 'twodrag':
+                        // Always scroll in the same position.
+                        // We don't know if the mouse was moved so we need to move it
+                        // every update.
+                        this._fakeMouseMove(ev, pos.x, pos.y);
+                        while ((ev.detail.magnitudeY - this._gestureLastMagnitudeY) > GESTURE_SCRLSENS) {
+                            this._handleMouseButton(pos.x, pos.y, 0x8);
+                            this._handleMouseButton(pos.x, pos.y, 0x0);
+                            this._gestureLastMagnitudeY += GESTURE_SCRLSENS;
+                        }
+                        while ((ev.detail.magnitudeY - this._gestureLastMagnitudeY) < -GESTURE_SCRLSENS) {
+                            this._handleMouseButton(pos.x, pos.y, 0x10);
+                            this._handleMouseButton(pos.x, pos.y, 0x0);
+                            this._gestureLastMagnitudeY -= GESTURE_SCRLSENS;
+                        }
+                        while ((ev.detail.magnitudeX - this._gestureLastMagnitudeX) > GESTURE_SCRLSENS) {
+                            this._handleMouseButton(pos.x, pos.y, 0x20);
+                            this._handleMouseButton(pos.x, pos.y, 0x0);
+                            this._gestureLastMagnitudeX += GESTURE_SCRLSENS;
+                        }
+                        while ((ev.detail.magnitudeX - this._gestureLastMagnitudeX) < -GESTURE_SCRLSENS) {
+                            this._handleMouseButton(pos.x, pos.y, 0x40);
+                            this._handleMouseButton(pos.x, pos.y, 0x0);
+                            this._gestureLastMagnitudeX -= GESTURE_SCRLSENS;
+                        }
+                        break;
+                    case 'pinch':
+                        // Always scroll in the same position.
+                        // We don't know if the mouse was moved so we need to move it
+                        // every update.
+                        this._fakeMouseMove(ev, pos.x, pos.y);
+                        magnitude = Math.hypot(ev.detail.magnitudeX, ev.detail.magnitudeY);
+                        if (Math.abs(magnitude - this._gestureLastMagnitudeX) > GESTURE_ZOOMSENS) {
+                            this._handleKeyEvent(KeyTable.XK_Control_L, "ControlLeft", true);
+                            while ((magnitude - this._gestureLastMagnitudeX) > GESTURE_ZOOMSENS) {
+                                this._handleMouseButton(pos.x, pos.y, 0x8);
+                                this._handleMouseButton(pos.x, pos.y, 0x0);
+                                this._gestureLastMagnitudeX += GESTURE_ZOOMSENS;
+                            }
+                            while ((magnitude -  this._gestureLastMagnitudeX) < -GESTURE_ZOOMSENS) {
+                                this._handleMouseButton(pos.x, pos.y, 0x10);
+                                this._handleMouseButton(pos.x, pos.y, 0x0);
+                                this._gestureLastMagnitudeX -= GESTURE_ZOOMSENS;
+                            }
+                        }
+                        this._handleKeyEvent(KeyTable.XK_Control_L, "ControlLeft", false);
+                        break;
+                }
+                break;
+
+            case 'gestureend':
+                switch (ev.detail.type) {
+                    case 'onetap':
+                    case 'twotap':
+                    case 'threetap':
+                    case 'pinch':
+                    case 'twodrag':
+                        break;
+                    case 'drag':
+                        if (this.dragViewport) {
+                            this._viewportDragging = false;
+                        } else {
+                            this._fakeMouseMove(ev, pos.x, pos.y);
+                            this._handleMouseButton(pos.x, pos.y, 0x0);
+                        }
+                        break;
+                    case 'longpress':
+                        if (this._viewportHasMoved) {
+                            // We don't want to send any events if we have moved
+                            // our viewport
+                            break;
+                        }
+
+                        if (this.dragViewport && !this._viewportHasMoved) {
+                            this._fakeMouseMove(ev, pos.x, pos.y);
+                            // If dragViewport is true, we need to wait to see
+                            // if we have dragged outside the threshold before
+                            // sending any events to the server.
+                            this._handleMouseButton(pos.x, pos.y, 0x4);
+                            this._handleMouseButton(pos.x, pos.y, 0x0);
+                            this._viewportDragging = false;
+                        } else {
+                            this._fakeMouseMove(ev, pos.x, pos.y);
+                            this._handleMouseButton(pos.x, pos.y, 0x0);
+                        }
+                        break;
+                }
+                break;
+        }
+    }
+
+    _flushMouseMoveTimer(x, y) {
+        if (this._mouseMoveTimer !== null) {
+            clearTimeout(this._mouseMoveTimer);
+            this._mouseMoveTimer = null;
+            this._sendMouse(x, y, this._mouseButtonMask);
+        }
+    }
+
+    // Message handlers
+
+    _negotiateProtocolVersion() {
+        if (this._sock.rQwait("version", 12)) {
+            return false;
+        }
+
+        const sversion = this._sock.rQshiftStr(12).substr(4, 7);
+        Log.Info("Server ProtocolVersion: " + sversion);
+        let isRepeater = 0;
+        switch (sversion) {
+            case "000.000":  // UltraVNC repeater
+                isRepeater = 1;
+                break;
+            case "003.003":
+            case "003.006":  // UltraVNC
+                this._rfbVersion = 3.3;
+                break;
+            case "003.007":
+                this._rfbVersion = 3.7;
+                break;
+            case "003.008":
+            case "003.889":  // Apple Remote Desktop
+            case "004.000":  // Intel AMT KVM
+            case "004.001":  // RealVNC 4.6
+            case "005.000":  // RealVNC 5.3
+                this._rfbVersion = 3.8;
+                break;
+            default:
+                return this._fail("Invalid server version " + sversion);
+        }
+
+        if (isRepeater) {
+            let repeaterID = "ID:" + this._repeaterID;
+            while (repeaterID.length < 250) {
+                repeaterID += "\0";
+            }
+            this._sock.sQpushString(repeaterID);
+            this._sock.flush();
+            return true;
+        }
+
+        if (this._rfbVersion > this._rfbMaxVersion) {
+            this._rfbVersion = this._rfbMaxVersion;
+        }
+
+        const cversion = "00" + parseInt(this._rfbVersion, 10) +
+                       ".00" + ((this._rfbVersion * 10) % 10);
+        this._sock.sQpushString("RFB " + cversion + "\n");
+        this._sock.flush();
+        Log.Debug('Sent ProtocolVersion: ' + cversion);
+
+        this._rfbInitState = 'Security';
+    }
+
+    _isSupportedSecurityType(type) {
+        const clientTypes = [
+            securityTypeNone,
+            securityTypeVNCAuth,
+            securityTypeRA2ne,
+            securityTypeTight,
+            securityTypeVeNCrypt,
+            securityTypeXVP,
+            securityTypeARD,
+            securityTypeMSLogonII,
+            securityTypePlain,
+        ];
+
+        return clientTypes.includes(type);
+    }
+
+    _negotiateSecurity() {
+        if (this._rfbVersion >= 3.7) {
+            // Server sends supported list, client decides
+            const numTypes = this._sock.rQshift8();
+            if (this._sock.rQwait("security type", numTypes, 1)) { return false; }
+
+            if (numTypes === 0) {
+                this._rfbInitState = "SecurityReason";
+                this._securityContext = "no security types";
+                this._securityStatus = 1;
+                return true;
+            }
+
+            const types = this._sock.rQshiftBytes(numTypes);
+            Log.Debug("Server security types: " + types);
+
+            // Look for a matching security type in the order that the
+            // server prefers
+            this._rfbAuthScheme = -1;
+            for (let type of types) {
+                if (this._isSupportedSecurityType(type)) {
+                    this._rfbAuthScheme = type;
+                    break;
+                }
+            }
+
+            if (this._rfbAuthScheme === -1) {
+                return this._fail("Unsupported security types (types: " + types + ")");
+            }
+
+            this._sock.sQpush8(this._rfbAuthScheme);
+            this._sock.flush();
+        } else {
+            // Server decides
+            if (this._sock.rQwait("security scheme", 4)) { return false; }
+            this._rfbAuthScheme = this._sock.rQshift32();
+
+            if (this._rfbAuthScheme == 0) {
+                this._rfbInitState = "SecurityReason";
+                this._securityContext = "authentication scheme";
+                this._securityStatus = 1;
+                return true;
+            }
+        }
+
+        this._rfbInitState = 'Authentication';
+        Log.Debug('Authenticating using scheme: ' + this._rfbAuthScheme);
+
+        return true;
+    }
+
+    _handleSecurityReason() {
+        if (this._sock.rQwait("reason length", 4)) {
+            return false;
+        }
+        const strlen = this._sock.rQshift32();
+        let reason = "";
+
+        if (strlen > 0) {
+            if (this._sock.rQwait("reason", strlen, 4)) { return false; }
+            reason = this._sock.rQshiftStr(strlen);
+        }
+
+        if (reason !== "") {
+            this.dispatchEvent(new CustomEvent(
+                "securityfailure",
+                { detail: { status: this._securityStatus,
+                            reason: reason } }));
+
+            return this._fail("Security negotiation failed on " +
+                              this._securityContext +
+                              " (reason: " + reason + ")");
+        } else {
+            this.dispatchEvent(new CustomEvent(
+                "securityfailure",
+                { detail: { status: this._securityStatus } }));
+
+            return this._fail("Security negotiation failed on " +
+                              this._securityContext);
+        }
+    }
+
+    // authentication
+    _negotiateXvpAuth() {
+        if (this._rfbCredentials.username === undefined ||
+            this._rfbCredentials.password === undefined ||
+            this._rfbCredentials.target === undefined) {
+            this.dispatchEvent(new CustomEvent(
+                "credentialsrequired",
+                { detail: { types: ["username", "password", "target"] } }));
+            return false;
+        }
+
+        this._sock.sQpush8(this._rfbCredentials.username.length);
+        this._sock.sQpush8(this._rfbCredentials.target.length);
+        this._sock.sQpushString(this._rfbCredentials.username);
+        this._sock.sQpushString(this._rfbCredentials.target);
+
+        this._sock.flush();
+
+        this._rfbAuthScheme = securityTypeVNCAuth;
+
+        return this._negotiateAuthentication();
+    }
+
+    // VeNCrypt authentication, currently only supports version 0.2 and only Plain subtype
+    _negotiateVeNCryptAuth() {
+
+        // waiting for VeNCrypt version
+        if (this._rfbVeNCryptState == 0) {
+            if (this._sock.rQwait("vencrypt version", 2)) { return false; }
+
+            const major = this._sock.rQshift8();
+            const minor = this._sock.rQshift8();
+
+            if (!(major == 0 && minor == 2)) {
+                return this._fail("Unsupported VeNCrypt version " + major + "." + minor);
+            }
+
+            this._sock.sQpush8(0);
+            this._sock.sQpush8(2);
+            this._sock.flush();
+            this._rfbVeNCryptState = 1;
+        }
+
+        // waiting for ACK
+        if (this._rfbVeNCryptState == 1) {
+            if (this._sock.rQwait("vencrypt ack", 1)) { return false; }
+
+            const res = this._sock.rQshift8();
+
+            if (res != 0) {
+                return this._fail("VeNCrypt failure " + res);
+            }
+
+            this._rfbVeNCryptState = 2;
+        }
+        // must fall through here (i.e. no "else if"), beacause we may have already received
+        // the subtypes length and won't be called again
+
+        if (this._rfbVeNCryptState == 2) { // waiting for subtypes length
+            if (this._sock.rQwait("vencrypt subtypes length", 1)) { return false; }
+
+            const subtypesLength = this._sock.rQshift8();
+            if (subtypesLength < 1) {
+                return this._fail("VeNCrypt subtypes empty");
+            }
+
+            this._rfbVeNCryptSubtypesLength = subtypesLength;
+            this._rfbVeNCryptState = 3;
+        }
+
+        // waiting for subtypes list
+        if (this._rfbVeNCryptState == 3) {
+            if (this._sock.rQwait("vencrypt subtypes", 4 * this._rfbVeNCryptSubtypesLength)) { return false; }
+
+            const subtypes = [];
+            for (let i = 0; i < this._rfbVeNCryptSubtypesLength; i++) {
+                subtypes.push(this._sock.rQshift32());
+            }
+
+            // Look for a matching security type in the order that the
+            // server prefers
+            this._rfbAuthScheme = -1;
+            for (let type of subtypes) {
+                // Avoid getting in to a loop
+                if (type === securityTypeVeNCrypt) {
+                    continue;
+                }
+
+                if (this._isSupportedSecurityType(type)) {
+                    this._rfbAuthScheme = type;
+                    break;
+                }
+            }
+
+            if (this._rfbAuthScheme === -1) {
+                return this._fail("Unsupported security types (types: " + subtypes + ")");
+            }
+
+            this._sock.sQpush32(this._rfbAuthScheme);
+            this._sock.flush();
+
+            this._rfbVeNCryptState = 4;
+            return true;
+        }
+    }
+
+    _negotiatePlainAuth() {
+        if (this._rfbCredentials.username === undefined ||
+            this._rfbCredentials.password === undefined) {
+            this.dispatchEvent(new CustomEvent(
+                "credentialsrequired",
+                { detail: { types: ["username", "password"] } }));
+            return false;
+        }
+
+        const user = encodeUTF8(this._rfbCredentials.username);
+        const pass = encodeUTF8(this._rfbCredentials.password);
+
+        this._sock.sQpush32(user.length);
+        this._sock.sQpush32(pass.length);
+        this._sock.sQpushString(user);
+        this._sock.sQpushString(pass);
+        this._sock.flush();
+
+        this._rfbInitState = "SecurityResult";
+        return true;
+    }
+
+    _negotiateStdVNCAuth() {
+        if (this._sock.rQwait("auth challenge", 16)) { return false; }
+
+        if (this._rfbCredentials.password === undefined) {
+            this.dispatchEvent(new CustomEvent(
+                "credentialsrequired",
+                { detail: { types: ["password"] } }));
+            return false;
+        }
+
+        // TODO(directxman12): make genDES not require an Array
+        const challenge = Array.prototype.slice.call(this._sock.rQshiftBytes(16));
+        const response = RFB.genDES(this._rfbCredentials.password, challenge);
+        this._sock.sQpushBytes(response);
+        this._sock.flush();
+        this._rfbInitState = "SecurityResult";
+        return true;
+    }
+
+    _negotiateARDAuth() {
+
+        if (this._rfbCredentials.username === undefined ||
+            this._rfbCredentials.password === undefined) {
+            this.dispatchEvent(new CustomEvent(
+                "credentialsrequired",
+                { detail: { types: ["username", "password"] } }));
+            return false;
+        }
+
+        if (this._rfbCredentials.ardPublicKey != undefined &&
+            this._rfbCredentials.ardCredentials != undefined) {
+            // if the async web crypto is done return the results
+            this._sock.sQpushBytes(this._rfbCredentials.ardCredentials);
+            this._sock.sQpushBytes(this._rfbCredentials.ardPublicKey);
+            this._sock.flush();
+            this._rfbCredentials.ardCredentials = null;
+            this._rfbCredentials.ardPublicKey = null;
+            this._rfbInitState = "SecurityResult";
+            return true;
+        }
+
+        if (this._sock.rQwait("read ard", 4)) { return false; }
+
+        let generator = this._sock.rQshiftBytes(2);   // DH base generator value
+
+        let keyLength = this._sock.rQshift16();
+
+        if (this._sock.rQwait("read ard keylength", keyLength*2, 4)) { return false; }
+
+        // read the server values
+        let prime = this._sock.rQshiftBytes(keyLength);  // predetermined prime modulus
+        let serverPublicKey = this._sock.rQshiftBytes(keyLength); // other party's public key
+
+        let clientKey = legacyCrypto.generateKey(
+            { name: "DH", g: generator, p: prime }, false, ["deriveBits"]);
+        this._negotiateARDAuthAsync(keyLength, serverPublicKey, clientKey);
+
+        return false;
+    }
+
+    async _negotiateARDAuthAsync(keyLength, serverPublicKey, clientKey) {
+        const clientPublicKey = legacyCrypto.exportKey("raw", clientKey.publicKey);
+        const sharedKey = legacyCrypto.deriveBits(
+            { name: "DH", public: serverPublicKey }, clientKey.privateKey, keyLength * 8);
+
+        const username = encodeUTF8(this._rfbCredentials.username).substring(0, 63);
+        const password = encodeUTF8(this._rfbCredentials.password).substring(0, 63);
+
+        const credentials = window.crypto.getRandomValues(new Uint8Array(128));
+        for (let i = 0; i < username.length; i++) {
+            credentials[i] = username.charCodeAt(i);
+        }
+        credentials[username.length] = 0;
+        for (let i = 0; i < password.length; i++) {
+            credentials[64 + i] = password.charCodeAt(i);
+        }
+        credentials[64 + password.length] = 0;
+
+        const key = await legacyCrypto.digest("MD5", sharedKey);
+        const cipher = await legacyCrypto.importKey(
+            "raw", key, { name: "AES-ECB" }, false, ["encrypt"]);
+        const encrypted = await legacyCrypto.encrypt({ name: "AES-ECB" }, cipher, credentials);
+
+        this._rfbCredentials.ardCredentials = encrypted;
+        this._rfbCredentials.ardPublicKey = clientPublicKey;
+
+        this._resumeAuthentication();
+    }
+
+    _negotiateTightUnixAuth() {
+        if (this._rfbCredentials.username === undefined ||
+            this._rfbCredentials.password === undefined) {
+            this.dispatchEvent(new CustomEvent(
+                "credentialsrequired",
+                { detail: { types: ["username", "password"] } }));
+            return false;
+        }
+
+        this._sock.sQpush32(this._rfbCredentials.username.length);
+        this._sock.sQpush32(this._rfbCredentials.password.length);
+        this._sock.sQpushString(this._rfbCredentials.username);
+        this._sock.sQpushString(this._rfbCredentials.password);
+        this._sock.flush();
+
+        this._rfbInitState = "SecurityResult";
+        return true;
+    }
+
+    _negotiateTightTunnels(numTunnels) {
+        const clientSupportedTunnelTypes = {
+            0: { vendor: 'TGHT', signature: 'NOTUNNEL' }
+        };
+        const serverSupportedTunnelTypes = {};
+        // receive tunnel capabilities
+        for (let i = 0; i < numTunnels; i++) {
+            const capCode = this._sock.rQshift32();
+            const capVendor = this._sock.rQshiftStr(4);
+            const capSignature = this._sock.rQshiftStr(8);
+            serverSupportedTunnelTypes[capCode] = { vendor: capVendor, signature: capSignature };
+        }
+
+        Log.Debug("Server Tight tunnel types: " + serverSupportedTunnelTypes);
+
+        // Siemens touch panels have a VNC server that supports NOTUNNEL,
+        // but forgets to advertise it. Try to detect such servers by
+        // looking for their custom tunnel type.
+        if (serverSupportedTunnelTypes[1] &&
+            (serverSupportedTunnelTypes[1].vendor === "SICR") &&
+            (serverSupportedTunnelTypes[1].signature === "SCHANNEL")) {
+            Log.Debug("Detected Siemens server. Assuming NOTUNNEL support.");
+            serverSupportedTunnelTypes[0] = { vendor: 'TGHT', signature: 'NOTUNNEL' };
+        }
+
+        // choose the notunnel type
+        if (serverSupportedTunnelTypes[0]) {
+            if (serverSupportedTunnelTypes[0].vendor != clientSupportedTunnelTypes[0].vendor ||
+                serverSupportedTunnelTypes[0].signature != clientSupportedTunnelTypes[0].signature) {
+                return this._fail("Client's tunnel type had the incorrect " +
+                                  "vendor or signature");
+            }
+            Log.Debug("Selected tunnel type: " + clientSupportedTunnelTypes[0]);
+            this._sock.sQpush32(0); // use NOTUNNEL
+            this._sock.flush();
+            return false; // wait until we receive the sub auth count to continue
+        } else {
+            return this._fail("Server wanted tunnels, but doesn't support " +
+                              "the notunnel type");
+        }
+    }
+
+    _negotiateTightAuth() {
+        if (!this._rfbTightVNC) {  // first pass, do the tunnel negotiation
+            if (this._sock.rQwait("num tunnels", 4)) { return false; }
+            const numTunnels = this._sock.rQshift32();
+            if (numTunnels > 0 && this._sock.rQwait("tunnel capabilities", 16 * numTunnels, 4)) { return false; }
+
+            this._rfbTightVNC = true;
+
+            if (numTunnels > 0) {
+                this._negotiateTightTunnels(numTunnels);
+                return false;  // wait until we receive the sub auth to continue
+            }
+        }
+
+        // second pass, do the sub-auth negotiation
+        if (this._sock.rQwait("sub auth count", 4)) { return false; }
+        const subAuthCount = this._sock.rQshift32();
+        if (subAuthCount === 0) {  // empty sub-auth list received means 'no auth' subtype selected
+            this._rfbInitState = 'SecurityResult';
+            return true;
+        }
+
+        if (this._sock.rQwait("sub auth capabilities", 16 * subAuthCount, 4)) { return false; }
+
+        const clientSupportedTypes = {
+            'STDVNOAUTH__': 1,
+            'STDVVNCAUTH_': 2,
+            'TGHTULGNAUTH': 129
+        };
+
+        const serverSupportedTypes = [];
+
+        for (let i = 0; i < subAuthCount; i++) {
+            this._sock.rQshift32(); // capNum
+            const capabilities = this._sock.rQshiftStr(12);
+            serverSupportedTypes.push(capabilities);
+        }
+
+        Log.Debug("Server Tight authentication types: " + serverSupportedTypes);
+
+        for (let authType in clientSupportedTypes) {
+            if (serverSupportedTypes.indexOf(authType) != -1) {
+                this._sock.sQpush32(clientSupportedTypes[authType]);
+                this._sock.flush();
+                Log.Debug("Selected authentication type: " + authType);
+
+                switch (authType) {
+                    case 'STDVNOAUTH__':  // no auth
+                        this._rfbInitState = 'SecurityResult';
+                        return true;
+                    case 'STDVVNCAUTH_':
+                        this._rfbAuthScheme = securityTypeVNCAuth;
+                        return true;
+                    case 'TGHTULGNAUTH':
+                        this._rfbAuthScheme = securityTypeUnixLogon;
+                        return true;
+                    default:
+                        return this._fail("Unsupported tiny auth scheme " +
+                                          "(scheme: " + authType + ")");
+                }
+            }
+        }
+
+        return this._fail("No supported sub-auth types!");
+    }
+
+    _handleRSAAESCredentialsRequired(event) {
+        this.dispatchEvent(event);
+    }
+
+    _handleRSAAESServerVerification(event) {
+        this.dispatchEvent(event);
+    }
+
+    _negotiateRA2neAuth() {
+        if (this._rfbRSAAESAuthenticationState === null) {
+            this._rfbRSAAESAuthenticationState = new RSAAESAuthenticationState(this._sock, () => this._rfbCredentials);
+            this._rfbRSAAESAuthenticationState.addEventListener(
+                "serververification", this._eventHandlers.handleRSAAESServerVerification);
+            this._rfbRSAAESAuthenticationState.addEventListener(
+                "credentialsrequired", this._eventHandlers.handleRSAAESCredentialsRequired);
+        }
+        this._rfbRSAAESAuthenticationState.checkInternalEvents();
+        if (!this._rfbRSAAESAuthenticationState.hasStarted) {
+            this._rfbRSAAESAuthenticationState.negotiateRA2neAuthAsync()
+                .catch((e) => {
+                    if (e.message !== "disconnect normally") {
+                        this._fail(e.message);
+                    }
+                })
+                .then(() => {
+                    this._rfbInitState = "SecurityResult";
+                    return true;
+                }).finally(() => {
+                    this._rfbRSAAESAuthenticationState.removeEventListener(
+                        "serververification", this._eventHandlers.handleRSAAESServerVerification);
+                    this._rfbRSAAESAuthenticationState.removeEventListener(
+                        "credentialsrequired", this._eventHandlers.handleRSAAESCredentialsRequired);
+                    this._rfbRSAAESAuthenticationState = null;
+                });
+        }
+        return false;
+    }
+
+    _negotiateMSLogonIIAuth() {
+        if (this._sock.rQwait("mslogonii dh param", 24)) { return false; }
+
+        if (this._rfbCredentials.username === undefined ||
+            this._rfbCredentials.password === undefined) {
+            this.dispatchEvent(new CustomEvent(
+                "credentialsrequired",
+                { detail: { types: ["username", "password"] } }));
+            return false;
+        }
+
+        const g = this._sock.rQshiftBytes(8);
+        const p = this._sock.rQshiftBytes(8);
+        const A = this._sock.rQshiftBytes(8);
+        const dhKey = legacyCrypto.generateKey({ name: "DH", g: g, p: p }, true, ["deriveBits"]);
+        const B = legacyCrypto.exportKey("raw", dhKey.publicKey);
+        const secret = legacyCrypto.deriveBits({ name: "DH", public: A }, dhKey.privateKey, 64);
+
+        const key = legacyCrypto.importKey("raw", secret, { name: "DES-CBC" }, false, ["encrypt"]);
+        const username = encodeUTF8(this._rfbCredentials.username).substring(0, 255);
+        const password = encodeUTF8(this._rfbCredentials.password).substring(0, 63);
+        let usernameBytes = new Uint8Array(256);
+        let passwordBytes = new Uint8Array(64);
+        window.crypto.getRandomValues(usernameBytes);
+        window.crypto.getRandomValues(passwordBytes);
+        for (let i = 0; i < username.length; i++) {
+            usernameBytes[i] = username.charCodeAt(i);
+        }
+        usernameBytes[username.length] = 0;
+        for (let i = 0; i < password.length; i++) {
+            passwordBytes[i] = password.charCodeAt(i);
+        }
+        passwordBytes[password.length] = 0;
+        usernameBytes = legacyCrypto.encrypt({ name: "DES-CBC", iv: secret }, key, usernameBytes);
+        passwordBytes = legacyCrypto.encrypt({ name: "DES-CBC", iv: secret }, key, passwordBytes);
+        this._sock.sQpushBytes(B);
+        this._sock.sQpushBytes(usernameBytes);
+        this._sock.sQpushBytes(passwordBytes);
+        this._sock.flush();
+        this._rfbInitState = "SecurityResult";
+        return true;
+    }
+
+    _negotiateAuthentication() {
+        switch (this._rfbAuthScheme) {
+            case securityTypeNone:
+                if (this._rfbVersion >= 3.8) {
+                    this._rfbInitState = 'SecurityResult';
+                } else {
+                    this._rfbInitState = 'ClientInitialisation';
+                }
+                return true;
+
+            case securityTypeXVP:
+                return this._negotiateXvpAuth();
+
+            case securityTypeARD:
+                return this._negotiateARDAuth();
+
+            case securityTypeVNCAuth:
+                return this._negotiateStdVNCAuth();
+
+            case securityTypeTight:
+                return this._negotiateTightAuth();
+
+            case securityTypeVeNCrypt:
+                return this._negotiateVeNCryptAuth();
+
+            case securityTypePlain:
+                return this._negotiatePlainAuth();
+
+            case securityTypeUnixLogon:
+                return this._negotiateTightUnixAuth();
+
+            case securityTypeRA2ne:
+                return this._negotiateRA2neAuth();
+
+            case securityTypeMSLogonII:
+                return this._negotiateMSLogonIIAuth();
+
+            default:
+                return this._fail("Unsupported auth scheme (scheme: " +
+                                  this._rfbAuthScheme + ")");
+        }
+    }
+
+    _handleSecurityResult() {
+        if (this._sock.rQwait('VNC auth response ', 4)) { return false; }
+
+        const status = this._sock.rQshift32();
+
+        if (status === 0) { // OK
+            this._rfbInitState = 'ClientInitialisation';
+            Log.Debug('Authentication OK');
+            return true;
+        } else {
+            if (this._rfbVersion >= 3.8) {
+                this._rfbInitState = "SecurityReason";
+                this._securityContext = "security result";
+                this._securityStatus = status;
+                return true;
+            } else {
+                this.dispatchEvent(new CustomEvent(
+                    "securityfailure",
+                    { detail: { status: status } }));
+
+                return this._fail("Security handshake failed");
+            }
+        }
+    }
+
+    _negotiateServerInit() {
+        if (this._sock.rQwait("server initialization", 24)) { return false; }
+
+        /* Screen size */
+        const width = this._sock.rQshift16();
+        const height = this._sock.rQshift16();
+
+        /* PIXEL_FORMAT */
+        const bpp         = this._sock.rQshift8();
+        const depth       = this._sock.rQshift8();
+        const bigEndian  = this._sock.rQshift8();
+        const trueColor  = this._sock.rQshift8();
+
+        const redMax     = this._sock.rQshift16();
+        const greenMax   = this._sock.rQshift16();
+        const blueMax    = this._sock.rQshift16();
+        const redShift   = this._sock.rQshift8();
+        const greenShift = this._sock.rQshift8();
+        const blueShift  = this._sock.rQshift8();
+        this._sock.rQskipBytes(3);  // padding
+
+        // NB(directxman12): we don't want to call any callbacks or print messages until
+        //                   *after* we're past the point where we could backtrack
+
+        /* Connection name/title */
+        const nameLength = this._sock.rQshift32();
+        if (this._sock.rQwait('server init name', nameLength, 24)) { return false; }
+        let name = this._sock.rQshiftStr(nameLength);
+        name = decodeUTF8(name, true);
+
+        if (this._rfbTightVNC) {
+            if (this._sock.rQwait('TightVNC extended server init header', 8, 24 + nameLength)) { return false; }
+            // In TightVNC mode, ServerInit message is extended
+            const numServerMessages = this._sock.rQshift16();
+            const numClientMessages = this._sock.rQshift16();
+            const numEncodings = this._sock.rQshift16();
+            this._sock.rQskipBytes(2);  // padding
+
+            const totalMessagesLength = (numServerMessages + numClientMessages + numEncodings) * 16;
+            if (this._sock.rQwait('TightVNC extended server init header', totalMessagesLength, 32 + nameLength)) { return false; }
+
+            // we don't actually do anything with the capability information that TIGHT sends,
+            // so we just skip the all of this.
+
+            // TIGHT server message capabilities
+            this._sock.rQskipBytes(16 * numServerMessages);
+
+            // TIGHT client message capabilities
+            this._sock.rQskipBytes(16 * numClientMessages);
+
+            // TIGHT encoding capabilities
+            this._sock.rQskipBytes(16 * numEncodings);
+        }
+
+        // NB(directxman12): these are down here so that we don't run them multiple times
+        //                   if we backtrack
+        Log.Info("Screen: " + width + "x" + height +
+                  ", bpp: " + bpp + ", depth: " + depth +
+                  ", bigEndian: " + bigEndian +
+                  ", trueColor: " + trueColor +
+                  ", redMax: " + redMax +
+                  ", greenMax: " + greenMax +
+                  ", blueMax: " + blueMax +
+                  ", redShift: " + redShift +
+                  ", greenShift: " + greenShift +
+                  ", blueShift: " + blueShift);
+
+        // we're past the point where we could backtrack, so it's safe to call this
+        this._setDesktopName(name);
+        this._resize(width, height);
+
+        if (!this._viewOnly) {
+            this._keyboard.grab();
+            this._asyncClipboard.grab();
+        }
+
+        this._fbDepth = 24;
+
+        if (this._fbName === "Intel(r) AMT KVM") {
+            Log.Warn("Intel AMT KVM only supports 8/16 bit depths. Using low color mode.");
+            this._fbDepth = 8;
+        }
+
+        RFB.messages.pixelFormat(this._sock, this._fbDepth, true);
+        this._sendEncodings();
+        RFB.messages.fbUpdateRequest(this._sock, false, 0, 0, this._fbWidth, this._fbHeight);
+
+        this._updateConnectionState('connected');
+        return true;
+    }
+
+    _sendEncodings() {
+        const encs = [];
+
+        // In preference order
+        encs.push(encodings.encodingCopyRect);
+        // Only supported with full depth support
+        if (this._fbDepth == 24) {
+            if (supportsWebCodecsH264Decode) {
+                encs.push(encodings.encodingH264);
+            }
+            encs.push(encodings.encodingTight);
+            encs.push(encodings.encodingTightPNG);
+            encs.push(encodings.encodingZRLE);
+            encs.push(encodings.encodingJPEG);
+            encs.push(encodings.encodingHextile);
+            encs.push(encodings.encodingRRE);
+            encs.push(encodings.encodingZlib);
+        }
+        encs.push(encodings.encodingRaw);
+
+        // Psuedo-encoding settings
+        encs.push(encodings.pseudoEncodingQualityLevel0 + this._qualityLevel);
+        encs.push(encodings.pseudoEncodingCompressLevel0 + this._compressionLevel);
+
+        encs.push(encodings.pseudoEncodingDesktopSize);
+        encs.push(encodings.pseudoEncodingLastRect);
+        encs.push(encodings.pseudoEncodingQEMUExtendedKeyEvent);
+        encs.push(encodings.pseudoEncodingQEMULedEvent);
+        encs.push(encodings.pseudoEncodingExtendedDesktopSize);
+        encs.push(encodings.pseudoEncodingXvp);
+        encs.push(encodings.pseudoEncodingFence);
+        encs.push(encodings.pseudoEncodingContinuousUpdates);
+        encs.push(encodings.pseudoEncodingDesktopName);
+        encs.push(encodings.pseudoEncodingExtendedClipboard);
+        encs.push(encodings.pseudoEncodingExtendedMouseButtons);
+
+        if (this._fbDepth == 24) {
+            encs.push(encodings.pseudoEncodingVMwareCursor);
+            encs.push(encodings.pseudoEncodingCursor);
+        }
+
+        RFB.messages.clientEncodings(this._sock, encs);
+    }
+
+    /* RFB protocol initialization states:
+     *   ProtocolVersion
+     *   Security
+     *   Authentication
+     *   SecurityResult
+     *   ClientInitialization - not triggered by server message
+     *   ServerInitialization
+     */
+    _initMsg() {
+        switch (this._rfbInitState) {
+            case 'ProtocolVersion':
+                return this._negotiateProtocolVersion();
+
+            case 'Security':
+                return this._negotiateSecurity();
+
+            case 'Authentication':
+                return this._negotiateAuthentication();
+
+            case 'SecurityResult':
+                return this._handleSecurityResult();
+
+            case 'SecurityReason':
+                return this._handleSecurityReason();
+
+            case 'ClientInitialisation':
+                this._sock.sQpush8(this._shared ? 1 : 0); // ClientInitialisation
+                this._sock.flush();
+                this._rfbInitState = 'ServerInitialisation';
+                return true;
+
+            case 'ServerInitialisation':
+                return this._negotiateServerInit();
+
+            default:
+                return this._fail("Unknown init state (state: " +
+                                  this._rfbInitState + ")");
+        }
+    }
+
+    // Resume authentication handshake after it was paused for some
+    // reason, e.g. waiting for a password from the user
+    _resumeAuthentication() {
+        // We use setTimeout() so it's run in its own context, just like
+        // it originally did via the WebSocket's event handler
+        setTimeout(this._initMsg.bind(this), 0);
+    }
+
+    _handleSetColourMapMsg() {
+        Log.Debug("SetColorMapEntries");
+
+        return this._fail("Unexpected SetColorMapEntries message");
+    }
+
+    _writeClipboard(text) {
+        if (this._viewOnly) return;
+        if (this._asyncClipboard.writeClipboard(text)) return;
+        // Fallback clipboard
+        this.dispatchEvent(
+            new CustomEvent("clipboard", {detail: {text: text}})
+        );
+    }
+
+    _handleServerCutText() {
+        Log.Debug("ServerCutText");
+
+        if (this._sock.rQwait("ServerCutText header", 7, 1)) { return false; }
+
+        this._sock.rQskipBytes(3);  // Padding
+
+        let length = this._sock.rQshift32();
+        length = toSigned32bit(length);
+
+        if (this._sock.rQwait("ServerCutText content", Math.abs(length), 8)) { return false; }
+
+        if (length >= 0) {
+            //Standard msg
+            const text = this._sock.rQshiftStr(length);
+            if (this._viewOnly) {
+                return true;
+            }
+
+            this._writeClipboard(text);
+
+        } else {
+            //Extended msg.
+            length = Math.abs(length);
+            const flags = this._sock.rQshift32();
+            let formats = flags & 0x0000FFFF;
+            let actions = flags & 0xFF000000;
+
+            let isCaps = (!!(actions & extendedClipboardActionCaps));
+            if (isCaps) {
+                this._clipboardServerCapabilitiesFormats = {};
+                this._clipboardServerCapabilitiesActions = {};
+
+                // Update our server capabilities for Formats
+                for (let i = 0; i <= 15; i++) {
+                    let index = 1 << i;
+
+                    // Check if format flag is set.
+                    if ((formats & index)) {
+                        this._clipboardServerCapabilitiesFormats[index] = true;
+                        // We don't send unsolicited clipboard, so we
+                        // ignore the size
+                        this._sock.rQshift32();
+                    }
+                }
+
+                // Update our server capabilities for Actions
+                for (let i = 24; i <= 31; i++) {
+                    let index = 1 << i;
+                    this._clipboardServerCapabilitiesActions[index] = !!(actions & index);
+                }
+
+                /*  Caps handling done, send caps with the clients
+                    capabilities set as a response */
+                let clientActions = [
+                    extendedClipboardActionCaps,
+                    extendedClipboardActionRequest,
+                    extendedClipboardActionPeek,
+                    extendedClipboardActionNotify,
+                    extendedClipboardActionProvide
+                ];
+                RFB.messages.extendedClipboardCaps(this._sock, clientActions, {extendedClipboardFormatText: 0});
+
+            } else if (actions === extendedClipboardActionRequest) {
+                if (this._viewOnly) {
+                    return true;
+                }
+
+                // Check if server has told us it can handle Provide and there is clipboard data to send.
+                if (this._clipboardText != null &&
+                    this._clipboardServerCapabilitiesActions[extendedClipboardActionProvide]) {
+
+                    if (formats & extendedClipboardFormatText) {
+                        RFB.messages.extendedClipboardProvide(this._sock, [extendedClipboardFormatText], [this._clipboardText]);
+                    }
+                }
+
+            } else if (actions === extendedClipboardActionPeek) {
+                if (this._viewOnly) {
+                    return true;
+                }
+
+                if (this._clipboardServerCapabilitiesActions[extendedClipboardActionNotify]) {
+
+                    if (this._clipboardText != null) {
+                        RFB.messages.extendedClipboardNotify(this._sock, [extendedClipboardFormatText]);
+                    } else {
+                        RFB.messages.extendedClipboardNotify(this._sock, []);
+                    }
+                }
+
+            } else if (actions === extendedClipboardActionNotify) {
+                if (this._viewOnly) {
+                    return true;
+                }
+
+                if (this._clipboardServerCapabilitiesActions[extendedClipboardActionRequest]) {
+
+                    if (formats & extendedClipboardFormatText) {
+                        RFB.messages.extendedClipboardRequest(this._sock, [extendedClipboardFormatText]);
+                    }
+                }
+
+            } else if (actions === extendedClipboardActionProvide) {
+                if (this._viewOnly) {
+                    return true;
+                }
+
+                if (!(formats & extendedClipboardFormatText)) {
+                    return true;
+                }
+                // Ignore what we had in our clipboard client side.
+                this._clipboardText = null;
+
+                // FIXME: Should probably verify that this data was actually requested
+                let zlibStream = this._sock.rQshiftBytes(length - 4);
+                let streamInflator = new Inflator();
+                let textData = null;
+
+                streamInflator.setInput(zlibStream);
+                for (let i = 0; i <= 15; i++) {
+                    let format = 1 << i;
+
+                    if (formats & format) {
+
+                        let size = 0x00;
+                        let sizeArray = streamInflator.inflate(4);
+
+                        size |= (sizeArray[0] << 24);
+                        size |= (sizeArray[1] << 16);
+                        size |= (sizeArray[2] << 8);
+                        size |= (sizeArray[3]);
+                        let chunk = streamInflator.inflate(size);
+
+                        if (format === extendedClipboardFormatText) {
+                            textData = chunk;
+                        }
+                    }
+                }
+                streamInflator.setInput(null);
+
+                if (textData !== null) {
+                    let tmpText = "";
+                    for (let i = 0; i < textData.length; i++) {
+                        tmpText += String.fromCharCode(textData[i]);
+                    }
+                    textData = tmpText;
+
+                    textData = decodeUTF8(textData);
+                    if ((textData.length > 0) && "\0" === textData.charAt(textData.length - 1)) {
+                        textData = textData.slice(0, -1);
+                    }
+
+                    textData = textData.replaceAll("\r\n", "\n");
+
+                    this._writeClipboard(textData);
+                }
+            } else {
+                return this._fail("Unexpected action in extended clipboard message: " + actions);
+            }
+        }
+        return true;
+    }
+
+    _handleServerFenceMsg() {
+        if (this._sock.rQwait("ServerFence header", 8, 1)) { return false; }
+        this._sock.rQskipBytes(3); // Padding
+        let flags = this._sock.rQshift32();
+        let length = this._sock.rQshift8();
+
+        if (this._sock.rQwait("ServerFence payload", length, 9)) { return false; }
+
+        if (length > 64) {
+            Log.Warn("Bad payload length (" + length + ") in fence response");
+            length = 64;
+        }
+
+        const payload = this._sock.rQshiftStr(length);
+
+        this._supportsFence = true;
+
+        /*
+         * Fence flags
+         *
+         *  (1<<0)  - BlockBefore
+         *  (1<<1)  - BlockAfter
+         *  (1<<2)  - SyncNext
+         *  (1<<31) - Request
+         */
+
+        if (!(flags & (1<<31))) {
+            return this._fail("Unexpected fence response");
+        }
+
+        // Filter out unsupported flags
+        // FIXME: support syncNext
+        flags &= (1<<0) | (1<<1);
+
+        // BlockBefore and BlockAfter are automatically handled by
+        // the fact that we process each incoming message
+        // synchronuosly.
+        RFB.messages.clientFence(this._sock, flags, payload);
+
+        return true;
+    }
+
+    _handleXvpMsg() {
+        if (this._sock.rQwait("XVP version and message", 3, 1)) { return false; }
+        this._sock.rQskipBytes(1);  // Padding
+        const xvpVer = this._sock.rQshift8();
+        const xvpMsg = this._sock.rQshift8();
+
+        switch (xvpMsg) {
+            case 0:  // XVP_FAIL
+                Log.Error("XVP operation failed");
+                break;
+            case 1:  // XVP_INIT
+                this._rfbXvpVer = xvpVer;
+                Log.Info("XVP extensions enabled (version " + this._rfbXvpVer + ")");
+                this._setCapability("power", true);
+                break;
+            default:
+                this._fail("Illegal server XVP message (msg: " + xvpMsg + ")");
+                break;
+        }
+
+        return true;
+    }
+
+    _normalMsg() {
+        let msgType;
+        if (this._FBU.rects > 0) {
+            msgType = 0;
+        } else {
+            msgType = this._sock.rQshift8();
+        }
+
+        let first, ret;
+        switch (msgType) {
+            case 0:  // FramebufferUpdate
+                ret = this._framebufferUpdate();
+                if (ret && !this._enabledContinuousUpdates) {
+                    RFB.messages.fbUpdateRequest(this._sock, true, 0, 0,
+                                                 this._fbWidth, this._fbHeight);
+                }
+                return ret;
+
+            case 1:  // SetColorMapEntries
+                return this._handleSetColourMapMsg();
+
+            case 2:  // Bell
+                Log.Debug("Bell");
+                this.dispatchEvent(new CustomEvent(
+                    "bell",
+                    { detail: {} }));
+                return true;
+
+            case 3:  // ServerCutText
+                return this._handleServerCutText();
+
+            case 150: // EndOfContinuousUpdates
+                first = !this._supportsContinuousUpdates;
+                this._supportsContinuousUpdates = true;
+                this._enabledContinuousUpdates = false;
+                if (first) {
+                    this._enabledContinuousUpdates = true;
+                    this._updateContinuousUpdates();
+                    Log.Info("Enabling continuous updates.");
+                } else {
+                    // FIXME: We need to send a framebufferupdaterequest here
+                    // if we add support for turning off continuous updates
+                }
+                return true;
+
+            case 248: // ServerFence
+                return this._handleServerFenceMsg();
+
+            case 250:  // XVP
+                return this._handleXvpMsg();
+
+            default:
+                this._fail("Unexpected server message (type " + msgType + ")");
+                Log.Debug("sock.rQpeekBytes(30): " + this._sock.rQpeekBytes(30));
+                return true;
+        }
+    }
+
+    _framebufferUpdate() {
+        if (this._FBU.rects === 0) {
+            if (this._sock.rQwait("FBU header", 3, 1)) { return false; }
+            this._sock.rQskipBytes(1);  // Padding
+            this._FBU.rects = this._sock.rQshift16();
+
+            // Make sure the previous frame is fully rendered first
+            // to avoid building up an excessive queue
+            if (this._display.pending()) {
+                this._flushing = true;
+                this._display.flush()
+                    .then(() => {
+                        this._flushing = false;
+                        // Resume processing
+                        if (!this._sock.rQwait("message", 1)) {
+                            this._handleMessage();
+                        }
+                    });
+                return false;
+            }
+        }
+
+        while (this._FBU.rects > 0) {
+            if (this._FBU.encoding === null) {
+                if (this._sock.rQwait("rect header", 12)) { return false; }
+                /* New FramebufferUpdate */
+
+                this._FBU.x = this._sock.rQshift16();
+                this._FBU.y = this._sock.rQshift16();
+                this._FBU.width = this._sock.rQshift16();
+                this._FBU.height = this._sock.rQshift16();
+                this._FBU.encoding = this._sock.rQshift32();
+                /* Encodings are signed */
+                this._FBU.encoding >>= 0;
+            }
+
+            if (!this._handleRect()) {
+                return false;
+            }
+
+            this._FBU.rects--;
+            this._FBU.encoding = null;
+        }
+
+        this._display.flip();
+
+        return true;  // We finished this FBU
+    }
+
+    _handleRect() {
+        switch (this._FBU.encoding) {
+            case encodings.pseudoEncodingLastRect:
+                this._FBU.rects = 1; // Will be decreased when we return
+                return true;
+
+            case encodings.pseudoEncodingVMwareCursor:
+                return this._handleVMwareCursor();
+
+            case encodings.pseudoEncodingCursor:
+                return this._handleCursor();
+
+            case encodings.pseudoEncodingQEMUExtendedKeyEvent:
+                this._qemuExtKeyEventSupported = true;
+                return true;
+
+            case encodings.pseudoEncodingDesktopName:
+                return this._handleDesktopName();
+
+            case encodings.pseudoEncodingDesktopSize:
+                this._resize(this._FBU.width, this._FBU.height);
+                return true;
+
+            case encodings.pseudoEncodingExtendedDesktopSize:
+                return this._handleExtendedDesktopSize();
+
+            case encodings.pseudoEncodingExtendedMouseButtons:
+                this._extendedPointerEventSupported = true;
+                return true;
+
+            case encodings.pseudoEncodingQEMULedEvent:
+                return this._handleLedEvent();
+
+            default:
+                return this._handleDataRect();
+        }
+    }
+
+    _handleVMwareCursor() {
+        const hotx = this._FBU.x;  // hotspot-x
+        const hoty = this._FBU.y;  // hotspot-y
+        const w = this._FBU.width;
+        const h = this._FBU.height;
+        if (this._sock.rQwait("VMware cursor encoding", 1)) {
+            return false;
+        }
+
+        const cursorType = this._sock.rQshift8();
+
+        this._sock.rQshift8(); //Padding
+
+        let rgba;
+        const bytesPerPixel = 4;
+
+        //Classic cursor
+        if (cursorType == 0) {
+            //Used to filter away unimportant bits.
+            //OR is used for correct conversion in js.
+            const PIXEL_MASK = 0xffffff00 | 0;
+            rgba = new Array(w * h * bytesPerPixel);
+
+            if (this._sock.rQwait("VMware cursor classic encoding",
+                                  (w * h * bytesPerPixel) * 2, 2)) {
+                return false;
+            }
+
+            let andMask = new Array(w * h);
+            for (let pixel = 0; pixel < (w * h); pixel++) {
+                andMask[pixel] = this._sock.rQshift32();
+            }
+
+            let xorMask = new Array(w * h);
+            for (let pixel = 0; pixel < (w * h); pixel++) {
+                xorMask[pixel] = this._sock.rQshift32();
+            }
+
+            for (let pixel = 0; pixel < (w * h); pixel++) {
+                if (andMask[pixel] == 0) {
+                    //Fully opaque pixel
+                    let bgr = xorMask[pixel];
+                    let r   = bgr >> 8  & 0xff;
+                    let g   = bgr >> 16 & 0xff;
+                    let b   = bgr >> 24 & 0xff;
+
+                    rgba[(pixel * bytesPerPixel)     ] = r;    //r
+                    rgba[(pixel * bytesPerPixel) + 1 ] = g;    //g
+                    rgba[(pixel * bytesPerPixel) + 2 ] = b;    //b
+                    rgba[(pixel * bytesPerPixel) + 3 ] = 0xff; //a
+
+                } else if ((andMask[pixel] & PIXEL_MASK) ==
+                           PIXEL_MASK) {
+                    //Only screen value matters, no mouse colouring
+                    if (xorMask[pixel] == 0) {
+                        //Transparent pixel
+                        rgba[(pixel * bytesPerPixel)     ] = 0x00;
+                        rgba[(pixel * bytesPerPixel) + 1 ] = 0x00;
+                        rgba[(pixel * bytesPerPixel) + 2 ] = 0x00;
+                        rgba[(pixel * bytesPerPixel) + 3 ] = 0x00;
+
+                    } else if ((xorMask[pixel] & PIXEL_MASK) ==
+                               PIXEL_MASK) {
+                        //Inverted pixel, not supported in browsers.
+                        //Fully opaque instead.
+                        rgba[(pixel * bytesPerPixel)     ] = 0x00;
+                        rgba[(pixel * bytesPerPixel) + 1 ] = 0x00;
+                        rgba[(pixel * bytesPerPixel) + 2 ] = 0x00;
+                        rgba[(pixel * bytesPerPixel) + 3 ] = 0xff;
+
+                    } else {
+                        //Unhandled xorMask
+                        rgba[(pixel * bytesPerPixel)     ] = 0x00;
+                        rgba[(pixel * bytesPerPixel) + 1 ] = 0x00;
+                        rgba[(pixel * bytesPerPixel) + 2 ] = 0x00;
+                        rgba[(pixel * bytesPerPixel) + 3 ] = 0xff;
+                    }
+
+                } else {
+                    //Unhandled andMask
+                    rgba[(pixel * bytesPerPixel)     ] = 0x00;
+                    rgba[(pixel * bytesPerPixel) + 1 ] = 0x00;
+                    rgba[(pixel * bytesPerPixel) + 2 ] = 0x00;
+                    rgba[(pixel * bytesPerPixel) + 3 ] = 0xff;
+                }
+            }
+
+        //Alpha cursor.
+        } else if (cursorType == 1) {
+            if (this._sock.rQwait("VMware cursor alpha encoding",
+                                  (w * h * 4), 2)) {
+                return false;
+            }
+
+            rgba = new Array(w * h * bytesPerPixel);
+
+            for (let pixel = 0; pixel < (w * h); pixel++) {
+                let data = this._sock.rQshift32();
+
+                rgba[(pixel * 4)     ] = data >> 24 & 0xff; //r
+                rgba[(pixel * 4) + 1 ] = data >> 16 & 0xff; //g
+                rgba[(pixel * 4) + 2 ] = data >> 8 & 0xff;  //b
+                rgba[(pixel * 4) + 3 ] = data & 0xff;       //a
+            }
+
+        } else {
+            Log.Warn("The given cursor type is not supported: "
+                      + cursorType + " given.");
+            return false;
+        }
+
+        this._updateCursor(rgba, hotx, hoty, w, h);
+
+        return true;
+    }
+
+    _handleCursor() {
+        const hotx = this._FBU.x;  // hotspot-x
+        const hoty = this._FBU.y;  // hotspot-y
+        const w = this._FBU.width;
+        const h = this._FBU.height;
+
+        const pixelslength = w * h * 4;
+        const masklength = Math.ceil(w / 8) * h;
+
+        let bytes = pixelslength + masklength;
+        if (this._sock.rQwait("cursor encoding", bytes)) {
+            return false;
+        }
+
+        // Decode from BGRX pixels + bit mask to RGBA
+        const pixels = this._sock.rQshiftBytes(pixelslength);
+        const mask = this._sock.rQshiftBytes(masklength);
+        let rgba = new Uint8Array(w * h * 4);
+
+        let pixIdx = 0;
+        for (let y = 0; y < h; y++) {
+            for (let x = 0; x < w; x++) {
+                let maskIdx = y * Math.ceil(w / 8) + Math.floor(x / 8);
+                let alpha = (mask[maskIdx] << (x % 8)) & 0x80 ? 255 : 0;
+                rgba[pixIdx    ] = pixels[pixIdx + 2];
+                rgba[pixIdx + 1] = pixels[pixIdx + 1];
+                rgba[pixIdx + 2] = pixels[pixIdx];
+                rgba[pixIdx + 3] = alpha;
+                pixIdx += 4;
+            }
+        }
+
+        this._updateCursor(rgba, hotx, hoty, w, h);
+
+        return true;
+    }
+
+    _handleDesktopName() {
+        if (this._sock.rQwait("DesktopName", 4)) {
+            return false;
+        }
+
+        let length = this._sock.rQshift32();
+
+        if (this._sock.rQwait("DesktopName", length, 4)) {
+            return false;
+        }
+
+        let name = this._sock.rQshiftStr(length);
+        name = decodeUTF8(name, true);
+
+        this._setDesktopName(name);
+
+        return true;
+    }
+
+    _handleLedEvent() {
+        if (this._sock.rQwait("LED status", 1)) {
+            return false;
+        }
+
+        let data = this._sock.rQshift8();
+        // ScrollLock state can be retrieved with data & 1. This is currently not needed.
+        let numLock = data & 2 ? true : false;
+        let capsLock = data & 4 ? true : false;
+        this._remoteCapsLock = capsLock;
+        this._remoteNumLock = numLock;
+
+        return true;
+    }
+
+    _handleExtendedDesktopSize() {
+        if (this._sock.rQwait("ExtendedDesktopSize", 4)) {
+            return false;
+        }
+
+        const numberOfScreens = this._sock.rQpeek8();
+
+        let bytes = 4 + (numberOfScreens * 16);
+        if (this._sock.rQwait("ExtendedDesktopSize", bytes)) {
+            return false;
+        }
+
+        const firstUpdate = !this._supportsSetDesktopSize;
+        this._supportsSetDesktopSize = true;
+
+        this._sock.rQskipBytes(1);  // number-of-screens
+        this._sock.rQskipBytes(3);  // padding
+
+        for (let i = 0; i < numberOfScreens; i += 1) {
+            // Save the id and flags of the first screen
+            if (i === 0) {
+                this._screenID = this._sock.rQshift32();    // id
+                this._sock.rQskipBytes(2);                  // x-position
+                this._sock.rQskipBytes(2);                  // y-position
+                this._sock.rQskipBytes(2);                  // width
+                this._sock.rQskipBytes(2);                  // height
+                this._screenFlags = this._sock.rQshift32(); // flags
+            } else {
+                this._sock.rQskipBytes(16);
+            }
+        }
+
+        /*
+         * The x-position indicates the reason for the change:
+         *
+         *  0 - server resized on its own
+         *  1 - this client requested the resize
+         *  2 - another client requested the resize
+         */
+
+        if (this._FBU.x === 1) {
+            this._pendingRemoteResize = false;
+        }
+
+        // We need to handle errors when we requested the resize.
+        if (this._FBU.x === 1 && this._FBU.y !== 0) {
+            let msg = "";
+            // The y-position indicates the status code from the server
+            switch (this._FBU.y) {
+                case 1:
+                    msg = "Resize is administratively prohibited";
+                    break;
+                case 2:
+                    msg = "Out of resources";
+                    break;
+                case 3:
+                    msg = "Invalid screen layout";
+                    break;
+                default:
+                    msg = "Unknown reason";
+                    break;
+            }
+            Log.Warn("Server did not accept the resize request: "
+                     + msg);
+        } else {
+            this._resize(this._FBU.width, this._FBU.height);
+        }
+
+        // Normally we only apply the current resize mode after a
+        // window resize event. However there is no such trigger on the
+        // initial connect. And we don't know if the server supports
+        // resizing until we've gotten here.
+        if (firstUpdate) {
+            this._requestRemoteResize();
+        }
+
+        if (this._FBU.x === 1 && this._FBU.y === 0) {
+            // We might have resized again whilst waiting for the
+            // previous request, so check if we are in sync
+            this._requestRemoteResize();
+        }
+
+        return true;
+    }
+
+    _handleDataRect() {
+        let decoder = this._decoders[this._FBU.encoding];
+        if (!decoder) {
+            this._fail("Unsupported encoding (encoding: " +
+                       this._FBU.encoding + ")");
+            return false;
+        }
+
+        try {
+            return decoder.decodeRect(this._FBU.x, this._FBU.y,
+                                      this._FBU.width, this._FBU.height,
+                                      this._sock, this._display,
+                                      this._fbDepth);
+        } catch (err) {
+            this._fail("Error decoding rect: " + err);
+            return false;
+        }
+    }
+
+    _updateContinuousUpdates() {
+        if (!this._enabledContinuousUpdates) { return; }
+
+        RFB.messages.enableContinuousUpdates(this._sock, true, 0, 0,
+                                             this._fbWidth, this._fbHeight);
+    }
+
+    // Handle resize-messages from the server
+    _resize(width, height) {
+        this._fbWidth = width;
+        this._fbHeight = height;
+
+        this._display.resize(this._fbWidth, this._fbHeight);
+
+        // Adjust the visible viewport based on the new dimensions
+        this._updateClip();
+        this._updateScale();
+
+        this._updateContinuousUpdates();
+
+        // Keep this size until browser client size changes
+        this._saveExpectedClientSize();
+    }
+
+    _xvpOp(ver, op) {
+        if (this._rfbXvpVer < ver) { return; }
+        Log.Info("Sending XVP operation " + op + " (version " + ver + ")");
+        RFB.messages.xvpOp(this._sock, ver, op);
+    }
+
+    _updateCursor(rgba, hotx, hoty, w, h) {
+        this._cursorImage = {
+            rgbaPixels: rgba,
+            hotx: hotx, hoty: hoty, w: w, h: h,
+        };
+        this._refreshCursor();
+    }
+
+    _shouldShowDotCursor() {
+        // Called when this._cursorImage is updated
+        if (!this._showDotCursor) {
+            // User does not want to see the dot, so...
+            return false;
+        }
+
+        // The dot should not be shown if the cursor is already visible,
+        // i.e. contains at least one not-fully-transparent pixel.
+        // So iterate through all alpha bytes in rgba and stop at the
+        // first non-zero.
+        for (let i = 3; i < this._cursorImage.rgbaPixels.length; i += 4) {
+            if (this._cursorImage.rgbaPixels[i]) {
+                return false;
+            }
+        }
+
+        // At this point, we know that the cursor is fully transparent, and
+        // the user wants to see the dot instead of this.
+        return true;
+    }
+
+    _refreshCursor() {
+        if (this._rfbConnectionState !== "connecting" &&
+            this._rfbConnectionState !== "connected") {
+            return;
+        }
+        const image = this._shouldShowDotCursor() ? RFB.cursors.dot : this._cursorImage;
+        this._cursor.change(image.rgbaPixels,
+                            image.hotx, image.hoty,
+                            image.w, image.h
+        );
+    }
+
+    static genDES(password, challenge) {
+        const passwordChars = password.split('').map(c => c.charCodeAt(0));
+        const key = legacyCrypto.importKey(
+            "raw", passwordChars, { name: "DES-ECB" }, false, ["encrypt"]);
+        return legacyCrypto.encrypt({ name: "DES-ECB" }, key, challenge);
+    }
+}
+
+// Class Methods
+RFB.messages = {
+    keyEvent(sock, keysym, down) {
+        sock.sQpush8(4); // msg-type
+        sock.sQpush8(down);
+
+        sock.sQpush16(0);
+
+        sock.sQpush32(keysym);
+
+        sock.flush();
+    },
+
+    QEMUExtendedKeyEvent(sock, keysym, down, keycode) {
+        function getRFBkeycode(xtScanCode) {
+            const upperByte = (keycode >> 8);
+            const lowerByte = (keycode & 0x00ff);
+            if (upperByte === 0xe0 && lowerByte < 0x7f) {
+                return lowerByte | 0x80;
+            }
+            return xtScanCode;
+        }
+
+        sock.sQpush8(255); // msg-type
+        sock.sQpush8(0); // sub msg-type
+
+        sock.sQpush16(down);
+
+        sock.sQpush32(keysym);
+
+        const RFBkeycode = getRFBkeycode(keycode);
+
+        sock.sQpush32(RFBkeycode);
+
+        sock.flush();
+    },
+
+    pointerEvent(sock, x, y, mask) {
+        sock.sQpush8(5); // msg-type
+
+        // Marker bit must be set to 0, otherwise the server might
+        // confuse the marker bit with the highest bit in a normal
+        // PointerEvent message.
+        mask = mask & 0x7f;
+        sock.sQpush8(mask);
+
+        sock.sQpush16(x);
+        sock.sQpush16(y);
+
+        sock.flush();
+    },
+
+    extendedPointerEvent(sock, x, y, mask) {
+        sock.sQpush8(5); // msg-type
+
+        let higherBits = (mask >> 7) & 0xff;
+
+        // Bits 2-7 are reserved
+        if (higherBits & 0xfc) {
+            throw new Error("Invalid mouse button mask: " + mask);
+        }
+
+        let lowerBits = mask & 0x7f;
+        lowerBits |= 0x80; // Set marker bit to 1
+
+        sock.sQpush8(lowerBits);
+        sock.sQpush16(x);
+        sock.sQpush16(y);
+        sock.sQpush8(higherBits);
+
+        sock.flush();
+    },
+
+    // Used to build Notify and Request data.
+    _buildExtendedClipboardFlags(actions, formats) {
+        let data = new Uint8Array(4);
+        let formatFlag = 0x00000000;
+        let actionFlag = 0x00000000;
+
+        for (let i = 0; i < actions.length; i++) {
+            actionFlag |= actions[i];
+        }
+
+        for (let i = 0; i < formats.length; i++) {
+            formatFlag |= formats[i];
+        }
+
+        data[0] = actionFlag >> 24; // Actions
+        data[1] = 0x00;             // Reserved
+        data[2] = 0x00;             // Reserved
+        data[3] = formatFlag;       // Formats
+
+        return data;
+    },
+
+    extendedClipboardProvide(sock, formats, inData) {
+        // Deflate incomming data and their sizes
+        let deflator = new Deflator();
+        let dataToDeflate = [];
+
+        for (let i = 0; i < formats.length; i++) {
+            // We only support the format Text at this time
+            if (formats[i] != extendedClipboardFormatText) {
+                throw new Error("Unsupported extended clipboard format for Provide message.");
+            }
+
+            // Change lone \r or \n into \r\n as defined in rfbproto
+            inData[i] = inData[i].replace(/\r\n|\r|\n/gm, "\r\n");
+
+            // Check if it already has \0
+            let text = encodeUTF8(inData[i] + "\0");
+
+            dataToDeflate.push( (text.length >> 24) & 0xFF,
+                                (text.length >> 16) & 0xFF,
+                                (text.length >>  8) & 0xFF,
+                                (text.length & 0xFF));
+
+            for (let j = 0; j < text.length; j++) {
+                dataToDeflate.push(text.charCodeAt(j));
+            }
+        }
+
+        let deflatedData = deflator.deflate(new Uint8Array(dataToDeflate));
+
+        // Build data  to send
+        let data = new Uint8Array(4 + deflatedData.length);
+        data.set(RFB.messages._buildExtendedClipboardFlags([extendedClipboardActionProvide],
+                                                           formats));
+        data.set(deflatedData, 4);
+
+        RFB.messages.clientCutText(sock, data, true);
+    },
+
+    extendedClipboardNotify(sock, formats) {
+        let flags = RFB.messages._buildExtendedClipboardFlags([extendedClipboardActionNotify],
+                                                              formats);
+        RFB.messages.clientCutText(sock, flags, true);
+    },
+
+    extendedClipboardRequest(sock, formats) {
+        let flags = RFB.messages._buildExtendedClipboardFlags([extendedClipboardActionRequest],
+                                                              formats);
+        RFB.messages.clientCutText(sock, flags, true);
+    },
+
+    extendedClipboardCaps(sock, actions, formats) {
+        let formatKeys = Object.keys(formats);
+        let data  = new Uint8Array(4 + (4 * formatKeys.length));
+
+        formatKeys.map(x => parseInt(x));
+        formatKeys.sort((a, b) =>  a - b);
+
+        data.set(RFB.messages._buildExtendedClipboardFlags(actions, []));
+
+        let loopOffset = 4;
+        for (let i = 0; i < formatKeys.length; i++) {
+            data[loopOffset]     = formats[formatKeys[i]] >> 24;
+            data[loopOffset + 1] = formats[formatKeys[i]] >> 16;
+            data[loopOffset + 2] = formats[formatKeys[i]] >> 8;
+            data[loopOffset + 3] = formats[formatKeys[i]] >> 0;
+
+            loopOffset += 4;
+            data[3] |= (1 << formatKeys[i]); // Update our format flags
+        }
+
+        RFB.messages.clientCutText(sock, data, true);
+    },
+
+    clientCutText(sock, data, extended = false) {
+        sock.sQpush8(6); // msg-type
+
+        sock.sQpush8(0); // padding
+        sock.sQpush8(0); // padding
+        sock.sQpush8(0); // padding
+
+        let length;
+        if (extended) {
+            length = toUnsigned32bit(-data.length);
+        } else {
+            length = data.length;
+        }
+
+        sock.sQpush32(length);
+        sock.sQpushBytes(data);
+        sock.flush();
+    },
+
+    setDesktopSize(sock, width, height, id, flags) {
+        sock.sQpush8(251); // msg-type
+
+        sock.sQpush8(0); // padding
+
+        sock.sQpush16(width);
+        sock.sQpush16(height);
+
+        sock.sQpush8(1); // number-of-screens
+
+        sock.sQpush8(0); // padding
+
+        // screen array
+        sock.sQpush32(id);
+        sock.sQpush16(0); // x-position
+        sock.sQpush16(0); // y-position
+        sock.sQpush16(width);
+        sock.sQpush16(height);
+        sock.sQpush32(flags);
+
+        sock.flush();
+    },
+
+    clientFence(sock, flags, payload) {
+        sock.sQpush8(248); // msg-type
+
+        sock.sQpush8(0); // padding
+        sock.sQpush8(0); // padding
+        sock.sQpush8(0); // padding
+
+        sock.sQpush32(flags);
+
+        sock.sQpush8(payload.length);
+        sock.sQpushString(payload);
+
+        sock.flush();
+    },
+
+    enableContinuousUpdates(sock, enable, x, y, width, height) {
+        sock.sQpush8(150); // msg-type
+
+        sock.sQpush8(enable);
+
+        sock.sQpush16(x);
+        sock.sQpush16(y);
+        sock.sQpush16(width);
+        sock.sQpush16(height);
+
+        sock.flush();
+    },
+
+    pixelFormat(sock, depth, trueColor) {
+        let bpp;
+
+        if (depth > 16) {
+            bpp = 32;
+        } else if (depth > 8) {
+            bpp = 16;
+        } else {
+            bpp = 8;
+        }
+
+        const bits = Math.floor(depth/3);
+
+        sock.sQpush8(0); // msg-type
+
+        sock.sQpush8(0); // padding
+        sock.sQpush8(0); // padding
+        sock.sQpush8(0); // padding
+
+        sock.sQpush8(bpp);
+        sock.sQpush8(depth);
+        sock.sQpush8(0); // little-endian
+        sock.sQpush8(trueColor ? 1 : 0);
+
+        sock.sQpush16((1 << bits) - 1); // red-max
+        sock.sQpush16((1 << bits) - 1); // green-max
+        sock.sQpush16((1 << bits) - 1); // blue-max
+
+        sock.sQpush8(bits * 0); // red-shift
+        sock.sQpush8(bits * 1); // green-shift
+        sock.sQpush8(bits * 2); // blue-shift
+
+        sock.sQpush8(0); // padding
+        sock.sQpush8(0); // padding
+        sock.sQpush8(0); // padding
+
+        sock.flush();
+    },
+
+    clientEncodings(sock, encodings) {
+        sock.sQpush8(2); // msg-type
+
+        sock.sQpush8(0); // padding
+
+        sock.sQpush16(encodings.length);
+        for (let i = 0; i < encodings.length; i++) {
+            sock.sQpush32(encodings[i]);
+        }
+
+        sock.flush();
+    },
+
+    fbUpdateRequest(sock, incremental, x, y, w, h) {
+        if (typeof(x) === "undefined") { x = 0; }
+        if (typeof(y) === "undefined") { y = 0; }
+
+        sock.sQpush8(3); // msg-type
+
+        sock.sQpush8(incremental ? 1 : 0);
+
+        sock.sQpush16(x);
+        sock.sQpush16(y);
+        sock.sQpush16(w);
+        sock.sQpush16(h);
+
+        sock.flush();
+    },
+
+    xvpOp(sock, ver, op) {
+        sock.sQpush8(250); // msg-type
+
+        sock.sQpush8(0); // padding
+
+        sock.sQpush8(ver);
+        sock.sQpush8(op);
+
+        sock.flush();
+    }
+};
+
+RFB.cursors = {
+    none: {
+        rgbaPixels: new Uint8Array(),
+        w: 0, h: 0,
+        hotx: 0, hoty: 0,
+    },
+
+    dot: {
+        /* eslint-disable indent */
+        rgbaPixels: new Uint8Array([
+            255, 255, 255, 255,   0,   0,   0, 255, 255, 255, 255, 255,
+              0,   0,   0, 255,   0,   0,   0,   0,   0,   0,  0,  255,
+            255, 255, 255, 255,   0,   0,   0, 255, 255, 255, 255, 255,
+        ]),
+        /* eslint-enable indent */
+        w: 3, h: 3,
+        hotx: 1, hoty: 1,
+    }
+};
pkg/web/noVNC/core/websock.js
@@ -0,0 +1,369 @@
+/*
+ * Websock: high-performance buffering wrapper
+ * Copyright (C) 2019 The noVNC authors
+ * Licensed under MPL 2.0 (see LICENSE.txt)
+ *
+ * Websock is similar to the standard WebSocket / RTCDataChannel object
+ * but with extra buffer handling.
+ *
+ * Websock has built-in receive queue buffering; the message event
+ * does not contain actual data but is simply a notification that
+ * there is new data available. Several rQ* methods are available to
+ * read binary data off of the receive queue.
+ */
+
+import * as Log from './util/logging.js';
+
+// this has performance issues in some versions Chromium, and
+// doesn't gain a tremendous amount of performance increase in Firefox
+// at the moment.  It may be valuable to turn it on in the future.
+const MAX_RQ_GROW_SIZE = 40 * 1024 * 1024;  // 40 MiB
+
+// Constants pulled from RTCDataChannelState enum
+// https://developer.mozilla.org/en-US/docs/Web/API/RTCDataChannel/readyState#RTCDataChannelState_enum
+const DataChannel = {
+    CONNECTING: "connecting",
+    OPEN: "open",
+    CLOSING: "closing",
+    CLOSED: "closed"
+};
+
+const ReadyStates = {
+    CONNECTING: [WebSocket.CONNECTING, DataChannel.CONNECTING],
+    OPEN: [WebSocket.OPEN, DataChannel.OPEN],
+    CLOSING: [WebSocket.CLOSING, DataChannel.CLOSING],
+    CLOSED: [WebSocket.CLOSED, DataChannel.CLOSED],
+};
+
+// Properties a raw channel must have, WebSocket and RTCDataChannel are two examples
+const rawChannelProps = [
+    "send",
+    "close",
+    "binaryType",
+    "onerror",
+    "onmessage",
+    "onopen",
+    "protocol",
+    "readyState",
+];
+
+export default class Websock {
+    constructor() {
+        this._websocket = null;  // WebSocket or RTCDataChannel object
+
+        this._rQi = 0;           // Receive queue index
+        this._rQlen = 0;         // Next write position in the receive queue
+        this._rQbufferSize = 1024 * 1024 * 4; // Receive queue buffer size (4 MiB)
+        // called in init: this._rQ = new Uint8Array(this._rQbufferSize);
+        this._rQ = null; // Receive queue
+
+        this._sQbufferSize = 1024 * 10;  // 10 KiB
+        // called in init: this._sQ = new Uint8Array(this._sQbufferSize);
+        this._sQlen = 0;
+        this._sQ = null;  // Send queue
+
+        this._eventHandlers = {
+            message: () => {},
+            open: () => {},
+            close: () => {},
+            error: () => {}
+        };
+    }
+
+    // Getters and setters
+
+    get readyState() {
+        let subState;
+
+        if (this._websocket === null) {
+            return "unused";
+        }
+
+        subState = this._websocket.readyState;
+
+        if (ReadyStates.CONNECTING.includes(subState)) {
+            return "connecting";
+        } else if (ReadyStates.OPEN.includes(subState)) {
+            return "open";
+        } else if (ReadyStates.CLOSING.includes(subState)) {
+            return "closing";
+        } else if (ReadyStates.CLOSED.includes(subState)) {
+            return "closed";
+        }
+
+        return "unknown";
+    }
+
+    // Receive queue
+    rQpeek8() {
+        return this._rQ[this._rQi];
+    }
+
+    rQskipBytes(bytes) {
+        this._rQi += bytes;
+    }
+
+    rQshift8() {
+        return this._rQshift(1);
+    }
+
+    rQshift16() {
+        return this._rQshift(2);
+    }
+
+    rQshift32() {
+        return this._rQshift(4);
+    }
+
+    // TODO(directxman12): test performance with these vs a DataView
+    _rQshift(bytes) {
+        let res = 0;
+        for (let byte = bytes - 1; byte >= 0; byte--) {
+            res += this._rQ[this._rQi++] << (byte * 8);
+        }
+        return res >>> 0;
+    }
+
+    rQlen() {
+        return this._rQlen - this._rQi;
+    }
+
+    rQshiftStr(len) {
+        let str = "";
+        // Handle large arrays in steps to avoid long strings on the stack
+        for (let i = 0; i < len; i += 4096) {
+            let part = this.rQshiftBytes(Math.min(4096, len - i), false);
+            str += String.fromCharCode.apply(null, part);
+        }
+        return str;
+    }
+
+    rQshiftBytes(len, copy=true) {
+        this._rQi += len;
+        if (copy) {
+            return this._rQ.slice(this._rQi - len, this._rQi);
+        } else {
+            return this._rQ.subarray(this._rQi - len, this._rQi);
+        }
+    }
+
+    rQshiftTo(target, len) {
+        // TODO: make this just use set with views when using a ArrayBuffer to store the rQ
+        target.set(new Uint8Array(this._rQ.buffer, this._rQi, len));
+        this._rQi += len;
+    }
+
+    rQpeekBytes(len, copy=true) {
+        if (copy) {
+            return this._rQ.slice(this._rQi, this._rQi + len);
+        } else {
+            return this._rQ.subarray(this._rQi, this._rQi + len);
+        }
+    }
+
+    // Check to see if we must wait for 'num' bytes (default to FBU.bytes)
+    // to be available in the receive queue. Return true if we need to
+    // wait (and possibly print a debug message), otherwise false.
+    rQwait(msg, num, goback) {
+        if (this._rQlen - this._rQi < num) {
+            if (goback) {
+                if (this._rQi < goback) {
+                    throw new Error("rQwait cannot backup " + goback + " bytes");
+                }
+                this._rQi -= goback;
+            }
+            return true; // true means need more data
+        }
+        return false;
+    }
+
+    // Send queue
+
+    sQpush8(num) {
+        this._sQensureSpace(1);
+        this._sQ[this._sQlen++] = num;
+    }
+
+    sQpush16(num) {
+        this._sQensureSpace(2);
+        this._sQ[this._sQlen++] = (num >> 8) & 0xff;
+        this._sQ[this._sQlen++] = (num >> 0) & 0xff;
+    }
+
+    sQpush32(num) {
+        this._sQensureSpace(4);
+        this._sQ[this._sQlen++] = (num >> 24) & 0xff;
+        this._sQ[this._sQlen++] = (num >> 16) & 0xff;
+        this._sQ[this._sQlen++] = (num >>  8) & 0xff;
+        this._sQ[this._sQlen++] = (num >>  0) & 0xff;
+    }
+
+    sQpushString(str) {
+        let bytes = str.split('').map(chr => chr.charCodeAt(0));
+        this.sQpushBytes(new Uint8Array(bytes));
+    }
+
+    sQpushBytes(bytes) {
+        for (let offset = 0;offset < bytes.length;) {
+            this._sQensureSpace(1);
+
+            let chunkSize = this._sQbufferSize - this._sQlen;
+            if (chunkSize > bytes.length - offset) {
+                chunkSize = bytes.length - offset;
+            }
+
+            this._sQ.set(bytes.subarray(offset, offset + chunkSize), this._sQlen);
+            this._sQlen += chunkSize;
+            offset += chunkSize;
+        }
+    }
+
+    flush() {
+        if (this._sQlen > 0 && this.readyState === 'open') {
+            this._websocket.send(new Uint8Array(this._sQ.buffer, 0, this._sQlen));
+            this._sQlen = 0;
+        }
+    }
+
+    _sQensureSpace(bytes) {
+        if (this._sQbufferSize - this._sQlen < bytes) {
+            this.flush();
+        }
+    }
+
+    // Event handlers
+    off(evt) {
+        this._eventHandlers[evt] = () => {};
+    }
+
+    on(evt, handler) {
+        this._eventHandlers[evt] = handler;
+    }
+
+    _allocateBuffers() {
+        this._rQ = new Uint8Array(this._rQbufferSize);
+        this._sQ = new Uint8Array(this._sQbufferSize);
+    }
+
+    init() {
+        this._allocateBuffers();
+        this._rQi = 0;
+        this._websocket = null;
+    }
+
+    open(uri, protocols) {
+        this.attach(new WebSocket(uri, protocols));
+    }
+
+    attach(rawChannel) {
+        this.init();
+
+        // Must get object and class methods to be compatible with the tests.
+        const channelProps = [...Object.keys(rawChannel), ...Object.getOwnPropertyNames(Object.getPrototypeOf(rawChannel))];
+        for (let i = 0; i < rawChannelProps.length; i++) {
+            const prop = rawChannelProps[i];
+            if (channelProps.indexOf(prop) < 0) {
+                throw new Error('Raw channel missing property: ' + prop);
+            }
+        }
+
+        this._websocket = rawChannel;
+        this._websocket.binaryType = "arraybuffer";
+        this._websocket.onmessage = this._recvMessage.bind(this);
+
+        this._websocket.onopen = () => {
+            Log.Debug('>> WebSock.onopen');
+            if (this._websocket.protocol) {
+                Log.Info("Server choose sub-protocol: " + this._websocket.protocol);
+            }
+
+            this._eventHandlers.open();
+            Log.Debug("<< WebSock.onopen");
+        };
+
+        this._websocket.onclose = (e) => {
+            Log.Debug(">> WebSock.onclose");
+            this._eventHandlers.close(e);
+            Log.Debug("<< WebSock.onclose");
+        };
+
+        this._websocket.onerror = (e) => {
+            Log.Debug(">> WebSock.onerror: " + e);
+            this._eventHandlers.error(e);
+            Log.Debug("<< WebSock.onerror: " + e);
+        };
+    }
+
+    close() {
+        if (this._websocket) {
+            if (this.readyState === 'connecting' ||
+                this.readyState === 'open') {
+                Log.Info("Closing WebSocket connection");
+                this._websocket.close();
+            }
+
+            this._websocket.onmessage = () => {};
+        }
+    }
+
+    // private methods
+
+    // We want to move all the unread data to the start of the queue,
+    // e.g. compacting.
+    // The function also expands the receive que if needed, and for
+    // performance reasons we combine these two actions to avoid
+    // unnecessary copying.
+    _expandCompactRQ(minFit) {
+        // if we're using less than 1/8th of the buffer even with the incoming bytes, compact in place
+        // instead of resizing
+        const requiredBufferSize =  (this._rQlen - this._rQi + minFit) * 8;
+        const resizeNeeded = this._rQbufferSize < requiredBufferSize;
+
+        if (resizeNeeded) {
+            // Make sure we always *at least* double the buffer size, and have at least space for 8x
+            // the current amount of data
+            this._rQbufferSize = Math.max(this._rQbufferSize * 2, requiredBufferSize);
+        }
+
+        // we don't want to grow unboundedly
+        if (this._rQbufferSize > MAX_RQ_GROW_SIZE) {
+            this._rQbufferSize = MAX_RQ_GROW_SIZE;
+            if (this._rQbufferSize - (this._rQlen - this._rQi) < minFit) {
+                throw new Error("Receive queue buffer exceeded " + MAX_RQ_GROW_SIZE + " bytes, and the new message could not fit");
+            }
+        }
+
+        if (resizeNeeded) {
+            const oldRQbuffer = this._rQ.buffer;
+            this._rQ = new Uint8Array(this._rQbufferSize);
+            this._rQ.set(new Uint8Array(oldRQbuffer, this._rQi, this._rQlen - this._rQi));
+        } else {
+            this._rQ.copyWithin(0, this._rQi, this._rQlen);
+        }
+
+        this._rQlen = this._rQlen - this._rQi;
+        this._rQi = 0;
+    }
+
+    // push arraybuffer values onto the end of the receive que
+    _recvMessage(e) {
+        if (this._rQlen == this._rQi) {
+            // All data has now been processed, this means we
+            // can reset the receive queue.
+            this._rQlen = 0;
+            this._rQi = 0;
+        }
+        const u8 = new Uint8Array(e.data);
+        if (u8.length > this._rQbufferSize - this._rQlen) {
+            this._expandCompactRQ(u8.length);
+        }
+        this._rQ.set(u8, this._rQlen);
+        this._rQlen += u8.length;
+
+        if (this._rQlen - this._rQi > 0) {
+            this._eventHandlers.message();
+        } else {
+            Log.Debug("Ignoring empty message");
+        }
+    }
+}
pkg/web/noVNC/vendor/pako/lib/utils/common.js
@@ -0,0 +1,45 @@
+// reduce buffer size, avoiding mem copy
+export function shrinkBuf (buf, size) {
+  if (buf.length === size) { return buf; }
+  if (buf.subarray) { return buf.subarray(0, size); }
+  buf.length = size;
+  return buf;
+};
+
+
+export function arraySet (dest, src, src_offs, len, dest_offs) {
+  if (src.subarray && dest.subarray) {
+    dest.set(src.subarray(src_offs, src_offs + len), dest_offs);
+    return;
+  }
+  // Fallback to ordinary array
+  for (var i = 0; i < len; i++) {
+    dest[dest_offs + i] = src[src_offs + i];
+  }
+}
+
+// Join array of chunks to single array.
+export function flattenChunks (chunks) {
+  var i, l, len, pos, chunk, result;
+
+  // calculate data length
+  len = 0;
+  for (i = 0, l = chunks.length; i < l; i++) {
+    len += chunks[i].length;
+  }
+
+  // join chunks
+  result = new Uint8Array(len);
+  pos = 0;
+  for (i = 0, l = chunks.length; i < l; i++) {
+    chunk = chunks[i];
+    result.set(chunk, pos);
+    pos += chunk.length;
+  }
+
+  return result;
+}
+
+export var Buf8  = Uint8Array;
+export var Buf16 = Uint16Array;
+export var Buf32 = Int32Array;
pkg/web/noVNC/vendor/pako/lib/zlib/adler32.js
@@ -0,0 +1,27 @@
+// Note: adler32 takes 12% for level 0 and 2% for level 6.
+// It doesn't worth to make additional optimizationa as in original.
+// Small size is preferable.
+
+export default function adler32(adler, buf, len, pos) {
+  var s1 = (adler & 0xffff) |0,
+      s2 = ((adler >>> 16) & 0xffff) |0,
+      n = 0;
+
+  while (len !== 0) {
+    // Set limit ~ twice less than 5552, to keep
+    // s2 in 31-bits, because we force signed ints.
+    // in other case %= will fail.
+    n = len > 2000 ? 2000 : len;
+    len -= n;
+
+    do {
+      s1 = (s1 + buf[pos++]) |0;
+      s2 = (s2 + s1) |0;
+    } while (--n);
+
+    s1 %= 65521;
+    s2 %= 65521;
+  }
+
+  return (s1 | (s2 << 16)) |0;
+}
pkg/web/noVNC/vendor/pako/lib/zlib/constants.js
@@ -0,0 +1,47 @@
+export default {
+
+  /* Allowed flush values; see deflate() and inflate() below for details */
+  Z_NO_FLUSH:         0,
+  Z_PARTIAL_FLUSH:    1,
+  Z_SYNC_FLUSH:       2,
+  Z_FULL_FLUSH:       3,
+  Z_FINISH:           4,
+  Z_BLOCK:            5,
+  Z_TREES:            6,
+
+  /* Return codes for the compression/decompression functions. Negative values
+  * are errors, positive values are used for special but normal events.
+  */
+  Z_OK:               0,
+  Z_STREAM_END:       1,
+  Z_NEED_DICT:        2,
+  Z_ERRNO:           -1,
+  Z_STREAM_ERROR:    -2,
+  Z_DATA_ERROR:      -3,
+  //Z_MEM_ERROR:     -4,
+  Z_BUF_ERROR:       -5,
+  //Z_VERSION_ERROR: -6,
+
+  /* compression levels */
+  Z_NO_COMPRESSION:         0,
+  Z_BEST_SPEED:             1,
+  Z_BEST_COMPRESSION:       9,
+  Z_DEFAULT_COMPRESSION:   -1,
+
+
+  Z_FILTERED:               1,
+  Z_HUFFMAN_ONLY:           2,
+  Z_RLE:                    3,
+  Z_FIXED:                  4,
+  Z_DEFAULT_STRATEGY:       0,
+
+  /* Possible values of the data_type field (though see inflate()) */
+  Z_BINARY:                 0,
+  Z_TEXT:                   1,
+  //Z_ASCII:                1, // = Z_TEXT (deprecated)
+  Z_UNKNOWN:                2,
+
+  /* The deflate compression method */
+  Z_DEFLATED:               8
+  //Z_NULL:                 null // Use -1 or null inline, depending on var type
+};
pkg/web/noVNC/vendor/pako/lib/zlib/crc32.js
@@ -0,0 +1,36 @@
+// Note: we can't get significant speed boost here.
+// So write code to minimize size - no pregenerated tables
+// and array tools dependencies.
+
+
+// Use ordinary array, since untyped makes no boost here
+export default function makeTable() {
+  var c, table = [];
+
+  for (var n = 0; n < 256; n++) {
+    c = n;
+    for (var k = 0; k < 8; k++) {
+      c = ((c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1));
+    }
+    table[n] = c;
+  }
+
+  return table;
+}
+
+// Create table on load. Just 255 signed longs. Not a problem.
+var crcTable = makeTable();
+
+
+function crc32(crc, buf, len, pos) {
+  var t = crcTable,
+      end = pos + len;
+
+  crc ^= -1;
+
+  for (var i = pos; i < end; i++) {
+    crc = (crc >>> 8) ^ t[(crc ^ buf[i]) & 0xFF];
+  }
+
+  return (crc ^ (-1)); // >>> 0;
+}
pkg/web/noVNC/vendor/pako/lib/zlib/deflate.js
@@ -0,0 +1,1846 @@
+import * as utils from "../utils/common.js";
+import * as trees from "./trees.js";
+import adler32 from "./adler32.js";
+import crc32 from "./crc32.js";
+import msg from "./messages.js";
+
+/* Public constants ==========================================================*/
+/* ===========================================================================*/
+
+
+/* Allowed flush values; see deflate() and inflate() below for details */
+export const Z_NO_FLUSH      = 0;
+export const Z_PARTIAL_FLUSH = 1;
+//export const Z_SYNC_FLUSH    = 2;
+export const Z_FULL_FLUSH    = 3;
+export const Z_FINISH        = 4;
+export const Z_BLOCK         = 5;
+//export const Z_TREES         = 6;
+
+
+/* Return codes for the compression/decompression functions. Negative values
+ * are errors, positive values are used for special but normal events.
+ */
+export const Z_OK            = 0;
+export const Z_STREAM_END    = 1;
+//export const Z_NEED_DICT     = 2;
+//export const Z_ERRNO         = -1;
+export const Z_STREAM_ERROR  = -2;
+export const Z_DATA_ERROR    = -3;
+//export const Z_MEM_ERROR     = -4;
+export const Z_BUF_ERROR     = -5;
+//export const Z_VERSION_ERROR = -6;
+
+
+/* compression levels */
+//export const Z_NO_COMPRESSION      = 0;
+//export const Z_BEST_SPEED          = 1;
+//export const Z_BEST_COMPRESSION    = 9;
+export const Z_DEFAULT_COMPRESSION = -1;
+
+
+export const Z_FILTERED            = 1;
+export const Z_HUFFMAN_ONLY        = 2;
+export const Z_RLE                 = 3;
+export const Z_FIXED               = 4;
+export const Z_DEFAULT_STRATEGY    = 0;
+
+/* Possible values of the data_type field (though see inflate()) */
+//export const Z_BINARY              = 0;
+//export const Z_TEXT                = 1;
+//export const Z_ASCII               = 1; // = Z_TEXT
+export const Z_UNKNOWN             = 2;
+
+
+/* The deflate compression method */
+export const Z_DEFLATED  = 8;
+
+/*============================================================================*/
+
+
+var MAX_MEM_LEVEL = 9;
+/* Maximum value for memLevel in deflateInit2 */
+var MAX_WBITS = 15;
+/* 32K LZ77 window */
+var DEF_MEM_LEVEL = 8;
+
+
+var LENGTH_CODES  = 29;
+/* number of length codes, not counting the special END_BLOCK code */
+var LITERALS      = 256;
+/* number of literal bytes 0..255 */
+var L_CODES       = LITERALS + 1 + LENGTH_CODES;
+/* number of Literal or Length codes, including the END_BLOCK code */
+var D_CODES       = 30;
+/* number of distance codes */
+var BL_CODES      = 19;
+/* number of codes used to transfer the bit lengths */
+var HEAP_SIZE     = 2 * L_CODES + 1;
+/* maximum heap size */
+var MAX_BITS  = 15;
+/* All codes must not exceed MAX_BITS bits */
+
+var MIN_MATCH = 3;
+var MAX_MATCH = 258;
+var MIN_LOOKAHEAD = (MAX_MATCH + MIN_MATCH + 1);
+
+var PRESET_DICT = 0x20;
+
+var INIT_STATE = 42;
+var EXTRA_STATE = 69;
+var NAME_STATE = 73;
+var COMMENT_STATE = 91;
+var HCRC_STATE = 103;
+var BUSY_STATE = 113;
+var FINISH_STATE = 666;
+
+var BS_NEED_MORE      = 1; /* block not completed, need more input or more output */
+var BS_BLOCK_DONE     = 2; /* block flush performed */
+var BS_FINISH_STARTED = 3; /* finish started, need only more output at next deflate */
+var BS_FINISH_DONE    = 4; /* finish done, accept no more input or output */
+
+var OS_CODE = 0x03; // Unix :) . Don't detect, use this default.
+
+function err(strm, errorCode) {
+  strm.msg = msg[errorCode];
+  return errorCode;
+}
+
+function rank(f) {
+  return ((f) << 1) - ((f) > 4 ? 9 : 0);
+}
+
+function zero(buf) { var len = buf.length; while (--len >= 0) { buf[len] = 0; } }
+
+
+/* =========================================================================
+ * Flush as much pending output as possible. All deflate() output goes
+ * through this function so some applications may wish to modify it
+ * to avoid allocating a large strm->output buffer and copying into it.
+ * (See also read_buf()).
+ */
+function flush_pending(strm) {
+  var s = strm.state;
+
+  //_tr_flush_bits(s);
+  var len = s.pending;
+  if (len > strm.avail_out) {
+    len = strm.avail_out;
+  }
+  if (len === 0) { return; }
+
+  utils.arraySet(strm.output, s.pending_buf, s.pending_out, len, strm.next_out);
+  strm.next_out += len;
+  s.pending_out += len;
+  strm.total_out += len;
+  strm.avail_out -= len;
+  s.pending -= len;
+  if (s.pending === 0) {
+    s.pending_out = 0;
+  }
+}
+
+
+function flush_block_only(s, last) {
+  trees._tr_flush_block(s, (s.block_start >= 0 ? s.block_start : -1), s.strstart - s.block_start, last);
+  s.block_start = s.strstart;
+  flush_pending(s.strm);
+}
+
+
+function put_byte(s, b) {
+  s.pending_buf[s.pending++] = b;
+}
+
+
+/* =========================================================================
+ * Put a short in the pending buffer. The 16-bit value is put in MSB order.
+ * IN assertion: the stream state is correct and there is enough room in
+ * pending_buf.
+ */
+function putShortMSB(s, b) {
+//  put_byte(s, (Byte)(b >> 8));
+//  put_byte(s, (Byte)(b & 0xff));
+  s.pending_buf[s.pending++] = (b >>> 8) & 0xff;
+  s.pending_buf[s.pending++] = b & 0xff;
+}
+
+
+/* ===========================================================================
+ * Read a new buffer from the current input stream, update the adler32
+ * and total number of bytes read.  All deflate() input goes through
+ * this function so some applications may wish to modify it to avoid
+ * allocating a large strm->input buffer and copying from it.
+ * (See also flush_pending()).
+ */
+function read_buf(strm, buf, start, size) {
+  var len = strm.avail_in;
+
+  if (len > size) { len = size; }
+  if (len === 0) { return 0; }
+
+  strm.avail_in -= len;
+
+  // zmemcpy(buf, strm->next_in, len);
+  utils.arraySet(buf, strm.input, strm.next_in, len, start);
+  if (strm.state.wrap === 1) {
+    strm.adler = adler32(strm.adler, buf, len, start);
+  }
+
+  else if (strm.state.wrap === 2) {
+    strm.adler = crc32(strm.adler, buf, len, start);
+  }
+
+  strm.next_in += len;
+  strm.total_in += len;
+
+  return len;
+}
+
+
+/* ===========================================================================
+ * Set match_start to the longest match starting at the given string and
+ * return its length. Matches shorter or equal to prev_length are discarded,
+ * in which case the result is equal to prev_length and match_start is
+ * garbage.
+ * IN assertions: cur_match is the head of the hash chain for the current
+ *   string (strstart) and its distance is <= MAX_DIST, and prev_length >= 1
+ * OUT assertion: the match length is not greater than s->lookahead.
+ */
+function longest_match(s, cur_match) {
+  var chain_length = s.max_chain_length;      /* max hash chain length */
+  var scan = s.strstart; /* current string */
+  var match;                       /* matched string */
+  var len;                           /* length of current match */
+  var best_len = s.prev_length;              /* best match length so far */
+  var nice_match = s.nice_match;             /* stop if match long enough */
+  var limit = (s.strstart > (s.w_size - MIN_LOOKAHEAD)) ?
+      s.strstart - (s.w_size - MIN_LOOKAHEAD) : 0/*NIL*/;
+
+  var _win = s.window; // shortcut
+
+  var wmask = s.w_mask;
+  var prev  = s.prev;
+
+  /* Stop when cur_match becomes <= limit. To simplify the code,
+   * we prevent matches with the string of window index 0.
+   */
+
+  var strend = s.strstart + MAX_MATCH;
+  var scan_end1  = _win[scan + best_len - 1];
+  var scan_end   = _win[scan + best_len];
+
+  /* The code is optimized for HASH_BITS >= 8 and MAX_MATCH-2 multiple of 16.
+   * It is easy to get rid of this optimization if necessary.
+   */
+  // Assert(s->hash_bits >= 8 && MAX_MATCH == 258, "Code too clever");
+
+  /* Do not waste too much time if we already have a good match: */
+  if (s.prev_length >= s.good_match) {
+    chain_length >>= 2;
+  }
+  /* Do not look for matches beyond the end of the input. This is necessary
+   * to make deflate deterministic.
+   */
+  if (nice_match > s.lookahead) { nice_match = s.lookahead; }
+
+  // Assert((ulg)s->strstart <= s->window_size-MIN_LOOKAHEAD, "need lookahead");
+
+  do {
+    // Assert(cur_match < s->strstart, "no future");
+    match = cur_match;
+
+    /* Skip to next match if the match length cannot increase
+     * or if the match length is less than 2.  Note that the checks below
+     * for insufficient lookahead only occur occasionally for performance
+     * reasons.  Therefore uninitialized memory will be accessed, and
+     * conditional jumps will be made that depend on those values.
+     * However the length of the match is limited to the lookahead, so
+     * the output of deflate is not affected by the uninitialized values.
+     */
+
+    if (_win[match + best_len]     !== scan_end  ||
+        _win[match + best_len - 1] !== scan_end1 ||
+        _win[match]                !== _win[scan] ||
+        _win[++match]              !== _win[scan + 1]) {
+      continue;
+    }
+
+    /* The check at best_len-1 can be removed because it will be made
+     * again later. (This heuristic is not always a win.)
+     * It is not necessary to compare scan[2] and match[2] since they
+     * are always equal when the other bytes match, given that
+     * the hash keys are equal and that HASH_BITS >= 8.
+     */
+    scan += 2;
+    match++;
+    // Assert(*scan == *match, "match[2]?");
+
+    /* We check for insufficient lookahead only every 8th comparison;
+     * the 256th check will be made at strstart+258.
+     */
+    do {
+      // Do nothing
+    } while (_win[++scan] === _win[++match] && _win[++scan] === _win[++match] &&
+             _win[++scan] === _win[++match] && _win[++scan] === _win[++match] &&
+             _win[++scan] === _win[++match] && _win[++scan] === _win[++match] &&
+             _win[++scan] === _win[++match] && _win[++scan] === _win[++match] &&
+             scan < strend);
+
+    // Assert(scan <= s->window+(unsigned)(s->window_size-1), "wild scan");
+
+    len = MAX_MATCH - (strend - scan);
+    scan = strend - MAX_MATCH;
+
+    if (len > best_len) {
+      s.match_start = cur_match;
+      best_len = len;
+      if (len >= nice_match) {
+        break;
+      }
+      scan_end1  = _win[scan + best_len - 1];
+      scan_end   = _win[scan + best_len];
+    }
+  } while ((cur_match = prev[cur_match & wmask]) > limit && --chain_length !== 0);
+
+  if (best_len <= s.lookahead) {
+    return best_len;
+  }
+  return s.lookahead;
+}
+
+
+/* ===========================================================================
+ * Fill the window when the lookahead becomes insufficient.
+ * Updates strstart and lookahead.
+ *
+ * IN assertion: lookahead < MIN_LOOKAHEAD
+ * OUT assertions: strstart <= window_size-MIN_LOOKAHEAD
+ *    At least one byte has been read, or avail_in == 0; reads are
+ *    performed for at least two bytes (required for the zip translate_eol
+ *    option -- not supported here).
+ */
+function fill_window(s) {
+  var _w_size = s.w_size;
+  var p, n, m, more, str;
+
+  //Assert(s->lookahead < MIN_LOOKAHEAD, "already enough lookahead");
+
+  do {
+    more = s.window_size - s.lookahead - s.strstart;
+
+    // JS ints have 32 bit, block below not needed
+    /* Deal with !@#$% 64K limit: */
+    //if (sizeof(int) <= 2) {
+    //    if (more == 0 && s->strstart == 0 && s->lookahead == 0) {
+    //        more = wsize;
+    //
+    //  } else if (more == (unsigned)(-1)) {
+    //        /* Very unlikely, but possible on 16 bit machine if
+    //         * strstart == 0 && lookahead == 1 (input done a byte at time)
+    //         */
+    //        more--;
+    //    }
+    //}
+
+
+    /* If the window is almost full and there is insufficient lookahead,
+     * move the upper half to the lower one to make room in the upper half.
+     */
+    if (s.strstart >= _w_size + (_w_size - MIN_LOOKAHEAD)) {
+
+      utils.arraySet(s.window, s.window, _w_size, _w_size, 0);
+      s.match_start -= _w_size;
+      s.strstart -= _w_size;
+      /* we now have strstart >= MAX_DIST */
+      s.block_start -= _w_size;
+
+      /* Slide the hash table (could be avoided with 32 bit values
+       at the expense of memory usage). We slide even when level == 0
+       to keep the hash table consistent if we switch back to level > 0
+       later. (Using level 0 permanently is not an optimal usage of
+       zlib, so we don't care about this pathological case.)
+       */
+
+      n = s.hash_size;
+      p = n;
+      do {
+        m = s.head[--p];
+        s.head[p] = (m >= _w_size ? m - _w_size : 0);
+      } while (--n);
+
+      n = _w_size;
+      p = n;
+      do {
+        m = s.prev[--p];
+        s.prev[p] = (m >= _w_size ? m - _w_size : 0);
+        /* If n is not on any hash chain, prev[n] is garbage but
+         * its value will never be used.
+         */
+      } while (--n);
+
+      more += _w_size;
+    }
+    if (s.strm.avail_in === 0) {
+      break;
+    }
+
+    /* If there was no sliding:
+     *    strstart <= WSIZE+MAX_DIST-1 && lookahead <= MIN_LOOKAHEAD - 1 &&
+     *    more == window_size - lookahead - strstart
+     * => more >= window_size - (MIN_LOOKAHEAD-1 + WSIZE + MAX_DIST-1)
+     * => more >= window_size - 2*WSIZE + 2
+     * In the BIG_MEM or MMAP case (not yet supported),
+     *   window_size == input_size + MIN_LOOKAHEAD  &&
+     *   strstart + s->lookahead <= input_size => more >= MIN_LOOKAHEAD.
+     * Otherwise, window_size == 2*WSIZE so more >= 2.
+     * If there was sliding, more >= WSIZE. So in all cases, more >= 2.
+     */
+    //Assert(more >= 2, "more < 2");
+    n = read_buf(s.strm, s.window, s.strstart + s.lookahead, more);
+    s.lookahead += n;
+
+    /* Initialize the hash value now that we have some input: */
+    if (s.lookahead + s.insert >= MIN_MATCH) {
+      str = s.strstart - s.insert;
+      s.ins_h = s.window[str];
+
+      /* UPDATE_HASH(s, s->ins_h, s->window[str + 1]); */
+      s.ins_h = ((s.ins_h << s.hash_shift) ^ s.window[str + 1]) & s.hash_mask;
+//#if MIN_MATCH != 3
+//        Call update_hash() MIN_MATCH-3 more times
+//#endif
+      while (s.insert) {
+        /* UPDATE_HASH(s, s->ins_h, s->window[str + MIN_MATCH-1]); */
+        s.ins_h = ((s.ins_h << s.hash_shift) ^ s.window[str + MIN_MATCH - 1]) & s.hash_mask;
+
+        s.prev[str & s.w_mask] = s.head[s.ins_h];
+        s.head[s.ins_h] = str;
+        str++;
+        s.insert--;
+        if (s.lookahead + s.insert < MIN_MATCH) {
+          break;
+        }
+      }
+    }
+    /* If the whole input has less than MIN_MATCH bytes, ins_h is garbage,
+     * but this is not important since only literal bytes will be emitted.
+     */
+
+  } while (s.lookahead < MIN_LOOKAHEAD && s.strm.avail_in !== 0);
+
+  /* If the WIN_INIT bytes after the end of the current data have never been
+   * written, then zero those bytes in order to avoid memory check reports of
+   * the use of uninitialized (or uninitialised as Julian writes) bytes by
+   * the longest match routines.  Update the high water mark for the next
+   * time through here.  WIN_INIT is set to MAX_MATCH since the longest match
+   * routines allow scanning to strstart + MAX_MATCH, ignoring lookahead.
+   */
+//  if (s.high_water < s.window_size) {
+//    var curr = s.strstart + s.lookahead;
+//    var init = 0;
+//
+//    if (s.high_water < curr) {
+//      /* Previous high water mark below current data -- zero WIN_INIT
+//       * bytes or up to end of window, whichever is less.
+//       */
+//      init = s.window_size - curr;
+//      if (init > WIN_INIT)
+//        init = WIN_INIT;
+//      zmemzero(s->window + curr, (unsigned)init);
+//      s->high_water = curr + init;
+//    }
+//    else if (s->high_water < (ulg)curr + WIN_INIT) {
+//      /* High water mark at or above current data, but below current data
+//       * plus WIN_INIT -- zero out to current data plus WIN_INIT, or up
+//       * to end of window, whichever is less.
+//       */
+//      init = (ulg)curr + WIN_INIT - s->high_water;
+//      if (init > s->window_size - s->high_water)
+//        init = s->window_size - s->high_water;
+//      zmemzero(s->window + s->high_water, (unsigned)init);
+//      s->high_water += init;
+//    }
+//  }
+//
+//  Assert((ulg)s->strstart <= s->window_size - MIN_LOOKAHEAD,
+//    "not enough room for search");
+}
+
+/* ===========================================================================
+ * Copy without compression as much as possible from the input stream, return
+ * the current block state.
+ * This function does not insert new strings in the dictionary since
+ * uncompressible data is probably not useful. This function is used
+ * only for the level=0 compression option.
+ * NOTE: this function should be optimized to avoid extra copying from
+ * window to pending_buf.
+ */
+function deflate_stored(s, flush) {
+  /* Stored blocks are limited to 0xffff bytes, pending_buf is limited
+   * to pending_buf_size, and each stored block has a 5 byte header:
+   */
+  var max_block_size = 0xffff;
+
+  if (max_block_size > s.pending_buf_size - 5) {
+    max_block_size = s.pending_buf_size - 5;
+  }
+
+  /* Copy as much as possible from input to output: */
+  for (;;) {
+    /* Fill the window as much as possible: */
+    if (s.lookahead <= 1) {
+
+      //Assert(s->strstart < s->w_size+MAX_DIST(s) ||
+      //  s->block_start >= (long)s->w_size, "slide too late");
+//      if (!(s.strstart < s.w_size + (s.w_size - MIN_LOOKAHEAD) ||
+//        s.block_start >= s.w_size)) {
+//        throw  new Error("slide too late");
+//      }
+
+      fill_window(s);
+      if (s.lookahead === 0 && flush === Z_NO_FLUSH) {
+        return BS_NEED_MORE;
+      }
+
+      if (s.lookahead === 0) {
+        break;
+      }
+      /* flush the current block */
+    }
+    //Assert(s->block_start >= 0L, "block gone");
+//    if (s.block_start < 0) throw new Error("block gone");
+
+    s.strstart += s.lookahead;
+    s.lookahead = 0;
+
+    /* Emit a stored block if pending_buf will be full: */
+    var max_start = s.block_start + max_block_size;
+
+    if (s.strstart === 0 || s.strstart >= max_start) {
+      /* strstart == 0 is possible when wraparound on 16-bit machine */
+      s.lookahead = s.strstart - max_start;
+      s.strstart = max_start;
+      /*** FLUSH_BLOCK(s, 0); ***/
+      flush_block_only(s, false);
+      if (s.strm.avail_out === 0) {
+        return BS_NEED_MORE;
+      }
+      /***/
+
+
+    }
+    /* Flush if we may have to slide, otherwise block_start may become
+     * negative and the data will be gone:
+     */
+    if (s.strstart - s.block_start >= (s.w_size - MIN_LOOKAHEAD)) {
+      /*** FLUSH_BLOCK(s, 0); ***/
+      flush_block_only(s, false);
+      if (s.strm.avail_out === 0) {
+        return BS_NEED_MORE;
+      }
+      /***/
+    }
+  }
+
+  s.insert = 0;
+
+  if (flush === Z_FINISH) {
+    /*** FLUSH_BLOCK(s, 1); ***/
+    flush_block_only(s, true);
+    if (s.strm.avail_out === 0) {
+      return BS_FINISH_STARTED;
+    }
+    /***/
+    return BS_FINISH_DONE;
+  }
+
+  if (s.strstart > s.block_start) {
+    /*** FLUSH_BLOCK(s, 0); ***/
+    flush_block_only(s, false);
+    if (s.strm.avail_out === 0) {
+      return BS_NEED_MORE;
+    }
+    /***/
+  }
+
+  return BS_NEED_MORE;
+}
+
+/* ===========================================================================
+ * Compress as much as possible from the input stream, return the current
+ * block state.
+ * This function does not perform lazy evaluation of matches and inserts
+ * new strings in the dictionary only for unmatched strings or for short
+ * matches. It is used only for the fast compression options.
+ */
+function deflate_fast(s, flush) {
+  var hash_head;        /* head of the hash chain */
+  var bflush;           /* set if current block must be flushed */
+
+  for (;;) {
+    /* Make sure that we always have enough lookahead, except
+     * at the end of the input file. We need MAX_MATCH bytes
+     * for the next match, plus MIN_MATCH bytes to insert the
+     * string following the next match.
+     */
+    if (s.lookahead < MIN_LOOKAHEAD) {
+      fill_window(s);
+      if (s.lookahead < MIN_LOOKAHEAD && flush === Z_NO_FLUSH) {
+        return BS_NEED_MORE;
+      }
+      if (s.lookahead === 0) {
+        break; /* flush the current block */
+      }
+    }
+
+    /* Insert the string window[strstart .. strstart+2] in the
+     * dictionary, and set hash_head to the head of the hash chain:
+     */
+    hash_head = 0/*NIL*/;
+    if (s.lookahead >= MIN_MATCH) {
+      /*** INSERT_STRING(s, s.strstart, hash_head); ***/
+      s.ins_h = ((s.ins_h << s.hash_shift) ^ s.window[s.strstart + MIN_MATCH - 1]) & s.hash_mask;
+      hash_head = s.prev[s.strstart & s.w_mask] = s.head[s.ins_h];
+      s.head[s.ins_h] = s.strstart;
+      /***/
+    }
+
+    /* Find the longest match, discarding those <= prev_length.
+     * At this point we have always match_length < MIN_MATCH
+     */
+    if (hash_head !== 0/*NIL*/ && ((s.strstart - hash_head) <= (s.w_size - MIN_LOOKAHEAD))) {
+      /* To simplify the code, we prevent matches with the string
+       * of window index 0 (in particular we have to avoid a match
+       * of the string with itself at the start of the input file).
+       */
+      s.match_length = longest_match(s, hash_head);
+      /* longest_match() sets match_start */
+    }
+    if (s.match_length >= MIN_MATCH) {
+      // check_match(s, s.strstart, s.match_start, s.match_length); // for debug only
+
+      /*** _tr_tally_dist(s, s.strstart - s.match_start,
+                     s.match_length - MIN_MATCH, bflush); ***/
+      bflush = trees._tr_tally(s, s.strstart - s.match_start, s.match_length - MIN_MATCH);
+
+      s.lookahead -= s.match_length;
+
+      /* Insert new strings in the hash table only if the match length
+       * is not too large. This saves time but degrades compression.
+       */
+      if (s.match_length <= s.max_lazy_match/*max_insert_length*/ && s.lookahead >= MIN_MATCH) {
+        s.match_length--; /* string at strstart already in table */
+        do {
+          s.strstart++;
+          /*** INSERT_STRING(s, s.strstart, hash_head); ***/
+          s.ins_h = ((s.ins_h << s.hash_shift) ^ s.window[s.strstart + MIN_MATCH - 1]) & s.hash_mask;
+          hash_head = s.prev[s.strstart & s.w_mask] = s.head[s.ins_h];
+          s.head[s.ins_h] = s.strstart;
+          /***/
+          /* strstart never exceeds WSIZE-MAX_MATCH, so there are
+           * always MIN_MATCH bytes ahead.
+           */
+        } while (--s.match_length !== 0);
+        s.strstart++;
+      } else
+      {
+        s.strstart += s.match_length;
+        s.match_length = 0;
+        s.ins_h = s.window[s.strstart];
+        /* UPDATE_HASH(s, s.ins_h, s.window[s.strstart+1]); */
+        s.ins_h = ((s.ins_h << s.hash_shift) ^ s.window[s.strstart + 1]) & s.hash_mask;
+
+//#if MIN_MATCH != 3
+//                Call UPDATE_HASH() MIN_MATCH-3 more times
+//#endif
+        /* If lookahead < MIN_MATCH, ins_h is garbage, but it does not
+         * matter since it will be recomputed at next deflate call.
+         */
+      }
+    } else {
+      /* No match, output a literal byte */
+      //Tracevv((stderr,"%c", s.window[s.strstart]));
+      /*** _tr_tally_lit(s, s.window[s.strstart], bflush); ***/
+      bflush = trees._tr_tally(s, 0, s.window[s.strstart]);
+
+      s.lookahead--;
+      s.strstart++;
+    }
+    if (bflush) {
+      /*** FLUSH_BLOCK(s, 0); ***/
+      flush_block_only(s, false);
+      if (s.strm.avail_out === 0) {
+        return BS_NEED_MORE;
+      }
+      /***/
+    }
+  }
+  s.insert = ((s.strstart < (MIN_MATCH - 1)) ? s.strstart : MIN_MATCH - 1);
+  if (flush === Z_FINISH) {
+    /*** FLUSH_BLOCK(s, 1); ***/
+    flush_block_only(s, true);
+    if (s.strm.avail_out === 0) {
+      return BS_FINISH_STARTED;
+    }
+    /***/
+    return BS_FINISH_DONE;
+  }
+  if (s.last_lit) {
+    /*** FLUSH_BLOCK(s, 0); ***/
+    flush_block_only(s, false);
+    if (s.strm.avail_out === 0) {
+      return BS_NEED_MORE;
+    }
+    /***/
+  }
+  return BS_BLOCK_DONE;
+}
+
+/* ===========================================================================
+ * Same as above, but achieves better compression. We use a lazy
+ * evaluation for matches: a match is finally adopted only if there is
+ * no better match at the next window position.
+ */
+function deflate_slow(s, flush) {
+  var hash_head;          /* head of hash chain */
+  var bflush;              /* set if current block must be flushed */
+
+  var max_insert;
+
+  /* Process the input block. */
+  for (;;) {
+    /* Make sure that we always have enough lookahead, except
+     * at the end of the input file. We need MAX_MATCH bytes
+     * for the next match, plus MIN_MATCH bytes to insert the
+     * string following the next match.
+     */
+    if (s.lookahead < MIN_LOOKAHEAD) {
+      fill_window(s);
+      if (s.lookahead < MIN_LOOKAHEAD && flush === Z_NO_FLUSH) {
+        return BS_NEED_MORE;
+      }
+      if (s.lookahead === 0) { break; } /* flush the current block */
+    }
+
+    /* Insert the string window[strstart .. strstart+2] in the
+     * dictionary, and set hash_head to the head of the hash chain:
+     */
+    hash_head = 0/*NIL*/;
+    if (s.lookahead >= MIN_MATCH) {
+      /*** INSERT_STRING(s, s.strstart, hash_head); ***/
+      s.ins_h = ((s.ins_h << s.hash_shift) ^ s.window[s.strstart + MIN_MATCH - 1]) & s.hash_mask;
+      hash_head = s.prev[s.strstart & s.w_mask] = s.head[s.ins_h];
+      s.head[s.ins_h] = s.strstart;
+      /***/
+    }
+
+    /* Find the longest match, discarding those <= prev_length.
+     */
+    s.prev_length = s.match_length;
+    s.prev_match = s.match_start;
+    s.match_length = MIN_MATCH - 1;
+
+    if (hash_head !== 0/*NIL*/ && s.prev_length < s.max_lazy_match &&
+        s.strstart - hash_head <= (s.w_size - MIN_LOOKAHEAD)/*MAX_DIST(s)*/) {
+      /* To simplify the code, we prevent matches with the string
+       * of window index 0 (in particular we have to avoid a match
+       * of the string with itself at the start of the input file).
+       */
+      s.match_length = longest_match(s, hash_head);
+      /* longest_match() sets match_start */
+
+      if (s.match_length <= 5 &&
+         (s.strategy === Z_FILTERED || (s.match_length === MIN_MATCH && s.strstart - s.match_start > 4096/*TOO_FAR*/))) {
+
+        /* If prev_match is also MIN_MATCH, match_start is garbage
+         * but we will ignore the current match anyway.
+         */
+        s.match_length = MIN_MATCH - 1;
+      }
+    }
+    /* If there was a match at the previous step and the current
+     * match is not better, output the previous match:
+     */
+    if (s.prev_length >= MIN_MATCH && s.match_length <= s.prev_length) {
+      max_insert = s.strstart + s.lookahead - MIN_MATCH;
+      /* Do not insert strings in hash table beyond this. */
+
+      //check_match(s, s.strstart-1, s.prev_match, s.prev_length);
+
+      /***_tr_tally_dist(s, s.strstart - 1 - s.prev_match,
+                     s.prev_length - MIN_MATCH, bflush);***/
+      bflush = trees._tr_tally(s, s.strstart - 1 - s.prev_match, s.prev_length - MIN_MATCH);
+      /* Insert in hash table all strings up to the end of the match.
+       * strstart-1 and strstart are already inserted. If there is not
+       * enough lookahead, the last two strings are not inserted in
+       * the hash table.
+       */
+      s.lookahead -= s.prev_length - 1;
+      s.prev_length -= 2;
+      do {
+        if (++s.strstart <= max_insert) {
+          /*** INSERT_STRING(s, s.strstart, hash_head); ***/
+          s.ins_h = ((s.ins_h << s.hash_shift) ^ s.window[s.strstart + MIN_MATCH - 1]) & s.hash_mask;
+          hash_head = s.prev[s.strstart & s.w_mask] = s.head[s.ins_h];
+          s.head[s.ins_h] = s.strstart;
+          /***/
+        }
+      } while (--s.prev_length !== 0);
+      s.match_available = 0;
+      s.match_length = MIN_MATCH - 1;
+      s.strstart++;
+
+      if (bflush) {
+        /*** FLUSH_BLOCK(s, 0); ***/
+        flush_block_only(s, false);
+        if (s.strm.avail_out === 0) {
+          return BS_NEED_MORE;
+        }
+        /***/
+      }
+
+    } else if (s.match_available) {
+      /* If there was no match at the previous position, output a
+       * single literal. If there was a match but the current match
+       * is longer, truncate the previous match to a single literal.
+       */
+      //Tracevv((stderr,"%c", s->window[s->strstart-1]));
+      /*** _tr_tally_lit(s, s.window[s.strstart-1], bflush); ***/
+      bflush = trees._tr_tally(s, 0, s.window[s.strstart - 1]);
+
+      if (bflush) {
+        /*** FLUSH_BLOCK_ONLY(s, 0) ***/
+        flush_block_only(s, false);
+        /***/
+      }
+      s.strstart++;
+      s.lookahead--;
+      if (s.strm.avail_out === 0) {
+        return BS_NEED_MORE;
+      }
+    } else {
+      /* There is no previous match to compare with, wait for
+       * the next step to decide.
+       */
+      s.match_available = 1;
+      s.strstart++;
+      s.lookahead--;
+    }
+  }
+  //Assert (flush != Z_NO_FLUSH, "no flush?");
+  if (s.match_available) {
+    //Tracevv((stderr,"%c", s->window[s->strstart-1]));
+    /*** _tr_tally_lit(s, s.window[s.strstart-1], bflush); ***/
+    bflush = trees._tr_tally(s, 0, s.window[s.strstart - 1]);
+
+    s.match_available = 0;
+  }
+  s.insert = s.strstart < MIN_MATCH - 1 ? s.strstart : MIN_MATCH - 1;
+  if (flush === Z_FINISH) {
+    /*** FLUSH_BLOCK(s, 1); ***/
+    flush_block_only(s, true);
+    if (s.strm.avail_out === 0) {
+      return BS_FINISH_STARTED;
+    }
+    /***/
+    return BS_FINISH_DONE;
+  }
+  if (s.last_lit) {
+    /*** FLUSH_BLOCK(s, 0); ***/
+    flush_block_only(s, false);
+    if (s.strm.avail_out === 0) {
+      return BS_NEED_MORE;
+    }
+    /***/
+  }
+
+  return BS_BLOCK_DONE;
+}
+
+
+/* ===========================================================================
+ * For Z_RLE, simply look for runs of bytes, generate matches only of distance
+ * one.  Do not maintain a hash table.  (It will be regenerated if this run of
+ * deflate switches away from Z_RLE.)
+ */
+function deflate_rle(s, flush) {
+  var bflush;            /* set if current block must be flushed */
+  var prev;              /* byte at distance one to match */
+  var scan, strend;      /* scan goes up to strend for length of run */
+
+  var _win = s.window;
+
+  for (;;) {
+    /* Make sure that we always have enough lookahead, except
+     * at the end of the input file. We need MAX_MATCH bytes
+     * for the longest run, plus one for the unrolled loop.
+     */
+    if (s.lookahead <= MAX_MATCH) {
+      fill_window(s);
+      if (s.lookahead <= MAX_MATCH && flush === Z_NO_FLUSH) {
+        return BS_NEED_MORE;
+      }
+      if (s.lookahead === 0) { break; } /* flush the current block */
+    }
+
+    /* See how many times the previous byte repeats */
+    s.match_length = 0;
+    if (s.lookahead >= MIN_MATCH && s.strstart > 0) {
+      scan = s.strstart - 1;
+      prev = _win[scan];
+      if (prev === _win[++scan] && prev === _win[++scan] && prev === _win[++scan]) {
+        strend = s.strstart + MAX_MATCH;
+        do {
+          // Do nothing
+        } while (prev === _win[++scan] && prev === _win[++scan] &&
+                 prev === _win[++scan] && prev === _win[++scan] &&
+                 prev === _win[++scan] && prev === _win[++scan] &&
+                 prev === _win[++scan] && prev === _win[++scan] &&
+                 scan < strend);
+        s.match_length = MAX_MATCH - (strend - scan);
+        if (s.match_length > s.lookahead) {
+          s.match_length = s.lookahead;
+        }
+      }
+      //Assert(scan <= s->window+(uInt)(s->window_size-1), "wild scan");
+    }
+
+    /* Emit match if have run of MIN_MATCH or longer, else emit literal */
+    if (s.match_length >= MIN_MATCH) {
+      //check_match(s, s.strstart, s.strstart - 1, s.match_length);
+
+      /*** _tr_tally_dist(s, 1, s.match_length - MIN_MATCH, bflush); ***/
+      bflush = trees._tr_tally(s, 1, s.match_length - MIN_MATCH);
+
+      s.lookahead -= s.match_length;
+      s.strstart += s.match_length;
+      s.match_length = 0;
+    } else {
+      /* No match, output a literal byte */
+      //Tracevv((stderr,"%c", s->window[s->strstart]));
+      /*** _tr_tally_lit(s, s.window[s.strstart], bflush); ***/
+      bflush = trees._tr_tally(s, 0, s.window[s.strstart]);
+
+      s.lookahead--;
+      s.strstart++;
+    }
+    if (bflush) {
+      /*** FLUSH_BLOCK(s, 0); ***/
+      flush_block_only(s, false);
+      if (s.strm.avail_out === 0) {
+        return BS_NEED_MORE;
+      }
+      /***/
+    }
+  }
+  s.insert = 0;
+  if (flush === Z_FINISH) {
+    /*** FLUSH_BLOCK(s, 1); ***/
+    flush_block_only(s, true);
+    if (s.strm.avail_out === 0) {
+      return BS_FINISH_STARTED;
+    }
+    /***/
+    return BS_FINISH_DONE;
+  }
+  if (s.last_lit) {
+    /*** FLUSH_BLOCK(s, 0); ***/
+    flush_block_only(s, false);
+    if (s.strm.avail_out === 0) {
+      return BS_NEED_MORE;
+    }
+    /***/
+  }
+  return BS_BLOCK_DONE;
+}
+
+/* ===========================================================================
+ * For Z_HUFFMAN_ONLY, do not look for matches.  Do not maintain a hash table.
+ * (It will be regenerated if this run of deflate switches away from Huffman.)
+ */
+function deflate_huff(s, flush) {
+  var bflush;             /* set if current block must be flushed */
+
+  for (;;) {
+    /* Make sure that we have a literal to write. */
+    if (s.lookahead === 0) {
+      fill_window(s);
+      if (s.lookahead === 0) {
+        if (flush === Z_NO_FLUSH) {
+          return BS_NEED_MORE;
+        }
+        break;      /* flush the current block */
+      }
+    }
+
+    /* Output a literal byte */
+    s.match_length = 0;
+    //Tracevv((stderr,"%c", s->window[s->strstart]));
+    /*** _tr_tally_lit(s, s.window[s.strstart], bflush); ***/
+    bflush = trees._tr_tally(s, 0, s.window[s.strstart]);
+    s.lookahead--;
+    s.strstart++;
+    if (bflush) {
+      /*** FLUSH_BLOCK(s, 0); ***/
+      flush_block_only(s, false);
+      if (s.strm.avail_out === 0) {
+        return BS_NEED_MORE;
+      }
+      /***/
+    }
+  }
+  s.insert = 0;
+  if (flush === Z_FINISH) {
+    /*** FLUSH_BLOCK(s, 1); ***/
+    flush_block_only(s, true);
+    if (s.strm.avail_out === 0) {
+      return BS_FINISH_STARTED;
+    }
+    /***/
+    return BS_FINISH_DONE;
+  }
+  if (s.last_lit) {
+    /*** FLUSH_BLOCK(s, 0); ***/
+    flush_block_only(s, false);
+    if (s.strm.avail_out === 0) {
+      return BS_NEED_MORE;
+    }
+    /***/
+  }
+  return BS_BLOCK_DONE;
+}
+
+/* Values for max_lazy_match, good_match and max_chain_length, depending on
+ * the desired pack level (0..9). The values given below have been tuned to
+ * exclude worst case performance for pathological files. Better values may be
+ * found for specific files.
+ */
+function Config(good_length, max_lazy, nice_length, max_chain, func) {
+  this.good_length = good_length;
+  this.max_lazy = max_lazy;
+  this.nice_length = nice_length;
+  this.max_chain = max_chain;
+  this.func = func;
+}
+
+var configuration_table;
+
+configuration_table = [
+  /*      good lazy nice chain */
+  new Config(0, 0, 0, 0, deflate_stored),          /* 0 store only */
+  new Config(4, 4, 8, 4, deflate_fast),            /* 1 max speed, no lazy matches */
+  new Config(4, 5, 16, 8, deflate_fast),           /* 2 */
+  new Config(4, 6, 32, 32, deflate_fast),          /* 3 */
+
+  new Config(4, 4, 16, 16, deflate_slow),          /* 4 lazy matches */
+  new Config(8, 16, 32, 32, deflate_slow),         /* 5 */
+  new Config(8, 16, 128, 128, deflate_slow),       /* 6 */
+  new Config(8, 32, 128, 256, deflate_slow),       /* 7 */
+  new Config(32, 128, 258, 1024, deflate_slow),    /* 8 */
+  new Config(32, 258, 258, 4096, deflate_slow)     /* 9 max compression */
+];
+
+
+/* ===========================================================================
+ * Initialize the "longest match" routines for a new zlib stream
+ */
+function lm_init(s) {
+  s.window_size = 2 * s.w_size;
+
+  /*** CLEAR_HASH(s); ***/
+  zero(s.head); // Fill with NIL (= 0);
+
+  /* Set the default configuration parameters:
+   */
+  s.max_lazy_match = configuration_table[s.level].max_lazy;
+  s.good_match = configuration_table[s.level].good_length;
+  s.nice_match = configuration_table[s.level].nice_length;
+  s.max_chain_length = configuration_table[s.level].max_chain;
+
+  s.strstart = 0;
+  s.block_start = 0;
+  s.lookahead = 0;
+  s.insert = 0;
+  s.match_length = s.prev_length = MIN_MATCH - 1;
+  s.match_available = 0;
+  s.ins_h = 0;
+}
+
+
+function DeflateState() {
+  this.strm = null;            /* pointer back to this zlib stream */
+  this.status = 0;            /* as the name implies */
+  this.pending_buf = null;      /* output still pending */
+  this.pending_buf_size = 0;  /* size of pending_buf */
+  this.pending_out = 0;       /* next pending byte to output to the stream */
+  this.pending = 0;           /* nb of bytes in the pending buffer */
+  this.wrap = 0;              /* bit 0 true for zlib, bit 1 true for gzip */
+  this.gzhead = null;         /* gzip header information to write */
+  this.gzindex = 0;           /* where in extra, name, or comment */
+  this.method = Z_DEFLATED; /* can only be DEFLATED */
+  this.last_flush = -1;   /* value of flush param for previous deflate call */
+
+  this.w_size = 0;  /* LZ77 window size (32K by default) */
+  this.w_bits = 0;  /* log2(w_size)  (8..16) */
+  this.w_mask = 0;  /* w_size - 1 */
+
+  this.window = null;
+  /* Sliding window. Input bytes are read into the second half of the window,
+   * and move to the first half later to keep a dictionary of at least wSize
+   * bytes. With this organization, matches are limited to a distance of
+   * wSize-MAX_MATCH bytes, but this ensures that IO is always
+   * performed with a length multiple of the block size.
+   */
+
+  this.window_size = 0;
+  /* Actual size of window: 2*wSize, except when the user input buffer
+   * is directly used as sliding window.
+   */
+
+  this.prev = null;
+  /* Link to older string with same hash index. To limit the size of this
+   * array to 64K, this link is maintained only for the last 32K strings.
+   * An index in this array is thus a window index modulo 32K.
+   */
+
+  this.head = null;   /* Heads of the hash chains or NIL. */
+
+  this.ins_h = 0;       /* hash index of string to be inserted */
+  this.hash_size = 0;   /* number of elements in hash table */
+  this.hash_bits = 0;   /* log2(hash_size) */
+  this.hash_mask = 0;   /* hash_size-1 */
+
+  this.hash_shift = 0;
+  /* Number of bits by which ins_h must be shifted at each input
+   * step. It must be such that after MIN_MATCH steps, the oldest
+   * byte no longer takes part in the hash key, that is:
+   *   hash_shift * MIN_MATCH >= hash_bits
+   */
+
+  this.block_start = 0;
+  /* Window position at the beginning of the current output block. Gets
+   * negative when the window is moved backwards.
+   */
+
+  this.match_length = 0;      /* length of best match */
+  this.prev_match = 0;        /* previous match */
+  this.match_available = 0;   /* set if previous match exists */
+  this.strstart = 0;          /* start of string to insert */
+  this.match_start = 0;       /* start of matching string */
+  this.lookahead = 0;         /* number of valid bytes ahead in window */
+
+  this.prev_length = 0;
+  /* Length of the best match at previous step. Matches not greater than this
+   * are discarded. This is used in the lazy match evaluation.
+   */
+
+  this.max_chain_length = 0;
+  /* To speed up deflation, hash chains are never searched beyond this
+   * length.  A higher limit improves compression ratio but degrades the
+   * speed.
+   */
+
+  this.max_lazy_match = 0;
+  /* Attempt to find a better match only when the current match is strictly
+   * smaller than this value. This mechanism is used only for compression
+   * levels >= 4.
+   */
+  // That's alias to max_lazy_match, don't use directly
+  //this.max_insert_length = 0;
+  /* Insert new strings in the hash table only if the match length is not
+   * greater than this length. This saves time but degrades compression.
+   * max_insert_length is used only for compression levels <= 3.
+   */
+
+  this.level = 0;     /* compression level (1..9) */
+  this.strategy = 0;  /* favor or force Huffman coding*/
+
+  this.good_match = 0;
+  /* Use a faster search when the previous match is longer than this */
+
+  this.nice_match = 0; /* Stop searching when current match exceeds this */
+
+              /* used by trees.c: */
+
+  /* Didn't use ct_data typedef below to suppress compiler warning */
+
+  // struct ct_data_s dyn_ltree[HEAP_SIZE];   /* literal and length tree */
+  // struct ct_data_s dyn_dtree[2*D_CODES+1]; /* distance tree */
+  // struct ct_data_s bl_tree[2*BL_CODES+1];  /* Huffman tree for bit lengths */
+
+  // Use flat array of DOUBLE size, with interleaved fata,
+  // because JS does not support effective
+  this.dyn_ltree  = new utils.Buf16(HEAP_SIZE * 2);
+  this.dyn_dtree  = new utils.Buf16((2 * D_CODES + 1) * 2);
+  this.bl_tree    = new utils.Buf16((2 * BL_CODES + 1) * 2);
+  zero(this.dyn_ltree);
+  zero(this.dyn_dtree);
+  zero(this.bl_tree);
+
+  this.l_desc   = null;         /* desc. for literal tree */
+  this.d_desc   = null;         /* desc. for distance tree */
+  this.bl_desc  = null;         /* desc. for bit length tree */
+
+  //ush bl_count[MAX_BITS+1];
+  this.bl_count = new utils.Buf16(MAX_BITS + 1);
+  /* number of codes at each bit length for an optimal tree */
+
+  //int heap[2*L_CODES+1];      /* heap used to build the Huffman trees */
+  this.heap = new utils.Buf16(2 * L_CODES + 1);  /* heap used to build the Huffman trees */
+  zero(this.heap);
+
+  this.heap_len = 0;               /* number of elements in the heap */
+  this.heap_max = 0;               /* element of largest frequency */
+  /* The sons of heap[n] are heap[2*n] and heap[2*n+1]. heap[0] is not used.
+   * The same heap array is used to build all trees.
+   */
+
+  this.depth = new utils.Buf16(2 * L_CODES + 1); //uch depth[2*L_CODES+1];
+  zero(this.depth);
+  /* Depth of each subtree used as tie breaker for trees of equal frequency
+   */
+
+  this.l_buf = 0;          /* buffer index for literals or lengths */
+
+  this.lit_bufsize = 0;
+  /* Size of match buffer for literals/lengths.  There are 4 reasons for
+   * limiting lit_bufsize to 64K:
+   *   - frequencies can be kept in 16 bit counters
+   *   - if compression is not successful for the first block, all input
+   *     data is still in the window so we can still emit a stored block even
+   *     when input comes from standard input.  (This can also be done for
+   *     all blocks if lit_bufsize is not greater than 32K.)
+   *   - if compression is not successful for a file smaller than 64K, we can
+   *     even emit a stored file instead of a stored block (saving 5 bytes).
+   *     This is applicable only for zip (not gzip or zlib).
+   *   - creating new Huffman trees less frequently may not provide fast
+   *     adaptation to changes in the input data statistics. (Take for
+   *     example a binary file with poorly compressible code followed by
+   *     a highly compressible string table.) Smaller buffer sizes give
+   *     fast adaptation but have of course the overhead of transmitting
+   *     trees more frequently.
+   *   - I can't count above 4
+   */
+
+  this.last_lit = 0;      /* running index in l_buf */
+
+  this.d_buf = 0;
+  /* Buffer index for distances. To simplify the code, d_buf and l_buf have
+   * the same number of elements. To use different lengths, an extra flag
+   * array would be necessary.
+   */
+
+  this.opt_len = 0;       /* bit length of current block with optimal trees */
+  this.static_len = 0;    /* bit length of current block with static trees */
+  this.matches = 0;       /* number of string matches in current block */
+  this.insert = 0;        /* bytes at end of window left to insert */
+
+
+  this.bi_buf = 0;
+  /* Output buffer. bits are inserted starting at the bottom (least
+   * significant bits).
+   */
+  this.bi_valid = 0;
+  /* Number of valid bits in bi_buf.  All bits above the last valid bit
+   * are always zero.
+   */
+
+  // Used for window memory init. We safely ignore it for JS. That makes
+  // sense only for pointers and memory check tools.
+  //this.high_water = 0;
+  /* High water mark offset in window for initialized bytes -- bytes above
+   * this are set to zero in order to avoid memory check warnings when
+   * longest match routines access bytes past the input.  This is then
+   * updated to the new high water mark.
+   */
+}
+
+
+function deflateResetKeep(strm) {
+  var s;
+
+  if (!strm || !strm.state) {
+    return err(strm, Z_STREAM_ERROR);
+  }
+
+  strm.total_in = strm.total_out = 0;
+  strm.data_type = Z_UNKNOWN;
+
+  s = strm.state;
+  s.pending = 0;
+  s.pending_out = 0;
+
+  if (s.wrap < 0) {
+    s.wrap = -s.wrap;
+    /* was made negative by deflate(..., Z_FINISH); */
+  }
+  s.status = (s.wrap ? INIT_STATE : BUSY_STATE);
+  strm.adler = (s.wrap === 2) ?
+    0  // crc32(0, Z_NULL, 0)
+  :
+    1; // adler32(0, Z_NULL, 0)
+  s.last_flush = Z_NO_FLUSH;
+  trees._tr_init(s);
+  return Z_OK;
+}
+
+
+function deflateReset(strm) {
+  var ret = deflateResetKeep(strm);
+  if (ret === Z_OK) {
+    lm_init(strm.state);
+  }
+  return ret;
+}
+
+
+function deflateSetHeader(strm, head) {
+  if (!strm || !strm.state) { return Z_STREAM_ERROR; }
+  if (strm.state.wrap !== 2) { return Z_STREAM_ERROR; }
+  strm.state.gzhead = head;
+  return Z_OK;
+}
+
+
+function deflateInit2(strm, level, method, windowBits, memLevel, strategy) {
+  if (!strm) { // === Z_NULL
+    return Z_STREAM_ERROR;
+  }
+  var wrap = 1;
+
+  if (level === Z_DEFAULT_COMPRESSION) {
+    level = 6;
+  }
+
+  if (windowBits < 0) { /* suppress zlib wrapper */
+    wrap = 0;
+    windowBits = -windowBits;
+  }
+
+  else if (windowBits > 15) {
+    wrap = 2;           /* write gzip wrapper instead */
+    windowBits -= 16;
+  }
+
+
+  if (memLevel < 1 || memLevel > MAX_MEM_LEVEL || method !== Z_DEFLATED ||
+    windowBits < 8 || windowBits > 15 || level < 0 || level > 9 ||
+    strategy < 0 || strategy > Z_FIXED) {
+    return err(strm, Z_STREAM_ERROR);
+  }
+
+
+  if (windowBits === 8) {
+    windowBits = 9;
+  }
+  /* until 256-byte window bug fixed */
+
+  var s = new DeflateState();
+
+  strm.state = s;
+  s.strm = strm;
+
+  s.wrap = wrap;
+  s.gzhead = null;
+  s.w_bits = windowBits;
+  s.w_size = 1 << s.w_bits;
+  s.w_mask = s.w_size - 1;
+
+  s.hash_bits = memLevel + 7;
+  s.hash_size = 1 << s.hash_bits;
+  s.hash_mask = s.hash_size - 1;
+  s.hash_shift = ~~((s.hash_bits + MIN_MATCH - 1) / MIN_MATCH);
+
+  s.window = new utils.Buf8(s.w_size * 2);
+  s.head = new utils.Buf16(s.hash_size);
+  s.prev = new utils.Buf16(s.w_size);
+
+  // Don't need mem init magic for JS.
+  //s.high_water = 0;  /* nothing written to s->window yet */
+
+  s.lit_bufsize = 1 << (memLevel + 6); /* 16K elements by default */
+
+  s.pending_buf_size = s.lit_bufsize * 4;
+
+  //overlay = (ushf *) ZALLOC(strm, s->lit_bufsize, sizeof(ush)+2);
+  //s->pending_buf = (uchf *) overlay;
+  s.pending_buf = new utils.Buf8(s.pending_buf_size);
+
+  // It is offset from `s.pending_buf` (size is `s.lit_bufsize * 2`)
+  //s->d_buf = overlay + s->lit_bufsize/sizeof(ush);
+  s.d_buf = 1 * s.lit_bufsize;
+
+  //s->l_buf = s->pending_buf + (1+sizeof(ush))*s->lit_bufsize;
+  s.l_buf = (1 + 2) * s.lit_bufsize;
+
+  s.level = level;
+  s.strategy = strategy;
+  s.method = method;
+
+  return deflateReset(strm);
+}
+
+function deflateInit(strm, level) {
+  return deflateInit2(strm, level, Z_DEFLATED, MAX_WBITS, DEF_MEM_LEVEL, Z_DEFAULT_STRATEGY);
+}
+
+
+function deflate(strm, flush) {
+  var old_flush, s;
+  var beg, val; // for gzip header write only
+
+  if (!strm || !strm.state ||
+    flush > Z_BLOCK || flush < 0) {
+    return strm ? err(strm, Z_STREAM_ERROR) : Z_STREAM_ERROR;
+  }
+
+  s = strm.state;
+
+  if (!strm.output ||
+      (!strm.input && strm.avail_in !== 0) ||
+      (s.status === FINISH_STATE && flush !== Z_FINISH)) {
+    return err(strm, (strm.avail_out === 0) ? Z_BUF_ERROR : Z_STREAM_ERROR);
+  }
+
+  s.strm = strm; /* just in case */
+  old_flush = s.last_flush;
+  s.last_flush = flush;
+
+  /* Write the header */
+  if (s.status === INIT_STATE) {
+
+    if (s.wrap === 2) { // GZIP header
+      strm.adler = 0;  //crc32(0L, Z_NULL, 0);
+      put_byte(s, 31);
+      put_byte(s, 139);
+      put_byte(s, 8);
+      if (!s.gzhead) { // s->gzhead == Z_NULL
+        put_byte(s, 0);
+        put_byte(s, 0);
+        put_byte(s, 0);
+        put_byte(s, 0);
+        put_byte(s, 0);
+        put_byte(s, s.level === 9 ? 2 :
+                    (s.strategy >= Z_HUFFMAN_ONLY || s.level < 2 ?
+                     4 : 0));
+        put_byte(s, OS_CODE);
+        s.status = BUSY_STATE;
+      }
+      else {
+        put_byte(s, (s.gzhead.text ? 1 : 0) +
+                    (s.gzhead.hcrc ? 2 : 0) +
+                    (!s.gzhead.extra ? 0 : 4) +
+                    (!s.gzhead.name ? 0 : 8) +
+                    (!s.gzhead.comment ? 0 : 16)
+                );
+        put_byte(s, s.gzhead.time & 0xff);
+        put_byte(s, (s.gzhead.time >> 8) & 0xff);
+        put_byte(s, (s.gzhead.time >> 16) & 0xff);
+        put_byte(s, (s.gzhead.time >> 24) & 0xff);
+        put_byte(s, s.level === 9 ? 2 :
+                    (s.strategy >= Z_HUFFMAN_ONLY || s.level < 2 ?
+                     4 : 0));
+        put_byte(s, s.gzhead.os & 0xff);
+        if (s.gzhead.extra && s.gzhead.extra.length) {
+          put_byte(s, s.gzhead.extra.length & 0xff);
+          put_byte(s, (s.gzhead.extra.length >> 8) & 0xff);
+        }
+        if (s.gzhead.hcrc) {
+          strm.adler = crc32(strm.adler, s.pending_buf, s.pending, 0);
+        }
+        s.gzindex = 0;
+        s.status = EXTRA_STATE;
+      }
+    }
+    else // DEFLATE header
+    {
+      var header = (Z_DEFLATED + ((s.w_bits - 8) << 4)) << 8;
+      var level_flags = -1;
+
+      if (s.strategy >= Z_HUFFMAN_ONLY || s.level < 2) {
+        level_flags = 0;
+      } else if (s.level < 6) {
+        level_flags = 1;
+      } else if (s.level === 6) {
+        level_flags = 2;
+      } else {
+        level_flags = 3;
+      }
+      header |= (level_flags << 6);
+      if (s.strstart !== 0) { header |= PRESET_DICT; }
+      header += 31 - (header % 31);
+
+      s.status = BUSY_STATE;
+      putShortMSB(s, header);
+
+      /* Save the adler32 of the preset dictionary: */
+      if (s.strstart !== 0) {
+        putShortMSB(s, strm.adler >>> 16);
+        putShortMSB(s, strm.adler & 0xffff);
+      }
+      strm.adler = 1; // adler32(0L, Z_NULL, 0);
+    }
+  }
+
+//#ifdef GZIP
+  if (s.status === EXTRA_STATE) {
+    if (s.gzhead.extra/* != Z_NULL*/) {
+      beg = s.pending;  /* start of bytes to update crc */
+
+      while (s.gzindex < (s.gzhead.extra.length & 0xffff)) {
+        if (s.pending === s.pending_buf_size) {
+          if (s.gzhead.hcrc && s.pending > beg) {
+            strm.adler = crc32(strm.adler, s.pending_buf, s.pending - beg, beg);
+          }
+          flush_pending(strm);
+          beg = s.pending;
+          if (s.pending === s.pending_buf_size) {
+            break;
+          }
+        }
+        put_byte(s, s.gzhead.extra[s.gzindex] & 0xff);
+        s.gzindex++;
+      }
+      if (s.gzhead.hcrc && s.pending > beg) {
+        strm.adler = crc32(strm.adler, s.pending_buf, s.pending - beg, beg);
+      }
+      if (s.gzindex === s.gzhead.extra.length) {
+        s.gzindex = 0;
+        s.status = NAME_STATE;
+      }
+    }
+    else {
+      s.status = NAME_STATE;
+    }
+  }
+  if (s.status === NAME_STATE) {
+    if (s.gzhead.name/* != Z_NULL*/) {
+      beg = s.pending;  /* start of bytes to update crc */
+      //int val;
+
+      do {
+        if (s.pending === s.pending_buf_size) {
+          if (s.gzhead.hcrc && s.pending > beg) {
+            strm.adler = crc32(strm.adler, s.pending_buf, s.pending - beg, beg);
+          }
+          flush_pending(strm);
+          beg = s.pending;
+          if (s.pending === s.pending_buf_size) {
+            val = 1;
+            break;
+          }
+        }
+        // JS specific: little magic to add zero terminator to end of string
+        if (s.gzindex < s.gzhead.name.length) {
+          val = s.gzhead.name.charCodeAt(s.gzindex++) & 0xff;
+        } else {
+          val = 0;
+        }
+        put_byte(s, val);
+      } while (val !== 0);
+
+      if (s.gzhead.hcrc && s.pending > beg) {
+        strm.adler = crc32(strm.adler, s.pending_buf, s.pending - beg, beg);
+      }
+      if (val === 0) {
+        s.gzindex = 0;
+        s.status = COMMENT_STATE;
+      }
+    }
+    else {
+      s.status = COMMENT_STATE;
+    }
+  }
+  if (s.status === COMMENT_STATE) {
+    if (s.gzhead.comment/* != Z_NULL*/) {
+      beg = s.pending;  /* start of bytes to update crc */
+      //int val;
+
+      do {
+        if (s.pending === s.pending_buf_size) {
+          if (s.gzhead.hcrc && s.pending > beg) {
+            strm.adler = crc32(strm.adler, s.pending_buf, s.pending - beg, beg);
+          }
+          flush_pending(strm);
+          beg = s.pending;
+          if (s.pending === s.pending_buf_size) {
+            val = 1;
+            break;
+          }
+        }
+        // JS specific: little magic to add zero terminator to end of string
+        if (s.gzindex < s.gzhead.comment.length) {
+          val = s.gzhead.comment.charCodeAt(s.gzindex++) & 0xff;
+        } else {
+          val = 0;
+        }
+        put_byte(s, val);
+      } while (val !== 0);
+
+      if (s.gzhead.hcrc && s.pending > beg) {
+        strm.adler = crc32(strm.adler, s.pending_buf, s.pending - beg, beg);
+      }
+      if (val === 0) {
+        s.status = HCRC_STATE;
+      }
+    }
+    else {
+      s.status = HCRC_STATE;
+    }
+  }
+  if (s.status === HCRC_STATE) {
+    if (s.gzhead.hcrc) {
+      if (s.pending + 2 > s.pending_buf_size) {
+        flush_pending(strm);
+      }
+      if (s.pending + 2 <= s.pending_buf_size) {
+        put_byte(s, strm.adler & 0xff);
+        put_byte(s, (strm.adler >> 8) & 0xff);
+        strm.adler = 0; //crc32(0L, Z_NULL, 0);
+        s.status = BUSY_STATE;
+      }
+    }
+    else {
+      s.status = BUSY_STATE;
+    }
+  }
+//#endif
+
+  /* Flush as much pending output as possible */
+  if (s.pending !== 0) {
+    flush_pending(strm);
+    if (strm.avail_out === 0) {
+      /* Since avail_out is 0, deflate will be called again with
+       * more output space, but possibly with both pending and
+       * avail_in equal to zero. There won't be anything to do,
+       * but this is not an error situation so make sure we
+       * return OK instead of BUF_ERROR at next call of deflate:
+       */
+      s.last_flush = -1;
+      return Z_OK;
+    }
+
+    /* Make sure there is something to do and avoid duplicate consecutive
+     * flushes. For repeated and useless calls with Z_FINISH, we keep
+     * returning Z_STREAM_END instead of Z_BUF_ERROR.
+     */
+  } else if (strm.avail_in === 0 && rank(flush) <= rank(old_flush) &&
+    flush !== Z_FINISH) {
+    return err(strm, Z_BUF_ERROR);
+  }
+
+  /* User must not provide more input after the first FINISH: */
+  if (s.status === FINISH_STATE && strm.avail_in !== 0) {
+    return err(strm, Z_BUF_ERROR);
+  }
+
+  /* Start a new block or continue the current one.
+   */
+  if (strm.avail_in !== 0 || s.lookahead !== 0 ||
+    (flush !== Z_NO_FLUSH && s.status !== FINISH_STATE)) {
+    var bstate = (s.strategy === Z_HUFFMAN_ONLY) ? deflate_huff(s, flush) :
+      (s.strategy === Z_RLE ? deflate_rle(s, flush) :
+        configuration_table[s.level].func(s, flush));
+
+    if (bstate === BS_FINISH_STARTED || bstate === BS_FINISH_DONE) {
+      s.status = FINISH_STATE;
+    }
+    if (bstate === BS_NEED_MORE || bstate === BS_FINISH_STARTED) {
+      if (strm.avail_out === 0) {
+        s.last_flush = -1;
+        /* avoid BUF_ERROR next call, see above */
+      }
+      return Z_OK;
+      /* If flush != Z_NO_FLUSH && avail_out == 0, the next call
+       * of deflate should use the same flush parameter to make sure
+       * that the flush is complete. So we don't have to output an
+       * empty block here, this will be done at next call. This also
+       * ensures that for a very small output buffer, we emit at most
+       * one empty block.
+       */
+    }
+    if (bstate === BS_BLOCK_DONE) {
+      if (flush === Z_PARTIAL_FLUSH) {
+        trees._tr_align(s);
+      }
+      else if (flush !== Z_BLOCK) { /* FULL_FLUSH or SYNC_FLUSH */
+
+        trees._tr_stored_block(s, 0, 0, false);
+        /* For a full flush, this empty block will be recognized
+         * as a special marker by inflate_sync().
+         */
+        if (flush === Z_FULL_FLUSH) {
+          /*** CLEAR_HASH(s); ***/             /* forget history */
+          zero(s.head); // Fill with NIL (= 0);
+
+          if (s.lookahead === 0) {
+            s.strstart = 0;
+            s.block_start = 0;
+            s.insert = 0;
+          }
+        }
+      }
+      flush_pending(strm);
+      if (strm.avail_out === 0) {
+        s.last_flush = -1; /* avoid BUF_ERROR at next call, see above */
+        return Z_OK;
+      }
+    }
+  }
+  //Assert(strm->avail_out > 0, "bug2");
+  //if (strm.avail_out <= 0) { throw new Error("bug2");}
+
+  if (flush !== Z_FINISH) { return Z_OK; }
+  if (s.wrap <= 0) { return Z_STREAM_END; }
+
+  /* Write the trailer */
+  if (s.wrap === 2) {
+    put_byte(s, strm.adler & 0xff);
+    put_byte(s, (strm.adler >> 8) & 0xff);
+    put_byte(s, (strm.adler >> 16) & 0xff);
+    put_byte(s, (strm.adler >> 24) & 0xff);
+    put_byte(s, strm.total_in & 0xff);
+    put_byte(s, (strm.total_in >> 8) & 0xff);
+    put_byte(s, (strm.total_in >> 16) & 0xff);
+    put_byte(s, (strm.total_in >> 24) & 0xff);
+  }
+  else
+  {
+    putShortMSB(s, strm.adler >>> 16);
+    putShortMSB(s, strm.adler & 0xffff);
+  }
+
+  flush_pending(strm);
+  /* If avail_out is zero, the application will call deflate again
+   * to flush the rest.
+   */
+  if (s.wrap > 0) { s.wrap = -s.wrap; }
+  /* write the trailer only once! */
+  return s.pending !== 0 ? Z_OK : Z_STREAM_END;
+}
+
+function deflateEnd(strm) {
+  var status;
+
+  if (!strm/*== Z_NULL*/ || !strm.state/*== Z_NULL*/) {
+    return Z_STREAM_ERROR;
+  }
+
+  status = strm.state.status;
+  if (status !== INIT_STATE &&
+    status !== EXTRA_STATE &&
+    status !== NAME_STATE &&
+    status !== COMMENT_STATE &&
+    status !== HCRC_STATE &&
+    status !== BUSY_STATE &&
+    status !== FINISH_STATE
+  ) {
+    return err(strm, Z_STREAM_ERROR);
+  }
+
+  strm.state = null;
+
+  return status === BUSY_STATE ? err(strm, Z_DATA_ERROR) : Z_OK;
+}
+
+
+/* =========================================================================
+ * Initializes the compression dictionary from the given byte
+ * sequence without producing any compressed output.
+ */
+function deflateSetDictionary(strm, dictionary) {
+  var dictLength = dictionary.length;
+
+  var s;
+  var str, n;
+  var wrap;
+  var avail;
+  var next;
+  var input;
+  var tmpDict;
+
+  if (!strm/*== Z_NULL*/ || !strm.state/*== Z_NULL*/) {
+    return Z_STREAM_ERROR;
+  }
+
+  s = strm.state;
+  wrap = s.wrap;
+
+  if (wrap === 2 || (wrap === 1 && s.status !== INIT_STATE) || s.lookahead) {
+    return Z_STREAM_ERROR;
+  }
+
+  /* when using zlib wrappers, compute Adler-32 for provided dictionary */
+  if (wrap === 1) {
+    /* adler32(strm->adler, dictionary, dictLength); */
+    strm.adler = adler32(strm.adler, dictionary, dictLength, 0);
+  }
+
+  s.wrap = 0;   /* avoid computing Adler-32 in read_buf */
+
+  /* if dictionary would fill window, just replace the history */
+  if (dictLength >= s.w_size) {
+    if (wrap === 0) {            /* already empty otherwise */
+      /*** CLEAR_HASH(s); ***/
+      zero(s.head); // Fill with NIL (= 0);
+      s.strstart = 0;
+      s.block_start = 0;
+      s.insert = 0;
+    }
+    /* use the tail */
+    // dictionary = dictionary.slice(dictLength - s.w_size);
+    tmpDict = new utils.Buf8(s.w_size);
+    utils.arraySet(tmpDict, dictionary, dictLength - s.w_size, s.w_size, 0);
+    dictionary = tmpDict;
+    dictLength = s.w_size;
+  }
+  /* insert dictionary into window and hash */
+  avail = strm.avail_in;
+  next = strm.next_in;
+  input = strm.input;
+  strm.avail_in = dictLength;
+  strm.next_in = 0;
+  strm.input = dictionary;
+  fill_window(s);
+  while (s.lookahead >= MIN_MATCH) {
+    str = s.strstart;
+    n = s.lookahead - (MIN_MATCH - 1);
+    do {
+      /* UPDATE_HASH(s, s->ins_h, s->window[str + MIN_MATCH-1]); */
+      s.ins_h = ((s.ins_h << s.hash_shift) ^ s.window[str + MIN_MATCH - 1]) & s.hash_mask;
+
+      s.prev[str & s.w_mask] = s.head[s.ins_h];
+
+      s.head[s.ins_h] = str;
+      str++;
+    } while (--n);
+    s.strstart = str;
+    s.lookahead = MIN_MATCH - 1;
+    fill_window(s);
+  }
+  s.strstart += s.lookahead;
+  s.block_start = s.strstart;
+  s.insert = s.lookahead;
+  s.lookahead = 0;
+  s.match_length = s.prev_length = MIN_MATCH - 1;
+  s.match_available = 0;
+  strm.next_in = next;
+  strm.input = input;
+  strm.avail_in = avail;
+  s.wrap = wrap;
+  return Z_OK;
+}
+
+
+export { deflateInit, deflateInit2, deflateReset, deflateResetKeep, deflateSetHeader, deflate, deflateEnd, deflateSetDictionary };
+export var deflateInfo = 'pako deflate (from Nodeca project)';
+
+/* Not implemented
+exports.deflateBound = deflateBound;
+exports.deflateCopy = deflateCopy;
+exports.deflateParams = deflateParams;
+exports.deflatePending = deflatePending;
+exports.deflatePrime = deflatePrime;
+exports.deflateTune = deflateTune;
+*/
pkg/web/noVNC/vendor/pako/lib/zlib/gzheader.js
@@ -0,0 +1,35 @@
+export default function GZheader() {
+  /* true if compressed data believed to be text */
+  this.text       = 0;
+  /* modification time */
+  this.time       = 0;
+  /* extra flags (not used when writing a gzip file) */
+  this.xflags     = 0;
+  /* operating system */
+  this.os         = 0;
+  /* pointer to extra field or Z_NULL if none */
+  this.extra      = null;
+  /* extra field length (valid if extra != Z_NULL) */
+  this.extra_len  = 0; // Actually, we don't need it in JS,
+                       // but leave for few code modifications
+
+  //
+  // Setup limits is not necessary because in js we should not preallocate memory
+  // for inflate use constant limit in 65536 bytes
+  //
+
+  /* space at extra (only when reading header) */
+  // this.extra_max  = 0;
+  /* pointer to zero-terminated file name or Z_NULL */
+  this.name       = '';
+  /* space at name (only when reading header) */
+  // this.name_max   = 0;
+  /* pointer to zero-terminated comment or Z_NULL */
+  this.comment    = '';
+  /* space at comment (only when reading header) */
+  // this.comm_max   = 0;
+  /* true if there was or will be a header crc */
+  this.hcrc       = 0;
+  /* true when done reading gzip header (not used when writing a gzip file) */
+  this.done       = false;
+}
pkg/web/noVNC/vendor/pako/lib/zlib/inffast.js
@@ -0,0 +1,324 @@
+// See state defs from inflate.js
+var BAD = 30;       /* got a data error -- remain here until reset */
+var TYPE = 12;      /* i: waiting for type bits, including last-flag bit */
+
+/*
+   Decode literal, length, and distance codes and write out the resulting
+   literal and match bytes until either not enough input or output is
+   available, an end-of-block is encountered, or a data error is encountered.
+   When large enough input and output buffers are supplied to inflate(), for
+   example, a 16K input buffer and a 64K output buffer, more than 95% of the
+   inflate execution time is spent in this routine.
+
+   Entry assumptions:
+
+        state.mode === LEN
+        strm.avail_in >= 6
+        strm.avail_out >= 258
+        start >= strm.avail_out
+        state.bits < 8
+
+   On return, state.mode is one of:
+
+        LEN -- ran out of enough output space or enough available input
+        TYPE -- reached end of block code, inflate() to interpret next block
+        BAD -- error in block data
+
+   Notes:
+
+    - The maximum input bits used by a length/distance pair is 15 bits for the
+      length code, 5 bits for the length extra, 15 bits for the distance code,
+      and 13 bits for the distance extra.  This totals 48 bits, or six bytes.
+      Therefore if strm.avail_in >= 6, then there is enough input to avoid
+      checking for available input while decoding.
+
+    - The maximum bytes that a single length/distance pair can output is 258
+      bytes, which is the maximum length that can be coded.  inflate_fast()
+      requires strm.avail_out >= 258 for each loop to avoid checking for
+      output space.
+ */
+export default function inflate_fast(strm, start) {
+  var state;
+  var _in;                    /* local strm.input */
+  var last;                   /* have enough input while in < last */
+  var _out;                   /* local strm.output */
+  var beg;                    /* inflate()'s initial strm.output */
+  var end;                    /* while out < end, enough space available */
+//#ifdef INFLATE_STRICT
+  var dmax;                   /* maximum distance from zlib header */
+//#endif
+  var wsize;                  /* window size or zero if not using window */
+  var whave;                  /* valid bytes in the window */
+  var wnext;                  /* window write index */
+  // Use `s_window` instead `window`, avoid conflict with instrumentation tools
+  var s_window;               /* allocated sliding window, if wsize != 0 */
+  var hold;                   /* local strm.hold */
+  var bits;                   /* local strm.bits */
+  var lcode;                  /* local strm.lencode */
+  var dcode;                  /* local strm.distcode */
+  var lmask;                  /* mask for first level of length codes */
+  var dmask;                  /* mask for first level of distance codes */
+  var here;                   /* retrieved table entry */
+  var op;                     /* code bits, operation, extra bits, or */
+                              /*  window position, window bytes to copy */
+  var len;                    /* match length, unused bytes */
+  var dist;                   /* match distance */
+  var from;                   /* where to copy match from */
+  var from_source;
+
+
+  var input, output; // JS specific, because we have no pointers
+
+  /* copy state to local variables */
+  state = strm.state;
+  //here = state.here;
+  _in = strm.next_in;
+  input = strm.input;
+  last = _in + (strm.avail_in - 5);
+  _out = strm.next_out;
+  output = strm.output;
+  beg = _out - (start - strm.avail_out);
+  end = _out + (strm.avail_out - 257);
+//#ifdef INFLATE_STRICT
+  dmax = state.dmax;
+//#endif
+  wsize = state.wsize;
+  whave = state.whave;
+  wnext = state.wnext;
+  s_window = state.window;
+  hold = state.hold;
+  bits = state.bits;
+  lcode = state.lencode;
+  dcode = state.distcode;
+  lmask = (1 << state.lenbits) - 1;
+  dmask = (1 << state.distbits) - 1;
+
+
+  /* decode literals and length/distances until end-of-block or not enough
+     input data or output space */
+
+  top:
+  do {
+    if (bits < 15) {
+      hold += input[_in++] << bits;
+      bits += 8;
+      hold += input[_in++] << bits;
+      bits += 8;
+    }
+
+    here = lcode[hold & lmask];
+
+    dolen:
+    for (;;) { // Goto emulation
+      op = here >>> 24/*here.bits*/;
+      hold >>>= op;
+      bits -= op;
+      op = (here >>> 16) & 0xff/*here.op*/;
+      if (op === 0) {                          /* literal */
+        //Tracevv((stderr, here.val >= 0x20 && here.val < 0x7f ?
+        //        "inflate:         literal '%c'\n" :
+        //        "inflate:         literal 0x%02x\n", here.val));
+        output[_out++] = here & 0xffff/*here.val*/;
+      }
+      else if (op & 16) {                     /* length base */
+        len = here & 0xffff/*here.val*/;
+        op &= 15;                           /* number of extra bits */
+        if (op) {
+          if (bits < op) {
+            hold += input[_in++] << bits;
+            bits += 8;
+          }
+          len += hold & ((1 << op) - 1);
+          hold >>>= op;
+          bits -= op;
+        }
+        //Tracevv((stderr, "inflate:         length %u\n", len));
+        if (bits < 15) {
+          hold += input[_in++] << bits;
+          bits += 8;
+          hold += input[_in++] << bits;
+          bits += 8;
+        }
+        here = dcode[hold & dmask];
+
+        dodist:
+        for (;;) { // goto emulation
+          op = here >>> 24/*here.bits*/;
+          hold >>>= op;
+          bits -= op;
+          op = (here >>> 16) & 0xff/*here.op*/;
+
+          if (op & 16) {                      /* distance base */
+            dist = here & 0xffff/*here.val*/;
+            op &= 15;                       /* number of extra bits */
+            if (bits < op) {
+              hold += input[_in++] << bits;
+              bits += 8;
+              if (bits < op) {
+                hold += input[_in++] << bits;
+                bits += 8;
+              }
+            }
+            dist += hold & ((1 << op) - 1);
+//#ifdef INFLATE_STRICT
+            if (dist > dmax) {
+              strm.msg = 'invalid distance too far back';
+              state.mode = BAD;
+              break top;
+            }
+//#endif
+            hold >>>= op;
+            bits -= op;
+            //Tracevv((stderr, "inflate:         distance %u\n", dist));
+            op = _out - beg;                /* max distance in output */
+            if (dist > op) {                /* see if copy from window */
+              op = dist - op;               /* distance back in window */
+              if (op > whave) {
+                if (state.sane) {
+                  strm.msg = 'invalid distance too far back';
+                  state.mode = BAD;
+                  break top;
+                }
+
+// (!) This block is disabled in zlib defailts,
+// don't enable it for binary compatibility
+//#ifdef INFLATE_ALLOW_INVALID_DISTANCE_TOOFAR_ARRR
+//                if (len <= op - whave) {
+//                  do {
+//                    output[_out++] = 0;
+//                  } while (--len);
+//                  continue top;
+//                }
+//                len -= op - whave;
+//                do {
+//                  output[_out++] = 0;
+//                } while (--op > whave);
+//                if (op === 0) {
+//                  from = _out - dist;
+//                  do {
+//                    output[_out++] = output[from++];
+//                  } while (--len);
+//                  continue top;
+//                }
+//#endif
+              }
+              from = 0; // window index
+              from_source = s_window;
+              if (wnext === 0) {           /* very common case */
+                from += wsize - op;
+                if (op < len) {         /* some from window */
+                  len -= op;
+                  do {
+                    output[_out++] = s_window[from++];
+                  } while (--op);
+                  from = _out - dist;  /* rest from output */
+                  from_source = output;
+                }
+              }
+              else if (wnext < op) {      /* wrap around window */
+                from += wsize + wnext - op;
+                op -= wnext;
+                if (op < len) {         /* some from end of window */
+                  len -= op;
+                  do {
+                    output[_out++] = s_window[from++];
+                  } while (--op);
+                  from = 0;
+                  if (wnext < len) {  /* some from start of window */
+                    op = wnext;
+                    len -= op;
+                    do {
+                      output[_out++] = s_window[from++];
+                    } while (--op);
+                    from = _out - dist;      /* rest from output */
+                    from_source = output;
+                  }
+                }
+              }
+              else {                      /* contiguous in window */
+                from += wnext - op;
+                if (op < len) {         /* some from window */
+                  len -= op;
+                  do {
+                    output[_out++] = s_window[from++];
+                  } while (--op);
+                  from = _out - dist;  /* rest from output */
+                  from_source = output;
+                }
+              }
+              while (len > 2) {
+                output[_out++] = from_source[from++];
+                output[_out++] = from_source[from++];
+                output[_out++] = from_source[from++];
+                len -= 3;
+              }
+              if (len) {
+                output[_out++] = from_source[from++];
+                if (len > 1) {
+                  output[_out++] = from_source[from++];
+                }
+              }
+            }
+            else {
+              from = _out - dist;          /* copy direct from output */
+              do {                        /* minimum length is three */
+                output[_out++] = output[from++];
+                output[_out++] = output[from++];
+                output[_out++] = output[from++];
+                len -= 3;
+              } while (len > 2);
+              if (len) {
+                output[_out++] = output[from++];
+                if (len > 1) {
+                  output[_out++] = output[from++];
+                }
+              }
+            }
+          }
+          else if ((op & 64) === 0) {          /* 2nd level distance code */
+            here = dcode[(here & 0xffff)/*here.val*/ + (hold & ((1 << op) - 1))];
+            continue dodist;
+          }
+          else {
+            strm.msg = 'invalid distance code';
+            state.mode = BAD;
+            break top;
+          }
+
+          break; // need to emulate goto via "continue"
+        }
+      }
+      else if ((op & 64) === 0) {              /* 2nd level length code */
+        here = lcode[(here & 0xffff)/*here.val*/ + (hold & ((1 << op) - 1))];
+        continue dolen;
+      }
+      else if (op & 32) {                     /* end-of-block */
+        //Tracevv((stderr, "inflate:         end of block\n"));
+        state.mode = TYPE;
+        break top;
+      }
+      else {
+        strm.msg = 'invalid literal/length code';
+        state.mode = BAD;
+        break top;
+      }
+
+      break; // need to emulate goto via "continue"
+    }
+  } while (_in < last && _out < end);
+
+  /* return unused bytes (on entry, bits < 8, so in won't go too far back) */
+  len = bits >> 3;
+  _in -= len;
+  bits -= len << 3;
+  hold &= (1 << bits) - 1;
+
+  /* update state and return */
+  strm.next_in = _in;
+  strm.next_out = _out;
+  strm.avail_in = (_in < last ? 5 + (last - _in) : 5 - (_in - last));
+  strm.avail_out = (_out < end ? 257 + (end - _out) : 257 - (_out - end));
+  state.hold = hold;
+  state.bits = bits;
+  return;
+};
pkg/web/noVNC/vendor/pako/lib/zlib/inflate.js
@@ -0,0 +1,1527 @@
+import * as utils from "../utils/common.js";
+import adler32 from "./adler32.js";
+import crc32 from "./crc32.js";
+import inflate_fast from "./inffast.js";
+import inflate_table from "./inftrees.js";
+
+var CODES = 0;
+var LENS = 1;
+var DISTS = 2;
+
+/* Public constants ==========================================================*/
+/* ===========================================================================*/
+
+
+/* Allowed flush values; see deflate() and inflate() below for details */
+//export const Z_NO_FLUSH      = 0;
+//export const Z_PARTIAL_FLUSH = 1;
+//export const Z_SYNC_FLUSH    = 2;
+//export const Z_FULL_FLUSH    = 3;
+export const Z_FINISH        = 4;
+export const Z_BLOCK         = 5;
+export const Z_TREES         = 6;
+
+
+/* Return codes for the compression/decompression functions. Negative values
+ * are errors, positive values are used for special but normal events.
+ */
+export const Z_OK            = 0;
+export const Z_STREAM_END    = 1;
+export const Z_NEED_DICT     = 2;
+//export const Z_ERRNO         = -1;
+export const Z_STREAM_ERROR  = -2;
+export const Z_DATA_ERROR    = -3;
+export const Z_MEM_ERROR     = -4;
+export const Z_BUF_ERROR     = -5;
+//export const Z_VERSION_ERROR = -6;
+
+/* The deflate compression method */
+export const Z_DEFLATED  = 8;
+
+
+/* STATES ====================================================================*/
+/* ===========================================================================*/
+
+
+var    HEAD = 1;       /* i: waiting for magic header */
+var    FLAGS = 2;      /* i: waiting for method and flags (gzip) */
+var    TIME = 3;       /* i: waiting for modification time (gzip) */
+var    OS = 4;         /* i: waiting for extra flags and operating system (gzip) */
+var    EXLEN = 5;      /* i: waiting for extra length (gzip) */
+var    EXTRA = 6;      /* i: waiting for extra bytes (gzip) */
+var    NAME = 7;       /* i: waiting for end of file name (gzip) */
+var    COMMENT = 8;    /* i: waiting for end of comment (gzip) */
+var    HCRC = 9;       /* i: waiting for header crc (gzip) */
+var    DICTID = 10;    /* i: waiting for dictionary check value */
+var    DICT = 11;      /* waiting for inflateSetDictionary() call */
+var        TYPE = 12;      /* i: waiting for type bits, including last-flag bit */
+var        TYPEDO = 13;    /* i: same, but skip check to exit inflate on new block */
+var        STORED = 14;    /* i: waiting for stored size (length and complement) */
+var        COPY_ = 15;     /* i/o: same as COPY below, but only first time in */
+var        COPY = 16;      /* i/o: waiting for input or output to copy stored block */
+var        TABLE = 17;     /* i: waiting for dynamic block table lengths */
+var        LENLENS = 18;   /* i: waiting for code length code lengths */
+var        CODELENS = 19;  /* i: waiting for length/lit and distance code lengths */
+var            LEN_ = 20;      /* i: same as LEN below, but only first time in */
+var            LEN = 21;       /* i: waiting for length/lit/eob code */
+var            LENEXT = 22;    /* i: waiting for length extra bits */
+var            DIST = 23;      /* i: waiting for distance code */
+var            DISTEXT = 24;   /* i: waiting for distance extra bits */
+var            MATCH = 25;     /* o: waiting for output space to copy string */
+var            LIT = 26;       /* o: waiting for output space to write literal */
+var    CHECK = 27;     /* i: waiting for 32-bit check value */
+var    LENGTH = 28;    /* i: waiting for 32-bit length (gzip) */
+var    DONE = 29;      /* finished check, done -- remain here until reset */
+var    BAD = 30;       /* got a data error -- remain here until reset */
+var    MEM = 31;       /* got an inflate() memory error -- remain here until reset */
+var    SYNC = 32;      /* looking for synchronization bytes to restart inflate() */
+
+/* ===========================================================================*/
+
+
+
+var ENOUGH_LENS = 852;
+var ENOUGH_DISTS = 592;
+//var ENOUGH =  (ENOUGH_LENS+ENOUGH_DISTS);
+
+var MAX_WBITS = 15;
+/* 32K LZ77 window */
+var DEF_WBITS = MAX_WBITS;
+
+
+function zswap32(q) {
+  return  (((q >>> 24) & 0xff) +
+          ((q >>> 8) & 0xff00) +
+          ((q & 0xff00) << 8) +
+          ((q & 0xff) << 24));
+}
+
+
+function InflateState() {
+  this.mode = 0;             /* current inflate mode */
+  this.last = false;          /* true if processing last block */
+  this.wrap = 0;              /* bit 0 true for zlib, bit 1 true for gzip */
+  this.havedict = false;      /* true if dictionary provided */
+  this.flags = 0;             /* gzip header method and flags (0 if zlib) */
+  this.dmax = 0;              /* zlib header max distance (INFLATE_STRICT) */
+  this.check = 0;             /* protected copy of check value */
+  this.total = 0;             /* protected copy of output count */
+  // TODO: may be {}
+  this.head = null;           /* where to save gzip header information */
+
+  /* sliding window */
+  this.wbits = 0;             /* log base 2 of requested window size */
+  this.wsize = 0;             /* window size or zero if not using window */
+  this.whave = 0;             /* valid bytes in the window */
+  this.wnext = 0;             /* window write index */
+  this.window = null;         /* allocated sliding window, if needed */
+
+  /* bit accumulator */
+  this.hold = 0;              /* input bit accumulator */
+  this.bits = 0;              /* number of bits in "in" */
+
+  /* for string and stored block copying */
+  this.length = 0;            /* literal or length of data to copy */
+  this.offset = 0;            /* distance back to copy string from */
+
+  /* for table and code decoding */
+  this.extra = 0;             /* extra bits needed */
+
+  /* fixed and dynamic code tables */
+  this.lencode = null;          /* starting table for length/literal codes */
+  this.distcode = null;         /* starting table for distance codes */
+  this.lenbits = 0;           /* index bits for lencode */
+  this.distbits = 0;          /* index bits for distcode */
+
+  /* dynamic table building */
+  this.ncode = 0;             /* number of code length code lengths */
+  this.nlen = 0;              /* number of length code lengths */
+  this.ndist = 0;             /* number of distance code lengths */
+  this.have = 0;              /* number of code lengths in lens[] */
+  this.next = null;              /* next available space in codes[] */
+
+  this.lens = new utils.Buf16(320); /* temporary storage for code lengths */
+  this.work = new utils.Buf16(288); /* work area for code table building */
+
+  /*
+   because we don't have pointers in js, we use lencode and distcode directly
+   as buffers so we don't need codes
+  */
+  //this.codes = new utils.Buf32(ENOUGH);       /* space for code tables */
+  this.lendyn = null;              /* dynamic table for length/literal codes (JS specific) */
+  this.distdyn = null;             /* dynamic table for distance codes (JS specific) */
+  this.sane = 0;                   /* if false, allow invalid distance too far */
+  this.back = 0;                   /* bits back of last unprocessed length/lit */
+  this.was = 0;                    /* initial length of match */
+}
+
+function inflateResetKeep(strm) {
+  var state;
+
+  if (!strm || !strm.state) { return Z_STREAM_ERROR; }
+  state = strm.state;
+  strm.total_in = strm.total_out = state.total = 0;
+  strm.msg = ''; /*Z_NULL*/
+  if (state.wrap) {       /* to support ill-conceived Java test suite */
+    strm.adler = state.wrap & 1;
+  }
+  state.mode = HEAD;
+  state.last = 0;
+  state.havedict = 0;
+  state.dmax = 32768;
+  state.head = null/*Z_NULL*/;
+  state.hold = 0;
+  state.bits = 0;
+  //state.lencode = state.distcode = state.next = state.codes;
+  state.lencode = state.lendyn = new utils.Buf32(ENOUGH_LENS);
+  state.distcode = state.distdyn = new utils.Buf32(ENOUGH_DISTS);
+
+  state.sane = 1;
+  state.back = -1;
+  //Tracev((stderr, "inflate: reset\n"));
+  return Z_OK;
+}
+
+function inflateReset(strm) {
+  var state;
+
+  if (!strm || !strm.state) { return Z_STREAM_ERROR; }
+  state = strm.state;
+  state.wsize = 0;
+  state.whave = 0;
+  state.wnext = 0;
+  return inflateResetKeep(strm);
+
+}
+
+function inflateReset2(strm, windowBits) {
+  var wrap;
+  var state;
+
+  /* get the state */
+  if (!strm || !strm.state) { return Z_STREAM_ERROR; }
+  state = strm.state;
+
+  /* extract wrap request from windowBits parameter */
+  if (windowBits < 0) {
+    wrap = 0;
+    windowBits = -windowBits;
+  }
+  else {
+    wrap = (windowBits >> 4) + 1;
+    if (windowBits < 48) {
+      windowBits &= 15;
+    }
+  }
+
+  /* set number of window bits, free window if different */
+  if (windowBits && (windowBits < 8 || windowBits > 15)) {
+    return Z_STREAM_ERROR;
+  }
+  if (state.window !== null && state.wbits !== windowBits) {
+    state.window = null;
+  }
+
+  /* update state and reset the rest of it */
+  state.wrap = wrap;
+  state.wbits = windowBits;
+  return inflateReset(strm);
+}
+
+function inflateInit2(strm, windowBits) {
+  var ret;
+  var state;
+
+  if (!strm) { return Z_STREAM_ERROR; }
+  //strm.msg = Z_NULL;                 /* in case we return an error */
+
+  state = new InflateState();
+
+  //if (state === Z_NULL) return Z_MEM_ERROR;
+  //Tracev((stderr, "inflate: allocated\n"));
+  strm.state = state;
+  state.window = null/*Z_NULL*/;
+  ret = inflateReset2(strm, windowBits);
+  if (ret !== Z_OK) {
+    strm.state = null/*Z_NULL*/;
+  }
+  return ret;
+}
+
+function inflateInit(strm) {
+  return inflateInit2(strm, DEF_WBITS);
+}
+
+
+/*
+ Return state with length and distance decoding tables and index sizes set to
+ fixed code decoding.  Normally this returns fixed tables from inffixed.h.
+ If BUILDFIXED is defined, then instead this routine builds the tables the
+ first time it's called, and returns those tables the first time and
+ thereafter.  This reduces the size of the code by about 2K bytes, in
+ exchange for a little execution time.  However, BUILDFIXED should not be
+ used for threaded applications, since the rewriting of the tables and virgin
+ may not be thread-safe.
+ */
+var virgin = true;
+
+var lenfix, distfix; // We have no pointers in JS, so keep tables separate
+
+function fixedtables(state) {
+  /* build fixed huffman tables if first call (may not be thread safe) */
+  if (virgin) {
+    var sym;
+
+    lenfix = new utils.Buf32(512);
+    distfix = new utils.Buf32(32);
+
+    /* literal/length table */
+    sym = 0;
+    while (sym < 144) { state.lens[sym++] = 8; }
+    while (sym < 256) { state.lens[sym++] = 9; }
+    while (sym < 280) { state.lens[sym++] = 7; }
+    while (sym < 288) { state.lens[sym++] = 8; }
+
+    inflate_table(LENS,  state.lens, 0, 288, lenfix,   0, state.work, { bits: 9 });
+
+    /* distance table */
+    sym = 0;
+    while (sym < 32) { state.lens[sym++] = 5; }
+
+    inflate_table(DISTS, state.lens, 0, 32,   distfix, 0, state.work, { bits: 5 });
+
+    /* do this just once */
+    virgin = false;
+  }
+
+  state.lencode = lenfix;
+  state.lenbits = 9;
+  state.distcode = distfix;
+  state.distbits = 5;
+}
+
+
+/*
+ Update the window with the last wsize (normally 32K) bytes written before
+ returning.  If window does not exist yet, create it.  This is only called
+ when a window is already in use, or when output has been written during this
+ inflate call, but the end of the deflate stream has not been reached yet.
+ It is also called to create a window for dictionary data when a dictionary
+ is loaded.
+
+ Providing output buffers larger than 32K to inflate() should provide a speed
+ advantage, since only the last 32K of output is copied to the sliding window
+ upon return from inflate(), and since all distances after the first 32K of
+ output will fall in the output data, making match copies simpler and faster.
+ The advantage may be dependent on the size of the processor's data caches.
+ */
+function updatewindow(strm, src, end, copy) {
+  var dist;
+  var state = strm.state;
+
+  /* if it hasn't been done already, allocate space for the window */
+  if (state.window === null) {
+    state.wsize = 1 << state.wbits;
+    state.wnext = 0;
+    state.whave = 0;
+
+    state.window = new utils.Buf8(state.wsize);
+  }
+
+  /* copy state->wsize or less output bytes into the circular window */
+  if (copy >= state.wsize) {
+    utils.arraySet(state.window, src, end - state.wsize, state.wsize, 0);
+    state.wnext = 0;
+    state.whave = state.wsize;
+  }
+  else {
+    dist = state.wsize - state.wnext;
+    if (dist > copy) {
+      dist = copy;
+    }
+    //zmemcpy(state->window + state->wnext, end - copy, dist);
+    utils.arraySet(state.window, src, end - copy, dist, state.wnext);
+    copy -= dist;
+    if (copy) {
+      //zmemcpy(state->window, end - copy, copy);
+      utils.arraySet(state.window, src, end - copy, copy, 0);
+      state.wnext = copy;
+      state.whave = state.wsize;
+    }
+    else {
+      state.wnext += dist;
+      if (state.wnext === state.wsize) { state.wnext = 0; }
+      if (state.whave < state.wsize) { state.whave += dist; }
+    }
+  }
+  return 0;
+}
+
+function inflate(strm, flush) {
+  var state;
+  var input, output;          // input/output buffers
+  var next;                   /* next input INDEX */
+  var put;                    /* next output INDEX */
+  var have, left;             /* available input and output */
+  var hold;                   /* bit buffer */
+  var bits;                   /* bits in bit buffer */
+  var _in, _out;              /* save starting available input and output */
+  var copy;                   /* number of stored or match bytes to copy */
+  var from;                   /* where to copy match bytes from */
+  var from_source;
+  var here = 0;               /* current decoding table entry */
+  var here_bits, here_op, here_val; // paked "here" denormalized (JS specific)
+  //var last;                   /* parent table entry */
+  var last_bits, last_op, last_val; // paked "last" denormalized (JS specific)
+  var len;                    /* length to copy for repeats, bits to drop */
+  var ret;                    /* return code */
+  var hbuf = new utils.Buf8(4);    /* buffer for gzip header crc calculation */
+  var opts;
+
+  var n; // temporary var for NEED_BITS
+
+  var order = /* permutation of code lengths */
+    [ 16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15 ];
+
+
+  if (!strm || !strm.state || !strm.output ||
+      (!strm.input && strm.avail_in !== 0)) {
+    return Z_STREAM_ERROR;
+  }
+
+  state = strm.state;
+  if (state.mode === TYPE) { state.mode = TYPEDO; }    /* skip check */
+
+
+  //--- LOAD() ---
+  put = strm.next_out;
+  output = strm.output;
+  left = strm.avail_out;
+  next = strm.next_in;
+  input = strm.input;
+  have = strm.avail_in;
+  hold = state.hold;
+  bits = state.bits;
+  //---
+
+  _in = have;
+  _out = left;
+  ret = Z_OK;
+
+  inf_leave: // goto emulation
+  for (;;) {
+    switch (state.mode) {
+    case HEAD:
+      if (state.wrap === 0) {
+        state.mode = TYPEDO;
+        break;
+      }
+      //=== NEEDBITS(16);
+      while (bits < 16) {
+        if (have === 0) { break inf_leave; }
+        have--;
+        hold += input[next++] << bits;
+        bits += 8;
+      }
+      //===//
+      if ((state.wrap & 2) && hold === 0x8b1f) {  /* gzip header */
+        state.check = 0/*crc32(0L, Z_NULL, 0)*/;
+        //=== CRC2(state.check, hold);
+        hbuf[0] = hold & 0xff;
+        hbuf[1] = (hold >>> 8) & 0xff;
+        state.check = crc32(state.check, hbuf, 2, 0);
+        //===//
+
+        //=== INITBITS();
+        hold = 0;
+        bits = 0;
+        //===//
+        state.mode = FLAGS;
+        break;
+      }
+      state.flags = 0;           /* expect zlib header */
+      if (state.head) {
+        state.head.done = false;
+      }
+      if (!(state.wrap & 1) ||   /* check if zlib header allowed */
+        (((hold & 0xff)/*BITS(8)*/ << 8) + (hold >> 8)) % 31) {
+        strm.msg = 'incorrect header check';
+        state.mode = BAD;
+        break;
+      }
+      if ((hold & 0x0f)/*BITS(4)*/ !== Z_DEFLATED) {
+        strm.msg = 'unknown compression method';
+        state.mode = BAD;
+        break;
+      }
+      //--- DROPBITS(4) ---//
+      hold >>>= 4;
+      bits -= 4;
+      //---//
+      len = (hold & 0x0f)/*BITS(4)*/ + 8;
+      if (state.wbits === 0) {
+        state.wbits = len;
+      }
+      else if (len > state.wbits) {
+        strm.msg = 'invalid window size';
+        state.mode = BAD;
+        break;
+      }
+      state.dmax = 1 << len;
+      //Tracev((stderr, "inflate:   zlib header ok\n"));
+      strm.adler = state.check = 1/*adler32(0L, Z_NULL, 0)*/;
+      state.mode = hold & 0x200 ? DICTID : TYPE;
+      //=== INITBITS();
+      hold = 0;
+      bits = 0;
+      //===//
+      break;
+    case FLAGS:
+      //=== NEEDBITS(16); */
+      while (bits < 16) {
+        if (have === 0) { break inf_leave; }
+        have--;
+        hold += input[next++] << bits;
+        bits += 8;
+      }
+      //===//
+      state.flags = hold;
+      if ((state.flags & 0xff) !== Z_DEFLATED) {
+        strm.msg = 'unknown compression method';
+        state.mode = BAD;
+        break;
+      }
+      if (state.flags & 0xe000) {
+        strm.msg = 'unknown header flags set';
+        state.mode = BAD;
+        break;
+      }
+      if (state.head) {
+        state.head.text = ((hold >> 8) & 1);
+      }
+      if (state.flags & 0x0200) {
+        //=== CRC2(state.check, hold);
+        hbuf[0] = hold & 0xff;
+        hbuf[1] = (hold >>> 8) & 0xff;
+        state.check = crc32(state.check, hbuf, 2, 0);
+        //===//
+      }
+      //=== INITBITS();
+      hold = 0;
+      bits = 0;
+      //===//
+      state.mode = TIME;
+      /* falls through */
+    case TIME:
+      //=== NEEDBITS(32); */
+      while (bits < 32) {
+        if (have === 0) { break inf_leave; }
+        have--;
+        hold += input[next++] << bits;
+        bits += 8;
+      }
+      //===//
+      if (state.head) {
+        state.head.time = hold;
+      }
+      if (state.flags & 0x0200) {
+        //=== CRC4(state.check, hold)
+        hbuf[0] = hold & 0xff;
+        hbuf[1] = (hold >>> 8) & 0xff;
+        hbuf[2] = (hold >>> 16) & 0xff;
+        hbuf[3] = (hold >>> 24) & 0xff;
+        state.check = crc32(state.check, hbuf, 4, 0);
+        //===
+      }
+      //=== INITBITS();
+      hold = 0;
+      bits = 0;
+      //===//
+      state.mode = OS;
+      /* falls through */
+    case OS:
+      //=== NEEDBITS(16); */
+      while (bits < 16) {
+        if (have === 0) { break inf_leave; }
+        have--;
+        hold += input[next++] << bits;
+        bits += 8;
+      }
+      //===//
+      if (state.head) {
+        state.head.xflags = (hold & 0xff);
+        state.head.os = (hold >> 8);
+      }
+      if (state.flags & 0x0200) {
+        //=== CRC2(state.check, hold);
+        hbuf[0] = hold & 0xff;
+        hbuf[1] = (hold >>> 8) & 0xff;
+        state.check = crc32(state.check, hbuf, 2, 0);
+        //===//
+      }
+      //=== INITBITS();
+      hold = 0;
+      bits = 0;
+      //===//
+      state.mode = EXLEN;
+      /* falls through */
+    case EXLEN:
+      if (state.flags & 0x0400) {
+        //=== NEEDBITS(16); */
+        while (bits < 16) {
+          if (have === 0) { break inf_leave; }
+          have--;
+          hold += input[next++] << bits;
+          bits += 8;
+        }
+        //===//
+        state.length = hold;
+        if (state.head) {
+          state.head.extra_len = hold;
+        }
+        if (state.flags & 0x0200) {
+          //=== CRC2(state.check, hold);
+          hbuf[0] = hold & 0xff;
+          hbuf[1] = (hold >>> 8) & 0xff;
+          state.check = crc32(state.check, hbuf, 2, 0);
+          //===//
+        }
+        //=== INITBITS();
+        hold = 0;
+        bits = 0;
+        //===//
+      }
+      else if (state.head) {
+        state.head.extra = null/*Z_NULL*/;
+      }
+      state.mode = EXTRA;
+      /* falls through */
+    case EXTRA:
+      if (state.flags & 0x0400) {
+        copy = state.length;
+        if (copy > have) { copy = have; }
+        if (copy) {
+          if (state.head) {
+            len = state.head.extra_len - state.length;
+            if (!state.head.extra) {
+              // Use untyped array for more conveniend processing later
+              state.head.extra = new Array(state.head.extra_len);
+            }
+            utils.arraySet(
+              state.head.extra,
+              input,
+              next,
+              // extra field is limited to 65536 bytes
+              // - no need for additional size check
+              copy,
+              /*len + copy > state.head.extra_max - len ? state.head.extra_max : copy,*/
+              len
+            );
+            //zmemcpy(state.head.extra + len, next,
+            //        len + copy > state.head.extra_max ?
+            //        state.head.extra_max - len : copy);
+          }
+          if (state.flags & 0x0200) {
+            state.check = crc32(state.check, input, copy, next);
+          }
+          have -= copy;
+          next += copy;
+          state.length -= copy;
+        }
+        if (state.length) { break inf_leave; }
+      }
+      state.length = 0;
+      state.mode = NAME;
+      /* falls through */
+    case NAME:
+      if (state.flags & 0x0800) {
+        if (have === 0) { break inf_leave; }
+        copy = 0;
+        do {
+          // TODO: 2 or 1 bytes?
+          len = input[next + copy++];
+          /* use constant limit because in js we should not preallocate memory */
+          if (state.head && len &&
+              (state.length < 65536 /*state.head.name_max*/)) {
+            state.head.name += String.fromCharCode(len);
+          }
+        } while (len && copy < have);
+
+        if (state.flags & 0x0200) {
+          state.check = crc32(state.check, input, copy, next);
+        }
+        have -= copy;
+        next += copy;
+        if (len) { break inf_leave; }
+      }
+      else if (state.head) {
+        state.head.name = null;
+      }
+      state.length = 0;
+      state.mode = COMMENT;
+      /* falls through */
+    case COMMENT:
+      if (state.flags & 0x1000) {
+        if (have === 0) { break inf_leave; }
+        copy = 0;
+        do {
+          len = input[next + copy++];
+          /* use constant limit because in js we should not preallocate memory */
+          if (state.head && len &&
+              (state.length < 65536 /*state.head.comm_max*/)) {
+            state.head.comment += String.fromCharCode(len);
+          }
+        } while (len && copy < have);
+        if (state.flags & 0x0200) {
+          state.check = crc32(state.check, input, copy, next);
+        }
+        have -= copy;
+        next += copy;
+        if (len) { break inf_leave; }
+      }
+      else if (state.head) {
+        state.head.comment = null;
+      }
+      state.mode = HCRC;
+      /* falls through */
+    case HCRC:
+      if (state.flags & 0x0200) {
+        //=== NEEDBITS(16); */
+        while (bits < 16) {
+          if (have === 0) { break inf_leave; }
+          have--;
+          hold += input[next++] << bits;
+          bits += 8;
+        }
+        //===//
+        if (hold !== (state.check & 0xffff)) {
+          strm.msg = 'header crc mismatch';
+          state.mode = BAD;
+          break;
+        }
+        //=== INITBITS();
+        hold = 0;
+        bits = 0;
+        //===//
+      }
+      if (state.head) {
+        state.head.hcrc = ((state.flags >> 9) & 1);
+        state.head.done = true;
+      }
+      strm.adler = state.check = 0;
+      state.mode = TYPE;
+      break;
+    case DICTID:
+      //=== NEEDBITS(32); */
+      while (bits < 32) {
+        if (have === 0) { break inf_leave; }
+        have--;
+        hold += input[next++] << bits;
+        bits += 8;
+      }
+      //===//
+      strm.adler = state.check = zswap32(hold);
+      //=== INITBITS();
+      hold = 0;
+      bits = 0;
+      //===//
+      state.mode = DICT;
+      /* falls through */
+    case DICT:
+      if (state.havedict === 0) {
+        //--- RESTORE() ---
+        strm.next_out = put;
+        strm.avail_out = left;
+        strm.next_in = next;
+        strm.avail_in = have;
+        state.hold = hold;
+        state.bits = bits;
+        //---
+        return Z_NEED_DICT;
+      }
+      strm.adler = state.check = 1/*adler32(0L, Z_NULL, 0)*/;
+      state.mode = TYPE;
+      /* falls through */
+    case TYPE:
+      if (flush === Z_BLOCK || flush === Z_TREES) { break inf_leave; }
+      /* falls through */
+    case TYPEDO:
+      if (state.last) {
+        //--- BYTEBITS() ---//
+        hold >>>= bits & 7;
+        bits -= bits & 7;
+        //---//
+        state.mode = CHECK;
+        break;
+      }
+      //=== NEEDBITS(3); */
+      while (bits < 3) {
+        if (have === 0) { break inf_leave; }
+        have--;
+        hold += input[next++] << bits;
+        bits += 8;
+      }
+      //===//
+      state.last = (hold & 0x01)/*BITS(1)*/;
+      //--- DROPBITS(1) ---//
+      hold >>>= 1;
+      bits -= 1;
+      //---//
+
+      switch ((hold & 0x03)/*BITS(2)*/) {
+      case 0:                             /* stored block */
+        //Tracev((stderr, "inflate:     stored block%s\n",
+        //        state.last ? " (last)" : ""));
+        state.mode = STORED;
+        break;
+      case 1:                             /* fixed block */
+        fixedtables(state);
+        //Tracev((stderr, "inflate:     fixed codes block%s\n",
+        //        state.last ? " (last)" : ""));
+        state.mode = LEN_;             /* decode codes */
+        if (flush === Z_TREES) {
+          //--- DROPBITS(2) ---//
+          hold >>>= 2;
+          bits -= 2;
+          //---//
+          break inf_leave;
+        }
+        break;
+      case 2:                             /* dynamic block */
+        //Tracev((stderr, "inflate:     dynamic codes block%s\n",
+        //        state.last ? " (last)" : ""));
+        state.mode = TABLE;
+        break;
+      case 3:
+        strm.msg = 'invalid block type';
+        state.mode = BAD;
+      }
+      //--- DROPBITS(2) ---//
+      hold >>>= 2;
+      bits -= 2;
+      //---//
+      break;
+    case STORED:
+      //--- BYTEBITS() ---// /* go to byte boundary */
+      hold >>>= bits & 7;
+      bits -= bits & 7;
+      //---//
+      //=== NEEDBITS(32); */
+      while (bits < 32) {
+        if (have === 0) { break inf_leave; }
+        have--;
+        hold += input[next++] << bits;
+        bits += 8;
+      }
+      //===//
+      if ((hold & 0xffff) !== ((hold >>> 16) ^ 0xffff)) {
+        strm.msg = 'invalid stored block lengths';
+        state.mode = BAD;
+        break;
+      }
+      state.length = hold & 0xffff;
+      //Tracev((stderr, "inflate:       stored length %u\n",
+      //        state.length));
+      //=== INITBITS();
+      hold = 0;
+      bits = 0;
+      //===//
+      state.mode = COPY_;
+      if (flush === Z_TREES) { break inf_leave; }
+      /* falls through */
+    case COPY_:
+      state.mode = COPY;
+      /* falls through */
+    case COPY:
+      copy = state.length;
+      if (copy) {
+        if (copy > have) { copy = have; }
+        if (copy > left) { copy = left; }
+        if (copy === 0) { break inf_leave; }
+        //--- zmemcpy(put, next, copy); ---
+        utils.arraySet(output, input, next, copy, put);
+        //---//
+        have -= copy;
+        next += copy;
+        left -= copy;
+        put += copy;
+        state.length -= copy;
+        break;
+      }
+      //Tracev((stderr, "inflate:       stored end\n"));
+      state.mode = TYPE;
+      break;
+    case TABLE:
+      //=== NEEDBITS(14); */
+      while (bits < 14) {
+        if (have === 0) { break inf_leave; }
+        have--;
+        hold += input[next++] << bits;
+        bits += 8;
+      }
+      //===//
+      state.nlen = (hold & 0x1f)/*BITS(5)*/ + 257;
+      //--- DROPBITS(5) ---//
+      hold >>>= 5;
+      bits -= 5;
+      //---//
+      state.ndist = (hold & 0x1f)/*BITS(5)*/ + 1;
+      //--- DROPBITS(5) ---//
+      hold >>>= 5;
+      bits -= 5;
+      //---//
+      state.ncode = (hold & 0x0f)/*BITS(4)*/ + 4;
+      //--- DROPBITS(4) ---//
+      hold >>>= 4;
+      bits -= 4;
+      //---//
+//#ifndef PKZIP_BUG_WORKAROUND
+      if (state.nlen > 286 || state.ndist > 30) {
+        strm.msg = 'too many length or distance symbols';
+        state.mode = BAD;
+        break;
+      }
+//#endif
+      //Tracev((stderr, "inflate:       table sizes ok\n"));
+      state.have = 0;
+      state.mode = LENLENS;
+      /* falls through */
+    case LENLENS:
+      while (state.have < state.ncode) {
+        //=== NEEDBITS(3);
+        while (bits < 3) {
+          if (have === 0) { break inf_leave; }
+          have--;
+          hold += input[next++] << bits;
+          bits += 8;
+        }
+        //===//
+        state.lens[order[state.have++]] = (hold & 0x07);//BITS(3);
+        //--- DROPBITS(3) ---//
+        hold >>>= 3;
+        bits -= 3;
+        //---//
+      }
+      while (state.have < 19) {
+        state.lens[order[state.have++]] = 0;
+      }
+      // We have separate tables & no pointers. 2 commented lines below not needed.
+      //state.next = state.codes;
+      //state.lencode = state.next;
+      // Switch to use dynamic table
+      state.lencode = state.lendyn;
+      state.lenbits = 7;
+
+      opts = { bits: state.lenbits };
+      ret = inflate_table(CODES, state.lens, 0, 19, state.lencode, 0, state.work, opts);
+      state.lenbits = opts.bits;
+
+      if (ret) {
+        strm.msg = 'invalid code lengths set';
+        state.mode = BAD;
+        break;
+      }
+      //Tracev((stderr, "inflate:       code lengths ok\n"));
+      state.have = 0;
+      state.mode = CODELENS;
+      /* falls through */
+    case CODELENS:
+      while (state.have < state.nlen + state.ndist) {
+        for (;;) {
+          here = state.lencode[hold & ((1 << state.lenbits) - 1)];/*BITS(state.lenbits)*/
+          here_bits = here >>> 24;
+          here_op = (here >>> 16) & 0xff;
+          here_val = here & 0xffff;
+
+          if ((here_bits) <= bits) { break; }
+          //--- PULLBYTE() ---//
+          if (have === 0) { break inf_leave; }
+          have--;
+          hold += input[next++] << bits;
+          bits += 8;
+          //---//
+        }
+        if (here_val < 16) {
+          //--- DROPBITS(here.bits) ---//
+          hold >>>= here_bits;
+          bits -= here_bits;
+          //---//
+          state.lens[state.have++] = here_val;
+        }
+        else {
+          if (here_val === 16) {
+            //=== NEEDBITS(here.bits + 2);
+            n = here_bits + 2;
+            while (bits < n) {
+              if (have === 0) { break inf_leave; }
+              have--;
+              hold += input[next++] << bits;
+              bits += 8;
+            }
+            //===//
+            //--- DROPBITS(here.bits) ---//
+            hold >>>= here_bits;
+            bits -= here_bits;
+            //---//
+            if (state.have === 0) {
+              strm.msg = 'invalid bit length repeat';
+              state.mode = BAD;
+              break;
+            }
+            len = state.lens[state.have - 1];
+            copy = 3 + (hold & 0x03);//BITS(2);
+            //--- DROPBITS(2) ---//
+            hold >>>= 2;
+            bits -= 2;
+            //---//
+          }
+          else if (here_val === 17) {
+            //=== NEEDBITS(here.bits + 3);
+            n = here_bits + 3;
+            while (bits < n) {
+              if (have === 0) { break inf_leave; }
+              have--;
+              hold += input[next++] << bits;
+              bits += 8;
+            }
+            //===//
+            //--- DROPBITS(here.bits) ---//
+            hold >>>= here_bits;
+            bits -= here_bits;
+            //---//
+            len = 0;
+            copy = 3 + (hold & 0x07);//BITS(3);
+            //--- DROPBITS(3) ---//
+            hold >>>= 3;
+            bits -= 3;
+            //---//
+          }
+          else {
+            //=== NEEDBITS(here.bits + 7);
+            n = here_bits + 7;
+            while (bits < n) {
+              if (have === 0) { break inf_leave; }
+              have--;
+              hold += input[next++] << bits;
+              bits += 8;
+            }
+            //===//
+            //--- DROPBITS(here.bits) ---//
+            hold >>>= here_bits;
+            bits -= here_bits;
+            //---//
+            len = 0;
+            copy = 11 + (hold & 0x7f);//BITS(7);
+            //--- DROPBITS(7) ---//
+            hold >>>= 7;
+            bits -= 7;
+            //---//
+          }
+          if (state.have + copy > state.nlen + state.ndist) {
+            strm.msg = 'invalid bit length repeat';
+            state.mode = BAD;
+            break;
+          }
+          while (copy--) {
+            state.lens[state.have++] = len;
+          }
+        }
+      }
+
+      /* handle error breaks in while */
+      if (state.mode === BAD) { break; }
+
+      /* check for end-of-block code (better have one) */
+      if (state.lens[256] === 0) {
+        strm.msg = 'invalid code -- missing end-of-block';
+        state.mode = BAD;
+        break;
+      }
+
+      /* build code tables -- note: do not change the lenbits or distbits
+         values here (9 and 6) without reading the comments in inftrees.h
+         concerning the ENOUGH constants, which depend on those values */
+      state.lenbits = 9;
+
+      opts = { bits: state.lenbits };
+      ret = inflate_table(LENS, state.lens, 0, state.nlen, state.lencode, 0, state.work, opts);
+      // We have separate tables & no pointers. 2 commented lines below not needed.
+      // state.next_index = opts.table_index;
+      state.lenbits = opts.bits;
+      // state.lencode = state.next;
+
+      if (ret) {
+        strm.msg = 'invalid literal/lengths set';
+        state.mode = BAD;
+        break;
+      }
+
+      state.distbits = 6;
+      //state.distcode.copy(state.codes);
+      // Switch to use dynamic table
+      state.distcode = state.distdyn;
+      opts = { bits: state.distbits };
+      ret = inflate_table(DISTS, state.lens, state.nlen, state.ndist, state.distcode, 0, state.work, opts);
+      // We have separate tables & no pointers. 2 commented lines below not needed.
+      // state.next_index = opts.table_index;
+      state.distbits = opts.bits;
+      // state.distcode = state.next;
+
+      if (ret) {
+        strm.msg = 'invalid distances set';
+        state.mode = BAD;
+        break;
+      }
+      //Tracev((stderr, 'inflate:       codes ok\n'));
+      state.mode = LEN_;
+      if (flush === Z_TREES) { break inf_leave; }
+      /* falls through */
+    case LEN_:
+      state.mode = LEN;
+      /* falls through */
+    case LEN:
+      if (have >= 6 && left >= 258) {
+        //--- RESTORE() ---
+        strm.next_out = put;
+        strm.avail_out = left;
+        strm.next_in = next;
+        strm.avail_in = have;
+        state.hold = hold;
+        state.bits = bits;
+        //---
+        inflate_fast(strm, _out);
+        //--- LOAD() ---
+        put = strm.next_out;
+        output = strm.output;
+        left = strm.avail_out;
+        next = strm.next_in;
+        input = strm.input;
+        have = strm.avail_in;
+        hold = state.hold;
+        bits = state.bits;
+        //---
+
+        if (state.mode === TYPE) {
+          state.back = -1;
+        }
+        break;
+      }
+      state.back = 0;
+      for (;;) {
+        here = state.lencode[hold & ((1 << state.lenbits) - 1)];  /*BITS(state.lenbits)*/
+        here_bits = here >>> 24;
+        here_op = (here >>> 16) & 0xff;
+        here_val = here & 0xffff;
+
+        if (here_bits <= bits) { break; }
+        //--- PULLBYTE() ---//
+        if (have === 0) { break inf_leave; }
+        have--;
+        hold += input[next++] << bits;
+        bits += 8;
+        //---//
+      }
+      if (here_op && (here_op & 0xf0) === 0) {
+        last_bits = here_bits;
+        last_op = here_op;
+        last_val = here_val;
+        for (;;) {
+          here = state.lencode[last_val +
+                  ((hold & ((1 << (last_bits + last_op)) - 1))/*BITS(last.bits + last.op)*/ >> last_bits)];
+          here_bits = here >>> 24;
+          here_op = (here >>> 16) & 0xff;
+          here_val = here & 0xffff;
+
+          if ((last_bits + here_bits) <= bits) { break; }
+          //--- PULLBYTE() ---//
+          if (have === 0) { break inf_leave; }
+          have--;
+          hold += input[next++] << bits;
+          bits += 8;
+          //---//
+        }
+        //--- DROPBITS(last.bits) ---//
+        hold >>>= last_bits;
+        bits -= last_bits;
+        //---//
+        state.back += last_bits;
+      }
+      //--- DROPBITS(here.bits) ---//
+      hold >>>= here_bits;
+      bits -= here_bits;
+      //---//
+      state.back += here_bits;
+      state.length = here_val;
+      if (here_op === 0) {
+        //Tracevv((stderr, here.val >= 0x20 && here.val < 0x7f ?
+        //        "inflate:         literal '%c'\n" :
+        //        "inflate:         literal 0x%02x\n", here.val));
+        state.mode = LIT;
+        break;
+      }
+      if (here_op & 32) {
+        //Tracevv((stderr, "inflate:         end of block\n"));
+        state.back = -1;
+        state.mode = TYPE;
+        break;
+      }
+      if (here_op & 64) {
+        strm.msg = 'invalid literal/length code';
+        state.mode = BAD;
+        break;
+      }
+      state.extra = here_op & 15;
+      state.mode = LENEXT;
+      /* falls through */
+    case LENEXT:
+      if (state.extra) {
+        //=== NEEDBITS(state.extra);
+        n = state.extra;
+        while (bits < n) {
+          if (have === 0) { break inf_leave; }
+          have--;
+          hold += input[next++] << bits;
+          bits += 8;
+        }
+        //===//
+        state.length += hold & ((1 << state.extra) - 1)/*BITS(state.extra)*/;
+        //--- DROPBITS(state.extra) ---//
+        hold >>>= state.extra;
+        bits -= state.extra;
+        //---//
+        state.back += state.extra;
+      }
+      //Tracevv((stderr, "inflate:         length %u\n", state.length));
+      state.was = state.length;
+      state.mode = DIST;
+      /* falls through */
+    case DIST:
+      for (;;) {
+        here = state.distcode[hold & ((1 << state.distbits) - 1)];/*BITS(state.distbits)*/
+        here_bits = here >>> 24;
+        here_op = (here >>> 16) & 0xff;
+        here_val = here & 0xffff;
+
+        if ((here_bits) <= bits) { break; }
+        //--- PULLBYTE() ---//
+        if (have === 0) { break inf_leave; }
+        have--;
+        hold += input[next++] << bits;
+        bits += 8;
+        //---//
+      }
+      if ((here_op & 0xf0) === 0) {
+        last_bits = here_bits;
+        last_op = here_op;
+        last_val = here_val;
+        for (;;) {
+          here = state.distcode[last_val +
+                  ((hold & ((1 << (last_bits + last_op)) - 1))/*BITS(last.bits + last.op)*/ >> last_bits)];
+          here_bits = here >>> 24;
+          here_op = (here >>> 16) & 0xff;
+          here_val = here & 0xffff;
+
+          if ((last_bits + here_bits) <= bits) { break; }
+          //--- PULLBYTE() ---//
+          if (have === 0) { break inf_leave; }
+          have--;
+          hold += input[next++] << bits;
+          bits += 8;
+          //---//
+        }
+        //--- DROPBITS(last.bits) ---//
+        hold >>>= last_bits;
+        bits -= last_bits;
+        //---//
+        state.back += last_bits;
+      }
+      //--- DROPBITS(here.bits) ---//
+      hold >>>= here_bits;
+      bits -= here_bits;
+      //---//
+      state.back += here_bits;
+      if (here_op & 64) {
+        strm.msg = 'invalid distance code';
+        state.mode = BAD;
+        break;
+      }
+      state.offset = here_val;
+      state.extra = (here_op) & 15;
+      state.mode = DISTEXT;
+      /* falls through */
+    case DISTEXT:
+      if (state.extra) {
+        //=== NEEDBITS(state.extra);
+        n = state.extra;
+        while (bits < n) {
+          if (have === 0) { break inf_leave; }
+          have--;
+          hold += input[next++] << bits;
+          bits += 8;
+        }
+        //===//
+        state.offset += hold & ((1 << state.extra) - 1)/*BITS(state.extra)*/;
+        //--- DROPBITS(state.extra) ---//
+        hold >>>= state.extra;
+        bits -= state.extra;
+        //---//
+        state.back += state.extra;
+      }
+//#ifdef INFLATE_STRICT
+      if (state.offset > state.dmax) {
+        strm.msg = 'invalid distance too far back';
+        state.mode = BAD;
+        break;
+      }
+//#endif
+      //Tracevv((stderr, "inflate:         distance %u\n", state.offset));
+      state.mode = MATCH;
+      /* falls through */
+    case MATCH:
+      if (left === 0) { break inf_leave; }
+      copy = _out - left;
+      if (state.offset > copy) {         /* copy from window */
+        copy = state.offset - copy;
+        if (copy > state.whave) {
+          if (state.sane) {
+            strm.msg = 'invalid distance too far back';
+            state.mode = BAD;
+            break;
+          }
+// (!) This block is disabled in zlib defailts,
+// don't enable it for binary compatibility
+//#ifdef INFLATE_ALLOW_INVALID_DISTANCE_TOOFAR_ARRR
+//          Trace((stderr, "inflate.c too far\n"));
+//          copy -= state.whave;
+//          if (copy > state.length) { copy = state.length; }
+//          if (copy > left) { copy = left; }
+//          left -= copy;
+//          state.length -= copy;
+//          do {
+//            output[put++] = 0;
+//          } while (--copy);
+//          if (state.length === 0) { state.mode = LEN; }
+//          break;
+//#endif
+        }
+        if (copy > state.wnext) {
+          copy -= state.wnext;
+          from = state.wsize - copy;
+        }
+        else {
+          from = state.wnext - copy;
+        }
+        if (copy > state.length) { copy = state.length; }
+        from_source = state.window;
+      }
+      else {                              /* copy from output */
+        from_source = output;
+        from = put - state.offset;
+        copy = state.length;
+      }
+      if (copy > left) { copy = left; }
+      left -= copy;
+      state.length -= copy;
+      do {
+        output[put++] = from_source[from++];
+      } while (--copy);
+      if (state.length === 0) { state.mode = LEN; }
+      break;
+    case LIT:
+      if (left === 0) { break inf_leave; }
+      output[put++] = state.length;
+      left--;
+      state.mode = LEN;
+      break;
+    case CHECK:
+      if (state.wrap) {
+        //=== NEEDBITS(32);
+        while (bits < 32) {
+          if (have === 0) { break inf_leave; }
+          have--;
+          // Use '|' insdead of '+' to make sure that result is signed
+          hold |= input[next++] << bits;
+          bits += 8;
+        }
+        //===//
+        _out -= left;
+        strm.total_out += _out;
+        state.total += _out;
+        if (_out) {
+          strm.adler = state.check =
+              /*UPDATE(state.check, put - _out, _out);*/
+              (state.flags ? crc32(state.check, output, _out, put - _out) : adler32(state.check, output, _out, put - _out));
+
+        }
+        _out = left;
+        // NB: crc32 stored as signed 32-bit int, zswap32 returns signed too
+        if ((state.flags ? hold : zswap32(hold)) !== state.check) {
+          strm.msg = 'incorrect data check';
+          state.mode = BAD;
+          break;
+        }
+        //=== INITBITS();
+        hold = 0;
+        bits = 0;
+        //===//
+        //Tracev((stderr, "inflate:   check matches trailer\n"));
+      }
+      state.mode = LENGTH;
+      /* falls through */
+    case LENGTH:
+      if (state.wrap && state.flags) {
+        //=== NEEDBITS(32);
+        while (bits < 32) {
+          if (have === 0) { break inf_leave; }
+          have--;
+          hold += input[next++] << bits;
+          bits += 8;
+        }
+        //===//
+        if (hold !== (state.total & 0xffffffff)) {
+          strm.msg = 'incorrect length check';
+          state.mode = BAD;
+          break;
+        }
+        //=== INITBITS();
+        hold = 0;
+        bits = 0;
+        //===//
+        //Tracev((stderr, "inflate:   length matches trailer\n"));
+      }
+      state.mode = DONE;
+      /* falls through */
+    case DONE:
+      ret = Z_STREAM_END;
+      break inf_leave;
+    case BAD:
+      ret = Z_DATA_ERROR;
+      break inf_leave;
+    case MEM:
+      return Z_MEM_ERROR;
+    case SYNC:
+      /* falls through */
+    default:
+      return Z_STREAM_ERROR;
+    }
+  }
+
+  // inf_leave <- here is real place for "goto inf_leave", emulated via "break inf_leave"
+
+  /*
+     Return from inflate(), updating the total counts and the check value.
+     If there was no progress during the inflate() call, return a buffer
+     error.  Call updatewindow() to create and/or update the window state.
+     Note: a memory error from inflate() is non-recoverable.
+   */
+
+  //--- RESTORE() ---
+  strm.next_out = put;
+  strm.avail_out = left;
+  strm.next_in = next;
+  strm.avail_in = have;
+  state.hold = hold;
+  state.bits = bits;
+  //---
+
+  if (state.wsize || (_out !== strm.avail_out && state.mode < BAD &&
+                      (state.mode < CHECK || flush !== Z_FINISH))) {
+    if (updatewindow(strm, strm.output, strm.next_out, _out - strm.avail_out)) {
+      state.mode = MEM;
+      return Z_MEM_ERROR;
+    }
+  }
+  _in -= strm.avail_in;
+  _out -= strm.avail_out;
+  strm.total_in += _in;
+  strm.total_out += _out;
+  state.total += _out;
+  if (state.wrap && _out) {
+    strm.adler = state.check = /*UPDATE(state.check, strm.next_out - _out, _out);*/
+      (state.flags ? crc32(state.check, output, _out, strm.next_out - _out) : adler32(state.check, output, _out, strm.next_out - _out));
+  }
+  strm.data_type = state.bits + (state.last ? 64 : 0) +
+                    (state.mode === TYPE ? 128 : 0) +
+                    (state.mode === LEN_ || state.mode === COPY_ ? 256 : 0);
+  if (((_in === 0 && _out === 0) || flush === Z_FINISH) && ret === Z_OK) {
+    ret = Z_BUF_ERROR;
+  }
+  return ret;
+}
+
+function inflateEnd(strm) {
+
+  if (!strm || !strm.state /*|| strm->zfree == (free_func)0*/) {
+    return Z_STREAM_ERROR;
+  }
+
+  var state = strm.state;
+  if (state.window) {
+    state.window = null;
+  }
+  strm.state = null;
+  return Z_OK;
+}
+
+function inflateGetHeader(strm, head) {
+  var state;
+
+  /* check state */
+  if (!strm || !strm.state) { return Z_STREAM_ERROR; }
+  state = strm.state;
+  if ((state.wrap & 2) === 0) { return Z_STREAM_ERROR; }
+
+  /* save header structure */
+  state.head = head;
+  head.done = false;
+  return Z_OK;
+}
+
+function inflateSetDictionary(strm, dictionary) {
+  var dictLength = dictionary.length;
+
+  var state;
+  var dictid;
+  var ret;
+
+  /* check state */
+  if (!strm /* == Z_NULL */ || !strm.state /* == Z_NULL */) { return Z_STREAM_ERROR; }
+  state = strm.state;
+
+  if (state.wrap !== 0 && state.mode !== DICT) {
+    return Z_STREAM_ERROR;
+  }
+
+  /* check for correct dictionary identifier */
+  if (state.mode === DICT) {
+    dictid = 1; /* adler32(0, null, 0)*/
+    /* dictid = adler32(dictid, dictionary, dictLength); */
+    dictid = adler32(dictid, dictionary, dictLength, 0);
+    if (dictid !== state.check) {
+      return Z_DATA_ERROR;
+    }
+  }
+  /* copy dictionary to window using updatewindow(), which will amend the
+   existing dictionary if appropriate */
+  ret = updatewindow(strm, dictionary, dictLength, dictLength);
+  if (ret) {
+    state.mode = MEM;
+    return Z_MEM_ERROR;
+  }
+  state.havedict = 1;
+  // Tracev((stderr, "inflate:   dictionary set\n"));
+  return Z_OK;
+}
+
+export { inflateReset, inflateReset2, inflateResetKeep, inflateInit, inflateInit2, inflate, inflateEnd, inflateGetHeader, inflateSetDictionary };
+export var inflateInfo = 'pako inflate (from Nodeca project)';
+
+/* Not implemented
+exports.inflateCopy = inflateCopy;
+exports.inflateGetDictionary = inflateGetDictionary;
+exports.inflateMark = inflateMark;
+exports.inflatePrime = inflatePrime;
+exports.inflateSync = inflateSync;
+exports.inflateSyncPoint = inflateSyncPoint;
+exports.inflateUndermine = inflateUndermine;
+*/
pkg/web/noVNC/vendor/pako/lib/zlib/inftrees.js
@@ -0,0 +1,322 @@
+import * as utils from "../utils/common.js";
+
+var MAXBITS = 15;
+var ENOUGH_LENS = 852;
+var ENOUGH_DISTS = 592;
+//var ENOUGH = (ENOUGH_LENS+ENOUGH_DISTS);
+
+var CODES = 0;
+var LENS = 1;
+var DISTS = 2;
+
+var lbase = [ /* Length codes 257..285 base */
+  3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31,
+  35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258, 0, 0
+];
+
+var lext = [ /* Length codes 257..285 extra */
+  16, 16, 16, 16, 16, 16, 16, 16, 17, 17, 17, 17, 18, 18, 18, 18,
+  19, 19, 19, 19, 20, 20, 20, 20, 21, 21, 21, 21, 16, 72, 78
+];
+
+var dbase = [ /* Distance codes 0..29 base */
+  1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193,
+  257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, 6145,
+  8193, 12289, 16385, 24577, 0, 0
+];
+
+var dext = [ /* Distance codes 0..29 extra */
+  16, 16, 16, 16, 17, 17, 18, 18, 19, 19, 20, 20, 21, 21, 22, 22,
+  23, 23, 24, 24, 25, 25, 26, 26, 27, 27,
+  28, 28, 29, 29, 64, 64
+];
+
+export default function inflate_table(type, lens, lens_index, codes, table, table_index, work, opts)
+{
+  var bits = opts.bits;
+      //here = opts.here; /* table entry for duplication */
+
+  var len = 0;               /* a code's length in bits */
+  var sym = 0;               /* index of code symbols */
+  var min = 0, max = 0;          /* minimum and maximum code lengths */
+  var root = 0;              /* number of index bits for root table */
+  var curr = 0;              /* number of index bits for current table */
+  var drop = 0;              /* code bits to drop for sub-table */
+  var left = 0;                   /* number of prefix codes available */
+  var used = 0;              /* code entries in table used */
+  var huff = 0;              /* Huffman code */
+  var incr;              /* for incrementing code, index */
+  var fill;              /* index for replicating entries */
+  var low;               /* low bits for current root entry */
+  var mask;              /* mask for low root bits */
+  var next;             /* next available space in table */
+  var base = null;     /* base value table to use */
+  var base_index = 0;
+//  var shoextra;    /* extra bits table to use */
+  var end;                    /* use base and extra for symbol > end */
+  var count = new utils.Buf16(MAXBITS + 1); //[MAXBITS+1];    /* number of codes of each length */
+  var offs = new utils.Buf16(MAXBITS + 1); //[MAXBITS+1];     /* offsets in table for each length */
+  var extra = null;
+  var extra_index = 0;
+
+  var here_bits, here_op, here_val;
+
+  /*
+   Process a set of code lengths to create a canonical Huffman code.  The
+   code lengths are lens[0..codes-1].  Each length corresponds to the
+   symbols 0..codes-1.  The Huffman code is generated by first sorting the
+   symbols by length from short to long, and retaining the symbol order
+   for codes with equal lengths.  Then the code starts with all zero bits
+   for the first code of the shortest length, and the codes are integer
+   increments for the same length, and zeros are appended as the length
+   increases.  For the deflate format, these bits are stored backwards
+   from their more natural integer increment ordering, and so when the
+   decoding tables are built in the large loop below, the integer codes
+   are incremented backwards.
+
+   This routine assumes, but does not check, that all of the entries in
+   lens[] are in the range 0..MAXBITS.  The caller must assure this.
+   1..MAXBITS is interpreted as that code length.  zero means that that
+   symbol does not occur in this code.
+
+   The codes are sorted by computing a count of codes for each length,
+   creating from that a table of starting indices for each length in the
+   sorted table, and then entering the symbols in order in the sorted
+   table.  The sorted table is work[], with that space being provided by
+   the caller.
+
+   The length counts are used for other purposes as well, i.e. finding
+   the minimum and maximum length codes, determining if there are any
+   codes at all, checking for a valid set of lengths, and looking ahead
+   at length counts to determine sub-table sizes when building the
+   decoding tables.
+   */
+
+  /* accumulate lengths for codes (assumes lens[] all in 0..MAXBITS) */
+  for (len = 0; len <= MAXBITS; len++) {
+    count[len] = 0;
+  }
+  for (sym = 0; sym < codes; sym++) {
+    count[lens[lens_index + sym]]++;
+  }
+
+  /* bound code lengths, force root to be within code lengths */
+  root = bits;
+  for (max = MAXBITS; max >= 1; max--) {
+    if (count[max] !== 0) { break; }
+  }
+  if (root > max) {
+    root = max;
+  }
+  if (max === 0) {                     /* no symbols to code at all */
+    //table.op[opts.table_index] = 64;  //here.op = (var char)64;    /* invalid code marker */
+    //table.bits[opts.table_index] = 1;   //here.bits = (var char)1;
+    //table.val[opts.table_index++] = 0;   //here.val = (var short)0;
+    table[table_index++] = (1 << 24) | (64 << 16) | 0;
+
+
+    //table.op[opts.table_index] = 64;
+    //table.bits[opts.table_index] = 1;
+    //table.val[opts.table_index++] = 0;
+    table[table_index++] = (1 << 24) | (64 << 16) | 0;
+
+    opts.bits = 1;
+    return 0;     /* no symbols, but wait for decoding to report error */
+  }
+  for (min = 1; min < max; min++) {
+    if (count[min] !== 0) { break; }
+  }
+  if (root < min) {
+    root = min;
+  }
+
+  /* check for an over-subscribed or incomplete set of lengths */
+  left = 1;
+  for (len = 1; len <= MAXBITS; len++) {
+    left <<= 1;
+    left -= count[len];
+    if (left < 0) {
+      return -1;
+    }        /* over-subscribed */
+  }
+  if (left > 0 && (type === CODES || max !== 1)) {
+    return -1;                      /* incomplete set */
+  }
+
+  /* generate offsets into symbol table for each length for sorting */
+  offs[1] = 0;
+  for (len = 1; len < MAXBITS; len++) {
+    offs[len + 1] = offs[len] + count[len];
+  }
+
+  /* sort symbols by length, by symbol order within each length */
+  for (sym = 0; sym < codes; sym++) {
+    if (lens[lens_index + sym] !== 0) {
+      work[offs[lens[lens_index + sym]]++] = sym;
+    }
+  }
+
+  /*
+   Create and fill in decoding tables.  In this loop, the table being
+   filled is at next and has curr index bits.  The code being used is huff
+   with length len.  That code is converted to an index by dropping drop
+   bits off of the bottom.  For codes where len is less than drop + curr,
+   those top drop + curr - len bits are incremented through all values to
+   fill the table with replicated entries.
+
+   root is the number of index bits for the root table.  When len exceeds
+   root, sub-tables are created pointed to by the root entry with an index
+   of the low root bits of huff.  This is saved in low to check for when a
+   new sub-table should be started.  drop is zero when the root table is
+   being filled, and drop is root when sub-tables are being filled.
+
+   When a new sub-table is needed, it is necessary to look ahead in the
+   code lengths to determine what size sub-table is needed.  The length
+   counts are used for this, and so count[] is decremented as codes are
+   entered in the tables.
+
+   used keeps track of how many table entries have been allocated from the
+   provided *table space.  It is checked for LENS and DIST tables against
+   the constants ENOUGH_LENS and ENOUGH_DISTS to guard against changes in
+   the initial root table size constants.  See the comments in inftrees.h
+   for more information.
+
+   sym increments through all symbols, and the loop terminates when
+   all codes of length max, i.e. all codes, have been processed.  This
+   routine permits incomplete codes, so another loop after this one fills
+   in the rest of the decoding tables with invalid code markers.
+   */
+
+  /* set up for code type */
+  // poor man optimization - use if-else instead of switch,
+  // to avoid deopts in old v8
+  if (type === CODES) {
+    base = extra = work;    /* dummy value--not used */
+    end = 19;
+
+  } else if (type === LENS) {
+    base = lbase;
+    base_index -= 257;
+    extra = lext;
+    extra_index -= 257;
+    end = 256;
+
+  } else {                    /* DISTS */
+    base = dbase;
+    extra = dext;
+    end = -1;
+  }
+
+  /* initialize opts for loop */
+  huff = 0;                   /* starting code */
+  sym = 0;                    /* starting code symbol */
+  len = min;                  /* starting code length */
+  next = table_index;              /* current table to fill in */
+  curr = root;                /* current table index bits */
+  drop = 0;                   /* current bits to drop from code for index */
+  low = -1;                   /* trigger new sub-table when len > root */
+  used = 1 << root;          /* use root table entries */
+  mask = used - 1;            /* mask for comparing low */
+
+  /* check available table space */
+  if ((type === LENS && used > ENOUGH_LENS) ||
+    (type === DISTS && used > ENOUGH_DISTS)) {
+    return 1;
+  }
+
+  /* process all codes and make table entries */
+  for (;;) {
+    /* create table entry */
+    here_bits = len - drop;
+    if (work[sym] < end) {
+      here_op = 0;
+      here_val = work[sym];
+    }
+    else if (work[sym] > end) {
+      here_op = extra[extra_index + work[sym]];
+      here_val = base[base_index + work[sym]];
+    }
+    else {
+      here_op = 32 + 64;         /* end of block */
+      here_val = 0;
+    }
+
+    /* replicate for those indices with low len bits equal to huff */
+    incr = 1 << (len - drop);
+    fill = 1 << curr;
+    min = fill;                 /* save offset to next table */
+    do {
+      fill -= incr;
+      table[next + (huff >> drop) + fill] = (here_bits << 24) | (here_op << 16) | here_val |0;
+    } while (fill !== 0);
+
+    /* backwards increment the len-bit code huff */
+    incr = 1 << (len - 1);
+    while (huff & incr) {
+      incr >>= 1;
+    }
+    if (incr !== 0) {
+      huff &= incr - 1;
+      huff += incr;
+    } else {
+      huff = 0;
+    }
+
+    /* go to next symbol, update count, len */
+    sym++;
+    if (--count[len] === 0) {
+      if (len === max) { break; }
+      len = lens[lens_index + work[sym]];
+    }
+
+    /* create new sub-table if needed */
+    if (len > root && (huff & mask) !== low) {
+      /* if first time, transition to sub-tables */
+      if (drop === 0) {
+        drop = root;
+      }
+
+      /* increment past last table */
+      next += min;            /* here min is 1 << curr */
+
+      /* determine length of next table */
+      curr = len - drop;
+      left = 1 << curr;
+      while (curr + drop < max) {
+        left -= count[curr + drop];
+        if (left <= 0) { break; }
+        curr++;
+        left <<= 1;
+      }
+
+      /* check for enough space */
+      used += 1 << curr;
+      if ((type === LENS && used > ENOUGH_LENS) ||
+        (type === DISTS && used > ENOUGH_DISTS)) {
+        return 1;
+      }
+
+      /* point entry in root table to sub-table */
+      low = huff & mask;
+      /*table.op[low] = curr;
+      table.bits[low] = root;
+      table.val[low] = next - opts.table_index;*/
+      table[low] = (root << 24) | (curr << 16) | (next - table_index) |0;
+    }
+  }
+
+  /* fill in remaining table entry if code is incomplete (guaranteed to have
+   at most one remaining entry, since if the code is incomplete, the
+   maximum code length that was allowed to get this far is one bit) */
+  if (huff !== 0) {
+    //table.op[next + huff] = 64;            /* invalid code marker */
+    //table.bits[next + huff] = len - drop;
+    //table.val[next + huff] = 0;
+    table[next + huff] = ((len - drop) << 24) | (64 << 16) |0;
+  }
+
+  /* set return parameters */
+  //opts.table_index += used;
+  opts.bits = root;
+  return 0;
+};
pkg/web/noVNC/vendor/pako/lib/zlib/messages.js
@@ -0,0 +1,11 @@
+export default {
+  2:      'need dictionary',     /* Z_NEED_DICT       2  */
+  1:      'stream end',          /* Z_STREAM_END      1  */
+  0:      '',                    /* Z_OK              0  */
+  '-1':   'file error',          /* Z_ERRNO         (-1) */
+  '-2':   'stream error',        /* Z_STREAM_ERROR  (-2) */
+  '-3':   'data error',          /* Z_DATA_ERROR    (-3) */
+  '-4':   'insufficient memory', /* Z_MEM_ERROR     (-4) */
+  '-5':   'buffer error',        /* Z_BUF_ERROR     (-5) */
+  '-6':   'incompatible version' /* Z_VERSION_ERROR (-6) */
+};
pkg/web/noVNC/vendor/pako/lib/zlib/trees.js
@@ -0,0 +1,1195 @@
+import * as utils from "../utils/common.js";
+
+/* Public constants ==========================================================*/
+/* ===========================================================================*/
+
+
+//var Z_FILTERED          = 1;
+//var Z_HUFFMAN_ONLY      = 2;
+//var Z_RLE               = 3;
+var Z_FIXED               = 4;
+//var Z_DEFAULT_STRATEGY  = 0;
+
+/* Possible values of the data_type field (though see inflate()) */
+var Z_BINARY              = 0;
+var Z_TEXT                = 1;
+//var Z_ASCII             = 1; // = Z_TEXT
+var Z_UNKNOWN             = 2;
+
+/*============================================================================*/
+
+
+function zero(buf) { var len = buf.length; while (--len >= 0) { buf[len] = 0; } }
+
+// From zutil.h
+
+var STORED_BLOCK = 0;
+var STATIC_TREES = 1;
+var DYN_TREES    = 2;
+/* The three kinds of block type */
+
+var MIN_MATCH    = 3;
+var MAX_MATCH    = 258;
+/* The minimum and maximum match lengths */
+
+// From deflate.h
+/* ===========================================================================
+ * Internal compression state.
+ */
+
+var LENGTH_CODES  = 29;
+/* number of length codes, not counting the special END_BLOCK code */
+
+var LITERALS      = 256;
+/* number of literal bytes 0..255 */
+
+var L_CODES       = LITERALS + 1 + LENGTH_CODES;
+/* number of Literal or Length codes, including the END_BLOCK code */
+
+var D_CODES       = 30;
+/* number of distance codes */
+
+var BL_CODES      = 19;
+/* number of codes used to transfer the bit lengths */
+
+var HEAP_SIZE     = 2 * L_CODES + 1;
+/* maximum heap size */
+
+var MAX_BITS      = 15;
+/* All codes must not exceed MAX_BITS bits */
+
+var Buf_size      = 16;
+/* size of bit buffer in bi_buf */
+
+
+/* ===========================================================================
+ * Constants
+ */
+
+var MAX_BL_BITS = 7;
+/* Bit length codes must not exceed MAX_BL_BITS bits */
+
+var END_BLOCK   = 256;
+/* end of block literal code */
+
+var REP_3_6     = 16;
+/* repeat previous bit length 3-6 times (2 bits of repeat count) */
+
+var REPZ_3_10   = 17;
+/* repeat a zero length 3-10 times  (3 bits of repeat count) */
+
+var REPZ_11_138 = 18;
+/* repeat a zero length 11-138 times  (7 bits of repeat count) */
+
+/* eslint-disable comma-spacing,array-bracket-spacing */
+var extra_lbits =   /* extra bits for each length code */
+  [0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0];
+
+var extra_dbits =   /* extra bits for each distance code */
+  [0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13];
+
+var extra_blbits =  /* extra bits for each bit length code */
+  [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,3,7];
+
+var bl_order =
+  [16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15];
+/* eslint-enable comma-spacing,array-bracket-spacing */
+
+/* The lengths of the bit length codes are sent in order of decreasing
+ * probability, to avoid transmitting the lengths for unused bit length codes.
+ */
+
+/* ===========================================================================
+ * Local data. These are initialized only once.
+ */
+
+// We pre-fill arrays with 0 to avoid uninitialized gaps
+
+var DIST_CODE_LEN = 512; /* see definition of array dist_code below */
+
+// !!!! Use flat array insdead of structure, Freq = i*2, Len = i*2+1
+var static_ltree  = new Array((L_CODES + 2) * 2);
+zero(static_ltree);
+/* The static literal tree. Since the bit lengths are imposed, there is no
+ * need for the L_CODES extra codes used during heap construction. However
+ * The codes 286 and 287 are needed to build a canonical tree (see _tr_init
+ * below).
+ */
+
+var static_dtree  = new Array(D_CODES * 2);
+zero(static_dtree);
+/* The static distance tree. (Actually a trivial tree since all codes use
+ * 5 bits.)
+ */
+
+var _dist_code    = new Array(DIST_CODE_LEN);
+zero(_dist_code);
+/* Distance codes. The first 256 values correspond to the distances
+ * 3 .. 258, the last 256 values correspond to the top 8 bits of
+ * the 15 bit distances.
+ */
+
+var _length_code  = new Array(MAX_MATCH - MIN_MATCH + 1);
+zero(_length_code);
+/* length code for each normalized match length (0 == MIN_MATCH) */
+
+var base_length   = new Array(LENGTH_CODES);
+zero(base_length);
+/* First normalized length for each code (0 = MIN_MATCH) */
+
+var base_dist     = new Array(D_CODES);
+zero(base_dist);
+/* First normalized distance for each code (0 = distance of 1) */
+
+
+function StaticTreeDesc(static_tree, extra_bits, extra_base, elems, max_length) {
+
+  this.static_tree  = static_tree;  /* static tree or NULL */
+  this.extra_bits   = extra_bits;   /* extra bits for each code or NULL */
+  this.extra_base   = extra_base;   /* base index for extra_bits */
+  this.elems        = elems;        /* max number of elements in the tree */
+  this.max_length   = max_length;   /* max bit length for the codes */
+
+  // show if `static_tree` has data or dummy - needed for monomorphic objects
+  this.has_stree    = static_tree && static_tree.length;
+}
+
+
+var static_l_desc;
+var static_d_desc;
+var static_bl_desc;
+
+
+function TreeDesc(dyn_tree, stat_desc) {
+  this.dyn_tree = dyn_tree;     /* the dynamic tree */
+  this.max_code = 0;            /* largest code with non zero frequency */
+  this.stat_desc = stat_desc;   /* the corresponding static tree */
+}
+
+
+
+function d_code(dist) {
+  return dist < 256 ? _dist_code[dist] : _dist_code[256 + (dist >>> 7)];
+}
+
+
+/* ===========================================================================
+ * Output a short LSB first on the stream.
+ * IN assertion: there is enough room in pendingBuf.
+ */
+function put_short(s, w) {
+//    put_byte(s, (uch)((w) & 0xff));
+//    put_byte(s, (uch)((ush)(w) >> 8));
+  s.pending_buf[s.pending++] = (w) & 0xff;
+  s.pending_buf[s.pending++] = (w >>> 8) & 0xff;
+}
+
+
+/* ===========================================================================
+ * Send a value on a given number of bits.
+ * IN assertion: length <= 16 and value fits in length bits.
+ */
+function send_bits(s, value, length) {
+  if (s.bi_valid > (Buf_size - length)) {
+    s.bi_buf |= (value << s.bi_valid) & 0xffff;
+    put_short(s, s.bi_buf);
+    s.bi_buf = value >> (Buf_size - s.bi_valid);
+    s.bi_valid += length - Buf_size;
+  } else {
+    s.bi_buf |= (value << s.bi_valid) & 0xffff;
+    s.bi_valid += length;
+  }
+}
+
+
+function send_code(s, c, tree) {
+  send_bits(s, tree[c * 2]/*.Code*/, tree[c * 2 + 1]/*.Len*/);
+}
+
+
+/* ===========================================================================
+ * Reverse the first len bits of a code, using straightforward code (a faster
+ * method would use a table)
+ * IN assertion: 1 <= len <= 15
+ */
+function bi_reverse(code, len) {
+  var res = 0;
+  do {
+    res |= code & 1;
+    code >>>= 1;
+    res <<= 1;
+  } while (--len > 0);
+  return res >>> 1;
+}
+
+
+/* ===========================================================================
+ * Flush the bit buffer, keeping at most 7 bits in it.
+ */
+function bi_flush(s) {
+  if (s.bi_valid === 16) {
+    put_short(s, s.bi_buf);
+    s.bi_buf = 0;
+    s.bi_valid = 0;
+
+  } else if (s.bi_valid >= 8) {
+    s.pending_buf[s.pending++] = s.bi_buf & 0xff;
+    s.bi_buf >>= 8;
+    s.bi_valid -= 8;
+  }
+}
+
+
+/* ===========================================================================
+ * Compute the optimal bit lengths for a tree and update the total bit length
+ * for the current block.
+ * IN assertion: the fields freq and dad are set, heap[heap_max] and
+ *    above are the tree nodes sorted by increasing frequency.
+ * OUT assertions: the field len is set to the optimal bit length, the
+ *     array bl_count contains the frequencies for each bit length.
+ *     The length opt_len is updated; static_len is also updated if stree is
+ *     not null.
+ */
+function gen_bitlen(s, desc)
+//    deflate_state *s;
+//    tree_desc *desc;    /* the tree descriptor */
+{
+  var tree            = desc.dyn_tree;
+  var max_code        = desc.max_code;
+  var stree           = desc.stat_desc.static_tree;
+  var has_stree       = desc.stat_desc.has_stree;
+  var extra           = desc.stat_desc.extra_bits;
+  var base            = desc.stat_desc.extra_base;
+  var max_length      = desc.stat_desc.max_length;
+  var h;              /* heap index */
+  var n, m;           /* iterate over the tree elements */
+  var bits;           /* bit length */
+  var xbits;          /* extra bits */
+  var f;              /* frequency */
+  var overflow = 0;   /* number of elements with bit length too large */
+
+  for (bits = 0; bits <= MAX_BITS; bits++) {
+    s.bl_count[bits] = 0;
+  }
+
+  /* In a first pass, compute the optimal bit lengths (which may
+   * overflow in the case of the bit length tree).
+   */
+  tree[s.heap[s.heap_max] * 2 + 1]/*.Len*/ = 0; /* root of the heap */
+
+  for (h = s.heap_max + 1; h < HEAP_SIZE; h++) {
+    n = s.heap[h];
+    bits = tree[tree[n * 2 + 1]/*.Dad*/ * 2 + 1]/*.Len*/ + 1;
+    if (bits > max_length) {
+      bits = max_length;
+      overflow++;
+    }
+    tree[n * 2 + 1]/*.Len*/ = bits;
+    /* We overwrite tree[n].Dad which is no longer needed */
+
+    if (n > max_code) { continue; } /* not a leaf node */
+
+    s.bl_count[bits]++;
+    xbits = 0;
+    if (n >= base) {
+      xbits = extra[n - base];
+    }
+    f = tree[n * 2]/*.Freq*/;
+    s.opt_len += f * (bits + xbits);
+    if (has_stree) {
+      s.static_len += f * (stree[n * 2 + 1]/*.Len*/ + xbits);
+    }
+  }
+  if (overflow === 0) { return; }
+
+  // Trace((stderr,"\nbit length overflow\n"));
+  /* This happens for example on obj2 and pic of the Calgary corpus */
+
+  /* Find the first bit length which could increase: */
+  do {
+    bits = max_length - 1;
+    while (s.bl_count[bits] === 0) { bits--; }
+    s.bl_count[bits]--;      /* move one leaf down the tree */
+    s.bl_count[bits + 1] += 2; /* move one overflow item as its brother */
+    s.bl_count[max_length]--;
+    /* The brother of the overflow item also moves one step up,
+     * but this does not affect bl_count[max_length]
+     */
+    overflow -= 2;
+  } while (overflow > 0);
+
+  /* Now recompute all bit lengths, scanning in increasing frequency.
+   * h is still equal to HEAP_SIZE. (It is simpler to reconstruct all
+   * lengths instead of fixing only the wrong ones. This idea is taken
+   * from 'ar' written by Haruhiko Okumura.)
+   */
+  for (bits = max_length; bits !== 0; bits--) {
+    n = s.bl_count[bits];
+    while (n !== 0) {
+      m = s.heap[--h];
+      if (m > max_code) { continue; }
+      if (tree[m * 2 + 1]/*.Len*/ !== bits) {
+        // Trace((stderr,"code %d bits %d->%d\n", m, tree[m].Len, bits));
+        s.opt_len += (bits - tree[m * 2 + 1]/*.Len*/) * tree[m * 2]/*.Freq*/;
+        tree[m * 2 + 1]/*.Len*/ = bits;
+      }
+      n--;
+    }
+  }
+}
+
+
+/* ===========================================================================
+ * Generate the codes for a given tree and bit counts (which need not be
+ * optimal).
+ * IN assertion: the array bl_count contains the bit length statistics for
+ * the given tree and the field len is set for all tree elements.
+ * OUT assertion: the field code is set for all tree elements of non
+ *     zero code length.
+ */
+function gen_codes(tree, max_code, bl_count)
+//    ct_data *tree;             /* the tree to decorate */
+//    int max_code;              /* largest code with non zero frequency */
+//    ushf *bl_count;            /* number of codes at each bit length */
+{
+  var next_code = new Array(MAX_BITS + 1); /* next code value for each bit length */
+  var code = 0;              /* running code value */
+  var bits;                  /* bit index */
+  var n;                     /* code index */
+
+  /* The distribution counts are first used to generate the code values
+   * without bit reversal.
+   */
+  for (bits = 1; bits <= MAX_BITS; bits++) {
+    next_code[bits] = code = (code + bl_count[bits - 1]) << 1;
+  }
+  /* Check that the bit counts in bl_count are consistent. The last code
+   * must be all ones.
+   */
+  //Assert (code + bl_count[MAX_BITS]-1 == (1<<MAX_BITS)-1,
+  //        "inconsistent bit counts");
+  //Tracev((stderr,"\ngen_codes: max_code %d ", max_code));
+
+  for (n = 0;  n <= max_code; n++) {
+    var len = tree[n * 2 + 1]/*.Len*/;
+    if (len === 0) { continue; }
+    /* Now reverse the bits */
+    tree[n * 2]/*.Code*/ = bi_reverse(next_code[len]++, len);
+
+    //Tracecv(tree != static_ltree, (stderr,"\nn %3d %c l %2d c %4x (%x) ",
+    //     n, (isgraph(n) ? n : ' '), len, tree[n].Code, next_code[len]-1));
+  }
+}
+
+
+/* ===========================================================================
+ * Initialize the various 'constant' tables.
+ */
+function tr_static_init() {
+  var n;        /* iterates over tree elements */
+  var bits;     /* bit counter */
+  var length;   /* length value */
+  var code;     /* code value */
+  var dist;     /* distance index */
+  var bl_count = new Array(MAX_BITS + 1);
+  /* number of codes at each bit length for an optimal tree */
+
+  // do check in _tr_init()
+  //if (static_init_done) return;
+
+  /* For some embedded targets, global variables are not initialized: */
+/*#ifdef NO_INIT_GLOBAL_POINTERS
+  static_l_desc.static_tree = static_ltree;
+  static_l_desc.extra_bits = extra_lbits;
+  static_d_desc.static_tree = static_dtree;
+  static_d_desc.extra_bits = extra_dbits;
+  static_bl_desc.extra_bits = extra_blbits;
+#endif*/
+
+  /* Initialize the mapping length (0..255) -> length code (0..28) */
+  length = 0;
+  for (code = 0; code < LENGTH_CODES - 1; code++) {
+    base_length[code] = length;
+    for (n = 0; n < (1 << extra_lbits[code]); n++) {
+      _length_code[length++] = code;
+    }
+  }
+  //Assert (length == 256, "tr_static_init: length != 256");
+  /* Note that the length 255 (match length 258) can be represented
+   * in two different ways: code 284 + 5 bits or code 285, so we
+   * overwrite length_code[255] to use the best encoding:
+   */
+  _length_code[length - 1] = code;
+
+  /* Initialize the mapping dist (0..32K) -> dist code (0..29) */
+  dist = 0;
+  for (code = 0; code < 16; code++) {
+    base_dist[code] = dist;
+    for (n = 0; n < (1 << extra_dbits[code]); n++) {
+      _dist_code[dist++] = code;
+    }
+  }
+  //Assert (dist == 256, "tr_static_init: dist != 256");
+  dist >>= 7; /* from now on, all distances are divided by 128 */
+  for (; code < D_CODES; code++) {
+    base_dist[code] = dist << 7;
+    for (n = 0; n < (1 << (extra_dbits[code] - 7)); n++) {
+      _dist_code[256 + dist++] = code;
+    }
+  }
+  //Assert (dist == 256, "tr_static_init: 256+dist != 512");
+
+  /* Construct the codes of the static literal tree */
+  for (bits = 0; bits <= MAX_BITS; bits++) {
+    bl_count[bits] = 0;
+  }
+
+  n = 0;
+  while (n <= 143) {
+    static_ltree[n * 2 + 1]/*.Len*/ = 8;
+    n++;
+    bl_count[8]++;
+  }
+  while (n <= 255) {
+    static_ltree[n * 2 + 1]/*.Len*/ = 9;
+    n++;
+    bl_count[9]++;
+  }
+  while (n <= 279) {
+    static_ltree[n * 2 + 1]/*.Len*/ = 7;
+    n++;
+    bl_count[7]++;
+  }
+  while (n <= 287) {
+    static_ltree[n * 2 + 1]/*.Len*/ = 8;
+    n++;
+    bl_count[8]++;
+  }
+  /* Codes 286 and 287 do not exist, but we must include them in the
+   * tree construction to get a canonical Huffman tree (longest code
+   * all ones)
+   */
+  gen_codes(static_ltree, L_CODES + 1, bl_count);
+
+  /* The static distance tree is trivial: */
+  for (n = 0; n < D_CODES; n++) {
+    static_dtree[n * 2 + 1]/*.Len*/ = 5;
+    static_dtree[n * 2]/*.Code*/ = bi_reverse(n, 5);
+  }
+
+  // Now data ready and we can init static trees
+  static_l_desc = new StaticTreeDesc(static_ltree, extra_lbits, LITERALS + 1, L_CODES, MAX_BITS);
+  static_d_desc = new StaticTreeDesc(static_dtree, extra_dbits, 0,          D_CODES, MAX_BITS);
+  static_bl_desc = new StaticTreeDesc(new Array(0), extra_blbits, 0,         BL_CODES, MAX_BL_BITS);
+
+  //static_init_done = true;
+}
+
+
+/* ===========================================================================
+ * Initialize a new block.
+ */
+function init_block(s) {
+  var n; /* iterates over tree elements */
+
+  /* Initialize the trees. */
+  for (n = 0; n < L_CODES;  n++) { s.dyn_ltree[n * 2]/*.Freq*/ = 0; }
+  for (n = 0; n < D_CODES;  n++) { s.dyn_dtree[n * 2]/*.Freq*/ = 0; }
+  for (n = 0; n < BL_CODES; n++) { s.bl_tree[n * 2]/*.Freq*/ = 0; }
+
+  s.dyn_ltree[END_BLOCK * 2]/*.Freq*/ = 1;
+  s.opt_len = s.static_len = 0;
+  s.last_lit = s.matches = 0;
+}
+
+
+/* ===========================================================================
+ * Flush the bit buffer and align the output on a byte boundary
+ */
+function bi_windup(s)
+{
+  if (s.bi_valid > 8) {
+    put_short(s, s.bi_buf);
+  } else if (s.bi_valid > 0) {
+    //put_byte(s, (Byte)s->bi_buf);
+    s.pending_buf[s.pending++] = s.bi_buf;
+  }
+  s.bi_buf = 0;
+  s.bi_valid = 0;
+}
+
+/* ===========================================================================
+ * Copy a stored block, storing first the length and its
+ * one's complement if requested.
+ */
+function copy_block(s, buf, len, header)
+//DeflateState *s;
+//charf    *buf;    /* the input data */
+//unsigned len;     /* its length */
+//int      header;  /* true if block header must be written */
+{
+  bi_windup(s);        /* align on byte boundary */
+
+  if (header) {
+    put_short(s, len);
+    put_short(s, ~len);
+  }
+//  while (len--) {
+//    put_byte(s, *buf++);
+//  }
+  utils.arraySet(s.pending_buf, s.window, buf, len, s.pending);
+  s.pending += len;
+}
+
+/* ===========================================================================
+ * Compares to subtrees, using the tree depth as tie breaker when
+ * the subtrees have equal frequency. This minimizes the worst case length.
+ */
+function smaller(tree, n, m, depth) {
+  var _n2 = n * 2;
+  var _m2 = m * 2;
+  return (tree[_n2]/*.Freq*/ < tree[_m2]/*.Freq*/ ||
+         (tree[_n2]/*.Freq*/ === tree[_m2]/*.Freq*/ && depth[n] <= depth[m]));
+}
+
+/* ===========================================================================
+ * Restore the heap property by moving down the tree starting at node k,
+ * exchanging a node with the smallest of its two sons if necessary, stopping
+ * when the heap property is re-established (each father smaller than its
+ * two sons).
+ */
+function pqdownheap(s, tree, k)
+//    deflate_state *s;
+//    ct_data *tree;  /* the tree to restore */
+//    int k;               /* node to move down */
+{
+  var v = s.heap[k];
+  var j = k << 1;  /* left son of k */
+  while (j <= s.heap_len) {
+    /* Set j to the smallest of the two sons: */
+    if (j < s.heap_len &&
+      smaller(tree, s.heap[j + 1], s.heap[j], s.depth)) {
+      j++;
+    }
+    /* Exit if v is smaller than both sons */
+    if (smaller(tree, v, s.heap[j], s.depth)) { break; }
+
+    /* Exchange v with the smallest son */
+    s.heap[k] = s.heap[j];
+    k = j;
+
+    /* And continue down the tree, setting j to the left son of k */
+    j <<= 1;
+  }
+  s.heap[k] = v;
+}
+
+
+// inlined manually
+// var SMALLEST = 1;
+
+/* ===========================================================================
+ * Send the block data compressed using the given Huffman trees
+ */
+function compress_block(s, ltree, dtree)
+//    deflate_state *s;
+//    const ct_data *ltree; /* literal tree */
+//    const ct_data *dtree; /* distance tree */
+{
+  var dist;           /* distance of matched string */
+  var lc;             /* match length or unmatched char (if dist == 0) */
+  var lx = 0;         /* running index in l_buf */
+  var code;           /* the code to send */
+  var extra;          /* number of extra bits to send */
+
+  if (s.last_lit !== 0) {
+    do {
+      dist = (s.pending_buf[s.d_buf + lx * 2] << 8) | (s.pending_buf[s.d_buf + lx * 2 + 1]);
+      lc = s.pending_buf[s.l_buf + lx];
+      lx++;
+
+      if (dist === 0) {
+        send_code(s, lc, ltree); /* send a literal byte */
+        //Tracecv(isgraph(lc), (stderr," '%c' ", lc));
+      } else {
+        /* Here, lc is the match length - MIN_MATCH */
+        code = _length_code[lc];
+        send_code(s, code + LITERALS + 1, ltree); /* send the length code */
+        extra = extra_lbits[code];
+        if (extra !== 0) {
+          lc -= base_length[code];
+          send_bits(s, lc, extra);       /* send the extra length bits */
+        }
+        dist--; /* dist is now the match distance - 1 */
+        code = d_code(dist);
+        //Assert (code < D_CODES, "bad d_code");
+
+        send_code(s, code, dtree);       /* send the distance code */
+        extra = extra_dbits[code];
+        if (extra !== 0) {
+          dist -= base_dist[code];
+          send_bits(s, dist, extra);   /* send the extra distance bits */
+        }
+      } /* literal or match pair ? */
+
+      /* Check that the overlay between pending_buf and d_buf+l_buf is ok: */
+      //Assert((uInt)(s->pending) < s->lit_bufsize + 2*lx,
+      //       "pendingBuf overflow");
+
+    } while (lx < s.last_lit);
+  }
+
+  send_code(s, END_BLOCK, ltree);
+}
+
+
+/* ===========================================================================
+ * Construct one Huffman tree and assigns the code bit strings and lengths.
+ * Update the total bit length for the current block.
+ * IN assertion: the field freq is set for all tree elements.
+ * OUT assertions: the fields len and code are set to the optimal bit length
+ *     and corresponding code. The length opt_len is updated; static_len is
+ *     also updated if stree is not null. The field max_code is set.
+ */
+function build_tree(s, desc)
+//    deflate_state *s;
+//    tree_desc *desc; /* the tree descriptor */
+{
+  var tree     = desc.dyn_tree;
+  var stree    = desc.stat_desc.static_tree;
+  var has_stree = desc.stat_desc.has_stree;
+  var elems    = desc.stat_desc.elems;
+  var n, m;          /* iterate over heap elements */
+  var max_code = -1; /* largest code with non zero frequency */
+  var node;          /* new node being created */
+
+  /* Construct the initial heap, with least frequent element in
+   * heap[SMALLEST]. The sons of heap[n] are heap[2*n] and heap[2*n+1].
+   * heap[0] is not used.
+   */
+  s.heap_len = 0;
+  s.heap_max = HEAP_SIZE;
+
+  for (n = 0; n < elems; n++) {
+    if (tree[n * 2]/*.Freq*/ !== 0) {
+      s.heap[++s.heap_len] = max_code = n;
+      s.depth[n] = 0;
+
+    } else {
+      tree[n * 2 + 1]/*.Len*/ = 0;
+    }
+  }
+
+  /* The pkzip format requires that at least one distance code exists,
+   * and that at least one bit should be sent even if there is only one
+   * possible code. So to avoid special checks later on we force at least
+   * two codes of non zero frequency.
+   */
+  while (s.heap_len < 2) {
+    node = s.heap[++s.heap_len] = (max_code < 2 ? ++max_code : 0);
+    tree[node * 2]/*.Freq*/ = 1;
+    s.depth[node] = 0;
+    s.opt_len--;
+
+    if (has_stree) {
+      s.static_len -= stree[node * 2 + 1]/*.Len*/;
+    }
+    /* node is 0 or 1 so it does not have extra bits */
+  }
+  desc.max_code = max_code;
+
+  /* The elements heap[heap_len/2+1 .. heap_len] are leaves of the tree,
+   * establish sub-heaps of increasing lengths:
+   */
+  for (n = (s.heap_len >> 1/*int /2*/); n >= 1; n--) { pqdownheap(s, tree, n); }
+
+  /* Construct the Huffman tree by repeatedly combining the least two
+   * frequent nodes.
+   */
+  node = elems;              /* next internal node of the tree */
+  do {
+    //pqremove(s, tree, n);  /* n = node of least frequency */
+    /*** pqremove ***/
+    n = s.heap[1/*SMALLEST*/];
+    s.heap[1/*SMALLEST*/] = s.heap[s.heap_len--];
+    pqdownheap(s, tree, 1/*SMALLEST*/);
+    /***/
+
+    m = s.heap[1/*SMALLEST*/]; /* m = node of next least frequency */
+
+    s.heap[--s.heap_max] = n; /* keep the nodes sorted by frequency */
+    s.heap[--s.heap_max] = m;
+
+    /* Create a new node father of n and m */
+    tree[node * 2]/*.Freq*/ = tree[n * 2]/*.Freq*/ + tree[m * 2]/*.Freq*/;
+    s.depth[node] = (s.depth[n] >= s.depth[m] ? s.depth[n] : s.depth[m]) + 1;
+    tree[n * 2 + 1]/*.Dad*/ = tree[m * 2 + 1]/*.Dad*/ = node;
+
+    /* and insert the new node in the heap */
+    s.heap[1/*SMALLEST*/] = node++;
+    pqdownheap(s, tree, 1/*SMALLEST*/);
+
+  } while (s.heap_len >= 2);
+
+  s.heap[--s.heap_max] = s.heap[1/*SMALLEST*/];
+
+  /* At this point, the fields freq and dad are set. We can now
+   * generate the bit lengths.
+   */
+  gen_bitlen(s, desc);
+
+  /* The field len is now set, we can generate the bit codes */
+  gen_codes(tree, max_code, s.bl_count);
+}
+
+
+/* ===========================================================================
+ * Scan a literal or distance tree to determine the frequencies of the codes
+ * in the bit length tree.
+ */
+function scan_tree(s, tree, max_code)
+//    deflate_state *s;
+//    ct_data *tree;   /* the tree to be scanned */
+//    int max_code;    /* and its largest code of non zero frequency */
+{
+  var n;                     /* iterates over all tree elements */
+  var prevlen = -1;          /* last emitted length */
+  var curlen;                /* length of current code */
+
+  var nextlen = tree[0 * 2 + 1]/*.Len*/; /* length of next code */
+
+  var count = 0;             /* repeat count of the current code */
+  var max_count = 7;         /* max repeat count */
+  var min_count = 4;         /* min repeat count */
+
+  if (nextlen === 0) {
+    max_count = 138;
+    min_count = 3;
+  }
+  tree[(max_code + 1) * 2 + 1]/*.Len*/ = 0xffff; /* guard */
+
+  for (n = 0; n <= max_code; n++) {
+    curlen = nextlen;
+    nextlen = tree[(n + 1) * 2 + 1]/*.Len*/;
+
+    if (++count < max_count && curlen === nextlen) {
+      continue;
+
+    } else if (count < min_count) {
+      s.bl_tree[curlen * 2]/*.Freq*/ += count;
+
+    } else if (curlen !== 0) {
+
+      if (curlen !== prevlen) { s.bl_tree[curlen * 2]/*.Freq*/++; }
+      s.bl_tree[REP_3_6 * 2]/*.Freq*/++;
+
+    } else if (count <= 10) {
+      s.bl_tree[REPZ_3_10 * 2]/*.Freq*/++;
+
+    } else {
+      s.bl_tree[REPZ_11_138 * 2]/*.Freq*/++;
+    }
+
+    count = 0;
+    prevlen = curlen;
+
+    if (nextlen === 0) {
+      max_count = 138;
+      min_count = 3;
+
+    } else if (curlen === nextlen) {
+      max_count = 6;
+      min_count = 3;
+
+    } else {
+      max_count = 7;
+      min_count = 4;
+    }
+  }
+}
+
+
+/* ===========================================================================
+ * Send a literal or distance tree in compressed form, using the codes in
+ * bl_tree.
+ */
+function send_tree(s, tree, max_code)
+//    deflate_state *s;
+//    ct_data *tree; /* the tree to be scanned */
+//    int max_code;       /* and its largest code of non zero frequency */
+{
+  var n;                     /* iterates over all tree elements */
+  var prevlen = -1;          /* last emitted length */
+  var curlen;                /* length of current code */
+
+  var nextlen = tree[0 * 2 + 1]/*.Len*/; /* length of next code */
+
+  var count = 0;             /* repeat count of the current code */
+  var max_count = 7;         /* max repeat count */
+  var min_count = 4;         /* min repeat count */
+
+  /* tree[max_code+1].Len = -1; */  /* guard already set */
+  if (nextlen === 0) {
+    max_count = 138;
+    min_count = 3;
+  }
+
+  for (n = 0; n <= max_code; n++) {
+    curlen = nextlen;
+    nextlen = tree[(n + 1) * 2 + 1]/*.Len*/;
+
+    if (++count < max_count && curlen === nextlen) {
+      continue;
+
+    } else if (count < min_count) {
+      do { send_code(s, curlen, s.bl_tree); } while (--count !== 0);
+
+    } else if (curlen !== 0) {
+      if (curlen !== prevlen) {
+        send_code(s, curlen, s.bl_tree);
+        count--;
+      }
+      //Assert(count >= 3 && count <= 6, " 3_6?");
+      send_code(s, REP_3_6, s.bl_tree);
+      send_bits(s, count - 3, 2);
+
+    } else if (count <= 10) {
+      send_code(s, REPZ_3_10, s.bl_tree);
+      send_bits(s, count - 3, 3);
+
+    } else {
+      send_code(s, REPZ_11_138, s.bl_tree);
+      send_bits(s, count - 11, 7);
+    }
+
+    count = 0;
+    prevlen = curlen;
+    if (nextlen === 0) {
+      max_count = 138;
+      min_count = 3;
+
+    } else if (curlen === nextlen) {
+      max_count = 6;
+      min_count = 3;
+
+    } else {
+      max_count = 7;
+      min_count = 4;
+    }
+  }
+}
+
+
+/* ===========================================================================
+ * Construct the Huffman tree for the bit lengths and return the index in
+ * bl_order of the last bit length code to send.
+ */
+function build_bl_tree(s) {
+  var max_blindex;  /* index of last bit length code of non zero freq */
+
+  /* Determine the bit length frequencies for literal and distance trees */
+  scan_tree(s, s.dyn_ltree, s.l_desc.max_code);
+  scan_tree(s, s.dyn_dtree, s.d_desc.max_code);
+
+  /* Build the bit length tree: */
+  build_tree(s, s.bl_desc);
+  /* opt_len now includes the length of the tree representations, except
+   * the lengths of the bit lengths codes and the 5+5+4 bits for the counts.
+   */
+
+  /* Determine the number of bit length codes to send. The pkzip format
+   * requires that at least 4 bit length codes be sent. (appnote.txt says
+   * 3 but the actual value used is 4.)
+   */
+  for (max_blindex = BL_CODES - 1; max_blindex >= 3; max_blindex--) {
+    if (s.bl_tree[bl_order[max_blindex] * 2 + 1]/*.Len*/ !== 0) {
+      break;
+    }
+  }
+  /* Update opt_len to include the bit length tree and counts */
+  s.opt_len += 3 * (max_blindex + 1) + 5 + 5 + 4;
+  //Tracev((stderr, "\ndyn trees: dyn %ld, stat %ld",
+  //        s->opt_len, s->static_len));
+
+  return max_blindex;
+}
+
+
+/* ===========================================================================
+ * Send the header for a block using dynamic Huffman trees: the counts, the
+ * lengths of the bit length codes, the literal tree and the distance tree.
+ * IN assertion: lcodes >= 257, dcodes >= 1, blcodes >= 4.
+ */
+function send_all_trees(s, lcodes, dcodes, blcodes)
+//    deflate_state *s;
+//    int lcodes, dcodes, blcodes; /* number of codes for each tree */
+{
+  var rank;                    /* index in bl_order */
+
+  //Assert (lcodes >= 257 && dcodes >= 1 && blcodes >= 4, "not enough codes");
+  //Assert (lcodes <= L_CODES && dcodes <= D_CODES && blcodes <= BL_CODES,
+  //        "too many codes");
+  //Tracev((stderr, "\nbl counts: "));
+  send_bits(s, lcodes - 257, 5); /* not +255 as stated in appnote.txt */
+  send_bits(s, dcodes - 1,   5);
+  send_bits(s, blcodes - 4,  4); /* not -3 as stated in appnote.txt */
+  for (rank = 0; rank < blcodes; rank++) {
+    //Tracev((stderr, "\nbl code %2d ", bl_order[rank]));
+    send_bits(s, s.bl_tree[bl_order[rank] * 2 + 1]/*.Len*/, 3);
+  }
+  //Tracev((stderr, "\nbl tree: sent %ld", s->bits_sent));
+
+  send_tree(s, s.dyn_ltree, lcodes - 1); /* literal tree */
+  //Tracev((stderr, "\nlit tree: sent %ld", s->bits_sent));
+
+  send_tree(s, s.dyn_dtree, dcodes - 1); /* distance tree */
+  //Tracev((stderr, "\ndist tree: sent %ld", s->bits_sent));
+}
+
+
+/* ===========================================================================
+ * Check if the data type is TEXT or BINARY, using the following algorithm:
+ * - TEXT if the two conditions below are satisfied:
+ *    a) There are no non-portable control characters belonging to the
+ *       "black list" (0..6, 14..25, 28..31).
+ *    b) There is at least one printable character belonging to the
+ *       "white list" (9 {TAB}, 10 {LF}, 13 {CR}, 32..255).
+ * - BINARY otherwise.
+ * - The following partially-portable control characters form a
+ *   "gray list" that is ignored in this detection algorithm:
+ *   (7 {BEL}, 8 {BS}, 11 {VT}, 12 {FF}, 26 {SUB}, 27 {ESC}).
+ * IN assertion: the fields Freq of dyn_ltree are set.
+ */
+function detect_data_type(s) {
+  /* black_mask is the bit mask of black-listed bytes
+   * set bits 0..6, 14..25, and 28..31
+   * 0xf3ffc07f = binary 11110011111111111100000001111111
+   */
+  var black_mask = 0xf3ffc07f;
+  var n;
+
+  /* Check for non-textual ("black-listed") bytes. */
+  for (n = 0; n <= 31; n++, black_mask >>>= 1) {
+    if ((black_mask & 1) && (s.dyn_ltree[n * 2]/*.Freq*/ !== 0)) {
+      return Z_BINARY;
+    }
+  }
+
+  /* Check for textual ("white-listed") bytes. */
+  if (s.dyn_ltree[9 * 2]/*.Freq*/ !== 0 || s.dyn_ltree[10 * 2]/*.Freq*/ !== 0 ||
+      s.dyn_ltree[13 * 2]/*.Freq*/ !== 0) {
+    return Z_TEXT;
+  }
+  for (n = 32; n < LITERALS; n++) {
+    if (s.dyn_ltree[n * 2]/*.Freq*/ !== 0) {
+      return Z_TEXT;
+    }
+  }
+
+  /* There are no "black-listed" or "white-listed" bytes:
+   * this stream either is empty or has tolerated ("gray-listed") bytes only.
+   */
+  return Z_BINARY;
+}
+
+
+var static_init_done = false;
+
+/* ===========================================================================
+ * Initialize the tree data structures for a new zlib stream.
+ */
+function _tr_init(s)
+{
+
+  if (!static_init_done) {
+    tr_static_init();
+    static_init_done = true;
+  }
+
+  s.l_desc  = new TreeDesc(s.dyn_ltree, static_l_desc);
+  s.d_desc  = new TreeDesc(s.dyn_dtree, static_d_desc);
+  s.bl_desc = new TreeDesc(s.bl_tree, static_bl_desc);
+
+  s.bi_buf = 0;
+  s.bi_valid = 0;
+
+  /* Initialize the first block of the first file: */
+  init_block(s);
+}
+
+
+/* ===========================================================================
+ * Send a stored block
+ */
+function _tr_stored_block(s, buf, stored_len, last)
+//DeflateState *s;
+//charf *buf;       /* input block */
+//ulg stored_len;   /* length of input block */
+//int last;         /* one if this is the last block for a file */
+{
+  send_bits(s, (STORED_BLOCK << 1) + (last ? 1 : 0), 3);    /* send block type */
+  copy_block(s, buf, stored_len, true); /* with header */
+}
+
+
+/* ===========================================================================
+ * Send one empty static block to give enough lookahead for inflate.
+ * This takes 10 bits, of which 7 may remain in the bit buffer.
+ */
+function _tr_align(s) {
+  send_bits(s, STATIC_TREES << 1, 3);
+  send_code(s, END_BLOCK, static_ltree);
+  bi_flush(s);
+}
+
+
+/* ===========================================================================
+ * Determine the best encoding for the current block: dynamic trees, static
+ * trees or store, and output the encoded block to the zip file.
+ */
+function _tr_flush_block(s, buf, stored_len, last)
+//DeflateState *s;
+//charf *buf;       /* input block, or NULL if too old */
+//ulg stored_len;   /* length of input block */
+//int last;         /* one if this is the last block for a file */
+{
+  var opt_lenb, static_lenb;  /* opt_len and static_len in bytes */
+  var max_blindex = 0;        /* index of last bit length code of non zero freq */
+
+  /* Build the Huffman trees unless a stored block is forced */
+  if (s.level > 0) {
+
+    /* Check if the file is binary or text */
+    if (s.strm.data_type === Z_UNKNOWN) {
+      s.strm.data_type = detect_data_type(s);
+    }
+
+    /* Construct the literal and distance trees */
+    build_tree(s, s.l_desc);
+    // Tracev((stderr, "\nlit data: dyn %ld, stat %ld", s->opt_len,
+    //        s->static_len));
+
+    build_tree(s, s.d_desc);
+    // Tracev((stderr, "\ndist data: dyn %ld, stat %ld", s->opt_len,
+    //        s->static_len));
+    /* At this point, opt_len and static_len are the total bit lengths of
+     * the compressed block data, excluding the tree representations.
+     */
+
+    /* Build the bit length tree for the above two trees, and get the index
+     * in bl_order of the last bit length code to send.
+     */
+    max_blindex = build_bl_tree(s);
+
+    /* Determine the best encoding. Compute the block lengths in bytes. */
+    opt_lenb = (s.opt_len + 3 + 7) >>> 3;
+    static_lenb = (s.static_len + 3 + 7) >>> 3;
+
+    // Tracev((stderr, "\nopt %lu(%lu) stat %lu(%lu) stored %lu lit %u ",
+    //        opt_lenb, s->opt_len, static_lenb, s->static_len, stored_len,
+    //        s->last_lit));
+
+    if (static_lenb <= opt_lenb) { opt_lenb = static_lenb; }
+
+  } else {
+    // Assert(buf != (char*)0, "lost buf");
+    opt_lenb = static_lenb = stored_len + 5; /* force a stored block */
+  }
+
+  if ((stored_len + 4 <= opt_lenb) && (buf !== -1)) {
+    /* 4: two words for the lengths */
+
+    /* The test buf != NULL is only necessary if LIT_BUFSIZE > WSIZE.
+     * Otherwise we can't have processed more than WSIZE input bytes since
+     * the last block flush, because compression would have been
+     * successful. If LIT_BUFSIZE <= WSIZE, it is never too late to
+     * transform a block into a stored block.
+     */
+    _tr_stored_block(s, buf, stored_len, last);
+
+  } else if (s.strategy === Z_FIXED || static_lenb === opt_lenb) {
+
+    send_bits(s, (STATIC_TREES << 1) + (last ? 1 : 0), 3);
+    compress_block(s, static_ltree, static_dtree);
+
+  } else {
+    send_bits(s, (DYN_TREES << 1) + (last ? 1 : 0), 3);
+    send_all_trees(s, s.l_desc.max_code + 1, s.d_desc.max_code + 1, max_blindex + 1);
+    compress_block(s, s.dyn_ltree, s.dyn_dtree);
+  }
+  // Assert (s->compressed_len == s->bits_sent, "bad compressed size");
+  /* The above check is made mod 2^32, for files larger than 512 MB
+   * and uLong implemented on 32 bits.
+   */
+  init_block(s);
+
+  if (last) {
+    bi_windup(s);
+  }
+  // Tracev((stderr,"\ncomprlen %lu(%lu) ", s->compressed_len>>3,
+  //       s->compressed_len-7*last));
+}
+
+/* ===========================================================================
+ * Save the match info and tally the frequency counts. Return true if
+ * the current block must be flushed.
+ */
+function _tr_tally(s, dist, lc)
+//    deflate_state *s;
+//    unsigned dist;  /* distance of matched string */
+//    unsigned lc;    /* match length-MIN_MATCH or unmatched char (if dist==0) */
+{
+  //var out_length, in_length, dcode;
+
+  s.pending_buf[s.d_buf + s.last_lit * 2]     = (dist >>> 8) & 0xff;
+  s.pending_buf[s.d_buf + s.last_lit * 2 + 1] = dist & 0xff;
+
+  s.pending_buf[s.l_buf + s.last_lit] = lc & 0xff;
+  s.last_lit++;
+
+  if (dist === 0) {
+    /* lc is the unmatched char */
+    s.dyn_ltree[lc * 2]/*.Freq*/++;
+  } else {
+    s.matches++;
+    /* Here, lc is the match length - MIN_MATCH */
+    dist--;             /* dist = match distance - 1 */
+    //Assert((ush)dist < (ush)MAX_DIST(s) &&
+    //       (ush)lc <= (ush)(MAX_MATCH-MIN_MATCH) &&
+    //       (ush)d_code(dist) < (ush)D_CODES,  "_tr_tally: bad match");
+
+    s.dyn_ltree[(_length_code[lc] + LITERALS + 1) * 2]/*.Freq*/++;
+    s.dyn_dtree[d_code(dist) * 2]/*.Freq*/++;
+  }
+
+// (!) This block is disabled in zlib defailts,
+// don't enable it for binary compatibility
+
+//#ifdef TRUNCATE_BLOCK
+//  /* Try to guess if it is profitable to stop the current block here */
+//  if ((s.last_lit & 0x1fff) === 0 && s.level > 2) {
+//    /* Compute an upper bound for the compressed length */
+//    out_length = s.last_lit*8;
+//    in_length = s.strstart - s.block_start;
+//
+//    for (dcode = 0; dcode < D_CODES; dcode++) {
+//      out_length += s.dyn_dtree[dcode*2]/*.Freq*/ * (5 + extra_dbits[dcode]);
+//    }
+//    out_length >>>= 3;
+//    //Tracev((stderr,"\nlast_lit %u, in %ld, out ~%ld(%ld%%) ",
+//    //       s->last_lit, in_length, out_length,
+//    //       100L - out_length*100L/in_length));
+//    if (s.matches < (s.last_lit>>1)/*int /2*/ && out_length < (in_length>>1)/*int /2*/) {
+//      return true;
+//    }
+//  }
+//#endif
+
+  return (s.last_lit === s.lit_bufsize - 1);
+  /* We avoid equality with lit_bufsize because of wraparound at 64K
+   * on 16 bit machines and because stored blocks are restricted to
+   * 64K-1 bytes.
+   */
+}
+
+export { _tr_init, _tr_stored_block, _tr_flush_block, _tr_tally, _tr_align };
pkg/web/noVNC/vendor/pako/lib/zlib/zstream.js
@@ -0,0 +1,24 @@
+export default function ZStream() {
+  /* next input byte */
+  this.input = null; // JS specific, because we have no pointers
+  this.next_in = 0;
+  /* number of bytes available at input */
+  this.avail_in = 0;
+  /* total number of input bytes read so far */
+  this.total_in = 0;
+  /* next output byte should be put there */
+  this.output = null; // JS specific, because we have no pointers
+  this.next_out = 0;
+  /* remaining free space at output */
+  this.avail_out = 0;
+  /* total number of bytes output so far */
+  this.total_out = 0;
+  /* last error message, NULL if no error */
+  this.msg = ''/*Z_NULL*/;
+  /* not visible by applications */
+  this.state = null;
+  /* best guess about the data type: binary or text */
+  this.data_type = 2/*Z_UNKNOWN*/;
+  /* adler32 value of the uncompressed data */
+  this.adler = 0;
+}
pkg/web/noVNC/vendor/pako/LICENSE
@@ -0,0 +1,21 @@
+(The MIT License)
+
+Copyright (C) 2014-2016 by Vitaly Puzrin
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
pkg/web/noVNC/vendor/pako/README.md
@@ -0,0 +1,6 @@
+This is an ES6-modules-compatible version of
+https://github.com/nodeca/pako, based on pako version 1.0.3.
+
+It's more-or-less a direct translation of the original, with unused parts
+removed, and the dynamic support for non-typed arrays removed (since ES6
+modules don't work well with dynamic exports).
pkg/web/noVNC/.gitkeep
@@ -0,0 +1,1 @@
+placeholder
pkg/web/noVNC/LICENSE.txt
@@ -0,0 +1,62 @@
+noVNC is Copyright (C) 2022 The noVNC authors
+(./AUTHORS)
+
+The noVNC core library files are licensed under the MPL 2.0 (Mozilla
+Public License 2.0). The noVNC core library is composed of the
+Javascript code necessary for full noVNC operation. This includes (but
+is not limited to):
+
+    core/**/*.js
+    app/*.js
+    test/playback.js
+
+The HTML, CSS, font and images files that included with the noVNC
+source distibution (or repository) are not considered part of the
+noVNC core library and are licensed under more permissive licenses.
+The intent is to allow easy integration of noVNC into existing web
+sites and web applications.
+
+The HTML, CSS, font and image files are licensed as follows:
+
+    *.html                     : 2-Clause BSD license
+
+    app/styles/*.css           : 2-Clause BSD license
+
+    app/styles/Orbitron*       : SIL Open Font License 1.1
+                                 (Copyright 2009 Matt McInerney)
+
+    app/images/                : Creative Commons Attribution-ShareAlike
+                                 http://creativecommons.org/licenses/by-sa/3.0/
+
+Some portions of noVNC are copyright to their individual authors.
+Please refer to the individual source files and/or to the noVNC commit
+history: https://github.com/novnc/noVNC/commits/master
+
+The are several files and projects that have been incorporated into
+the noVNC core library. Here is a list of those files and the original
+licenses (all MPL 2.0 compatible):
+
+    core/base64.js          : MPL 2.0
+
+    core/des.js             : Various BSD style licenses
+
+    vendor/pako/            : MIT
+
+Any other files not mentioned above are typically marked with
+a copyright/license header at the top of the file. The default noVNC
+license is MPL-2.0.
+
+The following license texts are included:
+
+    docs/LICENSE.MPL-2.0
+    docs/LICENSE.OFL-1.1
+    docs/LICENSE.BSD-3-Clause (New BSD)
+    docs/LICENSE.BSD-2-Clause (Simplified BSD / FreeBSD)
+    vendor/pako/LICENSE (MIT)
+
+Or alternatively the license texts may be found here:
+
+    http://www.mozilla.org/MPL/2.0/
+    http://scripts.sil.org/OFL
+    http://en.wikipedia.org/wiki/BSD_licenses
+    https://opensource.org/licenses/MIT
pkg/web/noVNC/vnc.html
@@ -0,0 +1,423 @@
+<!DOCTYPE html>
+<html lang="en" class="noVNC_loading">
+<head>
+
+    <!--
+    noVNC example: simple example using default UI
+    Copyright (C) 2019 The noVNC authors
+    noVNC is licensed under the MPL 2.0 (see LICENSE.txt)
+    This file is licensed under the 2-Clause BSD license (see LICENSE.txt).
+
+    Connect parameters are provided in query string:
+        http://example.com/?host=HOST&port=PORT&encrypt=1
+    or the fragment:
+        http://example.com/#host=HOST&port=PORT&encrypt=1
+    -->
+    <title>noVNC</title>
+
+    <link rel="icon" type="image/x-icon" href="app/images/icons/novnc.ico">
+    <meta name="theme-color" content="#313131">
+
+    <!-- Apple iOS Safari settings -->
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
+    <meta name="apple-mobile-web-app-capable" content="yes">
+    <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
+
+    <!-- @2x -->
+    <link rel="apple-touch-icon" sizes="40x40" type="image/png" href="app/images/icons/novnc-ios-40.png">
+    <link rel="apple-touch-icon" sizes="58x58" type="image/png" href="app/images/icons/novnc-ios-58.png">
+    <link rel="apple-touch-icon" sizes="80x80" type="image/png" href="app/images/icons/novnc-ios-80.png">
+    <link rel="apple-touch-icon" sizes="120x120" type="image/png" href="app/images/icons/novnc-ios-120.png">
+    <link rel="apple-touch-icon" sizes="152x152" type="image/png" href="app/images/icons/novnc-ios-152.png">
+    <link rel="apple-touch-icon" sizes="167x167" type="image/png" href="app/images/icons/novnc-ios-167.png">
+    <!-- @3x -->
+    <link rel="apple-touch-icon" sizes="60x60" type="image/png" href="app/images/icons/novnc-ios-60.png">
+    <link rel="apple-touch-icon" sizes="87x87" type="image/png" href="app/images/icons/novnc-ios-87.png">
+    <link rel="apple-touch-icon" sizes="120x120" type="image/png" href="app/images/icons/novnc-ios-120.png">
+    <link rel="apple-touch-icon" sizes="180x180" type="image/png" href="app/images/icons/novnc-ios-180.png">
+
+    <!-- Stylesheets -->
+    <link rel="stylesheet" href="app/styles/constants.css">
+    <link rel="stylesheet" href="app/styles/base.css">
+    <link rel="stylesheet" href="app/styles/input.css">
+
+    <!-- Images that will later appear via CSS -->
+    <link rel="preload" as="image" href="app/images/info.svg">
+    <link rel="preload" as="image" href="app/images/error.svg">
+    <link rel="preload" as="image" href="app/images/warning.svg">
+
+    <script type="module" crossorigin="anonymous" src="app/error-handler.js"></script>
+
+    <script type="module">
+        import UI from "./app/ui.js";
+        import * as Log from './core/util/logging.js';
+
+        let response;
+
+        let defaults = {};
+        let mandatory = {};
+
+        // Default settings will be loaded from defaults.json. Mandatory
+        // settings will be loaded from mandatory.json, which the user
+        // cannot change.
+
+        try {
+            response = await fetch('./defaults.json');
+            if (!response.ok) {
+                throw Error("" + response.status + " " + response.statusText);
+            }
+
+            defaults = await response.json();
+        } catch (err) {
+            Log.Error("Couldn't fetch defaults.json: " + err);
+        }
+
+        try {
+            response = await fetch('./mandatory.json');
+            if (!response.ok) {
+                throw Error("" + response.status + " " + response.statusText);
+            }
+
+            mandatory = await response.json();
+        } catch (err) {
+            Log.Error("Couldn't fetch mandatory.json: " + err);
+        }
+
+        // You can also override any defaults you need here:
+        //
+        // defaults['host'] = 'vnc.example.com';
+
+        // Or force a specific setting, preventing the user from
+        // changing it:
+        //
+        // mandatory['view_only'] = true;
+
+        // See docs/EMBEDDING.md for a list of possible settings.
+
+        UI.start({ settings: { defaults: defaults,
+                               mandatory: mandatory } });
+    </script>
+</head>
+
+<body>
+
+    <div id="noVNC_fallback_error" class="noVNC_center">
+        <div>
+            <div>noVNC encountered an error:</div>
+            <br>
+            <div id="noVNC_fallback_errormsg"></div>
+        </div>
+    </div>
+
+    <!-- noVNC control bar -->
+    <div id="noVNC_control_bar_anchor" class="noVNC_vcenter">
+
+        <div id="noVNC_control_bar">
+            <div id="noVNC_control_bar_handle" title="Hide/Show the control bar"><div></div></div>
+
+            <div class="noVNC_scroll">
+
+            <h1 class="noVNC_logo" translate="no"><span>no</span><br>VNC</h1>
+
+            <hr>
+
+            <!-- Drag/Pan the viewport -->
+            <input type="image" alt="Drag" src="app/images/drag.svg"
+                id="noVNC_view_drag_button" class="noVNC_button noVNC_hidden"
+                title="Move/Drag viewport">
+
+            <!--noVNC touch device only buttons-->
+            <div id="noVNC_mobile_buttons">
+                <input type="image" alt="Keyboard" src="app/images/keyboard.svg"
+                    id="noVNC_keyboard_button" class="noVNC_button" title="Show keyboard">
+            </div>
+
+            <!-- Extra manual keys -->
+            <input type="image" alt="Extra keys" src="app/images/toggleextrakeys.svg"
+                id="noVNC_toggle_extra_keys_button" class="noVNC_button"
+                title="Show extra keys">
+            <div class="noVNC_vcenter">
+            <div id="noVNC_modifiers" class="noVNC_panel">
+                <input type="image" alt="Ctrl" src="app/images/ctrl.svg"
+                    id="noVNC_toggle_ctrl_button" class="noVNC_button"
+                    title="Toggle Ctrl">
+                <input type="image" alt="Alt" src="app/images/alt.svg"
+                    id="noVNC_toggle_alt_button" class="noVNC_button"
+                    title="Toggle Alt">
+                <input type="image" alt="Windows" src="app/images/windows.svg"
+                    id="noVNC_toggle_windows_button" class="noVNC_button"
+                    title="Toggle Windows">
+                <input type="image" alt="Tab" src="app/images/tab.svg"
+                    id="noVNC_send_tab_button" class="noVNC_button"
+                    title="Send Tab">
+                <input type="image" alt="Esc" src="app/images/esc.svg"
+                    id="noVNC_send_esc_button" class="noVNC_button"
+                    title="Send Escape">
+                <input type="image" alt="Ctrl+Alt+Del" src="app/images/ctrlaltdel.svg"
+                    id="noVNC_send_ctrl_alt_del_button" class="noVNC_button"
+                    title="Send Ctrl-Alt-Del">
+            </div>
+            </div>
+
+            <!-- Shutdown/Reboot -->
+            <input type="image" alt="Shutdown/Reboot" src="app/images/power.svg"
+                id="noVNC_power_button" class="noVNC_button"
+                title="Shutdown/Reboot...">
+            <div class="noVNC_vcenter">
+            <div id="noVNC_power" class="noVNC_panel">
+                <div class="noVNC_heading">
+                    <img alt="" src="app/images/power.svg"> Power
+                </div>
+                <input type="button" id="noVNC_shutdown_button" value="Shutdown">
+                <input type="button" id="noVNC_reboot_button" value="Reboot">
+                <input type="button" id="noVNC_reset_button" value="Reset">
+            </div>
+            </div>
+
+            <!-- Clipboard -->
+            <input type="image" alt="Clipboard" src="app/images/clipboard.svg"
+                id="noVNC_clipboard_button" class="noVNC_button"
+                title="Clipboard">
+            <div class="noVNC_vcenter">
+            <div id="noVNC_clipboard" class="noVNC_panel">
+                <div class="noVNC_heading">
+                    <img alt="" src="app/images/clipboard.svg"> Clipboard
+                </div>
+                <p class="noVNC_subheading">
+                    Edit clipboard content in the textarea below.
+                </p>
+                <textarea id="noVNC_clipboard_text" rows=5></textarea>
+            </div>
+            </div>
+
+            <!-- Toggle fullscreen -->
+            <input type="image" alt="Full screen" src="app/images/fullscreen.svg"
+                id="noVNC_fullscreen_button" class="noVNC_button noVNC_hidden"
+                title="Full screen">
+
+            <!-- Settings -->
+            <input type="image" alt="Settings" src="app/images/settings.svg"
+                id="noVNC_settings_button" class="noVNC_button"
+                title="Settings">
+            <div class="noVNC_vcenter">
+            <div id="noVNC_settings" class="noVNC_panel">
+                <div class="noVNC_heading">
+                    <img alt="" src="app/images/settings.svg"> Settings
+                </div>
+                <ul>
+                    <li>
+                        <label>
+                            <input id="noVNC_setting_shared" type="checkbox"
+                                   class="toggle">
+                            Shared mode
+                        </label>
+                    </li>
+                    <li>
+                        <label>
+                            <input id="noVNC_setting_view_only" type="checkbox"
+                                   class="toggle">
+                            View only
+                        </label>
+                    </li>
+                    <li><hr></li>
+                    <li>
+                        <label>
+                            <input id="noVNC_setting_view_clip" type="checkbox"
+                                   class="toggle">
+                            Clip to window
+                        </label>
+                    </li>
+                    <li>
+                        <label for="noVNC_setting_resize">Scaling mode:</label>
+                        <select id="noVNC_setting_resize" name="vncResize">
+                            <option value="off">None</option>
+                            <option value="scale">Local scaling</option>
+                            <option value="remote">Remote resizing</option>
+                        </select>
+                    </li>
+                    <li><hr></li>
+                    <li>
+                        <div class="noVNC_expander">Advanced</div>
+                        <div><ul>
+                            <li>
+                                <label for="noVNC_setting_quality">Quality:</label>
+                                <input id="noVNC_setting_quality" type="range" min="0" max="9" value="6">
+                            </li>
+                            <li>
+                                <label for="noVNC_setting_compression">Compression level:</label>
+                                <input id="noVNC_setting_compression" type="range" min="0" max="9" value="2">
+                            </li>
+                            <li><hr></li>
+                            <li>
+                                <label for="noVNC_setting_repeaterID">Repeater ID:</label>
+                                <input id="noVNC_setting_repeaterID" type="text" value="">
+                            </li>
+                            <li>
+                                <div class="noVNC_expander">WebSocket</div>
+                                <div><ul>
+                                    <li>
+                                        <label>
+                                            <input id="noVNC_setting_encrypt" type="checkbox"
+                                                   class="toggle">
+                                            Encrypt
+                                        </label>
+                                    </li>
+                                    <li>
+                                        <label for="noVNC_setting_host">Host:</label>
+                                        <input id="noVNC_setting_host">
+                                    </li>
+                                    <li>
+                                        <label for="noVNC_setting_port">Port:</label>
+                                        <input id="noVNC_setting_port" type="number">
+                                    </li>
+                                    <li>
+                                        <label for="noVNC_setting_path">Path:</label>
+                                        <input id="noVNC_setting_path" type="text" value="websockify">
+                                    </li>
+                                </ul></div>
+                            </li>
+                            <li><hr></li>
+                            <li>
+                                <label>
+                                    <input id="noVNC_setting_reconnect" type="checkbox"
+                                           class="toggle">
+                                    Automatic reconnect
+                                </label>
+                            </li>
+                            <li>
+                                <label for="noVNC_setting_reconnect_delay">Reconnect delay (ms):</label>
+                                <input id="noVNC_setting_reconnect_delay" type="number">
+                            </li>
+                            <li><hr></li>
+                            <li>
+                                <label>
+                                    <input id="noVNC_setting_show_dot" type="checkbox"
+                                           class="toggle">
+                                    Show dot when no cursor
+                                </label>
+                            </li>
+                            <li>
+                                <label>
+                                    <input id="noVNC_setting_keep_device_awake" type="checkbox"
+                                            class="toggle">
+                                    Keep client display awake while connected
+                                </label>
+                            </li>
+                            <li><hr></li>
+                            <!-- Logging selection dropdown -->
+                            <li>
+                                <label>Logging:
+                                    <select id="noVNC_setting_logging" name="vncLogging">
+                                    </select>
+                                </label>
+                            </li>
+                        </ul></div>
+                    </li>
+                    <li class="noVNC_version_separator"><hr></li>
+                    <li class="noVNC_version_wrapper">
+                        <span>Version:</span>
+                        <span class="noVNC_version"></span>
+                    </li>
+                </ul>
+            </div>
+            </div>
+
+            <!-- Connection controls -->
+            <input type="image" alt="Disconnect" src="app/images/disconnect.svg"
+                id="noVNC_disconnect_button" class="noVNC_button"
+                title="Disconnect">
+
+            </div>
+        </div>
+
+    </div> <!-- End of noVNC_control_bar -->
+
+    <div id="noVNC_hint_anchor" class="noVNC_vcenter">
+        <div id="noVNC_control_bar_hint">
+        </div>
+    </div>
+
+    <!-- Status dialog -->
+    <div id="noVNC_status"></div>
+
+    <!-- Connect button -->
+    <div class="noVNC_center">
+        <div id="noVNC_connect_dlg">
+            <p class="noVNC_logo" translate="no"><span>no</span>VNC</p>
+            <div>
+                <button id="noVNC_connect_button">
+                    <img alt="" src="app/images/connect.svg"> Connect
+                </button>
+            </div>
+        </div>
+    </div>
+
+    <!-- Server key verification dialog -->
+    <div class="noVNC_center noVNC_connect_layer">
+    <div id="noVNC_verify_server_dlg" class="noVNC_panel"><form>
+        <div class="noVNC_heading">
+            Server identity
+        </div>
+        <div>
+            The server has provided the following identifying information:
+        </div>
+        <div id="noVNC_fingerprint_block">
+            Fingerprint:
+            <span id="noVNC_fingerprint"></span>
+        </div>
+        <div>
+            Please verify that the information is correct and press
+            "Approve". Otherwise press "Reject".
+        </div>
+        <div class="button_row">
+            <input id="noVNC_approve_server_button" type="submit" value="Approve">
+            <input id="noVNC_reject_server_button" type="button" value="Reject">
+        </div>
+    </form></div>
+    </div>
+
+    <!-- Password dialog -->
+    <div class="noVNC_center noVNC_connect_layer">
+    <div id="noVNC_credentials_dlg" class="noVNC_panel"><form>
+        <div class="noVNC_heading">
+            Credentials
+        </div>
+        <div id="noVNC_username_block">
+            <label for="noVNC_username_input">Username:</label>
+            <input id="noVNC_username_input">
+        </div>
+        <div id="noVNC_password_block">
+            <label for="noVNC_password_input">Password:</label>
+            <input id="noVNC_password_input" type="password">
+        </div>
+        <div class="button_row">
+            <input id="noVNC_credentials_button" type="submit" value="Send credentials">
+        </div>
+    </form></div>
+    </div>
+
+    <!-- Transition screens -->
+    <div id="noVNC_transition">
+        <div id="noVNC_transition_text"></div>
+        <div>
+        <input type="button" id="noVNC_cancel_reconnect_button" value="Cancel">
+        </div>
+        <div class="noVNC_spinner"></div>
+    </div>
+
+    <!-- This is where the RFB elements will attach -->
+    <div id="noVNC_container">
+        <!-- Note that Google Chrome on Android doesn't respect any of these,
+             html attributes which attempt to disable text suggestions on the
+             on-screen keyboard. Let's hope Chrome implements the ime-mode
+             style for example -->
+        <textarea id="noVNC_keyboardinput" autocapitalize="off"
+            autocomplete="off" spellcheck="false" tabindex="-1"></textarea>
+    </div>
+
+    <audio id="noVNC_bell">
+        <source src="app/sounds/bell.oga" type="audio/ogg">
+        <source src="app/sounds/bell.mp3" type="audio/mpeg">
+    </audio>
+ </body>
+</html>
pkg/web/api.go
@@ -0,0 +1,171 @@
+// Package web provides HTTP/WebSocket server for VNC proxy.
+package web
+
+import (
+	"encoding/json"
+	"net/http"
+	"time"
+
+	"goVNC/pkg/input"
+	"goVNC/pkg/rfb"
+)
+
+// API provides REST API handlers for VNC operations.
+type API struct {
+	vnc *rfb.Client
+}
+
+// NewAPI creates a new API handler.
+func NewAPI(vnc *rfb.Client) *API {
+	return &API{vnc: vnc}
+}
+
+// ClipboardRequest is the request body for PUT /api/clipboard.
+type ClipboardRequest struct {
+	Text string `json:"text"`
+}
+
+// ClipboardResponse is the response for GET /api/clipboard.
+type ClipboardResponse struct {
+	Text      string    `json:"text"`
+	Timestamp time.Time `json:"timestamp"`
+}
+
+// KeysRequest is the request body for PUT /api/keys.
+type KeysRequest struct {
+	// Text to type (simple mode)
+	Text string `json:"text,omitempty"`
+
+	// DelayMs between keystrokes
+	DelayMs int `json:"delay_ms,omitempty"`
+
+	// Keys for raw key events
+	Keys []KeyEvent `json:"keys,omitempty"`
+}
+
+// KeyEvent represents a single key event.
+type KeyEvent struct {
+	Key  string `json:"key"`
+	Down bool   `json:"down"`
+}
+
+// SessionResponse is the response for GET /api/session.
+type SessionResponse struct {
+	Name      string `json:"name"`
+	Width     int    `json:"width"`
+	Height    int    `json:"height"`
+	Connected bool   `json:"connected"`
+}
+
+// HandleClipboard handles clipboard API requests.
+func (a *API) HandleClipboard(w http.ResponseWriter, r *http.Request) {
+	switch r.Method {
+	case http.MethodGet:
+		a.getClipboard(w, r)
+	case http.MethodPut:
+		a.setClipboard(w, r)
+	default:
+		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+	}
+}
+
+func (a *API) getClipboard(w http.ResponseWriter, _ *http.Request) {
+	text, timestamp := a.vnc.GetLastClipboard()
+	resp := ClipboardResponse{
+		Text:      text,
+		Timestamp: timestamp,
+	}
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(resp)
+}
+
+func (a *API) setClipboard(w http.ResponseWriter, r *http.Request) {
+	var req ClipboardRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		http.Error(w, "Invalid JSON", http.StatusBadRequest)
+		return
+	}
+
+	if err := a.vnc.SetClipboard(r.Context(), req.Text); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	w.WriteHeader(http.StatusNoContent)
+}
+
+// HandleKeys handles keyboard API requests.
+func (a *API) HandleKeys(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodPut {
+		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+		return
+	}
+
+	var req KeysRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		http.Error(w, "Invalid JSON", http.StatusBadRequest)
+		return
+	}
+
+	ctx := r.Context()
+
+	// If keys array is provided, send raw key events
+	if len(req.Keys) > 0 {
+		for _, ke := range req.Keys {
+			key, ok := input.ParseKeyName(ke.Key)
+			if !ok {
+				// Try as single character
+				if len(ke.Key) == 1 {
+					key = rfb.RuneToKeysym(rune(ke.Key[0]))
+				} else {
+					http.Error(w, "Unknown key: "+ke.Key, http.StatusBadRequest)
+					return
+				}
+			}
+			if err := a.vnc.KeyEvent(ctx, key, ke.Down); err != nil {
+				http.Error(w, err.Error(), http.StatusInternalServerError)
+				return
+			}
+		}
+		w.WriteHeader(http.StatusNoContent)
+		return
+	}
+
+	// Otherwise, type the text
+	if req.Text != "" {
+		// Add delay between keystrokes if specified
+		for _, r := range req.Text {
+			key := rfb.RuneToKeysym(r)
+			if err := a.vnc.Tap(ctx, key); err != nil {
+				http.Error(w, err.Error(), http.StatusInternalServerError)
+				return
+			}
+			if req.DelayMs > 0 {
+				time.Sleep(time.Duration(req.DelayMs) * time.Millisecond)
+			}
+		}
+	}
+
+	w.WriteHeader(http.StatusNoContent)
+}
+
+// HandleSession handles session info API requests.
+func (a *API) HandleSession(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodGet {
+		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+		return
+	}
+
+	session := a.vnc.Session()
+	resp := SessionResponse{
+		Connected: session != nil,
+	}
+	if session != nil {
+		resp.Name = session.Name
+		resp.Width = int(session.Width)
+		resp.Height = int(session.Height)
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(resp)
+}
pkg/web/embed_test.go
@@ -0,0 +1,51 @@
+package web
+
+import (
+	"testing"
+)
+
+func TestEmbeddedNoVNC(t *testing.T) {
+	if !hasEmbeddedNoVNC() {
+		t.Fatal("noVNC files should be embedded")
+	}
+
+	// Check that key files exist
+	files := []string{
+		"noVNC/vnc.html",
+		"noVNC/app/ui.js",
+		"noVNC/core/rfb.js",
+		"noVNC/vendor/pako/lib/zlib/inflate.js",
+	}
+
+	for _, path := range files {
+		data, err := embeddedNoVNC.ReadFile(path)
+		if err != nil {
+			t.Errorf("missing embedded file %s: %v", path, err)
+			continue
+		}
+		if len(data) == 0 {
+			t.Errorf("embedded file %s is empty", path)
+		}
+	}
+}
+
+func TestEmbeddedNoVNCDirectories(t *testing.T) {
+	// Verify directory structure
+	dirs := []string{
+		"noVNC",
+		"noVNC/app",
+		"noVNC/core",
+		"noVNC/vendor",
+	}
+
+	for _, dir := range dirs {
+		entries, err := embeddedNoVNC.ReadDir(dir)
+		if err != nil {
+			t.Errorf("cannot read directory %s: %v", dir, err)
+			continue
+		}
+		if len(entries) == 0 {
+			t.Errorf("directory %s is empty", dir)
+		}
+	}
+}
pkg/web/server.go
@@ -0,0 +1,235 @@
+package web
+
+import (
+	"context"
+	"embed"
+	"fmt"
+	"io/fs"
+	"log/slog"
+	"net/http"
+	"strings"
+
+	"goVNC/pkg/proxy"
+	"goVNC/pkg/rfb"
+)
+
+// Server is the HTTP/WebSocket server for VNC proxy.
+type Server struct {
+	httpServer *http.Server
+	mux        *proxy.Multiplexer
+	api        *API
+	config     *ServerConfig
+}
+
+// ServerConfig configures the web server.
+type ServerConfig struct {
+	// ListenAddr is the address to listen on (e.g., ":8080").
+	ListenAddr string
+
+	// APIPrefix is the prefix for API routes (default: "/api").
+	APIPrefix string
+
+	// NoVNCPath is the path to noVNC static files.
+	// If empty or "embedded", uses embedded noVNC files.
+	NoVNCPath string
+
+	// CORSOrigin is the allowed CORS origin (* for any).
+	CORSOrigin string
+
+	// ProxyMode determines how clients interact with the session.
+	ProxyMode proxy.SessionMode
+
+	// MaxClients is the maximum number of concurrent clients.
+	MaxClients int
+}
+
+// DefaultServerConfig returns default server configuration.
+func DefaultServerConfig() *ServerConfig {
+	return &ServerConfig{
+		ListenAddr: ":8080",
+		APIPrefix:  "/api",
+		CORSOrigin: "*",
+		ProxyMode:  proxy.SharedMode,
+		MaxClients: 10,
+	}
+}
+
+// NewServer creates a new web server.
+func NewServer(vnc *rfb.Client, cfg *ServerConfig) *Server {
+	if cfg == nil {
+		cfg = DefaultServerConfig()
+	}
+
+	mux := proxy.NewMultiplexer(vnc, cfg.ProxyMode)
+	api := NewAPI(vnc)
+
+	return &Server{
+		mux:    mux,
+		api:    api,
+		config: cfg,
+	}
+}
+
+// ListenAndServe starts the HTTP server.
+func (s *Server) ListenAndServe(ctx context.Context) error {
+	mux := http.NewServeMux()
+
+	// API routes
+	apiPrefix := s.config.APIPrefix
+	if !strings.HasSuffix(apiPrefix, "/") {
+		apiPrefix += "/"
+	}
+
+	mux.HandleFunc(apiPrefix+"clipboard", s.corsMiddleware(s.api.HandleClipboard))
+	mux.HandleFunc(apiPrefix+"keys", s.corsMiddleware(s.api.HandleKeys))
+	mux.HandleFunc(apiPrefix+"session", s.corsMiddleware(s.api.HandleSession))
+
+	// WebSocket proxy endpoint
+	wsHandler := NewWebsockifyHandler(s.mux)
+	mux.Handle("/websockify", wsHandler)
+
+	// noVNC static files
+	if err := s.setupNoVNC(mux); err != nil {
+		return fmt.Errorf("setting up noVNC: %w", err)
+	}
+
+	// Root redirect to noVNC
+	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+		if r.URL.Path == "/" {
+			http.Redirect(w, r, "/noVNC/vnc.html", http.StatusFound)
+			return
+		}
+		http.NotFound(w, r)
+	})
+
+	s.httpServer = &http.Server{
+		Addr:    s.config.ListenAddr,
+		Handler: mux,
+	}
+
+	slog.Info("starting web server",
+		slog.String("addr", s.config.ListenAddr),
+		slog.String("api", s.config.APIPrefix))
+
+	// Start server in goroutine
+	errCh := make(chan error, 1)
+	go func() {
+		errCh <- s.httpServer.ListenAndServe()
+	}()
+
+	// Wait for context or error
+	select {
+	case <-ctx.Done():
+		s.httpServer.Shutdown(context.Background())
+		return ctx.Err()
+	case err := <-errCh:
+		return err
+	}
+}
+
+// corsMiddleware adds CORS headers to responses.
+func (s *Server) corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		if s.config.CORSOrigin != "" {
+			w.Header().Set("Access-Control-Allow-Origin", s.config.CORSOrigin)
+			w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE, OPTIONS")
+			w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
+		}
+
+		if r.Method == http.MethodOptions {
+			w.WriteHeader(http.StatusOK)
+			return
+		}
+
+		next(w, r)
+	}
+}
+
+// setupNoVNC sets up the noVNC static file handler.
+func (s *Server) setupNoVNC(mux *http.ServeMux) error {
+	if s.config.NoVNCPath != "" && s.config.NoVNCPath != "embedded" {
+		// Serve from filesystem path
+		fileServer := http.FileServer(http.Dir(s.config.NoVNCPath))
+		mux.Handle("/noVNC/", http.StripPrefix("/noVNC/", fileServer))
+		slog.Info("serving noVNC from filesystem", slog.String("path", s.config.NoVNCPath))
+		return nil
+	}
+
+	// Try embedded files
+	if hasEmbeddedNoVNC() {
+		subFS, err := fs.Sub(embeddedNoVNC, "noVNC")
+		if err != nil {
+			return fmt.Errorf("creating sub filesystem: %w", err)
+		}
+		fileServer := http.FileServer(http.FS(subFS))
+		mux.Handle("/noVNC/", http.StripPrefix("/noVNC/", fileServer))
+		slog.Info("serving noVNC from embedded files")
+		return nil
+	}
+
+	// No noVNC available - serve placeholder
+	mux.HandleFunc("/noVNC/", func(w http.ResponseWriter, _ *http.Request) {
+		w.Header().Set("Content-Type", "text/html")
+		w.Write([]byte(noVNCPlaceholder))
+	})
+	slog.Warn("noVNC not available - serving placeholder")
+	return nil
+}
+
+// Close shuts down the server.
+func (s *Server) Close() error {
+	if s.httpServer != nil {
+		s.httpServer.Close()
+	}
+	return s.mux.Close()
+}
+
+// Multiplexer returns the proxy multiplexer for registering message handlers.
+func (s *Server) Multiplexer() *proxy.Multiplexer {
+	return s.mux
+}
+
+// embeddedNoVNC holds embedded noVNC files.
+// The "all:" prefix includes files starting with . or _
+//
+//go:embed all:noVNC
+var embeddedNoVNC embed.FS
+
+func hasEmbeddedNoVNC() bool {
+	entries, err := embeddedNoVNC.ReadDir("noVNC")
+	return err == nil && len(entries) > 0
+}
+
+const noVNCPlaceholder = `<!DOCTYPE html>
+<html>
+<head>
+    <title>noVNC Not Available</title>
+    <style>
+        body { font-family: sans-serif; padding: 20px; max-width: 600px; margin: 0 auto; }
+        h1 { color: #333; }
+        code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; }
+        pre { background: #f4f4f4; padding: 10px; border-radius: 5px; overflow-x: auto; }
+    </style>
+</head>
+<body>
+    <h1>noVNC Not Available</h1>
+    <p>The noVNC web client is not embedded in this build.</p>
+    <p>You can:</p>
+    <ul>
+        <li>Download noVNC and specify the path with <code>--novnc-path /path/to/noVNC</code></li>
+        <li>Use the REST API directly:
+            <pre>
+# Send keys
+curl -X PUT localhost:8080/api/keys -d '{"text":"hello\n"}'
+
+# Send clipboard
+curl -X PUT localhost:8080/api/clipboard -d '{"text":"copied"}'
+
+# Get session info
+curl localhost:8080/api/session
+            </pre>
+        </li>
+        <li>Connect with any noVNC-compatible client to <code>ws://localhost:8080/websockify</code></li>
+    </ul>
+</body>
+</html>`
pkg/web/websockify.go
@@ -0,0 +1,111 @@
+package web
+
+import (
+	"context"
+	"fmt"
+	"log/slog"
+	"net/http"
+	"sync/atomic"
+
+	"github.com/coder/websocket"
+
+	"goVNC/pkg/proxy"
+	"goVNC/pkg/transport"
+)
+
+// WebsockifyHandler handles WebSocket proxy connections.
+type WebsockifyHandler struct {
+	mux       *proxy.Multiplexer
+	clientSeq uint64
+}
+
+// NewWebsockifyHandler creates a new WebSocket proxy handler.
+func NewWebsockifyHandler(mux *proxy.Multiplexer) *WebsockifyHandler {
+	return &WebsockifyHandler{
+		mux: mux,
+	}
+}
+
+// ServeHTTP handles WebSocket upgrade and proxying.
+func (h *WebsockifyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	// Accept WebSocket connection
+	conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
+		Subprotocols:   []string{"binary"},
+		OriginPatterns: []string{"*"}, // Allow all origins for now
+	})
+	if err != nil {
+		slog.Error("accepting websocket", slog.String("error", err.Error()))
+		return
+	}
+
+	// Generate client ID
+	seq := atomic.AddUint64(&h.clientSeq, 1)
+	clientID := fmt.Sprintf("client-%d", seq)
+
+	// Wrap in transport
+	clientTransport := &wsTransport{conn: conn}
+
+	// Register with multiplexer
+	client := h.mux.AddClient(clientID, clientTransport)
+	defer h.mux.RemoveClient(clientID)
+
+	// Set up context
+	ctx := r.Context()
+
+	// Get current session info to send initial state
+	session := h.mux.VNCClient().Session()
+	if session != nil {
+		// Send ServerInit-like message so client knows dimensions
+		// noVNC expects to receive the full handshake, but since we've
+		// already done that, we need to replay it
+		slog.Info("client connected to session",
+			slog.String("client", clientID),
+			slog.String("session", session.Name))
+	}
+
+	// Run reader and writer concurrently
+	errCh := make(chan error, 2)
+
+	go func() {
+		errCh <- h.mux.RunClientWriter(ctx, client)
+	}()
+
+	go func() {
+		errCh <- h.mux.RunClientReader(ctx, client)
+	}()
+
+	// Wait for either to complete
+	err = <-errCh
+	if err != nil && err != context.Canceled {
+		slog.Debug("client connection ended",
+			slog.String("client", clientID),
+			slog.String("error", err.Error()))
+	}
+
+	// Close the connection
+	conn.Close(websocket.StatusNormalClosure, "")
+}
+
+// wsTransport wraps a WebSocket connection as a Transport.
+type wsTransport struct {
+	conn *websocket.Conn
+}
+
+func (t *wsTransport) Read(ctx context.Context) ([]byte, error) {
+	_, data, err := t.conn.Read(ctx)
+	return data, err
+}
+
+func (t *wsTransport) Write(ctx context.Context, data []byte) error {
+	return t.conn.Write(ctx, websocket.MessageBinary, data)
+}
+
+func (t *wsTransport) Close() error {
+	return t.conn.CloseNow()
+}
+
+func (t *wsTransport) SetReadLimit(limit int64) {
+	t.conn.SetReadLimit(limit)
+}
+
+var _ transport.Transport = (*wsTransport)(nil)
.gitignore
@@ -0,0 +1,3 @@
+.govnc_history
+clipboard_in
+clipboard_out