Merge branch 'master' of github.com:webmin/webmin
Some checks failed
Tests / prove (push) Has been cancelled
Package and upload artifacts / build (push) Has been cancelled
Close inactive / close-inactive (push) Has been cancelled

This commit is contained in:
Jamie Cameron
2026-10-08 21:26:25 -07:00
16 changed files with 322 additions and 30 deletions

View File

@@ -1,5 +1,7 @@
## Changelog
#### 2.671 (October, 2026)
* Add color schemes to the Terminal module
* Add per-user Terminal preferences for color scheme, font size, screen reader mode and terminal dimensions
* Add a Cloudmin virtualization host profile to the nftables module
* Fix two-factor authentication validation to use the Webmin configuration and data directories passed by the caller
* Fix HTML escaping of module, script and session fields on the Webmin Actions Log details page
@@ -7,6 +9,10 @@
* Fix path traversal and command injection in parameterized Custom Commands file editors
* Fix MySQL/MariaDB manual editor ACL bypass
* Add optional address-family selection to HTTP downloads and socket connections, preserved across redirects [#1300](https://github.com/virtualmin/virtualmin-gpl/issues/1300)
* Update the Authentic theme to the latest version with various improvements:
- Add support for Terminal module color preferences
- Add support for the new Cloudmin navigation, graphical console and live virtual machine controls
- Fix spacing and borders in widgets, tables and tab panes
#### 2.670 (September 20, 2026)
* Add new Hardware Information module for inspecting system, firmware, security, PCI, USB, storage, network, processor, sensor, driver, and kernel module details

View File

@@ -7,7 +7,7 @@ index_ecommands=Ninguno de los comandos de respaldo de sistemas de archivo (como
index_return=lista de respaldos
dump_dir=Directorio a respaldar
dump_dirs=Directorios a rspaldar
dump_dirs=Directorios a respaldar
dump_fs=Sistema de archivos
dump_sched=¿Programado?
dump_when=Horas programadas para respaldar

View File

@@ -1553,6 +1553,19 @@ return $name && $name !~ /[\0\r\n\/]/ && $name !~ /^\./ &&
$name =~ /^[a-z0-9\.\_\-\@:]+\.($units_piped)$/i;
}
=head2 unit_verify_options(analyze)
Returns validation options supported by the given systemd-analyze executable.
=cut
sub unit_verify_options
{
my ($analyze) = @_;
# Version 250 added an exit status for warnings in the submitted unit only.
my $help = backquote_command(quotemeta($analyze)." --help 2>/dev/null");
return !$? && $help =~ /--recursive-errors=/ ? ("--recursive-errors=no") : ();
}
=head2 verify_unit_data(file, data, [user-scope], [user])
Runs C<systemd-analyze verify> against unit-file contents before a manual save.
@@ -1600,11 +1613,12 @@ if (!$write_ok) {
# User units have slightly different directive rules, so verify them through
# the target user's manager environment when the owner is known.
my @verify_options = unit_verify_options($analyze);
my $cmd = $uinfo ?
user_manager_command($user, quotemeta($analyze), "--user",
"verify", quotemeta($tmpfile)) :
"verify", @verify_options, quotemeta($tmpfile)) :
quotemeta($analyze)." ".($user_scope ? "--user " : "").
"verify ".quotemeta($tmpfile);
"verify ".join("", map { $_." " } @verify_options).quotemeta($tmpfile);
return (0, $bad_user) if (!$cmd);
$cmd .= " 2>&1 </dev/null";
my $out = backquote_logged($cmd);
@@ -1613,7 +1627,9 @@ unlink($tmpfile);
rmdir($tmpdir);
my $issue = $out;
$issue =~ s/^\s+|\s+$//g;
return (1, undef) if (!$rv && !$issue);
# Scoped validation reports this unit's warnings through the exit status.
# Older analyzers still need their output checked to catch ignored directives.
return (1, undef) if (!$rv && (@verify_options || !$issue));
$out ||= $text{'manual_ewrite'};
return (0, text('systemd_everify', ui_tag('tt', html_escape($out))));
}
@@ -1843,11 +1859,12 @@ if (!$write_ok) {
ui_tag('tt', html_escape($err))));
}
my @verify_options = unit_verify_options($analyze);
my $cmd = $uinfo ?
user_manager_command($user, quotemeta($analyze), "--user",
"verify", quotemeta($tmpfile)) :
"verify", @verify_options, quotemeta($tmpfile)) :
quotemeta($analyze)." ".($user_scope ? "--user " : "").
"verify ".quotemeta($tmpfile);
"verify ".join("", map { $_." " } @verify_options).quotemeta($tmpfile);
return (0, $bad_user) if (!$cmd);
$cmd .= " 2>&1 </dev/null";
my $out = backquote_logged($cmd);
@@ -1858,7 +1875,9 @@ unlink($tmpfile);
rmdir($tmpdir);
my $issue = $out;
$issue =~ s/^\s+|\s+$//g;
return (1, undef) if (!$rv && !$issue);
# Scoped validation reports this unit's warnings through the exit status.
# Older analyzers still need their output checked to catch ignored directives.
return (1, undef) if (!$rv && (@verify_options || !$issue));
$out ||= $text{'manual_ewrite'};
return (0, text('systemd_everify', ui_tag('tt', html_escape($out))));
}

