diff --git a/CHANGELOG.md b/CHANGELOG.md index 958394743..95217f678 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/fsdump/lang/es b/fsdump/lang/es index a34c91650..ca5957ad5 100644 --- a/fsdump/lang/es +++ b/fsdump/lang/es @@ -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 diff --git a/systemd/systemd-lib.pl b/systemd/systemd-lib.pl index 2ca27a756..6ae138144 100644 --- a/systemd/systemd-lib.pl +++ b/systemd/systemd-lib.pl @@ -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 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 [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'); + } } { diff --git a/t/ui-lib-widgets.t b/t/ui-lib-widgets.t index 53d399055..7022b0158 100644 --- a/t/ui-lib-widgets.t +++ b/t/ui-lib-widgets.t @@ -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'); diff --git a/t/web-lib-funcs-paths.t b/t/web-lib-funcs-paths.t index 18e1b5fdc..f45619565 100644 --- a/t/web-lib-funcs-paths.t +++ b/t/web-lib-funcs-paths.t @@ -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: diff --git a/ui-lib.pl b/ui-lib.pl index e9838a20d..5ffc3d9bb 100755 --- a/ui-lib.pl +++ b/ui-lib.pl @@ -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, 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 -and C for the minimum tile width. +and C for the minimum tile width, and C for the default corner +shape. An individual tile's C 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'} ? diff --git a/unauthenticated/css/ui-lib.css b/unauthenticated/css/ui-lib.css index 607748248..a1062936f 100644 --- a/unauthenticated/css/ui-lib.css +++ b/unauthenticated/css/ui-lib.css @@ -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 { diff --git a/web-lib-funcs.pl b/web-lib-funcs.pl index fb8e94f09..8105f0619 100755 --- a/web-lib-funcs.pl +++ b/web-lib-funcs.pl @@ -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(); diff --git a/xterm/config b/xterm/config index 2afe49516..b34c99ebd 100644 --- a/xterm/config +++ b/xterm/config @@ -1,5 +1,6 @@ xterm=xterm-256color fontsize=14 +theme=dark base_port=555 rcfile=0 locale=0 diff --git a/xterm/config.info b/xterm/config.info index 518833c33..ad91928af 100644 --- a/xterm/config.info +++ b/xterm/config.info @@ -1,6 +1,7 @@ xterm=Set TERM environmental variable to,4,xterm+256color-xterm-256color,xterm+16color-xterm-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-en_US.UTF-8,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 diff --git a/xterm/index.cgi b/xterm/index.cgi index 8921cc6d2..4b949e570 100755 --- a/xterm/index.cgi +++ b/xterm/index.cgi @@ -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, "{'css'}[0]\">\n". @@ -177,7 +195,10 @@ ui_print_header(undef, $text{'index_title'}, "", undef, 1, 1, 0, undef, ); # Print main container -print "
\n"; +print "
\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 = <'; }; socket.onclose = function() { + if (termcont.xtermTheme) termcont.xtermTheme.dispose(); termcont.innerHTML = 'Error: ' + err_conn_lost + ''; }; @@ -284,21 +310,21 @@ my $term_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 "\n"; ui_print_footer(); diff --git a/xterm/prefs.info b/xterm/prefs.info new file mode 100644 index 000000000..4664791b9 --- /dev/null +++ b/xterm/prefs.info @@ -0,0 +1 @@ +allowed=theme,fontsize,screen_reader,size diff --git a/xterm/t/run-tests.t b/xterm/t/run-tests.t index a6adeac12..99a539f9d 100644 --- a/xterm/t/run-tests.t +++ b/xterm/t/run-tests.t @@ -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) diff --git a/xterm/xterm-lib.pl b/xterm/xterm-lib.pl index 2861c1c7a..6aadd2155 100644 --- a/xterm/xterm-lib.pl +++ b/xterm/xterm-lib.pl @@ -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 < +(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(); +})(); + +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 diff --git a/xterm/xterm.css b/xterm/xterm.css index 80166484a..fece03ad8 100644 --- a/xterm/xterm.css +++ b/xterm/xterm.css @@ -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; }