-# JetKVM Development Guide
-
Welcome to JetKVM development! This guide will help you get started quickly, whether you're fixing bugs, adding features, or just exploring the codebase.
## Get Started
### Prerequisites
+
- **A JetKVM device** (for full development)
- **[Go 1.24.4+](https://go.dev/doc/install)** and **[Node.js 22.15.0](https://nodejs.org/en/download/)**
- **[Git](https://git-scm.com/downloads)** for version control
@@ -25,9 +22,10 @@ Welcome to JetKVM development! This guide will help you get started quickly, whe
### Development Environment
-**Recommended:** Development is best done on **Linux** or **macOS**.
+**Recommended:** Development is best done on **Linux** or **macOS**.
If you're using Windows, we strongly recommend using **WSL (Windows Subsystem for Linux)** for the best development experience:
+
- [Install WSL on Windows](https://docs.microsoft.com/en-us/windows/wsl/install)
- [WSL Setup Guide](https://docs.microsoft.com/en-us/windows/wsl/setup/environment)
@@ -36,12 +34,14 @@ This ensures compatibility with shell scripts and build tools used in the projec
### Project Setup
1. **Clone the repository:**
+
```bash
git clone https://github.com/jetkvm/kvm.git
cd kvm
```
2. **Check your tools:**
+
```bash
go version && node --version
```
@@ -49,6 +49,7 @@ This ensures compatibility with shell scripts and build tools used in the projec
3. **Find your JetKVM IP address** (check your router or device screen)
4. **Deploy and test:**
+
```bash
./dev_deploy.sh -r 192.168.1.100 # Replace with your device IP
```
@@ -95,40 +96,44 @@ tail -f /var/log/jetkvm.log
## Project Layout
-```
+```plaintext
/kvm/
-├── main.go # App entry point
-├── config.go # Settings & configuration
-├── display.go # Device UI control
-├── web.go # API endpoints
-├── cmd/ # Command line main
-├── internal/ # Internal Go packages
-│ ├── confparser/ # Configuration file implementation
-│ ├── hidrpc/ # HIDRPC implementation for HID devices (keyboard, mouse, etc.)
-│ ├── logging/ # Logging implementation
-│ ├── mdns/ # mDNS implementation
-│ ├── native/ # CGO / Native code glue layer (on-device hardware)
-│ │ ├── cgo/ # C files for the native library (HDMI, Touchscreen, etc.)
-│ │ └── eez/ # EEZ Studio Project files (for Touchscreen)
-│ ├── network/ # Network implementation
-│ ├── timesync/ # Time sync/NTP implementation
-│ ├── tzdata/ # Timezone data and generation
-│ ├── udhcpc/ # DHCP implementation
-│ ├── usbgadget/ # USB gadget
-│ ├── utils/ # SSH handling
-│ └── websecure/ # TLS certificate management
-├── resource/ # netboot iso and other resources
-├── scripts/ # Bash shell scripts for building and deploying
-└── static/ # (react client build output)
-└── ui/ # React frontend
- ├── public/ # UI website static images and fonts
- └── src/ # Client React UI
- ├── assets/ # UI in-page images
- ├── components/ # UI components
- ├── hooks/ # Hooks (stores, RPC handling, virtual devices)
- ├── keyboardLayouts/ # Keyboard layout definitions
- ├── providers/ # Feature flags
- └── routes/ # Pages (login, settings, etc.)
+├── main.go # App entry point
+├── config.go # Settings & configuration
+├── display.go # Device UI control
+├── web.go # API endpoints
+├── cmd/ # Command line main
+├── internal/ # Internal Go packages
+│ ├── confparser/ # Configuration file implementation
+│ ├── hidrpc/ # HIDRPC implementation for HID devices (keyboard, mouse, etc.)
+│ ├── logging/ # Logging implementation
+│ ├── mdns/ # mDNS implementation
+│ ├── native/ # CGO / Native code glue layer (on-device hardware)
+│ │ ├── cgo/ # C files for the native library (HDMI, Touchscreen, etc.)
+│ │ └── eez/ # EEZ Studio Project files (for Touchscreen)
+│ ├── network/ # Network implementation
+│ ├── timesync/ # Time sync/NTP implementation
+│ ├── tzdata/ # Timezone data and generation
+│ ├── udhcpc/ # DHCP implementation
+│ ├── usbgadget/ # USB gadget
+│ ├── utils/ # SSH handling
+│ └── websecure/ # TLS certificate management
+├── resource/ # netboot iso and other resources
+├── scripts/ # Bash shell scripts for building and deploying
+└── static/ # (react client build output)
+└── ui/ # React frontend
+ ├── localization/ # Client UI localization (i18n)
+ │ ├── jetKVM.UI.inlang/ # Settings for inlang
+ │ └── messages/ # Messages localized
+ ├── public/ # UI website static images and fonts
+ └── src/ # Client React UI
+ ├── assets/ # UI in-page images
+ ├── components/ # UI components
+ ├── hooks/ # Hooks (stores, RPC handling, virtual devices)
+ ├── keyboardLayouts/ # Keyboard layout definitions
+ ├── paraglide/ # (localization compiled messages output)
+ ├── providers/ # Feature flags
+ └── routes/ # Pages (login, settings, etc.)
```
**Key files for beginners:**
@@ -144,7 +149,7 @@ tail -f /var/log/jetkvm.log
### Full Development (Recommended)
-*Best for: Complete feature development*
+#### _Best for: Complete feature development_
```bash
# Deploy everything to your JetKVM device
@@ -153,7 +158,7 @@ tail -f /var/log/jetkvm.log
### Frontend Only
-*Best for: UI changes without device*
+#### _Best for: UI changes without device_
```bash
cd ui
@@ -167,7 +172,7 @@ Please click the `Build` button in EEZ Studio then run `./dev_deploy.sh -r ../internal/native/cgo/lvgl-minify.patch &&
git diff --name-only --diff-filter=D --cached > ../internal/native/cgo/lvgl-minify.del
```
+### Localization
+
+The browser/client frontend uses the [paraglide-js](https://inlang.com/m/gerre34r/library-inlang-paraglideJs) plug-in from the [inlang.com](https://inlang.com/) project to allow compile-time validated localization of all user-facing UI strings in the browser/client UI. This includes `title`, `text`, `name`, `description`, `placeholder`, `label`, `aria-label`, _message attributes_ (such as `confirmText`, `unit`, `badge`, `tag`, or `flag`), HTML _element text_ (such as ``, ``, or `
` elements), _notifications messages_, and option _label_ strings, etc.
+
+We **do not** translate the console log messages, CSS class names, theme names, nor the various _value_ strings (e.g. for value/label pair options), nor URL routes.
+
+The localizations are stored in _.json_ files in the `ui/localizations/messages` directory, with one language-per-file using the [ISO 3166-1 alpha-2 country code](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2) (e.g. en for English, de for German, etc.)
+
+#### m-function-matcher
+
+The translations are extracted into language files (e.g. _en.json_ for English) and then paraglide-js compiles them into helpers for use with the [m-function-matcher](https://inlang.com/m/632iow21/plugin-inlang-mFunctionMatcher). An example:
+
+```tsx
+
+```
+
+#### shakespere plug-in
+
+If you enable the [Sherlock](https://inlang.com/m/r7kp499g/app-inlang-ideExtension) plug-in, the localized text "tooltip" is shown in the VSCode editor after any localized text in the language you've selected for preview. In this image, it's the blue text at the end of the line :
+
+
+
+#### Process
+
+##### Localizing a UI
+
+1. Locate a string that is visible to the end user on the client/browser
+2. Assign that string a "key" that reflects the logical meaning of the string in snake-case (look at existing localizations for examples), for example if there's a string `This is a test` on the _thing edit page_ it would be "thing_edit_this_is_a_test"
+
+ ```json
+ "thing_edit_this_is_a_test": "This is a test",
+ ```
+
+3. Add the key and string to the _en.json_ like this:
+
+ - **Note** if the string has replacement parameters (line a user-entered name), the syntax for the localized string has `{ }` around the replacement token (e.g. _This is your name: {name}_). An complex example:
+
+ ```react
+ {m.mount_button_showing_results({
+ from: indexOfFirstFile + 1,
+ to: Math.min(indexOfLastFile, onStorageFiles.length),
+ total: onStorageFiles.length
+ })}
+ ```
+
+4. Save the _en.json_ file and execute `npm run i18n` to resort the language files, validate the translations, and create the m-functions
+5. Edit the _.tsx_ file and replace the string with the calls to the new m-function which will be the key-string you chose in snake-case. For example `This is a test` in _thing edit page_ turns into `m.thing_edit_this_is_a_test()`
+ - **Note** if the string has a replacement token, supply that to the m-function, for example for the literal `I will call you {name}`, use `m.profile_i_will_call_you({ name: edit.value })`
+6. When all your strings are extracted, run `npm run i18n:machine-translate` to get a first-stab at the translations for the other supported languages. Make sure you use an LLM (you can use [aifiesta](https://chat.aifiesta.ai/chat/) to use multiple LLMs) or a [translator](https://translate.google.com) of some form to back-translate each **new** machine-generation in each _language_ to ensure those terms translate reasonably.
+
+### Adding a new language
+
+1. Get the [ISO 3166-1 alpha-2 country code](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2) (for example AT for Austria)
+2. Create a new file in the _ui/localization/messages_ directory (example _at.json_)
+3. Add the new country code to the _ui/localizations/settings.json_ file in both the `"locales"` and the `"languageTags"` section (inlang and Sherlock aren't exactly current to each other, so we need it in both places).
+4. That file also declares the baseLocale/sourceLanguageTag which is `"en"` because this project started out in English. Do NOT change that.
+5. Run `npm run i18n:machine-translate` to do an initial pass at localizing all existing messages to the new language.
+ - **Note** you will get an error _DB has been closed_, ignore that message, we're not using a database.
+ - **Note** you likely will get errors while running this command due to rate limits and such (it uses anonymous Google Translate). Just keep running the command over and over... it'll translate a bunch each time until it says _Machine translate complete_
+
+### Other notes
+
+- Run `npm run i18n:validate` to ensure that language files and settings are well-formed.
+- Run `npm run i18n:find-excess` to look for extra keys in other language files that have been deleted from the master-list in _en.json_.
+- Run `npm run i18n:find-dupes` to look for multiple keys in _en.json_ that have the same translated value (this is normal)
+- Run `npm run i18n:find-unused` to look for keys in _en.json_ that are not referenced in the UI anywhere.
+ - **Note** there are a few that are not currently used, only concern yourself with ones you obsoleted.
+- Run `npm run i18n:audit` to do all the above checks.
+- Using [inlang CLI](https://inlang.com/m/2qj2w8pu/app-inlang-cli) to support the npm commands.
+- You can install the [Sherlock VS Code extension](https://marketplace.visualstudio.com/items?itemName=inlang.vs-code-extension) in your devcontainer.
---
diff --git a/hw.go b/hw.go
index 20d88ebf..7797adc1 100644
--- a/hw.go
+++ b/hw.go
@@ -3,6 +3,7 @@ package kvm
import (
"fmt"
"os"
+ "os/exec"
"regexp"
"strings"
"sync"
@@ -36,6 +37,37 @@ func readOtpEntropy() ([]byte, error) { //nolint:unused
return content[0x17:0x1C], nil
}
+func hwReboot(force bool, postRebootAction *PostRebootAction, delay time.Duration) error {
+ logger.Info().Msgf("Reboot requested, rebooting in %d seconds...", delay)
+
+ writeJSONRPCEvent("willReboot", postRebootAction, currentSession)
+ time.Sleep(1 * time.Second) // Wait for the JSONRPCEvent to be sent
+
+ nativeInstance.SwitchToScreenIfDifferent("rebooting_screen")
+ time.Sleep(delay - (1 * time.Second)) // wait requested extra settle time
+
+ args := []string{}
+ if force {
+ args = append(args, "-f")
+ }
+
+ cmd := exec.Command("reboot", args...)
+ err := cmd.Start()
+ if err != nil {
+ logger.Error().Err(err).Msg("failed to reboot")
+ switchToMainScreen()
+ return fmt.Errorf("failed to reboot: %w", err)
+ }
+
+ // If the reboot command is successful, exit the program after 5 seconds
+ go func() {
+ time.Sleep(5 * time.Second)
+ os.Exit(0)
+ }()
+
+ return nil
+}
+
var deviceID string
var deviceIDOnce sync.Once
diff --git a/jsonrpc.go b/jsonrpc.go
index 2c06f12b..dede5bf0 100644
--- a/jsonrpc.go
+++ b/jsonrpc.go
@@ -173,34 +173,8 @@ func rpcGetDeviceID() (string, error) {
}
func rpcReboot(force bool) error {
- logger.Info().Msg("Got reboot request from JSONRPC, rebooting...")
-
- writeJSONRPCEvent("willReboot", nil, currentSession)
-
- // Wait for the JSONRPCEvent to be sent
- time.Sleep(1 * time.Second)
- nativeInstance.SwitchToScreenIfDifferent("rebooting_screen")
-
- args := []string{}
- if force {
- args = append(args, "-f")
- }
-
- cmd := exec.Command("reboot", args...)
- err := cmd.Start()
- if err != nil {
- logger.Error().Err(err).Msg("failed to reboot")
- switchToMainScreen()
- return fmt.Errorf("failed to reboot: %w", err)
- }
-
- // If the reboot command is successful, exit the program after 5 seconds
- go func() {
- time.Sleep(5 * time.Second)
- os.Exit(0)
- }()
-
- return nil
+ logger.Info().Msg("Got reboot request via RPC")
+ return hwReboot(force, nil, 0)
}
var streamFactor = 1.0
diff --git a/native.go b/native.go
index 5f26c014..4a523bce 100644
--- a/native.go
+++ b/native.go
@@ -37,14 +37,17 @@ func initNative(systemVersion *semver.Version, appVersion *semver.Version) {
nativeLogger.Trace().Str("event", event).Msg("rpc event received")
switch event {
case "resetConfig":
+ nativeLogger.Info().Msg("Reset configuration request via native rpc event")
err := rpcResetConfig()
if err != nil {
nativeLogger.Warn().Err(err).Msg("error resetting config")
}
_ = rpcReboot(true)
case "reboot":
+ nativeLogger.Info().Msg("Reboot request via native rpc event")
_ = rpcReboot(true)
case "toggleDHCPClient":
+ nativeLogger.Info().Msg("Toggle DHCP request via native rpc event")
_ = rpcToggleDHCPClient()
default:
nativeLogger.Warn().Str("event", event).Msg("unknown rpc event received")
diff --git a/network.go b/network.go
index ff071460..83eae429 100644
--- a/network.go
+++ b/network.go
@@ -193,6 +193,7 @@ func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (re
oldIPv4Mode := oldConfig.IPv4Mode.String
newIPv4Mode := newConfig.IPv4Mode.String
+
// IPv4 mode change requires reboot
if newIPv4Mode != oldIPv4Mode {
rebootRequired = true
@@ -284,7 +285,8 @@ func rpcSetNetworkSettings(settings RpcNetworkSettings) (*RpcNetworkSettings, er
}
if rebootRequired {
- if err := rpcReboot(false); err != nil {
+ l.Info().Msg("Rebooting due to network changes")
+ if err := hwReboot(true, postRebootAction, 0); err != nil {
return nil, err
}
}
diff --git a/ota.go b/ota.go
index 7063c7ff..41bfea96 100644
--- a/ota.go
+++ b/ota.go
@@ -487,25 +487,15 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
}
if rebootNeeded {
- scopedLogger.Info().Msg("System Rebooting in 10s")
+ scopedLogger.Info().Msg("System Rebooting due to OTA update")
- // TODO: Future enhancement - send postRebootAction to redirect to release notes
- // Example:
- // postRebootAction := &PostRebootAction{
- // HealthCheck: "[..]/device/status",
- // RedirectUrl: "[..]/settings/general/update?version=X.Y.Z",
- // }
- // writeJSONRPCEvent("willReboot", postRebootAction, currentSession)
+ postRebootAction := &PostRebootAction{
+ HealthCheck: "/device/status",
+ RedirectUrl: "/settings/general/update?version=" + remote.SystemVersion,
+ }
- time.Sleep(10 * time.Second)
- cmd := exec.Command("reboot")
- err := cmd.Start()
- if err != nil {
- otaState.Error = fmt.Sprintf("Failed to start reboot: %v", err)
- scopedLogger.Error().Err(err).Msg("Failed to start reboot")
- return fmt.Errorf("failed to start reboot: %w", err)
- } else {
- os.Exit(0)
+ if err := hwReboot(true, postRebootAction, 10*time.Second); err != nil {
+ return fmt.Errorf("error requesting reboot: %w", err)
}
}
diff --git a/ui/eslint.config.cjs b/ui/eslint.config.cjs
index 6e972586..ad4338a3 100644
--- a/ui/eslint.config.cjs
+++ b/ui/eslint.config.cjs
@@ -9,8 +9,6 @@ const {
fixupConfigRules,
} = require("@eslint/compat");
-const tsParser = require("@typescript-eslint/parser");
-const reactRefresh = require("eslint-plugin-react-refresh");
const js = require("@eslint/js");
const {
@@ -23,6 +21,9 @@ const compat = new FlatCompat({
allConfig: js.configs.all
});
+const tsParser = require("@typescript-eslint/parser");
+const reactRefresh = require("eslint-plugin-react-refresh");
+
module.exports = defineConfig([{
languageOptions: {
globals: {
@@ -66,7 +67,7 @@ module.exports = defineConfig([{
groups: ["builtin", "external", "internal", "parent", "sibling"],
"newlines-between": "always",
}],
-
+
"@typescript-eslint/no-unused-vars": ["warn", {
"argsIgnorePattern": "^_", "varsIgnorePattern": "^_"
}],
@@ -81,7 +82,10 @@ module.exports = defineConfig([{
map: [
["@components", "./src/components"],
["@routes", "./src/routes"],
+ ["@hooks", "./src/hooks"],
+ ["@providers", "./src/providers"],
["@assets", "./src/assets"],
+ ["@localizations", "./localization/paraglide"],
["@", "./src"],
],
diff --git a/ui/index.html b/ui/index.html
index 3c6c5606..77936233 100644
--- a/ui/index.html
+++ b/ui/index.html
@@ -45,31 +45,39 @@
-
+
-
+