Compare commits

...

13 Commits
2.3.5 ... 2.3.6

Author SHA1 Message Date
wenjie
590db5b08c release: nanokvm@2.3.6 2026-03-12 16:02:18 +08:00
wenjie
10a512ec96 feat: Implement AP password authentication for the WiFi configuration page 2026-03-12 15:21:18 +08:00
wenjie
08093dddad Merge pull request #769 from Voranto/Voranto-patch-1
Enable support for 640x480 resolution
2026-03-12 11:21:17 +08:00
wenjie
86a85a61b7 Merge pull request #761 from klim4-bot/main
feat : Add Korean and Japanese virtual keyboard layouts
2026-03-12 11:20:54 +08:00
wenjie
cb3a2c562e Merge pull request #744 from tkmsst/pull-request
Update Japanese translation
2026-03-12 11:18:12 +08:00
Voranto
67fe57f189 Enable support for 640x480 resolution 2026-03-05 15:52:49 +01:00
BuGu
44caee21ab Add encryption parameters for OLED distribution network 2026-03-04 17:11:42 +08:00
BuGu
5b2a1ff788 Add webpage logo generation tool and method 2026-03-04 17:09:52 +08:00
BuGu
dc5ba29bcf Add a logo file generator based on TUI 2026-03-03 19:12:59 +08:00
BuGu
89c14310c7 Add custom logo function 2026-03-03 18:50:35 +08:00
BuGu
3322a3c07f Add update mechanism for tailscale startup script 2026-03-03 18:49:02 +08:00
jason
57887f3f69 Add Korean and Japanese virtual keyboard layouts 2026-02-19 07:47:41 +09:00
tkmsst
2b8f640957 Update Japanese translation 2026-02-02 01:14:57 +09:00
20 changed files with 1987 additions and 824 deletions

View File