View File

@@ -1307,6 +1307,8 @@ like(get_unit_root(), qr{^/(etc|usr/lib|lib)/systemd/system$},
my $verify_root = "$work/verify-units";
make_path($verify_root);
my @verify_commands;
# Keep the original cases on the legacy analyzer path.
local *main::backquote_command = sub { $? = 0; return ""; };
my $verify_count = 0;
local *main::has_command = sub {
return $_[0] eq 'systemd-analyze' ? '/bin/systemd-analyze' : undef;
@@ -1428,6 +1430,35 @@ like(get_unit_root(), qr{^/(etc|usr/lib|lib)/systemd/system$},
ok($ok, 'verify_dropin_data accepts user-owned drop-in data');
is($user_verify[-1]->[0], 'alice',
'verify_dropin_data verifies as the target user');
# Modern analyzers distinguish submitted-unit warnings from dependency noise.
local *main::backquote_command = sub {
$? = 0;
return " --recursive-errors=MODE Control verification\n";
};
foreach my $dropin (0, 1) {
my $status = 0;
local *main::backquote_logged = sub {
push(@verify_commands, $_[0]);
$? = $status;
return "vendor.service:2: Support for option CPUAccounting= has been removed\n";
};
my $validate = sub {
return $dropin ? verify_dropin_data(
'/etc/systemd/system/scoped.service',
"[Service]\nExecStart=/bin/true\n", "[Service]\nRestart=always\n", 0)
: verify_unit_data('/etc/systemd/system/scoped.service',
"[Service]\nExecStart=/bin/true\n", 0);
};
($ok, $err) = $validate->();
ok($ok, 'scoped verification accepts dependency warnings (drop-in='.$dropin.')');
like($verify_commands[-1], qr/--recursive-errors=no/,
'scoped verification asks for submitted-unit warning status');
$status = 256;
($ok, $err) = $validate->();
ok(!$ok, 'scoped verification rejects submitted-unit warnings (drop-in='.$dropin.')');
like($err, qr/CPUAccounting/, 'scoped validation failure retains diagnostics');
}
}
{

View File

@@ -113,6 +113,23 @@ assert_no_handler_injection(
assert_no_handler_injection(
main::ui_stat({ 'value' => $xss, 'label' => $xss, 'href' => $xss }),
'ui_stat');
# Row defaults must not replace explicit tile shapes or mutate input options.
{
my $tile = { 'value' => 4 };
my $square = { 'value' => 2, 'shape' => 'square' };
my $html = main::ui_stats([$tile, $square], { 'shape' => 'rounded' });
my @shapes = $html =~ /\bui_stat_(rounded|square)\b/g;
is_deeply(\@shapes, ['rounded', 'square'],
'ui_stats applies its shape only when a tile has no override');
is_deeply($tile, { 'value' => 4 }, 'ui_stats preserves caller options');
like(main::ui_stat({ 'value' => 1 }), qr/\bui_stat_square\b/,
'ui_stat defaults to square corners');
$html = main::ui_stat({ 'value' => 1, 'shape' => $xss });
like($html, qr/\bui_stat_square\b/, 'unknown shapes fall back to square');
assert_no_handler_injection($html, 'ui_stat shape');
}
assert_no_handler_injection(
main::ui_empty_state({ 'title' => $xss, 'desc' => $xss }),
'ui_empty_state');

View File

@@ -17,6 +17,43 @@ my $script = File::Spec->rel2abs(
File::Spec->catfile(dirname(__FILE__), '..', 'web-lib-funcs.pl'));
require $script;
# get_module_info — validate names before looking up module files.
subtest 'get_module_info name validation' => sub {
no warnings qw(redefine once);
my @lookups;
# Record accepted names and stop before any filesystem access.
local *main::module_root_directory = sub {
push(@lookups, $_[0]);
return '/unused';
};
local *main::load_language_auto = sub { return 0; };
local *main::load_language_neutral = sub { return 0; };
local *main::read_file_cached = sub { return 0; };
# Invalid names must return empty without reaching the module lookup.
foreach my $case (
[ undef, 'undefined' ], [ '', 'empty' ],
[ "module\n", 'trailing newline' ],
[ "module\r\n", 'trailing CRLF' ],
[ "mod\nule", 'embedded newline' ],
[ 'module ', 'trailing space' ],
[ "module\t", 'trailing tab' ],
[ 'mod.ule', 'dot' ], [ 'mod/ule', 'slash' ],
[ "mod\x{e9}ule", 'non-ASCII letter' ]) {
@lookups = ();
is_deeply([main::get_module_info($case->[0])], [],
"$case->[1] returns no module info");
is_deeply(\@lookups, [], "$case->[1] skips module lookup");
}
# Valid names, including the string "0", must still reach the lookup.
foreach my $name ('module', 'Module_123-test', '0') {
@lookups = ();
my %info = main::get_module_info($name);
is_deeply(\@lookups, [$name], "$name reaches module lookup");
}
};
# simplify_path — strip ./ and resolve ../, refusing to escape root.
#
# Contract:

View File

@@ -4442,7 +4442,8 @@ return &ui_tag($tag, "\n".$content."\n", $attrs);
Returns inline SVG for a named icon from the built-in set, drawn with the
current text color. Unlike ui_icon, this does not depend on any theme
icon font. Options are :
icon font. Each icon has a CSS class identifying its name, such as
C<ui_svg_icon_trash>, for targeted styling. Options are :
=item size - Pixel size, defaulting to 16.
@@ -4460,7 +4461,8 @@ $opts ||= {};
my $paths = $ui_svg_icons{$name};
return "" if (!$paths);
my $size = int($opts->{'size'} || 16);
my %attrs = ( 'class' => &_ui_class('ui_svg_icon', $opts->{'class'}),
my %attrs = ( 'class' => &_ui_class('ui_svg_icon', 'ui_svg_icon_'.$name,
$opts->{'class'}),
'width' => $size,
'height' => $size,
'viewBox' => '0 0 16 16',
@@ -4850,6 +4852,8 @@ Options are :
=item icon - Optional icon name shown before the value.
=item shape - Square corners (square, the default) or rounded corners (rounded).
=cut
sub ui_stat
{
@@ -4871,7 +4875,8 @@ my $desc = &_ui_text($opts, 'desc');
$body .= &ui_tag('span', $desc, { 'class' => 'ui_stat_desc' })
if (defined($desc));
return &_ui_block($opts->{'href'} ? 'a' : 'div', $body, &_ui_attrs({
'class' => 'ui_stat',
'class' => &_ui_class('ui_stat', ($opts->{'shape'} || '') eq 'rounded' ?
'ui_stat_rounded' : 'ui_stat_square'),
'href' => $opts->{'href'} }));
}
@@ -4879,7 +4884,8 @@ return &_ui_block($opts->{'href'} ? 'a' : 'div', $body, &_ui_attrs({
Returns HTML for a responsive row of statistic tiles. Each element of the
stats array is a hash reference in ui_stat format. Options are C<class>
and C<min> for the minimum tile width.
and C<min> for the minimum tile width, and C<shape> for the default corner
shape. An individual tile's C<shape> overrides the row's setting.
=cut
sub ui_stats
@@ -4888,7 +4894,9 @@ return &theme_ui_stats(@_) if (defined(&theme_ui_stats));
my ($stats, $opts) = @_;
$opts ||= {};
return "" if (!$stats || !@$stats);
return &_ui_block('div', join("", map { &ui_stat($_) } @$stats),
# Apply row defaults to a copy so caller-owned tile options stay unchanged.
return &_ui_block('div', join("", map {
&ui_stat({ 'shape' => $opts->{'shape'}, %$_ }) } @$stats),
&_ui_attrs({
'class' => &_ui_class('ui_stats', $opts->{'class'}),
'style' => $opts->{'min'} ?

View File

@@ -64,7 +64,7 @@
--ui-info-line: var(--border-color-info, #b8d6f2);
--ui-neutral: var(--text-color-muted, #4b5563);
--ui-neutral-text: var(--text-color-neutral-stressed, #40474f);
--ui-neutral-soft: var(--bg-color-neutral, #eff1f3);
--ui-neutral-soft: var(--ui-surface-2);
--ui-neutral-line: var(--border-color-neutral, #d7dbe0);
/* Controls */
@@ -218,9 +218,9 @@ body:has(.ui_page[data-ui-scheme="dark"]) {
.ui_page > * + * { margin-top: var(--ui-gap); }
.ui_page :not(.ui_grid, .ui_stack, .ui_cluster, .ui_stats) >
* + :is(.ui_grid, .ui_stack, .ui_card, .ui_empty),
* + :is(.ui_grid, .ui_stack, .ui_card, .ui_empty, .ui_stats),
.ui_page :not(.ui_grid, .ui_stack, .ui_cluster, .ui_stats) >
:is(.ui_grid, .ui_stack, .ui_card, .ui_empty) + * {
:is(.ui_grid, .ui_stack, .ui_card, .ui_empty, .ui_stats) + * {
margin-top: var(--ui-gap);
}
.ui_page [hidden] { display: none !important; }
@@ -342,6 +342,10 @@ body:has(.ui_page[data-ui-scheme="dark"]) {
}
.ui_cluster { --ui-gap: 10px; }
/* Separate action forms share the cluster's gap. Theme spacing between stacked
* forms must not offset one action vertically inside a horizontal cluster. */
.ui_cluster > form.ui_form { margin: 0 !important; }
/* Themes that regroup adjacent submit buttons into one box, as Authentic
* does with its btn-group, wrap them inside that box with no vertical
* space. Give those rows a gap while keeping the buttons joined. */
@@ -427,6 +431,8 @@ body:has(.ui_page[data-ui-scheme="dark"]) {
color: inherit;
text-decoration: none;
}
.ui_stat_square { border-radius: 0; }
.ui_stat_rounded { border-radius: 0.5em; }
.ui_stat_value {
font-size: 1.85em;
font-weight: 500;
@@ -608,7 +614,11 @@ a.ui_stat:hover .ui_stat_value { color: var(--ui-accent); }
}
:is(button, a).ui_action_rounded { border-radius: 4px; }
:is(button, a).ui_action_pill { border-radius: 999px; }
.ui_action .ui_action_content > span { align-self: baseline; }
.ui_action .ui_svg_icon { width: 1em; height: 1em; flex-shrink: 0; }
.ui_action_small .ui_svg_icon_trash {
transform: translateY(-0.25px);
}
/* Focus follows the visible surface, even when a theme resets link outlines. */
.ui_action:not(:disabled):not([aria-disabled="true"]):hover .ui_action_content {

View File

@@ -6540,7 +6540,7 @@ sub get_module_info
{
my ($mod, $noclone, $forcache) = @_;
return () if (!defined($mod) || $mod !~ /^[A-Za-z0-9_-]+$/);
return () if (!defined($mod) || $mod eq "" || $mod =~ /[^A-Za-z0-9_-]/);
my (%rv, $clone, $o);
my $mdir = &module_root_directory($mod);
my $auto = load_language_auto();

View File

@@ -1,5 +1,6 @@
xterm=xterm-256color
fontsize=14
theme=dark
base_port=555
rcfile=0
locale=0

View File

@@ -1,6 +1,7 @@
xterm=Set <tt>TERM</tt> environmental variable to,4,xterm+256color-xterm&#45;256color,xterm+16color-xterm&#45;16color,xterm-xterm,vt102-vt102,vt100-vt100,vt52-vt52,rxvt-rxvt,nsterm-nsterm,dtterm-dtterm,ansi-ansi
size=Terminal width and height in characters,3,Automatic,5,,,Static (80x24)
fontsize=Adjust font size,4,0-Auto,12-Small,14-Normal,16-Large,18-Huge
theme=Color scheme,4,dark-Dark,light-Light,auto-Auto
locale=Set shell character encoding,10,0-Shell default,1-<tt>en_US.UTF&#45;8</tt>,Custom
rcfile=Execute initialization commands from file,10,0-Shell default,1-Module default,Custom
screen_reader=Enable screen reader mode,1,true-Yes,false-No

View File

@@ -80,12 +80,26 @@ if ($conf_cols_n && $conf_rows_n && !$xmlhr) {
$ENV{'LINES'} = $conf_rows_n;
}
# Resolve colors once for both the standalone terminal and theme clients.
my $terminal_options = get_terminal_options($config{'theme'});
# Auto clients choose between the same palettes used by explicit selections.
my $terminal_themes = $config{'theme'} eq 'auto' ? {
'dark' => get_terminal_options('dark'),
'light' => get_terminal_options('light'),
} : undef;
my $terminal_theme_json = convert_to_json($terminal_options->{'theme'});
my $terminal_background = $terminal_options->{'theme'}->{'background'};
my $terminal_foreground = $terminal_options->{'theme'}->{'foreground'};
my $terminal_contrast = $terminal_options->{'minimumContrastRatio'};
# Define columns and rows
my $conf_screen_reader = $config{'screen_reader'} eq 'true' ? 'true' : 'false';
my %term_opts;
$term_opts{'Options'} = "{ cols: $env_cols, rows: $env_rows, ".
"screenReaderMode: $conf_screen_reader, ".
"overviewRuler: { width: 9 }, ".
"theme: $terminal_theme_json, ".
"minimumContrastRatio: $terminal_contrast, ".
"fontSize: $font_size }";
my $term_size = "
@@ -104,8 +118,9 @@ body[style='height:100%'] {
white-space: nowrap;
}
#terminal {
border: 1px solid #000;
background-color: #000;
border: 1px solid var(--terminal-background, #000);
background-color: var(--terminal-background, #000);
color: var(--terminal-foreground, #fff);
padding: 2px;
margin: 0 auto;
$term_size
@@ -126,8 +141,8 @@ body[style='height:100%'] {
box-sizing: border-box;
border: 1px solid transparent;
border-top-color: rgba(255, 255, 255, 0.8);
border-bottom-color: rgba(255, 255, 255, 0.8);
border-top-color: var(--terminal-foreground, #fff);
border-bottom-color: var(--terminal-foreground, #fff);
animation: jumping-spinner 1s ease infinite;
}
@@ -138,7 +153,7 @@ body[style='height:100%'] {
margin-left: 24px;
margin-top: -16px;
font-weight: 100;
color: rgba(255, 255, 255, 0.8);
color: var(--terminal-foreground, #fff);
font-family: "Lucida Console", Courier, monospace;
font-size: 14px;
text-transform: uppercase;
@@ -167,6 +182,9 @@ body[style='height:100%'] {
EOF
# Share loading colors and Auto updates with other terminal modules.
my $terminal_loading_script = get_terminal_theme_script($config{'theme'});
# Print header
ui_print_header(undef, $text{'index_title'}, "", undef, 1, 1, 0, undef,
"<link rel=stylesheet href=\"$termlinks->{'css'}[0]\">\n".
@@ -177,7 +195,10 @@ ui_print_header(undef, $text{'index_title'}, "", undef, 1, 1, 0, undef,
);
# Print main container
print "<div data-label=\"$text{'index_connecting'}\" id=\"terminal\"></div>\n";
print "<div data-label=\"$text{'index_connecting'}\" id=\"terminal\" ".
"style=\"--terminal-background: $terminal_background; ".
"--terminal-foreground: $terminal_foreground\"></div>\n".
($terminal_themes ? $terminal_loading_script : '');
# Get a free port that can be used for the socket. Normal browser sessions
# are revalidated by miniserv while the websocket stays open. Proxied or
@@ -236,6 +257,9 @@ my $term_script = <<EOF;
fitAddon = new FitAddon.FitAddon(),
renderScript = document.createElement('script');
// Attach the renderer to the palette used while connecting.
if (termcont.xtermTheme) termcont.xtermTheme(term);
renderScript.src = webGLAddonLink;
renderScript.async = false;
document.body.appendChild(renderScript);
@@ -273,10 +297,12 @@ my $term_script = <<EOF;
});
};
socket.onerror = function() {
if (termcont.xtermTheme) termcont.xtermTheme.dispose();
termcont.innerHTML = '<tt style="color: \#ff0000">Error: ' +
err_conn_cannot + '</tt>';
};
socket.onclose = function() {
if (termcont.xtermTheme) termcont.xtermTheme.dispose();
termcont.innerHTML = '<tt style="color: \#ff0000">Error: ' +
err_conn_lost + '</tt>';
};
@@ -284,21 +310,21 @@ my $term_script = <<EOF;
EOF
# Return inline script data depending on type
# Expose the same options in full pages and XHR responses. Themes can also
# read them when a proxy returns the standalone page instead of an XHR response.
print "<script>\n";
if ($xmlhr) {
print "var xterm_argv = ".
print "var xterm_argv = ".
convert_to_json(
{ 'conf' => \%config,
'options' => $terminal_options,
'themes' => $terminal_themes,
'files' => $termlinks,
'socket_url' => $url,
'port' => $port,
'cols' => $env_cols,
'rows' => $env_rows,
'uinfo' => \@uinfo });
}
else {
print $term_script;
}
'uinfo' => \@uinfo }).";\n";
# Only standalone pages start their own terminal client.
print $term_script if (!$xmlhr);
print "</script>\n";
ui_print_footer();

1
xterm/prefs.info Normal file
View File

@@ -0,0 +1 @@
allowed=theme,fontsize,screen_reader,size

View File

@@ -115,6 +115,50 @@ subtest 'config_pre_load' => sub {
}
};
# Terminal appearance settings use the module's native per-user preferences.
subtest 'terminal appearance preferences' => sub {
my $dark = get_terminal_options('dark');
my $light = get_terminal_options('light');
is($dark->{'theme'}->{'background'}, '#000000', 'Dark background');
is($light->{'theme'}->{'background'}, '#ffffff', 'Light background');
is($light->{'theme'}->{'foreground'}, '#242424', 'Light uses dark text');
is($light->{'theme'}->{'cursorAccent'}, '#ffffff', 'block cursor text');
cmp_ok($light->{'minimumContrastRatio'}, '>=', 4.5,
'bright and indexed text remains readable');
is_deeply(get_terminal_options(undef), $dark, 'missing theme defaults to Dark');
is_deeply(get_terminal_options('gray'), $dark, 'unknown theme defaults to Dark');
is_deeply(get_terminal_options('auto'), $dark, 'Auto starts Dark until resolved by the client');
# Save appearance settings per user without saving the shell account.
require '../config-lib.pl';
local $main::remote_user = 'color-test-one';
local $WebminCore::remote_user = $main::remote_user;
my %defaults = (theme => 'dark', fontsize => 14,
screen_reader => 'false', size => '80X24');
save_module_preferences('xterm', { theme => 'light', fontsize => 16,
screen_reader => 'true', size => '100X30', user => 'ignored' });
my %first = (%defaults, user => 'unchanged');
load_module_preferences('xterm', \%first);
is($first{'theme'}, 'light', 'first user reloads Light');
is_deeply([ @first{qw(fontsize screen_reader size)} ],
[ 16, 'true', '100X30' ],
'font size, screen reader and dimensions reload for this user');
is($first{'user'}, 'unchanged', 'shell account is not a personal preference');
{
local $main::remote_user = 'color-test-two';
local $WebminCore::remote_user = $main::remote_user;
my %second = %defaults;
load_module_preferences('xterm', \%second);
is_deeply(\%second, \%defaults, 'new user keeps all module defaults');
save_module_preferences('xterm', { theme => 'dark' });
}
load_module_preferences('xterm', \%first);
is($first{'theme'}, 'light', 'another user saving preserves the first choice');
save_module_preferences('xterm', { theme => 'auto' });
load_module_preferences('xterm', \%first);
is($first{'theme'}, 'auto', 'Auto is saved as a personal preference');
};
# verify_websocket_key — handshake auth
#
# miniserv.pl rewrites the inbound Sec-WebSocket-Key to base64(session_id)

View File

@@ -25,6 +25,95 @@ if (($ENV{'HTTP_X_REQUESTED_WITH'} || '') eq "XMLHttpRequest") {
}
}
# get_terminal_options([theme])
# Return xterm.js color options for a Dark or Light scheme. Missing or
# unrecognized names use Dark, preserving the default for older installs.
# Auto starts with Dark until the client resolves the page color scheme.
sub get_terminal_options
{
my ($theme) = @_;
if ($theme ne 'light') {
# Keep xterm.js defaults for the dark palette and text contrast.
return { 'theme' => { 'background' => '#000000',
'foreground' => '#ffffff' },
'minimumContrastRatio' => 1 };
}
# Darker ANSI colors and contrast adjustment keep light terminals readable.
return {
'theme' => {
'background' => '#ffffff', 'foreground' => '#242424',
'cursor' => '#242424', 'cursorAccent' => '#ffffff',
'selectionBackground' => '#add6ff',
'selectionInactiveBackground' => '#d0d0d0',
'black' => '#242424', 'red' => '#a31515', 'green' => '#256c18',
'yellow' => '#795e00', 'blue' => '#0451a5', 'magenta' => '#7a287d',
'cyan' => '#007070', 'white' => '#bfbfbf',
'brightBlack' => '#666666', 'brightRed' => '#b52020',
'brightGreen' => '#287028', 'brightYellow' => '#806000',
'brightBlue' => '#005cc5', 'brightMagenta' => '#8b298f',
'brightCyan' => '#007575', 'brightWhite' => '#ffffff',
},
'minimumContrastRatio' => 4.5,
};
}
# get_terminal_theme_script(theme)
# Return the initial Auto palette and its change listener for #terminal.
# Clients pass their xterm.js instance to the container's xtermTheme function
# and call xtermTheme.dispose() when the connection closes.
sub get_terminal_theme_script
{
my ($theme) = @_;
return '' if ($theme ne 'auto');
my $themes_json = convert_to_json({
'dark' => get_terminal_options('dark'),
'light' => get_terminal_options('light'),
});
return <<EOF;
<script>
(function() {
const termcont = document.getElementById('terminal'),
root = document.documentElement,
themes = $themes_json,
schemeQuery = window.matchMedia('(prefers-color-scheme: dark)');
let terminal;
// Keep the loading surface and renderer on the same palette.
const update = function() {
const pageScheme = getComputedStyle(document.body).colorScheme,
background = root.getAttribute('data-bgs'),
mode = background ? (background === 'nightRider' ? 'dark' : 'light') :
(pageScheme === 'dark' || pageScheme === 'light' ? pageScheme :
(schemeQuery.matches ? 'dark' : 'light')),
colors = themes[mode];
if (terminal) {
terminal.options.theme = colors.theme;
terminal.options.minimumContrastRatio = colors.minimumContrastRatio;
}
termcont.setAttribute('data-terminal-theme', mode);
termcont.style.setProperty('--terminal-background', colors.theme.background);
termcont.style.setProperty('--terminal-foreground', colors.theme.foreground);
};
// xtermTheme(term) attaches the renderer after the loading colors are set.
termcont.xtermTheme = function(term) { terminal = term; update(); };
const observer = new MutationObserver(update),
observeOptions = { attributes: true,
attributeFilter: ['data-bgs', 'data-scheme', 'class', 'style'] };
observer.observe(root, observeOptions);
observer.observe(document.body, observeOptions);
schemeQuery.addEventListener('change', update);
// dispose() stops palette notifications after disconnect or a theme takeover.
termcont.xtermTheme.dispose = function() {
observer.disconnect();
schemeQuery.removeEventListener('change', update);
terminal = null;
};
update();
})();
</script>
EOF
}
# verify_websocket_key(client-key, session-id)
# Returns 1 if the client's Sec-WebSocket-Key matches the base64-encoded
# session ID, 0 otherwise. miniserv.pl rewrites the inbound handshake key

View File

@@ -7,4 +7,6 @@
*/
.xterm{cursor:text;position:relative;user-select:none;-ms-user-select:none;-webkit-user-select:none;}.xterm.focus,.xterm:focus{outline:none;}.xterm .xterm-helpers{position:absolute;top:0;z-index:5;}.xterm .xterm-helper-textarea{padding:0;border:0;margin:0;position:absolute;opacity:0;left:-9999em;top:0;width:0;height:0;z-index:-5;white-space:nowrap;overflow:hidden;resize:none;}.xterm .composition-view{background:#000;color:#FFF;display:none;position:absolute;white-space:nowrap;z-index:1;}.xterm .composition-view.active{display:block;}.xterm .xterm-viewport{background-color:#000;overflow-y:scroll;cursor:default;position:absolute;right:0;left:0;top:0;bottom:0;}.xterm .xterm-screen{position:relative;}.xterm .xterm-screen canvas{position:absolute;left:0;top:0;}.xterm-char-measure-element{display:inline-block;visibility:hidden;position:absolute;top:0;left:-9999em;line-height:normal;}.xterm.enable-mouse-events{cursor:default;}.xterm.xterm-cursor-pointer,.xterm .xterm-cursor-pointer{cursor:pointer;}.xterm.column-select.focus{cursor:crosshair;}.xterm .xterm-accessibility:not(.debug),.xterm .xterm-message{position:absolute;left:0;top:0;bottom:0;right:0;z-index:10;color:transparent;pointer-events:none;}.xterm .xterm-accessibility-tree:not(.debug) *::selection{color:transparent;}.xterm .xterm-accessibility-tree{font-family:monospace;user-select:text;white-space:pre;}.xterm .xterm-accessibility-tree>div{transform-origin:left;width:fit-content;}.xterm .live-region{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden;}.xterm-dim{opacity:1!important;}.xterm-underline-1{text-decoration:underline}.xterm-underline-2{text-decoration:double underline}.xterm-underline-3{text-decoration:wavy underline}.xterm-underline-4{text-decoration:dotted underline}.xterm-underline-5{text-decoration:dashed underline}.xterm-overline{text-decoration:overline;}.xterm-overline.xterm-underline-1{text-decoration:overline underline}.xterm-overline.xterm-underline-2{text-decoration:overline double underline}.xterm-overline.xterm-underline-3{text-decoration:overline wavy underline}.xterm-overline.xterm-underline-4{text-decoration:overline dotted underline}.xterm-overline.xterm-underline-5{text-decoration:overline dashed underline}.xterm-strikethrough{text-decoration:line-through;}.xterm-screen .xterm-decoration-container .xterm-decoration{z-index:6;position:absolute;}.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer{z-index:7;}.xterm-decoration-overview-ruler{z-index:8;position:absolute;top:0;right:0;pointer-events:none;}.xterm-decoration-top{z-index:2;position:relative;}.xterm .xterm-scrollable-element>.scrollbar{cursor:default;}.xterm .xterm-scrollable-element>.scrollbar>.scra{cursor:pointer;font-size:11px!important;}.xterm .xterm-scrollable-element>.visible{opacity:1;background:rgba(0,0,0,0);transition:opacity 100ms linear;z-index:11;}.xterm .xterm-scrollable-element>.invisible{opacity:0;pointer-events:none;}.xterm .xterm-scrollable-element>.invisible.fade{transition:opacity 800ms linear;}.xterm .xterm-scrollable-element>.shadow{position:absolute;display:none;}.xterm .xterm-scrollable-element>.shadow.top{display:block;top:0;left:3px;height:3px;width:100%;box-shadow:var(--vscode-scrollbar-shadow,#000) 0 6px 6px -6px inset;}.xterm .xterm-scrollable-element>.shadow.left{display:block;top:3px;left:0;height:100%;width:3px;box-shadow:var(--vscode-scrollbar-shadow,#000) 6px 0 6px -6px inset;}.xterm .xterm-scrollable-element>.shadow.top-left-corner{display:block;top:0;left:0;height:3px;width:3px;}.xterm .xterm-scrollable-element>.shadow.top.left{box-shadow:var(--vscode-scrollbar-shadow,#000) 6px 0 6px -6px inset;}
/* custom */
/* Match the viewport around the canvas to the module's selected background. */
.xterm .xterm-viewport { background-color: var(--terminal-background, #000); }
.xterm .xterm-decoration-overview-ruler { display: none; }