@@ -1,3 +1,18 @@
## 2.3.6 (2026-03-12)
### Features
* Implemented AP password authentication for the WiFi configuration page
* Added Korean and Japanese virtual keyboard layouts (thanks to [@klim4-bot](https://github.com/klim4-bot))
* Enabled support for 640x480 resolution (thanks to [@Voranto](https://github.com/Voranto))
* Added encryption parameters for OLED distribution network
* Added custom logo function and logo generation tools
* Added update mechanism for tailscale startup script
### Localization
* Updated Japanese translation (thanks to [@tkmsst](https://github.com/tkmsst))
## 2.3.5 (2026-02-28)
### Features

View File

@@ -32,6 +32,18 @@ case "$1" in
iptables -C OUTPUT -o eth0 -p tcp --sport 8000 -m state --state ESTABLISHED -j DROP 2>/dev/null || \
iptables -A OUTPUT -o eth0 -p tcp --sport 8000 -m state --state ESTABLISHED -j DROP
# Logo selection
if [ -f /boot/logo.ico ]; then
if [ ! -f /kvmapp/server/web/original.ico ]; then
mv /kvmapp/server/web/sipeed.ico /kvmapp/server/web/original.ico
cp /boot/logo.ico /kvmapp/server/web/sipeed.ico
fi
else
if [ -f /kvmapp/server/web/original.ico ]; then
mv /kvmapp/server/web/original.ico /kvmapp/server/web/sipeed.ico
fi
fi
# Start services
cp -r /kvmapp/kvm_system /tmp/
/tmp/kvm_system/kvm_system &

View File

@@ -10,7 +10,8 @@ import (
func networkRouter(r *gin.Engine) {
service := network.NewService()
r.POST("/api/network/wifi", service.ConnectWifiNoAuth) // connect Wi-Fi without auth (only available in ap mode)
r.POST("/api/network/wifi", service.ConnectWifiNoAuth) // connect Wi-Fi without auth (only available in ap mode)
r.POST("/api/network/wifi/verify", service.VerifyApLogin) // verify ap login
api := r.Group("/api").Use(middleware.CheckToken())

View File

@@ -1,6 +1,7 @@
package network
import (
"crypto/subtle"
"fmt"
"os"
"os/exec"
@@ -21,6 +22,7 @@ const (
WiFiConnect = "/kvmapp/kvm/wifi_try_connect"
WiFiStateFile = "/kvmapp/kvm/wifi_state"
WiFiScript = "/etc/init.d/S30wifi"
WiFiApPassFile = "/kvmapp/kvm/ap.pass"
)
func (s *Service) GetWifi(c *gin.Context) {
@@ -62,6 +64,15 @@ func (s *Service) ConnectWifiNoAuth(c *gin.Context) {
return
}
// Verify AP Password
apKey := c.GetHeader("X-AP-Key")
expectedPass := getApPassword()
if apKey == "" || expectedPass == "" || subtle.ConstantTimeCompare([]byte(apKey), []byte(expectedPass)) != 1 {
time.Sleep(2 * time.Second)
rsp.ErrRsp(c, -4, "unauthorized")
return
}
if err := proto.ParseFormRequest(c, &req); err != nil {
time.Sleep(1 * time.Second)
rsp.ErrRsp(c, -2, "invalid parameters")
@@ -79,6 +90,26 @@ func (s *Service) ConnectWifiNoAuth(c *gin.Context) {
log.Debugf("set wifi ap mode successfully")
}
func (s *Service) VerifyApLogin(c *gin.Context) {
var rsp proto.Response
if !isSupported() || !isAPMode() {
time.Sleep(2 * time.Second)
rsp.ErrRsp(c, -1, "invalid mode")
return
}
apKey := c.GetHeader("X-AP-Key")
expectedPass := getApPassword()
if apKey == "" || expectedPass == "" || subtle.ConstantTimeCompare([]byte(apKey), []byte(expectedPass)) != 1 {
time.Sleep(2 * time.Second)
rsp.ErrRsp(c, -4, "unauthorized")
return
}
rsp.OkRsp(c)
}
func (s *Service) ConnectWifi(c *gin.Context) {
var req proto.ConnectWifiReq
var rsp proto.Response
@@ -111,7 +142,6 @@ func (s *Service) ConnectWifi(c *gin.Context) {
}
rsp.ErrRsp(c, -3, "failed to get wifi status")
return
}
func (s *Service) DisconnectWifi(c *gin.Context) {
@@ -182,3 +212,12 @@ func getWiFiSsid() string {
return strings.ReplaceAll(string(ssidByte), "\n", "")
}
func getApPassword() string {
passByte, err := os.ReadFile(WiFiApPassFile)
if err != nil {
return ""
}
return strings.ReplaceAll(string(passByte), "\n", "")
}

View File

@@ -164,6 +164,7 @@ uint16_t hdmi_res_list[][2] = {
{1152, 864},
{1024, 768},
{800, 600},
{640, 480},
};
uint16_t hdmi_unsupported_res_list[][2] = {

File diff suppressed because it is too large Load Diff

View File

@@ -356,8 +356,8 @@ void OLED_ShowStringTurn(uint8_t x, uint8_t y, char *chr, uint8_t sizey);
void OLED_ShowStringtoend(uint8_t x, uint8_t y, char *chr, uint8_t sizey, uint8_t end);
void OLED_ShowString_AlignRight(uint8_t x_end, uint8_t y, char *chr, uint8_t size);
void OLED_ROW(uint8_t _EN);
void OLED_ShowKVMLogo();
void OLED_ShowLogo();
void OLED_ShowSipeedLogo();
void OLED_Showline();
void OLED_Showline_1();
void OLED_ShowIMG(uint8_t x,uint8_t y,char *chr,uint8_t width,uint8_t height);

View File

@@ -13,8 +13,8 @@ void kvm_init_cube_ui(void)
OLED_Clear();
// OLED_Revolve();
OLED_ShowKVMLogo();
OLED_ShowLogo();
OLED_ShowSipeedLogo();
OLED_ShowKVMState(HDMI_STATE, 0);
OLED_ShowKVMState(HID_STATE, 0);
OLED_ShowKVMState(ETH_STATE, 0);
@@ -37,7 +37,7 @@ void kvm_init_pcie_ui(void)
{
OLED_Revolve();
OLED_Showline_1();
OLED_ShowSipeedLogo();
OLED_ShowLogo();
OLED_ShowKVMState(HDMI_STATE, 0);
OLED_ShowKVMState(HID_STATE, 0);
OLED_ShowKVMState(ETH_STATE, 0);
@@ -412,8 +412,15 @@ void show_wifi_config_ip(void)
{
OLED_Clear();
get_ip_addr(WiFi_IP);
OLED_ShowString(0, 1, "Config IP:", 8);
OLED_ShowString_AlignRight(63, 2, (char*)kvm_sys_state.wifi_addr, 4);
OLED_ShowString(1, 0, "Config URL", 8);
OLED_ShowString_AlignRight(63, 1, "----------------", 4);
// OLED_ShowString_AlignRight(63, 2, (char*)kvm_sys_state.wifi_addr, 4);
static char wifi_addr_with_path[30];
static char wifi_addr_with_key[30];
sprintf(wifi_addr_with_path, "%s/#/", kvm_sys_state.wifi_addr);
sprintf(wifi_addr_with_key, "WIFI?P=%s", kvm_sys_state.wifi_ap_pass);
OLED_ShowString_AlignRight(63, 2, wifi_addr_with_path, 4);
OLED_ShowString_AlignRight(63, 3, wifi_addr_with_key, 4);
}
void show_wifi_config_QR(void)
@@ -421,8 +428,7 @@ void show_wifi_config_QR(void)
static char cmd[70];
OLED_Clear();
get_ip_addr(WiFi_IP);
printf("http://%s/#/wifi\n", kvm_sys_state.wifi_addr);
sprintf(cmd, "http://%s/#/wifi", kvm_sys_state.wifi_addr);
sprintf(cmd, "http://%s/#/WIFI?P=%s", kvm_sys_state.wifi_addr, kvm_sys_state.wifi_ap_pass);
qrencode(cmd);
}

View File

@@ -53,6 +53,7 @@ void Production_testing_patch(void)
void new_app_init(void)
{
// Update the necessary scripts
system("rm -f /boot/logo.jpeg");
system("cp -f /kvmapp/system/update-nanokvm.py /etc/kvm/");
system("rm -f /etc/init.d/S02udisk");
system("cp -f /kvmapp/system/init.d/S00kmod /etc/init.d/");
@@ -66,6 +67,11 @@ void new_app_init(void)
} else {
system("rm -f /etc/init.d/S30wifi");
}
// if exit /etc/init.d/S98tailscaled then cp -f /kvmapp/system/init.d/S98tailscaled /etc/init.d/
if(access("/etc/init.d/S98tailscaled", F_OK) == 0){
system("cp -f /kvmapp/system/init.d/S98tailscaled /etc/init.d/");
}
// rmmod soph_saradc
system("rmmod soph_saradc");

View File

@@ -0,0 +1,605 @@
from PIL import Image
import numpy as np
import sys
import os
from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, Button, Input, Label, Static
from textual.containers import Container, Horizontal, Vertical, Center, Middle
from textual.binding import Binding
from textual import events
from textual.message import Message
# Language dictionary for i18n support
LANGUAGES = {
'zh': {
'app_title': '🖼️ [ NanoKVM Logo 生成器 ]',
'app_title_editor': '✏️ NanoKVM Logo 编辑器',
'contrast_minus': '对比度-',
'contrast_plus': '对比度+',
'current_contrast': '当前对比度:{}',
'click_hint': '💡 点击图片以修改像素',
'btn_invert': '🔃 反转',
'btn_reset': '🔄 重置',
'btn_export': '💾 导出',
'btn_quit': '❌ 退出',
'help_text_with_image': '快捷键:↑↓ 调整±10 | ←→ 调整±1 | I 反转 | R 重置 | D 导出 | Q 退出',
'help_text_editor': '快捷键:I 反转 | R 清空 | D 导出 | Q 退出',
'status_blank_canvas': '空白画布 - 点击像素开始绘制',
'status_loaded': '已加载:{}',
'status_load_failed': '无法加载图片:{}',
'status_contrast': '对比度:{}',
'status_inverted': '已反转所有像素',
'status_reset_image': '已重置为原始图片',
'status_reset_canvas': '已清空画布',
'status_export_error': '错误:没有可导出的数据',
'status_exported': '✅ 已导出 logo 文件',
'status_pixel_toggled': '切换像素 ({}, {})',
'lang_select_title': '选择语言 / Select Language',
'btn_chinese': '中文 (Chinese)',
'btn_english': 'English',
'binding_quit': '退出',
'binding_export': '导出',
'binding_reset': '重置',
'binding_invert': '反转',
'binding_threshold_up': '阈值 +10',
'binding_threshold_down': '阈值 -10',
'binding_threshold_up_small': '阈值 +1',
'binding_threshold_down_small': '阈值 -1',
},
'en': {
'app_title': '🖼️ [ NanoKVM Logo Generator ]',
'app_title_editor': '✏️ NanoKVM Logo Editor',
'contrast_minus': 'Contrast-',
'contrast_plus': 'Contrast+',
'current_contrast': 'Contrast: {}',
'click_hint': '💡 Click image to toggle pixels',
'btn_invert': '🔃 Invert',
'btn_reset': '🔄 Reset',
'btn_export': '💾 Export',
'btn_quit': '❌ Quit',
'help_text_with_image': 'Shortcuts: ↑↓ ±10 | ←→ ±1 | I Invert | R Reset | D Export | Q Quit',
'help_text_editor': 'Shortcuts: I Invert | R Clear | D Export | Q Quit',
'status_blank_canvas': 'Blank canvas - Click pixels to draw',
'status_loaded': 'Loaded: {}',
'status_load_failed': 'Failed to load image: {}',
'status_contrast': 'Contrast: {}',
'status_inverted': 'All pixels inverted',
'status_reset_image': 'Reset to original image',
'status_reset_canvas': 'Canvas cleared',
'status_export_error': 'Error: No data to export',
'status_exported': '✅ Exported logo file',
'status_pixel_toggled': 'Toggled pixel ({}, {})',
'lang_select_title': '选择语言 / Select Language',
'btn_chinese': '中文 (Chinese)',
'btn_english': 'English',
'binding_quit': 'Quit',
'binding_export': 'Export',
'binding_reset': 'Reset',
'binding_invert': 'Invert',
'binding_threshold_up': 'Threshold +10',
'binding_threshold_down': 'Threshold -10',
'binding_threshold_up_small': 'Threshold +1',
'binding_threshold_down_small': 'Threshold -1',
}
}
def reverse_byte(byte):
"""Reverse the bits in a byte"""
result = 0
for i in range(8):
if (byte >> i) & 1:
result |= (1 << (7 - i))
return result
def process_image(image_path, threshold=128):
"""
Process image to 16x16 binary data
Args:
image_path: Path to the image file
threshold: Binarization threshold (0-255)
Returns:
16x16 numpy array of binary values
"""
if not os.path.exists(image_path):
return None
try:
img = Image.open(image_path).convert('L')
except Exception as e:
print(f"Error: Failed to open image - {e}")
return None
# If not square, crop the center region
if img.width != img.height:
size = min(img.width, img.height)
left = (img.width - size) // 2
top = (img.height - size) // 2
img = img.crop((left, top, left + size, top + size))
# Resize to 16x16 and binarize
img = img.resize((16, 16), Image.Resampling.LANCZOS)
binary = (np.array(img) < threshold).astype(np.uint8)
return binary
def create_blank_image():
"""Create a blank 16x16 image (all black)"""
return np.zeros((16, 16), dtype=np.uint8)
def binary_to_bytes(binary):
"""Convert 16x16 binary data to 32 bytes"""
data = []
for x in range(16):
for page in range(2):
byte = 0
start_row = page * 8
for bit in range(8):
row = start_row + bit
if row < 16 and binary[row][x] == 1:
byte |= (1 << (7 - bit))
data.append(reverse_byte(byte))
return bytes(data)
def binary_to_image(binary_data, size=64):
"""
Convert 16x16 binary data to 64x64 RGBA image
Args:
binary_data: 16x16 numpy array of binary values
size: Size to scale the image to (default 64)
Returns:
PIL Image in RGBA mode
"""
# Create 16x16 RGBA image from binary data
img_16 = Image.new('RGBA', (16, 16), (0, 0, 0, 255)) # Black for 1
pixels = img_16.load()
for y in range(16):
for x in range(16):
if binary_data[y][x] == 1:
pixels[x, y] = (255, 255, 255, 255) # White
else:
pixels[x, y] = (0, 0, 0, 255) # Black
# Scale to 64x64 using NEAREST for pixel-perfect scaling
img_64 = img_16.resize((size, size), Image.Resampling.NEAREST)
return img_64
def image_to_ico(image_path=None, binary_data=None, output_path="logo.ico", size=64):
"""
Convert image or binary data to 64x64 32-bit ICO file
Args:
image_path: Path to the source image (optional if binary_data is provided)
binary_data: 16x16 numpy array of binary values (optional if image_path is provided)
output_path: Output ICO file path
size: Size to scale the icon to (default 64)
"""
try:
if image_path and os.path.exists(image_path):
img = Image.open(image_path).convert('RGBA')
# If not square, crop the center region
if img.width != img.height:
min_size = min(img.width, img.height)
left = (img.width - min_size) // 2
top = (img.height - min_size) // 2
img = img.crop((left, top, left + min_size, top + min_size))
# Resize to 64x64 using LANCZOS for high quality
img_64 = img.resize((size, size), Image.Resampling.LANCZOS)
elif binary_data is not None:
# Convert binary data to image and scale
img_64 = binary_to_image(binary_data, size)
else:
print("Error: No image source provided")
return False
except Exception as e:
print(f"Error: Failed to create ICO - {e}")
return False
# Save as ICO with 32-bit color depth
img_64.save(output_path, format='ICO', sizes=[(size, size)])
return True
class PixelGrid(Static):
"""16x16 pixel grid component"""
class PixelClick(Message):
def __init__(self, x: int, y: int):
super().__init__()
self.x = x
self.y = y
DEFAULT_CSS = """
PixelGrid {
width: auto;
height: auto;
padding: 0;
background: $surface;
}
"""
def __init__(self, binary_data=None, id=None):
super().__init__(id=id)
self.binary_data = binary_data if binary_data is not None else np.zeros((16, 16), dtype=np.uint8)
self.can_focus = True
def update_data(self, binary_data):
self.binary_data = binary_data
self.refresh()
def render(self):
"""Render the pixel grid"""
lines = []
# Top border
lines.append("┌" + "─" * 32 + "┐")
for y in range(16):
line = "│"
for x in range(16):
if self.binary_data[y][x] == 1:
line += "██"
else:
line += " "
line += "│"
lines.append(line)
# Bottom border
lines.append("└" + "─" * 32 + "┘")
return "\n".join(lines)
def on_click(self, event: events.Click):
"""Handle click event"""
# Get coordinates relative to the component
# Use event.offset to get coordinates relative to the component
try:
local_x = event.offset_x
local_y = event.offset_y
except AttributeError:
# If offset attribute doesn't exist, use event coordinates
local_x = event.x if hasattr(event, 'x') else 0
local_y = event.y if hasattr(event, 'y') else 0
# Render format:
# Row 0: ┌────────────────────────────────┐ (top border)
# Rows 1-16: │██ ██ ... │ (pixel rows, 16 pixels per row, 2 chars per pixel)
# Row 17: └────────────────────────────────┘ (bottom border)
# Column positions: 0 is left border │, 1-32 is pixel area (2 chars per pixel), 33 is right border │
# Check if within pixel grid area (y from 1 to 16, since row 0 is top border)
if local_y >= 1 and local_y <= 16:
grid_y = local_y - 1
# Check if x is within valid range (1 to 32, since there are borders on both sides)
if local_x >= 1 and local_x <= 32:
# Each pixel takes 2 chars, (1,2) is column 0, (3,4) is column 1, etc.
grid_x = (local_x - 1) // 2
if 0 <= grid_x < 16 and 0 <= grid_y < 16:
self.post_message(self.PixelClick(grid_x, grid_y))
class LanguageSelectApp(App):
"""Language selection screen"""
CSS = """
Screen {
background: $surface;
}
#lang-container {
width: 100%;
height: 100%;
content-align: center middle;
}
#lang-title {
text-align: center;
text-style: bold;
padding: 2 0;
}
#lang-buttons {
width: auto;
height: auto;
content-align: center middle;
}
Button {
margin: 1 2;
min-width: 20;
}
"""
def __init__(self):
super().__init__()
self.selected_language = 'zh' # Default to Chinese
def compose(self) -> ComposeResult:
with Container(id="lang-container"):
yield Label("选择语言 / Select Language", id="lang-title")
with Horizontal(id="lang-buttons"):
yield Button("中文 (Chinese)", id="btn-chinese", variant="primary")
yield Button("English", id="btn-english", variant="success")
def on_button_pressed(self, event: Button.Pressed):
if event.button.id == "btn-chinese":
self.selected_language = 'zh'
self.exit('zh')
elif event.button.id == "btn-english":
self.selected_language = 'en'
self.exit('en')
class LogoApp(App):
"""Logo conversion TUI application"""
CSS = """
Screen {
background: $surface;
}
#main-container {
width: 100%;
height: 100%;
padding: 1 2;
}
#title {
text-align: center;
text-style: bold;
padding: 1 0;
}
#threshold-container {
width: 100%;
height: auto;
padding: 1 2;
content-align: center middle;
}
#threshold-value {
text-align: center;
padding: 0 1;
width: 20;
}
#button-container {
width: 100%;
height: auto;
content-align: center middle;
padding: 1 0;
}
Button {
margin: 0 1;
}
#status {
text-align: center;
padding: 1 0;
text-style: italic;
}
#help-text {
text-align: center;
padding: 1 0;
color: $text-muted;
}
"""
BINDINGS = [
Binding("q", "quit", "Quit"),
Binding("d", "export", "Export"),
Binding("r", "reset", "Reset"),
Binding("i", "invert", "Invert"),
Binding("up", "threshold_up", "Threshold +10"),
Binding("down", "threshold_down", "Threshold -10"),
Binding("left", "threshold_down_small", "Threshold -1"),
Binding("right", "threshold_up_small", "Threshold +1"),
]
def __init__(self, image_path=None, language='zh'):
super().__init__()
self.image_path = image_path
self.language = language
self.lang = LANGUAGES[language]
self.original_binary = None
self.current_binary = None
self.threshold = 128
self.has_image = image_path is not None
# Update bindings with selected language
self.BINDINGS = [
Binding("q", "quit", self.lang['binding_quit']),
Binding("d", "export", self.lang['binding_export']),
Binding("r", "reset", self.lang['binding_reset']),
Binding("i", "invert", self.lang['binding_invert']),
Binding("up", "threshold_up", self.lang['binding_threshold_up']),
Binding("down", "threshold_down", self.lang['binding_threshold_down']),
Binding("left", "threshold_down_small", self.lang['binding_threshold_down_small']),
Binding("right", "threshold_up_small", self.lang['binding_threshold_up_small']),
]
def compose(self) -> ComposeResult:
yield Header()
with Container(id="main-container"):
if self.has_image:
yield Label(self.lang['app_title'], id="title")
# Contrast control area: [Contrast-] Current value [Contrast+]
with Horizontal(id="threshold-container"):
yield Button(self.lang['contrast_minus'], id="threshold-minus-btn", variant="primary")
yield Label(self.lang['current_contrast'].format(self.threshold), id="threshold-value-label")
yield Button(self.lang['contrast_plus'], id="threshold-plus-btn", variant="primary")
else:
yield Label(self.lang['app_title_editor'], id="title")
yield PixelGrid(id="pixel-grid")
yield Label(self.lang['click_hint'], id="click-hint")
# Action button area: Invert | Reset | Export | Quit
with Horizontal(id="button-container"):
yield Button(self.lang['btn_invert'], id="invert-btn", variant="default")
yield Button(self.lang['btn_reset'], id="reset-btn", variant="warning")
yield Button(self.lang['btn_export'], id="export-btn", variant="success")
yield Button(self.lang['btn_quit'], id="quit-btn", variant="error")
yield Label("", id="status")
if self.has_image:
yield Label(self.lang['help_text_with_image'], id="help-text")
else:
yield Label(self.lang['help_text_editor'], id="help-text")
yield Footer()
def on_mount(self):
"""When app starts"""
self.pixel_grid = self.query_one("#pixel-grid", PixelGrid)
self.threshold_value_label = self.query_one("#threshold-value-label", Label) if self.has_image else None
self.status = self.query_one("#status", Label)
if self.has_image:
self.load_image(self.image_path)
else:
self.current_binary = create_blank_image()
self.pixel_grid.update_data(self.current_binary)
self.status.update(self.lang['status_blank_canvas'])
def load_image(self, path):
"""Load image"""
self.original_binary = process_image(path, self.threshold)
if self.original_binary is not None:
self.current_binary = self.original_binary.copy()
self.pixel_grid.update_data(self.current_binary)
self.status.update(self.lang['status_loaded'].format(path))
else:
self.status.update(self.lang['status_load_failed'].format(path))
def update_threshold(self, new_value: int):
"""Update threshold"""
new_value = max(0, min(255, new_value))
if new_value != self.threshold:
self.threshold = new_value
if self.threshold_value_label:
self.threshold_value_label.update(self.lang['current_contrast'].format(self.threshold))
if self.original_binary is not None:
self.current_binary = process_image(self.image_path, self.threshold)
self.pixel_grid.update_data(self.current_binary)
self.status.update(self.lang['status_contrast'].format(self.threshold))
def on_button_pressed(self, event: Button.Pressed):
"""Button press event"""
button_id = event.button.id
if button_id == "invert-btn":
self.action_invert()
elif button_id == "reset-btn":
self.action_reset()
elif button_id == "export-btn":
self.action_export()
elif button_id == "quit-btn":
self.action_quit()
elif button_id == "threshold-plus-btn":
self.action_threshold_up()
elif button_id == "threshold-minus-btn":
self.action_threshold_down()
def on_pixel_grid_pixel_click(self, event: PixelGrid.PixelClick):
"""Pixel click event"""
if self.current_binary is not None:
# Toggle pixel value
self.current_binary[event.y][event.x] = 1 - self.current_binary[event.y][event.x]
self.pixel_grid.update_data(self.current_binary)
self.status.update(self.lang['status_pixel_toggled'].format(event.x, event.y))
def action_invert(self):
"""Invert all pixels"""
if self.current_binary is not None:
self.current_binary = 1 - self.current_binary
self.pixel_grid.update_data(self.current_binary)
self.status.update(self.lang['status_inverted'])
def action_reset(self):
"""Reset to original image or clear"""
if self.has_image and self.original_binary is not None:
self.current_binary = self.original_binary.copy()
self.pixel_grid.update_data(self.current_binary)
self.status.update(self.lang['status_reset_image'])
else:
self.current_binary = create_blank_image()
self.pixel_grid.update_data(self.current_binary)
self.status.update(self.lang['status_reset_canvas'])
def action_export(self):
"""Export logo.bin and logo.ico"""
if self.current_binary is None:
self.status.update(self.lang['status_export_error'])
return
data = binary_to_bytes(self.current_binary)
with open("logo.bin", "wb") as f:
f.write(data)
# Export 64x64 32-bit ICO file
if self.has_image and self.image_path:
image_to_ico(self.image_path, "logo.ico", size=64)
else:
# Export from current canvas (16x16 -> 64x64)
image_to_ico(binary_data=self.current_binary, output_path="logo.ico", size=64)
self.status.update(self.lang['status_exported'])
def action_threshold_up(self):
"""Increase threshold by 10"""
if self.has_image:
self.update_threshold(self.threshold + 10)
def action_threshold_down(self):
"""Decrease threshold by 10"""
if self.has_image:
self.update_threshold(self.threshold - 10)
def action_threshold_up_small(self):
"""Increase threshold by 1"""
if self.has_image:
self.update_threshold(self.threshold + 1)
def action_threshold_down_small(self):
"""Decrease threshold by 1"""
if self.has_image:
self.update_threshold(self.threshold - 1)
def action_quit(self):
self.exit()
def main():
image_path = None
# First, show language selection screen
lang_app = LanguageSelectApp()
selected_language = lang_app.run()
if len(sys.argv) >= 2:
image_path = sys.argv[1]
if not os.path.exists(image_path):
print(f"Error: File not found - {image_path}")
return
app = LogoApp(image_path, language=selected_language)
app.run()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,39 @@
# NanoKVM Logo Generator
**Compatible Hardware:** NanoKVM-Cube, NanoKVM-PCIe
🛠️ **Tool Description:**
This is a logo generator for the NanoKVM OLED display and Web. You can use it to create a `logo.bin` and `logo.ico` file and place them in the `/boot` directory of your NanoKVM system.
📋 **Instructions:**
1. **Install required libraries:**
```bash
pip install Pillow numpy textual
```
2. **Prepare your logo image:**
Make sure it's as square as possible for best results 🖼️
3. **Run the script:**
```bash
python logo_generator.py path/to/your_logo.png
```
4. **Choose your language:**
Select either English or Chinese
5. **Adjust the contrast:**
Fine-tune the contrast based on the preview below until you're happy with how it looks ✨
6. **Fine-tune pixel by pixel:**
Click on individual pixels to adjust details. If you prefer an inverted display, just click the invert button 🔄
7. **Export and deploy:**
Export the `logo.bin` & `logo.ico`, then copy it to the `/boot` directory of your NanoKVM system. You can do this by:
* Enabling SSH and using SCP to copy the file 💻
* Or directly placing it in the boot partition of your TF card 💾
8. **Reboot and enjoy:**
Make sure your NanoKVM application version is greater than 2.3.6 ✅
Reboot your NanoKVM, and you should see your custom logo on the OLED display! 🎉

View File

@@ -31,12 +31,29 @@ export function getWiFi() {
}
// connect wifi without auth (only available in wifi configuration mode)
export function connectWifiNoAuth(ssid: string, password: string) {
export function connectWifiNoAuth(ssid: string, password: string, apPassword?: string) {
const data = {
ssid,
password
};
return http.post('/api/network/wifi', data);
return http.post('/api/network/wifi', data, {
headers: {
'X-AP-Key': apPassword || ''
}
});
}
// verify ap login
export function verifyApLogin(apPassword: string) {
return http.post(
'/api/network/wifi/verify',
{},
{
headers: {
'X-AP-Key': apPassword || ''
}
}
);
}
// connect wifi

View File

@@ -48,7 +48,14 @@ const en = {
invalidMode:
'The current mode does not support network setup. Please go to your device and enable Wi-Fi configuration mode.',
confirmBtn: 'Ok',
finishBtn: 'Finished'
finishBtn: 'Finished',
ap: {
authTitle: 'Authentication Required',
authDescription: 'Please enter the AP password to continue',
authFailed: 'Invalid AP password',
passPlaceholder: 'AP password',
verifyBtn: 'Verify',
}
},
screen: {
scale: 'Scale',

View File

@@ -11,16 +11,14 @@ const ja = {
login: 'ログイン',
placeholderUsername: 'ユーザー名を入力してください',
placeholderPassword: 'パスワードを入力してください',
placeholderPassword2: '再度パスワードを入力してください',
placeholderPassword2: 'パスワードをもう一度入力してください',
noEmptyUsername: 'ユーザー名は空にできません',
noEmptyPassword: 'パスワードは空にできません',
noAccount:
'ユーザー情報の取得に失敗しました。ウェブページをリフレッシュするか、パスワードをリセットしてください。',
invalidUser: '無効なユーザー名またはパスワード',
error: '予期しないエラー',
noAccount: 'ユーザー情報の取得に失敗しました。ページを更新してもう一度お試しいただくか、パスワードをリセットしてください。',
invalidUser: 'ユーザー名またはパスワードが正しくありません',
error: '不明なエラー',
changePassword: 'パスワード変更',
changePasswordDesc:
'デバイスのセキュリティのために、ウェブログインのパスワードを変更してください。',
changePasswordDesc: 'デバイスのセキュリティのために、パスワードを変更してください!',
differentPassword: 'パスワードが一致しません',
illegalUsername: 'ユーザー名に不正な文字が含まれています',
illegalPassword: 'パスワードに不正な文字が含まれています',
@@ -29,86 +27,147 @@ const ja = {
cancel: 'キャンセル',
loginButtonText: 'ログイン',
tips: {
reset1:
'To reset the passwords, pressing and holding the BOOT button on the NanoKVM for 10 seconds.',
reset2: 'For detailed steps, please consult this document:',
reset3: 'Web default account:',
reset4: 'SSH default account:',
change1: 'Please note that this action will change the following passwords:',
change2: 'Web login password',
change3: 'System root password (SSH login password)',
change4: 'To reset the passwords, press and hold the BOOT button on the NanoKVM.'
reset1: 'パスワードをリセットするには、NanoKVM の BOOT ボタンを 10 秒間押し続けます。',
reset2: '詳細な手順については、次のドキュメントを参照してください:',
reset3: 'ウェブデフォルトアカウント:',
reset4: 'SSH デフォルトアカウント:',
change1: 'この操作により、以下のパスワードも更新されることに注意してください:',
change2: 'ウェブログインパスワード',
change3: 'システム root パスワード(SSH ログインパスワード)',
change4: 'パスワードを忘れた場合は、NanoKVM の BOOT ボタンを長押ししてパスワードをリセットする必要があります。'
}
},
wifi: {
title: 'Wi-Fi',
description: 'Configure Wi-Fi for NanoKVM',
success: 'Please check the network status of NanoKVM and visit the new IP address.',
failed: 'Operation failed, please try again.',
confirmBtn: 'Ok',
finishBtn: 'Finished'
description: 'NanoKVM の Wi-Fi を設定する',
success: 'NanoKVM のネットワークステータスを確認するにはデバイスにアクセスしてください。',
failed: '操作に失敗しました。もう一度お試しください。',
invalidMode: '現在のモードではネットワーク設定はサポートされていません。デバイスで Wi-Fi 設定モードを有効にしてください。',
confirmBtn: 'OK',
finishBtn: '完了'
},
screen: {
scale: '倍率',
title: '画面',
video: 'ビデオモード',
videoDirectTips: 'このモードを使用するには「設定 - デバイス」で HTTPS を有効にしてください',
resolution: '解像度',
auto: '自動',
autoTips:
'特定の解像度で画面のティアリングやマウスのオフセットが発生する可能性があります。リモートホストの解像度を調整するか、自動モードを無効にすることを検討してください。',
fps: 'FPS',
'特定の解像度で画面のちらつきやマウスカーソルのずれが発生する場合があります。リモートホストの解像度を調整するか、自動モードを無効にしてください。',
fps: 'フレームレート',
customizeFps: 'カスタマイズ',
quality: '品質',
quality: '画質',
qualityLossless: 'ロスレス',
qualityHigh: '高い',
qualityMedium: '中くらい',
qualityLow: '低い',
frameDetect: 'フレーム検出',
frameDetectTip:
'フレーム間の差を計算します。リモートホストの画面で変更が検出されない場合、ビデオストリームの送信を停止します。',
resetHdmi: 'Reset HDMI'
qualityHigh: '高',
qualityMedium: '中',
qualityLow: '低',
frameDetect: 'フレーム差分検出',
frameDetectTip: 'フレーム間の差異を計算し、リモートホストの画面が変更されない場合はビデオストリームの送信を停止します',
resetHdmi: 'HDMI をリセット'
},
keyboard: {
title: 'キーボード',
paste: '貼り付け',
tips: '標準的なキーボードの文字と記号のみがサポートされています',
tips: '標準的なキーボードの文字と記号のみをサポートしています',
placeholder: '入力してください',
submit: '送信',
virtual: 'キーボード',
ctrlaltdel: 'Ctrl+Alt+Del'
virtual: '仮想キーボード',
shortcut: {
title: 'ショートカット',
custom: 'カスタム',
capture: 'ショートカットをキャプチャするにはここをクリックしてください',
clear: 'クリア',
save: '保存',
captureTips: 'システムレベルのキー(Windows キーなど)をキャプチャするには、全画面表示の権限が必要です。',
enterFullScreen: '全画面モードに切り替えます。'
},
leaderKey: {
title: 'リーダーキー',
desc: 'ブラウザの制限を回避して、システムによってブロックされているショートカットキーをリモートホストに送信します。',
howToUse: '使用方法',
simultaneous: {
title: '同時モード',
desc1: 'リーダーキーを押したまま、ショートカットキーを押します。',
desc2: '操作は直感的ですが、システムの使用状況により一部のショートカットキーが機能しない場合があります。'
},
sequential: {
title: 'シーケンシャルモード',
desc1: '【リーダーキー】をクリックして開始 →【ショートカットキー】を順にクリック →【リーダーキー】をクリックして終了します。',
desc2: 'いくつかの手順が必要ですが、システムキーの競合を完全に回避します。'
},
enable: 'リーダーキーを有効化',
tip: 'リーダーキーとして設定すると、このキーはショートカットキーをトリガーするためにのみ使用され、通常のキーとしては使用できなくなります。',
placeholder: 'リーダーキーを押してください',
shiftRight: '右 Shift',
ctrlRight: '右 Ctrl',
metaRight: '右 Win',
submit: '送信',
recorder: {
rec: 'REC',
activate: 'キーを有効にする',
input: 'ショートカットキーを押してください...'
}
}
},
mouse: {
default: 'デフォルトカーソル',
title: 'マウス',
cursor: 'ポインター形状',
default: 'デフォルトポインター',
pointer: 'ポインターカーソル',
cell: 'セルカーソル',
cell: 'セルポインター',
text: 'テキストカーソル',
grab: 'つかむカーソル',
hide: 'カーソルを非表示',
grab: 'つかむポインター',
hide: 'ポインターを非表示',
mode: 'マウスモード',
absolute: '絶対モード',
relative: '相対モード',
requestPointer:
'相対モードを使用中です。デスクトップをクリックしてマウスポインタを取得してください。',
resetHid: 'HIDをリセット'
direction: 'ホイール方向',
scrollUp: '上',
scrollDown: '下',
speed: 'ホイール速度',
fast: '速い',
slow: '遅い',
requestPointer: '相対モードを使用中です。マウスポインターを取得するには、デスクトップをクリックしてください。',
resetHid: 'HID をリセット',
hidOnly: {
title: 'HID-Only モード',
desc: '使用中にマウスとキーボードが反応しなくなり、HID をリセットしても効果がない場合は、NanoKVM とデバイス間の互換性に問題がある可能性があります。互換性を向上させるために、HID-Only モードを有効にすることをお勧めします。',
tip1: 'このモードを有効にすると仮想 USB ドライブと仮想ネットワークアダプターが無効になります。',
tip2: 'このモードではイメージマウント機能は使用できません。',
tip3: 'モードを切り替えると、NanoKVM は自動的に再起動します。',
enable: 'HID-Only モードを有効化',
disable: 'HID-Only モードを無効化'
}
},
image: {
title: 'イメージ',
loading: '読み込み中...',
empty: '見つかりませんでした',
loading: '読み込み中',
empty: 'イメージファイルがありません',
mountMode: 'マウントモード',
mountFailed: 'マウントに失敗しました',
mountDesc:
'一部のシステムでは、イメージをマウントする前にリモートホストで仮想ディスクをアンマウントする必要があります。',
mountDesc: '一部のシステムでは、イメージをマウントする前にリモートホストで仮想ディスクをアンマウントする必要があります。',
unmountFailed: 'アンマウントに失敗しました',
unmountDesc: '一部のシステムでは、イメージをアンマウントする前にリモートホストから手動で取り出す必要があります。',
refresh: 'イメージリストを更新',
attention: '注意',
deleteConfirm: 'このイメージを削除してもよろしいですか?',
okBtn: 'はい',
cancelBtn: 'いいえ',
tips: {
title: 'アップロード方法',
usb1: 'NanoKVMをUSB経由でコンピュータに接続します。',
usb2: '仮想ディスクがマウントされていることを確認します(設定 - 仮想ディスク)。',
usb1: 'NanoKVM を USB 経由でコンピュータに接続します;',
usb2: '仮想ディスクがマウントされていることを確認します(設定 - 仮想ディスク);',
usb3: 'コンピュータ上で仮想ディスクを開き、イメージファイルを仮想ディスクのルートディレクトリにコピーします。',
scp1: 'NanoKVMとコンピュータが同じローカルネットワークに接続されていることを確認します。',
scp2: 'コンピュータのターミナルを開き、SCPコマンドを使用してイメージファイルをNanoKVMの/dataディレクトリにアップロードします。',
scp3: '例: scp あなたのイメージパス root@あなたのナノKVMのIP:/data',
tfCard: 'TFカード',
tf1: 'この方法はLinuxシステムでサポートされています',
tf2: 'NanoKVMからTFカードを取り出します(フルバージョンの場合、まずケースを分解してください)。',
tf3: 'TFカードをカードリーダーに挿入し、コンピュータに接続します。',
tf4: 'TFカードの/dataディレクトリにイメージファイルをコピーします。',
tf5: 'TFカードをNanoKVMに挿入します。'
scp1: 'NanoKVM とコンピュータが同じローカルエリアネットワークに接続されていることを確認します;',
scp2: 'コンピュータでターミナルを開き、SCP コマンドを使用してイメージファイルを NanoKVM の /data ディレクトリにアップロードします。',
scp3: '例:scp your-image-path root@your-nanokvm-ip:/data',
tfCard: 'TF カード',
tf1: 'この方法は Linux システムでサポートされています',
tf2: 'NanoKVM から TF カードを取り出します(フルバージョンでは、まずケースを分解してください);',
tf3: 'TF カードをカードリーダーに挿入してコンピュータに接続します;',
tf4: 'コンピューターから TF カードの /data ディレクトリにイメージファイルをコピーします;',
tf5: 'TF カードを NanoKVM に挿入します。'
}
},
script: {
@@ -126,7 +185,7 @@ const ja = {
},
terminal: {
title: 'ターミナル',
nanokvm: 'NanoKVMターミナル',
nanokvm: 'NanoKVM ターミナル',
serial: 'シリアルポートターミナル',
serialPort: 'シリアルポート',
serialPortPlaceholder: 'シリアルポートを入力してください',
@@ -137,115 +196,225 @@ const ja = {
title: 'Wake-on-LAN',
sending: 'コマンドを送信中...',
sent: 'コマンドを送信しました',
input: 'MACを入力してください',
input: 'MAC アドレスを入力してください',
ok: 'OK'
},
download: {
title: 'イメージダウンローダー',
input: 'イメージのダウンロードアドレスを入力してください',
ok: 'OK',
disabled: '/data パーティションは読み取り専用であり、イメージのダウンロードには使用できません'
},
power: {
title: '電源',
showConfirm: '確認メッセージ',
showConfirmTip: '電源操作の確認メッセージを表示する',
reset: 'リセット',
power: '電源',
powerShort: '電源(短いクリック)',
powerLong: '電源(長いクリック)'
powerShort: '電源(クリック)',
powerLong: '電源(長押し)',
resetConfirm: '再起動を実行しますか?',
powerConfirm: '電源操作を実行しますか?',
okBtn: 'はい',
cancelBtn: 'いいえ'
},
settings: {
title: 'Settings',
title: '設定',
about: {
title: 'NanoKVMについて',
title: 'NanoKVM について',
information: '情報',
ip: 'IP',
mdns: 'mDNS',
application: 'アプリケーションバージョン',
applicationTip: 'NanoKVM web application version',
applicationTip: 'NanoKVM ウェブアプリケーションバージョン',
image: 'イメージバージョン',
imageTip: 'NanoKVM system image version',
firmware: 'ファームウェアバージョン',
imageTip: 'NanoKVM システムイメージバージョン',
deviceKey: 'デバイスキー',
community: 'コミュニティ'
community: 'コミュニティ',
hostname: 'ホスト名',
hostnameUpdated: 'ホスト名は正常に変更され、再起動後に有効になります'
},
appearance: {
title: 'Appearance',
display: 'Display',
language: 'Language',
menuBar: 'Menu Bar',
menuBarDesc: 'Display icons in the menu bar'
title: '外観',
display: '表示',
language: '言語',
languageDesc: 'インターフェース言語の選択',
webTitle: 'ウェブページタイトル',
webTitleDesc: 'ウェブページタイトルのカスタマイズ',
menuBar: {
title: 'メニューバー',
mode: '表示モード',
modeDesc: 'メニューバーの画面表示方法',
modeOff: '閉じる',
modeAuto: '自動非表示',
modeAlways: '常に表示',
icons: 'メニューアイコン',
iconsDesc: 'メニューバーでのサブメニューアイコンの表示'
}
},
device: {
title: 'Device',
title: 'デバイス',
oled: {
title: 'OLED',
description: 'OLED screen automatically sleep',
0: 'Never',
15: '15 sec',
30: '30 sec',
60: '1 min',
180: '3 min',
300: '5 min',
600: '10 min',
1800: '30 min',
3600: '1 hour'
description: 'OLED 画面の自動スリープ時間',
0: '無効',
15: '15秒',
30: '30秒',
60: '1分',
180: '3分',
300: '5分',
600: '10分',
1800: '30分',
3600: '1時間'
},
wifi: {
title: 'Wi-Fi',
description: 'Configure Wi-Fi',
setBtn: 'Config'
description: 'Wi-Fi 設定',
apMode: 'AP モードが有効になりました。QR コードをスキャンして Wi-Fi に接続してください。',
connect: 'Wi-Fi に接続',
connectDesc1: 'SSID とパスワードを入力してください',
connectDesc2: 'このネットワークに接続するためのパスワードを入力してください',
disconnect: 'このネットワーク接続を切断しますか?',
failed: '接続に失敗しました。もう一度お試しください。',
ssid: 'SSID',
password: 'パスワード',
joinBtn: '接続',
confirmBtn: 'OK',
cancelBtn: 'キャンセル'
},
disk: 'Virtual Disk',
diskDesc: 'Mount virtual U-disk on the remote host',
network: 'Virtual Network',
networkDesc: 'Mount virtual network card on the remote host'
ssh: {
description: 'SSH リモートアクセスを有効にする',
tip: '使用する前に必ず強力なパスワードを設定してください(アカウント - パスワードの変更)'
},
tls: {
description: 'HTTPS プロトコルを有効にする',
tip: '注意:HTTPS を使用すると、特に MJPEG ビデオモードで遅延が増加する可能性があります。'
},
advanced: '詳細設定',
swap: {
disable: '無効',
description: 'スワップファイルのサイズを設定する',
tip: 'この機能を有効にすると、SD カードの寿命が短くなる可能性があります!'
},
mouseJiggler: {
title: 'マウスジグラー',
description: 'リモートホストの休止を防ぐ',
disable: '閉じる',
absolute: '絶対モード',
relative: '相対モード'
},
mdns: {
description: 'mDNS 検出サービスを有効にする',
tip: 'この機能を使用していない場合は、オフにすることをお勧めします'
},
hdmi: {
description: 'HDMI/モニター 出力機能を有効にする'
},
autostart: {
title: '自動起動スクリプト設定',
description: 'NanoKVM の起動時に自動的に実行されるスクリプトファイルを管理します',
new: '新しいスクリプトを作成する',
deleteConfirm: 'このファイルを削除してもよろしいですか?',
yes: 'はい',
no: 'いいえ',
scriptName: '自動起動スクリプト名',
scriptContent: '自動起動スクリプト内容',
settings: '設定'
},
hidOnly: 'HID-Only モード',
hidOnlyDesc: 'このモードでは仮想デバイスはマウントされなくなり、基本的な HID 制御機能のみが保持されます。',
disk: '仮想ディスク',
diskDesc: 'リモートホストに仮想 USB ドライブをマウントする',
network: '仮想ネットワークカード',
networkDesc: 'リモートホストに仮想ネットワークカードをマウントする',
okBtn: 'はい',
cancelBtn: 'いいえ'
},
tailscale: {
title: 'Tailscale',
memory: {
title: 'Memory optimization',
tip: "When memory usage exceeds the limit, garbage collection is performed more aggressively to attempt to free up memory. it's recommended to set to 50MB if using Tailscale. A Tailscale restart is required for the change to take effect.",
disable: 'Disable'
title: 'メモリ最適化',
tip: "メモリ使用量が上限を超えると、メモリ解放のためにより積極的にガベージコレクションが実行されます。Tailscale を使用する場合は 50MB に設定することをお勧めします。この設定を有効にするには Tailscale を再起動する必要があります。",
},
restart: 'Are you sure to restart Tailscale?',
stop: 'Are you sure to stop Tailscale?',
stopDesc: 'Log out Tailscale and disable its automatic startup on boot.',
swap: {
title: 'スワップメモリ',
tip: 'メモリ最適化を有効にしても問題が解決しない場合は、スワップメモリ​​を有効にしてみてください。有効にするとスワップファイルが 256MB に設定されます。このサイズは「設定 - デバイス」で変更できます。'
},
restart: 'Tailscale を再起動しますか?',
stop: 'Tailscale を停止しますか?',
stopDesc: 'Tailscale からログアウトし、起動時の自動実行を無効にします。',
loading: '読み込み中...',
notInstall: 'Tailscaleが見つかりません!インストールしてください。',
notInstall: 'Tailscale が見つかりません。インストールしてください。',
install: 'インストール',
installing: 'インストール中',
failed: 'インストールに失敗しました',
retry: 'ページをリフレッシュして再試行してください。または手動でインストールしてください',
download: 'インストールパッケージをダウンロードして',
package: '解凍してください',
upTailscale: 'tailscaleをNanoKVMのディレクトリ/usr/bin/にアップロードしてください',
upTailscaled: 'tailscaledをNanoKVMのディレクトリ/usr/sbin/にアップロードしてください',
refresh: '現在のページをリフレッシュします',
notLogin:
'デバイスはまだバインドされていません。ログインしてこのデバイスをアカウントにバインドしてください。',
urlPeriod: 'このURLは10分間有効です',
retry: 'ページを更新してもう一度お試しいただくか、手動でインストールしてください',
download: 'ダウンロードして',
package: 'インストールパッケージを',
unzip: '解凍してください',
upTailscale: 'tailscale ファイルを NanoKVM の /usr/bin ディレクトリにアップロードします',
upTailscaled: 'tailscaled ファイルを NanoKVM の /usr/sbin ディレクトリにアップロードします',
refresh: 'ページを更新します',
notRunning: 'Tailscale はまだ実行されていません。起動操作を実行してください',
run: '起動',
notLogin: 'このデバイスはまだバインドされていません。ログインしてデバイスをアカウントにバインドしてください。',
urlPeriod: 'この URL は 10 分間有効です',
login: 'ログイン',
loginSuccess: 'ログイン成功',
enable: 'Tailscaleを有効化',
enable: 'Tailscale を有効化',
deviceName: 'デバイス名',
deviceIP: 'デバイスIP',
deviceIP: 'デバイスアドレス',
account: 'アカウント',
logout: 'ログアウト',
logout2: 'ログアウトしてもよろしいですか?',
okBtn: 'Yes',
cancelBtn: 'No'
logoutDesc: 'ログアウトしてもよろしいですか?',
uninstall: 'Tailscale をアンインストール',
uninstallDesc: 'Tailscale をアンインストールしてもよろしいですか?',
reboot: '再起動',
rebootDesc: 'NanoKVM を再起動してもよろしいですか?',
okBtn: 'はい',
cancelBtn: 'いいえ'
},
update: {
title: 'アップデートの確認',
queryFailed: 'バージョンの取得に失敗しました',
updateFailed: '更新に失敗しました。再試行してください。',
isLatest: '最新のバージョンを既に持っています。',
available: 'アップデートが利用可能です。更新してもよろしいですか?',
updating: '更新を開始しました。お待ちください...',
confirm: '確認',
cancel: 'キャンセル'
title: 'アップデート',
queryFailed: 'バージョン番号の取得に失敗しました',
updateFailed: 'アップデートに失敗しました。もう一度お試しください。',
isLatest: 'すでに最新バージョンです。',
available: '新しいバージョンが利用可能です。アップデートしてもよろしいですか?',
updating: 'アップデート中、お待ちください...',
confirm: 'はい',
cancel: 'いいえ',
preview: 'プレビューアップデート',
previewDesc: '新機能や改善をいち早く体験する',
previewTip: 'プレビューアップデートには不安定な部分や不完全な機能が含まれる場合があります!',
offline: {
title: 'オフラインアップデート',
desc: 'ローカルインストールパッケージでアップデートする',
upload: 'アップロード',
invalidName: 'ファイル名の形式が正しくありません。GitHub リリースページにアクセスしてインストールパッケージをダウンロードしてください。',
updateFailed: 'アップデートに失敗しました。もう一度お試しください。'
}
},
account: {
title: 'Account',
webAccount: 'Web Account Name',
password: 'Password',
updateBtn: 'Update',
logoutBtn: 'Logout'
title: 'アカウント',
webAccount: 'ウェブアカウント名',
password: 'パスワード',
updateBtn: '変更',
logoutBtn: 'ログアウト',
logoutDesc: 'ログアウトしてもよろしいですか?',
confirm: 'はい',
cancel: 'いいえ'
}
},
error: {
title: 'エラーが発生しました',
refresh: '更新'
},
fullscreen: {
toggle: '全画面表示切り替え'
},
menu: {
collapse: 'メニューを折りたたむ',
expand: 'メニューを展開する'
}
}
};

View File

@@ -46,7 +46,14 @@ const zh = {
failed: '操作失败,请重试。',
invalidMode: '当前模式不支持配置网络。请先前往设备启用 Wi-Fi 配置模式。',
confirmBtn: '确定',
finishBtn: '完成'
finishBtn: '完成',
ap: {
authTitle: '身份验证',
authDescription: '请输入 AP 密码以继续',
authFailed: '密码错误',
passPlaceholder: 'AP 密码',
verifyBtn: '验证',
}
},
screen: {
scale: '缩放',

View File

@@ -58,11 +58,12 @@ class Http {
});
}
public post(url: string, data?: any): Promise<Response> {
public post(url: string, data?: any, config?: AxiosRequestConfig): Promise<Response> {
return this.instance.request({
method: 'post',
url,
data
data,
...config
});
}

View File

@@ -46,8 +46,10 @@ export const VirtualKeyboard = () => {
const languages = [
{ value: 'en', label: 'English' },
{ value: 'fr', label: 'French' },
{ value: 'de', label: 'Deutsch' },
{ value: 'ru', label: 'Russian' }
{ value: 'de', label: 'German' },
{ value: 'ru', label: 'Russian' },
{ value: 'ko', label: 'Korean' },
{ value: 'ja', label: 'Japanese' }
];
useEffect(() => {
@@ -67,7 +69,9 @@ export const VirtualKeyboard = () => {
['en', 'default'],
['ru', 'rus'],
['de', 'qwertz'],
['fr', 'azerty']
['fr', 'azerty'],
['ko', 'ko'],
['ja', 'ja']
]);
if (keyboardLanguage === 'en' && keyboardSystem === 'mac') {
@@ -141,6 +145,16 @@ export const VirtualKeyboard = () => {
return getKeycode(base);
}
if (keyboardLanguage === 'ko' && key.endsWith('_ko')) {
const base = key.replace('_ko', '');
return getKeycode(base);
}
if (keyboardLanguage === 'ja' && key.endsWith('_ja')) {
const base = key.replace('_ja', '');
return getKeycode(base);
}
const specialKey = specialKeyMap.get(key);
if (specialKey) {
return getKeycode(specialKey);

View File

@@ -44,6 +44,22 @@ export const keyboardOptions = {
'{capslock} KeyQ_azerty KeyS_azerty KeyD_azerty KeyF_azerty KeyG_azerty KeyH_azerty KeyJ_azerty KeyK_azerty KeyL_azerty Semicolon_azerty Quote_azerty {enter}',
'{shiftleft} KeyW_azerty KeyX_azerty KeyC_azerty KeyV_azerty KeyB_azerty KeyN_azerty KeyM_azerty Comma_azerty Period_azerty Slash_azerty {shiftright}',
'{controlleft} {winleft} {altleft} {space} {altright} {winright} {menu} {controlright}'
],
ko: [
'{escape} F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12',
'Backquote_ko Digit1_ko Digit2_ko Digit3_ko Digit4_ko Digit5_ko Digit6_ko Digit7_ko Digit8_ko Digit9_ko Digit0_ko Minus_ko Equal_ko {backspace}',
'{tab} KeyQ_ko KeyW_ko KeyE_ko KeyR_ko KeyT_ko KeyY_ko KeyU_ko KeyI_ko KeyO_ko KeyP_ko BracketLeft_ko BracketRight_ko Backslash_ko',
'{capslock} KeyA_ko KeyS_ko KeyD_ko KeyF_ko KeyG_ko KeyH_ko KeyJ_ko KeyK_ko KeyL_ko Semicolon_ko Quote_ko {enter}',
'{shiftleft} KeyZ_ko KeyX_ko KeyC_ko KeyV_ko KeyB_ko KeyN_ko KeyM_ko Comma_ko Period_ko Slash_ko {shiftright}',
'{controlleft} {winleft} {altleft} {space} {altright} {winright} {menu} {controlright}'
],
ja: [
'{escape} F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12',
'Backquote_ja Digit1_ja Digit2_ja Digit3_ja Digit4_ja Digit5_ja Digit6_ja Digit7_ja Digit8_ja Digit9_ja Digit0_ja Minus_ja Equal_ja {backspace}',
'{tab} KeyQ_ja KeyW_ja KeyE_ja KeyR_ja KeyT_ja KeyY_ja KeyU_ja KeyI_ja KeyO_ja KeyP_ja BracketLeft_ja BracketRight_ja Backslash_ja',
'{capslock} KeyA_ja KeyS_ja KeyD_ja KeyF_ja KeyG_ja KeyH_ja KeyJ_ja KeyK_ja KeyL_ja Semicolon_ja Quote_ja {enter}',
'{shiftleft} KeyZ_ja KeyX_ja KeyC_ja KeyV_ja KeyB_ja KeyN_ja KeyM_ja Comma_ja Period_ja Slash_ja {shiftright}',
'{controlleft} {winleft} {altleft} {space} {altright} {winright} {menu} {controlright}'
]
},
display: {
@@ -186,6 +202,110 @@ export const keyboardOptions = {
RusPeriod: 'Ю',
RusSlash: ',<br/>.',
// Korean (2-set) display
Backquote_ko: '`',
Digit1_ko: '1',
Digit2_ko: '2',
Digit3_ko: '3',
Digit4_ko: '4',
Digit5_ko: '5',
Digit6_ko: '6',
Digit7_ko: '7',
Digit8_ko: '8',
Digit9_ko: '9',
Digit0_ko: '0',
Minus_ko: '-',
Equal_ko: '=',
KeyQ_ko: 'ㅂ',
KeyW_ko: 'ㅈ',
KeyE_ko: 'ㄷ',
KeyR_ko: 'ㄱ',
KeyT_ko: 'ㅅ',
KeyY_ko: 'ㅛ',
KeyU_ko: 'ㅕ',
KeyI_ko: 'ㅑ',
KeyO_ko: 'ㅐ',
KeyP_ko: 'ㅔ',
BracketLeft_ko: '[',
BracketRight_ko: ']',
Backslash_ko: '\\',
KeyA_ko: 'ㅁ',
KeyS_ko: 'ㄴ',
KeyD_ko: 'ㅇ',
KeyF_ko: 'ㄹ',
KeyG_ko: 'ㅎ',
KeyH_ko: 'ㅗ',
KeyJ_ko: 'ㅓ',
KeyK_ko: 'ㅏ',
KeyL_ko: 'ㅣ',
Semicolon_ko: ';',
Quote_ko: "'",
KeyZ_ko: 'ㅋ',
KeyX_ko: 'ㅌ',
KeyC_ko: 'ㅊ',
KeyV_ko: 'ㅍ',
KeyB_ko: 'ㅠ',
KeyN_ko: 'ㅜ',
KeyM_ko: 'ㅡ',
Comma_ko: ',',
Period_ko: '.',
Slash_ko: '/',
// Japanese display (representative hiragana per key)
Backquote_ja: '`',
Digit1_ja: '1',
Digit2_ja: '2',
Digit3_ja: '3',
Digit4_ja: '4',
Digit5_ja: '5',
Digit6_ja: '6',
Digit7_ja: '7',
Digit8_ja: '8',
Digit9_ja: '9',
Digit0_ja: '0',
Minus_ja: '-',
Equal_ja: '=',
KeyQ_ja: 'た',
KeyW_ja: 'て',
KeyE_ja: 'い',
KeyR_ja: 'す',
KeyT_ja: 'か',
KeyY_ja: 'ん',
KeyU_ja: 'な',
KeyI_ja: 'に',
KeyO_ja: 'ら',
KeyP_ja: 'せ',
BracketLeft_ja: '[',
BracketRight_ja: ']',
Backslash_ja: '\\',
KeyA_ja: 'ち',
KeyS_ja: 'と',
KeyD_ja: 'し',
KeyF_ja: 'は',
KeyG_ja: 'き',
KeyH_ja: 'く',
KeyJ_ja: 'ま',
KeyK_ja: 'の',
KeyL_ja: 'り',
Semicolon_ja: ';',
Quote_ja: "'",
KeyZ_ja: 'つ',
KeyX_ja: 'さ',
KeyC_ja: 'そ',
KeyV_ja: 'ひ',
KeyB_ja: 'こ',
KeyN_ja: 'み',
KeyM_ja: 'も',
Comma_ja: ',',
Period_ja: '.',
Slash_ja: '/',
// AZERTY specific display keys
// Row 1
Backquote_azerty: '&#60;<br/>&#62;',

View File

@@ -1,17 +1,56 @@
import { useState } from 'react';
import { CheckOutlined, LockOutlined, WifiOutlined } from '@ant-design/icons';
import { useEffect, useState } from 'react';
import { CheckOutlined, KeyOutlined, LockOutlined, WifiOutlined } from '@ant-design/icons';
import { Button, Form, Input } from 'antd';
import { useTranslation } from 'react-i18next';
import { useSearchParams } from 'react-router-dom';
import * as api from '@/api/network.ts';
import { Head } from '@/components/head.tsx';
type State = '' | 'loading' | 'success' | 'failed' | 'denied';
type VerifyState = '' | 'failed' | 'denied';
export const Wifi = () => {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const [state, setState] = useState<State>('');
const [apPassword, setApPassword] = useState<string>('');
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
const [verifying, setVerifying] = useState<boolean>(false);
const [verifyState, setVerifyState] = useState<VerifyState>('');
useEffect(() => {
const pass = searchParams.get('p') || searchParams.get('P');
if (pass) {
verifyPassword(pass);
}
}, []);
async function verifyPassword(password: string) {
if (verifying) return;
setVerifying(true);
setVerifyState('');
try {
const rsp = await api.verifyApLogin(password);
if (rsp?.code === 0) {
setApPassword(password);
setIsAuthenticated(true);
} else {
setVerifyState(rsp?.code === -1 ? 'denied' : 'failed');
}
} catch (err) {
console.error(err);
setVerifyState('failed');
}
setVerifying(false);
}
async function onVerifyFinish(values: any) {
if (!values.apPassword) return;
await verifyPassword(values.apPassword);
}
async function connect(values: any) {
if (!values.ssid || !values.password) return;
@@ -20,13 +59,14 @@ export const Wifi = () => {
setState('loading');
try {
const rsp = await api.connectWifiNoAuth(values.ssid, values.password);
const rsp = await api.connectWifiNoAuth(values.ssid, values.password, apPassword);
switch (rsp?.code) {
case 0:
setState('success');
return;
case -1:
case -4:
setState('denied');
return;
case -2:
@@ -41,6 +81,48 @@ export const Wifi = () => {
setState('success');
}
if (!isAuthenticated) {
return (
<>
<Head title={t('head.wifi')} />
<div className="flex h-screen w-screen flex-col items-center justify-center">
<Form
style={{ minWidth: 300, maxWidth: 500 }}
initialValues={{ remember: true }}
onFinish={onVerifyFinish}
>
<div className="flex flex-col space-y-1 pb-5">
<span className="text-center text-2xl font-semibold text-red-500">
{t('wifi.ap.authTitle')}
</span>
<span className="text-center text-neutral-400">{t('wifi.ap.authDescription')}</span>
</div>
<Form.Item name="apPassword">
<Input.Password prefix={<KeyOutlined />} placeholder={t('wifi.ap.passPlaceholder')} />
</Form.Item>
<Form.Item>
<Button className="w-full" htmlType="submit" type="primary" loading={verifying}>
{t('wifi.ap.verifyBtn')}
</Button>
</Form.Item>
</Form>
<div className="flex max-w-[500px] justify-center px-5 pt-3 md:px-10">
{verifyState === 'failed' && (
<span className="text-sm text-red-500">{t('wifi.ap.authFailed')}</span>
)}
{verifyState === 'denied' && (
<span className="text-sm text-red-500">{t('wifi.invalidMode')}</span>
)}
</div>
</div>
</>
);
}
return (
<>
<Head title={t('head.wifi')} />

View File

@@ -44,6 +44,7 @@ export const router = createHashRouter([
},
{
path: '/wifi',
caseSensitive: false,
lazy: async () => {
const { Wifi } = await import('./pages/wifi');
return { Component: Wifi };