shellm 1.1.0 — a faithful Rust + iced 0.13 modernization of Steffen Leich-Nienhaus's classic Tcl/Tk secpanel SSH connection manager, with a Midnight-Commander-style dual-pane SCP browser, modern terminal auto-detection, and a flat-design refresh. Written by Jeremy Anderson, released under GPL-2.0
This commit is contained in:
commit
1fae47d20e
|
|
@ -0,0 +1,40 @@
|
|||
[package]
|
||||
name = "shellm"
|
||||
version = "1.1.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.80"
|
||||
authors = ["Jeremy Anderson"]
|
||||
description = "Graphical user interface for managing SSH- and SCP-connections (Rust/iced port of secpanel)"
|
||||
license = "GPL-2.0-or-later"
|
||||
homepage = "https://dcos.net"
|
||||
repository = "https://dcos.net"
|
||||
|
||||
[[bin]]
|
||||
name = "shellm"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
iced = { version = "0.13", default-features = false, features = ["tokio", "image", "svg", "advanced", "multi-window", "tiny-skia"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
toml = "0.8"
|
||||
directories = "5"
|
||||
dirs = "5"
|
||||
open = "5"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
once_cell = "1"
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
anyhow = "1"
|
||||
thiserror = "1"
|
||||
indexmap = { version = "2", features = ["serde"] }
|
||||
regex = "1"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
strip = "debuginfo"
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
|
|
@ -0,0 +1,357 @@
|
|||
Copyright (C) 2026 Jeremy Anderson <info@dcos.net>
|
||||
|
||||
This program is free software; you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along
|
||||
with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
---
|
||||
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 2, June 1991
|
||||
|
||||
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
|
||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The licenses for most software are designed to take away your
|
||||
freedom to share and change it. By contrast, the GNU General Public
|
||||
License is intended to guarantee your freedom to share and change free
|
||||
software--to make sure the software is free for all its users. This
|
||||
General Public License applies to most of the Free Software
|
||||
Foundation's software and to any other program whose authors commit to
|
||||
using it. (Some other Free Software Foundation software is covered by
|
||||
the GNU Lesser General Public License instead.) You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
this service if you wish), that you receive source code or can get it
|
||||
if you want it, that you can change the software or use pieces of it
|
||||
in new free programs; and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to make restrictions that forbid
|
||||
anyone to deny you these rights or to ask you to surrender the rights.
|
||||
These restrictions translate to certain responsibilities for you if you
|
||||
distribute copies of the software, or if you modify it.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must give the recipients all the rights that
|
||||
you have. You must make sure that they, too, receive or can get the
|
||||
source code. And you must show them these terms so they know their
|
||||
rights.
|
||||
|
||||
We protect your rights with two steps: (1) copyright the software, and
|
||||
(2) offer you this license which gives you legal permission to copy,
|
||||
distribute and/or modify the software.
|
||||
|
||||
Also, for each author's protection and ours, we want to make certain
|
||||
that everyone understands that there is no warranty for this free
|
||||
software. If the software is modified by someone else and passed on, we
|
||||
want its recipients to know that what they have is not the original, so
|
||||
that any problems introduced by others will not reflect on the original
|
||||
authors' reputations.
|
||||
|
||||
Finally, any free program is threatened constantly by software
|
||||
patents. We wish to avoid the danger that redistributors of a free
|
||||
program will individually obtain patent licenses, in effect making the
|
||||
program proprietary. To prevent this, we have made it clear that any
|
||||
patent must be licensed for everyone's free use or not licensed at all.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. This License applies to any program or other work which contains
|
||||
a notice placed by the copyright holder saying it may be distributed
|
||||
under the terms of this General Public License. The "Program", below,
|
||||
refers to any such program or work, and a "work based on the Program"
|
||||
means either the Program or any derivative work under copyright law:
|
||||
that is to say, a work containing the Program or a portion of it,
|
||||
either verbatim or with modifications and/or translated into another
|
||||
language. (Hereinafter, translation is included without limitation in
|
||||
the term "modification".) Each licensee is addressed as "you".
|
||||
|
||||
Activities other than copying, distribution and modification are not
|
||||
covered by this License; they are outside its scope. The act of
|
||||
running the Program is not restricted, and the output from the Program
|
||||
is covered only if its contents constitute a work based on the
|
||||
Program (independent of having been made by running the Program).
|
||||
Whether that is true depends on what the Program does.
|
||||
|
||||
1. You may copy and distribute verbatim copies of the Program's
|
||||
source code as you receive it, in any medium, provided that you
|
||||
conspicuously and appropriately publish on each copy an appropriate
|
||||
copyright notice and disclaimer of warranty; keep intact all the
|
||||
notices that refer to this License and to the absence of any warranty;
|
||||
and give any other recipients of the Program a copy of this License
|
||||
along with the Program.
|
||||
|
||||
You may charge a fee for the physical act of transferring a copy, and
|
||||
you may at your option offer warranty protection in exchange for a fee.
|
||||
|
||||
2. You may modify your copy or copies of the Program or any portion
|
||||
of it, thus forming a work based on the Program, and copy and
|
||||
distribute such modifications or work under the terms of Section 1
|
||||
above, provided that you also meet all of these conditions:
|
||||
|
||||
a) You must cause the modified files to carry prominent notices
|
||||
stating that you changed the files and the date of any change.
|
||||
|
||||
b) You must cause any work that you distribute or publish, that in
|
||||
whole or in part contains or is derived from the Program or any
|
||||
part thereof, to be licensed as a whole at no charge to all third
|
||||
parties under the terms of this License.
|
||||
|
||||
c) If the modified program normally reads commands interactively
|
||||
when run, you must cause it, when started running for such
|
||||
interactive use in the most ordinary way, to print or display an
|
||||
announcement including an appropriate copyright notice and a
|
||||
notice that there is no warranty (or else, saying that you provide
|
||||
a warranty) and that users may redistribute the program under
|
||||
these conditions, and telling the user how to view a copy of this
|
||||
License. (Exception: if the Program itself is interactive but
|
||||
does not normally print such an announcement, your work based on
|
||||
the Program is not required to print an announcement.)
|
||||
|
||||
These requirements apply to the modified work as a whole. If
|
||||
identifiable sections of that work are not derived from the Program,
|
||||
and can be reasonably considered independent and separate works in
|
||||
themselves, then this License, and its terms, do not apply to those
|
||||
sections when you distribute them as separate works. But when you
|
||||
distribute the same sections as part of a whole which is a work based
|
||||
on the Program, the distribution of the whole must be on the terms of
|
||||
this License, whose permissions for other licensees extend to the
|
||||
entire whole, and thus to each and every part regardless of who wrote it.
|
||||
|
||||
Thus, it is not the intent of this section to claim rights or contest
|
||||
your rights to work written entirely by you; rather, the intent is to
|
||||
exercise the right to control the distribution of derivative or
|
||||
collective works based on the Program.
|
||||
|
||||
In addition, mere aggregation of another work not based on the Program
|
||||
with the Program (or with a work based on the Program) on a volume of
|
||||
a storage or distribution medium does not bring the other work under
|
||||
the scope of this License.
|
||||
|
||||
3. You may copy and distribute the Program (or a work based on it,
|
||||
under Section 2) in object code or executable form under the terms of
|
||||
Sections 1 and 2 above provided that you also do one of the following:
|
||||
|
||||
a) Accompany it with the complete corresponding machine-readable
|
||||
source code, which must be distributed under the terms of Sections
|
||||
1 and 2 on a medium customarily used for software interchange; or,
|
||||
|
||||
b) Accompany it with a written offer, valid for at least three
|
||||
years, to give any third party, for a charge no more than your
|
||||
cost of physically performing source distribution, a complete
|
||||
machine-readable copy of the corresponding source code, to be
|
||||
distributed under the terms of Sections 1 and 2 on a medium
|
||||
customarily used for software interchange; or,
|
||||
|
||||
c) Accompany it with the information you received as to the offer
|
||||
to distribute corresponding source code. (This alternative is
|
||||
allowed only for noncommercial distribution and only if you
|
||||
received the program in object code or executable form with such
|
||||
an offer, in accord with Subsection b above.)
|
||||
|
||||
The source code for a work means the preferred form of the work for
|
||||
making modifications to it. For an executable work, complete source
|
||||
code means all the source code for all modules it contains, plus any
|
||||
associated interface definition files, plus the scripts used to
|
||||
control compilation and installation of the executable. However, as a
|
||||
special exception, the source code distributed need not include
|
||||
anything that is normally distributed (in either source or binary
|
||||
form) with the major components (compiler, kernel, and so on) of the
|
||||
operating system on which the executable runs, unless that component
|
||||
itself accompanies the executable.
|
||||
|
||||
If distribution of executable or object code is made by offering
|
||||
access to copy from a designated place, then offering equivalent
|
||||
access to copy the source code from the same place counts as
|
||||
distribution of the source code, even though third parties are not
|
||||
compelled to copy the source along with the object code.
|
||||
|
||||
4. You may not copy, modify, sublicense, or distribute the Program
|
||||
except as expressly provided under this License. Any attempt
|
||||
otherwise to copy, modify, sublicense or distribute the Program is
|
||||
void, and will automatically terminate your rights under this License.
|
||||
However, parties who have received copies, or rights, from you under
|
||||
this License will not have their licenses terminated so long as such
|
||||
parties remain in full compliance.
|
||||
|
||||
5. You are not required to accept this License, since you have not
|
||||
signed it. However, nothing else grants you permission to modify or
|
||||
distribute the Program or its derivative works. These actions are
|
||||
prohibited by law if you do not accept this License. Therefore, by
|
||||
modifying or distributing the Program (or any work based on the
|
||||
Program), you indicate your acceptance of this License to do so, and
|
||||
all its terms and conditions for copying, distributing or modifying
|
||||
the Program or works based on it.
|
||||
|
||||
6. Each time you redistribute the Program (or any work based on the
|
||||
Program), the recipient automatically receives a license from the
|
||||
original licensor to copy, distribute or modify the Program subject to
|
||||
these terms and conditions. You may not impose any further
|
||||
restrictions on the recipients' exercise of the rights granted herein.
|
||||
You are not responsible for enforcing compliance by third parties to
|
||||
this License.
|
||||
|
||||
7. If, as a consequence of a court judgment or allegation of patent
|
||||
infringement or for any other reason (not limited to patent issues),
|
||||
conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot
|
||||
distribute so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you
|
||||
may not distribute the Program at all. For example, if a patent
|
||||
license would not permit royalty-free redistribution of the Program by
|
||||
all those who receive copies directly or indirectly through you, then
|
||||
the only way you could satisfy both it and this License would be to
|
||||
refrain entirely from distribution of the Program.
|
||||
|
||||
If any portion of this section is held invalid or unenforceable under
|
||||
any particular circumstance, the balance of the section is intended to
|
||||
apply and the section as a whole is intended to apply in other
|
||||
circumstances.
|
||||
|
||||
It is not the purpose of this section to induce you to infringe any
|
||||
patents or other property right claims or to contest validity of any
|
||||
such claims; this section has the sole purpose of protecting the
|
||||
integrity of the free software distribution system, which is
|
||||
implemented by public license practices. Many people have made
|
||||
generous contributions to the wide range of software distributed
|
||||
through that system in reliance on consistent application of that
|
||||
system; it is up to the author/donor to decide if he or she is willing
|
||||
to distribute software through any other system and a licensee cannot
|
||||
impose that choice.
|
||||
|
||||
This section is intended to make thoroughly clear what is believed to
|
||||
be a consequence of the rest of this License.
|
||||
|
||||
8. If the distribution and/or use of the Program is restricted in
|
||||
certain countries either by patents or by copyrighted interfaces, the
|
||||
original copyright holder who places the Program under this License
|
||||
may add an explicit geographical distribution limitation excluding
|
||||
those countries, so that distribution is permitted only in or among
|
||||
countries not thus excluded. In such case, this License incorporates
|
||||
the limitation as if written in the body of this License.
|
||||
|
||||
9. The Free Software Foundation may publish revised and/or new versions
|
||||
of the General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program
|
||||
specifies a version number of this License which applies to it and "any
|
||||
later version", you have the option of following the terms and conditions
|
||||
either of that version or of any later version published by the Free
|
||||
Software Foundation. If the Program does not specify a version number of
|
||||
this License, you may choose any version ever published by the Free Software
|
||||
Foundation.
|
||||
|
||||
10. If you wish to incorporate parts of the Program into other free
|
||||
programs whose distribution conditions are different, write to the author
|
||||
to ask for permission. For software which is copyrighted by the Free
|
||||
Software Foundation, write to the Free Software Foundation; we sometimes
|
||||
make exceptions for this. Our decision will be guided by the two goals
|
||||
of preserving the free status of all derivatives of our free software and
|
||||
of promoting the sharing and reuse of software generally.
|
||||
|
||||
NO WARRANTY
|
||||
|
||||
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
|
||||
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
|
||||
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
|
||||
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
|
||||
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
|
||||
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
|
||||
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
|
||||
REPAIR OR CORRECTION.
|
||||
|
||||
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
|
||||
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
|
||||
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
|
||||
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
|
||||
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
|
||||
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
convey the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software; you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along
|
||||
with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program is interactive, make it output a short notice like this
|
||||
when it starts in an interactive mode:
|
||||
|
||||
Gnomovision version 69, Copyright (C) year name of author
|
||||
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, the commands you use may
|
||||
be called something other than `show w' and `show c'; they could even be
|
||||
mouse-clicks or menu items--whatever suits your program.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or your
|
||||
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||
necessary. Here is a sample; alter the names:
|
||||
|
||||
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
|
||||
`Gnomovision' (which makes passes at compilers) written by James Hacker.
|
||||
|
||||
<signature of Ty Coon>, 1 April 1989
|
||||
Ty Coon, President of Vice
|
||||
|
||||
This General Public License does not permit incorporating your program into
|
||||
proprietary programs. If your program is a subroutine library, you may
|
||||
consider it more useful to permit linking proprietary applications with the
|
||||
library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License.
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
# shellm — A Rust/iced Port of secpanel, with a Modern SCP Browser Grafted On
|
||||
|
||||
*Project README for shellm 1.1.0 — a faithful Rust + iced 0.13 modernization of Steffen Leich-Nienhaus's classic Tcl/Tk secpanel SSH connection manager, with a Midnight-Commander-style dual-pane SCP browser, modern terminal auto-detection, and a flat-design refresh. Written by Jeremy Anderson, released under GPL-2.0-or-later, first shipped under this layout in 2026.*
|
||||
|
||||
There is a particular category of Unix desktop tool that was effectively frozen in amber around 2005: the SSH connection manager. The terminals evolved, the toolkits evolved, the underlying OpenSSH client gained ciphers, key-exchange algorithms, and config-file directives that the original Tcl scripts never heard of — but the connection managers themselves stayed on Tk 8.4, with grey groove borders and beveled buttons, reading their state from flat text files in `~/.secpanel/`. secpanel 0.6.1, by Steffen Leich-Nienhaus, was one of the better specimens: a clean Tcl/Tk application that wrapped `ssh`, `scp`, `ssh-agent`, `ssh-add`, `ssh-keygen`, and `sftp` into a four-tab GUI, with profile persistence, port-forward management, keypair generation, hostkey editing, and a key-distribution wizard. The design was sound. The implementation was sound. The look, by 2026 standards, was a museum piece.
|
||||
|
||||
shellm is a faithful Rust port of secpanel to the [iced](https://iced.rs) GUI toolkit, preserving 100% of the original's command-line semantics while modernizing the visuals and finishing several features that the original had stubbed out. It is not a rewrite in the loose sense — the SSH command builder in `src/ssh/command.rs` emits `-A`/`-a`, `-X`/`-x`, `-1`/`-2`, `-4`/`-6`, `-p`, `-c`, `-i`, `-F`, `-P`, `-v`, `-q`, `-f`, `-g`, `-C`, `-N`, `-o StrictHostKeyChecking=yes`, `-o CompressionLevel=N`, `-L ...`, `-R ...` byte-for-byte as the original Tcl `connect` proc did, including the asymmetric syntax OpenSSH uses for `CompressionLevel` versus the equals-sign syntax other SSH implementations expect. The on-disk layout under `~/.shellm/` mirrors the original secpanel's `~/.secpanel/` structure (same `mode#unixtime#text` history file format, same `time#cmd` trace log format), so users migrating from secpanel 0.6.x can copy their `~/.secpanel/` contents into `~/.shellm/` and it will Just Work. The Rust port adds a new SCP browser tab, modern terminal auto-detection, and a flat-design refresh, but the SSH semantics underneath are unchanged.
|
||||
|
||||
This README documents what shellm is, what it does, how to build and run it, and how the codebase is laid out. For a step-by-step getting-started guide, see `quickstart.md`. For a long-form technical walkthrough of the design decisions — why the data directory is `~/.shellm/`, why the SCP browser defaults to `ssh`+`scp` (with `sftp -b -` as an opt-in backend) instead of a Rust SFTP library, why there is no TLS anywhere — see `blog.md`.
|
||||
|
||||
---
|
||||
|
||||
## What's New in 1.1.0
|
||||
|
||||
### Dual-pane SCP browser (new tab)
|
||||
|
||||
A new **SCP Browser** tab sits between **Keys & Agent** and **Configs** in the main window. It is a Midnight-Commander-style dual-pane file manager with a WinSCP-inspired (but not identical) visual design: a top bar with a profile picker, Connect/Disconnect buttons, and a live connection status indicator (green dot plus `user@host:port`); two panes side-by-side for Local (left) and Remote (right); a center transfer column with large Upload and Download buttons that activate only when a transferable file or directory is selected; and a bottom status bar showing entry counts or the last operation result. Each pane has its own path bar with a Go button, a toolbar with up/refresh/new-folder/delete actions, and a three-column file list (Name, Size, Modified) where directories are bold blue, symlinks are cyan, and the selected row gets a light-blue tint with an accent border.
|
||||
|
||||
The navigation model is single-click throughout, matching Midnight Commander's idiom rather than the double-click idiom of file managers descended from Finder. Single-click on a row selects it for upload, download, or delete; single-click on a directory navigates into it; single-click on the `..` entry (always at the top of the list, except at the filesystem root) goes up one level. The up arrow in the toolbar and the path bar (type a path, hit Enter or click Go) provide alternative navigation. The list auto-refreshes after every transfer, delete, or mkdir operation. The implementation lives in `src/scp_browser/mod.rs` and uses the OpenSSH `sftp` and `scp` command-line tools rather than pulling in a Rust SFTP library — a deliberate choice that keeps the build light and matches shellm's existing "shell out to ssh tools" pattern. All operations run inside `tokio::task::spawn_blocking` so the UI stays responsive, and a busy indicator (an orange dot in the status bar) shows when an async operation is in flight.
|
||||
|
||||
The file operations map directly to the underlying SSH tools. Upload uses `scp -P port local user@host:remote`, auto-detecting directories and adding `-r` when needed. Download uses `scp -r -P port user@host:remote local`. Delete-local uses `std::fs::remove_file` or `remove_dir_all`. By default, remote list/mkdir/delete operations run via `ssh host -- ls -la` / `mkdir -p` / `rm -rf` — no `sftp` binary required (modern `scp` in OpenSSH 9.0+ uses the SFTP protocol under the hood for transfers anyway). Users with restricted sshd configs that disable shell access (`ForceCommand internal-sftp`) can opt in to the `sftp -b -` backend in the Configs tab. Mkdir-local uses `std::fs::create_dir_all`. List-local uses `std::fs::read_dir` with directory-first sorting; list-remote parses the `ls -la` output (from either `ssh` or `sftp`) to extract name, size, permissions, and modified date.
|
||||
|
||||
### Modern terminals and smart default detection
|
||||
|
||||
The terminal list in `src/data/terminal.rs` now includes the original 11 terminals from secpanel's `termdefs.txt` (aterm, Eterm, multi-aterm, Konsole, gnome-terminal, xterm, rxvt, mrxvt, xvt, XFCE Terminal, PuTTY's terminal) plus ten modern additions: `rs-mrxvt` (a Rust rewrite of mrxvt), `rxvt-unicode` (urxvt), `Alacritty`, `Kitty`, `WezTerm`, `Foot`, `GNOME Console` (kgx), `xfce4-terminal`, `Tabby` (Electron-based, plugin ecosystem), `Ghostty` (Mitchell Hashimoto's GPU-accelerated terminal), and `Warp` (AI-first, Rust-based). The `TerminalKind` enum has 22 variants total, each serializable to and from TOML via `serde` with `PascalCase` rename rules.
|
||||
|
||||
The default terminal is auto-detected at first run with a fallback chain that prefers modern, GPU-accelerated terminals over the legacy X11 defaults: `rs-mrxvt` first (the preferred default), then `alacritty`, `kitty`, `wezterm`, `ghostty`, `warp`, `tabby`, `foot`, `kgx`, `rxvt-unicode`, `xfce4-terminal`, then the original `mrxvt` and `xterm` as always-available fallbacks. The Configs tab now shows which terminals are actually available on `PATH` so the user knows what will work before they pick one. This matters because shellm execs the configured terminal to run its generated runner scripts — if the terminal isn't installed, the connect button silently does nothing, and that was a recurring support issue with the original secpanel.
|
||||
|
||||
### Modern visual design
|
||||
|
||||
The 1995 Tk look — grey groove borders, beveled buttons, Motif-style scrollbars — is gone. The new design language is flat surfaces with subtle 1px borders, 6px corner radius on cards/buttons/inputs, a modern blue accent (`#2563EB`) for primary actions, card-style containers (white background with a subtle border), an underline-style tab bar where the active tab gets a blue underline and dark text, a dot-style agent indicator (a small colored dot — green for our agent, red for no agent, yellow for external — with a label, instead of a full-color status bar background), modal panels with shadow that float on a dimmed backdrop, four button variants (Primary, Secondary, Danger, Ghost) for a modern hierarchy of emphasis, and section labels in small uppercase grey text for grouping. The style functions live in `src/widgets/styles.rs`, and the palette helpers (hex strings to `iced::Color`) live in `src/theme.rs` and `src/theme_style.rs`.
|
||||
|
||||
### All stubs finished
|
||||
|
||||
The original secpanel 0.6.1 had several features that were stubbed out in the Tcl source — present in the menu, but not actually wired up. shellm finishes all of them:
|
||||
|
||||
| Stub | Status |
|
||||
| ---------------------------------- | ------------------------------------------------------------ |
|
||||
| Key distribution wizard final step | Wired up — calls `build_keydist_script` and launches it via `launch_interactive` |
|
||||
| Ask-for-user modal at connect time | Real modal flow — defers connect until user submits a username |
|
||||
| Color & font settings save | Both dialogs write through to `configs.toml` |
|
||||
| Profile export | All 4 formats: shell script, SSH config entry, GNOME `.desktop`, KDE `.desktop` |
|
||||
| Remote account manager | Reads `~/.shosts` and `~/.ssh/authorized_keys` via scp, writes back via scp |
|
||||
| GPG data protection | Full round-trip encrypt/decrypt of `~/.shellm/` using GnuPG AES256 |
|
||||
| `AddIdentity` file picker | Properly routed through the `FilePicked` message |
|
||||
| `SetDefIdent` file picker | Saves to `configs.defident` and persists |
|
||||
| Right-click context menu | Connect / Edit / SCP / External SFTP / Export / Remote / Delete |
|
||||
|
||||
---
|
||||
|
||||
## What Works (Full Feature List)
|
||||
|
||||
The main window has four tabs — **Connections**, **Profiles**, **Keys & Agent**, **Configs** — plus the new **SCP Browser** tab, with a modern underline tab bar and the dot-style agent indicator described above. The Connections tab shows the list of profiles with two-line rows (title on the first line, `user@host` on the second), Connect / SCP / Multi (cssh) / Multi (xterm) / New / Edit / Delete buttons, and a right-click context menu. The Profiles tab is a full editor for every field in the original `default.profile` — title, host, user (with an Ask toggle that defers the username prompt until connect time), port, command (with a NoExec toggle), subsystem, identity, cfgfile, algo, compress (with a level slider), all nine boolean checkboxes, SSH/IP version pickers, a gateway toggle, and Local/Remote forwards dialogs.
|
||||
|
||||
The Local and Remote forwards dialogs support list, add, and delete operations, with quick-port buttons for HTTP (80), POP3 (110), IMAP (143), and SMTP (25), and `<TARGET-HOST>` / `<LOCAL-HOST>` placeholder substitution at connect time. The Keys & Agent tab provides Launch agent / Kill agent / Add identity / Agent info / Set/Remove default identity, plus keypair management, a hostkeys browser, and a key-distribution wizard. The keypair management dialog generates RSA1/RSA/DSA keys via `ssh-keygen -t ... -f ... -N ... -C ...`, lists existing `~/.ssh/*.pub` files, displays fingerprint and bubblebabble info, changes passphrases, and deletes keypairs. The hostkeys browser lists `~/.ssh/known_hosts` and deletes entries via `ssh-keygen -R <host>`.
|
||||
|
||||
The Configs tab lets the user pick the SSH, ssh-keygen, ssh-agent, ssh-add, ssh-askpass, scp, and browser binaries (each with a file-picker), pick the terminal emulator (all 22 supported, with availability indicators), pick the external SFTP client (FileZilla, Nautilus, Konqueror — used only by the "External SFTP" context-menu action), and pick the SCP browser backend (`scp + ssh` by default, or `sftp -b -` for restricted sshd configs). The SSH command builder is byte-for-byte faithful to the original `connect` proc — it emits `-A`/`-a`, `-X`/`-x`, `-1`/`-2`, `-4`/`-6`, `-p`, `-c`, `-i`, `-F`, `-P`, `-v`, `-q`, `-f`, `-g`, `-C`, `-N`, `-o StrictHostKeyChecking=yes`, `-o CompressionLevel=N`, `-L ...`, `-R ...` exactly as the Tcl version does, and the `CompressionLevel` syntax is correctly switched between OpenSSH's space-separated form and the equals-sign form used by other SSH implementations. The runner-script mechanism writes `~/.shellm/.runfiles/runproc.<ts>` and `runproc.<ts>-run` shell scripts (mirroring the original's `provrunfile` proc) and execs the configured terminal to run them. Profile and config persistence reads and writes `~/.shellm/profiles/*.profile` and `~/.shellm/config` as TOML, and the loader is backwards-compatible — it auto-imports the original Tcl-style `set configs(key) value` and `set title "..."` profile formats on load.
|
||||
|
||||
History and trace logs append to `~/.shellm/history` (in `mode#unixtime#text` format) and `~/.shellm/.runfiles/trace.log` (in `time#cmd` format) exactly as the original did, and a history viewer and trace viewer dialog are included. External agent detection works the same way as the original: if `SSH_AUTH_SOCK` and `SSH_AGENT_PID` are already set in the environment when shellm starts, it accepts the external agent and refuses to kill it. A satellite mini-window can be toggled from the tab bar to switch to a compact connection-menu view with the green/red agent indicator. The profile export dialog generates shell scripts, SSH config entries, GNOME `.desktop` files, or KDE `.desktop` files for the currently-loaded profile. The remote account manager opens a dialog showing the remote `~/.shosts` and `~/.ssh/authorized_keys` for the selected profile, lets the user edit them, and writes them back via scp. GPG data protection provides optional symmetric encryption of the entire `~/.shellm/` tree into `spdata.lck` using GnuPG AES256; it is toggled via the Protect menu entry and encrypts on exit if a passphrase is set. About, Message, Confirm, Colors, Fonts, History, Trace, Agent info, and Key info dialogs round out the overlay set.
|
||||
|
||||
---
|
||||
|
||||
## File Layout
|
||||
|
||||
The on-disk layout mirrors the original secpanel's `~/.secpanel/` structure (shellm uses `~/.shellm/`), so existing users of the Tcl/Tk original (or of prior Rust-port versions) can copy their `~/.secpanel/` directory into `~/.shellm/` and it will Just Work:
|
||||
|
||||
```
|
||||
~/.shellm/
|
||||
├── config # TOML (legacy Tcl-style auto-imported)
|
||||
├── profiles/
|
||||
│ └── <name>.profile # TOML (legacy Tcl-style auto-imported)
|
||||
├── history # plain text, format: mode#time#text
|
||||
├── spdata.lck # GPG-encrypted tarball (when protectdata is on)
|
||||
├── .runfiles/
|
||||
│ ├── trace.log # plain text, format: time#cmd
|
||||
│ ├── runproc.<ts> # inner runner script (#!/bin/sh + cmd)
|
||||
│ └── runproc.<ts>-run # outer wrapper (exec <term> -e <inner>)
|
||||
└── .init # version marker (written on first run)
|
||||
```
|
||||
|
||||
Both profiles and config are written as TOML, but the loader in `src/data/config.rs` and `src/data/profile.rs` accepts the original Tcl syntax (`set title "..."`, `array set lfs {...}`, etc.) so existing secpanel 0.6.x users can copy their `~/.secpanel/` directory to `~/.shellm/` without conversion. shellm uses `~/.shellm/` as its own data directory; the layout is identical to secpanel's `~/.secpanel/` so a `cp -a ~/.secpanel/. ~/.shellm/` is sufficient to migrate an existing install.
|
||||
|
||||
---
|
||||
|
||||
## Building from Source
|
||||
|
||||
shellm builds with Rust stable 1.80 or later against iced 0.13. The build has been verified on Debian 12, Ubuntu 24.04, Fedora 40, and Arch Linux as of mid-2026.
|
||||
|
||||
```bash
|
||||
# 1. Install Rust (stable, >= 1.80) via rustup:
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
source "$HOME/.cargo/env"
|
||||
|
||||
# 2. Install iced 0.13 system deps (Debian/Ubuntu):
|
||||
sudo apt install -y libx11-dev libxext-dev libxft-dev libxinerama-dev \
|
||||
libxcursor-dev libxrender-dev libxfixes-dev libxrandr-dev libdbus-1-dev \
|
||||
libudev-dev libwayland-dev libgl-dev libglib2.0-dev libcairo2-dev \
|
||||
libpango1.0-dev libgdk-pixbuf2.0-dev libatk1.0-dev libgtk-3-dev
|
||||
|
||||
# 3. Build:
|
||||
cd shellm
|
||||
cargo build --release
|
||||
# -> ./target/release/shellm
|
||||
```
|
||||
|
||||
If you do not have OpenGL dev headers and want a smaller, faster build, the default `Cargo.toml` already uses iced's `tiny-skia` software renderer — no GPU required. This is the configuration shipped in the 1.1.0 release tarball. The release profile uses `opt-level = 3`, thin LTO, a single codegen unit, and `strip = "debuginfo"` to produce a reasonably small binary.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
./shellm
|
||||
```
|
||||
|
||||
First run creates `~/.shellm/` with `chmod 700`, copies in default configs, and opens the main window. The version marker is written to `~/.shellm/.init` so subsequent runs can detect upgrades and run any necessary migrations. If `SSH_AUTH_SOCK` and `SSH_AGENT_PID` are already set in the environment, shellm accepts the external agent rather than launching its own — this mirrors the original secpanel behavior and avoids the situation where two agents compete for the same socket.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.rs Entry point; iced 0.13 application builder
|
||||
├── state.rs SecPanel state struct (configs, profiles, dialogs)
|
||||
├── message.rs Message enum + sub-enums (Tab, ConfigBin, ExportFormat, ...)
|
||||
├── update.rs Message dispatch — one match arm per Message
|
||||
├── update_actions.rs Composite actions: connect, sftp, multi, agent
|
||||
├── view.rs Top-level view: menu + tab_bar + page + status_bar
|
||||
├── theme.rs Color palette helpers (hex → iced::Color)
|
||||
├── theme_style.rs Re-exports widget style fns and palette colors
|
||||
├── profiles_export.rs Profile export: shell script, SSH config, .desktop
|
||||
├── data/
|
||||
│ ├── mod.rs
|
||||
│ ├── paths.rs ~/.shellm paths, which(), ensure_shellm_tree()
|
||||
│ ├── profile.rs Profile struct + Forward + Algo + legacy parser
|
||||
│ ├── config.rs Configs struct + SftpClient + legacy parser
|
||||
│ ├── terminal.rs TerminalKind (22 entries: 11 original + 11 modern)
|
||||
│ ├── history.rs HistoryEntry + append/read/clear + trace log
|
||||
│ └── protection.rs GPG-based encrypt/decrypt of ~/.shellm/
|
||||
├── ssh/
|
||||
│ ├── command.rs build_ssh_command(profile, configs, hostname)
|
||||
│ └── runner.rs LaunchSpec + launch() + terminal wrapper writer
|
||||
├── agent/mod.rs ssh-agent / ssh-add management
|
||||
├── keygen/mod.rs ssh-keygen operations
|
||||
├── scp_browser/mod.rs Dual-pane SCP browser (sftp/scp subprocess wrapper)
|
||||
├── widgets/
|
||||
│ ├── styles.rs Modern style fns (primary/secondary/ghost/danger + card)
|
||||
│ ├── tab_bar.rs Underline-style tab bar
|
||||
│ ├── connections_page.rs
|
||||
│ ├── profiles_page.rs
|
||||
│ ├── keys_agent_page.rs
|
||||
│ ├── configs_page.rs
|
||||
│ └── scp_browser_page.rs
|
||||
├── dialogs/mod.rs All modal dialogs (overlays via iced::widget::stack)
|
||||
└── util/mod.rs zenity/kdialog file picker
|
||||
```
|
||||
|
||||
The Elm-architecture split — `state`, `message`, `update`, `view` — is preserved as the four top-level modules, with `update_actions.rs` holding the composite actions (connect, sftp, multi-launch, agent launch) that would otherwise bloat `update.rs`. Internal Rust identifiers use the `Shellm` name throughout — the application state struct is `Shellm`, the path helpers are `shellm_dir()` and `ensure_shellm_tree()` in `data::paths`, and they return paths under `~/.shellm/`. The original secpanel identifiers are gone from the code; only heritage mentions remain in comments and docs.
|
||||
|
||||
---
|
||||
|
||||
## Credits
|
||||
|
||||
Original secpanel © Steffen Leich-Nienhaus, <https://themediahost.de/secpanel/> (mirrored at <https://sourceforge.net/projects/secpanel/>), GPL-2.0-or-later. This Rust/iced port, shellm, is © 2026 Jeremy Anderson, <https://dcos.net>, GPL-2.0-or-later. All credit for the original design and functionality belongs to Steffen Leich-Nienhaus; shellm is a faithful modernization of his work and would not exist without it.
|
||||
|
||||
*shellm 1.1.0 — released 2026 — Jeremy Anderson, dcos.net — GPL-2.0-or-later. Original secpanel © Steffen Leich-Nienhaus, GPL-2.0-or-later. See `LICENSE` for the full GPLv2 text and the copyright disclaimer that applies to both works.*
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
# shellm and the secpanel Heritage: A Faithful Port, a Modern SCP Browser, and the Case for Shelling Out
|
||||
|
||||
*A technical walkthrough of how shellm 1.1.0 — a Rust/iced port of Steffen Leich-Nienhaus's classic Tcl/Tk secpanel — preserves the original's command-line semantics byte-for-byte while modernizing the visuals, finishing the stubs the original left undone, and grafting on a Midnight-Commander-style dual-pane SCP browser. Grounded in the source code, not in marketing claims.*
|
||||
|
||||
There is a particular category of Unix desktop tool that was effectively frozen in amber around 2005. The terminals evolved — rxvt gave way to urxvt, urxvt to Alacritty, Alacritty to Kitty and WezTerm and Foot — and the underlying OpenSSH client gained ciphers, key-exchange algorithms, and config-file directives that the original Tcl scripts never heard of. The toolkits evolved: Tk 8.4 gave way to GTK 2, then GTK 3, then GTK 4, with Qt taking a parallel path through versions 3, 4, 5, and 6. But the SSH connection managers themselves stayed on Tk 8.4, with grey groove borders and beveled buttons, reading their state from flat text files in `~/.secpanel/` or `~/.ssh-gui/` or wherever the particular application chose to put them. The design was sound — these tools were thin wrappers around `ssh` and `scp` and `ssh-agent`, and they worked. The implementation was sound. The look, by 2026 standards, was a museum piece.
|
||||
|
||||
shellm is a faithful Rust port of one of those tools — Steffen Leich-Nienhaus's secpanel 0.6.1 — to the [iced](https://iced.rs) GUI toolkit. It is not a rewrite in the loose sense. The SSH command builder in `src/ssh/command.rs` emits `-A`/`-a`, `-X`/`-x`, `-1`/`-2`, `-4`/`-6`, `-p`, `-c`, `-i`, `-F`, `-P`, `-v`, `-q`, `-f`, `-g`, `-C`, `-N`, `-o StrictHostKeyChecking=yes`, `-o CompressionLevel=N`, `-L ...`, `-R ...` byte-for-byte as the original Tcl `connect` proc did, including the asymmetric syntax OpenSSH uses for `CompressionLevel` versus the equals-sign syntax other SSH implementations expect. The on-disk layout under `~/.shellm/` mirrors the original secpanel's `~/.secpanel/` structure — the `mode#unixtime#text` history file format, the `time#cmd` trace log format, the `runproc.<ts>` runner-script naming — so existing secpanel 0.6.x users can `cp -a ~/.secpanel/. ~/.shellm/` and it will Just Work. The Rust port adds a new SCP browser tab, modern terminal auto-detection, a flat-design refresh, and finishes several features the original had stubbed out, but the SSH semantics underneath are unchanged.
|
||||
|
||||
This post is a technical walkthrough of how those pieces fit together, grounded in the source code rather than marketing claims. It is organized around the three decisions that most heavily shape shellm's identity: the decision to mirror the original `~/.secpanel/` directory and file formats under a new `~/.shellm/` name, the decision to build the SCP browser on top of `sftp -b -` rather than a Rust SFTP library, and the decision to ship without any TLS or certificate management anywhere in the stack.
|
||||
|
||||
---
|
||||
|
||||
## The Heritage Directory: Why `~/.shellm/` Mirrors `~/.secpanel/`
|
||||
|
||||
The first design decision a port author has to make is whether to keep the original's on-disk layout or rename it to match the new project name. shellm uses its own directory `~/.shellm/` — but the layout, file formats, and naming conventions inside that directory are preserved verbatim from the original secpanel's `~/.secpanel/`. The data directory is `~/.shellm/`, the config file is `~/.shellm/config`, the profiles live in `~/.shellm/profiles/*.profile`, the history is `~/.shellm/history`, the runner scripts are in `~/.shellm/.runfiles/`, and the GPG-encrypted data vault (when enabled) is `~/.shellm/spdata.lck`. The internal Rust identifiers reflect the new name throughout: the state struct is `Shellm` (defined in `src/state.rs`), the path utilities are in `src/data/paths.rs` and the functions are named `shellm_dir()`, `ensure_shellm_tree()`, `profiles_dir()`, `runfiles_dir()`, and so on. The original secpanel identifiers appear only in heritage comments and docs.
|
||||
|
||||
This is a deliberate backwards-compatibility decision. The original secpanel used `~/.secpanel/` for two decades; shellm uses `~/.shellm/` as its own directory but mirrors the original's internal layout exactly so a `cp -a ~/.secpanel/. ~/.shellm/` brings every profile, config, history entry, and trace log forward without conversion. The legacy Tcl-style config syntax (`set configs(key) value`) and the Tcl-style profile syntax (`set title "..."`, `array set lfs {...}`) are still accepted by the loader in `src/data/config.rs` and `src/data/profile.rs`, so even hand-edited secpanel files migrate cleanly.
|
||||
|
||||
The loader's backwards-compatibility story is the more interesting half of this decision. The original secpanel wrote its config as Tcl-style `set configs(key) value` lines and its profiles as a mix of `set title "..."`, `array set lfs {...}`, and similar Tcl idioms. shellm writes both as TOML — the `toml` 0.8 crate, with `serde` derives on the `Configs` and `Profile` structs — but the loader accepts the original Tcl syntax and converts it in-memory. This means a user with a working secpanel 0.6.x installation can install shellm, run it once, and see all their profiles appear in the Connections tab without any conversion step. The Tcl-style config is parsed with a simple line-based scanner that looks for `set configs\(([^)]+)\) (.*)` patterns; the Tcl-style profile is parsed with a similar scanner that handles `set <field> <value>`, `array set <name> { ... }`, and the few other idioms secpanel used. Once loaded, the data is written back as TOML on the next save, so the migration is transparent and one-way.
|
||||
|
||||
The history file format is preserved for the same reason. The original secpanel wrote `mode#unixtime#text` lines to `~/.secpanel/history` — for example, `connect#1700000000#ssh -A -X user@host`. shellm writes the same format to `~/.shellm/history`, with the same field separators, so the history viewer dialog can read both old and new entries without knowing which version wrote them (and a migrated history file Just Works). The trace log format is similarly preserved: `time#cmd` lines in `~/.shellm/.runfiles/trace.log`, one per executed command. The runner-script naming convention (`runproc.<unix-timestamp>` for the inner script, `runproc.<unix-timestamp>-run` for the outer wrapper that execs the terminal) is preserved from the original's `provrunfile` proc. The version marker (`.init`) is preserved. None of this is technically necessary — shellm could have invented its own formats — but the cost of preservation is low and the benefit (drop-in compatibility with two decades of existing secpanel installations) is high.
|
||||
|
||||
## The SCP Browser: Why `ssh`+`scp` (and Optional `sftp -b -`) Instead of a Rust Library
|
||||
|
||||
The new SCP Browser tab is the largest single feature added in shellm 1.1.0, and the implementation choice that most defines its character is the decision to shell out to the OpenSSH `ssh`, `scp`, and (optionally) `sftp` command-line tools rather than pull in a Rust SFTP library. The implementation lives in `src/scp_browser/mod.rs` — about 600 lines of Rust that default to `ssh host -- ls -la` / `mkdir -p` / `rm -rf` for remote file operations and `scp` for file transfers, plus `std::fs` for local operations. The `sftp -b -` backend is opt-in via the `ScpBackend` config toggle for users whose sshd restricts shell access. All operations run inside `tokio::task::spawn_blocking` so the UI stays responsive.
|
||||
|
||||
The alternative — using a Rust SFTP library like `russh-sftp` or `ssh2` — would have meant re-implementing the SSH connection lifecycle that shellm already manages via `ssh` and `ssh-agent`. The Rust SSH libraries are fine pieces of software, but they have their own connection state, their own key management, their own agent protocol implementations, and their own cipher suites. Pulling one in would mean shellm has two SSH codepaths: the command-line `ssh` path used for terminal sessions and the library path used for the SCP browser. They would diverge in subtle ways — different hostkey verification behavior, different config-file parsing, different agent forwarding semantics — and the user would have to know which path was in use at any given moment. The shell-out approach keeps a single SSH codepath: the system `ssh` binary, configured exactly the same way for terminal sessions and for SFTP operations.
|
||||
|
||||
The `sftp -b -` interface is the key to making this work cleanly. The `-b -` flag tells `sftp` to read batch commands from standard input rather than from a file or interactive prompt, and `sftp` returns a non-zero exit code if any command in the batch fails. This makes it trivial to compose multi-command operations: to list a remote directory, shellm writes `cd <path>\nls -la\n` to `sftp`'s stdin, captures the stdout, and parses the `ls -la` output to extract name, size, permissions, and modified date. To make a directory, it writes `mkdir "<path>"\n`. To delete a file or directory, it writes `rm "<path>"\n` — `rm` works for both files and directories in OpenSSH 5.4 and later, which covers every OpenSSH installation still in use in 2026.
|
||||
|
||||
For file transfers, shellm uses `scp` directly rather than going through `sftp`. Upload uses `scp -P port local user@host:remote`, auto-detecting directories and adding `-r` when needed; download uses `scp -r -P port user@host:remote local`. The `scp` protocol is older and less featureful than SFTP, but it handles the bulk-transfer case more efficiently — `scp` opens a single data channel and streams bytes, while `sftp` does chunked reads and writes with per-chunk acknowledgments. For a 4 GiB log file transfer, the difference is measurable.
|
||||
|
||||
The UI side of the SCP browser is a dual-pane file manager with Midnight-Commander-style single-click navigation. The top bar has a profile picker, Connect/Disconnect buttons, and a live connection status indicator (green dot plus `user@host:port`). Two panes sit side-by-side — Local on the left, Remote on the right — and each pane has a path bar with a Go button, a toolbar with up/refresh/new-folder/delete actions, and a three-column file list (Name, Size, Modified). Directories are bold blue; symlinks are cyan; regular files use the default text color; the selected row gets a light-blue tint with an accent border. The center transfer column has large Upload (→) and Download (←) buttons that activate only when a transferable file or directory is selected, and a bottom status bar shows entry counts or the last operation result.
|
||||
|
||||
The navigation model is worth calling out because it differs from most modern file managers. Single-click on a row selects it for upload, download, or delete; single-click on a directory navigates into it; single-click on the `..` entry (always at the top of the list, except at the filesystem root) goes up one level. This is the Midnight Commander idiom, not the Finder/Explorer double-click idiom, and it takes a few minutes to get used to if you have not used MC before. The choice was deliberate: the SCP browser is meant for power users who want to move files quickly, and the MC idiom is faster once you internalize it — you select-and-navigate in one motion rather than two.
|
||||
|
||||
## The SSH Command Builder: Byte-for-Byte Faithful to the Original
|
||||
|
||||
The `build_ssh_command` function in `src/ssh/command.rs` is the load-bearing piece of the port. It takes a `Profile` and a `Configs` struct and produces an `SshCommand` — a vector of string tokens, rendered as a shell-quoted line for the runner script. The order of arguments, the conditional emission of each flag, the asymmetric `CompressionLevel` syntax between OpenSSH and other SSH implementations, the `<TARGET-HOST>` and `<LOCAL-HOST>` placeholder substitution in port forwards — all of this is preserved from the original Tcl `connect` proc.
|
||||
|
||||
The argument ordering matters because `ssh` parses its command line in a specific order, and some flags interact. The original secpanel emitted flags in this order: ssh binary, `-l user` (if user is set and Ask is off), `-A` or `-a` (agent forwarding, always emit one), `-X` or `-x` (X11 forwarding — note the inverted sense: `x11forward=true` means *no* X11, so `-x`), `-N` (no remote command, OpenSSH only, only if no command and no subsystem), `-o StrictHostKeyChecking=yes` (if strict hostkey is on), protocol version flag (`-1`, `-2`, or `-o Protocol=`), IP version flag (`-4` or `-6`), `-p port` (if not 22), `-c cipher` (if not default), `-i identity` (if set), `-F cfgfile` (if set), `-P` (no privileged source port), `-v`/`-q`/`-f`/`-g` (verbose/quiet/fork/gateway), `-C` and `CompressionLevel=N` (if compress is on), `-L` entries (local forwards), `-R` entries (remote forwards), host, and finally the command or subsystem (subsystem takes priority, with `-s` prefix). shellm emits the same flags in the same order.
|
||||
|
||||
The `CompressionLevel` asymmetry is the kind of detail that would be easy to get wrong in a rewrite. OpenSSH accepts `-o CompressionLevel 6` (space-separated), while other SSH implementations (the commercial SSH Tectia, the older SSH Communications Security versions) accept `-o CompressionLevel=6` (equals-sign). The original secpanel handled this with a Tcl `if` based on the configured SSH implementation; shellm handles it with a Rust `match` on the `SshImpl` enum (`OpenSsh` versus `Other`) in the same place, emitting the same syntax for each case. The test suite in `src/ssh/command.rs` verifies this — `basic_command`, `port_forward`, `shell_quote_basic`, and `custom_identity_and_cfgfile` cover the common cases.
|
||||
|
||||
Port forward placeholder substitution is similarly preserved. The original secpanel allowed `<TARGET-HOST>` and `<LOCAL-HOST>` placeholders in forward entries, substituted at connect time with the profile's host and the local machine's hostname respectively. This lets you write a forward like `8080:<TARGET-HOST>:80` that tunnels HTTP to whatever host you happen to be connecting to — useful for profile reuse across environments. shellm's `resolve_forwards` function in `src/ssh/command.rs` does the same substitution, emitting `-L 8080:<host>:80` or `-R ...` as appropriate. The forward struct itself (in `src/data/profile.rs`) preserves the placeholder string in the `host` field — the substitution happens at command-build time, not at profile-save time, so the profile on disk still says `<TARGET-HOST>` and works correctly if you connect to a different host later.
|
||||
|
||||
## The Runner Scripts: A Two-Layer Wrapper
|
||||
|
||||
The runner-script mechanism is one of the more old-fashioned pieces of shellm's design, and it is preserved from the original secpanel without modification. When you click Connect, shellm does not exec `ssh` directly — it writes a small shell script to `~/.shellm/.runfiles/runproc.<unix-timestamp>` containing the `ssh` command line, writes a second wrapper script to `runproc.<unix-timestamp>-run` that execs the configured terminal with the inner script as its argument, and execs the wrapper. The terminal opens, runs the inner script, and the SSH session appears.
|
||||
|
||||
This indirection serves two purposes. First, it leaves a forensic record on disk: every SSH command shellm has ever launched is recoverable from `~/.shellm/.runfiles/`, with timestamps, which is useful for debugging and auditing. The trace log at `~/.shellm/.runfiles/trace.log` records the same information in `time#cmd` format for quick grep-based analysis. Second, it allows the runner script to be modified before launch — for example, the key-distribution wizard generates a multi-target script that loops over selected profiles, and that script is launched via the same runner mechanism as a simple `ssh` connection. The `build_keydist_script` function in `src/ssh/command.rs` generates this multi-target script, complete with `mkdir $HOME/.ssh 2>/dev/null`, the `grep` check for existing authorized_keys entries, and the `if/elif/else` branching on the return code (`0` = already present, `255` = connection error, anything else = append the key with the correct permissions).
|
||||
|
||||
The wrapper layer exists because terminals do not agree on a common syntax for "run this command and stay open". xterm uses `-e command args...`; gnome-terminal uses `-- command args...`; Alacritty uses `-e command args...`; Kitty uses `command args...` directly without a flag. The `terminal.rs` module has a `command_for_terminal` function that takes a `TerminalKind` and a runner script path and produces the correct argument vector for that specific terminal. This is the kind of glue code that is not interesting but is unavoidable — every terminal launcher in the history of Unix has had to solve it, and there is no standard.
|
||||
|
||||
## Modernization: Terminals, Visuals, and Stubs
|
||||
|
||||
The modernization work in shellm 1.1.0 falls into three buckets: the terminal list, the visual design, and the stub finishing.
|
||||
|
||||
The terminal list in `src/data/terminal.rs` has 22 entries — the original 11 from secpanel's `termdefs.txt` (aterm, Eterm, multi-aterm, Konsole, gnome-terminal, xterm, rxvt, mrxvt, xvt, XFCE Terminal, PuTTY's terminal) plus ten modern additions (`rs-mrxvt`, `rxvt-unicode`, `Alacritty`, `Kitty`, `WezTerm`, `Foot`, `GNOME Console`, `xfce4-terminal`, `Tabby`, `Ghostty`, `Warp`). The default terminal is auto-detected at first run with a fallback chain that prefers modern, GPU-accelerated terminals: `rs-mrxvt` first (the preferred default), then `alacritty`, `kitty`, `wezterm`, `ghostty`, `warp`, `tabby`, `foot`, `kgx`, `rxvt-unicode`, `xfce4-terminal`, then the original `mrxvt` and `xterm` as always-available fallbacks. The Configs tab shows availability indicators (green dot for present, red dot for absent) next to each terminal in the dropdown, so the user can see at a glance what will actually work on their system. This was a recurring support issue with the original secpanel — users would pick a terminal from the list, click Connect, and nothing would happen because that terminal was not installed.
|
||||
|
||||
The visual design refresh is the most visible change. The 1995 Tk look — grey groove borders, beveled buttons, Motif-style scrollbars — is gone. The new design language is flat surfaces with subtle 1px borders, 6px corner radius on cards/buttons/inputs, a modern blue accent (`#2563EB`) for primary actions, card-style containers, an underline-style tab bar where the active tab gets a blue underline and dark text, a dot-style agent indicator (a small colored dot — green for our agent, red for no agent, yellow for external — with a label), modal panels with shadow that float on a dimmed backdrop, four button variants (Primary, Secondary, Danger, Ghost) for a modern hierarchy of emphasis, and section labels in small uppercase grey text for grouping. The style functions live in `src/widgets/styles.rs`, and the palette helpers (hex strings to `iced::Color`) live in `src/theme.rs` and `src/theme_style.rs`. None of this changes the underlying functionality — it is a pure visual refresh, and the SSH command lines produced are identical to what the original secpanel produced.
|
||||
|
||||
The stub finishing is the most substantive change. The original secpanel 0.6.1 had several features that were present in the menu but not actually wired up — the key distribution wizard's final step, the Ask-for-user modal at connect time, the color and font settings save, the profile export, the remote account manager, the GPG data protection, the AddIdentity and SetDefIdent file pickers, and the right-click context menu. shellm finishes all of them. The key distribution wizard's final step now calls `build_keydist_script` and launches the result via the standard `launch_interactive` runner. The Ask-for-user modal is a real modal flow that defers the connect until the user submits a username. The color and font dialogs write through to `configs.toml`. The profile export generates all four formats: shell script, SSH config entry, GNOME `.desktop`, KDE `.desktop`. The remote account manager reads `~/.shosts` and `~/.ssh/authorized_keys` via scp, lets the user edit them in a dialog, and writes them back via scp. The GPG data protection is a full round-trip encrypt/decrypt of the entire `~/.shellm/` tree using GnuPG AES256, triggered by the Protect menu entry.
|
||||
|
||||
## Security: No TLS, No Certificates, No Problem
|
||||
|
||||
One of the more striking architectural decisions in shellm is the **complete absence of TLS or certificate management anywhere in the stack**. There is no `rustls`, no `openssl`, no `crypto/tls`, no certificate authority, no key rotation, no certificate pinning. Transport security is delegated entirely to SSH itself — the SSH connection between shellm's host and the remote server is secured by SSH's own transport layer (which has its own key exchange, its own hostkey verification, its own cipher suite), and there is no other network communication for TLS to protect.
|
||||
|
||||
This is the same security model the original secpanel used, and it is the right model for an SSH connection manager. shellm does not run a server. It does not listen on any port. It does not make outbound network connections except by execing the system `ssh` binary, which then makes the connection using its own configuration and its own key material. The only secrets shellm holds are the paths to private key files (which it never reads — it just passes the paths to `ssh -i`), the passphrases typed into the GPG data protection dialog (which are passed to `gpg` via stdin and never written to disk), and the contents of `~/.shellm/config` and `~/.shellm/profiles/*.profile` (which may contain hostnames, usernames, and port forward specifications, but never private keys or passphrases unless the user has explicitly put them there). The GPG data protection, when enabled, encrypts the entire `~/.shellm/` tree into `spdata.lck` using GnuPG AES256 with a user-supplied passphrase — this is symmetric encryption, no public-key infrastructure, no certificate management.
|
||||
|
||||
The defense-in-depth model is the standard Unix one: filesystem permissions on `~/.shellm/` (mode 0700, set by `ensure_shellm_tree()`), filesystem permissions on `~/.ssh/` (the user is responsible for keeping this 0700, and OpenSSH will refuse to use private keys with looser permissions), and the SSH protocol itself for transport security. There is no application-layer TLS because there is no application-layer network communication to protect.
|
||||
|
||||
This is not a security model that works for every tool. If shellm were a multi-user SSH gateway with a web UI, it would need TLS. If it were a centralized configuration server pushing profiles to multiple workstations, it would need TLS. But it is a single-user desktop application that execs `ssh`, and SSH already has a transport security layer that has been battle-tested for three decades. Adding a second transport security layer on top of it would be worse than useless — it would add operational complexity (certificate renewal, cipher suite negotiation, SNI mismatches) without adding any actual security, because the SSH layer is already the trust boundary.
|
||||
|
||||
## Putting It All Together
|
||||
|
||||
The canonical shellm workflow ties all these pieces together:
|
||||
|
||||
1. **First run** creates `~/.shellm/` with mode 0700, writes the default config, and opens the main window.
|
||||
2. **Profile creation** writes a TOML file to `~/.shellm/profiles/<name>.profile`, with all the SSH-relevant fields the user filled in.
|
||||
3. **Connect** calls `build_ssh_command(profile, configs, local_host)` in `src/ssh/command.rs` to produce the `ssh` argument vector, writes a runner script to `~/.shellm/.runfiles/runproc.<ts>`, writes a wrapper to `runproc.<ts>-run` that execs the configured terminal, and execs the wrapper.
|
||||
4. **The terminal opens**, runs the runner script, and the SSH session appears. The user interacts with the remote host as usual.
|
||||
5. **SCP Browser** (optional) opens a second connection via `ssh`+`scp` for file operations, using the same profile's host/user/port/identity. List, mkdir, delete use `ssh host -- ls/mkdir/rm` (or `sftp -b -` if the user opted in via the Configs tab); upload/download use `scp`.
|
||||
6. **History and trace logs** record what happened: `mode#unixtime#text` lines in `~/.shellm/history`, `time#cmd` lines in `~/.shellm/.runfiles/trace.log`, and the runner scripts themselves left on disk in `~/.shellm/.runfiles/` for forensic inspection.
|
||||
7. **On exit** (optional, if GPG data protection is enabled) the entire `~/.shellm/` tree is tarred and encrypted into `spdata.lck` using GnuPG AES256 with the user-supplied passphrase. The plaintext files are deleted. On next launch, the user is prompted for the passphrase and the tree is decrypted back into place.
|
||||
|
||||
Each piece of this pipeline is independently simple — `ssh` is `ssh`, `sftp` is `sftp`, `scp` is `scp`, `ssh-keygen` is `ssh-keygen`, `ssh-agent` is `ssh-agent`, `gpg` is `gpg`. The value shellm adds is not in reimplementing any of them; it is in the GUI that ties them together, the profile persistence that remembers your configurations, the runner-script mechanism that leaves a forensic trail, and the backwards-compatible file formats that let two decades of secpanel users upgrade without losing their data. The Rust/iced port modernizes the visuals and finishes the stubs, but the underlying tool is the same one Steffen Leich-Nienhaus designed in the early 2000s — and that design has held up remarkably well.
|
||||
|
||||
---
|
||||
|
||||
*shellm 1.1.0 is developed by Jeremy Anderson at dcos.net and released under GPL-2.0-or-later. The original secpanel was developed by Steffen Leich-Nienhaus at themediahost.de and is also GPL-2.0-or-later. shellm is a faithful Rust/iced modernization of secpanel and would not exist without Steffen's original work; all credit for the design and the SSH command-line semantics belongs to him. This article was written in 2026 against the 1.1.0 release.*
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
# shellm Quickstart — From Tarball to a Live SSH Session in Under Five Minutes
|
||||
|
||||
*A getting-started guide for shellm 1.1.0, the Rust/iced port of secpanel. Covers install, first run, profile creation, the first SSH connect, keypair generation, and the new dual-pane SCP browser. Written for Linux desktops running X11 or Wayland, with notes for macOS users. Updated for the 1.1.0 release in 2026.*
|
||||
|
||||
The fastest path through shellm is straightforward: install the build dependencies, compile the binary, run it once to let it scaffold `~/.shellm/`, create a profile pointing at an SSH host you already have credentials for, click Connect, and verify that the SSH session opens in your terminal. From there the rest of the application — the SCP browser, keypair generation, port forwards, key distribution, GPG data protection — is discoverable in the same four-tab-plus-one layout that secpanel users have known for two decades. This document walks that path step by step, with the actual commands and the actual file paths the binary writes, so you can verify each stage before moving on to the next.
|
||||
|
||||
shellm is not a server. It does not run as a daemon, it does not listen on any port, and it does not modify your `~/.ssh/` directory except through the explicit `ssh-keygen` calls you initiate from the Keys & Agent tab. What it does is generate `ssh` command lines from the profile fields you fill in, wrap them in `~/.shellm/.runfiles/runproc.<ts>` shell scripts, and exec your configured terminal emulator to run them. If something goes wrong, the runner scripts and a trace log are left on disk for inspection — see the Troubleshooting section at the end of this document.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
You need a Linux desktop (or macOS, with caveats) with Rust 1.80 or later, the iced 0.13 system development headers, and at least one terminal emulator installed. The build has been verified on Debian 12, Ubuntu 24.04, Fedora 40, and Arch Linux as of mid-2026. On macOS, iced 0.13 builds but the resulting app runs under Rosetta on Apple Silicon and several of the modern terminals in shellm's terminal list (Foot, kgx, xfce4-terminal) are not available — use Alacritty, Kitty, or WezTerm instead.
|
||||
|
||||
You also need an SSH client (`ssh`, `scp`, `sftp`, `ssh-keygen`, `ssh-agent`, `ssh-add`) installed and on your `PATH`. shellm execs these by name; the default config points at the bare names `ssh`, `scp`, etc., which resolve through `PATH` lookup. If your SSH installation lives in a non-standard location (Homebrew on macOS, Nix, conda), open the Configs tab after first run and set the absolute paths explicitly. OpenSSH 8.0 or later is recommended; shellm's SCP browser uses `sftp -b -` batch mode for remote operations, which requires OpenSSH 5.4 or later for the `rm` command to work on directories.
|
||||
|
||||
A working SSH target is helpful for the first run — a VPS, a home server, a Raspberry Pi, anything you can already SSH to with a password or a key. If you don't have one handy, you can still walk through the first three steps of this guide (install, build, first run) and create a profile pointing at `localhost`, which is enough to verify the binary works.
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Install the Build Dependencies
|
||||
|
||||
On Debian or Ubuntu, install the iced 0.13 system development headers:
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y build-essential pkg-config \
|
||||
libx11-dev libxext-dev libxft-dev libxinerama-dev \
|
||||
libxcursor-dev libxrender-dev libxfixes-dev libxrandr-dev \
|
||||
libdbus-1-dev libudev-dev libwayland-dev libgl-dev \
|
||||
libglib2.0-dev libcairo2-dev libpango1.0-dev \
|
||||
libgdk-pixbuf2.0-dev libatk1.0-dev libgtk-3-dev
|
||||
```
|
||||
|
||||
On Fedora, the package names are slightly different — `mesa-libGL-devel` instead of `libgl-dev`, `gdk-pixbuf2-devel` instead of `libgdk-pixbuf2.0-dev`, and so on. The full Fedora equivalent is:
|
||||
|
||||
```bash
|
||||
sudo dnf install -y gcc pkg-config \
|
||||
libX11-devel libXext-devel libXft-devel libXinerama-devel \
|
||||
libXcursor-devel libXrender-devel libXfixes-devel libXrandr-devel \
|
||||
dbus-devel libudev-devel wayland-devel mesa-libGL-devel \
|
||||
glib2-devel cairo-devel pango-devel gdk-pixbuf2-devel \
|
||||
atk-devel gtk3-devel
|
||||
```
|
||||
|
||||
On Arch Linux, the `gtk3` and `cairo` packages pull in everything else as dependencies:
|
||||
|
||||
```bash
|
||||
sudo pacman -S --needed base-devel pkgconf gtk3 cairo pango
|
||||
```
|
||||
|
||||
On macOS, the Homebrew toolchain is sufficient — install Xcode Command Line Tools and Homebrew, then `brew install cairo pango`. The build will use the `tiny-skia` software renderer by default, so no GPU framework is required.
|
||||
|
||||
## Step 2: Install Rust
|
||||
|
||||
Install Rust stable 1.80 or later via rustup. The version shipped in Debian 12's `apt` repository (1.63 as of mid-2026) is too old for iced 0.13; you need rustup.
|
||||
|
||||
```bash
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
source "$HOME/.cargo/env"
|
||||
rustc --version
|
||||
# -> rustc 1.8x.0 (something >= 1.80)
|
||||
```
|
||||
|
||||
If you already have rustup installed, run `rustup update stable` to make sure you are on a recent toolchain.
|
||||
|
||||
## Step 3: Build shellm
|
||||
|
||||
Extract the 1.1.0 release tarball and build the release binary. The build takes about 90 seconds on a modern laptop with the `tiny-skia` software renderer; a full GPU build with `wgpu` enabled (not the default) takes about three minutes.
|
||||
|
||||
```bash
|
||||
tar -xjf shellm-1.tar.bz2
|
||||
cd shellm-1.1
|
||||
cargo build --release
|
||||
# -> ./target/release/shellm
|
||||
```
|
||||
|
||||
The first build downloads and compiles all dependencies — iced, tokio, serde, toml, chrono, regex, and so on. Subsequent builds are incremental and take a few seconds. If the build fails on a missing system library, double-check Step 1 — iced 0.13's compiler error messages are not always explicit about which system package is missing, but the package list above is the complete set on Debian/Ubuntu.
|
||||
|
||||
You can install the binary system-wide if you prefer, but it's not required:
|
||||
|
||||
```bash
|
||||
sudo cp ./target/release/shellm /usr/local/bin/
|
||||
# Or, if you use cargo's install path:
|
||||
cargo install --path .
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4: First Run
|
||||
|
||||
Run the binary:
|
||||
|
||||
```bash
|
||||
./shellm
|
||||
```
|
||||
|
||||
On first run, shellm calls `ensure_shellm_tree()` from `src/data/paths.rs`, which creates `~/.shellm/` with mode `0700`, creates `~/.shellm/profiles/` and `~/.shellm/.runfiles/`, writes a default config to `~/.shellm/config`, writes the version marker to `~/.shellm/.init`, and opens the main window. The window defaults to 960×640 pixels with a minimum size of 800×540 — wide enough to fit the Profiles tab's three-column layout (profiles list + SSH options + Connection fields) without truncating the rightmost input fields, and tall enough to show all checkboxes and forwarding controls without scrolling.
|
||||
|
||||
You should see the **Connections** tab as the active tab. The agent indicator at the top right shows a red dot with the label "No Agent" — this is correct, no agent has been launched yet. The status bar at the bottom shows the version string ("shellm 1.1.0") and is otherwise empty. The menu bar at the top has the standard entries (File, Edit, Profile, Keys, Options, Help) and the tab bar has five entries: Connections, Profiles, Keys & Agent, SCP Browser, Configs.
|
||||
|
||||
If the window does not open, check `~/.shellm/.runfiles/trace.log` for an error message. The most common first-run failure is a missing terminal emulator — shellm tries to auto-detect a terminal from the fallback chain described in `src/data/terminal.rs` (rs-mrxvt → alacritty → kitty → wezterm → ghostty → warp → tabby → foot → kgx → rxvt-unicode → xfce4-terminal → mrxvt → xterm), and if none of those are on `PATH`, the Configs tab will warn you but the main window will still open.
|
||||
|
||||
## Step 5: Create a Profile
|
||||
|
||||
Switch to the **Profiles** tab and click **New**. Fill in the fields:
|
||||
|
||||
| Field | Value | Notes |
|
||||
|-------|-------|-------|
|
||||
| Title | `my-server` | Display name; used as the filename for `~/.shellm/profiles/my-server.profile` |
|
||||
| Host | `your.server.example.com` | Or an IP address |
|
||||
| User | `your-username` | Check "Ask" if you want to be prompted at connect time |
|
||||
| Port | `22` | The default; change if your server runs SSH on a non-standard port |
|
||||
| Identity | (leave blank for password auth) | Click the file picker to select a private key from `~/.ssh/` |
|
||||
| Compression | `On` | Useful on slow links; harmless on fast ones |
|
||||
| Compression Level | `6` | The OpenSSH default |
|
||||
| All other fields | (defaults) | The defaults match the original secpanel's `default.profile` |
|
||||
|
||||
Click **Save**. The profile is written to `~/.shellm/profiles/my-server.profile` as TOML. Switch back to the Connections tab — you should see your new profile in the list with the title and `user@host` on two lines.
|
||||
|
||||
## Step 6: Connect
|
||||
|
||||
Click the profile row to select it, then click **Connect**. shellm builds the SSH command line via `build_ssh_command()` in `src/ssh/command.rs`, writes a runner script to `~/.shellm/.runfiles/runproc.<unix-timestamp>`, writes a wrapper script to `runproc.<unix-timestamp>-run` that execs your configured terminal with the runner script as its argument, and execs the wrapper. A new terminal window should open with an SSH session to your server.
|
||||
|
||||
If the connection fails, the terminal window will close immediately and the error message will be in the terminal's scrollback (if your terminal has one) or in `~/.shellm/.runfiles/trace.log`. The most common failure on first run is a hostkey mismatch — OpenSSH refuses to connect to a host whose hostkey has changed since the last connection, and the error message points you to `ssh-keygen -R <host>` to clear the old entry. shellm's **Keys & Agent** tab has a hostkeys browser that does this for you: open it, find the offending entry, click Delete.
|
||||
|
||||
If you checked **Ask** on the User field in the profile, a modal dialog appears when you click Connect asking for the username. Type it and press Enter; the connect proceeds. If you have an `Identity` file set and it has a passphrase, your SSH agent (if running) handles the prompt — or, if no agent is running, `ssh-askpass` is invoked. Make sure one of `ssh-askpass`, `x11-ssh-askpass`, or `ksshaskpass` is installed and pointed at by the `SSH_ASKPASS` environment variable; otherwise the passphrase prompt may not appear and the connection will hang silently.
|
||||
|
||||
## Step 7: Use the SCP Browser
|
||||
|
||||
Switch to the **SCP Browser** tab. Click the profile picker at the top, select your connected profile, and click **Connect**. The status indicator turns green and shows `user@host:port`; the right pane populates with the remote home directory listing. The left pane shows your local home directory by default.
|
||||
|
||||
Click on a directory in either pane to navigate into it (Midnight-Commander-style single-click navigation). Click on the `..` entry at the top to go up one level. The toolbar buttons above each pane provide up/refresh/new-folder/delete operations. The path bar above the file list accepts a typed path — type `/var/log` and hit Enter (or click Go) to jump there directly.
|
||||
|
||||
To transfer a file, click on it in the source pane to select it. The Upload button (the large → arrow in the center column) activates if a file is selected on the left pane and you are connected; the Download button (the ← arrow) activates if a file is selected on the right pane and you are connected. Click the arrow to transfer. The destination pane auto-refreshes after the transfer completes, and the status bar shows the result ("Uploaded myfile.txt (12.3 KB)" or similar). Directories are auto-detected and transferred recursively — `scp -r` is used automatically.
|
||||
|
||||
All SCP browser operations run in `tokio::task::spawn_blocking`, so the UI stays responsive during long transfers. The orange busy dot in the status bar shows when an operation is in flight; you can switch to other tabs while a transfer is running, but starting a second transfer before the first completes will queue it (the SCP browser is single-threaded per connection by design — `sftp` does not support concurrent operations on a single channel).
|
||||
|
||||
## Step 8: Generate a Keypair
|
||||
|
||||
Switch to the **Keys & Agent** tab. Click **Generate Keypair**. In the dialog that appears:
|
||||
|
||||
| Field | Value | Notes |
|
||||
|-------|-------|-------|
|
||||
| Type | `RSA` | Or DSA, or RSA1 (the original SSH v1 protocol) |
|
||||
| Bits | `4096` | 2048 is the minimum recommended; 4096 is the modern default |
|
||||
| File | `~/.ssh/id_rsa_new` | The `.pub` extension is added automatically to the public key |
|
||||
| Passphrase | (your choice) | Empty for no passphrase (not recommended) |
|
||||
| Comment | `your-email@example.com` | Appears in the public key file and in `authorized_keys` entries |
|
||||
|
||||
Click **Generate**. shellm invokes `ssh-keygen -t rsa -b 4096 -f ~/.ssh/id_rsa_new -N "passphrase" -C "your-email@example.com"` and reports the result. The new keypair appears in the keypair list, and you can click **Info** to view the fingerprint and bubblebabble representation. To use the new key with a profile, switch to the Profiles tab, edit the profile, and set the Identity field to the new private key path.
|
||||
|
||||
## Step 9: Distribute Your Public Key
|
||||
|
||||
Still on the **Keys & Agent** tab, click **Distribute Key**. A wizard opens:
|
||||
|
||||
1. **Pick the public key** to distribute (defaults to your default identity, or the most recently generated key).
|
||||
2. **Pick the targets** — a multi-select list of your existing profiles. You can select multiple targets; the wizard generates one key-distribution script that runs against all of them sequentially.
|
||||
3. **Review** the generated script. The script uses `ssh` to check whether the public key is already in `~/.ssh/authorized_keys` on each target, and appends it only if it's not — `mkdir $HOME/.ssh 2>/dev/null; grep "$(cat <pubkey>)" $HOME/.ssh/authorized_keys > /dev/null 2>&1` followed by an `if` branch that either skips (already present), reports a connection error (exit code 255), or appends the key with the correct permissions (`cat >> $HOME/.ssh/authorized_keys; chmod 600 $HOME/.ssh/authorized_keys; chmod 700 $HOME/.ssh`).
|
||||
4. **Run** — the script is written to `~/.shellm/.runfiles/runproc.<ts>` and launched via the configured terminal. You will be prompted for the password on each target host (unless you already have key-based auth set up, in which case the script just verifies presence).
|
||||
|
||||
This finishes a stub that was present in the original secpanel 0.6.1 — the wizard UI existed, but the final "Run" step was never wired up. shellm wires it up via `build_keydist_script()` in `src/ssh/command.rs` and the standard `launch_interactive` runner.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**The terminal does not open when I click Connect.** The most common cause is that the configured terminal emulator is not installed. Open the Configs tab and check the availability indicators next to each terminal in the dropdown — the ones with a red dot are not on your `PATH`. Pick one with a green dot, click Save, and try Connect again. If you are on a headless server with no terminal emulator at all, shellm cannot open a connection — it relies on a terminal to display the SSH session. Install xterm as a minimum.
|
||||
|
||||
**The connection fails immediately with "Host key verification failed".** The host's hostkey has changed since your last connection. Open the Keys & Agent tab, find the hostkey browser, locate the offending entry, and click Delete — this runs `ssh-keygen -R <host>` for you. Alternatively, set the profile's "Strict hostkey" checkbox to off (not recommended for production use).
|
||||
|
||||
**The SCP browser shows "ssh: command not found" or "sftp: command not found" when I click Connect.** By default the SCP browser uses `ssh` for remote list/mkdir/delete operations and `scp` for file transfers — both must be on your `PATH`. If you have switched to the `sftp -b -` backend in the Configs tab (for restricted sshd configs that disable shell access), the `sftp` binary must also be on `PATH`. Open Configs and check the SSH binary and SCP binary fields; switch the SCP browser backend back to `scp + ssh` (the default) if you do not need sftp.
|
||||
|
||||
**My settings are not saved between runs.** Check that `~/.shellm/config` is writable. If you ran shellm as root by accident (or with `sudo`), the `~/.shellm/` directory may be owned by root and your user cannot write to it. Fix with `sudo chown -R $USER:$USER ~/.shellm/`.
|
||||
|
||||
**The GPG data protection prompts for a passphrase on every launch.** This is by design. shellm does not cache the GPG passphrase — if `protectdata` is on, the entire `~/.shellm/` tree is encrypted into `spdata.lck` on exit and decrypted on launch. If this is too intrusive, turn off Protect in the Options menu and your data will be stored in plaintext TOML (the same as the original secpanel).
|
||||
|
||||
**I'm on macOS and the build fails with "linking with cc failed".** Install Xcode Command Line Tools (`xcode-select --install`) and Homebrew's `cairo` and `pango` packages. The iced 0.13 macOS build is finicky about library paths; `cargo build --release` should pick them up automatically via `pkg-config`, but if not, set `PKG_CONFIG_PATH=/opt/homebrew/lib/pkgconfig` (Apple Silicon) or `/usr/local/lib/pkgconfig` (Intel) before the build.
|
||||
|
||||
---
|
||||
|
||||
## Where to Go Next
|
||||
|
||||
- **`README.md`** — full feature list, architecture overview, and credits.
|
||||
- **`blog.md`** — long-form technical walkthrough of the design decisions, including why the data directory is `~/.shellm/`, why the SCP browser defaults to `ssh`+`scp` (with `sftp -b -` as an opt-in backend) instead of a Rust SFTP library, and why there is no TLS anywhere in the stack.
|
||||
- **`LICENSE`** — the full GPLv2 text and the copyright disclaimer that applies to shellm and to the original secpanel.
|
||||
|
||||
*shellm 1.1.0 quickstart — Jeremy Anderson, dcos.net, 2026 — GPL-2.0-or-later. Original secpanel © Steffen Leich-Nienhaus, GPL-2.0-or-later. This document walks through the install, build, first-run, and basic usage flow as shipped in the 1.1.0 release.*
|
||||
|
|
@ -0,0 +1,210 @@
|
|||
//! SSH agent management — mirrors the `manage_agent` proc.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::data::Configs;
|
||||
|
||||
/// State of the SSH agent as shellm understands it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AgentState {
|
||||
/// No agent is running (or none that shellm knows about).
|
||||
None,
|
||||
/// shellm started this agent.
|
||||
Ours,
|
||||
/// An agent was already running when shellm started (env vars set).
|
||||
External,
|
||||
}
|
||||
|
||||
impl AgentState {
|
||||
pub fn is_active(&self) -> bool {
|
||||
!matches!(self, AgentState::None)
|
||||
}
|
||||
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
AgentState::None => "No Agent",
|
||||
AgentState::Ours => "Agent active",
|
||||
AgentState::External => "Agent active (external)",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Information about a running agent.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AgentInfo {
|
||||
pub auth_sock: Option<String>,
|
||||
pub agent_pid: Option<String>,
|
||||
pub identities: Vec<String>,
|
||||
}
|
||||
|
||||
/// Detect an external agent from environment variables.
|
||||
pub fn detect_external() -> Option<AgentInfo> {
|
||||
let sock = std::env::var("SSH_AUTH_SOCK").ok();
|
||||
let pid = std::env::var("SSH_AGENT_PID").ok();
|
||||
if sock.is_some() && pid.is_some() {
|
||||
let mut info = AgentInfo {
|
||||
auth_sock: sock,
|
||||
agent_pid: pid,
|
||||
identities: Vec::new(),
|
||||
};
|
||||
// `ssh-add -L` lists full public key blobs — the original took the
|
||||
// 3rd whitespace field (the comment). We do the same.
|
||||
if let Ok(out) = std::process::Command::new("ssh-add").arg("-L").output() {
|
||||
if out.status.success() {
|
||||
let s = String::from_utf8_lossy(&out.stdout);
|
||||
for line in s.lines() {
|
||||
let mut it = line.split_whitespace();
|
||||
let _ = it.next();
|
||||
let _ = it.next();
|
||||
if let Some(comment) = it.next() {
|
||||
info.identities.push(comment.to_string());
|
||||
} else {
|
||||
// No comment — use the whole blob.
|
||||
info.identities.push(line.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(info)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Launch `ssh-agent` and apply its env vars to our process. Returns the
|
||||
/// parsed environment on success.
|
||||
pub fn launch_agent(configs: &Configs) -> std::io::Result<AgentInfo> {
|
||||
// If there's already an agent, don't clobber it.
|
||||
if std::env::var("SSH_AUTH_SOCK").is_ok() && std::env::var("SSH_AGENT_PID").is_ok() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::AlreadyExists,
|
||||
"An agent appears to be running already",
|
||||
));
|
||||
}
|
||||
|
||||
// Verify the agent binary exists.
|
||||
if !crate::data::paths::which(&configs.agentbin).map(|p| p.exists()).unwrap_or(false) {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
format!("ssh-agent binary not found: {}", configs.agentbin),
|
||||
));
|
||||
}
|
||||
|
||||
// ssh-agent -c prints csh-style `setenv SSH_AUTH_SOCK ...` lines.
|
||||
// We accept both csh (-c) and sh (default) styles for portability.
|
||||
let out = Command::new(&configs.agentbin).arg("-s").output()?;
|
||||
if !out.status.success() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
format!("ssh-agent failed: {}", String::from_utf8_lossy(&out.stderr)),
|
||||
));
|
||||
}
|
||||
let s = String::from_utf8_lossy(&out.stdout);
|
||||
|
||||
let mut env: HashMap<String, String> = HashMap::new();
|
||||
for line in s.lines() {
|
||||
// sh-style: SSH_AUTH_SOCK=/tmp/...; export SSH_AUTH_SOCK;
|
||||
// csh-style: setenv SSH_AUTH_SOCK /tmp/...;
|
||||
if let Some(eq) = line.find('=') {
|
||||
let key = line[..eq].trim().trim_start_matches("setenv ").trim().to_string();
|
||||
let rest = &line[eq + 1..];
|
||||
let val = rest.trim_end_matches(';').trim().trim_matches(';').trim().to_string();
|
||||
if key == "SSH_AUTH_SOCK" || key == "SSH_AGENT_PID" {
|
||||
env.insert(key, val);
|
||||
}
|
||||
} else if let Some(rest) = line.strip_prefix("setenv ") {
|
||||
let mut it = rest.split_whitespace();
|
||||
if let (Some(k), Some(v)) = (it.next(), it.next()) {
|
||||
if k == "SSH_AUTH_SOCK" || k == "SSH_AGENT_PID" {
|
||||
env.insert(k.to_string(), v.trim_end_matches(';').to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let sock = env.get("SSH_AUTH_SOCK").cloned();
|
||||
let pid = env.get("SSH_AGENT_PID").cloned();
|
||||
if let (Some(s), Some(p)) = (sock.as_ref(), pid.as_ref()) {
|
||||
std::env::set_var("SSH_AUTH_SOCK", s);
|
||||
std::env::set_var("SSH_AGENT_PID", p);
|
||||
} else {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"ssh-agent output did not contain expected env vars",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(AgentInfo {
|
||||
auth_sock: sock,
|
||||
agent_pid: pid,
|
||||
identities: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Kill the agent (only if we started it).
|
||||
pub fn kill_agent(_configs: &Configs) -> std::io::Result<()> {
|
||||
let pid = std::env::var("SSH_AGENT_PID");
|
||||
if let Ok(pid) = pid {
|
||||
let _ = Command::new("ssh-agent").arg("-k").output();
|
||||
std::env::remove_var("SSH_AUTH_SOCK");
|
||||
std::env::remove_var("SSH_AGENT_PID");
|
||||
log::info!("Killed ssh-agent pid={pid}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run `ssh-add -l` and return its output (one identity per line).
|
||||
pub fn list_identities() -> String {
|
||||
match Command::new("ssh-add").arg("-l").output() {
|
||||
Ok(o) => String::from_utf8_lossy(&o.stdout).into_owned(),
|
||||
Err(_) => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add an identity file to the agent. Sets `SSH_ASKPASS` if `configs.sshver`
|
||||
/// is OpenSSH (mirrors the original).
|
||||
pub fn add_identity(configs: &Configs, key_path: &Path) -> std::io::Result<()> {
|
||||
let mut cmd = Command::new(&configs.addbin);
|
||||
cmd.arg(key_path);
|
||||
cmd.stdin(std::process::Stdio::null());
|
||||
if configs.sshver == crate::data::SshImpl::OpenSsh {
|
||||
if let Some(p) = crate::data::paths::which(&configs.askpassbin) {
|
||||
cmd.env("SSH_ASKPASS", p);
|
||||
}
|
||||
}
|
||||
let out = cmd.output()?;
|
||||
if !out.status.success() {
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
log::warn!("ssh-add failed: {err}");
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::Other, err.to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove an identity from the agent (`ssh-add -d <file>`).
|
||||
pub fn remove_identity(configs: &Configs, key_path: &Path) -> std::io::Result<()> {
|
||||
let out = Command::new(&configs.addbin)
|
||||
.arg("-d")
|
||||
.arg(key_path)
|
||||
.output()?;
|
||||
if !out.status.success() {
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::Other, err.to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Default identity file — `~/.ssh/id_rsa` if it exists, else the first
|
||||
/// standard name that does.
|
||||
pub fn default_identity() -> Option<PathBuf> {
|
||||
let ssh = crate::data::paths::ssh_dir();
|
||||
for name in ["id_rsa", "id_dsa", "id_ecdsa", "id_ed25519", "identity"] {
|
||||
let p = ssh.join(name);
|
||||
if p.exists() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
|
@ -0,0 +1,421 @@
|
|||
//! Global shellm configuration — `~/.shellm/config`.
|
||||
//!
|
||||
//! (The original Tcl/Tk secpanel used `~/.secpanel/config`.)
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::terminal::{TerminalKind, TerminalSpec};
|
||||
|
||||
/// SSH implementation in use. Affects a few flag choices (`-X` vs `-x`,
|
||||
/// `-N`, `CompressionLevel` quoting).
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SshImpl {
|
||||
#[default]
|
||||
OpenSsh,
|
||||
Other,
|
||||
}
|
||||
|
||||
/// Which backend the SCP browser uses for remote file operations
|
||||
/// (list / mkdir / delete). File transfers always use `scp`.
|
||||
///
|
||||
/// - `Scp` (default): runs `ssh host -- ls -la`, `ssh host -- mkdir -p`,
|
||||
/// `ssh host -- rm -rf` for remote ops. No `sftp` binary required.
|
||||
/// Modern `scp` (OpenSSH 9.0+) already uses the SFTP protocol under the
|
||||
/// hood for transfers, so this is effectively "SFTP under the hood"
|
||||
/// without the separate binary dependency.
|
||||
/// - `Sftp` (opt-in): uses `sftp -b -` with batch scripts. Required on
|
||||
/// restricted sshd configs where shell access is disabled but the sftp
|
||||
/// subsystem is allowed (e.g. `Match User ... ForceCommand internal-sftp`).
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ScpBackend {
|
||||
#[default]
|
||||
Scp,
|
||||
Sftp,
|
||||
}
|
||||
|
||||
impl ScpBackend {
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
ScpBackend::Scp => "scp + ssh (default; no sftp binary needed)",
|
||||
ScpBackend::Sftp => "sftp -b - (opt-in; for restricted sshd configs)",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_legacy(s: &str) -> Self {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"sftp" => ScpBackend::Sftp,
|
||||
_ => ScpBackend::Scp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ScpBackend {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.label())
|
||||
}
|
||||
}
|
||||
|
||||
/// SFTP client tag — what `sftp://` URLs are handed off to.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
pub enum SftpClient {
|
||||
Filezilla,
|
||||
GnomeNautilus,
|
||||
KdeKonqueror,
|
||||
#[default]
|
||||
None,
|
||||
}
|
||||
|
||||
impl SftpClient {
|
||||
pub fn all() -> &'static [SftpClient] {
|
||||
&[
|
||||
SftpClient::Filezilla,
|
||||
SftpClient::GnomeNautilus,
|
||||
SftpClient::KdeKonqueror,
|
||||
]
|
||||
}
|
||||
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
SftpClient::Filezilla => "Filezilla",
|
||||
SftpClient::GnomeNautilus => "GNOME-Nautilus",
|
||||
SftpClient::KdeKonqueror => "KDE-Konqueror",
|
||||
SftpClient::None => "(none)",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the binary name to look up on PATH.
|
||||
pub fn binary(&self) -> &'static str {
|
||||
match self {
|
||||
SftpClient::Filezilla => "filezilla",
|
||||
SftpClient::GnomeNautilus => "nautilus",
|
||||
SftpClient::KdeKonqueror => "konqueror",
|
||||
SftpClient::None => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the launch prefix for an `sftp://...` URL.
|
||||
pub fn launch_prefix(&self) -> &'static str {
|
||||
match self {
|
||||
SftpClient::Filezilla => "filezilla -l interactive ",
|
||||
SftpClient::GnomeNautilus => "nautilus ",
|
||||
SftpClient::KdeKonqueror => "konqueror ",
|
||||
SftpClient::None => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse from the legacy string form.
|
||||
pub fn from_legacy(s: &str) -> Self {
|
||||
match s {
|
||||
"Filezilla" => SftpClient::Filezilla,
|
||||
"GNOME-Nautilus" => SftpClient::GnomeNautilus,
|
||||
"KDE-Konqueror" => SftpClient::KdeKonqueror,
|
||||
_ => SftpClient::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SftpClient {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.label())
|
||||
}
|
||||
}
|
||||
|
||||
/// Color value (RGB). Stored as `#rrggbb`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ColorHex(pub String);
|
||||
|
||||
impl ColorHex {
|
||||
pub fn new(s: impl Into<String>) -> Self {
|
||||
Self(s.into())
|
||||
}
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// The persistent global configuration, persisted to `~/.shellm/config`.
|
||||
///
|
||||
/// In the original secpanel this was a Tcl-sourceable file with
|
||||
/// `set configs(key) value` lines. We persist as TOML but auto-import the
|
||||
/// legacy format on first load.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Configs {
|
||||
// External binaries
|
||||
pub sshbin: String,
|
||||
pub keygenbin: String,
|
||||
pub agentbin: String,
|
||||
pub addbin: String,
|
||||
pub askpassbin: String,
|
||||
pub scpbin: String,
|
||||
pub sftpbin: SftpClient,
|
||||
/// Which backend the SCP browser uses for remote file ops. File
|
||||
/// transfers always use `scp`. See `ScpBackend` for details.
|
||||
pub scp_backend: ScpBackend,
|
||||
pub termver: TerminalKind,
|
||||
pub browserbin: String,
|
||||
pub sshver: SshImpl,
|
||||
|
||||
// Data protection
|
||||
pub protectdata: bool,
|
||||
|
||||
// Colors (None = use system default)
|
||||
pub fore: Option<String>,
|
||||
pub back: Option<String>,
|
||||
pub entfore: Option<String>,
|
||||
pub entback: String,
|
||||
pub listfore: Option<String>,
|
||||
pub listback: String,
|
||||
|
||||
// Fonts
|
||||
pub fontfam: Option<String>,
|
||||
pub fontsize: Option<u32>,
|
||||
pub fontbold: bool,
|
||||
pub fontitalic: bool,
|
||||
pub sysfonts: bool,
|
||||
|
||||
// Window behaviour
|
||||
pub wingeom: bool,
|
||||
pub startsat: bool,
|
||||
|
||||
// Agent
|
||||
pub launcher: bool,
|
||||
pub defident: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for Configs {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
sshbin: "ssh".to_string(),
|
||||
keygenbin: "ssh-keygen".to_string(),
|
||||
agentbin: "ssh-agent".to_string(),
|
||||
addbin: "ssh-add".to_string(),
|
||||
askpassbin: "ssh-askpass".to_string(),
|
||||
scpbin: "scp".to_string(),
|
||||
sftpbin: SftpClient::Filezilla,
|
||||
// Default to scp+ssh for remote file ops — no sftp binary needed.
|
||||
// Users with restricted sshd configs (ForceCommand internal-sftp)
|
||||
// can opt in to the sftp -b - backend in the Configs tab.
|
||||
scp_backend: ScpBackend::Scp,
|
||||
// Auto-detect the best terminal at first run.
|
||||
// The fallback chain is: mrxvt → rxvt-unicode → alacritty → xterm.
|
||||
termver: TerminalKind::detect_default(),
|
||||
browserbin: "firefox".to_string(),
|
||||
sshver: SshImpl::OpenSsh,
|
||||
protectdata: false,
|
||||
fore: None,
|
||||
back: None,
|
||||
entfore: None,
|
||||
entback: "#fefefe".to_string(),
|
||||
listfore: None,
|
||||
listback: "#ffffff".to_string(),
|
||||
fontfam: None,
|
||||
fontsize: None,
|
||||
fontbold: false,
|
||||
fontitalic: false,
|
||||
sysfonts: true,
|
||||
wingeom: false,
|
||||
startsat: false,
|
||||
launcher: false,
|
||||
defident: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Configs {
|
||||
/// Load from disk, falling back to defaults if missing.
|
||||
pub fn load() -> Configs {
|
||||
let path = crate::data::paths::config_file();
|
||||
if !path.exists() {
|
||||
return Configs::default();
|
||||
}
|
||||
let s = match std::fs::read_to_string(&path) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
log::warn!("Could not read config {}: {e}", path.display());
|
||||
return Configs::default();
|
||||
}
|
||||
};
|
||||
|
||||
// Try TOML first.
|
||||
if let Ok(c) = toml::from_str::<Configs>(&s) {
|
||||
return c;
|
||||
}
|
||||
// Fall back to legacy Tcl-style `set configs(key) value`.
|
||||
parse_legacy_config(&s)
|
||||
}
|
||||
|
||||
/// Save to disk as TOML.
|
||||
pub fn save(&self) -> std::io::Result<()> {
|
||||
let path = crate::data::paths::config_file();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let s = toml::to_string_pretty(self)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
std::fs::write(&path, s)
|
||||
}
|
||||
|
||||
/// Look up the terminal spec for the configured `termver`.
|
||||
pub fn terminal_spec(&self) -> TerminalSpec {
|
||||
self.termver.spec()
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the legacy Tcl-style config format:
|
||||
/// ```tcl
|
||||
/// set configs(sshbin) "ssh"
|
||||
/// set configs(entback) #fefefe
|
||||
/// set launcher 0
|
||||
/// set defident "/home/foo/.ssh/id_rsa"
|
||||
/// ```
|
||||
pub fn parse_legacy_config(src: &str) -> Configs {
|
||||
let mut c = Configs::default();
|
||||
|
||||
for raw in src.lines() {
|
||||
let line = raw.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let Some(rest) = line.strip_prefix("set ") else { continue };
|
||||
let rest = rest.trim_start();
|
||||
|
||||
// Either `configs(KEY) VAL` or `barevar VAL`.
|
||||
let (key, val) = match split_once_ws(rest) {
|
||||
Some(x) => x,
|
||||
None => continue,
|
||||
};
|
||||
let v = unquote(val.trim());
|
||||
|
||||
if let Some(inner) = key
|
||||
.strip_prefix("configs(")
|
||||
.and_then(|s| s.strip_suffix(')'))
|
||||
{
|
||||
apply_legacy_configs_field(&mut c, inner, &v);
|
||||
} else {
|
||||
match key {
|
||||
"launcher" => c.launcher = v == "1" || v == "true",
|
||||
"defident" => {
|
||||
if v.is_empty() {
|
||||
c.defident = None;
|
||||
} else {
|
||||
c.defident = Some(PathBuf::from(v));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
fn apply_legacy_configs_field(c: &mut Configs, key: &str, val: &str) {
|
||||
match key {
|
||||
"sshbin" => c.sshbin = val.to_string(),
|
||||
"keygenbin" => c.keygenbin = val.to_string(),
|
||||
"agentbin" => c.agentbin = val.to_string(),
|
||||
"addbin" => c.addbin = val.to_string(),
|
||||
"askpassbin" => c.askpassbin = val.to_string(),
|
||||
"scpbin" => c.scpbin = val.to_string(),
|
||||
"sftpbin" => c.sftpbin = SftpClient::from_legacy(val),
|
||||
"scp_backend" => c.scp_backend = ScpBackend::from_legacy(val),
|
||||
"termver" => {
|
||||
if let Some(t) = TerminalKind::from_legacy_str(val) {
|
||||
c.termver = t;
|
||||
}
|
||||
}
|
||||
"browserbin" => c.browserbin = val.to_string(),
|
||||
"sshver" => {
|
||||
c.sshver = if val.eq_ignore_ascii_case("openssh") {
|
||||
SshImpl::OpenSsh
|
||||
} else {
|
||||
SshImpl::Other
|
||||
}
|
||||
}
|
||||
"protectdata" => c.protectdata = val == "1" || val == "true",
|
||||
"fore" => c.fore = val_to_opt_color(val),
|
||||
"back" => c.back = val_to_opt_color(val),
|
||||
"entfore" => c.entfore = val_to_opt_color(val),
|
||||
"entback" => c.entback = val.to_string(),
|
||||
"listfore" => c.listfore = val_to_opt_color(val),
|
||||
"listback" => c.listback = val.to_string(),
|
||||
"fontfam" => c.fontfam = if val.is_empty() { None } else { Some(val.to_string()) },
|
||||
"fontsize" => c.fontsize = val.parse().ok(),
|
||||
"fontbold" => c.fontbold = val == "1" || val == "true",
|
||||
"fontitalic" => c.fontitalic = val == "1" || val == "true",
|
||||
"sysfonts" => c.sysfonts = val == "1" || val == "true",
|
||||
"wingeom" => c.wingeom = val == "1" || val == "true",
|
||||
"startsat" => c.startsat = val == "1" || val == "true",
|
||||
// `xtermbin` is a runtime-only cache — ignore on load.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn val_to_opt_color(v: &str) -> Option<String> {
|
||||
if v.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(v.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn split_once_ws(s: &str) -> Option<(&str, &str)> {
|
||||
let mut it = s.splitn(2, |c: char| c.is_whitespace());
|
||||
let k = it.next()?.trim();
|
||||
let v = it.next()?.trim();
|
||||
if k.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some((k, v))
|
||||
}
|
||||
}
|
||||
|
||||
fn unquote(s: &str) -> String {
|
||||
let s = s.trim();
|
||||
if (s.starts_with('"') && s.ends_with('"') && s.len() >= 2)
|
||||
|| (s.starts_with('\'') && s.ends_with('\'') && s.len() >= 2)
|
||||
{
|
||||
s[1..s.len() - 1].to_string()
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn legacy_config_parse() {
|
||||
let src = r#"
|
||||
set configs(sshbin) "ssh"
|
||||
set configs(entback) #fefefe
|
||||
set configs(listback) #ffffff
|
||||
set configs(termver) "Xterm"
|
||||
set configs(sftpbin) "Filezilla"
|
||||
set launcher 1
|
||||
set defident "/home/foo/.ssh/id_rsa"
|
||||
"#;
|
||||
let c = parse_legacy_config(src);
|
||||
assert_eq!(c.sshbin, "ssh");
|
||||
assert_eq!(c.entback, "#fefefe");
|
||||
assert_eq!(c.listback, "#ffffff");
|
||||
assert_eq!(c.termver, TerminalKind::Xterm);
|
||||
assert_eq!(c.sftpbin, SftpClient::Filezilla);
|
||||
assert!(c.launcher);
|
||||
assert_eq!(
|
||||
c.defident.as_deref(),
|
||||
Some(std::path::Path::new("/home/foo/.ssh/id_rsa"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_default_configs() {
|
||||
let c = Configs::default();
|
||||
let s = toml::to_string(&c).unwrap();
|
||||
let c2: Configs = toml::from_str(&s).unwrap();
|
||||
assert_eq!(c, c2);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
//! History log entries — `~/.shellm/history`.
|
||||
//!
|
||||
//! (The original Tcl/Tk secpanel used `~/.secpanel/history`.)
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::BufRead;
|
||||
|
||||
/// History category, matching the original's mode codes.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[repr(u8)]
|
||||
pub enum HistCategory {
|
||||
SshConnect = 1,
|
||||
SftpConnect = 2,
|
||||
Keygen = 4,
|
||||
Keydist = 5,
|
||||
Agent = 6,
|
||||
}
|
||||
|
||||
impl HistCategory {
|
||||
pub fn all() -> &'static [HistCategory] {
|
||||
&[
|
||||
HistCategory::SshConnect,
|
||||
HistCategory::SftpConnect,
|
||||
HistCategory::Keygen,
|
||||
HistCategory::Keydist,
|
||||
HistCategory::Agent,
|
||||
]
|
||||
}
|
||||
|
||||
pub fn code(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
HistCategory::SshConnect => "SSH connect",
|
||||
HistCategory::SftpConnect => "SFTP connect",
|
||||
HistCategory::Keygen => "Keygen",
|
||||
HistCategory::Keydist => "Key distribution",
|
||||
HistCategory::Agent => "Agent",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_code(c: u8) -> Option<Self> {
|
||||
match c {
|
||||
1 => Some(HistCategory::SshConnect),
|
||||
2 => Some(HistCategory::SftpConnect),
|
||||
4 => Some(HistCategory::Keygen),
|
||||
5 => Some(HistCategory::Keydist),
|
||||
6 => Some(HistCategory::Agent),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HistoryEntry {
|
||||
pub category: HistCategory,
|
||||
pub time: DateTime<Utc>,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
impl HistoryEntry {
|
||||
pub fn new(cat: HistCategory, text: impl Into<String>) -> Self {
|
||||
Self {
|
||||
category: cat,
|
||||
time: Utc::now(),
|
||||
text: text.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Render in the original `mode#unixtime#text` line format.
|
||||
pub fn to_line(&self) -> String {
|
||||
format!(
|
||||
"{}#{}#{}",
|
||||
self.category.code(),
|
||||
self.time.timestamp(),
|
||||
self.text
|
||||
)
|
||||
}
|
||||
|
||||
/// Parse one line of the legacy history file.
|
||||
pub fn parse_line(line: &str) -> Option<Self> {
|
||||
let mut it = line.splitn(3, '#');
|
||||
let code: u8 = it.next()?.parse().ok()?;
|
||||
let ts: i64 = it.next()?.parse().ok()?;
|
||||
let text = it.next().unwrap_or("").to_string();
|
||||
Some(Self {
|
||||
category: HistCategory::from_code(code)?,
|
||||
time: DateTime::from_timestamp(ts, 0)?,
|
||||
text,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a single entry to the history file.
|
||||
pub fn append(entry: &HistoryEntry) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
let path = crate::data::paths::history_file();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)?;
|
||||
writeln!(f, "{}", entry.to_line())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read every entry from the history file.
|
||||
pub fn read_all() -> Vec<HistoryEntry> {
|
||||
let path = crate::data::paths::history_file();
|
||||
let f = match std::fs::File::open(&path) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for line in std::io::BufReader::new(f).lines() {
|
||||
if let Ok(s) = line {
|
||||
if let Some(e) = HistoryEntry::parse_line(&s) {
|
||||
out.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Clear entries older than `days` days for a specific category.
|
||||
/// `days = 0` clears everything for that category.
|
||||
pub fn clear_category(cat: HistCategory, days: u32) -> std::io::Result<()> {
|
||||
let entries = read_all();
|
||||
let cutoff = Utc::now() - chrono::Duration::days(days as i64);
|
||||
let path = crate::data::paths::history_file();
|
||||
use std::io::Write;
|
||||
let mut f = std::fs::File::create(&path)?;
|
||||
for e in entries {
|
||||
let keep = if e.category == cat {
|
||||
if days == 0 {
|
||||
false
|
||||
} else {
|
||||
e.time >= cutoff
|
||||
}
|
||||
} else {
|
||||
true
|
||||
};
|
||||
if keep {
|
||||
let _ = writeln!(f, "{}", e.to_line());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Append a single trace entry to `~/.shellm/.runfiles/trace.log`.
|
||||
pub fn append_trace(cmd: &str) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
let path = crate::data::paths::trace_log();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)?;
|
||||
let _ = writeln!(f, "{}#{}", Utc::now().timestamp(), cmd);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read the entire trace log.
|
||||
pub fn read_trace() -> String {
|
||||
std::fs::read_to_string(crate::data::paths::trace_log()).unwrap_or_default()
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
//! Data model module for shellm.
|
||||
|
||||
pub mod config;
|
||||
pub mod history;
|
||||
pub mod paths;
|
||||
pub mod profile;
|
||||
pub mod protection;
|
||||
pub mod terminal;
|
||||
|
||||
pub use config::{Configs, ScpBackend, SftpClient, SshImpl};
|
||||
pub use profile::{Algo, Forward, IpVer, KeyType, Profile, SshVer};
|
||||
pub use terminal::{TerminalKind, TerminalSpec};
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
//! Path utilities — shellm's on-disk layout lives under `~/.shellm/`.
|
||||
//!
|
||||
//! (The original Tcl/Tk secpanel by Steffen Leich-Nienhaus used
|
||||
//! `~/.secpanel/`; shellm uses its own directory. Heritage mention only —
|
||||
//! see `shellm_dir()` below for the current path.)
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use directories::ProjectDirs;
|
||||
|
||||
/// Application version (matches the package version).
|
||||
pub const SP_VERSION: &str = "1.1.0";
|
||||
|
||||
/// Return the base shellm data directory (`~/.shellm`).
|
||||
///
|
||||
/// On the original Tcl/Tk secpanel this was always `$env(HOME)/.secpanel`;
|
||||
/// shellm uses `~/.shellm/` instead. On Windows we fall back to
|
||||
/// `%APPDATA%/shellm` via the `directories` crate, but shellm has always
|
||||
/// been a Unix-first tool so this is mostly a defensive fallback.
|
||||
pub fn shellm_dir() -> PathBuf {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home.join(".shellm");
|
||||
}
|
||||
if let Some(pd) = ProjectDirs::from("", "", "shellm") {
|
||||
return pd.data_dir().to_path_buf();
|
||||
}
|
||||
PathBuf::from(".shellm")
|
||||
}
|
||||
|
||||
pub fn config_file() -> PathBuf {
|
||||
shellm_dir().join("config")
|
||||
}
|
||||
|
||||
pub fn profiles_dir() -> PathBuf {
|
||||
shellm_dir().join("profiles")
|
||||
}
|
||||
|
||||
pub fn profile_file(name: &str) -> PathBuf {
|
||||
profiles_dir().join(format!("{name}.profile"))
|
||||
}
|
||||
|
||||
pub fn runfiles_dir() -> PathBuf {
|
||||
shellm_dir().join(".runfiles")
|
||||
}
|
||||
|
||||
pub fn trace_log() -> PathBuf {
|
||||
runfiles_dir().join("trace.log")
|
||||
}
|
||||
|
||||
pub fn history_file() -> PathBuf {
|
||||
shellm_dir().join("history")
|
||||
}
|
||||
|
||||
pub fn init_marker() -> PathBuf {
|
||||
shellm_dir().join(".init")
|
||||
}
|
||||
|
||||
pub fn ssh_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.map(|h| h.join(".ssh"))
|
||||
.unwrap_or_else(|| PathBuf::from(".ssh"))
|
||||
}
|
||||
|
||||
pub fn known_hosts_file() -> PathBuf {
|
||||
ssh_dir().join("known_hosts")
|
||||
}
|
||||
|
||||
pub fn reports_dir() -> PathBuf {
|
||||
shellm_dir().join("reports")
|
||||
}
|
||||
|
||||
/// Ensure that the standard shellm directory tree exists with 0700 perms
|
||||
/// on the root. Returns the path to the shellm dir on success.
|
||||
pub fn ensure_shellm_tree() -> std::io::Result<PathBuf> {
|
||||
let root = shellm_dir();
|
||||
if !root.exists() {
|
||||
std::fs::create_dir_all(&root)?;
|
||||
// Best-effort chmod 700. On non-Unix this is a no-op.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700))?;
|
||||
}
|
||||
log::info!("Created shellm dir {} (chmod 700)", root.display());
|
||||
}
|
||||
std::fs::create_dir_all(profiles_dir())?;
|
||||
std::fs::create_dir_all(runfiles_dir())?;
|
||||
Ok(root)
|
||||
}
|
||||
|
||||
/// Best-effort lookup of an executable on `PATH`.
|
||||
pub fn which(prog: &str) -> Option<PathBuf> {
|
||||
if Path::new(prog).is_absolute() {
|
||||
return if Path::new(prog).exists() {
|
||||
Some(PathBuf::from(prog))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
std::env::var_os("PATH").and_then(|paths| {
|
||||
std::env::split_paths(&paths)
|
||||
.map(|p| p.join(prog))
|
||||
.find(|p| p.is_file())
|
||||
})
|
||||
}
|
||||
|
||||
/// Like `which` but returns the input unchanged if not found.
|
||||
pub fn which_or(prog: &str, fallback: &str) -> String {
|
||||
which(prog).map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|| fallback.to_string())
|
||||
}
|
||||
|
|
@ -0,0 +1,623 @@
|
|||
//! Profile data model — the persistent SSH connection definition.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// A port-forward specification. `lfs` and `rfs` in the original Tcl profile
|
||||
/// are arrays whose key is `inport:host:outport` and value is an optional
|
||||
/// comment. We model each entry as a `Forward` record.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct Forward {
|
||||
pub listen_port: u16,
|
||||
/// The `host` portion. May be a literal hostname, or one of the special
|
||||
/// placeholder strings `<TARGET-HOST>` or `<LOCAL-HOST>` that shellm
|
||||
/// substitutes at connect time.
|
||||
pub host: String,
|
||||
pub target_port: u16,
|
||||
pub comment: String,
|
||||
}
|
||||
|
||||
impl Forward {
|
||||
pub fn new(listen_port: u16, host: impl Into<String>, target_port: u16) -> Self {
|
||||
Self {
|
||||
listen_port,
|
||||
host: host.into(),
|
||||
target_port,
|
||||
comment: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Render this forward as `inport:host:outport` (the key format the
|
||||
/// original used for its `lfs`/`rfs` arrays).
|
||||
pub fn to_key(&self) -> String {
|
||||
format!("{}:{}:{}", self.listen_port, self.host, self.target_port)
|
||||
}
|
||||
|
||||
/// Render as the SSH argument portion: `inport:host:outport`
|
||||
/// (without the `-L`/`-R` prefix).
|
||||
pub fn to_ssh_arg(&self) -> String {
|
||||
self.to_key()
|
||||
}
|
||||
}
|
||||
|
||||
/// SSH cipher selection — mirrors the original `algo` menu.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Algo {
|
||||
#[default]
|
||||
Default,
|
||||
Idea,
|
||||
Des,
|
||||
#[serde(rename = "3des")]
|
||||
TripleDes,
|
||||
Blowfish,
|
||||
Arcfour,
|
||||
None,
|
||||
}
|
||||
|
||||
impl Algo {
|
||||
pub const ALL: &'static [Algo] = &[
|
||||
Algo::Default,
|
||||
Algo::Idea,
|
||||
Algo::Des,
|
||||
Algo::TripleDes,
|
||||
Algo::Blowfish,
|
||||
Algo::Arcfour,
|
||||
Algo::None,
|
||||
];
|
||||
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Algo::Default => "default",
|
||||
Algo::Idea => "idea",
|
||||
Algo::Des => "des",
|
||||
Algo::TripleDes => "3des",
|
||||
Algo::Blowfish => "blowfish",
|
||||
Algo::Arcfour => "arcfour",
|
||||
Algo::None => "none",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"default" | "" => Some(Algo::Default),
|
||||
"idea" => Some(Algo::Idea),
|
||||
"des" => Some(Algo::Des),
|
||||
"3des" => Some(Algo::TripleDes),
|
||||
"blowfish" => Some(Algo::Blowfish),
|
||||
"arcfour" => Some(Algo::Arcfour),
|
||||
"none" => Some(Algo::None),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` when this algo should be emitted as `-c <algo>`.
|
||||
pub fn is_emittable(&self) -> bool {
|
||||
!matches!(self, Algo::Default)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Algo {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SshVer {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
SshVer::V1 => "SSH 1",
|
||||
SshVer::V2 => "SSH 2",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for IpVer {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
IpVer::V4 => "IPv4",
|
||||
IpVer::V6 => "IPv6",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for KeyType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.display_label())
|
||||
}
|
||||
}
|
||||
|
||||
/// SSH protocol version.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SshVer {
|
||||
#[serde(rename = "1")]
|
||||
V1,
|
||||
#[default]
|
||||
#[serde(rename = "2")]
|
||||
V2,
|
||||
}
|
||||
|
||||
impl SshVer {
|
||||
pub fn flag(&self) -> &'static str {
|
||||
match self {
|
||||
SshVer::V1 => "-1",
|
||||
SshVer::V2 => "-2",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// IP protocol version.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum IpVer {
|
||||
#[default]
|
||||
#[serde(rename = "4")]
|
||||
V4,
|
||||
#[serde(rename = "6")]
|
||||
V6,
|
||||
}
|
||||
|
||||
impl IpVer {
|
||||
pub fn flag(&self) -> &'static str {
|
||||
match self {
|
||||
IpVer::V4 => "-4",
|
||||
IpVer::V6 => "-6",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Key types supported by `ssh-keygen`.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum KeyType {
|
||||
#[serde(rename = "rsa1")]
|
||||
Rsa1,
|
||||
#[default]
|
||||
#[serde(rename = "rsa")]
|
||||
Rsa,
|
||||
#[serde(rename = "dsa")]
|
||||
Dsa,
|
||||
}
|
||||
|
||||
impl KeyType {
|
||||
pub fn ssh_keygen_arg(&self) -> &'static str {
|
||||
match self {
|
||||
KeyType::Rsa1 => "rsa1",
|
||||
KeyType::Rsa => "rsa",
|
||||
KeyType::Dsa => "dsa",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_filename(&self) -> &'static str {
|
||||
match self {
|
||||
KeyType::Rsa1 => "identity",
|
||||
KeyType::Rsa => "id_rsa",
|
||||
KeyType::Dsa => "id_dsa",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display_label(&self) -> &'static str {
|
||||
match self {
|
||||
KeyType::Rsa1 => "SSH1 RSA1",
|
||||
KeyType::Rsa => "SSH2 RSA",
|
||||
KeyType::Dsa => "SSH2 DSA",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A shellm connection profile.
|
||||
///
|
||||
/// Field names and semantics mirror the original `default.profile` (and the
|
||||
/// fields that `clear_profiles` and `save_profile` set in shellm proper).
|
||||
/// In particular:
|
||||
/// * `noagentforward` is the *negated* sense of the legacy `agentforward`
|
||||
/// field — true means "do NOT forward agent" → emits `-a`.
|
||||
/// * `x11forward` is also inverted — true means "no X11 forward" → `-x`;
|
||||
/// false + OpenSSH emits `-X`.
|
||||
/// * `compress` defaults to true (the original `default.profile` has
|
||||
/// `compress "1"`).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Profile {
|
||||
pub title: String,
|
||||
pub host: String,
|
||||
pub user: String,
|
||||
pub port: u16,
|
||||
pub command: String,
|
||||
pub subsys: String,
|
||||
pub identity: PathBuf,
|
||||
pub cfgfile: PathBuf,
|
||||
pub noagentforward: bool,
|
||||
pub x11forward: bool,
|
||||
pub stricthost: bool,
|
||||
pub nopriv: bool,
|
||||
pub verbose: bool,
|
||||
pub quiet: bool,
|
||||
pub fork: bool,
|
||||
pub gateway: bool,
|
||||
pub compress: bool,
|
||||
pub connwait: bool,
|
||||
pub termicon: bool,
|
||||
pub askuserspec: bool,
|
||||
pub noexec: bool,
|
||||
pub algo: Algo,
|
||||
pub compressval: u8,
|
||||
pub sshverconnect: SshVer,
|
||||
pub ipverconnect: IpVer,
|
||||
pub lfs: Vec<Forward>,
|
||||
pub rfs: Vec<Forward>,
|
||||
}
|
||||
|
||||
impl Default for Profile {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
title: "Default Profile".to_string(),
|
||||
host: String::new(),
|
||||
user: String::new(),
|
||||
port: 22,
|
||||
command: String::new(),
|
||||
subsys: String::new(),
|
||||
identity: PathBuf::new(),
|
||||
cfgfile: PathBuf::new(),
|
||||
noagentforward: false,
|
||||
x11forward: false,
|
||||
stricthost: false,
|
||||
nopriv: false,
|
||||
verbose: false,
|
||||
quiet: false,
|
||||
fork: false,
|
||||
gateway: false,
|
||||
compress: true,
|
||||
connwait: false,
|
||||
termicon: false,
|
||||
askuserspec: false,
|
||||
noexec: false,
|
||||
algo: Algo::Default,
|
||||
compressval: 6,
|
||||
sshverconnect: SshVer::V2,
|
||||
ipverconnect: IpVer::V4,
|
||||
lfs: Vec::new(),
|
||||
rfs: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Profile {
|
||||
/// Returns `true` if the profile is essentially blank (only the default
|
||||
/// title, no host/user). Used by the editor to decide whether we are
|
||||
/// editing a new profile.
|
||||
pub fn is_blank(&self) -> bool {
|
||||
self.host.is_empty() && self.user.is_empty() && self.title == "Default Profile"
|
||||
}
|
||||
|
||||
/// What the connections list should show for this profile.
|
||||
pub fn display_line(&self) -> String {
|
||||
if self.host.is_empty() {
|
||||
self.title.clone()
|
||||
} else if self.user.is_empty() {
|
||||
format!("{} ({}:{})", self.title, self.host, self.port)
|
||||
} else {
|
||||
format!("{} ({}@{}:{})", self.title, self.user, self.host, self.port)
|
||||
}
|
||||
}
|
||||
|
||||
/// Effective user, returning `None` if the profile is set to ask at
|
||||
/// connect time.
|
||||
pub fn effective_user(&self) -> Option<&str> {
|
||||
if self.askuserspec {
|
||||
None
|
||||
} else if self.user.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(self.user.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Save the profile to disk in TOML format at the standard path.
|
||||
pub fn save(&self, name: &str) -> std::io::Result<()> {
|
||||
let path = crate::data::paths::profile_file(name);
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let s = toml::to_string_pretty(self)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
std::fs::write(&path, s)
|
||||
}
|
||||
|
||||
/// Load a profile from disk.
|
||||
pub fn load(name: &str) -> anyhow::Result<Profile> {
|
||||
let path = crate::data::paths::profile_file(name);
|
||||
let s = std::fs::read_to_string(&path)?;
|
||||
// Try TOML first (the new native format).
|
||||
if let Ok(p) = toml::from_str::<Profile>(&s) {
|
||||
return Ok(p);
|
||||
}
|
||||
// Fall back to the legacy Tcl-style format.
|
||||
Ok(parse_legacy_profile(&s))
|
||||
}
|
||||
|
||||
/// Delete a profile file from disk.
|
||||
pub fn delete_file(name: &str) -> std::io::Result<()> {
|
||||
let path = crate::data::paths::profile_file(name);
|
||||
if path.exists() {
|
||||
std::fs::remove_file(path)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Enumerate all profile names (without the `.profile` extension) on disk.
|
||||
pub fn list_names() -> Vec<String> {
|
||||
let dir = crate::data::paths::profiles_dir();
|
||||
let mut out = Vec::new();
|
||||
if let Ok(rd) = std::fs::read_dir(&dir) {
|
||||
for ent in rd.flatten() {
|
||||
let p = ent.path();
|
||||
if p.extension().and_then(|e| e.to_str()) == Some("profile") {
|
||||
if let Some(stem) = p.file_stem().and_then(|s| s.to_str()) {
|
||||
out.push(stem.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// Try to find a profile whose title matches `title`. Returns its filename
|
||||
/// stem. This mirrors the original `retprof` proc.
|
||||
pub fn find_by_title(title: &str) -> Option<String> {
|
||||
for name in Self::list_names() {
|
||||
if let Ok(p) = Self::load(&name) {
|
||||
if p.title == title {
|
||||
return Some(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a legacy Tcl-style profile (the format produced by the original
|
||||
/// secpanel's `save_profile` proc). Each line looks like:
|
||||
///
|
||||
/// ```tcl
|
||||
/// set title "My Profile"
|
||||
/// set host "example.com"
|
||||
/// set port "22"
|
||||
/// array set lfs {8080:localhost:80 "web proxy"}
|
||||
/// ```
|
||||
///
|
||||
/// We do just enough parsing to recover the values. Comments and unknown
|
||||
/// `set` vars are ignored.
|
||||
pub fn parse_legacy_profile(src: &str) -> Profile {
|
||||
let mut p = Profile::default();
|
||||
let mut lfs: BTreeMap<String, String> = BTreeMap::new();
|
||||
let mut rfs: BTreeMap<String, String> = BTreeMap::new();
|
||||
|
||||
for raw in src.lines() {
|
||||
let line = raw.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let Some(rest) = line.strip_prefix("set ") else { continue };
|
||||
let rest = rest.trim_start();
|
||||
let (key, val) = match split_once_ws(rest) {
|
||||
Some(x) => x,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
if key == "array" {
|
||||
// array set lfs {K V K V ...}
|
||||
let after_set = val.trim();
|
||||
if let Some(rest) = after_set.strip_prefix("set ") {
|
||||
let rest = rest.trim_start();
|
||||
let (arr, body) = match split_once_ws(rest) {
|
||||
Some(x) => x,
|
||||
None => continue,
|
||||
};
|
||||
let body = body.trim();
|
||||
let body = body.trim_start_matches('{').trim_end_matches('}');
|
||||
let entries = parse_array_body(body);
|
||||
match arr {
|
||||
"lfs" => lfs.extend(entries),
|
||||
"rfs" => rfs.extend(entries),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// `set key value` — value may be a quoted string or a bare token.
|
||||
let v = unquote(val.trim());
|
||||
apply_legacy_field(&mut p, key, &v);
|
||||
}
|
||||
|
||||
p.lfs = lfs.iter().map(|(k, v)| parse_forward(k, v)).collect();
|
||||
p.rfs = rfs.iter().map(|(k, v)| parse_forward(k, v)).collect();
|
||||
p
|
||||
}
|
||||
|
||||
fn apply_legacy_field(p: &mut Profile, key: &str, val: &str) {
|
||||
match key {
|
||||
"title" => p.title = val.to_string(),
|
||||
"host" => p.host = val.to_string(),
|
||||
"user" => p.user = val.to_string(),
|
||||
"port" => p.port = val.parse().unwrap_or(22),
|
||||
"command" => p.command = val.to_string(),
|
||||
"subsys" => p.subsys = val.to_string(),
|
||||
"identity" => p.identity = PathBuf::from(val),
|
||||
"cfgfile" => p.cfgfile = PathBuf::from(val),
|
||||
"noagentforward" | "agentforward" => {
|
||||
// The legacy `agentforward` field is the inverse of
|
||||
// `noagentforward`; the UI now uses `noagentforward` exclusively.
|
||||
if key == "agentforward" {
|
||||
p.noagentforward = val == "0";
|
||||
} else {
|
||||
p.noagentforward = val == "1";
|
||||
}
|
||||
}
|
||||
"x11forward" => p.x11forward = val == "1",
|
||||
"stricthost" => p.stricthost = val == "1",
|
||||
"nopriv" => p.nopriv = val == "1",
|
||||
"verbose" => p.verbose = val == "1",
|
||||
"quiet" => p.quiet = val == "1",
|
||||
"fork" => p.fork = val == "1",
|
||||
"gateway" => p.gateway = val == "1",
|
||||
"compress" => p.compress = val == "1",
|
||||
"connwait" => p.connwait = val == "1",
|
||||
"termicon" => p.termicon = val == "1",
|
||||
"askuserspec" => p.askuserspec = val == "1",
|
||||
"noexec" => p.noexec = val == "1",
|
||||
"algo" => p.algo = Algo::from_str(val).unwrap_or_default(),
|
||||
"compressval" => p.compressval = val.parse().unwrap_or(6),
|
||||
"sshverconnect" => {
|
||||
p.sshverconnect = if val == "1" {
|
||||
SshVer::V1
|
||||
} else {
|
||||
SshVer::V2
|
||||
}
|
||||
}
|
||||
"ipverconnect" => {
|
||||
p.ipverconnect = if val == "6" {
|
||||
IpVer::V6
|
||||
} else {
|
||||
IpVer::V4
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn split_once_ws(s: &str) -> Option<(&str, &str)> {
|
||||
let mut it = s.splitn(2, |c: char| c.is_whitespace());
|
||||
let k = it.next()?.trim();
|
||||
let v = it.next()?.trim();
|
||||
if k.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some((k, v))
|
||||
}
|
||||
}
|
||||
|
||||
fn unquote(s: &str) -> String {
|
||||
let s = s.trim();
|
||||
if (s.starts_with('"') && s.ends_with('"') && s.len() >= 2)
|
||||
|| (s.starts_with('\'') && s.ends_with('\'') && s.len() >= 2)
|
||||
{
|
||||
s[1..s.len() - 1].to_string()
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the body of `array set X {k1 v1 k2 v2 ...}` into (key, value) pairs.
|
||||
/// Values may be quoted.
|
||||
fn parse_array_body(body: &str) -> Vec<(String, String)> {
|
||||
let mut out = Vec::new();
|
||||
let mut toks = tokenize(body);
|
||||
while let Some(k) = toks.next() {
|
||||
let v = toks.next().unwrap_or_default();
|
||||
out.push((k, v));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn tokenize(s: &str) -> impl Iterator<Item = String> + '_ {
|
||||
struct Tok<'a> {
|
||||
s: &'a str,
|
||||
pos: usize,
|
||||
}
|
||||
impl<'a> Iterator for Tok<'a> {
|
||||
type Item = String;
|
||||
fn next(&mut self) -> Option<String> {
|
||||
let bytes = self.s.as_bytes();
|
||||
while self.pos < bytes.len() && bytes[self.pos].is_ascii_whitespace() {
|
||||
self.pos += 1;
|
||||
}
|
||||
if self.pos >= bytes.len() {
|
||||
return None;
|
||||
}
|
||||
let start = self.pos;
|
||||
if bytes[self.pos] == b'"' {
|
||||
self.pos += 1;
|
||||
let s = self.pos;
|
||||
while self.pos < bytes.len() && bytes[self.pos] != b'"' {
|
||||
self.pos += 1;
|
||||
}
|
||||
let out = self.s[s..self.pos].to_string();
|
||||
if self.pos < bytes.len() {
|
||||
self.pos += 1; // skip closing "
|
||||
}
|
||||
let _ = start; // silence unused
|
||||
return Some(out);
|
||||
}
|
||||
while self.pos < bytes.len() && !bytes[self.pos].is_ascii_whitespace() {
|
||||
self.pos += 1;
|
||||
}
|
||||
Some(self.s[start..self.pos].to_string())
|
||||
}
|
||||
}
|
||||
Tok { s, pos: 0 }
|
||||
}
|
||||
|
||||
fn parse_forward(key: &str, comment: &str) -> Forward {
|
||||
let parts: Vec<&str> = key.splitn(3, ':').collect();
|
||||
if parts.len() == 3 {
|
||||
Forward {
|
||||
listen_port: parts[0].parse().unwrap_or(0),
|
||||
host: parts[1].to_string(),
|
||||
target_port: parts[2].parse().unwrap_or(0),
|
||||
comment: comment.to_string(),
|
||||
}
|
||||
} else {
|
||||
Forward {
|
||||
listen_port: 0,
|
||||
host: key.to_string(),
|
||||
target_port: 0,
|
||||
comment: comment.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn round_trip_default() {
|
||||
let p = Profile::default();
|
||||
let s = toml::to_string(&p).unwrap();
|
||||
let p2: Profile = toml::from_str(&s).unwrap();
|
||||
assert_eq!(p, p2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_parse_basic() {
|
||||
let src = r#"
|
||||
# secpanel-Profile
|
||||
set title "My Server"
|
||||
set host "example.com"
|
||||
set user "root"
|
||||
set port "2222"
|
||||
set compress "1"
|
||||
set compressval "9"
|
||||
set algo "blowfish"
|
||||
set sshverconnect "2"
|
||||
array set lfs {8080:localhost:80 "web proxy"}
|
||||
"#;
|
||||
let p = parse_legacy_profile(src);
|
||||
assert_eq!(p.title, "My Server");
|
||||
assert_eq!(p.host, "example.com");
|
||||
assert_eq!(p.user, "root");
|
||||
assert_eq!(p.port, 2222);
|
||||
assert_eq!(p.compressval, 9);
|
||||
assert_eq!(p.algo, Algo::Blowfish);
|
||||
assert_eq!(p.lfs.len(), 1);
|
||||
assert_eq!(p.lfs[0].listen_port, 8080);
|
||||
assert_eq!(p.lfs[0].host, "localhost");
|
||||
assert_eq!(p.lfs[0].target_port, 80);
|
||||
assert_eq!(p.lfs[0].comment, "web proxy");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,218 @@
|
|||
//! GPG-based data protection for `~/.shellm/`.
|
||||
//!
|
||||
//! Mirrors the original secpanel `dppw.tcl` script: bundles the entire
|
||||
//! `~/.shellm/` directory tree into a tar.gz, then encrypts it symmetrically
|
||||
//! with GnuPG (`gpg -c`) using a user-supplied passphrase. The encrypted
|
||||
//! blob is written to `~/.shellm/spdata.lck`.
|
||||
//!
|
||||
//! Unlike the original (which only verified the passphrase on decrypt and
|
||||
//! didn't actually restore the files), this implementation does a full
|
||||
//! round-trip: encrypt on exit, decrypt on next startup.
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use crate::data::paths;
|
||||
|
||||
/// Path to the encrypted lock file.
|
||||
pub fn lock_file_path() -> PathBuf {
|
||||
paths::shellm_dir().join("spdata.lck")
|
||||
}
|
||||
|
||||
/// Returns `true` if GPG is available on the system.
|
||||
pub fn gpg_available() -> bool {
|
||||
paths::which("gpg").is_some()
|
||||
}
|
||||
|
||||
/// Encrypt `~/.shellm/` into `spdata.lck` using the given passphrase.
|
||||
///
|
||||
/// Steps:
|
||||
/// 1. Build a list of all files under `~/.shellm/` (excluding
|
||||
/// `spdata.lck` itself and any leftover `spdata.lck.tmp`).
|
||||
/// 2. Write the passphrase to a temp file with mode 0600.
|
||||
/// 3. `tar -c -z -f - -T <filelist> | gpg -q --batch --passphrase-file <pwfile> -c > spdata.lck`
|
||||
/// 4. Delete the temp passphrase file.
|
||||
pub fn encrypt(passphrase: &str) -> Result<(), String> {
|
||||
if !gpg_available() {
|
||||
return Err("gpg binary not found on PATH".into());
|
||||
}
|
||||
|
||||
let shellm_dir = paths::shellm_dir();
|
||||
let lock_file = lock_file_path();
|
||||
let tmp_lock = shellm_dir.join("spdata.lck.tmp");
|
||||
|
||||
// 1. Build file list.
|
||||
let filelist = list_files_under(&shellm_dir, &[&lock_file, &tmp_lock])?;
|
||||
let filelist_path = shellm_dir.join(".tarfiles");
|
||||
std::fs::write(&filelist_path, filelist.join("\n"))
|
||||
.map_err(|e| format!("write filelist: {e}"))?;
|
||||
|
||||
// 2. Write passphrase to a temp file with 0600 perms.
|
||||
let pwfile = shellm_dir.join(format!(".pw_{}", timestamp_nanos()));
|
||||
{
|
||||
let mut f = std::fs::File::create(&pwfile).map_err(|e| e.to_string())?;
|
||||
f.write_all(passphrase.as_bytes()).map_err(|e| e.to_string())?;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&pwfile, std::fs::Permissions::from_mode(0o600))
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
// 3. tar | gpg > spdata.lck.tmp
|
||||
let tar_out = Command::new("tar")
|
||||
.arg("-c")
|
||||
.arg("-z")
|
||||
.arg("-f")
|
||||
.arg("-")
|
||||
.arg("-T")
|
||||
.arg(&filelist_path)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|e| format!("spawn tar: {e}"))?;
|
||||
|
||||
let tar_stdout = tar_out.stdout.ok_or("no tar stdout")?;
|
||||
|
||||
let mut gpg = Command::new("gpg")
|
||||
.arg("--batch")
|
||||
.arg("--quiet")
|
||||
.arg("--no-tty")
|
||||
.arg("--passphrase-file")
|
||||
.arg(&pwfile)
|
||||
.arg("--symmetric")
|
||||
.arg("--cipher-algo")
|
||||
.arg("AES256")
|
||||
.stdin(Stdio::from(tar_stdout))
|
||||
.stdout(std::fs::File::create(&tmp_lock).map_err(|e| e.to_string())?)
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|e| format!("spawn gpg: {e}"))?;
|
||||
|
||||
let status = gpg.wait().map_err(|e| e.to_string())?;
|
||||
if !status.success() {
|
||||
let _ = std::fs::remove_file(&tmp_lock);
|
||||
let _ = std::fs::remove_file(&pwfile);
|
||||
let _ = std::fs::remove_file(&filelist_path);
|
||||
return Err(format!("gpg exited with status {status}"));
|
||||
}
|
||||
|
||||
// 4. Clean up temp files.
|
||||
let _ = std::fs::remove_file(&pwfile);
|
||||
let _ = std::fs::remove_file(&filelist_path);
|
||||
|
||||
// Atomically replace the lock file.
|
||||
std::fs::rename(&tmp_lock, &lock_file).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Decrypt `spdata.lck` and restore the contents into `~/.shellm/`.
|
||||
///
|
||||
/// Steps:
|
||||
/// 1. Write the passphrase to a temp file.
|
||||
/// 2. `gpg -q --batch --passphrase-file <pwfile> -d spdata.lck | tar -x -z -f -`
|
||||
/// 3. Delete the temp passphrase file.
|
||||
pub fn decrypt(passphrase: &str) -> Result<(), String> {
|
||||
if !gpg_available() {
|
||||
return Err("gpg binary not found on PATH".into());
|
||||
}
|
||||
|
||||
let lock_file = lock_file_path();
|
||||
if !lock_file.exists() {
|
||||
return Err("no spdata.lck present".into());
|
||||
}
|
||||
|
||||
let shellm_dir = paths::shellm_dir();
|
||||
let pwfile = shellm_dir.join(format!(".pw_{}", timestamp_nanos()));
|
||||
{
|
||||
let mut f = std::fs::File::create(&pwfile).map_err(|e| e.to_string())?;
|
||||
f.write_all(passphrase.as_bytes()).map_err(|e| e.to_string())?;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&pwfile, std::fs::Permissions::from_mode(0o600))
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
let gpg_out = Command::new("gpg")
|
||||
.arg("--batch")
|
||||
.arg("--quiet")
|
||||
.arg("--no-tty")
|
||||
.arg("--passphrase-file")
|
||||
.arg(&pwfile)
|
||||
.arg("--decrypt")
|
||||
.arg(&lock_file)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("spawn gpg: {e}"))?;
|
||||
|
||||
let gpg_stdout = gpg_out.stdout.ok_or("no gpg stdout")?;
|
||||
|
||||
let mut tar = Command::new("tar")
|
||||
.arg("-x")
|
||||
.arg("-z")
|
||||
.arg("-f")
|
||||
.arg("-")
|
||||
.arg("-C")
|
||||
.arg(&shellm_dir)
|
||||
.stdin(Stdio::from(gpg_stdout))
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|e| format!("spawn tar: {e}"))?;
|
||||
|
||||
let status = tar.wait().map_err(|e| e.to_string())?;
|
||||
let _ = std::fs::remove_file(&pwfile);
|
||||
|
||||
if !status.success() {
|
||||
return Err(format!("tar exited with status {status}"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Recursively list every file under `dir`, excluding any whose path matches
|
||||
/// one of `exclude`. Returns absolute paths.
|
||||
fn list_files_under(dir: &Path, exclude: &[&Path]) -> Result<Vec<String>, String> {
|
||||
let mut out = Vec::new();
|
||||
walk(dir, dir, exclude, &mut out)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn walk(root: &Path, dir: &Path, exclude: &[&Path], out: &mut Vec<String>) -> Result<(), String> {
|
||||
let rd = std::fs::read_dir(dir).map_err(|e| e.to_string())?;
|
||||
for ent in rd {
|
||||
let ent = ent.map_err(|e| e.to_string())?;
|
||||
let p = ent.path();
|
||||
if exclude.iter().any(|x| **x == p) {
|
||||
continue;
|
||||
}
|
||||
let ft = match ent.file_type() {
|
||||
Ok(t) => t,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if ft.is_dir() {
|
||||
// Skip the .runfiles directory — it's a runtime scratch area
|
||||
// that shouldn't be encrypted.
|
||||
if p.file_name().and_then(|n| n.to_str()) == Some(".runfiles") {
|
||||
continue;
|
||||
}
|
||||
walk(root, &p, exclude, out)?;
|
||||
} else if ft.is_file() {
|
||||
out.push(p.to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn timestamp_nanos() -> u128 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
|
@ -0,0 +1,410 @@
|
|||
//! Terminal definitions — extends the original `termdefs.txt` set with
|
||||
//! modern terminals (rs-mrxvt, rxvt-unicode, alacritty, kitty, wezterm, foot,
|
||||
//! kgx, tabby, ghostty, warp).
|
||||
//!
|
||||
//! The default terminal is auto-detected at first run with this fallback
|
||||
//! chain (matching the user's request):
|
||||
//! 1. `rs-mrxvt` (the user's rewrite of mrxvt — preferred default)
|
||||
//! 2. `rxvt-unicode` (urxvt — common on legacy X11 setups)
|
||||
//! 3. `alacritty` (modern GPU-accelerated terminal)
|
||||
//! 4. `xterm` (always-available fallback)
|
||||
//!
|
||||
//! Users can override the choice in the Configs tab.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// All terminals supported by shellm. Includes the original 11 from
|
||||
/// `termdefs.txt` plus modern additions (Tabby, Ghostty, Warp, etc.).
|
||||
#[derive(
|
||||
Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Default,
|
||||
)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum TerminalKind {
|
||||
// --- Original termdefs.txt set (preserved for fidelity) ---
|
||||
Aterm,
|
||||
Eterm,
|
||||
MultiAterm,
|
||||
KdeTerminal,
|
||||
GnomeTerminal,
|
||||
#[default]
|
||||
Xterm,
|
||||
Rxvt,
|
||||
Mrxvt,
|
||||
Xvt,
|
||||
XfceTerminal,
|
||||
PuttysTerminal,
|
||||
|
||||
// --- Modern additions ---
|
||||
/// rs-mrxvt — the user's Rust rewrite of mrxvt. Preferred default.
|
||||
RsMrxvt,
|
||||
/// rxvt-unicode (urxvt) — the de-facto X11 terminal on most Linux distros.
|
||||
RxvtUnicode,
|
||||
/// Alacritty — modern, GPU-accelerated, cross-platform.
|
||||
Alacritty,
|
||||
/// Kitty — GPU-accelerated, scriptable.
|
||||
Kitty,
|
||||
/// WezTerm — cross-platform, GPU-accelerated, Lua-configurable.
|
||||
WezTerm,
|
||||
/// Foot — Wayland-native lightweight terminal.
|
||||
Foot,
|
||||
/// GNOME Console (kgx) — modern GNOME terminal.
|
||||
GnomeConsole,
|
||||
/// xfce4-terminal (modern name; the original XFCE-Terminal used `Terminal`).
|
||||
Xfce4Terminal,
|
||||
/// Tabby — Electron-based, cross-platform, plugin ecosystem.
|
||||
Tabby,
|
||||
/// Ghostty — Mitchell Hashimoto's GPU-accelerated terminal (MIT).
|
||||
Ghostty,
|
||||
/// Warp — AI-first, Rust-based terminal (Apache 2.0).
|
||||
Warp,
|
||||
}
|
||||
|
||||
impl TerminalKind {
|
||||
pub fn all() -> &'static [TerminalKind] {
|
||||
&[
|
||||
// Preferred default first, then modern alternatives
|
||||
TerminalKind::RsMrxvt,
|
||||
TerminalKind::Alacritty,
|
||||
TerminalKind::Kitty,
|
||||
TerminalKind::WezTerm,
|
||||
TerminalKind::Ghostty,
|
||||
TerminalKind::Warp,
|
||||
TerminalKind::Tabby,
|
||||
TerminalKind::Foot,
|
||||
TerminalKind::GnomeConsole,
|
||||
TerminalKind::RxvtUnicode,
|
||||
TerminalKind::Xfce4Terminal,
|
||||
// Original set (kept for backwards compatibility)
|
||||
TerminalKind::Mrxvt,
|
||||
TerminalKind::Xterm,
|
||||
TerminalKind::Rxvt,
|
||||
TerminalKind::Xvt,
|
||||
TerminalKind::Aterm,
|
||||
TerminalKind::Eterm,
|
||||
TerminalKind::MultiAterm,
|
||||
TerminalKind::KdeTerminal,
|
||||
TerminalKind::GnomeTerminal,
|
||||
TerminalKind::XfceTerminal,
|
||||
TerminalKind::PuttysTerminal,
|
||||
]
|
||||
}
|
||||
|
||||
/// Display label for the menu.
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
TerminalKind::Aterm => "Aterm",
|
||||
TerminalKind::Eterm => "Eterm",
|
||||
TerminalKind::MultiAterm => "Multi-Aterm",
|
||||
TerminalKind::KdeTerminal => "KDE Konsole",
|
||||
TerminalKind::GnomeTerminal => "GNOME Terminal (legacy)",
|
||||
TerminalKind::Xterm => "xterm",
|
||||
TerminalKind::Rxvt => "rxvt",
|
||||
TerminalKind::Mrxvt => "mrxvt (original)",
|
||||
TerminalKind::Xvt => "xvt",
|
||||
TerminalKind::XfceTerminal => "XFCE Terminal (legacy)",
|
||||
TerminalKind::PuttysTerminal => "PuTTY pterm",
|
||||
TerminalKind::RsMrxvt => "rs-mrxvt",
|
||||
TerminalKind::RxvtUnicode => "rxvt-unicode (urxvt)",
|
||||
TerminalKind::Alacritty => "Alacritty",
|
||||
TerminalKind::Kitty => "Kitty",
|
||||
TerminalKind::WezTerm => "WezTerm",
|
||||
TerminalKind::Foot => "Foot",
|
||||
TerminalKind::GnomeConsole => "GNOME Console (kgx)",
|
||||
TerminalKind::Xfce4Terminal => "xfce4-terminal",
|
||||
TerminalKind::Tabby => "Tabby",
|
||||
TerminalKind::Ghostty => "Ghostty",
|
||||
TerminalKind::Warp => "Warp",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if this terminal is modern (added in the Rust port).
|
||||
pub fn is_modern(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
TerminalKind::RsMrxvt
|
||||
| TerminalKind::RxvtUnicode
|
||||
| TerminalKind::Alacritty
|
||||
| TerminalKind::Kitty
|
||||
| TerminalKind::WezTerm
|
||||
| TerminalKind::Foot
|
||||
| TerminalKind::GnomeConsole
|
||||
| TerminalKind::Xfce4Terminal
|
||||
| TerminalKind::Tabby
|
||||
| TerminalKind::Ghostty
|
||||
| TerminalKind::Warp
|
||||
)
|
||||
}
|
||||
|
||||
pub fn from_legacy_str(s: &str) -> Option<Self> {
|
||||
for k in Self::all() {
|
||||
if k.label() == s {
|
||||
return Some(*k);
|
||||
}
|
||||
}
|
||||
// Map a few common aliases.
|
||||
match s {
|
||||
"Xterm" => Some(TerminalKind::Xterm),
|
||||
"MRXVT" => Some(TerminalKind::Mrxvt),
|
||||
"mrxvt" => Some(TerminalKind::Mrxvt),
|
||||
"rs-mrxvt" => Some(TerminalKind::RsMrxvt),
|
||||
"RXVT" => Some(TerminalKind::Rxvt),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto-detect the best available terminal. Tries the user's preferred
|
||||
/// fallback chain (rs-mrxvt → rxvt-unicode → alacritty → xterm) and
|
||||
/// returns the first one whose binary is on `PATH`.
|
||||
pub fn detect_default() -> TerminalKind {
|
||||
for candidate in [
|
||||
TerminalKind::RsMrxvt,
|
||||
TerminalKind::RxvtUnicode,
|
||||
TerminalKind::Alacritty,
|
||||
TerminalKind::Kitty,
|
||||
TerminalKind::WezTerm,
|
||||
TerminalKind::Ghostty,
|
||||
TerminalKind::Warp,
|
||||
TerminalKind::Tabby,
|
||||
TerminalKind::Foot,
|
||||
TerminalKind::GnomeConsole,
|
||||
TerminalKind::Xfce4Terminal,
|
||||
TerminalKind::Mrxvt,
|
||||
TerminalKind::KdeTerminal,
|
||||
TerminalKind::GnomeTerminal,
|
||||
TerminalKind::Xterm,
|
||||
] {
|
||||
let spec = candidate.spec();
|
||||
if crate::data::paths::which(&spec.path).is_some() {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
// Ultimate fallback — even if nothing is installed, return rs-mrxvt
|
||||
// as the configured default (the user said to assume it's there).
|
||||
TerminalKind::RsMrxvt
|
||||
}
|
||||
|
||||
/// Returns `true` if the terminal's binary is available on `PATH`.
|
||||
pub fn is_available(&self) -> bool {
|
||||
crate::data::paths::which(&self.spec().path).is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TerminalKind {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.label())
|
||||
}
|
||||
}
|
||||
|
||||
impl TerminalKind {
|
||||
/// Returns the per-terminal command-line spec.
|
||||
pub fn spec(&self) -> TerminalSpec {
|
||||
match self {
|
||||
// ===== Modern terminals =====
|
||||
//
|
||||
// rs-mrxvt is a Rust rewrite of mrxvt. As of this writing it
|
||||
// doesn't yet implement the `-title` / `-iconic` flags (clap
|
||||
// rejects them as unknown arguments), so we leave `titlepar`
|
||||
// and `iconpar` empty — `build_terminal_wrapper` skips them
|
||||
// when empty, and rs-mrxvt gets just `rs-mrxvt -e <runner>`.
|
||||
//
|
||||
// When rs-mrxvt adds title support (presumably as `--title` or
|
||||
// `-T` or whatever the maintainer prefers), update this spec
|
||||
// and the flags will start being passed automatically.
|
||||
TerminalKind::RsMrxvt => TerminalSpec {
|
||||
path: "rs-mrxvt".into(),
|
||||
titlepar: String::new(),
|
||||
quotepar: String::new(),
|
||||
iconpar: String::new(),
|
||||
execpar: "-e".into(),
|
||||
},
|
||||
TerminalKind::RxvtUnicode => TerminalSpec {
|
||||
path: "urxvt".into(),
|
||||
titlepar: "-title".into(),
|
||||
quotepar: String::new(),
|
||||
iconpar: "-iconic".into(),
|
||||
execpar: "-e".into(),
|
||||
},
|
||||
TerminalKind::Alacritty => TerminalSpec {
|
||||
path: "alacritty".into(),
|
||||
// Alacritty uses `--title` (long flag) and `--command` (or `-e`).
|
||||
titlepar: "--title".into(),
|
||||
quotepar: String::new(),
|
||||
iconpar: String::new(),
|
||||
execpar: "-e".into(),
|
||||
},
|
||||
TerminalKind::Kitty => TerminalSpec {
|
||||
path: "kitty".into(),
|
||||
titlepar: "--title".into(),
|
||||
quotepar: String::new(),
|
||||
iconpar: String::new(),
|
||||
execpar: "-e".into(),
|
||||
},
|
||||
TerminalKind::WezTerm => TerminalSpec {
|
||||
path: "wezterm".into(),
|
||||
titlepar: "--config".into(), // wezterm start --title doesn't exist; we use a workaround
|
||||
quotepar: String::new(),
|
||||
iconpar: String::new(),
|
||||
execpar: "-e".into(),
|
||||
// Note: wezterm's actual CLI is `wezterm start -- TITLE cmd...`,
|
||||
// but for our purposes (build_terminal_wrapper) the simpler
|
||||
// form `wezterm -e CMD` works because wezterm accepts that too.
|
||||
},
|
||||
TerminalKind::Foot => TerminalSpec {
|
||||
path: "foot".into(),
|
||||
titlepar: "-T".into(),
|
||||
quotepar: String::new(),
|
||||
iconpar: String::new(),
|
||||
execpar: "-e".into(),
|
||||
},
|
||||
TerminalKind::GnomeConsole => TerminalSpec {
|
||||
path: "kgx".into(),
|
||||
titlepar: "--title".into(),
|
||||
quotepar: String::new(),
|
||||
iconpar: String::new(),
|
||||
execpar: "-e".into(),
|
||||
// Note: kgx's `-e` is deprecated but still works.
|
||||
},
|
||||
TerminalKind::Xfce4Terminal => TerminalSpec {
|
||||
path: "xfce4-terminal".into(),
|
||||
titlepar: "--title".into(),
|
||||
quotepar: String::new(),
|
||||
iconpar: String::new(),
|
||||
execpar: "-x".into(),
|
||||
},
|
||||
TerminalKind::Tabby => TerminalSpec {
|
||||
// Tabby (formerly Terminus) is Electron-based and its CLI
|
||||
// doesn't have a classic xterm-style `-e` flag. The most
|
||||
// reliable way to run a command in a new Tabby window is
|
||||
// `tabby -- <cmd>...`, where `--` is the POSIX separator
|
||||
// that Tabby's Commander.js CLI respects. Tabby will spawn
|
||||
// the command in its default profile's shell.
|
||||
//
|
||||
// Tabby does not expose a CLI flag to set the window title
|
||||
// (the title comes from the active Tabby profile), so
|
||||
// `titlepar` and `iconpar` are left empty.
|
||||
path: "tabby".into(),
|
||||
titlepar: String::new(),
|
||||
quotepar: String::new(),
|
||||
iconpar: String::new(),
|
||||
execpar: "--".into(),
|
||||
},
|
||||
TerminalKind::Ghostty => TerminalSpec {
|
||||
// Ghostty (Mitchell Hashimoto's terminal, MIT, open-sourced
|
||||
// late 2024) supports the classic xterm-style `-e` flag and
|
||||
// `--title=VALUE` for the window title. We use the
|
||||
// space-separated form `--title VALUE` which Ghostty also
|
||||
// accepts.
|
||||
path: "ghostty".into(),
|
||||
titlepar: "--title".into(),
|
||||
quotepar: String::new(),
|
||||
iconpar: String::new(),
|
||||
execpar: "-e".into(),
|
||||
},
|
||||
TerminalKind::Warp => TerminalSpec {
|
||||
// Warp (AI-first, Rust-based, Apache 2.0 as of early 2025)
|
||||
// installs as `warp-terminal` on Linux. Its CLI accepts
|
||||
// `--title VALUE` and `-e CMD` (xterm-compatible).
|
||||
path: "warp-terminal".into(),
|
||||
titlepar: "--title".into(),
|
||||
quotepar: String::new(),
|
||||
iconpar: String::new(),
|
||||
execpar: "-e".into(),
|
||||
},
|
||||
|
||||
// ===== Original termdefs.txt set (preserved verbatim) =====
|
||||
TerminalKind::Aterm => TerminalSpec {
|
||||
path: "aterm".into(),
|
||||
titlepar: "-title".into(),
|
||||
quotepar: String::new(),
|
||||
iconpar: "-iconic".into(),
|
||||
execpar: "-e".into(),
|
||||
},
|
||||
TerminalKind::Eterm => TerminalSpec {
|
||||
path: "Eterm".into(),
|
||||
titlepar: "--title".into(),
|
||||
quotepar: String::new(),
|
||||
iconpar: "--iconic".into(),
|
||||
execpar: "--exec".into(),
|
||||
},
|
||||
TerminalKind::MultiAterm => TerminalSpec {
|
||||
path: "multi-aterm".into(),
|
||||
titlepar: "-name".into(),
|
||||
quotepar: String::new(),
|
||||
iconpar: "-iconic".into(),
|
||||
execpar: "-exec".into(),
|
||||
},
|
||||
TerminalKind::KdeTerminal => TerminalSpec {
|
||||
path: "konsole".into(),
|
||||
titlepar: "--title".into(),
|
||||
quotepar: String::new(),
|
||||
iconpar: String::new(),
|
||||
execpar: "-e".into(),
|
||||
},
|
||||
TerminalKind::GnomeTerminal => TerminalSpec {
|
||||
path: "gnome-terminal".into(),
|
||||
titlepar: "-t".into(),
|
||||
quotepar: "\"".into(),
|
||||
iconpar: String::new(),
|
||||
execpar: "-e".into(),
|
||||
},
|
||||
TerminalKind::Xterm => TerminalSpec {
|
||||
path: "xterm".into(),
|
||||
titlepar: "-T".into(),
|
||||
quotepar: String::new(),
|
||||
iconpar: "-iconic".into(),
|
||||
execpar: "-e".into(),
|
||||
},
|
||||
TerminalKind::Rxvt => TerminalSpec {
|
||||
path: "rxvt".into(),
|
||||
titlepar: "-title".into(),
|
||||
quotepar: String::new(),
|
||||
iconpar: "-iconic".into(),
|
||||
execpar: "-e".into(),
|
||||
},
|
||||
TerminalKind::Mrxvt => TerminalSpec {
|
||||
path: "mrxvt".into(),
|
||||
titlepar: "-title".into(),
|
||||
quotepar: String::new(),
|
||||
iconpar: "-iconic".into(),
|
||||
execpar: "-e".into(),
|
||||
},
|
||||
TerminalKind::Xvt => TerminalSpec {
|
||||
path: "xvt".into(),
|
||||
titlepar: "-title".into(),
|
||||
quotepar: String::new(),
|
||||
iconpar: "-iconic".into(),
|
||||
execpar: "-e".into(),
|
||||
},
|
||||
TerminalKind::XfceTerminal => TerminalSpec {
|
||||
path: "Terminal".into(),
|
||||
titlepar: "--title".into(),
|
||||
quotepar: String::new(),
|
||||
iconpar: String::new(),
|
||||
execpar: "-x".into(),
|
||||
},
|
||||
TerminalKind::PuttysTerminal => TerminalSpec {
|
||||
path: "pterm".into(),
|
||||
titlepar: "-title".into(),
|
||||
quotepar: String::new(),
|
||||
iconpar: String::new(),
|
||||
execpar: "-e".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-terminal command-line shape. The original secpanel stored these in
|
||||
/// `termdefs.txt` and sourced them into the `termtype()` array.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TerminalSpec {
|
||||
/// Binary name (looked up on `PATH`).
|
||||
pub path: String,
|
||||
/// Flag preceding the terminal window title.
|
||||
pub titlepar: String,
|
||||
/// A quote character (or empty) wrapping the runner file argument.
|
||||
pub quotepar: String,
|
||||
/// Flag (or empty) to start the terminal iconified.
|
||||
pub iconpar: String,
|
||||
/// Flag preceding the command to execute.
|
||||
pub execpar: String,
|
||||
}
|
||||
|
|
@ -0,0 +1,912 @@
|
|||
//! Modal dialogs (rendered as overlay panels within the main window).
|
||||
//!
|
||||
//! We render these as floating panels using a stack-based approach:
|
||||
//! - if a dialog state has `visible == true`, we render the dialog content
|
||||
//! on top of the main view in a centered container with a dimmed
|
||||
//! background.
|
||||
|
||||
use iced::widget::{button, column, container, row, scrollable, text, text_input};
|
||||
use iced::{Alignment, Element, Length, Padding};
|
||||
|
||||
use crate::message::{ConfirmKind, Message};
|
||||
use crate::state::Shellm;
|
||||
use crate::theme_style;
|
||||
|
||||
/// Compose: main content + any visible dialogs layered on top.
|
||||
pub fn overlay<'a>(main: Element<'a, Message>, state: &'a Shellm) -> Element<'a, Message> {
|
||||
// Build all visible dialogs as a vertical stack of centered panels.
|
||||
let mut layers: Vec<Element<Message>> = vec![main];
|
||||
|
||||
if state.message_dialog.visible {
|
||||
layers.push(message_dialog(state));
|
||||
}
|
||||
if state.confirm_dialog.visible {
|
||||
layers.push(confirm_dialog(state));
|
||||
}
|
||||
if state.about_dialog.visible {
|
||||
layers.push(about_dialog(state));
|
||||
}
|
||||
if state.agent_info_dialog.visible {
|
||||
layers.push(agent_info_dialog(state));
|
||||
}
|
||||
if state.key_info_dialog.visible {
|
||||
layers.push(key_info_dialog(state));
|
||||
}
|
||||
if state.hostkeys_dialog.visible {
|
||||
layers.push(hostkeys_dialog(state));
|
||||
}
|
||||
if state.history_dialog.visible {
|
||||
layers.push(history_dialog(state));
|
||||
}
|
||||
if state.trace_dialog.visible {
|
||||
layers.push(trace_dialog(state));
|
||||
}
|
||||
if state.colors_dialog.visible {
|
||||
layers.push(colors_dialog(state));
|
||||
}
|
||||
if state.forwards_dialog.entries.is_empty()
|
||||
&& state.forwards_dialog.kind == crate::message::ForwardKind::Local
|
||||
&& false
|
||||
{
|
||||
// never triggered
|
||||
}
|
||||
if !state.forwards_dialog_kind_is_default() {
|
||||
layers.push(forwards_dialog(state));
|
||||
}
|
||||
if state.keygen_dialog.change_pass.visible {
|
||||
layers.push(change_pass_dialog(state));
|
||||
}
|
||||
if state.key_dist_wizard.visible {
|
||||
layers.push(key_dist_wizard_dialog(state));
|
||||
}
|
||||
if state.keygen_dialog_visible {
|
||||
layers.push(keygen_dialog(state));
|
||||
}
|
||||
if state.ask_user_modal.visible {
|
||||
layers.push(ask_user_modal(state));
|
||||
}
|
||||
if state.export_dialog.visible {
|
||||
layers.push(export_dialog(state));
|
||||
}
|
||||
if state.remote_acct_dialog.visible {
|
||||
layers.push(remote_acct_dialog(state));
|
||||
}
|
||||
if state.protect_data_dialog.visible {
|
||||
layers.push(protect_data_dialog(state));
|
||||
}
|
||||
if state.fonts_dialog.visible {
|
||||
layers.push(fonts_dialog(state));
|
||||
}
|
||||
if state.context_menu.visible {
|
||||
layers.push(context_menu(state));
|
||||
}
|
||||
if state.scp_browser.mkdir_modal.visible {
|
||||
layers.push(scp_mkdir_modal(state));
|
||||
}
|
||||
|
||||
// We can only stack via iced::widget::stack (iced 0.13).
|
||||
iced::widget::stack(layers).into()
|
||||
}
|
||||
|
||||
/// The "Manage keypairs" dialog (`.top52` in the original).
|
||||
fn keygen_dialog(state: &Shellm) -> Element<Message> {
|
||||
let k = &state.keygen_dialog;
|
||||
let mut items: Vec<Element<Message>> = Vec::new();
|
||||
for name in &k.keys_list {
|
||||
items.push(
|
||||
row![
|
||||
text(name.as_str()).size(12).width(Length::Fill),
|
||||
button(text("Info").size(11))
|
||||
.on_press(Message::KeygenShowInfo(name.clone()))
|
||||
.padding(2)
|
||||
.style(theme_style::tk_button),
|
||||
button(text("ChPass").size(11))
|
||||
.on_press(Message::KeygenChangePassStart(name.clone()))
|
||||
.padding(2)
|
||||
.style(theme_style::tk_button),
|
||||
button(text("Del").size(11))
|
||||
.on_press(Message::KeygenDeleteKey(name.clone()))
|
||||
.padding(2)
|
||||
.style(theme_style::danger_button),
|
||||
]
|
||||
.spacing(4)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if items.is_empty() {
|
||||
items.push(text("(no keys found in ~/.ssh/)").size(12).into());
|
||||
}
|
||||
|
||||
let panel = container(
|
||||
column![
|
||||
text("shellm - Key management").size(14),
|
||||
text("Generate new keypair:").size(12),
|
||||
text("Comment:").size(11),
|
||||
text_input("comment", &k.comment).on_input(Message::KeygenCommentChanged).size(12),
|
||||
text("Key file:").size(11),
|
||||
row![
|
||||
text_input("~/.ssh/id_rsa", &k.file).on_input(Message::KeygenFileChanged).size(12),
|
||||
button(text("...").size(11)).on_press(Message::KeygenBrowseFile).padding(4).style(theme_style::tk_button),
|
||||
].spacing(4),
|
||||
row![
|
||||
iced::widget::checkbox("No passphrase", k.no_pass)
|
||||
.on_toggle(Message::KeygenNoPassToggled)
|
||||
.size(12),
|
||||
],
|
||||
text("Passphrase:").size(11),
|
||||
text_input("passphrase", &k.pass1).on_input(Message::KeygenPassChanged).size(12).secure(true),
|
||||
text("Repeat passphrase:").size(11),
|
||||
text_input("repeat", &k.pass2).on_input(Message::KeygenPass2Changed).size(12).secure(true),
|
||||
text("Key type:").size(11),
|
||||
row![
|
||||
button(text("SSH2 DSA").size(11))
|
||||
.on_press(Message::KeygenTypeChanged(crate::data::KeyType::Dsa))
|
||||
.padding(4)
|
||||
.style(if k.key_type == crate::data::KeyType::Dsa { theme_style::tab_button_active } else { theme_style::tk_button }),
|
||||
button(text("SSH2 RSA").size(11))
|
||||
.on_press(Message::KeygenTypeChanged(crate::data::KeyType::Rsa))
|
||||
.padding(4)
|
||||
.style(if k.key_type == crate::data::KeyType::Rsa { theme_style::tab_button_active } else { theme_style::tk_button }),
|
||||
button(text("SSH1 RSA1").size(11))
|
||||
.on_press(Message::KeygenTypeChanged(crate::data::KeyType::Rsa1))
|
||||
.padding(4)
|
||||
.style(if k.key_type == crate::data::KeyType::Rsa1 { theme_style::tab_button_active } else { theme_style::tk_button }),
|
||||
].spacing(4),
|
||||
row![
|
||||
button(text("Generate keypair").size(12)).on_press(Message::KeygenGenerate).padding(4).style(theme_style::tk_button),
|
||||
button(text("Refresh list").size(12)).on_press(Message::KeygenRefreshList).padding(4).style(theme_style::tk_button),
|
||||
button(text("Close").size(12)).on_press(Message::KeygenClose).padding(4).style(theme_style::tk_button),
|
||||
].spacing(4),
|
||||
text("Existing keys:").size(12),
|
||||
scrollable(column(items).spacing(2)).height(Length::Fixed(140.0)),
|
||||
]
|
||||
.spacing(4)
|
||||
.align_x(Alignment::Center),
|
||||
)
|
||||
.padding(16)
|
||||
.style(theme_style::groove_container)
|
||||
.width(Length::Fixed(460.0))
|
||||
.center(Length::Fill);
|
||||
panel.into()
|
||||
}
|
||||
|
||||
fn message_dialog(state: &Shellm) -> Element<Message> {
|
||||
let panel = container(
|
||||
column![
|
||||
text("shellm - Message").size(14),
|
||||
text(state.message_dialog.text.as_str()).size(13),
|
||||
button(text("OK").size(12))
|
||||
.on_press(Message::CloseMessage)
|
||||
.padding(6)
|
||||
.style(theme_style::tk_button),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_x(Alignment::Center),
|
||||
)
|
||||
.padding(16)
|
||||
.style(theme_style::groove_container)
|
||||
.width(Length::Fixed(360.0))
|
||||
.center(Length::Fill);
|
||||
panel.into()
|
||||
}
|
||||
|
||||
fn confirm_dialog(state: &Shellm) -> Element<Message> {
|
||||
let kind = state.confirm_dialog.kind;
|
||||
let yes_msg = Message::Confirm(kind, true);
|
||||
let no_msg = Message::Confirm(kind, false);
|
||||
let panel = container(
|
||||
column![
|
||||
text("shellm - Confirm").size(14),
|
||||
text(state.confirm_dialog.text.as_str()).size(13),
|
||||
row![
|
||||
button(text("Yes").size(12)).on_press(yes_msg).padding(6).style(theme_style::tk_button),
|
||||
button(text("No").size(12)).on_press(no_msg).padding(6).style(theme_style::tk_button),
|
||||
]
|
||||
.spacing(6),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_x(Alignment::Center),
|
||||
)
|
||||
.padding(16)
|
||||
.style(theme_style::groove_container)
|
||||
.width(Length::Fixed(360.0))
|
||||
.center(Length::Fill);
|
||||
let _ = ConfirmKind::Generic; // silence unused
|
||||
panel.into()
|
||||
}
|
||||
|
||||
fn about_dialog(_state: &Shellm) -> Element<Message> {
|
||||
let panel = container(
|
||||
column![
|
||||
text("shellm").size(16),
|
||||
text(format!("Version {}", crate::data::paths::SP_VERSION)).size(13),
|
||||
text("Graphical user interface for managing SSH- and SCP-connections").size(12),
|
||||
text("© Jeremy Anderson — https://dcos.net").size(11),
|
||||
text("Rust/iced port of the original Tcl/Tk secpanel by Steffen Leich-Nienhaus").size(11),
|
||||
text("https://themediahost.de/secpanel/").size(11),
|
||||
button(text("Close").size(12))
|
||||
.on_press(Message::CloseAbout)
|
||||
.padding(6)
|
||||
.style(theme_style::tk_button),
|
||||
]
|
||||
.spacing(4)
|
||||
.align_x(Alignment::Center),
|
||||
)
|
||||
.padding(16)
|
||||
.style(theme_style::groove_container)
|
||||
.width(Length::Fixed(360.0))
|
||||
.center(Length::Fill);
|
||||
panel.into()
|
||||
}
|
||||
|
||||
fn agent_info_dialog(state: &Shellm) -> Element<Message> {
|
||||
let panel = container(
|
||||
column![
|
||||
text("shellm - Agent info").size(14),
|
||||
scrollable(text(state.agent_info_dialog.text.as_str()).size(12))
|
||||
.width(Length::Fixed(560.0))
|
||||
.height(Length::Fixed(180.0)),
|
||||
button(text("Close").size(12))
|
||||
.on_press(Message::CloseAgentInfo)
|
||||
.padding(6)
|
||||
.style(theme_style::tk_button),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_x(Alignment::Center),
|
||||
)
|
||||
.padding(16)
|
||||
.style(theme_style::groove_container)
|
||||
.width(Length::Fixed(600.0))
|
||||
.center(Length::Fill);
|
||||
panel.into()
|
||||
}
|
||||
|
||||
fn key_info_dialog(state: &Shellm) -> Element<Message> {
|
||||
let panel = container(
|
||||
column![
|
||||
text("shellm - View key").size(14),
|
||||
text(format!("Fingerprint: {}", state.key_info_dialog.fingerprint)).size(12),
|
||||
text(format!("Bubblebabble: {}", state.key_info_dialog.bubblebabble)).size(12),
|
||||
button(text("Close").size(12))
|
||||
.on_press(Message::CloseKeyInfo)
|
||||
.padding(6)
|
||||
.style(theme_style::tk_button),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_x(Alignment::Center),
|
||||
)
|
||||
.padding(16)
|
||||
.style(theme_style::groove_container)
|
||||
.width(Length::Fixed(600.0))
|
||||
.center(Length::Fill);
|
||||
panel.into()
|
||||
}
|
||||
|
||||
fn hostkeys_dialog(state: &Shellm) -> Element<Message> {
|
||||
let mut items: Vec<Element<Message>> = Vec::new();
|
||||
for (host, kt) in &state.hostkeys_dialog.entries {
|
||||
items.push(
|
||||
row![
|
||||
text(host.as_str()).size(12).width(Length::Fill),
|
||||
text(kt.as_str()).size(12),
|
||||
button(text("Delete").size(11))
|
||||
.on_press(Message::HostkeyDelete(host.clone()))
|
||||
.padding(2)
|
||||
.style(theme_style::danger_button),
|
||||
]
|
||||
.spacing(6)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if items.is_empty() {
|
||||
items.push(text("(no hostkeys found)").size(12).into());
|
||||
}
|
||||
let panel = container(
|
||||
column![
|
||||
text("shellm - Hostkeys").size(14),
|
||||
scrollable(column(items).spacing(2)).width(Length::Fixed(440.0)).height(Length::Fixed(280.0)),
|
||||
button(text("Close").size(12))
|
||||
.on_press(Message::HostkeyClose)
|
||||
.padding(6)
|
||||
.style(theme_style::tk_button),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_x(Alignment::Center),
|
||||
)
|
||||
.padding(16)
|
||||
.style(theme_style::groove_container)
|
||||
.width(Length::Fixed(480.0))
|
||||
.center(Length::Fill);
|
||||
panel.into()
|
||||
}
|
||||
|
||||
fn history_dialog(state: &Shellm) -> Element<Message> {
|
||||
let mut items: Vec<Element<Message>> = Vec::new();
|
||||
for e in &state.history_dialog.entries {
|
||||
items.push(
|
||||
row![
|
||||
text(format!("[{}]", e.category.label())).size(11),
|
||||
text(format!("{} — {}", e.time.format("%Y-%m-%d %H:%M:%S"), e.text)).size(11),
|
||||
]
|
||||
.spacing(6)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if items.is_empty() {
|
||||
items.push(text("(history is empty)").size(12).into());
|
||||
}
|
||||
let panel = container(
|
||||
column![
|
||||
text("shellm - Command History").size(14),
|
||||
scrollable(column(items).spacing(2)).width(Length::Fixed(560.0)).height(Length::Fixed(320.0)),
|
||||
button(text("Close").size(12))
|
||||
.on_press(Message::CloseHistory)
|
||||
.padding(6)
|
||||
.style(theme_style::tk_button),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_x(Alignment::Center),
|
||||
)
|
||||
.padding(16)
|
||||
.style(theme_style::groove_container)
|
||||
.width(Length::Fixed(600.0))
|
||||
.center(Length::Fill);
|
||||
panel.into()
|
||||
}
|
||||
|
||||
fn trace_dialog(state: &Shellm) -> Element<Message> {
|
||||
let panel = container(
|
||||
column![
|
||||
text("shellm - Command Trace").size(14),
|
||||
scrollable(text(state.trace_dialog.text.as_str()).size(11))
|
||||
.width(Length::Fixed(560.0))
|
||||
.height(Length::Fixed(320.0)),
|
||||
button(text("Close").size(12))
|
||||
.on_press(Message::CloseTrace)
|
||||
.padding(6)
|
||||
.style(theme_style::tk_button),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_x(Alignment::Center),
|
||||
)
|
||||
.padding(16)
|
||||
.style(theme_style::groove_container)
|
||||
.width(Length::Fixed(600.0))
|
||||
.center(Length::Fill);
|
||||
panel.into()
|
||||
}
|
||||
|
||||
fn colors_dialog(state: &Shellm) -> Element<Message> {
|
||||
let panel = container(
|
||||
column![
|
||||
text("shellm - GUI Colors").size(14),
|
||||
row![
|
||||
text("Window fg:").size(12),
|
||||
text_input("#000000", &state.colors_dialog.fore).size(12),
|
||||
].spacing(4),
|
||||
row![
|
||||
text("Window bg:").size(12),
|
||||
text_input("#dcdcd5", &state.colors_dialog.back).size(12),
|
||||
].spacing(4),
|
||||
row![
|
||||
text("Entry fg:").size(12),
|
||||
text_input("#000000", &state.colors_dialog.entfore).size(12),
|
||||
].spacing(4),
|
||||
row![
|
||||
text("Entry bg:").size(12),
|
||||
text_input("#fefefe", &state.colors_dialog.entback).size(12),
|
||||
].spacing(4),
|
||||
row![
|
||||
text("Listbox fg:").size(12),
|
||||
text_input("#000000", &state.colors_dialog.listfore).size(12),
|
||||
].spacing(4),
|
||||
row![
|
||||
text("Listbox bg:").size(12),
|
||||
text_input("#ffffff", &state.colors_dialog.listback).size(12),
|
||||
].spacing(4),
|
||||
row![
|
||||
button(text("Cancel").size(12)).on_press(Message::CloseColors).padding(4).style(theme_style::tk_button),
|
||||
].spacing(4),
|
||||
]
|
||||
.spacing(4)
|
||||
.align_x(Alignment::Center),
|
||||
)
|
||||
.padding(16)
|
||||
.style(theme_style::groove_container)
|
||||
.width(Length::Fixed(420.0))
|
||||
.center(Length::Fill);
|
||||
panel.into()
|
||||
}
|
||||
|
||||
fn forwards_dialog(state: &Shellm) -> Element<Message> {
|
||||
let kind = state.forwards_dialog.kind;
|
||||
let title = match kind {
|
||||
crate::message::ForwardKind::Local => "shellm - Local forwardings",
|
||||
crate::message::ForwardKind::Remote => "shellm - Remote forwardings",
|
||||
};
|
||||
|
||||
let mut items: Vec<Element<Message>> = Vec::new();
|
||||
for (i, (ip, h, op, c)) in state.forwards_dialog.entries.iter().enumerate() {
|
||||
items.push(
|
||||
row![
|
||||
text(format!("{} -> {}:{}", ip, h, op)).size(12).width(Length::Fill),
|
||||
text(c.as_str()).size(11),
|
||||
button(text("Del").size(11))
|
||||
.on_press(Message::DelForward(kind, i))
|
||||
.padding(2)
|
||||
.style(theme_style::danger_button),
|
||||
]
|
||||
.spacing(6)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if items.is_empty() {
|
||||
items.push(text("(no forwards defined)").size(12).into());
|
||||
}
|
||||
|
||||
let new_in = text_input("in port", &state.forwards_dialog.new_in_port).size(12);
|
||||
let new_host = text_input("host (or <TARGET-HOST>/<LOCAL-HOST>)", &state.forwards_dialog.new_host).size(12);
|
||||
let new_out = text_input("out port", &state.forwards_dialog.new_out_port).size(12);
|
||||
let new_cmt = text_input("comment", &state.forwards_dialog.new_comment).size(12);
|
||||
|
||||
// Note: text_input's `on_input` produces per-keystroke updates, but we
|
||||
// don't have a way to bind the dialog's text fields back to state in
|
||||
// this simple model. For a faithful port we'd route through messages
|
||||
// carrying the field name. To keep this MVP focused, we use a single
|
||||
// `AddForward` message that reads the (current) text fields — but since
|
||||
// we can't extract text from a text_input without state, we instead
|
||||
// expect the user to fill them in then click Add. For now this is a
|
||||
// placeholder; a future iteration would add dedicated messages.
|
||||
|
||||
let panel = container(
|
||||
column![
|
||||
text(title).size(14),
|
||||
scrollable(column(items).spacing(2)).width(Length::Fixed(380.0)).height(Length::Fixed(200.0)),
|
||||
column![
|
||||
text("Add new:").size(12),
|
||||
new_in,
|
||||
new_host,
|
||||
new_out,
|
||||
new_cmt,
|
||||
row![
|
||||
text("Quick ports:").size(11),
|
||||
button(text("HTTP 80").size(11)).on_press(Message::InsertProtoPort("80".into())).padding(2).style(theme_style::tk_button),
|
||||
button(text("POP3 110").size(11)).on_press(Message::InsertProtoPort("110".into())).padding(2).style(theme_style::tk_button),
|
||||
button(text("IMAP 143").size(11)).on_press(Message::InsertProtoPort("143".into())).padding(2).style(theme_style::tk_button),
|
||||
button(text("SMTP 25").size(11)).on_press(Message::InsertProtoPort("25".into())).padding(2).style(theme_style::tk_button),
|
||||
].spacing(4),
|
||||
].spacing(4),
|
||||
row![
|
||||
button(text("OK").size(12)).on_press(Message::CloseForwardsDialog).padding(4).style(theme_style::tk_button),
|
||||
button(text("Cancel").size(12)).on_press(Message::CloseForwardsDialog).padding(4).style(theme_style::tk_button),
|
||||
].spacing(4),
|
||||
]
|
||||
.spacing(6)
|
||||
.align_x(Alignment::Center),
|
||||
)
|
||||
.padding(16)
|
||||
.style(theme_style::groove_container)
|
||||
.width(Length::Fixed(420.0))
|
||||
.center(Length::Fill);
|
||||
panel.into()
|
||||
}
|
||||
|
||||
fn change_pass_dialog(state: &Shellm) -> Element<Message> {
|
||||
let cp = &state.keygen_dialog.change_pass;
|
||||
let panel = container(
|
||||
column![
|
||||
text(format!("shellm - Change passphrase for {}", cp.target_key)).size(13),
|
||||
text("Old passphrase:").size(12),
|
||||
text_input("", &cp.old_pass).on_input(Message::KeygenChangePassOldChanged).size(13).secure(true),
|
||||
text("New passphrase:").size(12),
|
||||
text_input("", &cp.new_pass1).on_input(Message::KeygenChangePassNew1Changed).size(13).secure(true),
|
||||
text("Repeat new passphrase:").size(12),
|
||||
text_input("", &cp.new_pass2).on_input(Message::KeygenChangePassNew2Changed).size(13).secure(true),
|
||||
text("Comment:").size(12),
|
||||
text_input("", &cp.comment).on_input(Message::KeygenChangePassCommentChanged).size(13),
|
||||
row![
|
||||
button(text("Cancel").size(12)).on_press(Message::KeygenChangePassClose).padding(4).style(theme_style::tk_button),
|
||||
button(text("Save").size(12)).on_press(Message::KeygenChangePassDo).padding(4).style(theme_style::tk_button),
|
||||
].spacing(4),
|
||||
]
|
||||
.spacing(4)
|
||||
.align_x(Alignment::Center),
|
||||
)
|
||||
.padding(16)
|
||||
.style(theme_style::groove_container)
|
||||
.width(Length::Fixed(420.0))
|
||||
.center(Length::Fill);
|
||||
panel.into()
|
||||
}
|
||||
|
||||
fn key_dist_wizard_dialog(state: &Shellm) -> Element<Message> {
|
||||
let wiz = &state.key_dist_wizard;
|
||||
let step = wiz.step;
|
||||
|
||||
let title = format!("shellm - Key distribution (step {step}/3)");
|
||||
|
||||
let body: Element<Message> = match step {
|
||||
1 => {
|
||||
let mut items: Vec<Element<Message>> = Vec::new();
|
||||
for k in &state.keygen_dialog.keys_list {
|
||||
items.push(
|
||||
button(text(k.as_str()).size(12))
|
||||
.on_press(Message::StatusUpdate(format!("__distkey__{}", k)))
|
||||
.padding(4)
|
||||
.width(Length::Fill)
|
||||
.style(theme_style::tk_button)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
column![
|
||||
text("Select a public key:").size(12),
|
||||
scrollable(column(items).spacing(2)).height(Length::Fixed(180.0)),
|
||||
]
|
||||
.spacing(4)
|
||||
.into()
|
||||
}
|
||||
2 => {
|
||||
let mut items: Vec<Element<Message>> = Vec::new();
|
||||
for (name, p) in state.profiles_in_order() {
|
||||
items.push(
|
||||
button(text(format!("{} — {}", name, p.title)).size(12))
|
||||
.on_press(Message::StatusUpdate(format!("__disthost__{}", name)))
|
||||
.padding(4)
|
||||
.width(Length::Fill)
|
||||
.style(theme_style::tk_button)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
column![
|
||||
text("Select target connection(s):").size(12),
|
||||
scrollable(column(items).spacing(2)).height(Length::Fixed(180.0)),
|
||||
]
|
||||
.spacing(4)
|
||||
.into()
|
||||
}
|
||||
_ => {
|
||||
column![text(wiz.message.as_str()).size(12)].into()
|
||||
}
|
||||
};
|
||||
|
||||
let panel = container(
|
||||
column![
|
||||
text(title).size(14),
|
||||
body,
|
||||
row![
|
||||
button(text("Cancel").size(12)).on_press(Message::KeygenClose).padding(4).style(theme_style::tk_button),
|
||||
{
|
||||
let label: String = if step < 3 { "Continue".into() } else { "Distribute key".into() };
|
||||
let msg = if step < 3 {
|
||||
Message::StatusUpdate("__distnext__".into())
|
||||
} else {
|
||||
Message::StatusUpdate("__distfinish__".into())
|
||||
};
|
||||
let b: iced::widget::Button<Message> = button(text(label).size(12)).on_press(msg).padding(4).style(theme_style::tk_button);
|
||||
let e: Element<Message> = b.into();
|
||||
e
|
||||
}
|
||||
].spacing(4),
|
||||
]
|
||||
.spacing(6)
|
||||
.align_x(Alignment::Center),
|
||||
)
|
||||
.padding(16)
|
||||
.style(theme_style::groove_container)
|
||||
.width(Length::Fixed(440.0))
|
||||
.center(Length::Fill);
|
||||
panel.into()
|
||||
}
|
||||
|
||||
// ===== New dialogs added in the modern port =====
|
||||
|
||||
/// Ask-for-user modal — prompts for a single string value (typically a
|
||||
/// username for `<ASKFORUSER>` profiles). Mirrors `.top21` in the original.
|
||||
fn ask_user_modal(state: &Shellm) -> Element<Message> {
|
||||
let m = &state.ask_user_modal;
|
||||
let panel = container(
|
||||
column![
|
||||
text("Login required").size(14).font(iced::Font {
|
||||
weight: iced::font::Weight::Semibold,
|
||||
..Default::default()
|
||||
}),
|
||||
text(m.prompt.as_str()).size(12).color(theme_style::text_muted()),
|
||||
text_input("username", &m.value)
|
||||
.on_input(Message::AskUserInputChanged)
|
||||
.on_submit(Message::AskUserSubmit)
|
||||
.size(13),
|
||||
row![
|
||||
button(text("Cancel").size(12))
|
||||
.on_press(Message::AskUserCancelRequest)
|
||||
.padding(Padding::new(8.0))
|
||||
.style(theme_style::secondary_button),
|
||||
button(text("Connect").size(12))
|
||||
.on_press(Message::AskUserSubmit)
|
||||
.padding(Padding::new(8.0))
|
||||
.style(theme_style::primary_button),
|
||||
]
|
||||
.spacing(6),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_x(Alignment::Center),
|
||||
)
|
||||
.padding(20)
|
||||
.style(theme_style::modal_panel)
|
||||
.width(Length::Fixed(380.0))
|
||||
.center(Length::Fill);
|
||||
panel.into()
|
||||
}
|
||||
|
||||
/// Profile export dialog.
|
||||
fn export_dialog(state: &Shellm) -> Element<Message> {
|
||||
let d = &state.export_dialog;
|
||||
let formats: Vec<crate::message::ExportFormat> = crate::message::ExportFormat::all().to_vec();
|
||||
let fmt_pick = iced::widget::pick_list(formats, Some(d.format), |f| {
|
||||
Message::ExportProfileFormatChanged(f)
|
||||
});
|
||||
|
||||
let mut body: Vec<Element<Message>> = vec![
|
||||
text("Export profile").size(14).font(iced::Font {
|
||||
weight: iced::font::Weight::Semibold,
|
||||
..Default::default()
|
||||
}).into(),
|
||||
row![
|
||||
text("Format:").size(12).color(theme_style::text_muted()),
|
||||
fmt_pick,
|
||||
].spacing(6).align_y(Alignment::Center).into(),
|
||||
];
|
||||
|
||||
if !d.result_message.is_empty() {
|
||||
body.push(
|
||||
scrollable(text(d.result_message.as_str()).size(11))
|
||||
.width(Length::Fixed(440.0))
|
||||
.height(Length::Fixed(180.0))
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
body.push(
|
||||
row![
|
||||
button(text("Close").size(12))
|
||||
.on_press(Message::ExportProfileClose)
|
||||
.padding(Padding::new(8.0))
|
||||
.style(theme_style::secondary_button),
|
||||
button(text("Generate").size(12))
|
||||
.on_press(Message::ExportProfileDo)
|
||||
.padding(Padding::new(8.0))
|
||||
.style(theme_style::primary_button),
|
||||
]
|
||||
.spacing(6)
|
||||
.into(),
|
||||
);
|
||||
|
||||
let panel = container(column(body).spacing(8).align_x(Alignment::Center))
|
||||
.padding(20)
|
||||
.style(theme_style::modal_panel)
|
||||
.width(Length::Fixed(500.0))
|
||||
.center(Length::Fill);
|
||||
panel.into()
|
||||
}
|
||||
|
||||
/// Remote account manager dialog (`.top40` in the original).
|
||||
fn remote_acct_dialog(state: &Shellm) -> Element<Message> {
|
||||
let d = &state.remote_acct_dialog;
|
||||
let title = format!("Remote Account Manager — {}@{}:{}", d.user, d.host, d.port);
|
||||
|
||||
let panel = container(
|
||||
column![
|
||||
text(title).size(13).font(iced::Font {
|
||||
weight: iced::font::Weight::Semibold,
|
||||
..Default::default()
|
||||
}),
|
||||
text("~/.shosts:").size(11).color(theme_style::text_muted()),
|
||||
text_input("shosts entries (one per line)", &d.shosts)
|
||||
.on_input(Message::RemoteAcctShostsChanged)
|
||||
.size(11),
|
||||
text("~/.ssh/authorized_keys:").size(11).color(theme_style::text_muted()),
|
||||
scrollable(text(d.auth_keys.as_str()).size(11))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fixed(160.0)),
|
||||
text("Edit authorized_keys via the field below:").size(11).color(theme_style::text_muted()),
|
||||
text_input("authorized_keys (paste here)", &d.auth_keys)
|
||||
.on_input(Message::RemoteAcctAuthKeysChanged)
|
||||
.size(11),
|
||||
text(d.status.as_str()).size(11).color(theme_style::text_muted()),
|
||||
row![
|
||||
button(text("Cancel").size(12))
|
||||
.on_press(Message::RemoteAcctCancel)
|
||||
.padding(Padding::new(8.0))
|
||||
.style(theme_style::secondary_button),
|
||||
button(text("Write to remote").size(12))
|
||||
.on_press(Message::RemoteAcctWrite)
|
||||
.padding(Padding::new(8.0))
|
||||
.style(theme_style::primary_button),
|
||||
]
|
||||
.spacing(6),
|
||||
]
|
||||
.spacing(6)
|
||||
.align_x(Alignment::Center),
|
||||
)
|
||||
.padding(20)
|
||||
.style(theme_style::modal_panel)
|
||||
.width(Length::Fixed(540.0))
|
||||
.center(Length::Fill);
|
||||
panel.into()
|
||||
}
|
||||
|
||||
/// GPG data protection dialog.
|
||||
fn protect_data_dialog(state: &Shellm) -> Element<Message> {
|
||||
let d = &state.protect_data_dialog;
|
||||
let panel = container(
|
||||
column![
|
||||
text("Data protection").size(14).font(iced::Font {
|
||||
weight: iced::font::Weight::Semibold,
|
||||
..Default::default()
|
||||
}),
|
||||
text("Enter a passphrase to encrypt ~/.shellm/ using GnuPG (AES256).")
|
||||
.size(11)
|
||||
.color(theme_style::text_muted()),
|
||||
text_input("passphrase", &d.passphrase)
|
||||
.on_input(Message::ProtectDataPassphraseChanged)
|
||||
.secure(true)
|
||||
.size(13),
|
||||
text(d.status.as_str()).size(11).color(theme_style::text_muted()),
|
||||
row![
|
||||
button(text("Encrypt now").size(12))
|
||||
.on_press(Message::ProtectDataEncrypt)
|
||||
.padding(Padding::new(8.0))
|
||||
.style(theme_style::primary_button),
|
||||
button(text("Decrypt").size(12))
|
||||
.on_press(Message::ProtectDataDecrypt)
|
||||
.padding(Padding::new(8.0))
|
||||
.style(theme_style::secondary_button),
|
||||
]
|
||||
.spacing(6),
|
||||
]
|
||||
.spacing(6)
|
||||
.align_x(Alignment::Center),
|
||||
)
|
||||
.padding(20)
|
||||
.style(theme_style::modal_panel)
|
||||
.width(Length::Fixed(420.0))
|
||||
.center(Length::Fill);
|
||||
panel.into()
|
||||
}
|
||||
|
||||
/// Fonts dialog — modern replacement for `.top23`.
|
||||
fn fonts_dialog(state: &Shellm) -> Element<Message> {
|
||||
let d = &state.fonts_dialog;
|
||||
let panel = container(
|
||||
column![
|
||||
text("Font settings").size(14).font(iced::Font {
|
||||
weight: iced::font::Weight::Semibold,
|
||||
..Default::default()
|
||||
}),
|
||||
text("Font family:").size(11).color(theme_style::text_muted()),
|
||||
text_input("e.g. Inter, Sans", &d.fontfam)
|
||||
.on_input(Message::FontsFontFamilyChanged)
|
||||
.size(13),
|
||||
row![
|
||||
text("Size:").size(11).color(theme_style::text_muted()),
|
||||
iced::widget::slider(6..=60, d.fontsize, Message::FontsSizeChanged),
|
||||
text(d.fontsize.to_string()).size(11),
|
||||
].spacing(6).align_y(Alignment::Center),
|
||||
row![
|
||||
iced::widget::checkbox("Bold", d.bold).on_toggle(Message::FontsBoldToggled).size(13),
|
||||
iced::widget::checkbox("Italic", d.italic).on_toggle(Message::FontsItalicToggled).size(13),
|
||||
].spacing(8),
|
||||
iced::widget::checkbox("Use system fonts", d.sysfonts)
|
||||
.on_toggle(Message::FontsSystemToggled)
|
||||
.size(13),
|
||||
row![
|
||||
button(text("Cancel").size(12))
|
||||
.on_press(Message::FontsClose)
|
||||
.padding(Padding::new(8.0))
|
||||
.style(theme_style::secondary_button),
|
||||
button(text("Save").size(12))
|
||||
.on_press(Message::FontsSave)
|
||||
.padding(Padding::new(8.0))
|
||||
.style(theme_style::primary_button),
|
||||
].spacing(6),
|
||||
]
|
||||
.spacing(6)
|
||||
.align_x(Alignment::Center),
|
||||
)
|
||||
.padding(20)
|
||||
.style(theme_style::modal_panel)
|
||||
.width(Length::Fixed(380.0))
|
||||
.center(Length::Fill);
|
||||
panel.into()
|
||||
}
|
||||
|
||||
/// Right-click context menu on the connections list.
|
||||
fn context_menu(_state: &Shellm) -> Element<Message> {
|
||||
let panel = container(
|
||||
column![
|
||||
button(text("Connect").size(12))
|
||||
.on_press(Message::ConnectSelected)
|
||||
.padding(Padding::new(6.0))
|
||||
.width(Length::Fill)
|
||||
.style(theme_style::ghost_button),
|
||||
button(text("Edit").size(12))
|
||||
.on_press(Message::EditSelectedConnection)
|
||||
.padding(Padding::new(6.0))
|
||||
.width(Length::Fill)
|
||||
.style(theme_style::ghost_button),
|
||||
button(text("SCP").size(12))
|
||||
.on_press(Message::ConnectScpSelected)
|
||||
.padding(Padding::new(6.0))
|
||||
.width(Length::Fill)
|
||||
.style(theme_style::ghost_button),
|
||||
button(text("External SFTP").size(12))
|
||||
.on_press(Message::LaunchExternalSftp)
|
||||
.padding(Padding::new(6.0))
|
||||
.width(Length::Fill)
|
||||
.style(theme_style::ghost_button),
|
||||
button(text("Export as...").size(12))
|
||||
.on_press(Message::ExportProfileShow)
|
||||
.padding(Padding::new(6.0))
|
||||
.width(Length::Fill)
|
||||
.style(theme_style::ghost_button),
|
||||
button(text("Manage remote account").size(12))
|
||||
.on_press(Message::RemoteAcctOpen)
|
||||
.padding(Padding::new(6.0))
|
||||
.width(Length::Fill)
|
||||
.style(theme_style::ghost_button),
|
||||
button(text("Delete").size(12))
|
||||
.on_press(Message::DeleteSelectedConnection)
|
||||
.padding(Padding::new(6.0))
|
||||
.width(Length::Fill)
|
||||
.style(theme_style::danger_secondary_button),
|
||||
]
|
||||
.spacing(1),
|
||||
)
|
||||
.padding(4)
|
||||
.style(theme_style::modal_panel)
|
||||
.width(Length::Fixed(220.0))
|
||||
.center(Length::Fill);
|
||||
panel.into()
|
||||
}
|
||||
|
||||
/// SCP browser "new folder" modal — asks for a name and creates the folder
|
||||
/// on either the local or remote side (per `mkdir_modal.side`).
|
||||
fn scp_mkdir_modal(state: &Shellm) -> Element<Message> {
|
||||
let m = &state.scp_browser.mkdir_modal;
|
||||
let side_label = match m.side {
|
||||
crate::state::ScpSide::Local => "local",
|
||||
crate::state::ScpSide::Remote => "remote",
|
||||
};
|
||||
let panel = container(
|
||||
column![
|
||||
text("New folder").size(14).font(iced::Font {
|
||||
weight: iced::font::Weight::Semibold,
|
||||
..Default::default()
|
||||
}),
|
||||
text(format!("Create a new folder on the {} side:", side_label))
|
||||
.size(11)
|
||||
.color(theme_style::text_muted()),
|
||||
text_input("folder name", &m.name)
|
||||
.on_input(Message::ScpMkdirNameChanged)
|
||||
.on_submit(Message::ScpMkdirDo)
|
||||
.size(13),
|
||||
row![
|
||||
button(text("Cancel").size(12))
|
||||
.on_press(Message::ScpMkdirCancel)
|
||||
.padding(Padding::new(8.0))
|
||||
.style(theme_style::secondary_button),
|
||||
button(text("Create").size(12))
|
||||
.on_press(Message::ScpMkdirDo)
|
||||
.padding(Padding::new(8.0))
|
||||
.style(theme_style::primary_button),
|
||||
]
|
||||
.spacing(6),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_x(Alignment::Center),
|
||||
)
|
||||
.padding(20)
|
||||
.style(theme_style::modal_panel)
|
||||
.width(Length::Fixed(380.0))
|
||||
.center(Length::Fill);
|
||||
panel.into()
|
||||
}
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
//! ssh-keygen operations.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::data::{KeyType, Configs};
|
||||
|
||||
/// Result of an ssh-keygen info lookup.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KeyInfo {
|
||||
/// `ssh-keygen -l -f <key>` output (fingerprint line).
|
||||
pub fingerprint: String,
|
||||
/// `ssh-keygen -B -f <key>` output (bubblebabble line).
|
||||
pub bubblebabble: String,
|
||||
}
|
||||
|
||||
/// Look up fingerprint + bubblebabble for a key file.
|
||||
pub fn key_info(key_path: &Path) -> std::io::Result<KeyInfo> {
|
||||
let fp_out = Command::new("ssh-keygen").arg("-l").arg("-f").arg(key_path).output()?;
|
||||
let bb_out = Command::new("ssh-keygen").arg("-B").arg("-f").arg(key_path).output()?;
|
||||
Ok(KeyInfo {
|
||||
fingerprint: String::from_utf8_lossy(&fp_out.stdout).trim().to_string(),
|
||||
bubblebabble: String::from_utf8_lossy(&bb_out.stdout).trim().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Generate a new keypair. `passphrase` may be empty to indicate no passphrase.
|
||||
pub fn generate_keypair(
|
||||
configs: &Configs,
|
||||
key_type: KeyType,
|
||||
key_path: &Path,
|
||||
passphrase: &str,
|
||||
comment: &str,
|
||||
) -> std::io::Result<()> {
|
||||
let mut cmd = Command::new(&configs.keygenbin);
|
||||
cmd.arg("-t").arg(key_type.ssh_keygen_arg());
|
||||
cmd.arg("-f").arg(key_path);
|
||||
if passphrase.is_empty() {
|
||||
cmd.arg("-N").arg("");
|
||||
} else {
|
||||
cmd.arg("-N").arg(passphrase);
|
||||
}
|
||||
if !comment.is_empty() {
|
||||
cmd.arg("-C").arg(comment);
|
||||
}
|
||||
let out = cmd.output()?;
|
||||
if !out.status.success() {
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::Other, err.to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Change passphrase on a key (`ssh-keygen -p -f <key> -P <old> -N <new>`).
|
||||
pub fn change_passphrase(
|
||||
configs: &Configs,
|
||||
key_path: &Path,
|
||||
old_pass: &str,
|
||||
new_pass: &str,
|
||||
) -> std::io::Result<()> {
|
||||
let mut cmd = Command::new(&configs.keygenbin);
|
||||
cmd.arg("-p");
|
||||
cmd.arg("-f").arg(key_path);
|
||||
cmd.arg("-P").arg(old_pass);
|
||||
cmd.arg("-N").arg(new_pass);
|
||||
let out = cmd.output()?;
|
||||
if !out.status.success() {
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::Other, err.to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a keypair (both the private and public file).
|
||||
pub fn delete_keypair(name: &str) -> std::io::Result<()> {
|
||||
let ssh = crate::data::paths::ssh_dir();
|
||||
let priv_p = ssh.join(name);
|
||||
let pub_p = ssh.join(format!("{name}.pub"));
|
||||
if priv_p.exists() {
|
||||
std::fs::remove_file(priv_p)?;
|
||||
}
|
||||
if pub_p.exists() {
|
||||
std::fs::remove_file(pub_p)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all `~/.ssh/*.pub` files (returns the basename without `.pub`).
|
||||
pub fn list_public_keys() -> Vec<String> {
|
||||
let ssh = crate::data::paths::ssh_dir();
|
||||
let mut out = Vec::new();
|
||||
if let Ok(rd) = std::fs::read_dir(&ssh) {
|
||||
for ent in rd.flatten() {
|
||||
let p = ent.path();
|
||||
if p.extension().and_then(|e| e.to_str()) == Some("pub") {
|
||||
if let Some(stem) = p.file_stem().and_then(|s| s.to_str()) {
|
||||
out.push(stem.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// Look up info on a host-key line from `~/.ssh/known_hosts`.
|
||||
pub fn hostkey_info(configs: &Configs, hostname: &str) -> std::io::Result<KeyInfo> {
|
||||
// ssh-keygen -F <host> -l will print the fingerprint if the host is found.
|
||||
let mut cmd = Command::new(&configs.keygenbin);
|
||||
cmd.arg("-F").arg(hostname).arg("-l");
|
||||
let out = cmd.output()?;
|
||||
let fp = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
|
||||
let mut cmd2 = Command::new(&configs.keygenbin);
|
||||
cmd2.arg("-F").arg(hostname).arg("-B");
|
||||
let out2 = cmd2.output()?;
|
||||
let bb = String::from_utf8_lossy(&out2.stdout).trim().to_string();
|
||||
|
||||
Ok(KeyInfo {
|
||||
fingerprint: fp,
|
||||
bubblebabble: bb,
|
||||
})
|
||||
}
|
||||
|
||||
/// Remove a host from `~/.ssh/known_hosts` (`ssh-keygen -R <host>`).
|
||||
pub fn remove_hostkey(configs: &Configs, hostname: &str) -> std::io::Result<()> {
|
||||
let out = Command::new(&configs.keygenbin)
|
||||
.arg("-R")
|
||||
.arg(hostname)
|
||||
.output()?;
|
||||
if !out.status.success() {
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::Other, err.to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse `~/.ssh/known_hosts` and return a list of (hostname, keytype, key)
|
||||
/// tuples. Handles both plain (`host keytype key`) and hashed
|
||||
/// (`|1|hash|hash keytype key`) entries — for hashed entries the hostname is
|
||||
/// returned as `|1|...` (truncated).
|
||||
pub fn read_known_hosts() -> Vec<(String, String)> {
|
||||
let path = crate::data::paths::known_hosts_file();
|
||||
let s = match std::fs::read_to_string(&path) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for line in s.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
// Format: hostnames keytype key [comment]
|
||||
// hostnames may be comma-separated, or hashed (starting with |1|).
|
||||
let mut it = line.splitn(3, char::is_whitespace);
|
||||
let host = it.next().unwrap_or("").to_string();
|
||||
let keytype = it.next().unwrap_or("").to_string();
|
||||
out.push((host, keytype));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Default path for a new key of `key_type` (e.g. `~/.ssh/id_rsa`).
|
||||
pub fn default_key_path(key_type: KeyType) -> PathBuf {
|
||||
crate::data::paths::ssh_dir().join(key_type.default_filename())
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
//! shellm — Rust/iced port of the original Tcl/Tk secpanel 0.6.1.
|
||||
//!
|
||||
//! Graphical user interface for managing SSH- and SFTP-connections.
|
||||
|
||||
#![deny(unsafe_code)]
|
||||
|
||||
mod agent;
|
||||
mod data;
|
||||
mod dialogs;
|
||||
mod keygen;
|
||||
mod message;
|
||||
mod profiles_export;
|
||||
mod scp_browser;
|
||||
mod ssh;
|
||||
mod state;
|
||||
mod theme;
|
||||
mod theme_style;
|
||||
mod update;
|
||||
mod update_actions;
|
||||
mod util;
|
||||
mod view;
|
||||
mod widgets;
|
||||
|
||||
use iced::Theme;
|
||||
|
||||
use crate::message::Message;
|
||||
use crate::state::Shellm;
|
||||
|
||||
fn main() -> iced::Result {
|
||||
env_logger::init();
|
||||
log::info!("shellm {} starting up", data::paths::SP_VERSION);
|
||||
|
||||
// Ensure the ~/.shellm/ tree exists before we try to load configs.
|
||||
let _ = data::paths::ensure_shellm_tree();
|
||||
|
||||
iced::application(
|
||||
move |state: &Shellm| {
|
||||
let suffix = match state.agent_state {
|
||||
crate::agent::AgentState::None => "No Agent",
|
||||
crate::agent::AgentState::Ours => "Agent active",
|
||||
crate::agent::AgentState::External => "Agent active (external)",
|
||||
};
|
||||
format!("shellm - {suffix}")
|
||||
},
|
||||
App::update,
|
||||
App::view,
|
||||
)
|
||||
.theme(|_state| Theme::Light)
|
||||
.window(iced::window::Settings {
|
||||
// Default size chosen to fit the Profiles tab's three-column layout
|
||||
// (profiles list + SSH options + Connection fields) without
|
||||
// truncating the rightmost input fields. The Connections tab and
|
||||
// SCP browser tab also benefit from the extra width.
|
||||
size: iced::Size::new(960.0, 640.0),
|
||||
min_size: Some(iced::Size::new(800.0, 540.0)),
|
||||
resizable: true,
|
||||
decorations: true,
|
||||
..Default::default()
|
||||
})
|
||||
.run_with(|| {
|
||||
let state = Shellm::new();
|
||||
(state, iced::Task::none())
|
||||
})
|
||||
}
|
||||
|
||||
/// Wrapper namespace for the free functions passed to `iced::application`.
|
||||
/// We do this so the call site reads naturally (`App::update`, `App::view`).
|
||||
struct App;
|
||||
|
||||
impl App {
|
||||
fn update(state: &mut Shellm, message: Message) -> iced::Task<Message> {
|
||||
crate::update::update(state, message)
|
||||
}
|
||||
|
||||
fn view(state: &Shellm) -> iced::Element<Message> {
|
||||
crate::view::view(state)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,404 @@
|
|||
//! Centralized message type for the shellm iced app.
|
||||
|
||||
use crate::data::{KeyType, Profile, ScpBackend, TerminalKind, SftpClient};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Which top-level tab is visible on the main window.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Tab {
|
||||
Connections,
|
||||
Profiles,
|
||||
KeysAgent,
|
||||
Configs,
|
||||
ScpBrowser,
|
||||
}
|
||||
|
||||
/// All possible UI events.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Message {
|
||||
/// No-op (used by some widgets that don't need to do anything).
|
||||
None,
|
||||
|
||||
// ---- Tab / window control ----
|
||||
SwitchTab(Tab),
|
||||
ToggleSatellite,
|
||||
Exit,
|
||||
|
||||
// ---- Connections page ----
|
||||
ConnectionSelected(usize),
|
||||
ConnectionActivated(usize),
|
||||
ConnectionRightClick(usize),
|
||||
ConnectSelected,
|
||||
/// Open the SCP browser tab (the default file-transfer mechanism,
|
||||
/// which uses scp+ssh under the hood). This is the primary "file
|
||||
/// transfer" action on the Connections tab and context menu.
|
||||
ConnectScpSelected,
|
||||
/// Launch the configured external SFTP GUI client (FileZilla,
|
||||
/// Nautilus, Konqueror) via an `sftp://` URL. Opt-in alternative
|
||||
/// to the SCP browser for users who prefer a standalone GUI client.
|
||||
LaunchExternalSftp,
|
||||
MultiConnect(MultiConnectMode),
|
||||
NewConnection,
|
||||
EditSelectedConnection,
|
||||
DeleteSelectedConnection,
|
||||
|
||||
// ---- Profiles page ----
|
||||
ProfileSelected(usize),
|
||||
ProfileNameChanged(String),
|
||||
ProfileTitleChanged(String),
|
||||
ProfileHostChanged(String),
|
||||
ProfileUserChanged(String),
|
||||
ProfileAskUserToggled(bool),
|
||||
ProfilePortChanged(String),
|
||||
ProfileCommandChanged(String),
|
||||
ProfileNoExecToggled(bool),
|
||||
ProfileSubsysChanged(String),
|
||||
ProfileIdentityChanged(String),
|
||||
BrowseIdentity,
|
||||
ProfileCfgfileChanged(String),
|
||||
BrowseCfgfile,
|
||||
ProfileAlgoChanged(String),
|
||||
ProfileCompressToggled(bool),
|
||||
ProfileCompressValChanged(u8),
|
||||
ProfileCheckboxChanged(ProfileCheckbox, bool),
|
||||
ProfileSshVerChanged(u8),
|
||||
ProfileIpVerChanged(u8),
|
||||
ProfileGatewayToggled(bool),
|
||||
SaveProfile,
|
||||
DeleteProfile,
|
||||
LoadProfile,
|
||||
NewProfile,
|
||||
|
||||
// ---- Forwards dialogs ----
|
||||
OpenLocalForwards,
|
||||
OpenRemoteForwards,
|
||||
AddForward(ForwardKind, String, String, String, String), // kind, in_port, host, out_port, comment
|
||||
DelForward(ForwardKind, usize),
|
||||
ForwardSelected(ForwardKind, usize),
|
||||
CloseForwardsDialog,
|
||||
InsertProtoPort(String),
|
||||
|
||||
// ---- Keys / Agent page ----
|
||||
OpenKeygenDialog,
|
||||
OpenHostkeysDialog,
|
||||
OpenKeydistWizard,
|
||||
ManageAgent(AgentAction),
|
||||
|
||||
// ---- Configs page ----
|
||||
ConfigBinChanged(ConfigBin, String),
|
||||
BrowseConfigBin(ConfigBin),
|
||||
ConfigTerminalChanged(TerminalKind),
|
||||
ConfigSftpChanged(SftpClient),
|
||||
ConfigScpBackendChanged(ScpBackend),
|
||||
ConfigBrowserChanged(String),
|
||||
SaveConfigs,
|
||||
|
||||
// ---- Dialogs ----
|
||||
AskUserOk(String),
|
||||
AskUserCancel,
|
||||
Confirm(ConfirmKind, bool),
|
||||
ShowMessage(String),
|
||||
CloseMessage,
|
||||
OpenAbout,
|
||||
CloseAbout,
|
||||
OpenHistory,
|
||||
CloseHistory,
|
||||
OpenTrace,
|
||||
CloseTrace,
|
||||
OpenColors,
|
||||
CloseColors,
|
||||
OpenFonts,
|
||||
CloseFonts,
|
||||
ShowAgentInfo,
|
||||
CloseAgentInfo,
|
||||
OpenKeyInfo(PathBuf),
|
||||
CloseKeyInfo,
|
||||
|
||||
// ---- Key generation dialog ----
|
||||
KeygenCommentChanged(String),
|
||||
KeygenFileChanged(String),
|
||||
KeygenBrowseFile,
|
||||
KeygenPassChanged(String),
|
||||
KeygenPass2Changed(String),
|
||||
KeygenNoPassToggled(bool),
|
||||
KeygenTypeChanged(KeyType),
|
||||
KeygenGenerate,
|
||||
KeygenClose,
|
||||
KeygenRefreshList,
|
||||
KeygenShowInfo(String),
|
||||
KeygenDeleteKey(String),
|
||||
KeygenChangePassStart(String),
|
||||
KeygenChangePassOldChanged(String),
|
||||
KeygenChangePassNew1Changed(String),
|
||||
KeygenChangePassNew2Changed(String),
|
||||
KeygenChangePassCommentChanged(String),
|
||||
KeygenChangePassDo,
|
||||
KeygenChangePassClose,
|
||||
|
||||
// ---- Hostkeys dialog ----
|
||||
HostkeyDelete(String),
|
||||
HostkeyClose,
|
||||
|
||||
// ---- Background tasks ----
|
||||
AgentLaunched(Result<crate::agent::AgentInfo, String>),
|
||||
AgentKilled,
|
||||
AgentInfoLoaded(String),
|
||||
IdentityAdded(Result<(), String>),
|
||||
IdentityRemoved(Result<(), String>),
|
||||
KeyInfoLoaded(Result<crate::keygen::KeyInfo, String>),
|
||||
KeyPairGenerated(Result<(), String>),
|
||||
PassphraseChanged(Result<(), String>),
|
||||
HostkeyDeleted(Result<(), String>),
|
||||
StatusUpdate(String),
|
||||
ExternalAgentDetected(Option<crate::agent::AgentInfo>),
|
||||
ProfilesRefreshed(Vec<(String, Profile)>),
|
||||
ConfigsSaved,
|
||||
ProfileSaved(String),
|
||||
ProfileDeleted,
|
||||
HistoryLoaded(Vec<crate::data::history::HistoryEntry>),
|
||||
TraceLoaded(String),
|
||||
LaunchResult(String),
|
||||
|
||||
// ---- Status bar ----
|
||||
ClearStatus,
|
||||
|
||||
// ---- File picker (async) ----
|
||||
/// A file-picker operation completed. The "purpose" tag tells us what to
|
||||
/// do with the resulting path (or `None` if the user cancelled).
|
||||
FilePicked(FilePickPurpose, Option<PathBuf>),
|
||||
|
||||
// ---- Ask-for-user modal ----
|
||||
AskUserShow(String), // open the modal with a prompt
|
||||
AskUserInputChanged(String),
|
||||
AskUserSubmit,
|
||||
AskUserCancelRequest,
|
||||
|
||||
// ---- Key distribution wizard ----
|
||||
KeyDistPickKey(String),
|
||||
KeyDistPickTarget(String),
|
||||
KeyDistNextStep,
|
||||
KeyDistPrevStep,
|
||||
KeyDistFinish,
|
||||
KeyDistCancel,
|
||||
|
||||
// ---- Colors & Fonts dialogs ----
|
||||
ColorsFieldChanged(ColorsField, String),
|
||||
ColorsDefaultToggled(ColorsField, bool),
|
||||
ColorsSave,
|
||||
FontsShow,
|
||||
FontsFontFamilyChanged(String),
|
||||
FontsSizeChanged(u32),
|
||||
FontsBoldToggled(bool),
|
||||
FontsItalicToggled(bool),
|
||||
FontsSystemToggled(bool),
|
||||
FontsSave,
|
||||
FontsClose,
|
||||
|
||||
// ---- Profile export ----
|
||||
ExportProfileShow,
|
||||
ExportProfileFormatChanged(ExportFormat),
|
||||
ExportProfileDo,
|
||||
ExportProfileClose,
|
||||
ExportProfileResult(String),
|
||||
|
||||
// ---- Remote account manager ----
|
||||
RemoteAcctOpen,
|
||||
RemoteAcctShostsChanged(String),
|
||||
RemoteAcctAuthKeysChanged(String),
|
||||
RemoteAcctWrite,
|
||||
RemoteAcctCancel,
|
||||
RemoteAcctReadResult(Result<(String, String), String>), // (shosts, authkeys)
|
||||
RemoteAcctWriteResult(Result<(), String>),
|
||||
|
||||
// ---- GPG data protection ----
|
||||
ProtectDataToggle(bool),
|
||||
ProtectDataPassphraseShow,
|
||||
ProtectDataPassphraseChanged(String),
|
||||
ProtectDataEncrypt,
|
||||
ProtectDataDecrypt,
|
||||
ProtectDataResult(Result<(), String>),
|
||||
|
||||
// ---- Launcher checkbox (configs) ----
|
||||
LauncherToggled(bool),
|
||||
|
||||
// ---- Context menu ----
|
||||
ContextMenuShow(usize),
|
||||
ContextMenuClose,
|
||||
|
||||
// ---- SCP browser (dual-pane) ----
|
||||
ScpConnect(String), // profile name to connect to
|
||||
ScpDisconnect,
|
||||
ScpLocalRefresh,
|
||||
ScpRemoteRefresh,
|
||||
ScpLocalSelect(usize),
|
||||
ScpRemoteSelect(usize),
|
||||
/// Single-click activate: selects the entry, and if it's a directory,
|
||||
/// navigates into it. If the entry is `..`, navigates up.
|
||||
ScpLocalActivate(usize),
|
||||
ScpRemoteActivate(usize),
|
||||
ScpLocalUp,
|
||||
ScpRemoteUp,
|
||||
ScpLocalPathInputChanged(String),
|
||||
ScpRemotePathInputChanged(String),
|
||||
ScpLocalGoTo, // apply path bar
|
||||
ScpRemoteGoTo,
|
||||
ScpUpload, // local -> remote (selected local entry)
|
||||
ScpDownload, // remote -> local (selected remote entry)
|
||||
ScpLocalDelete,
|
||||
ScpRemoteDelete,
|
||||
ScpMkdirShow(crate::state::ScpSide),
|
||||
ScpMkdirNameChanged(String),
|
||||
ScpMkdirDo,
|
||||
ScpMkdirCancel,
|
||||
|
||||
// Async results from scp_browser operations.
|
||||
ScpLocalListingLoaded(Result<Vec<crate::scp_browser::FileEntry>, String>),
|
||||
ScpRemoteListingLoaded(Result<Vec<crate::scp_browser::FileEntry>, String>),
|
||||
ScpOpCompleted(Result<crate::scp_browser::ScpOp, String>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MultiConnectMode {
|
||||
Cssh,
|
||||
Multixterm,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProfileCheckbox {
|
||||
NoAgentForward,
|
||||
NoX11Forward,
|
||||
StrictHost,
|
||||
NoPrivPort,
|
||||
Fork,
|
||||
Quiet,
|
||||
Verbose,
|
||||
ConnWait,
|
||||
TermIcon,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum ForwardKind {
|
||||
#[default]
|
||||
Local,
|
||||
Remote,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AgentAction {
|
||||
Launch,
|
||||
Kill,
|
||||
Info,
|
||||
AddIdentity,
|
||||
RemoveIdentity(usize),
|
||||
SetDefIdent,
|
||||
RmDefIdent,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ConfigBin {
|
||||
Ssh,
|
||||
Keygen,
|
||||
Agent,
|
||||
Add,
|
||||
Askpass,
|
||||
Scp,
|
||||
Browser,
|
||||
}
|
||||
|
||||
impl ConfigBin {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
ConfigBin::Ssh => "SSH",
|
||||
ConfigBin::Keygen => "SSH-Keygen",
|
||||
ConfigBin::Agent => "SSH-Agent",
|
||||
ConfigBin::Add => "SSH-Add",
|
||||
ConfigBin::Askpass => "SSH-Askpass",
|
||||
ConfigBin::Scp => "SCP",
|
||||
ConfigBin::Browser => "WWW browser",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all() -> &'static [ConfigBin] {
|
||||
&[
|
||||
ConfigBin::Ssh,
|
||||
ConfigBin::Keygen,
|
||||
ConfigBin::Agent,
|
||||
ConfigBin::Add,
|
||||
ConfigBin::Askpass,
|
||||
ConfigBin::Scp,
|
||||
ConfigBin::Browser,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum ConfirmKind {
|
||||
DeleteConnection,
|
||||
DeleteProfile,
|
||||
DeleteKey,
|
||||
DeleteHostkey,
|
||||
OverwriteKey,
|
||||
Exit,
|
||||
#[default]
|
||||
Generic,
|
||||
}
|
||||
|
||||
/// What a file-picker invocation is for. Tagged onto `Message::FilePicked`
|
||||
/// so the update function knows what to do with the chosen path.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FilePickPurpose {
|
||||
ProfileIdentity,
|
||||
ProfileCfgfile,
|
||||
ConfigBin(ConfigBin),
|
||||
KeygenFile,
|
||||
AddIdentity,
|
||||
SetDefIdent,
|
||||
DistributeKeyPick,
|
||||
}
|
||||
|
||||
/// Color field tag for the Colors dialog.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ColorsField {
|
||||
Fore,
|
||||
Back,
|
||||
EntFore,
|
||||
EntBack,
|
||||
ListFore,
|
||||
ListBack,
|
||||
}
|
||||
|
||||
/// Profile export format.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum ExportFormat {
|
||||
#[default]
|
||||
ShellScript,
|
||||
SshConfig,
|
||||
GnomeIcon,
|
||||
KdeIcon,
|
||||
}
|
||||
|
||||
impl ExportFormat {
|
||||
pub fn all() -> &'static [ExportFormat] {
|
||||
&[
|
||||
ExportFormat::ShellScript,
|
||||
ExportFormat::SshConfig,
|
||||
ExportFormat::GnomeIcon,
|
||||
ExportFormat::KdeIcon,
|
||||
]
|
||||
}
|
||||
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
ExportFormat::ShellScript => "Shell script (.sh)",
|
||||
ExportFormat::SshConfig => "SSH config file entry",
|
||||
ExportFormat::GnomeIcon => "GNOME desktop icon",
|
||||
ExportFormat::KdeIcon => "KDE desktop icon",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ExportFormat {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.label())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
//! Profile export — produces a self-contained shell script, an SSH config
|
||||
//! file entry, or a desktop launcher (.desktop file) for a given profile.
|
||||
|
||||
use crate::data::{profile::Algo, Configs, Profile};
|
||||
use crate::message::ExportFormat;
|
||||
use crate::ssh::command::shell_quote;
|
||||
use std::fmt::Write as _;
|
||||
|
||||
/// Produce the export text for `profile` in the given `format`.
|
||||
pub fn export_profile(profile: &Profile, configs: &Configs, format: ExportFormat) -> String {
|
||||
match format {
|
||||
ExportFormat::ShellScript => export_shell_script(profile, configs),
|
||||
ExportFormat::SshConfig => export_ssh_config(profile),
|
||||
ExportFormat::GnomeIcon | ExportFormat::KdeIcon => export_desktop_file(profile, format),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a `#!/bin/sh` wrapper script that invokes ssh with the profile's
|
||||
/// options. Mirrors the original `export` proc with `form=="sh"`.
|
||||
fn export_shell_script(profile: &Profile, configs: &Configs) -> String {
|
||||
let local_host = crate::state::hostname();
|
||||
let cmd = crate::ssh::build_ssh_command(profile, configs, &local_host);
|
||||
let mut out = String::new();
|
||||
let _ = writeln!(out, "#!/bin/sh");
|
||||
let _ = writeln!(out, "# shellm-generated shell wrapper for profile: {}", profile.title);
|
||||
let _ = writeln!(out, "# Run this script to connect to {}@{}:{}", profile.user, profile.host, profile.port);
|
||||
let _ = writeln!(out);
|
||||
let _ = writeln!(out, "exec {}", cmd.to_shell_line());
|
||||
out
|
||||
}
|
||||
|
||||
/// Build an SSH config-file entry. SSH config doesn't support every option
|
||||
/// shellm profiles do, so we map the most important ones.
|
||||
fn export_ssh_config(profile: &Profile) -> String {
|
||||
let mut out = String::new();
|
||||
let _ = writeln!(out, "# shellm-generated SSH config entry for: {}", profile.title);
|
||||
// Use the profile title (sanitized) as the Host alias.
|
||||
let alias = sanitize_alias(&profile.title);
|
||||
let _ = writeln!(out, "Host {alias}");
|
||||
let _ = writeln!(out, " HostName {}", profile.host);
|
||||
if !profile.user.is_empty() {
|
||||
let _ = writeln!(out, " User {}", profile.user);
|
||||
}
|
||||
if profile.port != 22 {
|
||||
let _ = writeln!(out, " Port {}", profile.port);
|
||||
}
|
||||
if !profile.identity.as_os_str().is_empty() {
|
||||
let _ = writeln!(out, " IdentityFile {}", profile.identity.to_string_lossy());
|
||||
}
|
||||
if !profile.cfgfile.as_os_str().is_empty() {
|
||||
// ssh -F takes a config file; we can't put it inside the config file
|
||||
// itself, but we can note it as a comment.
|
||||
let _ = writeln!(out, " # CustomConfigFile {}", profile.cfgfile.to_string_lossy());
|
||||
}
|
||||
let _ = writeln!(out, " ForwardAgent {}", if profile.noagentforward { "no" } else { "yes" });
|
||||
let _ = writeln!(out, " ForwardX11 {}", if profile.x11forward { "no" } else { "yes" });
|
||||
if profile.compress {
|
||||
let _ = writeln!(out, " Compression yes");
|
||||
let _ = writeln!(out, " CompressionLevel {}", profile.compressval);
|
||||
}
|
||||
if profile.stricthost {
|
||||
let _ = writeln!(out, " StrictHostKeyChecking yes");
|
||||
}
|
||||
if profile.gateway {
|
||||
let _ = writeln!(out, " GatewayPorts yes");
|
||||
}
|
||||
if profile.algo != Algo::Default {
|
||||
let _ = writeln!(out, " Cipher {}", profile.algo.as_str());
|
||||
}
|
||||
if !profile.command.is_empty() && !profile.noexec {
|
||||
let _ = writeln!(out, " RemoteCommand {}", profile.command);
|
||||
let _ = writeln!(out, " RequestTTY yes");
|
||||
}
|
||||
if profile.noexec {
|
||||
let _ = writeln!(out, " RequestTTY no");
|
||||
}
|
||||
// Local forwards.
|
||||
for lf in &profile.lfs {
|
||||
let host = if lf.host == "<TARGET-HOST>" {
|
||||
profile.host.clone()
|
||||
} else {
|
||||
lf.host.clone()
|
||||
};
|
||||
let _ = writeln!(out, " LocalForward {} {}:{}", lf.listen_port, host, lf.target_port);
|
||||
}
|
||||
// Remote forwards.
|
||||
let local_host = crate::state::hostname();
|
||||
for rf in &profile.rfs {
|
||||
let host = if rf.host == "<LOCAL-HOST>" {
|
||||
local_host.clone()
|
||||
} else {
|
||||
rf.host.clone()
|
||||
};
|
||||
let _ = writeln!(out, " RemoteForward {} {}:{}", rf.listen_port, host, rf.target_port);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Build a `.desktop` launcher file.
|
||||
fn export_desktop_file(profile: &Profile, format: ExportFormat) -> String {
|
||||
let local_host = crate::state::hostname();
|
||||
// For the .desktop file we synthesize a one-line ssh command. We can't
|
||||
// easily run a runner-script here, so we build the ssh command directly.
|
||||
let configs = Configs::default();
|
||||
let cmd = crate::ssh::build_ssh_command(profile, &configs, &local_host);
|
||||
let ssh_line = cmd.to_shell_line();
|
||||
|
||||
let mut out = String::new();
|
||||
let _ = writeln!(out, "[Desktop Entry]");
|
||||
let _ = writeln!(out, "Version=1.0");
|
||||
let _ = writeln!(out, "Type=Application");
|
||||
let _ = writeln!(out, "Name=SSH: {}", profile.title);
|
||||
let _ = writeln!(out, "Comment=Connect to {}@{}:{}", profile.user, profile.host, profile.port);
|
||||
let _ = writeln!(out, "Icon=utilities-terminal");
|
||||
let _ = writeln!(out, "Exec=sh -c {}", shell_quote(&ssh_line));
|
||||
let _ = writeln!(out, "Terminal=true");
|
||||
let _ = writeln!(out, "Categories=Network;RemoteAccess;");
|
||||
if matches!(format, ExportFormat::KdeIcon) {
|
||||
let _ = writeln!(out, "X-KDE-SubstituteUID=false");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn sanitize_alias(s: &str) -> String {
|
||||
s.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
|
||||
.collect::<String>()
|
||||
.trim_matches('_')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::data::Profile;
|
||||
|
||||
#[test]
|
||||
fn shell_script_includes_ssh_command() {
|
||||
let mut p = Profile::default();
|
||||
p.title = "Test".into();
|
||||
p.host = "example.com".into();
|
||||
p.user = "root".into();
|
||||
let c = Configs::default();
|
||||
let out = export_shell_script(&p, &c);
|
||||
assert!(out.starts_with("#!/bin/sh"));
|
||||
assert!(out.contains("exec ssh"));
|
||||
assert!(out.contains("example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_config_includes_host_alias() {
|
||||
let mut p = Profile::default();
|
||||
p.title = "My Server".into();
|
||||
p.host = "example.com".into();
|
||||
p.user = "root".into();
|
||||
p.port = 2222;
|
||||
let out = export_ssh_config(&p);
|
||||
assert!(out.contains("Host My_Server"));
|
||||
assert!(out.contains("HostName example.com"));
|
||||
assert!(out.contains("Port 2222"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,637 @@
|
|||
//! Dual-pane SCP browser — file operations using `scp` (and `ssh` for remote
|
||||
//! file ops by default; `sftp -b -` is opt-in via `ScpBackend`).
|
||||
//!
|
||||
//! We deliberately shell out to the OpenSSH command-line tools (`ssh`,
|
||||
//! `scp`, optionally `sftp`) rather than pulling in a Rust SFTP library.
|
||||
//! This keeps the build light and matches shellm's existing "shell out to
|
||||
//! ssh tools" pattern.
|
||||
//!
|
||||
//! ## Backend choice
|
||||
//!
|
||||
//! `ScpBackend::Scp` (default) uses `ssh host -- ls -la` / `mkdir -p` / `rm -rf`
|
||||
//! for remote file operations and `scp` for transfers. No `sftp` binary is
|
||||
//! required. Modern `scp` (OpenSSH 9.0+) uses the SFTP protocol under the
|
||||
//! hood for transfers, so this is effectively "SFTP under the hood" without
|
||||
//! the separate binary dependency.
|
||||
//!
|
||||
//! `ScpBackend::Sftp` (opt-in) uses `sftp -b -` with batch scripts for remote
|
||||
//! file ops. Required on restricted sshd configs where shell access is
|
||||
//! disabled but the sftp subsystem is allowed (e.g.
|
||||
//! `Match User ... ForceCommand internal-sftp`).
|
||||
//!
|
||||
//! All operations are blocking (they spawn subprocesses), so callers should
|
||||
//! run them inside `tokio::task::spawn_blocking`.
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use crate::data::config::ScpBackend;
|
||||
|
||||
/// One entry in a directory listing.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct FileEntry {
|
||||
pub name: String,
|
||||
pub is_dir: bool,
|
||||
pub is_symlink: bool,
|
||||
pub size: u64,
|
||||
pub modified: String,
|
||||
pub permissions: String,
|
||||
}
|
||||
|
||||
impl FileEntry {
|
||||
pub fn is_parent(&self) -> bool {
|
||||
self.name == ".."
|
||||
}
|
||||
|
||||
/// Render size as a human-friendly string (B / KB / MB / GB).
|
||||
pub fn display_size(&self) -> String {
|
||||
if self.is_dir {
|
||||
return String::new();
|
||||
}
|
||||
let s = self.size;
|
||||
if s < 1024 {
|
||||
format!("{s} B")
|
||||
} else if s < 1024 * 1024 {
|
||||
format!("{:.1} KB", s as f64 / 1024.0)
|
||||
} else if s < 1024 * 1024 * 1024 {
|
||||
format!("{:.1} MB", s as f64 / (1024.0 * 1024.0))
|
||||
} else {
|
||||
format!("{:.1} GB", s as f64 / (1024.0 * 1024.0 * 1024.0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What kind of operation just completed. Used by the message layer to
|
||||
/// know which listing to refresh after an op.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ScpOp {
|
||||
Upload,
|
||||
Download,
|
||||
DeleteLocal,
|
||||
DeleteRemote,
|
||||
MkdirLocal,
|
||||
MkdirRemote,
|
||||
}
|
||||
|
||||
// ====================== Local filesystem ======================
|
||||
|
||||
/// List a local directory. Returns entries sorted directories-first then
|
||||
/// alphabetically. Always prepends a `..` entry (unless we're at the
|
||||
/// filesystem root).
|
||||
pub fn list_local(path: &Path) -> Result<Vec<FileEntry>, String> {
|
||||
let mut entries: Vec<FileEntry> = Vec::new();
|
||||
|
||||
if path.parent().is_some() {
|
||||
entries.push(parent_entry());
|
||||
}
|
||||
|
||||
let rd = std::fs::read_dir(path).map_err(|e| format!("read_dir {}: {e}", path.display()))?;
|
||||
let mut dirs: Vec<FileEntry> = Vec::new();
|
||||
let mut files: Vec<FileEntry> = Vec::new();
|
||||
|
||||
for ent in rd {
|
||||
let ent = match ent {
|
||||
Ok(e) => e,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let name = ent.file_name().to_string_lossy().into_owned();
|
||||
let metadata = match ent.metadata() {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let is_dir = metadata.is_dir();
|
||||
let is_symlink = metadata.file_type().is_symlink();
|
||||
let size = if is_dir { 0 } else { metadata.len() };
|
||||
let modified = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.and_then(|d| chrono::DateTime::<chrono::Utc>::from_timestamp(d.as_secs() as i64, 0))
|
||||
.map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
|
||||
.unwrap_or_default();
|
||||
let permissions = format_permissions(&metadata);
|
||||
|
||||
let entry = FileEntry {
|
||||
name,
|
||||
is_dir,
|
||||
is_symlink,
|
||||
size,
|
||||
modified,
|
||||
permissions,
|
||||
};
|
||||
if is_dir {
|
||||
dirs.push(entry);
|
||||
} else {
|
||||
files.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
dirs.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
files.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
entries.extend(dirs);
|
||||
entries.extend(files);
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn format_permissions(metadata: &std::fs::Metadata) -> String {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = metadata.permissions().mode();
|
||||
let mut s = String::with_capacity(10);
|
||||
s.push(if metadata.is_dir() { 'd' } else { '-' });
|
||||
s.push(if mode & 0o400 != 0 { 'r' } else { '-' });
|
||||
s.push(if mode & 0o200 != 0 { 'w' } else { '-' });
|
||||
s.push(if mode & 0o100 != 0 { 'x' } else { '-' });
|
||||
s.push(if mode & 0o040 != 0 { 'r' } else { '-' });
|
||||
s.push(if mode & 0o020 != 0 { 'w' } else { '-' });
|
||||
s.push(if mode & 0o010 != 0 { 'x' } else { '-' });
|
||||
s.push(if mode & 0o004 != 0 { 'r' } else { '-' });
|
||||
s.push(if mode & 0o002 != 0 { 'w' } else { '-' });
|
||||
s.push(if mode & 0o001 != 0 { 'x' } else { '-' });
|
||||
s
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn format_permissions(_metadata: &std::fs::Metadata) -> String {
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// Make a directory on the local filesystem.
|
||||
pub fn mkdir_local(parent: &Path, name: &str) -> Result<(), String> {
|
||||
let target = parent.join(name);
|
||||
std::fs::create_dir_all(&target).map_err(|e| format!("mkdir {}: {e}", target.display()))
|
||||
}
|
||||
|
||||
/// Delete a local file or directory (recursively).
|
||||
pub fn delete_local(path: &Path) -> Result<(), String> {
|
||||
if path.is_dir() {
|
||||
std::fs::remove_dir_all(path).map_err(|e| format!("rmdir {}: {e}", path.display()))
|
||||
} else {
|
||||
std::fs::remove_file(path).map_err(|e| format!("rm {}: {e}", path.display()))
|
||||
}
|
||||
}
|
||||
|
||||
// ====================== Remote filesystem ======================
|
||||
|
||||
/// Build the `user@host` target string used by both `ssh` and `sftp`.
|
||||
fn remote_target(user: &str, host: &str) -> String {
|
||||
format!("{user}@{host}")
|
||||
}
|
||||
|
||||
/// Run an sftp batch session. The batch string is fed to sftp's stdin.
|
||||
fn run_sftp_batch(host: &str, port: u16, user: &str, batch: &str) -> Result<String, String> {
|
||||
let mut cmd = Command::new("sftp");
|
||||
cmd.arg("-P").arg(port.to_string());
|
||||
cmd.arg("-b").arg("-");
|
||||
// -q suppresses the "Connected to..." progress chatter.
|
||||
cmd.arg("-q");
|
||||
cmd.arg(remote_target(user, host));
|
||||
cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
|
||||
let mut child = cmd.spawn().map_err(|e| format!("spawn sftp: {e}"))?;
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
let _ = stdin.write_all(batch.as_bytes());
|
||||
}
|
||||
let output = child.wait_with_output().map_err(|e| format!("sftp wait: {e}"))?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
return Err(format!(
|
||||
"sftp exited with status {}: stderr={stderr}, stdout={stdout}",
|
||||
output.status
|
||||
));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
|
||||
}
|
||||
|
||||
/// Run `ssh -p <port> user@host -- <cmd>`. Returns stdout on success.
|
||||
fn run_ssh_exec(sshbin: &str, host: &str, port: u16, user: &str, cmd: &str) -> Result<String, String> {
|
||||
let out = Command::new(sshbin)
|
||||
.arg("-p").arg(port.to_string())
|
||||
.arg(remote_target(user, host))
|
||||
.arg("--")
|
||||
.arg(cmd)
|
||||
.output()
|
||||
.map_err(|e| format!("spawn ssh: {e}"))?;
|
||||
if !out.status.success() {
|
||||
return Err(format!(
|
||||
"ssh exited with status {}: {}",
|
||||
out.status,
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||
}
|
||||
|
||||
/// List a remote directory.
|
||||
///
|
||||
/// Dispatches on `backend`:
|
||||
/// - `ScpBackend::Scp` (default): `ssh host -- ls -la <path>`
|
||||
/// - `ScpBackend::Sftp`: `sftp -b -` with `cd <path>\nls -la\nbye`
|
||||
pub fn list_remote(
|
||||
backend: ScpBackend,
|
||||
sshbin: &str,
|
||||
host: &str,
|
||||
port: u16,
|
||||
user: &str,
|
||||
path: &str,
|
||||
) -> Result<Vec<FileEntry>, String> {
|
||||
match backend {
|
||||
ScpBackend::Scp => {
|
||||
// Use `ls -la` via ssh. The path is shell-single-quoted so
|
||||
// paths with spaces or shell metacharacters survive. The `--`
|
||||
// separator before the command protects against paths that
|
||||
// start with `-` being interpreted as ls flags.
|
||||
let cmd = format!("ls -la -- {}", shell_single_quote(path));
|
||||
let output = run_ssh_exec(sshbin, host, port, user, &cmd)?;
|
||||
parse_ls_output(&output, "ssh")
|
||||
}
|
||||
ScpBackend::Sftp => {
|
||||
let batch = format!("cd {path}\nls -la\nbye\n");
|
||||
let output = run_sftp_batch(host, port, user, &batch)?;
|
||||
parse_ls_output(&output, "sftp")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `ls -la` output from either `ssh host -- ls -la` or `sftp -b -`.
|
||||
/// The format is essentially the same; only the noise lines we strip
|
||||
/// differ slightly.
|
||||
fn parse_ls_output(output: &str, source: &str) -> Result<Vec<FileEntry>, String> {
|
||||
let mut dirs: Vec<FileEntry> = Vec::new();
|
||||
let mut files: Vec<FileEntry> = Vec::new();
|
||||
let mut saw_any = false;
|
||||
|
||||
for line in output.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Strip sftp prompt noise.
|
||||
if source == "sftp" {
|
||||
if line.starts_with("sftp>")
|
||||
|| line.starts_with("Connected to")
|
||||
|| line.starts_with("Fetching")
|
||||
|| line.starts_with("Uploading")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Both sources emit a `total NNN` summary line — skip it.
|
||||
if line.starts_with("total ") {
|
||||
continue;
|
||||
}
|
||||
if let Some(entry) = parse_ls_line(line) {
|
||||
saw_any = true;
|
||||
if entry.is_dir {
|
||||
dirs.push(entry);
|
||||
} else {
|
||||
files.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !saw_any {
|
||||
return Err(format!(
|
||||
"{source} returned no entries (path may not exist or be empty)"
|
||||
));
|
||||
}
|
||||
|
||||
dirs.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
files.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
|
||||
let mut out: Vec<FileEntry> = Vec::with_capacity(dirs.len() + files.len() + 1);
|
||||
out.push(parent_entry());
|
||||
out.extend(dirs);
|
||||
out.extend(files);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The synthetic `..` parent entry we prepend to every listing.
|
||||
fn parent_entry() -> FileEntry {
|
||||
FileEntry {
|
||||
name: "..".to_string(),
|
||||
is_dir: true,
|
||||
is_symlink: false,
|
||||
size: 0,
|
||||
modified: String::new(),
|
||||
permissions: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse one line of `ls -la` output.
|
||||
fn parse_ls_line(line: &str) -> Option<FileEntry> {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() < 9 {
|
||||
return None;
|
||||
}
|
||||
let perms = parts[0];
|
||||
if perms.is_empty() || perms.len() < 10 {
|
||||
return None;
|
||||
}
|
||||
let is_dir = perms.starts_with('d');
|
||||
let is_symlink = perms.starts_with('l');
|
||||
|
||||
let size: u64 = parts[4].parse().unwrap_or(0);
|
||||
|
||||
let modified = if parts.len() >= 8 {
|
||||
format!("{} {} {}", parts[5], parts[6], parts[7])
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let name_start = 8;
|
||||
let mut name_parts: Vec<&str> = parts[name_start..].to_vec();
|
||||
if is_symlink {
|
||||
if let Some(arrow_idx) = name_parts.iter().position(|&s| s == "->") {
|
||||
name_parts.truncate(arrow_idx);
|
||||
}
|
||||
}
|
||||
let name = name_parts.join(" ");
|
||||
if name.is_empty() || name == "." || name == ".." {
|
||||
return None;
|
||||
}
|
||||
Some(FileEntry {
|
||||
name,
|
||||
is_dir,
|
||||
is_symlink,
|
||||
size,
|
||||
modified,
|
||||
permissions: perms.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Upload a single file (or directory recursively) via `scp`.
|
||||
pub fn upload(scpbin: &str, host: &str, port: u16, user: &str, local: &Path, remote: &str) -> Result<(), String> {
|
||||
let target = format!("{}@{}:{}", user, host, remote);
|
||||
let is_dir = local.is_dir();
|
||||
let mut cmd = Command::new(scpbin);
|
||||
cmd.arg("-P").arg(port.to_string());
|
||||
if is_dir {
|
||||
cmd.arg("-r");
|
||||
}
|
||||
cmd.arg(local).arg(&target);
|
||||
let out = cmd.output().map_err(|e| format!("spawn scp: {e}"))?;
|
||||
if !out.status.success() {
|
||||
return Err(format!(
|
||||
"scp upload failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Download a single file (or directory recursively) via `scp`.
|
||||
pub fn download(scpbin: &str, host: &str, port: u16, user: &str, remote: &str, local: &Path) -> Result<(), String> {
|
||||
let source = format!("{}@{}:{}", user, host, remote);
|
||||
let out = Command::new(scpbin)
|
||||
.arg("-r")
|
||||
.arg("-P").arg(port.to_string())
|
||||
.arg(&source)
|
||||
.arg(local)
|
||||
.output()
|
||||
.map_err(|e| format!("spawn scp: {e}"))?;
|
||||
if !out.status.success() {
|
||||
return Err(format!(
|
||||
"scp download failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a remote file.
|
||||
///
|
||||
/// Dispatches on `backend`:
|
||||
/// - `ScpBackend::Scp` (default): `ssh host -- rm -f -- <path>`
|
||||
/// - `ScpBackend::Sftp`: `sftp -b -` with `rm "<path>"`
|
||||
pub fn delete_remote_file(
|
||||
backend: ScpBackend,
|
||||
sshbin: &str,
|
||||
host: &str,
|
||||
port: u16,
|
||||
user: &str,
|
||||
path: &str,
|
||||
) -> Result<(), String> {
|
||||
match backend {
|
||||
ScpBackend::Scp => {
|
||||
let cmd = format!("rm -f -- {}", shell_single_quote(path));
|
||||
let _ = run_ssh_exec(sshbin, host, port, user, &cmd)?;
|
||||
}
|
||||
ScpBackend::Sftp => {
|
||||
let batch = format!("rm \"{path}\"\nbye\n");
|
||||
let _ = run_sftp_batch(host, port, user, &batch)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Recursively delete a remote directory.
|
||||
///
|
||||
/// Dispatches on `backend`:
|
||||
/// - `ScpBackend::Scp` (default): `ssh host -- rm -rf -- <path>`
|
||||
/// - `ScpBackend::Sftp`: `sftp -b -` with `rm "<path>"` (OpenSSH ≥ 5.4
|
||||
/// supports `rm -r` semantics for directories when the path is a dir)
|
||||
pub fn delete_remote_dir_recursive(
|
||||
backend: ScpBackend,
|
||||
sshbin: &str,
|
||||
host: &str,
|
||||
port: u16,
|
||||
user: &str,
|
||||
path: &str,
|
||||
) -> Result<(), String> {
|
||||
match backend {
|
||||
ScpBackend::Scp => {
|
||||
let cmd = format!("rm -rf -- {}", shell_single_quote(path));
|
||||
let _ = run_ssh_exec(sshbin, host, port, user, &cmd)?;
|
||||
}
|
||||
ScpBackend::Sftp => {
|
||||
let batch = format!("rm \"{path}\"\nbye\n");
|
||||
let _ = run_sftp_batch(host, port, user, &batch)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Make a directory on the remote side.
|
||||
///
|
||||
/// Dispatches on `backend`:
|
||||
/// - `ScpBackend::Scp` (default): `ssh host -- mkdir -p -- <path>`
|
||||
/// - `ScpBackend::Sftp`: `sftp -b -` with `mkdir "<path>"`
|
||||
pub fn mkdir_remote(
|
||||
backend: ScpBackend,
|
||||
sshbin: &str,
|
||||
host: &str,
|
||||
port: u16,
|
||||
user: &str,
|
||||
path: &str,
|
||||
) -> Result<(), String> {
|
||||
match backend {
|
||||
ScpBackend::Scp => {
|
||||
let cmd = format!("mkdir -p -- {}", shell_single_quote(path));
|
||||
let _ = run_ssh_exec(sshbin, host, port, user, &cmd)?;
|
||||
}
|
||||
ScpBackend::Sftp => {
|
||||
let batch = format!("mkdir \"{path}\"\nbye\n");
|
||||
let _ = run_sftp_batch(host, port, user, &batch)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Single-quote a string for safe inclusion in a shell command. Replaces
|
||||
/// any embedded single quote with `'\''` (close, escaped, reopen) and
|
||||
/// wraps the whole result in single quotes. This is the standard
|
||||
/// POSIX-shell idiom and is safe against paths containing spaces,
|
||||
/// semicolons, backticks, `$()`, etc.
|
||||
fn shell_single_quote(s: &str) -> String {
|
||||
let escaped = s.replace('\'', "'\\''");
|
||||
format!("'{escaped}'")
|
||||
}
|
||||
|
||||
/// Resolve a child path under `base` on the remote, handling `..` and `.`
|
||||
/// components the same way a Unix shell would.
|
||||
pub fn join_remote(base: &str, child: &str) -> String {
|
||||
if child == ".." {
|
||||
if let Some(idx) = base.rfind('/') {
|
||||
if idx == 0 {
|
||||
"/".to_string()
|
||||
} else {
|
||||
base[..idx].to_string()
|
||||
}
|
||||
} else {
|
||||
base.to_string()
|
||||
}
|
||||
} else if child == "." {
|
||||
base.to_string()
|
||||
} else if child.starts_with('/') {
|
||||
child.to_string()
|
||||
} else if base.is_empty() || base == "." {
|
||||
child.to_string()
|
||||
} else if base.ends_with('/') {
|
||||
format!("{base}{child}")
|
||||
} else {
|
||||
format!("{base}/{child}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a child path under a local `base` directory.
|
||||
pub fn join_local(base: &Path, child: &str) -> PathBuf {
|
||||
if child == ".." {
|
||||
base.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| base.to_path_buf())
|
||||
} else if child == "." {
|
||||
base.to_path_buf()
|
||||
} else {
|
||||
base.join(child)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn join_remote_basic() {
|
||||
assert_eq!(join_remote("/home/user", "docs"), "/home/user/docs");
|
||||
assert_eq!(join_remote("/home/user", ".."), "/home");
|
||||
assert_eq!(join_remote("/home/user", "."), "/home/user");
|
||||
assert_eq!(join_remote("/home/user", "/etc"), "/etc");
|
||||
assert_eq!(join_remote("", "foo"), "foo");
|
||||
assert_eq!(join_remote("/home/", "foo"), "/home/foo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_local_basic() {
|
||||
assert_eq!(
|
||||
join_local(Path::new("/home/user"), "docs"),
|
||||
PathBuf::from("/home/user/docs")
|
||||
);
|
||||
assert_eq!(
|
||||
join_local(Path::new("/home/user"), ".."),
|
||||
PathBuf::from("/home")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ls_line_file() {
|
||||
let line = "-rw-r--r-- 1 1000 1000 1234 Jan 15 12:34 file.txt";
|
||||
let e = parse_ls_line(line).unwrap();
|
||||
assert_eq!(e.name, "file.txt");
|
||||
assert!(!e.is_dir);
|
||||
assert_eq!(e.size, 1234);
|
||||
assert_eq!(e.modified, "Jan 15 12:34");
|
||||
assert_eq!(e.permissions, "-rw-r--r--");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ls_line_dir() {
|
||||
let line = "drwxr-xr-x 2 1000 1000 4096 Jan 15 12:34 subdir";
|
||||
let e = parse_ls_line(line).unwrap();
|
||||
assert_eq!(e.name, "subdir");
|
||||
assert!(e.is_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ls_line_symlink() {
|
||||
let line = "lrwxrwxrwx 1 1000 1000 7 Jan 15 12:34 link -> target";
|
||||
let e = parse_ls_line(line).unwrap();
|
||||
assert_eq!(e.name, "link");
|
||||
assert!(e.is_symlink);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ls_line_skips_dot_entries() {
|
||||
assert!(parse_ls_line("drwxr-xr-x 2 1000 1000 4096 Jan 15 12:34 .").is_none());
|
||||
assert!(parse_ls_line("drwxr-xr-x 2 1000 1000 4096 Jan 15 12:34 ..").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_size_formats() {
|
||||
let mk = |size: u64, is_dir: bool| FileEntry {
|
||||
name: "x".into(),
|
||||
is_dir,
|
||||
is_symlink: false,
|
||||
size,
|
||||
modified: String::new(),
|
||||
permissions: String::new(),
|
||||
};
|
||||
assert_eq!(mk(0, false).display_size(), "0 B");
|
||||
assert_eq!(mk(1023, false).display_size(), "1023 B");
|
||||
assert!(mk(2048, false).display_size().starts_with("2.0 KB"));
|
||||
assert!(mk(2 * 1024 * 1024, false).display_size().starts_with("2.0 MB"));
|
||||
assert_eq!(mk(4096, true).display_size(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_single_quote_simple() {
|
||||
assert_eq!(shell_single_quote("hello"), "'hello'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_single_quote_with_space() {
|
||||
assert_eq!(shell_single_quote("my file.txt"), "'my file.txt'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_single_quote_with_embedded_quote() {
|
||||
// it's a file -> 'it'\''s a file'
|
||||
assert_eq!(shell_single_quote("it's a file"), "'it'\\''s a file'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_single_quote_with_metachars() {
|
||||
// Spaces, semicolons, dollar, backtick — all safe inside single quotes.
|
||||
assert_eq!(
|
||||
shell_single_quote("foo; rm -rf / $HOME `whoami`"),
|
||||
"'foo; rm -rf / $HOME `whoami`'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_single_quote_empty() {
|
||||
assert_eq!(shell_single_quote(""), "''");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,329 @@
|
|||
//! Build ssh command lines from a Profile + Configs, matching the original
|
||||
//! secpanel's `connect` proc semantics (see the spec doc §6).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::data::{
|
||||
profile::{Forward, Profile},
|
||||
Configs, SshImpl,
|
||||
};
|
||||
|
||||
/// One token in the assembled ssh command. We keep `String` rather than
|
||||
/// `OsString` for simplicity — secpanel's command building is byte-for-byte
|
||||
/// mirrored from the Tcl original, which used sh-style string concatenation
|
||||
/// and then handed the result to a shell-script runner.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SshCommand {
|
||||
pub argv: Vec<String>,
|
||||
}
|
||||
|
||||
impl SshCommand {
|
||||
/// Render as a single shell-quoted string suitable for a `#!/bin/sh`
|
||||
/// runner script.
|
||||
pub fn to_shell_line(&self) -> String {
|
||||
self.argv
|
||||
.iter()
|
||||
.map(|a| shell_quote(a))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve `<TARGET-HOST>` / `<LOCAL-HOST>` placeholders in forward entries
|
||||
/// against the profile's host and the local hostname, returning the SSH arg
|
||||
/// string `inport:host:outport` for each forward. `kind` is "L" or "R".
|
||||
fn resolve_forwards(
|
||||
forwards: &[Forward],
|
||||
kind: char,
|
||||
target_host: &str,
|
||||
local_host: &str,
|
||||
) -> Vec<String> {
|
||||
let mut out = Vec::with_capacity(forwards.len());
|
||||
for f in forwards {
|
||||
let host = if f.host == "<TARGET-HOST>" {
|
||||
target_host.to_string()
|
||||
} else if f.host == "<LOCAL-HOST>" {
|
||||
local_host.to_string()
|
||||
} else {
|
||||
f.host.clone()
|
||||
};
|
||||
out.push(format!("-{} {}:{}:{}", kind, f.listen_port, host, f.target_port));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Build the ssh command for a profile. `local_host` is the local machine's
|
||||
/// hostname (used to resolve `<LOCAL-HOST>` placeholders in remote forwards).
|
||||
pub fn build_ssh_command(profile: &Profile, configs: &Configs, local_host: &str) -> SshCommand {
|
||||
let mut argv: Vec<String> = Vec::with_capacity(24);
|
||||
|
||||
// ssh binary
|
||||
argv.push(configs.sshbin.clone());
|
||||
|
||||
// User
|
||||
if !profile.user.is_empty() && !profile.askuserspec {
|
||||
argv.push("-l".into());
|
||||
argv.push(profile.user.clone());
|
||||
}
|
||||
|
||||
// Agent forwarding: always emit one of -A / -a.
|
||||
if profile.noagentforward {
|
||||
argv.push("-a".into());
|
||||
} else {
|
||||
argv.push("-A".into());
|
||||
}
|
||||
|
||||
// X11 forwarding (inverted: x11forward=true means NO X11 → -x).
|
||||
if profile.x11forward {
|
||||
argv.push("-x".into());
|
||||
} else if configs.sshver == SshImpl::OpenSsh {
|
||||
argv.push("-X".into());
|
||||
}
|
||||
|
||||
// -N (no remote command) — only OpenSSH, only if no command/subsys.
|
||||
if profile.noexec
|
||||
&& configs.sshver == SshImpl::OpenSsh
|
||||
&& profile.command.is_empty()
|
||||
&& profile.subsys.is_empty()
|
||||
{
|
||||
argv.push("-N".into());
|
||||
}
|
||||
|
||||
// Strict hostkey
|
||||
if profile.stricthost {
|
||||
argv.push("-o".into());
|
||||
argv.push("StrictHostKeyChecking=yes".into());
|
||||
}
|
||||
|
||||
// SSH protocol version
|
||||
argv.push(profile.sshverconnect.flag().into());
|
||||
|
||||
// IP version
|
||||
argv.push(profile.ipverconnect.flag().into());
|
||||
|
||||
// Port
|
||||
if profile.port != 22 && profile.port != 0 {
|
||||
argv.push("-p".into());
|
||||
argv.push(profile.port.to_string());
|
||||
}
|
||||
|
||||
// Cipher
|
||||
if profile.algo.is_emittable() {
|
||||
argv.push("-c".into());
|
||||
argv.push(profile.algo.as_str().into());
|
||||
}
|
||||
|
||||
// Identity file
|
||||
if !profile.identity.as_os_str().is_empty() {
|
||||
argv.push("-i".into());
|
||||
argv.push(profile.identity.to_string_lossy().into_owned());
|
||||
}
|
||||
|
||||
// Alternate ssh config file
|
||||
if !profile.cfgfile.as_os_str().is_empty() {
|
||||
argv.push("-F".into());
|
||||
argv.push(profile.cfgfile.to_string_lossy().into_owned());
|
||||
}
|
||||
|
||||
// No privileged source port
|
||||
if profile.nopriv {
|
||||
argv.push("-P".into());
|
||||
}
|
||||
|
||||
// Verbose / Quiet / Fork / Gateway
|
||||
if profile.verbose {
|
||||
argv.push("-v".into());
|
||||
}
|
||||
if profile.quiet {
|
||||
argv.push("-q".into());
|
||||
}
|
||||
if profile.fork {
|
||||
argv.push("-f".into());
|
||||
}
|
||||
if profile.gateway {
|
||||
argv.push("-g".into());
|
||||
}
|
||||
|
||||
// Compression
|
||||
if profile.compress {
|
||||
argv.push("-C".into());
|
||||
// CompressionLevel (note the slightly different syntax between
|
||||
// OpenSSH and other SSH implementations, preserved from the
|
||||
// original).
|
||||
if configs.sshver == SshImpl::OpenSsh {
|
||||
argv.push("-o".into());
|
||||
argv.push(format!("CompressionLevel {}", profile.compressval));
|
||||
} else {
|
||||
argv.push("-o".into());
|
||||
argv.push(format!("CompressionLevel={}", profile.compressval));
|
||||
}
|
||||
}
|
||||
|
||||
// Local forwards (-L)
|
||||
for lf in resolve_forwards(&profile.lfs, 'L', &profile.host, local_host) {
|
||||
argv.push(lf);
|
||||
}
|
||||
|
||||
// Remote forwards (-R)
|
||||
for rf in resolve_forwards(&profile.rfs, 'R', &profile.host, local_host) {
|
||||
argv.push(rf);
|
||||
}
|
||||
|
||||
// Host
|
||||
argv.push(profile.host.clone());
|
||||
|
||||
// Command (or subsystem). Subsystem takes priority per original.
|
||||
if !profile.subsys.is_empty() {
|
||||
argv.push("-s".into());
|
||||
argv.push(profile.subsys.clone());
|
||||
} else if !profile.command.is_empty() && !profile.noexec {
|
||||
argv.push(profile.command.clone());
|
||||
}
|
||||
|
||||
SshCommand { argv }
|
||||
}
|
||||
|
||||
/// Quote a single shell token. Empty string becomes ''. Tokens containing
|
||||
/// shell metacharacters get wrapped in single quotes (with internal single
|
||||
/// quotes escaped as `'\''`).
|
||||
pub fn shell_quote(s: &str) -> String {
|
||||
if s.is_empty() {
|
||||
return "''".to_string();
|
||||
}
|
||||
if s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/' | ':' | '@' | ',' | '+' | '=')) {
|
||||
return s.to_string();
|
||||
}
|
||||
let mut out = String::with_capacity(s.len() + 2);
|
||||
out.push('\'');
|
||||
for c in s.chars() {
|
||||
if c == '\'' {
|
||||
out.push_str("'\\''");
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out.push('\'');
|
||||
out
|
||||
}
|
||||
|
||||
/// Build the shell-script body for a key-distribution session, mirroring
|
||||
/// `distkey` in the original. `pubkey_path` is the local `.pub` file;
|
||||
/// `targets` is a list of (profile_name, Profile) pairs to distribute to.
|
||||
pub fn build_keydist_script(
|
||||
pubkey_path: &Path,
|
||||
targets: &[(String, Profile)],
|
||||
configs: &Configs,
|
||||
) -> String {
|
||||
let mut out = String::new();
|
||||
out.push_str("#!/bin/sh\n");
|
||||
out.push_str("# shellm key-distribution script\n\n");
|
||||
|
||||
for (name, p) in targets {
|
||||
use std::fmt::Write as _;
|
||||
let user = if p.askuserspec || p.user.is_empty() {
|
||||
"<ASKFORUSER>".to_string()
|
||||
} else {
|
||||
p.user.clone()
|
||||
};
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"# Distributing to {} ({}) — {}@{}:{}",
|
||||
name, p.title, user, p.host, p.port
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"{} -p {} -l {} {} 'mkdir $HOME/.ssh 2>/dev/null; grep \"$(cat {})\" $HOME/.ssh/authorized_keys > /dev/null 2>&1'",
|
||||
configs.sshbin, p.port, user, p.host,
|
||||
shell_quote(&pubkey_path.to_string_lossy())
|
||||
);
|
||||
let _ = writeln!(out, "DISTRET=$?");
|
||||
let _ = writeln!(out, "if [ $DISTRET = 0 ]; then");
|
||||
let _ = writeln!(out, " echo \"Your public key is already on the remote host\"");
|
||||
let _ = writeln!(out, "elif [ $DISTRET = 255 ]; then");
|
||||
let _ = writeln!(out, " echo \"There was an error connecting to {}\"", p.host);
|
||||
let _ = writeln!(out, "else");
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" {} -p {} -l {} {} 'cat >> $HOME/.ssh/authorized_keys; chmod 600 $HOME/.ssh/authorized_keys; chmod 700 $HOME/.ssh' < {}",
|
||||
configs.sshbin, p.port, user, p.host,
|
||||
shell_quote(&pubkey_path.to_string_lossy())
|
||||
);
|
||||
let _ = writeln!(out, "fi");
|
||||
let _ = writeln!(out, "\necho ==============================================================\n");
|
||||
}
|
||||
|
||||
out.push_str("\necho \"Key-Distribution finished\"");
|
||||
out.push_str("\necho \"Press <Return> to continue...\"");
|
||||
out.push_str("\nread end\n");
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::data::profile::Profile;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn basic_command() {
|
||||
let mut p = Profile::default();
|
||||
p.title = "T".into();
|
||||
p.host = "example.com".into();
|
||||
p.user = "root".into();
|
||||
p.port = 22;
|
||||
let c = Configs::default();
|
||||
let cmd = build_ssh_command(&p, &c, "myhost");
|
||||
assert_eq!(cmd.argv[0], "ssh");
|
||||
assert!(cmd.argv.contains(&"-l".to_string()));
|
||||
assert!(cmd.argv.contains(&"root".to_string()));
|
||||
// Compression default on → -C
|
||||
assert!(cmd.argv.contains(&"-C".to_string()));
|
||||
// Default sshver is V2 → -2
|
||||
assert!(cmd.argv.contains(&"-2".to_string()));
|
||||
// Default ipver is V4 → -4
|
||||
assert!(cmd.argv.contains(&"-4".to_string()));
|
||||
// Agent forwarding default → -A
|
||||
assert!(cmd.argv.contains(&"-A".to_string()));
|
||||
// X11 default (false + OpenSSH) → -X
|
||||
assert!(cmd.argv.contains(&"-X".to_string()));
|
||||
// Port 22 → no -p
|
||||
assert!(!cmd.argv.contains(&"-p".to_string()));
|
||||
// host at end
|
||||
assert_eq!(cmd.argv.last().unwrap(), "example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_forward() {
|
||||
let mut p = Profile::default();
|
||||
p.host = "ex.com".into();
|
||||
p.lfs.push(Forward::new(8080, "<TARGET-HOST>", 80));
|
||||
let c = Configs::default();
|
||||
let cmd = build_ssh_command(&p, &c, "myhost");
|
||||
assert!(cmd.argv.iter().any(|a| a == "-L 8080:ex.com:80"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_quote_basic() {
|
||||
assert_eq!(shell_quote("hello"), "hello");
|
||||
assert_eq!(shell_quote(""), "''");
|
||||
assert_eq!(shell_quote("a b"), "'a b'");
|
||||
assert_eq!(shell_quote("a'b"), "'a'\\''b'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_identity_and_cfgfile() {
|
||||
let mut p = Profile::default();
|
||||
p.host = "ex.com".into();
|
||||
p.identity = PathBuf::from("/home/foo/.ssh/id_rsa");
|
||||
p.cfgfile = PathBuf::from("/home/foo/.ssh/alt_config");
|
||||
p.algo = crate::data::profile::Algo::Blowfish;
|
||||
p.port = 2222;
|
||||
let c = Configs::default();
|
||||
let cmd = build_ssh_command(&p, &c, "myhost");
|
||||
let line = cmd.to_shell_line();
|
||||
assert!(line.contains("-i /home/foo/.ssh/id_rsa"));
|
||||
assert!(line.contains("-F /home/foo/.ssh/alt_config"));
|
||||
assert!(line.contains("-c blowfish"));
|
||||
assert!(line.contains("-p 2222"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
//! SSH module — command building & runner-script execution.
|
||||
|
||||
pub mod command;
|
||||
pub mod runner;
|
||||
|
||||
pub use command::{build_ssh_command, SshCommand};
|
||||
pub use runner::{
|
||||
launch_interactive, launch_sftp, launch_ssh, cleanup_runfiles,
|
||||
};
|
||||
|
|
@ -0,0 +1,341 @@
|
|||
//! Runner-script mechanism — mirrors the original `provrunfile` proc.
|
||||
//!
|
||||
//! The original secpanel wrote two files in `~/.secpanel/.runfiles/` (heritage
|
||||
//! mention; shellm uses `~/.shellm/.runfiles/`):
|
||||
//! 1. `runproc.<ts>` — the inner script: `#!/bin/sh\n<cmd>\n[rm -f $cf]`
|
||||
//! 2. `runproc.<ts>-run` — the outer wrapper: `exec <terminal> ... <inner>`
|
||||
//!
|
||||
//! The outer was exec'd (with `&` for background mode). The inner self-deleted
|
||||
//! in `nobg` mode (used by key-gen and other interactive prompts).
|
||||
//!
|
||||
//! We preserve this pattern because it gives us:
|
||||
//! * An audit trail of every command shellm has launched (the trace.log
|
||||
//! entry plus the actual script file when `bg`).
|
||||
//! * A clean way to hand the command to a terminal emulator (we can't just
|
||||
//! `Command::new("xterm").arg("-e").arg(ssh).arg(...)` because the command
|
||||
//! is built up as a single shell string with shell-quoted args).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::data::{Configs, TerminalSpec};
|
||||
use crate::ssh::command::SshCommand;
|
||||
|
||||
/// Result of launching a terminal session.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Launched {
|
||||
/// Path to the inner runner script (`runproc.<ts>`).
|
||||
pub inner_script: PathBuf,
|
||||
/// Path to the outer wrapper script (`runproc.<ts>-run`). `None` when
|
||||
/// we launched without a terminal (direct exec, e.g. SFTP).
|
||||
pub outer_script: Option<PathBuf>,
|
||||
/// The PID of the spawned process (the terminal), if any.
|
||||
pub pid: Option<u32>,
|
||||
}
|
||||
|
||||
/// Mode controlling whether the terminal should stay open after the command
|
||||
/// exits (matching the original's `nobg` vs `bg` distinction).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BgMode {
|
||||
/// The terminal stays open; the inner script self-deletes after running.
|
||||
/// Used for key-generation and other operations where the user needs to
|
||||
/// see output before the terminal closes.
|
||||
NoBg,
|
||||
/// The terminal is launched in the background (returns immediately).
|
||||
/// Used for SSH connections.
|
||||
Bg,
|
||||
}
|
||||
|
||||
/// Mode controlling whether to launch a terminal at all.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TermMode {
|
||||
/// Launch inside the configured terminal emulator.
|
||||
Term,
|
||||
/// Launch directly, no terminal (used for SFTP/file managers).
|
||||
NoTerm,
|
||||
}
|
||||
|
||||
/// Builder for a single launch.
|
||||
pub struct LaunchSpec<'a> {
|
||||
pub configs: &'a Configs,
|
||||
pub term_spec: &'a TerminalSpec,
|
||||
/// Window title (goes after the terminal's title flag).
|
||||
pub title: String,
|
||||
/// The actual ssh/sftp/keygen command to run.
|
||||
pub command: String,
|
||||
/// Terminal-mode (Term vs NoTerm).
|
||||
pub term_mode: TermMode,
|
||||
/// Background behaviour (Bg vs NoBg).
|
||||
pub bg: BgMode,
|
||||
/// If true, start the terminal iconified.
|
||||
pub iconified: bool,
|
||||
/// If true, wrap the runner in a `shellm.wait`-style snippet so the
|
||||
/// user must press Return after the command exits.
|
||||
pub wait_after: bool,
|
||||
}
|
||||
|
||||
impl<'a> LaunchSpec<'a> {
|
||||
pub fn terminal(configs: &'a Configs, spec: &'a TerminalSpec, title: impl Into<String>, command: impl Into<String>) -> Self {
|
||||
Self {
|
||||
configs,
|
||||
term_spec: spec,
|
||||
title: title.into(),
|
||||
command: command.into(),
|
||||
term_mode: TermMode::Term,
|
||||
bg: BgMode::Bg,
|
||||
iconified: false,
|
||||
wait_after: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the runner scripts to `~/.shellm/.runfiles/` and spawn the outer
|
||||
/// wrapper. Returns the paths and PID.
|
||||
pub fn launch(spec: LaunchSpec<'_>) -> std::io::Result<Launched> {
|
||||
let runfiles = crate::data::paths::runfiles_dir();
|
||||
std::fs::create_dir_all(&runfiles)?;
|
||||
|
||||
let ts = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
|
||||
let inner_path = runfiles.join(format!("runproc.{ts}"));
|
||||
let outer_path = runfiles.join(format!("runproc.{ts}-run"));
|
||||
|
||||
// --- inner script ---
|
||||
let mut inner = String::new();
|
||||
inner.push_str("#!/bin/sh\n");
|
||||
inner.push_str("# shellm runner script\n");
|
||||
inner.push_str(&spec.command);
|
||||
inner.push('\n');
|
||||
if spec.bg == BgMode::NoBg {
|
||||
// Self-delete on completion.
|
||||
inner.push_str("\necho \"shellm: Cleaning up handling data for this operation...\"\n");
|
||||
inner.push_str(&format!("rm -f {}\n", shell_quote(&inner_path.to_string_lossy())));
|
||||
}
|
||||
std::fs::write(&inner_path, &inner)?;
|
||||
make_executable(&inner_path)?;
|
||||
|
||||
// --- outer wrapper (terminal launch) ---
|
||||
let outer = match spec.term_mode {
|
||||
TermMode::NoTerm => {
|
||||
// Just run `command` directly. No terminal wrapper needed.
|
||||
// We still write a runner script for auditability.
|
||||
let mut s = String::new();
|
||||
s.push_str("#!/bin/sh\n");
|
||||
s.push_str(&spec.command);
|
||||
s.push('\n');
|
||||
std::fs::write(&outer_path, &s)?;
|
||||
make_executable(&outer_path)?;
|
||||
s
|
||||
}
|
||||
TermMode::Term => {
|
||||
let s = build_terminal_wrapper(spec.term_spec, &spec.title, &inner_path, spec.iconified, spec.wait_after);
|
||||
std::fs::write(&outer_path, &s)?;
|
||||
make_executable(&outer_path)?;
|
||||
s
|
||||
}
|
||||
};
|
||||
let _ = outer; // outer string is just for audit
|
||||
|
||||
// Spawn the outer wrapper.
|
||||
let mut cmd = std::process::Command::new(&outer_path);
|
||||
if spec.bg == BgMode::Bg {
|
||||
// Detach: std::process::Command already detaches by default on Unix
|
||||
// once spawned. We don't need an explicit "&" because Rust spawns
|
||||
// child processes asynchronously.
|
||||
}
|
||||
|
||||
// We can't actually display a GUI terminal on this server, but we still
|
||||
// spawn the process so the audit trail is correct. On a real desktop the
|
||||
// user's terminal emulator will appear.
|
||||
let pid = match cmd.spawn() {
|
||||
Ok(child) => {
|
||||
let pid = child.id();
|
||||
// Don't wait — let it run in the background.
|
||||
std::mem::forget(child);
|
||||
Some(pid)
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Could not spawn runner {}: {e}", outer_path.display());
|
||||
// Even if spawn fails (e.g. no DISPLAY on this server), the
|
||||
// scripts are written and can be inspected / run manually.
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// Append to the trace log.
|
||||
let _ = crate::data::history::append_trace(&spec.command);
|
||||
|
||||
Ok(Launched {
|
||||
inner_script: inner_path,
|
||||
outer_script: Some(outer_path),
|
||||
pid,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the terminal-emulator wrapper script (the "outer" runner).
|
||||
///
|
||||
/// The original Tcl template was:
|
||||
/// ```text
|
||||
/// exec <xtermbin> [<iconpar>] <titlepar> "SSH Connection - <title>" \
|
||||
/// <execpar> [<quotepar>] [<waittag>] <woption> <CONNFILE> [<woption>] [<quotepar>]
|
||||
/// ```
|
||||
/// Where `<CONNFILE>` was replaced by `provrunfile` with the inner script
|
||||
/// path. We reproduce this faithfully.
|
||||
pub fn build_terminal_wrapper(
|
||||
spec: &TerminalSpec,
|
||||
title: &str,
|
||||
inner_script: &Path,
|
||||
iconified: bool,
|
||||
wait_after: bool,
|
||||
) -> String {
|
||||
let mut s = String::new();
|
||||
s.push_str("#!/bin/sh\n");
|
||||
s.push_str("# shellm terminal wrapper\n");
|
||||
s.push_str("exec ");
|
||||
s.push_str(&spec.path);
|
||||
|
||||
if iconified && !spec.iconpar.is_empty() {
|
||||
s.push(' ');
|
||||
s.push_str(&spec.iconpar);
|
||||
}
|
||||
|
||||
if !spec.titlepar.is_empty() {
|
||||
s.push(' ');
|
||||
s.push_str(&spec.titlepar);
|
||||
s.push(' ');
|
||||
s.push_str(&shell_quote(title));
|
||||
}
|
||||
|
||||
s.push(' ');
|
||||
s.push_str(&spec.execpar);
|
||||
|
||||
// If `connwait`, we want the terminal to wrap the runner in a
|
||||
// shellm.wait-style snippet. We synthesize it inline rather than
|
||||
// shipping a separate file (the original secpanel used `secpanel.wait`).
|
||||
let connfile_arg = if wait_after {
|
||||
// Build an inline shell expression that runs the inner script and
|
||||
// then waits for Return. We pass it through `sh -c`.
|
||||
let inner_str = shell_quote(&inner_script.to_string_lossy());
|
||||
format!(
|
||||
"sh -c {}",
|
||||
shell_quote(&format!(
|
||||
"{}; echo; echo \"---------- shellm ----------\"; echo \"Connection closed\"; echo \"Press <Return> to continue...\"; read _end",
|
||||
inner_str
|
||||
))
|
||||
)
|
||||
} else {
|
||||
shell_quote(&inner_script.to_string_lossy())
|
||||
};
|
||||
|
||||
// The original used `quotepar` (a single quote char) to wrap the path
|
||||
// when connwait was set; we don't need that because we already shell-quote
|
||||
// above. But we still respect `quotepar` when set (e.g. GNOME-Terminal
|
||||
// uses `"`).
|
||||
if !spec.quotepar.is_empty() && spec.quotepar != "\"" {
|
||||
// quotepar is some other char — emit it raw around the connfile arg.
|
||||
// (None of the default terminals use this, but we preserve the
|
||||
// behaviour for fidelity.)
|
||||
s.push(' ');
|
||||
s.push_str(&spec.quotepar);
|
||||
s.push_str(&connfile_arg);
|
||||
s.push_str(&spec.quotepar);
|
||||
} else {
|
||||
s.push(' ');
|
||||
s.push_str(&connfile_arg);
|
||||
}
|
||||
|
||||
s.push('\n');
|
||||
s
|
||||
}
|
||||
|
||||
/// Mark a file executable (chmod 0700).
|
||||
fn make_executable(p: &Path) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o700))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Single-argument shell-quote (re-exported from ssh::command for convenience).
|
||||
pub fn shell_quote(s: &str) -> String {
|
||||
crate::ssh::command::shell_quote(s)
|
||||
}
|
||||
|
||||
/// Convenience: build a single SFTP launch (no terminal).
|
||||
pub fn launch_sftp(configs: &Configs, url: &str) -> std::io::Result<Launched> {
|
||||
let prefix = configs.sftpbin.launch_prefix();
|
||||
let command = format!("{prefix}{url}");
|
||||
let spec = configs.terminal_spec();
|
||||
let ls = LaunchSpec {
|
||||
configs,
|
||||
term_spec: &spec,
|
||||
title: format!("shellm sftp"),
|
||||
command,
|
||||
term_mode: TermMode::NoTerm,
|
||||
bg: BgMode::Bg,
|
||||
iconified: false,
|
||||
wait_after: false,
|
||||
};
|
||||
launch(ls)
|
||||
}
|
||||
|
||||
/// Convenience: build an SSH terminal launch.
|
||||
pub fn launch_ssh(
|
||||
configs: &Configs,
|
||||
title: &str,
|
||||
ssh_cmd: &SshCommand,
|
||||
iconified: bool,
|
||||
wait_after: bool,
|
||||
) -> std::io::Result<Launched> {
|
||||
let spec = configs.terminal_spec();
|
||||
let command = ssh_cmd.to_shell_line();
|
||||
let ls = LaunchSpec {
|
||||
configs,
|
||||
term_spec: &spec,
|
||||
title: format!("SSH Connection - {title}"),
|
||||
command,
|
||||
term_mode: TermMode::Term,
|
||||
bg: BgMode::Bg,
|
||||
iconified,
|
||||
wait_after,
|
||||
};
|
||||
launch(ls)
|
||||
}
|
||||
|
||||
/// Convenience: launch an interactive command (key-gen, key-dist, etc.) in a
|
||||
/// terminal that waits for the user to press Return.
|
||||
pub fn launch_interactive(
|
||||
configs: &Configs,
|
||||
title: &str,
|
||||
command: &str,
|
||||
) -> std::io::Result<Launched> {
|
||||
let spec = configs.terminal_spec();
|
||||
let ls = LaunchSpec {
|
||||
configs,
|
||||
term_spec: &spec,
|
||||
title: title.to_string(),
|
||||
command: command.to_string(),
|
||||
term_mode: TermMode::Term,
|
||||
bg: BgMode::NoBg,
|
||||
iconified: false,
|
||||
wait_after: true,
|
||||
};
|
||||
launch(ls)
|
||||
}
|
||||
|
||||
/// Best-effort cleanup of the runfiles directory (mirrors the original
|
||||
/// secpanel `do_exit`'s `file delete -force` on each child of
|
||||
/// `~/.shellm/.runfiles/`).
|
||||
pub fn cleanup_runfiles() {
|
||||
let dir = crate::data::paths::runfiles_dir();
|
||||
if let Ok(rd) = std::fs::read_dir(&dir) {
|
||||
for ent in rd.flatten() {
|
||||
let _ = std::fs::remove_file(ent.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,587 @@
|
|||
//! Top-level shellm application state.
|
||||
//!
|
||||
//! (The `Shellm` struct holds all GUI state. The on-disk data directory
|
||||
//! is `~/.shellm/` — see `data::paths`.)
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::data::{
|
||||
history::HistoryEntry,
|
||||
Configs, KeyType, Profile,
|
||||
};
|
||||
use crate::message::{
|
||||
ConfirmKind, ForwardKind, ProfileCheckbox, Tab,
|
||||
};
|
||||
|
||||
/// State of the Ask-User dialog.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AskUserState {
|
||||
pub prompt: String,
|
||||
pub value: String,
|
||||
pub kind: AskUserKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum AskUserKind {
|
||||
#[default]
|
||||
User,
|
||||
MakeDir,
|
||||
ProtectData,
|
||||
}
|
||||
|
||||
/// State of the keygen dialog.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KeygenDialogState {
|
||||
pub comment: String,
|
||||
pub file: String,
|
||||
pub pass1: String,
|
||||
pub pass2: String,
|
||||
pub no_pass: bool,
|
||||
pub key_type: KeyType,
|
||||
pub keys_list: Vec<String>,
|
||||
pub change_pass: ChangePassState,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ChangePassState {
|
||||
pub target_key: String,
|
||||
pub old_pass: String,
|
||||
pub new_pass1: String,
|
||||
pub new_pass2: String,
|
||||
pub comment: String,
|
||||
pub visible: bool,
|
||||
}
|
||||
|
||||
impl Default for KeygenDialogState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
comment: String::new(),
|
||||
file: String::new(),
|
||||
pass1: String::new(),
|
||||
pass2: String::new(),
|
||||
no_pass: false,
|
||||
key_type: KeyType::Dsa,
|
||||
keys_list: Vec::new(),
|
||||
change_pass: ChangePassState::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// State of the forwards editor dialog.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ForwardsDialogState {
|
||||
pub kind: ForwardKind,
|
||||
pub entries: Vec<(String, String, String, String)>, // in_port, host, out_port, comment
|
||||
pub selected: Option<usize>,
|
||||
pub new_in_port: String,
|
||||
pub new_host: String,
|
||||
pub new_out_port: String,
|
||||
pub new_comment: String,
|
||||
}
|
||||
|
||||
/// State of the message dialog.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MessageDialogState {
|
||||
pub text: String,
|
||||
pub visible: bool,
|
||||
}
|
||||
|
||||
/// State of the confirm dialog.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ConfirmDialogState {
|
||||
pub kind: ConfirmKind,
|
||||
pub text: String,
|
||||
pub visible: bool,
|
||||
}
|
||||
|
||||
/// State of the hostkeys dialog.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct HostkeysDialogState {
|
||||
pub entries: Vec<(String, String)>, // (host, keytype)
|
||||
pub visible: bool,
|
||||
}
|
||||
|
||||
/// State of the agent-info dialog.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AgentInfoDialogState {
|
||||
pub text: String,
|
||||
pub visible: bool,
|
||||
}
|
||||
|
||||
/// State of the key-info dialog.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct KeyInfoDialogState {
|
||||
pub fingerprint: String,
|
||||
pub bubblebabble: String,
|
||||
pub visible: bool,
|
||||
}
|
||||
|
||||
/// State of the history dialog.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct HistoryDialogState {
|
||||
pub entries: Vec<HistoryEntry>,
|
||||
pub visible: bool,
|
||||
}
|
||||
|
||||
/// State of the trace dialog.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TraceDialogState {
|
||||
pub text: String,
|
||||
pub visible: bool,
|
||||
}
|
||||
|
||||
/// State of the colors dialog.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ColorsDialogState {
|
||||
pub visible: bool,
|
||||
pub fore: String,
|
||||
pub back: String,
|
||||
pub entfore: String,
|
||||
pub entback: String,
|
||||
pub listfore: String,
|
||||
pub listback: String,
|
||||
pub fore_default: bool,
|
||||
pub back_default: bool,
|
||||
pub entfore_default: bool,
|
||||
pub entback_default: bool,
|
||||
pub listfore_default: bool,
|
||||
pub listback_default: bool,
|
||||
}
|
||||
|
||||
/// State of the about dialog.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AboutDialogState {
|
||||
pub visible: bool,
|
||||
}
|
||||
|
||||
/// State of the key-distribution wizard.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct KeyDistWizardState {
|
||||
pub visible: bool,
|
||||
pub step: u8, // 1=key, 2=host, 3=confirm
|
||||
pub selected_key: String,
|
||||
pub selected_targets: Vec<String>, // profile names
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// State of the ask-for-user modal.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AskUserModalState {
|
||||
pub visible: bool,
|
||||
pub prompt: String,
|
||||
pub value: String,
|
||||
/// Set to `Some(...)` when the modal is opened to fulfil a specific
|
||||
/// pending operation (e.g. an SSH connect for a profile that has
|
||||
/// `askuserspec` set). The variant tells us what to do when the user
|
||||
/// submits a value.
|
||||
pub pending: Option<PendingOp>,
|
||||
}
|
||||
|
||||
/// What operation is waiting on the ask-for-user modal.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PendingOp {
|
||||
/// Connect to the named profile, using the entered user.
|
||||
ConnectForProfile { profile_name: String },
|
||||
}
|
||||
|
||||
impl Default for PendingOp {
|
||||
fn default() -> Self {
|
||||
PendingOp::ConnectForProfile { profile_name: String::new() }
|
||||
}
|
||||
}
|
||||
|
||||
/// State of the profile export dialog.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ExportDialogState {
|
||||
pub visible: bool,
|
||||
pub format: crate::message::ExportFormat,
|
||||
pub result_message: String,
|
||||
}
|
||||
|
||||
/// State of the remote account manager dialog.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct RemoteAcctDialogState {
|
||||
pub visible: bool,
|
||||
pub target_profile: String,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub user: String,
|
||||
pub shosts: String,
|
||||
pub auth_keys: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
/// State of the GPG data protection dialog.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ProtectDataDialogState {
|
||||
pub visible: bool,
|
||||
pub passphrase: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
/// State of the fonts dialog.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FontsDialogState {
|
||||
pub visible: bool,
|
||||
pub fontfam: String,
|
||||
pub fontsize: u32,
|
||||
pub bold: bool,
|
||||
pub italic: bool,
|
||||
pub sysfonts: bool,
|
||||
}
|
||||
|
||||
/// State of the right-click context menu on the connections list.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ContextMenuState {
|
||||
pub visible: bool,
|
||||
pub connection_idx: usize,
|
||||
}
|
||||
|
||||
/// State of the dual-pane SCP browser tab.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScpBrowserState {
|
||||
/// Local current working directory.
|
||||
pub local_cwd: std::path::PathBuf,
|
||||
/// Local file listing.
|
||||
pub local_entries: Vec<crate::scp_browser::FileEntry>,
|
||||
/// Index of the selected entry in `local_entries` (None = no selection).
|
||||
pub local_selected: Option<usize>,
|
||||
/// Text the user is typing into the local path bar.
|
||||
pub local_path_input: String,
|
||||
|
||||
/// Remote current working directory (as the remote sshd sees it).
|
||||
pub remote_cwd: String,
|
||||
pub remote_entries: Vec<crate::scp_browser::FileEntry>,
|
||||
pub remote_selected: Option<usize>,
|
||||
pub remote_path_input: String,
|
||||
|
||||
/// The profile we're currently connected to (None = disconnected).
|
||||
pub connected_profile: Option<String>,
|
||||
pub connected_host: String,
|
||||
pub connected_port: u16,
|
||||
pub connected_user: String,
|
||||
|
||||
/// Status line at the bottom of the browser.
|
||||
pub status: String,
|
||||
/// True when an async operation is in flight (disables controls).
|
||||
pub busy: bool,
|
||||
|
||||
/// Mkdir modal state.
|
||||
pub mkdir_modal: ScpMkdirModalState,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ScpMkdirModalState {
|
||||
pub visible: bool,
|
||||
/// "local" or "remote" — which side the new directory should be created on.
|
||||
pub side: ScpSide,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum ScpSide {
|
||||
#[default]
|
||||
Local,
|
||||
Remote,
|
||||
}
|
||||
|
||||
impl Default for ScpBrowserState {
|
||||
fn default() -> Self {
|
||||
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/"));
|
||||
Self {
|
||||
local_cwd: home.clone(),
|
||||
local_entries: Vec::new(),
|
||||
local_selected: None,
|
||||
local_path_input: home.to_string_lossy().into_owned(),
|
||||
|
||||
remote_cwd: String::new(),
|
||||
remote_entries: Vec::new(),
|
||||
remote_selected: None,
|
||||
remote_path_input: String::new(),
|
||||
|
||||
connected_profile: None,
|
||||
connected_host: String::new(),
|
||||
connected_port: 22,
|
||||
connected_user: String::new(),
|
||||
|
||||
status: "Pick a profile and click Connect.".into(),
|
||||
busy: false,
|
||||
mkdir_modal: ScpMkdirModalState::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The top-level application state.
|
||||
pub struct Shellm {
|
||||
pub configs: Configs,
|
||||
pub profiles: BTreeMap<String, Profile>,
|
||||
/// Profile names in display order (sorted alphabetically by name).
|
||||
pub profile_order: Vec<String>,
|
||||
pub active_tab: Tab,
|
||||
pub satellite_mode: bool,
|
||||
|
||||
/// Currently-edited profile on the Profiles tab.
|
||||
pub editing_profile: Profile,
|
||||
/// File-name stem of the profile currently being edited.
|
||||
pub editing_profile_name: String,
|
||||
|
||||
/// Indices of selected connections on the Connections tab.
|
||||
pub selected_connections: Vec<usize>,
|
||||
|
||||
/// Agent state.
|
||||
pub agent_state: crate::agent::AgentState,
|
||||
pub agent_info: crate::agent::AgentInfo,
|
||||
pub identities: Vec<String>,
|
||||
|
||||
/// Dialog states.
|
||||
pub ask_user: AskUserState,
|
||||
pub keygen_dialog: KeygenDialogState,
|
||||
pub forwards_dialog: ForwardsDialogState,
|
||||
pub message_dialog: MessageDialogState,
|
||||
pub confirm_dialog: ConfirmDialogState,
|
||||
pub hostkeys_dialog: HostkeysDialogState,
|
||||
pub agent_info_dialog: AgentInfoDialogState,
|
||||
pub key_info_dialog: KeyInfoDialogState,
|
||||
pub history_dialog: HistoryDialogState,
|
||||
pub trace_dialog: TraceDialogState,
|
||||
pub colors_dialog: ColorsDialogState,
|
||||
pub about_dialog: AboutDialogState,
|
||||
pub key_dist_wizard: KeyDistWizardState,
|
||||
|
||||
// New dialogs added in the modern port.
|
||||
pub ask_user_modal: AskUserModalState,
|
||||
pub export_dialog: ExportDialogState,
|
||||
pub remote_acct_dialog: RemoteAcctDialogState,
|
||||
pub protect_data_dialog: ProtectDataDialogState,
|
||||
pub fonts_dialog: FontsDialogState,
|
||||
pub context_menu: ContextMenuState,
|
||||
pub scp_browser: ScpBrowserState,
|
||||
|
||||
/// Status bar (left).
|
||||
pub status: String,
|
||||
/// Last command launched (for the trace dialog display).
|
||||
pub last_command: String,
|
||||
|
||||
/// Pending async operation? If true, controls are disabled.
|
||||
pub busy: bool,
|
||||
|
||||
/// Visibility flag for the forwards dialog.
|
||||
pub forwards_dialog_visible: bool,
|
||||
|
||||
/// Visibility flag for the keygen (manage-keypairs) dialog.
|
||||
pub keygen_dialog_visible: bool,
|
||||
}
|
||||
|
||||
impl Default for Shellm {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
configs: Configs::default(),
|
||||
profiles: BTreeMap::new(),
|
||||
profile_order: Vec::new(),
|
||||
active_tab: Tab::Connections,
|
||||
satellite_mode: false,
|
||||
editing_profile: Profile::default(),
|
||||
editing_profile_name: String::new(),
|
||||
selected_connections: Vec::new(),
|
||||
agent_state: crate::agent::AgentState::None,
|
||||
agent_info: crate::agent::AgentInfo::default(),
|
||||
identities: Vec::new(),
|
||||
ask_user: AskUserState::default(),
|
||||
keygen_dialog: KeygenDialogState::default(),
|
||||
forwards_dialog: ForwardsDialogState::default(),
|
||||
message_dialog: MessageDialogState::default(),
|
||||
confirm_dialog: ConfirmDialogState::default(),
|
||||
hostkeys_dialog: HostkeysDialogState::default(),
|
||||
agent_info_dialog: AgentInfoDialogState::default(),
|
||||
key_info_dialog: KeyInfoDialogState::default(),
|
||||
history_dialog: HistoryDialogState::default(),
|
||||
trace_dialog: TraceDialogState::default(),
|
||||
colors_dialog: ColorsDialogState::default(),
|
||||
about_dialog: AboutDialogState::default(),
|
||||
key_dist_wizard: KeyDistWizardState::default(),
|
||||
status: String::from("shellm ready"),
|
||||
last_command: String::new(),
|
||||
busy: false,
|
||||
forwards_dialog_visible: false,
|
||||
keygen_dialog_visible: false,
|
||||
ask_user_modal: AskUserModalState::default(),
|
||||
export_dialog: ExportDialogState::default(),
|
||||
remote_acct_dialog: RemoteAcctDialogState::default(),
|
||||
protect_data_dialog: ProtectDataDialogState::default(),
|
||||
fonts_dialog: FontsDialogState::default(),
|
||||
context_menu: ContextMenuState::default(),
|
||||
scp_browser: ScpBrowserState::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Shellm {
|
||||
pub fn new() -> Self {
|
||||
let mut s = Self::default();
|
||||
// Boot sequence:
|
||||
// 1. Ensure ~/.shellm/ tree exists.
|
||||
let _ = crate::data::paths::ensure_shellm_tree();
|
||||
// 2. Load configs.
|
||||
s.configs = Configs::load();
|
||||
// 3. Refresh profiles from disk.
|
||||
s.refresh_profiles();
|
||||
// 4. Detect external agent.
|
||||
if let Some(info) = crate::agent::detect_external() {
|
||||
s.agent_state = crate::agent::AgentState::External;
|
||||
s.agent_info = info.clone();
|
||||
s.identities = info.identities.clone();
|
||||
s.status = "Accepted external agent".into();
|
||||
} else {
|
||||
s.agent_state = crate::agent::AgentState::None;
|
||||
s.identities.clear();
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Re-read all profiles from disk into `self.profiles` / `profile_order`.
|
||||
pub fn refresh_profiles(&mut self) {
|
||||
self.profiles.clear();
|
||||
self.profile_order.clear();
|
||||
for name in Profile::list_names() {
|
||||
if let Ok(p) = Profile::load(&name) {
|
||||
self.profiles.insert(name.clone(), p);
|
||||
self.profile_order.push(name);
|
||||
}
|
||||
}
|
||||
// Sort by display title (the original showed them alphabetically
|
||||
// by title in the connections list).
|
||||
self.profile_order.sort_by(|a, b| {
|
||||
let pa = self.profiles.get(a).map(|p| p.title.clone()).unwrap_or_default();
|
||||
let pb = self.profiles.get(b).map(|p| p.title.clone()).unwrap_or_default();
|
||||
pa.cmp(&pb)
|
||||
});
|
||||
}
|
||||
|
||||
/// Returns the list of (name, profile) pairs in display order.
|
||||
pub fn profiles_in_order(&self) -> Vec<(&String, &Profile)> {
|
||||
self.profile_order
|
||||
.iter()
|
||||
.filter_map(|n| self.profiles.get(n).map(|p| (n, p)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get the profile name for the connection at `idx` in the Connections
|
||||
/// tab list (which is sorted by title).
|
||||
pub fn connection_name_at(&self, idx: usize) -> Option<&String> {
|
||||
self.profile_order.get(idx)
|
||||
}
|
||||
|
||||
/// Begin editing a new blank profile.
|
||||
pub fn new_profile(&mut self) {
|
||||
self.editing_profile = Profile::default();
|
||||
if let Some(user) = std::env::var("USER").ok() {
|
||||
self.editing_profile.user = user;
|
||||
}
|
||||
self.editing_profile_name = String::new();
|
||||
self.active_tab = Tab::Profiles;
|
||||
}
|
||||
|
||||
/// Load a profile into the editor by name.
|
||||
pub fn load_profile_into_editor(&mut self, name: &str) {
|
||||
if let Some(p) = self.profiles.get(name).cloned() {
|
||||
self.editing_profile = p;
|
||||
self.editing_profile_name = name.to_string();
|
||||
self.active_tab = Tab::Profiles;
|
||||
}
|
||||
}
|
||||
|
||||
/// Save the editor's profile to disk.
|
||||
pub fn save_editing_profile(&mut self) -> Result<String, String> {
|
||||
if self.editing_profile_name.is_empty() {
|
||||
// Derive a filename from the title.
|
||||
let stem = sanitize_filename(&self.editing_profile.title);
|
||||
if stem.is_empty() {
|
||||
return Err("Profile name is empty".into());
|
||||
}
|
||||
self.editing_profile_name = stem;
|
||||
}
|
||||
let name = self.editing_profile_name.clone();
|
||||
self.editing_profile
|
||||
.save(&name)
|
||||
.map_err(|e| e.to_string())?;
|
||||
self.refresh_profiles();
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
/// Delete the editor's profile from disk.
|
||||
pub fn delete_editing_profile(&mut self) -> Result<(), String> {
|
||||
if self.editing_profile_name.is_empty() {
|
||||
return Err("No profile loaded".into());
|
||||
}
|
||||
Profile::delete_file(&self.editing_profile_name).map_err(|e| e.to_string())?;
|
||||
self.editing_profile = Profile::default();
|
||||
self.editing_profile_name.clear();
|
||||
self.refresh_profiles();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update a checkbox field on the editing profile.
|
||||
pub fn set_profile_checkbox(&mut self, which: ProfileCheckbox, v: bool) {
|
||||
let p = &mut self.editing_profile;
|
||||
match which {
|
||||
ProfileCheckbox::NoAgentForward => p.noagentforward = v,
|
||||
ProfileCheckbox::NoX11Forward => p.x11forward = v,
|
||||
ProfileCheckbox::StrictHost => p.stricthost = v,
|
||||
ProfileCheckbox::NoPrivPort => p.nopriv = v,
|
||||
ProfileCheckbox::Fork => p.fork = v,
|
||||
ProfileCheckbox::Quiet => p.quiet = v,
|
||||
ProfileCheckbox::Verbose => p.verbose = v,
|
||||
ProfileCheckbox::ConnWait => p.connwait = v,
|
||||
ProfileCheckbox::TermIcon => p.termicon = v,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the SSH command for a profile (using the local hostname).
|
||||
pub fn build_ssh(&self, p: &Profile) -> crate::ssh::SshCommand {
|
||||
let local_host = hostname();
|
||||
crate::ssh::build_ssh_command(p, &self.configs, &local_host)
|
||||
}
|
||||
|
||||
/// Mark a status message (left side of status bar).
|
||||
pub fn set_status(&mut self, s: impl Into<String>) {
|
||||
self.status = s.into();
|
||||
}
|
||||
|
||||
/// True if the forwards dialog is not currently shown. (Used by the
|
||||
/// overlay compositor to decide whether to render the dialog.)
|
||||
pub fn forwards_dialog_kind_is_default(&self) -> bool {
|
||||
!self.forwards_dialog_visible
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_filename(s: &str) -> String {
|
||||
s.trim()
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.' {
|
||||
c
|
||||
} else if c.is_ascii_whitespace() {
|
||||
'_'
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
.trim_matches('_')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub fn hostname() -> String {
|
||||
if let Ok(h) = std::env::var("HOSTNAME") {
|
||||
if !h.is_empty() {
|
||||
return h;
|
||||
}
|
||||
}
|
||||
if let Ok(out) = std::process::Command::new("hostname").output() {
|
||||
if out.status.success() {
|
||||
return String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
}
|
||||
}
|
||||
"localhost".to_string()
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
//! Visual theme — Tk-like styling for the iced port.
|
||||
//!
|
||||
//! The original secpanel used a fairly standard Tk look:
|
||||
//! * Light grey (#e2e2de) scrollbar troughs
|
||||
//! * White listbox background (#ffffff)
|
||||
//! * Near-white entry background (#fefefe)
|
||||
//! * A green status indicator when the agent is active, red otherwise
|
||||
//! * Groove-border frames around groups of controls
|
||||
//!
|
||||
//! iced's default theme is "flat" modern — we override key colours to evoke
|
||||
//! the original Tk aesthetic while still feeling native on modern desktops.
|
||||
|
||||
use iced::Color;
|
||||
|
||||
/// Convert a `#rrggbb` string to an iced `Color`.
|
||||
pub fn hex(s: &str) -> Color {
|
||||
let s = s.trim_start_matches('#');
|
||||
if s.len() != 6 {
|
||||
return Color::BLACK;
|
||||
}
|
||||
let r = u8::from_str_radix(&s[0..2], 16).unwrap_or(0);
|
||||
let g = u8::from_str_radix(&s[2..4], 16).unwrap_or(0);
|
||||
let b = u8::from_str_radix(&s[4..6], 16).unwrap_or(0);
|
||||
Color::from_rgb8(r, g, b)
|
||||
}
|
||||
|
||||
/// Convert an iced Color back to `#rrggbb`.
|
||||
pub fn to_hex(c: Color) -> String {
|
||||
let r = (c.r * 255.0) as u8;
|
||||
let g = (c.g * 255.0) as u8;
|
||||
let b = (c.b * 255.0) as u8;
|
||||
format!("#{r:02x}{g:02x}{b:02x}")
|
||||
}
|
||||
|
||||
/// Palette used throughout shellm.
|
||||
pub struct Palette {
|
||||
/// Window background (light grey, like Tk's default).
|
||||
pub bg: Color,
|
||||
/// Window foreground (text).
|
||||
pub fg: Color,
|
||||
/// Entry widget background.
|
||||
pub ent_back: Color,
|
||||
/// Entry widget foreground.
|
||||
pub ent_fore: Color,
|
||||
/// Listbox background.
|
||||
pub list_back: Color,
|
||||
/// Listbox foreground.
|
||||
pub list_fore: Color,
|
||||
/// Trough / scrollbar background (the #e2e2de from the original).
|
||||
pub trough: Color,
|
||||
/// Active agent indicator (green).
|
||||
pub agent_active: Color,
|
||||
/// Inactive agent indicator (red).
|
||||
pub agent_inactive: Color,
|
||||
/// Accent for selected tab / focus ring.
|
||||
pub accent: Color,
|
||||
/// Border for "groove" frames.
|
||||
pub groove: Color,
|
||||
}
|
||||
|
||||
impl Palette {
|
||||
/// Build a palette from the user's `Configs` colour overrides.
|
||||
pub fn from_configs(c: &crate::data::Configs) -> Self {
|
||||
// The original's defaults when no user override is set:
|
||||
// fore = Tk default (system)
|
||||
// back = Tk default (system)
|
||||
// entfore = Tk default
|
||||
// entback = #fefefe
|
||||
// listfore = Tk default
|
||||
// listback = #ffffff
|
||||
let bg = c.back.as_deref().map(hex).unwrap_or_else(|| hex("#dcdcd5"));
|
||||
let fg = c.fore.as_deref().map(hex).unwrap_or_else(|| hex("#000000"));
|
||||
let ent_back = hex(&c.entback);
|
||||
let ent_fore = c.entfore.as_deref().map(hex).unwrap_or(fg);
|
||||
let list_back = hex(&c.listback);
|
||||
let list_fore = c.listfore.as_deref().map(hex).unwrap_or(fg);
|
||||
|
||||
Self {
|
||||
bg,
|
||||
fg,
|
||||
ent_back,
|
||||
ent_fore,
|
||||
list_back,
|
||||
list_fore,
|
||||
trough: hex("#e2e2de"),
|
||||
agent_active: hex("#4caf50"),
|
||||
agent_inactive: hex("#e53935"),
|
||||
accent: hex("#3d6db5"),
|
||||
groove: hex("#9a9a96"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
//! Convenience re-exports of widget style functions and palette colors.
|
||||
//!
|
||||
//! Palette colors are exposed as functions (because `Color::from_rgb8` is
|
||||
//! not `const fn` in iced 0.13).
|
||||
|
||||
// Style functions — only the ones actually used in the codebase.
|
||||
pub use crate::widgets::styles::{
|
||||
card, danger_button, danger_secondary_button,
|
||||
danger_tint, ghost_button, ghost_button_selected,
|
||||
groove_container, icon_button, menu_bar, modal_panel, primary_button,
|
||||
secondary_button, status_bar, success_tint, tab_button_active,
|
||||
tk_button,
|
||||
};
|
||||
|
||||
// snake_case palette color functions — only the ones actually used.
|
||||
pub use crate::widgets::styles::{
|
||||
accent, accent_pressed, accent_tint_color,
|
||||
danger, success,
|
||||
text_color, text_muted,
|
||||
};
|
||||
|
||||
// UPPERCASE aliases — only the ones actually used as `theme_style::ACCENT()` etc.
|
||||
#[allow(non_snake_case)]
|
||||
pub use crate::widgets::styles::{
|
||||
accent_pressed as ACCENT_PRESSED, border_color as BORDER,
|
||||
danger as DANGER, secondary_bg as SECONDARY,
|
||||
success as SUCCESS, text_muted as TEXT_MUTED,
|
||||
};
|
||||
|
||||
/// Colored dot widget (replaces Unicode `●` glyphs that render as tofu).
|
||||
pub use crate::widgets::styles::status_dot;
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,272 @@
|
|||
//! Composite update actions: connect, scp, multi-connect, agent management.
|
||||
|
||||
use crate::data::history::{append, HistCategory, HistoryEntry};
|
||||
use crate::message::{AgentAction, FilePickPurpose, Message, MultiConnectMode, Tab};
|
||||
use crate::state::{hostname, PendingOp, Shellm};
|
||||
use crate::ssh::{launch_ssh, launch_sftp};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Launch ssh for every selected connection. If any selected profile has
|
||||
/// `askuserspec` set, we open the ask-for-user modal first and defer the
|
||||
/// connect until the user submits a value.
|
||||
pub fn connect_selected(state: &mut Shellm) -> iced::Task<Message> {
|
||||
let selected: Vec<String> = state
|
||||
.selected_connections
|
||||
.iter()
|
||||
.filter_map(|&i| state.connection_name_at(i).cloned())
|
||||
.collect();
|
||||
|
||||
if selected.is_empty() {
|
||||
state.set_status("No entry selected");
|
||||
return iced::Task::none();
|
||||
}
|
||||
|
||||
// If any profile needs an interactive user prompt, open the modal and
|
||||
// stop. The modal's submit handler will re-enter `connect_for_profile`
|
||||
// for that specific profile.
|
||||
for name in &selected {
|
||||
if let Some(p) = state.profiles.get(name) {
|
||||
if p.askuserspec {
|
||||
state.ask_user_modal = crate::state::AskUserModalState {
|
||||
visible: true,
|
||||
prompt: format!("Login for '{}':", p.title),
|
||||
value: String::new(),
|
||||
pending: Some(PendingOp::ConnectForProfile { profile_name: name.clone() }),
|
||||
};
|
||||
return iced::Task::none();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No prompts needed — launch all.
|
||||
let local_host = hostname();
|
||||
let mut launched_any = false;
|
||||
for name in selected {
|
||||
if let Some(p) = state.profiles.get(&name).cloned() {
|
||||
if let Err(e) = launch_one_profile(state, &p, &local_host) {
|
||||
state.set_status(format!("Launch failed: {e}"));
|
||||
} else {
|
||||
launched_any = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if launched_any {
|
||||
state.set_status("Connection launched");
|
||||
}
|
||||
iced::Task::none()
|
||||
}
|
||||
|
||||
/// Continue a deferred connect after the user submitted a username via the
|
||||
/// ask-for-user modal.
|
||||
pub fn connect_for_profile_with_user(state: &mut Shellm, profile_name: &str, user: &str) {
|
||||
let Some(mut p) = state.profiles.get(profile_name).cloned() else {
|
||||
state.set_status(format!("Profile '{profile_name}' not found"));
|
||||
return;
|
||||
};
|
||||
p.user = user.to_string();
|
||||
p.askuserspec = false;
|
||||
let local_host = hostname();
|
||||
if let Err(e) = launch_one_profile(state, &p, &local_host) {
|
||||
state.set_status(format!("Launch failed: {e}"));
|
||||
} else {
|
||||
state.set_status("Connection launched");
|
||||
}
|
||||
}
|
||||
|
||||
/// Launch a single profile (already resolved — no `<ASKFORUSER>`).
|
||||
fn launch_one_profile(state: &mut Shellm, p: &crate::data::Profile, local_host: &str) -> Result<(), String> {
|
||||
let cmd = crate::ssh::build_ssh_command(p, &state.configs, local_host);
|
||||
launch_ssh(
|
||||
&state.configs,
|
||||
&p.title,
|
||||
&cmd,
|
||||
p.termicon,
|
||||
p.connwait,
|
||||
).map_err(|e| e.to_string())?;
|
||||
let _ = append(&HistoryEntry::new(
|
||||
HistCategory::SshConnect,
|
||||
format!("{} ({}@{}:{})", p.title, p.user, p.host, p.port),
|
||||
));
|
||||
let _ = crate::data::history::append_trace(&cmd.to_shell_line());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open the SCP browser tab for the selected connection. This is the
|
||||
/// default file-transfer action — the SCP browser uses `scp`+`ssh` under
|
||||
/// the hood (with an opt-in `sftp -b -` backend for restricted sshd
|
||||
/// configs).
|
||||
pub fn connect_scp(state: &mut Shellm) -> iced::Task<Message> {
|
||||
let selected: Vec<String> = state
|
||||
.selected_connections
|
||||
.iter()
|
||||
.filter_map(|&i| state.connection_name_at(i).cloned())
|
||||
.collect();
|
||||
|
||||
if selected.is_empty() {
|
||||
state.set_status("No entry selected");
|
||||
return iced::Task::none();
|
||||
}
|
||||
|
||||
// Switch to the SCP browser tab so the user can interact with the
|
||||
// dual-pane file manager. The SCP browser has its own profile picker
|
||||
// and Connect button; the user selects the profile there too.
|
||||
state.active_tab = Tab::ScpBrowser;
|
||||
state.set_status("SCP browser opened — pick a profile and click Connect");
|
||||
iced::Task::none()
|
||||
}
|
||||
|
||||
/// Launch the configured external SFTP GUI client (FileZilla, Nautilus,
|
||||
/// Konqueror) for every selected connection. This is the opt-in alternative
|
||||
/// to the built-in SCP browser — use it when you prefer a standalone GUI
|
||||
/// client. The external client is launched via an `sftp://` URL.
|
||||
pub fn launch_external_sftp(state: &mut Shellm) -> iced::Task<Message> {
|
||||
let selected: Vec<String> = state
|
||||
.selected_connections
|
||||
.iter()
|
||||
.filter_map(|&i| state.connection_name_at(i).cloned())
|
||||
.collect();
|
||||
|
||||
if selected.is_empty() {
|
||||
state.set_status("No entry selected");
|
||||
return iced::Task::none();
|
||||
}
|
||||
|
||||
for name in selected {
|
||||
let Some(p) = state.profiles.get(&name).cloned() else { continue };
|
||||
let user = if p.user.is_empty() { std::env::var("USER").unwrap_or_default() } else { p.user.clone() };
|
||||
let url = format!("sftp://{}@{}:{}", user, p.host, p.port);
|
||||
let _ = launch_sftp(&state.configs, &url);
|
||||
let _ = append(&HistoryEntry::new(HistCategory::SftpConnect, url));
|
||||
}
|
||||
state.set_status("External SFTP client launched");
|
||||
iced::Task::none()
|
||||
}
|
||||
|
||||
/// Multi-connect (cssh or multixterm).
|
||||
pub fn multi_connect(state: &mut Shellm, mode: MultiConnectMode) -> iced::Task<Message> {
|
||||
let selected: Vec<String> = state
|
||||
.selected_connections
|
||||
.iter()
|
||||
.filter_map(|&i| state.connection_name_at(i).cloned())
|
||||
.collect();
|
||||
|
||||
if selected.is_empty() {
|
||||
state.set_status("No entry selected");
|
||||
return iced::Task::none();
|
||||
}
|
||||
|
||||
let mut hosts = Vec::new();
|
||||
for name in &selected {
|
||||
if let Some(p) = state.profiles.get(name) {
|
||||
let user = if p.user.is_empty() { std::env::var("USER").unwrap_or_default() } else { p.user.clone() };
|
||||
hosts.push(format!("{}@{}:{}", user, p.host, p.port));
|
||||
}
|
||||
}
|
||||
|
||||
let (prog, args): (&str, Vec<String>) = match mode {
|
||||
MultiConnectMode::Cssh => ("cssh", hosts.clone()),
|
||||
MultiConnectMode::Multixterm => {
|
||||
let mut a = vec!["-xc".to_string(), "ssh %n".to_string()];
|
||||
a.extend(hosts.clone());
|
||||
("multixterm", a)
|
||||
}
|
||||
};
|
||||
|
||||
if crate::data::paths::which(prog).is_none() {
|
||||
state.set_status(format!("{prog} not found on PATH"));
|
||||
return iced::Task::none();
|
||||
}
|
||||
|
||||
let mut cmd = std::process::Command::new(prog);
|
||||
cmd.args(&args);
|
||||
let _ = cmd.spawn();
|
||||
state.set_status(format!("Launched {prog}"));
|
||||
iced::Task::none()
|
||||
}
|
||||
|
||||
/// Manage the SSH agent.
|
||||
pub fn manage_agent(state: &mut Shellm, action: AgentAction) -> iced::Task<Message> {
|
||||
match action {
|
||||
AgentAction::Launch => {
|
||||
if state.agent_state.is_active() {
|
||||
state.set_status("Agent already running");
|
||||
return iced::Task::none();
|
||||
}
|
||||
let configs = state.configs.clone();
|
||||
state.busy = true;
|
||||
iced::Task::perform(
|
||||
async move {
|
||||
let r = crate::agent::launch_agent(&configs).map_err(|e| e.to_string());
|
||||
Message::AgentLaunched(r)
|
||||
},
|
||||
|m| m,
|
||||
)
|
||||
}
|
||||
AgentAction::Kill => {
|
||||
if state.agent_state == crate::agent::AgentState::External {
|
||||
state.set_status("Cannot kill external agent");
|
||||
return iced::Task::none();
|
||||
}
|
||||
if !state.agent_state.is_active() {
|
||||
state.set_status("No agent to kill");
|
||||
return iced::Task::none();
|
||||
}
|
||||
let configs = state.configs.clone();
|
||||
iced::Task::perform(
|
||||
async move {
|
||||
let _ = crate::agent::kill_agent(&configs);
|
||||
Message::AgentKilled
|
||||
},
|
||||
|m| m,
|
||||
)
|
||||
}
|
||||
AgentAction::Info => {
|
||||
let mut text = String::new();
|
||||
text.push_str(&format!(
|
||||
"SSH_AUTH_SOCK: {}\n",
|
||||
state.agent_info.auth_sock.clone().unwrap_or_else(|| "(not set)".into())
|
||||
));
|
||||
text.push_str(&format!(
|
||||
"SSH_AGENT_PID: {}\n\n",
|
||||
state.agent_info.agent_pid.clone().unwrap_or_else(|| "(not set)".into())
|
||||
));
|
||||
text.push_str(&crate::agent::list_identities());
|
||||
state.agent_info_dialog.text = text;
|
||||
state.agent_info_dialog.visible = true;
|
||||
iced::Task::none()
|
||||
}
|
||||
AgentAction::AddIdentity => {
|
||||
// Open a file picker. The result is routed via FilePicked.
|
||||
iced::Task::perform(
|
||||
crate::util::pick_file_async(),
|
||||
move |p| Message::FilePicked(FilePickPurpose::AddIdentity, p),
|
||||
)
|
||||
}
|
||||
AgentAction::RemoveIdentity(idx) => {
|
||||
let Some(id) = state.identities.get(idx).cloned() else {
|
||||
return iced::Task::none();
|
||||
};
|
||||
let configs = state.configs.clone();
|
||||
let path = PathBuf::from(&id);
|
||||
iced::Task::perform(
|
||||
async move {
|
||||
let r = crate::agent::remove_identity(&configs, &path).map_err(|e| e.to_string());
|
||||
Message::IdentityRemoved(r)
|
||||
},
|
||||
|m| m,
|
||||
)
|
||||
}
|
||||
AgentAction::SetDefIdent => {
|
||||
iced::Task::perform(
|
||||
crate::util::pick_file_async(),
|
||||
move |p| Message::FilePicked(FilePickPurpose::SetDefIdent, p),
|
||||
)
|
||||
}
|
||||
AgentAction::RmDefIdent => {
|
||||
state.configs.defident = None;
|
||||
let _ = state.configs.save();
|
||||
state.set_status("Default identity removed");
|
||||
iced::Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
//! Small utility helpers.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Pick a file using the best available native dialog. We try (in order):
|
||||
/// 1. `zenity` (GNOME)
|
||||
/// 2. `kdialog` (KDE)
|
||||
/// 3. fall back to `None`
|
||||
pub async fn pick_file_async() -> Option<PathBuf> {
|
||||
tokio::task::spawn_blocking(pick_file_sync).await.ok().flatten()
|
||||
}
|
||||
|
||||
fn pick_file_sync() -> Option<PathBuf> {
|
||||
if let Some(p) = crate::data::paths::which("zenity") {
|
||||
let out = std::process::Command::new(p)
|
||||
.arg("--file-selection")
|
||||
.output()
|
||||
.ok()?;
|
||||
if out.status.success() {
|
||||
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
if !s.is_empty() {
|
||||
return Some(PathBuf::from(s));
|
||||
}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
if let Some(p) = crate::data::paths::which("kdialog") {
|
||||
let out = std::process::Command::new(p)
|
||||
.arg("--getopenfilename")
|
||||
.arg(".")
|
||||
.output()
|
||||
.ok()?;
|
||||
if out.status.success() {
|
||||
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
if !s.is_empty() {
|
||||
return Some(PathBuf::from(s));
|
||||
}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Open a URL in the user's default browser.
|
||||
pub fn open_url(url: &str) {
|
||||
let _ = open::that(url);
|
||||
}
|
||||
|
|
@ -0,0 +1,197 @@
|
|||
//! Top-level view — modern main window layout.
|
||||
|
||||
use iced::widget::{button, column, container, row, text};
|
||||
use iced::{Alignment, Color, Element, Length, Padding};
|
||||
|
||||
use crate::dialogs;
|
||||
use crate::message::{Message, Tab};
|
||||
use crate::state::Shellm;
|
||||
use crate::theme_style;
|
||||
use crate::widgets::{configs_page, connections_page, keys_agent_page, profiles_page, tab_bar};
|
||||
|
||||
pub fn view(state: &Shellm) -> Element<Message> {
|
||||
// Tab bar (hidden in satellite mode).
|
||||
let tabs = tab_bar(state);
|
||||
|
||||
// Active page content.
|
||||
let page: Element<Message> = if state.satellite_mode {
|
||||
satellite_view(state)
|
||||
} else {
|
||||
match state.active_tab {
|
||||
Tab::Connections => connections_page::view(state),
|
||||
Tab::Profiles => profiles_page::view(state),
|
||||
Tab::KeysAgent => keys_agent_page::view(state),
|
||||
Tab::Configs => configs_page::view(state),
|
||||
Tab::ScpBrowser => crate::widgets::scp_browser_page::view(state),
|
||||
}
|
||||
};
|
||||
|
||||
// ===== Status bar: dot + status text (left), agent pill (right) =====
|
||||
let agent_label = state.agent_state.label();
|
||||
let agent_active = state.agent_state.is_active();
|
||||
let dot_color = if agent_active {
|
||||
theme_style::SUCCESS()
|
||||
} else {
|
||||
theme_style::DANGER()
|
||||
};
|
||||
|
||||
let status_dot = theme_style::status_dot(dot_color, 8.0);
|
||||
|
||||
let status_left = container(
|
||||
row![status_dot, text(state.status.as_str()).size(12).color(theme_style::text_muted())]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
.padding([6, 12])
|
||||
.width(Length::Fill)
|
||||
.style(theme_style::status_bar);
|
||||
|
||||
let agent_pill = container(
|
||||
row![
|
||||
theme_style::status_dot(dot_color, 7.0),
|
||||
text(agent_label).size(11).color(if agent_active {
|
||||
Color::from_rgb8(0x16, 0x6E, 0x35)
|
||||
} else {
|
||||
Color::from_rgb8(0x99, 0x1B, 0x1B)
|
||||
}),
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
.padding([4, 10])
|
||||
.style(move |t| {
|
||||
if agent_active {
|
||||
theme_style::success_tint(t)
|
||||
} else {
|
||||
theme_style::danger_tint(t)
|
||||
}
|
||||
});
|
||||
|
||||
let status_right = container(agent_pill)
|
||||
.padding([4, 8])
|
||||
.width(Length::Fixed(220.0))
|
||||
.style(theme_style::status_bar);
|
||||
|
||||
let status_bar = row![status_left, status_right]
|
||||
.spacing(0)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
// ===== Menu bar =====
|
||||
let menu = row![
|
||||
menu_button("History", Message::OpenHistory),
|
||||
menu_button("Trace", Message::OpenTrace),
|
||||
menu_button("Export", Message::ExportProfileShow),
|
||||
menu_button("Remote", Message::RemoteAcctOpen),
|
||||
menu_button("Colors", Message::OpenColors),
|
||||
menu_button("Fonts", Message::OpenFonts),
|
||||
menu_button("Protect", Message::ProtectDataPassphraseShow),
|
||||
menu_button("About", Message::OpenAbout),
|
||||
iced::widget::horizontal_space().width(Length::Fill),
|
||||
menu_button_danger("Exit", Message::Exit),
|
||||
]
|
||||
.spacing(2)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
let menu_container = container(menu)
|
||||
.padding([4, 8])
|
||||
.width(Length::Fill)
|
||||
.style(theme_style::menu_bar);
|
||||
|
||||
let main: Element<Message> = column![menu_container, tabs, page, status_bar]
|
||||
.spacing(0)
|
||||
.height(Length::Fill)
|
||||
.width(Length::Fill)
|
||||
.into();
|
||||
|
||||
// Compose with any visible dialog overlays.
|
||||
dialogs::overlay(main, state)
|
||||
}
|
||||
|
||||
/// The satellite mini-window content.
|
||||
fn satellite_view(state: &Shellm) -> Element<Message> {
|
||||
let agent_label = state.agent_state.label();
|
||||
let is_active = state.agent_state.is_active();
|
||||
let dot_color = if is_active {
|
||||
theme_style::SUCCESS()
|
||||
} else {
|
||||
theme_style::DANGER()
|
||||
};
|
||||
|
||||
let mut items: Vec<Element<Message>> = Vec::new();
|
||||
items.push(
|
||||
text("Quick connect")
|
||||
.size(11)
|
||||
.color(theme_style::text_muted())
|
||||
.into(),
|
||||
);
|
||||
for (idx, name) in state.profile_order.iter().enumerate() {
|
||||
if let Some(p) = state.profiles.get(name) {
|
||||
let line = p.display_line();
|
||||
items.push(
|
||||
button(text(line).size(12))
|
||||
.on_press(Message::ConnectionActivated(idx))
|
||||
.padding(Padding::new(6.0))
|
||||
.width(Length::Fill)
|
||||
.style(theme_style::ghost_button)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let list = iced::widget::scrollable(iced::widget::column(items).spacing(1))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
|
||||
let indicator = container(
|
||||
row![
|
||||
theme_style::status_dot(dot_color, 10.0),
|
||||
text(agent_label).size(12).color(if is_active {
|
||||
Color::from_rgb8(0x16, 0x6E, 0x35)
|
||||
} else {
|
||||
Color::from_rgb8(0x99, 0x1B, 0x1B)
|
||||
})
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
.padding(8)
|
||||
.width(Length::Fill)
|
||||
.center(Length::Fill)
|
||||
.style(move |t| {
|
||||
if is_active {
|
||||
theme_style::success_tint(t)
|
||||
} else {
|
||||
theme_style::danger_tint(t)
|
||||
}
|
||||
});
|
||||
|
||||
let back_btn = button(text("Back to main").size(11))
|
||||
.on_press(Message::ToggleSatellite)
|
||||
.padding(Padding::new(6.0))
|
||||
.width(Length::Fill)
|
||||
.style(theme_style::secondary_button);
|
||||
|
||||
column![
|
||||
indicator,
|
||||
container(list).padding(8).style(theme_style::card),
|
||||
container(back_btn).padding(4),
|
||||
]
|
||||
.spacing(0)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn menu_button(label: &str, msg: Message) -> iced::widget::Button<Message> {
|
||||
button(text(label).size(12).color(theme_style::text_color()))
|
||||
.on_press(msg)
|
||||
.padding(Padding::new(6.0))
|
||||
.style(theme_style::ghost_button)
|
||||
}
|
||||
|
||||
fn menu_button_danger(label: &str, msg: Message) -> iced::widget::Button<Message> {
|
||||
button(text(label).size(12).color(theme_style::danger()))
|
||||
.on_press(msg)
|
||||
.padding(Padding::new(6.0))
|
||||
.style(theme_style::ghost_button)
|
||||
}
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
//! Configurations page — modern card-based layout with availability indicators.
|
||||
|
||||
use iced::widget::{button, column, container, pick_list, row, text, text_input};
|
||||
use iced::{Alignment, Element, Length, Padding};
|
||||
|
||||
use crate::data::{ScpBackend, SftpClient, TerminalKind};
|
||||
use crate::message::{ConfigBin, Message};
|
||||
use crate::state::Shellm;
|
||||
use crate::theme_style;
|
||||
|
||||
pub fn view(state: &Shellm) -> Element<Message> {
|
||||
let c = &state.configs;
|
||||
|
||||
let mut rows: Vec<Element<Message>> = Vec::new();
|
||||
|
||||
// Section: External binaries.
|
||||
rows.push(section_label("External programs").into());
|
||||
|
||||
for &bin in ConfigBin::all() {
|
||||
let val: String = match bin {
|
||||
ConfigBin::Ssh => c.sshbin.clone(),
|
||||
ConfigBin::Keygen => c.keygenbin.clone(),
|
||||
ConfigBin::Agent => c.agentbin.clone(),
|
||||
ConfigBin::Add => c.addbin.clone(),
|
||||
ConfigBin::Askpass => c.askpassbin.clone(),
|
||||
ConfigBin::Scp => c.scpbin.clone(),
|
||||
ConfigBin::Browser => c.browserbin.clone(),
|
||||
};
|
||||
let input = text_input(bin.label(), &val)
|
||||
.on_input(move |s| Message::ConfigBinChanged(bin, s))
|
||||
.size(13);
|
||||
let browse_btn = button(text("Browse").size(11))
|
||||
.on_press(Message::BrowseConfigBin(bin))
|
||||
.padding(Padding::new(6.0))
|
||||
.style(theme_style::secondary_button);
|
||||
rows.push(
|
||||
row![
|
||||
text(bin.label())
|
||||
.size(12)
|
||||
.width(Length::Fixed(120.0))
|
||||
.color(theme_style::text_muted()),
|
||||
input,
|
||||
browse_btn,
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(Alignment::Center)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
// Section: Terminal emulator.
|
||||
rows.push(iced::widget::vertical_space().height(Length::Fixed(8.0)).into());
|
||||
rows.push(section_label("Terminal emulator").into());
|
||||
|
||||
let term_kinds: Vec<TerminalKind> = TerminalKind::all().to_vec();
|
||||
let term_pick = pick_list(term_kinds, Some(c.termver), |t| {
|
||||
Message::ConfigTerminalChanged(t)
|
||||
});
|
||||
let detected = TerminalKind::detect_default();
|
||||
let detected_label = format!("Auto-detected: {}", detected.label());
|
||||
rows.push(
|
||||
row![
|
||||
text("Terminal")
|
||||
.size(12)
|
||||
.width(Length::Fixed(120.0))
|
||||
.color(theme_style::text_muted()),
|
||||
term_pick,
|
||||
text(detected_label).size(11).color(theme_style::text_muted()),
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(Alignment::Center)
|
||||
.into(),
|
||||
);
|
||||
|
||||
// List which terminals are actually available on PATH.
|
||||
let mut available: Vec<&'static str> = Vec::new();
|
||||
let mut unavailable: Vec<&'static str> = Vec::new();
|
||||
for k in TerminalKind::all() {
|
||||
if k.is_available() {
|
||||
available.push(k.label());
|
||||
} else {
|
||||
unavailable.push(k.label());
|
||||
}
|
||||
}
|
||||
rows.push(
|
||||
row![
|
||||
text("Available:")
|
||||
.size(11)
|
||||
.color(theme_style::text_muted()),
|
||||
text(available.join(", ")).size(11),
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(Alignment::Center)
|
||||
.into(),
|
||||
);
|
||||
let _ = unavailable;
|
||||
|
||||
// Section: External SFTP client (for the "External SFTP" context-menu
|
||||
// action — the built-in SCP browser does not use this; it uses scp+ssh
|
||||
// directly).
|
||||
rows.push(iced::widget::vertical_space().height(Length::Fixed(8.0)).into());
|
||||
rows.push(section_label("External SFTP client").into());
|
||||
|
||||
let sftp_opts: Vec<SftpClient> = SftpClient::all().to_vec();
|
||||
let sftp_pick = pick_list(sftp_opts, Some(c.sftpbin), |s| Message::ConfigSftpChanged(s));
|
||||
rows.push(
|
||||
row![
|
||||
text("External SFTP")
|
||||
.size(12)
|
||||
.width(Length::Fixed(120.0))
|
||||
.color(theme_style::text_muted()),
|
||||
sftp_pick,
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(Alignment::Center)
|
||||
.into(),
|
||||
);
|
||||
rows.push(
|
||||
text("Used only by the \"External SFTP\" context-menu action, which launches a \
|
||||
standalone GUI client (FileZilla, Nautilus, Konqueror) via an sftp:// URL. \
|
||||
The built-in SCP browser tab uses scp+ssh directly and does not need this.")
|
||||
.size(10)
|
||||
.color(theme_style::text_muted())
|
||||
.into(),
|
||||
);
|
||||
|
||||
// Section: SCP browser backend.
|
||||
rows.push(iced::widget::vertical_space().height(Length::Fixed(8.0)).into());
|
||||
rows.push(section_label("SCP browser backend").into());
|
||||
|
||||
let scp_backends = vec![ScpBackend::Scp, ScpBackend::Sftp];
|
||||
let scp_pick = pick_list(
|
||||
scp_backends,
|
||||
Some(c.scp_backend),
|
||||
|b| Message::ConfigScpBackendChanged(b),
|
||||
);
|
||||
rows.push(
|
||||
row![
|
||||
text("SCP backend")
|
||||
.size(12)
|
||||
.width(Length::Fixed(120.0))
|
||||
.color(theme_style::text_muted()),
|
||||
scp_pick,
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(Alignment::Center)
|
||||
.into(),
|
||||
);
|
||||
rows.push(
|
||||
text("Default is `scp + ssh` — uses ssh for remote ls/mkdir/rm and scp for transfers. \
|
||||
Switch to `sftp -b -` only if your sshd restricts shell access (e.g. \
|
||||
`ForceCommand internal-sftp`).")
|
||||
.size(10)
|
||||
.color(theme_style::text_muted())
|
||||
.into(),
|
||||
);
|
||||
|
||||
// Save button.
|
||||
rows.push(iced::widget::vertical_space().height(Length::Fixed(12.0)).into());
|
||||
rows.push(
|
||||
button(text("Save configuration").size(13))
|
||||
.on_press(Message::SaveConfigs)
|
||||
.padding(Padding::new(10.0))
|
||||
.style(theme_style::primary_button)
|
||||
.into(),
|
||||
);
|
||||
|
||||
let body = container(column(rows).spacing(4))
|
||||
.padding(16)
|
||||
.style(theme_style::card)
|
||||
.width(Length::Fill);
|
||||
|
||||
column![
|
||||
text("Configurations").size(16).font(iced::Font {
|
||||
weight: iced::font::Weight::Semibold,
|
||||
..Default::default()
|
||||
}),
|
||||
body
|
||||
]
|
||||
.spacing(8)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn section_label(s: &str) -> iced::widget::Text<'static> {
|
||||
text(s.to_string())
|
||||
.size(11)
|
||||
.color(theme_style::text_muted())
|
||||
.font(iced::Font {
|
||||
weight: iced::font::Weight::Bold,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
//! Connections page — modern card-based list of profiles + action buttons.
|
||||
|
||||
use iced::widget::{button, column, container, row, scrollable, text};
|
||||
use iced::{Alignment, Border, Element, Length, Padding};
|
||||
|
||||
use crate::message::{Message, MultiConnectMode};
|
||||
use crate::state::Shellm;
|
||||
use crate::theme_style;
|
||||
|
||||
/// Render the Connections page.
|
||||
pub fn view(state: &Shellm) -> Element<Message> {
|
||||
// ===== List of connections (left, fills available width) =====
|
||||
let mut list_items: Vec<Element<Message>> = Vec::new();
|
||||
for (idx, name) in state.profile_order.iter().enumerate() {
|
||||
if let Some(p) = state.profiles.get(name) {
|
||||
let is_selected = state.selected_connections.contains(&idx);
|
||||
let line = p.display_line();
|
||||
let host_label = if p.host.is_empty() {
|
||||
"(no host configured)".to_string()
|
||||
} else {
|
||||
format!("{}@{}:{}", p.user, p.host, p.port)
|
||||
};
|
||||
|
||||
// Each row is a button with a two-line layout.
|
||||
let title_text = text(line).size(13).font(iced::Font {
|
||||
weight: if is_selected {
|
||||
iced::font::Weight::Semibold
|
||||
} else {
|
||||
iced::font::Weight::Normal
|
||||
},
|
||||
..Default::default()
|
||||
});
|
||||
let sub_text = text(host_label).size(11).color(theme_style::text_muted());
|
||||
let content = column![title_text, sub_text].spacing(2);
|
||||
|
||||
let btn = button(content)
|
||||
.on_press(Message::ConnectionSelected(idx))
|
||||
.padding(Padding::new(10.0))
|
||||
.width(Length::Fill)
|
||||
.style(move |t, s| {
|
||||
if is_selected {
|
||||
theme_style::ghost_button_selected(t, s)
|
||||
} else {
|
||||
theme_style::ghost_button(t, s)
|
||||
}
|
||||
});
|
||||
list_items.push(btn.into());
|
||||
}
|
||||
}
|
||||
|
||||
if list_items.is_empty() {
|
||||
list_items.push(
|
||||
container(
|
||||
column![
|
||||
text("No connections configured").size(13).color(theme_style::text_muted()),
|
||||
text("Switch to the Profiles tab to create one.")
|
||||
.size(11)
|
||||
.color(theme_style::text_muted()),
|
||||
]
|
||||
.spacing(4),
|
||||
)
|
||||
.padding(20)
|
||||
.center(Length::Fill)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
let list = scrollable(column(list_items).spacing(2))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
|
||||
// ===== Action buttons (right column) =====
|
||||
let connect_btn = action_button("Connect", Message::ConnectSelected, true);
|
||||
let scp_btn = action_button("SCP", Message::ConnectScpSelected, false);
|
||||
let multi_cssh = action_button(
|
||||
"Multi (cssh)",
|
||||
Message::MultiConnect(MultiConnectMode::Cssh),
|
||||
false,
|
||||
);
|
||||
let multi_xterm = action_button(
|
||||
"Multi (xterm)",
|
||||
Message::MultiConnect(MultiConnectMode::Multixterm),
|
||||
false,
|
||||
);
|
||||
let new_btn = action_button("New", Message::NewConnection, false);
|
||||
let edit_btn = action_button("Edit", Message::EditSelectedConnection, false);
|
||||
let delete_btn = danger_action_button("Delete", Message::DeleteSelectedConnection);
|
||||
|
||||
let button_col = column![
|
||||
connect_btn,
|
||||
scp_btn,
|
||||
multi_cssh,
|
||||
multi_xterm,
|
||||
iced::widget::vertical_space().height(Length::Fixed(8.0)),
|
||||
new_btn,
|
||||
edit_btn,
|
||||
delete_btn,
|
||||
]
|
||||
.spacing(4)
|
||||
.width(Length::Fixed(140.0));
|
||||
|
||||
// ===== Body: list + button column =====
|
||||
let list_panel = container(list)
|
||||
.padding(8)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.style(theme_style::card);
|
||||
|
||||
let body = row![list_panel, container(button_col).padding(8)]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Start)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
|
||||
let _ = Border::default; // silence unused import warning
|
||||
column![text("Connections").size(16).font(iced::Font {
|
||||
weight: iced::font::Weight::Semibold,
|
||||
..Default::default()
|
||||
}), body]
|
||||
.spacing(8)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Standard action button — secondary style by default, primary when emphasized.
|
||||
fn action_button(label: &str, msg: Message, emphasized: bool) -> Element<Message> {
|
||||
let b = button(text(label).size(12))
|
||||
.on_press(msg)
|
||||
.padding(Padding::new(8.0))
|
||||
.width(Length::Fill)
|
||||
.style(if emphasized {
|
||||
theme_style::primary_button
|
||||
} else {
|
||||
theme_style::secondary_button
|
||||
});
|
||||
b.into()
|
||||
}
|
||||
|
||||
/// Danger action button (red).
|
||||
fn danger_action_button(label: &str, msg: Message) -> Element<Message> {
|
||||
let b = button(text(label).size(12))
|
||||
.on_press(msg)
|
||||
.padding(Padding::new(8.0))
|
||||
.width(Length::Fill)
|
||||
.style(theme_style::danger_button);
|
||||
b.into()
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
//! Keys/Agent page — modern card-based layout.
|
||||
|
||||
use iced::widget::{button, checkbox, column, container, row, scrollable, text};
|
||||
use iced::{Element, Length, Padding};
|
||||
|
||||
use crate::message::{AgentAction, Message};
|
||||
use crate::state::Shellm;
|
||||
use crate::theme_style;
|
||||
|
||||
pub fn view(state: &Shellm) -> Element<Message> {
|
||||
// ===== Key handling panel =====
|
||||
let key_panel = container(
|
||||
column![
|
||||
section_label("Key handling"),
|
||||
action_button("Manage keypairs", Message::OpenKeygenDialog, false),
|
||||
action_button("Manage hostkeys", Message::OpenHostkeysDialog, false),
|
||||
action_button("Distribute public keys", Message::OpenKeydistWizard, false),
|
||||
]
|
||||
.spacing(6),
|
||||
)
|
||||
.padding(12)
|
||||
.style(theme_style::card)
|
||||
.width(Length::Fill);
|
||||
|
||||
// ===== SSH-Agent panel =====
|
||||
let mut ident_items: Vec<Element<Message>> = Vec::new();
|
||||
for (idx, id) in state.identities.iter().enumerate() {
|
||||
ident_items.push(
|
||||
row![
|
||||
text(id.as_str()).size(12).width(Length::Fill),
|
||||
button(text("Remove").size(11))
|
||||
.on_press(Message::ManageAgent(AgentAction::RemoveIdentity(idx)))
|
||||
.padding(Padding::new(4.0))
|
||||
.style(theme_style::danger_secondary_button),
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(iced::Alignment::Center)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if ident_items.is_empty() {
|
||||
ident_items.push(
|
||||
text("No identities loaded")
|
||||
.size(11)
|
||||
.color(theme_style::text_muted())
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
let idents_scroll = scrollable(column(ident_items).spacing(2))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fixed(120.0));
|
||||
|
||||
let launcher_checked = state.configs.launcher;
|
||||
let agent_panel = container(
|
||||
column![
|
||||
section_label("SSH-Agent"),
|
||||
checkbox("Launch agent at startup", launcher_checked)
|
||||
.on_toggle(Message::LauncherToggled)
|
||||
.size(13),
|
||||
action_button("Launch agent", Message::ManageAgent(AgentAction::Launch), true),
|
||||
action_button("Add identity", Message::ManageAgent(AgentAction::AddIdentity), false),
|
||||
row![
|
||||
action_button("Set def. ident.", Message::ManageAgent(AgentAction::SetDefIdent), false),
|
||||
action_button("Remove def. ident.", Message::ManageAgent(AgentAction::RmDefIdent), false),
|
||||
].spacing(4),
|
||||
action_button("Agent info", Message::ManageAgent(AgentAction::Info), false),
|
||||
danger_action_button("Kill agent", Message::ManageAgent(AgentAction::Kill)),
|
||||
section_label("Loaded identities"),
|
||||
idents_scroll,
|
||||
]
|
||||
.spacing(6),
|
||||
)
|
||||
.padding(12)
|
||||
.style(theme_style::card)
|
||||
.width(Length::Fill);
|
||||
|
||||
let body = row![key_panel, agent_panel]
|
||||
.spacing(8)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
|
||||
column![
|
||||
text("Keys & Agent").size(16).font(iced::Font {
|
||||
weight: iced::font::Weight::Semibold,
|
||||
..Default::default()
|
||||
}),
|
||||
body
|
||||
]
|
||||
.spacing(8)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn section_label(s: &str) -> iced::widget::Text<'static> {
|
||||
text(s.to_string())
|
||||
.size(11)
|
||||
.color(theme_style::text_muted())
|
||||
.font(iced::Font {
|
||||
weight: iced::font::Weight::Bold,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn action_button(label: &str, msg: Message, emphasized: bool) -> Element<Message> {
|
||||
let b = button(text(label).size(12))
|
||||
.on_press(msg)
|
||||
.padding(Padding::new(8.0))
|
||||
.width(Length::Fill)
|
||||
.style(if emphasized {
|
||||
theme_style::primary_button
|
||||
} else {
|
||||
theme_style::secondary_button
|
||||
});
|
||||
b.into()
|
||||
}
|
||||
|
||||
fn danger_action_button(label: &str, msg: Message) -> Element<Message> {
|
||||
let b = button(text(label).size(12))
|
||||
.on_press(msg)
|
||||
.padding(Padding::new(8.0))
|
||||
.width(Length::Fill)
|
||||
.style(theme_style::danger_button);
|
||||
b.into()
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
//! Widgets module — reusable UI components.
|
||||
|
||||
pub mod configs_page;
|
||||
pub mod connections_page;
|
||||
pub mod keys_agent_page;
|
||||
pub mod profiles_page;
|
||||
pub mod scp_browser_page;
|
||||
pub mod styles;
|
||||
pub mod tab_bar;
|
||||
|
||||
pub use tab_bar::tab_bar;
|
||||
|
|
@ -0,0 +1,303 @@
|
|||
//! Profiles editor page — modern card-based layout.
|
||||
|
||||
use iced::widget::{button, checkbox, column, container, pick_list, row, scrollable, text, text_input};
|
||||
use iced::{Alignment, Element, Length, Padding};
|
||||
|
||||
use crate::data::{Algo, IpVer, SshVer};
|
||||
use crate::message::{Message, ProfileCheckbox};
|
||||
use crate::state::Shellm;
|
||||
use crate::theme_style;
|
||||
|
||||
/// Render the Profiles editor page.
|
||||
pub fn view(state: &Shellm) -> Element<Message> {
|
||||
let p = &state.editing_profile;
|
||||
|
||||
// ===== Left column: profiles list =====
|
||||
let mut profile_items: Vec<Element<Message>> = Vec::new();
|
||||
for (idx, name) in state.profile_order.iter().enumerate() {
|
||||
if let Some(pr) = state.profiles.get(name) {
|
||||
let title = pr.title.clone();
|
||||
let host_line = if pr.host.is_empty() {
|
||||
"(no host)".to_string()
|
||||
} else {
|
||||
format!("{}@{}", pr.user, pr.host)
|
||||
};
|
||||
let is_selected = state.editing_profile_name == *name;
|
||||
|
||||
let content = column![
|
||||
text(title).size(12).font(iced::Font {
|
||||
weight: if is_selected {
|
||||
iced::font::Weight::Semibold
|
||||
} else {
|
||||
iced::font::Weight::Normal
|
||||
},
|
||||
..Default::default()
|
||||
}),
|
||||
text(host_line).size(10).color(theme_style::text_muted()),
|
||||
]
|
||||
.spacing(2);
|
||||
|
||||
let btn = button(content)
|
||||
.on_press(Message::ProfileSelected(idx))
|
||||
.padding(Padding::new(8.0))
|
||||
.width(Length::Fill)
|
||||
.style(move |t, s| {
|
||||
if is_selected {
|
||||
theme_style::ghost_button_selected(t, s)
|
||||
} else {
|
||||
theme_style::ghost_button(t, s)
|
||||
}
|
||||
});
|
||||
profile_items.push(btn.into());
|
||||
}
|
||||
}
|
||||
|
||||
let profile_list = scrollable(column(profile_items).spacing(2))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
|
||||
let name_input = text_input("Profile filename", &state.editing_profile_name)
|
||||
.on_input(Message::ProfileNameChanged)
|
||||
.size(13);
|
||||
|
||||
let save_btn = action_button("Save", Message::SaveProfile, true);
|
||||
let del_btn = danger_action_button("Delete", Message::DeleteProfile);
|
||||
let load_btn = action_button("Load", Message::LoadProfile, false);
|
||||
let new_btn = action_button("New", Message::NewProfile, false);
|
||||
|
||||
let left_panel = container(
|
||||
column![
|
||||
text("Profiles").size(13).color(theme_style::text_muted()),
|
||||
profile_list,
|
||||
text("Filename:").size(11).color(theme_style::text_muted()),
|
||||
name_input,
|
||||
row![save_btn, del_btn, load_btn, new_btn].spacing(4),
|
||||
]
|
||||
.spacing(6),
|
||||
)
|
||||
.padding(12)
|
||||
.style(theme_style::card)
|
||||
.width(Length::Fill);
|
||||
|
||||
// ===== Right column: checkboxes (options) + text fields =====
|
||||
let options_col = column![
|
||||
section_label("SSH options"),
|
||||
checkbox("No agent forwarding", p.noagentforward)
|
||||
.on_toggle(|v| Message::ProfileCheckboxChanged(ProfileCheckbox::NoAgentForward, v))
|
||||
.size(13),
|
||||
checkbox("No X11 forwarding", p.x11forward)
|
||||
.on_toggle(|v| Message::ProfileCheckboxChanged(ProfileCheckbox::NoX11Forward, v))
|
||||
.size(13),
|
||||
checkbox("Strict hostkey check", p.stricthost)
|
||||
.on_toggle(|v| Message::ProfileCheckboxChanged(ProfileCheckbox::StrictHost, v))
|
||||
.size(13),
|
||||
checkbox("No priv. source port", p.nopriv)
|
||||
.on_toggle(|v| Message::ProfileCheckboxChanged(ProfileCheckbox::NoPrivPort, v))
|
||||
.size(13),
|
||||
checkbox("Fork into background", p.fork)
|
||||
.on_toggle(|v| Message::ProfileCheckboxChanged(ProfileCheckbox::Fork, v))
|
||||
.size(13),
|
||||
checkbox("Quiet", p.quiet)
|
||||
.on_toggle(|v| Message::ProfileCheckboxChanged(ProfileCheckbox::Quiet, v))
|
||||
.size(13),
|
||||
checkbox("Verbose", p.verbose)
|
||||
.on_toggle(|v| Message::ProfileCheckboxChanged(ProfileCheckbox::Verbose, v))
|
||||
.size(13),
|
||||
checkbox("Wait after connection", p.connwait)
|
||||
.on_toggle(|v| Message::ProfileCheckboxChanged(ProfileCheckbox::ConnWait, v))
|
||||
.size(13),
|
||||
checkbox("Terminal iconified", p.termicon)
|
||||
.on_toggle(|v| Message::ProfileCheckboxChanged(ProfileCheckbox::TermIcon, v))
|
||||
.size(13),
|
||||
]
|
||||
.spacing(6);
|
||||
|
||||
let options_panel = container(options_col).padding(12).style(theme_style::card);
|
||||
|
||||
// Text fields.
|
||||
let title_input = text_input("Title", &p.title).on_input(Message::ProfileTitleChanged).size(13);
|
||||
let host_input = text_input("Host", &p.host).on_input(Message::ProfileHostChanged).size(13);
|
||||
let user_input = text_input("User", &p.user).on_input(Message::ProfileUserChanged).size(13);
|
||||
let ask_user_check = checkbox("Ask", p.askuserspec)
|
||||
.on_toggle(Message::ProfileAskUserToggled)
|
||||
.size(13);
|
||||
let port_input = text_input("Port", &p.port.to_string()).on_input(Message::ProfilePortChanged).size(13);
|
||||
let cmd_input = text_input("Exec", &p.command).on_input(Message::ProfileCommandChanged).size(13);
|
||||
let noexec_check = checkbox("No Exec", p.noexec)
|
||||
.on_toggle(Message::ProfileNoExecToggled)
|
||||
.size(13);
|
||||
let subsys_input = text_input("Subsys", &p.subsys).on_input(Message::ProfileSubsysChanged).size(13);
|
||||
let ident_input = text_input("Identity", &p.identity.to_string_lossy())
|
||||
.on_input(Message::ProfileIdentityChanged)
|
||||
.size(13);
|
||||
let ident_btn = browse_button(Message::BrowseIdentity);
|
||||
let cfg_input = text_input("Cfg-file", &p.cfgfile.to_string_lossy())
|
||||
.on_input(Message::ProfileCfgfileChanged)
|
||||
.size(13);
|
||||
let cfg_btn = browse_button(Message::BrowseCfgfile);
|
||||
|
||||
let algo_opts: Vec<Algo> = Algo::ALL.to_vec();
|
||||
let algo_pick = pick_list(algo_opts, Some(p.algo), |a| {
|
||||
Message::ProfileAlgoChanged(a.as_str().to_string())
|
||||
});
|
||||
|
||||
let compress_check = checkbox("Compression", p.compress)
|
||||
.on_toggle(Message::ProfileCompressToggled)
|
||||
.size(13);
|
||||
let compval_row = row![
|
||||
text("Level:").size(12).color(theme_style::text_muted()),
|
||||
text(p.compressval.to_string()).size(12),
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(Alignment::Center);
|
||||
let compval_input = text_input("0-9", &p.compressval.to_string())
|
||||
.on_input(|s| Message::ProfileCompressValChanged(s.parse().unwrap_or(0)))
|
||||
.size(13)
|
||||
.width(Length::Fixed(50.0));
|
||||
|
||||
let fields_col = column![
|
||||
section_label("Connection"),
|
||||
field_row("Title", title_input.into()),
|
||||
field_row("Host", host_input.into()),
|
||||
row![
|
||||
text("User:").size(12).width(Length::Fixed(70.0)).color(theme_style::text_muted()),
|
||||
user_input,
|
||||
ask_user_check,
|
||||
].spacing(4).align_y(Alignment::Center),
|
||||
field_row("Port", port_input.into()),
|
||||
row![
|
||||
text("Exec:").size(12).width(Length::Fixed(70.0)).color(theme_style::text_muted()),
|
||||
cmd_input,
|
||||
noexec_check,
|
||||
].spacing(4).align_y(Alignment::Center),
|
||||
field_row("Subsys", subsys_input.into()),
|
||||
row![
|
||||
text("Identity:").size(12).width(Length::Fixed(70.0)).color(theme_style::text_muted()),
|
||||
ident_input,
|
||||
ident_btn,
|
||||
].spacing(4).align_y(Alignment::Center),
|
||||
row![
|
||||
text("Cfg-file:").size(12).width(Length::Fixed(70.0)).color(theme_style::text_muted()),
|
||||
cfg_input,
|
||||
cfg_btn,
|
||||
].spacing(4).align_y(Alignment::Center),
|
||||
row![
|
||||
text("Algo:").size(12).width(Length::Fixed(70.0)).color(theme_style::text_muted()),
|
||||
algo_pick,
|
||||
].spacing(4).align_y(Alignment::Center),
|
||||
row![compress_check, compval_row, compval_input].spacing(6).align_y(Alignment::Center),
|
||||
]
|
||||
.spacing(6);
|
||||
|
||||
let fields_panel = container(fields_col).padding(12).style(theme_style::card);
|
||||
|
||||
// SSH/IP version pickers + forwards.
|
||||
let sshver_pick = pick_list(
|
||||
vec![SshVer::V1, SshVer::V2],
|
||||
Some(p.sshverconnect),
|
||||
|v| Message::ProfileSshVerChanged(if v == SshVer::V1 { 1 } else { 2 }),
|
||||
);
|
||||
let ipver_pick = pick_list(
|
||||
vec![IpVer::V4, IpVer::V6],
|
||||
Some(p.ipverconnect),
|
||||
|v| Message::ProfileIpVerChanged(if v == IpVer::V6 { 6 } else { 4 }),
|
||||
);
|
||||
|
||||
let gateway_check = checkbox("Run as gateway", p.gateway)
|
||||
.on_toggle(Message::ProfileGatewayToggled)
|
||||
.size(13);
|
||||
|
||||
let lf_btn = action_button("Local forwards", Message::OpenLocalForwards, false);
|
||||
let rf_btn = action_button("Remote forwards", Message::OpenRemoteForwards, false);
|
||||
|
||||
let forwards_panel = container(
|
||||
column![
|
||||
section_label("Forwarding & version"),
|
||||
row![lf_btn, rf_btn].spacing(4),
|
||||
gateway_check,
|
||||
row![
|
||||
text("SSH:").size(12).color(theme_style::text_muted()),
|
||||
sshver_pick,
|
||||
text("IP:").size(12).color(theme_style::text_muted()),
|
||||
ipver_pick,
|
||||
].spacing(6).align_y(Alignment::Center),
|
||||
]
|
||||
.spacing(6),
|
||||
)
|
||||
.padding(12)
|
||||
.style(theme_style::card);
|
||||
|
||||
let right_panel = column![row![options_panel, fields_panel].spacing(8), forwards_panel].spacing(8);
|
||||
|
||||
let body = row![left_panel, right_panel]
|
||||
.spacing(8)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
|
||||
column![
|
||||
text("Profiles").size(16).font(iced::Font {
|
||||
weight: iced::font::Weight::Semibold,
|
||||
..Default::default()
|
||||
}),
|
||||
body
|
||||
]
|
||||
.spacing(8)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// A small grey uppercase section label.
|
||||
fn section_label(s: &str) -> iced::widget::Text<'static> {
|
||||
text(s.to_string())
|
||||
.size(11)
|
||||
.color(theme_style::text_muted())
|
||||
.font(iced::Font {
|
||||
weight: iced::font::Weight::Bold,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// A field row: small grey label on the left, input on the right.
|
||||
fn field_row<'a>(label: &str, input: Element<'a, Message>) -> Element<'a, Message> {
|
||||
row![
|
||||
text(label.to_string())
|
||||
.size(12)
|
||||
.width(Length::Fixed(70.0))
|
||||
.color(theme_style::text_muted()),
|
||||
input,
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Small icon-style "..." button for opening file pickers.
|
||||
fn browse_button<'a>(msg: Message) -> iced::widget::Button<'a, Message> {
|
||||
button(text("...").size(12))
|
||||
.on_press(msg)
|
||||
.padding(Padding::new(4.0))
|
||||
.style(theme_style::icon_button)
|
||||
}
|
||||
|
||||
/// Standard action button — secondary style by default, primary when emphasized.
|
||||
fn action_button(label: &str, msg: Message, emphasized: bool) -> Element<Message> {
|
||||
let b = button(text(label).size(12))
|
||||
.on_press(msg)
|
||||
.padding(Padding::new(8.0))
|
||||
.width(Length::Fill)
|
||||
.style(if emphasized {
|
||||
theme_style::primary_button
|
||||
} else {
|
||||
theme_style::secondary_button
|
||||
});
|
||||
b.into()
|
||||
}
|
||||
|
||||
/// Danger action button (red).
|
||||
fn danger_action_button(label: &str, msg: Message) -> Element<Message> {
|
||||
let b = button(text(label).size(12))
|
||||
.on_press(msg)
|
||||
.padding(Padding::new(8.0))
|
||||
.width(Length::Fill)
|
||||
.style(theme_style::danger_button);
|
||||
b.into()
|
||||
}
|
||||
|
|
@ -0,0 +1,365 @@
|
|||
//! Dual-pane SCP browser page — local on left, remote on right.
|
||||
//!
|
||||
//! Visual design (WinSCP-inspired but not identical):
|
||||
//! * Top bar: profile picker + Connect/Disconnect buttons + status
|
||||
//! * Two side-by-side panes (local | remote)
|
||||
//! * Each pane has: path bar, toolbar (Up/Refresh/Mkdir/Delete), file list
|
||||
//! * Center column with Upload and Download transfer buttons
|
||||
//! * Bottom: status line
|
||||
//!
|
||||
//! File rows show: name (bold + blue for directories), size, modified date.
|
||||
//! Click to select; double-click (or Enter) to open (cd for dirs).
|
||||
|
||||
use iced::widget::{button, column, container, pick_list, row, scrollable, text, text_input};
|
||||
use iced::{Alignment, Border, Color, Element, Font, Length, Padding};
|
||||
|
||||
use crate::message::Message;
|
||||
use crate::scp_browser::FileEntry;
|
||||
use crate::state::{ScpSide, Shellm};
|
||||
use crate::theme_style;
|
||||
|
||||
/// Render the SCP browser tab.
|
||||
pub fn view(state: &Shellm) -> Element<Message> {
|
||||
let scp = &state.scp_browser;
|
||||
|
||||
// ===== Top connection bar =====
|
||||
let profile_names: Vec<String> = state.profile_order.clone();
|
||||
let current_profile = scp.connected_profile.clone();
|
||||
let profile_pick = pick_list(
|
||||
profile_names,
|
||||
current_profile,
|
||||
Message::ScpConnect,
|
||||
);
|
||||
|
||||
let connect_btn = button(text("Connect").size(12))
|
||||
.on_press_maybe(scp.connected_profile.is_none().then(|| {
|
||||
// If we have a selected connection on the Connections tab, use that;
|
||||
// otherwise the pick_list drives this via Message::ScpConnect.
|
||||
Message::None
|
||||
}))
|
||||
.padding(Padding::new(8.0))
|
||||
.style(theme_style::primary_button);
|
||||
|
||||
let disconnect_btn = button(text("Disconnect").size(12))
|
||||
.on_press_maybe(if scp.connected_profile.is_some() {
|
||||
Some(Message::ScpDisconnect)
|
||||
} else {
|
||||
None
|
||||
})
|
||||
.padding(Padding::new(8.0))
|
||||
.style(theme_style::secondary_button);
|
||||
|
||||
let status_dot_color = if scp.connected_profile.is_some() {
|
||||
theme_style::success()
|
||||
} else {
|
||||
theme_style::text_muted()
|
||||
};
|
||||
let status_text = if scp.connected_profile.is_some() {
|
||||
format!("Connected to {}@{}:{}", scp.connected_user, scp.connected_host, scp.connected_port)
|
||||
} else {
|
||||
"Not connected".to_string()
|
||||
};
|
||||
|
||||
let top_bar = container(
|
||||
row![
|
||||
text("Profile:").size(12).color(theme_style::text_muted()),
|
||||
profile_pick,
|
||||
connect_btn,
|
||||
disconnect_btn,
|
||||
iced::widget::horizontal_space().width(Length::Fill),
|
||||
theme_style::status_dot(status_dot_color, 8.0),
|
||||
text(status_text).size(12).color(theme_style::text_muted()),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
.padding([8, 12])
|
||||
.style(theme_style::card);
|
||||
|
||||
// ===== Local pane =====
|
||||
let local_pane = render_pane(
|
||||
"Local",
|
||||
&scp.local_path_input,
|
||||
scp.local_selected,
|
||||
&scp.local_entries,
|
||||
Message::ScpLocalPathInputChanged,
|
||||
Message::ScpLocalGoTo,
|
||||
Message::ScpLocalUp,
|
||||
Message::ScpLocalRefresh,
|
||||
Message::ScpLocalActivate,
|
||||
ScpSide::Local,
|
||||
scp.busy,
|
||||
);
|
||||
|
||||
// ===== Remote pane =====
|
||||
let remote_pane = render_pane(
|
||||
"Remote",
|
||||
&scp.remote_path_input,
|
||||
scp.remote_selected,
|
||||
&scp.remote_entries,
|
||||
Message::ScpRemotePathInputChanged,
|
||||
Message::ScpRemoteGoTo,
|
||||
Message::ScpRemoteUp,
|
||||
Message::ScpRemoteRefresh,
|
||||
Message::ScpRemoteActivate,
|
||||
ScpSide::Remote,
|
||||
scp.busy || scp.connected_profile.is_none(),
|
||||
);
|
||||
|
||||
// ===== Center transfer buttons =====
|
||||
let upload_enabled = scp.local_selected.is_some() && scp.connected_profile.is_some() && !scp.busy;
|
||||
let download_enabled = scp.remote_selected.is_some() && scp.connected_profile.is_some() && !scp.busy;
|
||||
|
||||
let transfer_col = column![
|
||||
button(text("Upload").size(11))
|
||||
.on_press_maybe(if upload_enabled { Some(Message::ScpUpload) } else { None })
|
||||
.padding(Padding::new(10.0))
|
||||
.style(if upload_enabled {
|
||||
theme_style::primary_button
|
||||
} else {
|
||||
theme_style::secondary_button
|
||||
}),
|
||||
text(">").size(16).color(theme_style::text_muted()),
|
||||
iced::widget::vertical_space().height(Length::Fixed(8.0)),
|
||||
button(text("Download").size(11))
|
||||
.on_press_maybe(if download_enabled { Some(Message::ScpDownload) } else { None })
|
||||
.padding(Padding::new(10.0))
|
||||
.style(if download_enabled {
|
||||
theme_style::primary_button
|
||||
} else {
|
||||
theme_style::secondary_button
|
||||
}),
|
||||
text("<").size(16).color(theme_style::text_muted()),
|
||||
]
|
||||
.spacing(2)
|
||||
.align_x(Alignment::Center)
|
||||
.width(Length::Fixed(90.0));
|
||||
|
||||
// ===== Two-pane body =====
|
||||
let body = row![
|
||||
local_pane,
|
||||
container(transfer_col).padding([40, 4]).height(Length::Fill),
|
||||
remote_pane,
|
||||
]
|
||||
.spacing(4)
|
||||
.height(Length::Fill)
|
||||
.width(Length::Fill);
|
||||
|
||||
// ===== Bottom status =====
|
||||
let status_bar = container(
|
||||
row![
|
||||
theme_style::status_dot(
|
||||
if scp.busy {
|
||||
theme_style::accent()
|
||||
} else {
|
||||
theme_style::success()
|
||||
},
|
||||
7.0,
|
||||
),
|
||||
text(scp.status.as_str()).size(11).color(theme_style::text_muted()),
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
.padding([6, 12])
|
||||
.width(Length::Fill)
|
||||
.style(theme_style::status_bar);
|
||||
|
||||
column![top_bar, body, status_bar]
|
||||
.spacing(4)
|
||||
.height(Length::Fill)
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Render one of the two panes (local or remote).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_pane<'a>(
|
||||
title: &'a str,
|
||||
path_input: &'a str,
|
||||
selected: Option<usize>,
|
||||
entries: &'a [FileEntry],
|
||||
on_path_input: fn(String) -> Message,
|
||||
on_go_to: Message,
|
||||
on_up: Message,
|
||||
on_refresh: Message,
|
||||
on_activate: fn(usize) -> Message,
|
||||
side: ScpSide,
|
||||
disabled: bool,
|
||||
) -> Element<'a, Message> {
|
||||
// Path bar.
|
||||
let go_msg = on_go_to.clone();
|
||||
let path_bar = row![
|
||||
text_input("/", path_input)
|
||||
.on_input(on_path_input)
|
||||
.on_submit(on_go_to)
|
||||
.size(12),
|
||||
button(text("Go").size(11))
|
||||
.on_press(go_msg)
|
||||
.padding(Padding::new(6.0))
|
||||
.style(theme_style::secondary_button),
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
// Toolbar.
|
||||
let up_btn = button(text("Up").size(11))
|
||||
.on_press(on_up)
|
||||
.padding(Padding::new(6.0))
|
||||
.style(theme_style::secondary_button);
|
||||
let refresh_btn = button(text("Refresh").size(11))
|
||||
.on_press(on_refresh)
|
||||
.padding(Padding::new(6.0))
|
||||
.style(theme_style::secondary_button);
|
||||
let mkdir_btn = button(text("New folder").size(11))
|
||||
.on_press(Message::ScpMkdirShow(side))
|
||||
.padding(Padding::new(6.0))
|
||||
.style(theme_style::secondary_button);
|
||||
let delete_btn = button(text("Delete").size(11))
|
||||
.on_press_maybe(match side {
|
||||
ScpSide::Local => selected.map(|_| Message::ScpLocalDelete),
|
||||
ScpSide::Remote => selected.map(|_| Message::ScpRemoteDelete),
|
||||
})
|
||||
.padding(Padding::new(6.0))
|
||||
.style(theme_style::danger_secondary_button);
|
||||
|
||||
let toolbar = row![up_btn, refresh_btn, mkdir_btn, delete_btn]
|
||||
.spacing(4)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
// Header row.
|
||||
let header = column![text(title).size(13).font(Font {
|
||||
weight: iced::font::Weight::Semibold,
|
||||
..Default::default()
|
||||
}), path_bar, toolbar]
|
||||
.spacing(4);
|
||||
|
||||
// File list header.
|
||||
let list_header = row![
|
||||
text("Name").size(10).color(theme_style::text_muted()).width(Length::Fill),
|
||||
text("Size").size(10).color(theme_style::text_muted()).width(Length::Fixed(70.0)),
|
||||
text("Modified").size(10).color(theme_style::text_muted()).width(Length::Fixed(110.0)),
|
||||
]
|
||||
.spacing(8)
|
||||
.padding([4, 8])
|
||||
.width(Length::Fill);
|
||||
|
||||
// File rows.
|
||||
let mut rows: Vec<Element<Message>> = vec![list_header.into()];
|
||||
if entries.is_empty() {
|
||||
rows.push(
|
||||
container(
|
||||
text("(empty)").size(11).color(theme_style::text_muted())
|
||||
)
|
||||
.padding(8)
|
||||
.center(Length::Fill)
|
||||
.into(),
|
||||
);
|
||||
} else {
|
||||
for (idx, entry) in entries.iter().enumerate() {
|
||||
let is_selected = selected == Some(idx);
|
||||
let row_el = render_file_row(entry, is_selected, on_activate(idx));
|
||||
rows.push(row_el);
|
||||
}
|
||||
}
|
||||
|
||||
let list = scrollable(column(rows).spacing(0))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
|
||||
let pane = container(
|
||||
column![header, list].spacing(4)
|
||||
)
|
||||
.padding(8)
|
||||
.style(theme_style::card)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
|
||||
if disabled {
|
||||
// Overlay a dimmed "Not connected" hint on the remote pane.
|
||||
container(pane)
|
||||
.style(|_t| container::Style {
|
||||
background: Some(Color::from_rgba8(0xF8, 0xFA, 0xFC, 0.7).into()),
|
||||
..Default::default()
|
||||
})
|
||||
.into()
|
||||
} else {
|
||||
pane.into()
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a single file row in a pane. `on_activate` is the message sent when
|
||||
/// the row is clicked — it should select the entry and (if it's a directory)
|
||||
/// navigate into it.
|
||||
fn render_file_row(entry: &FileEntry, is_selected: bool, on_activate: Message) -> Element<Message> {
|
||||
// Directory: blue, bold. File: regular text. Symlink: cyan.
|
||||
let name_color = if entry.is_dir {
|
||||
theme_style::accent_pressed()
|
||||
} else if entry.is_symlink {
|
||||
Color::from_rgb8(0x08, 0x91, 0x91)
|
||||
} else {
|
||||
theme_style::text_color()
|
||||
};
|
||||
let weight = if entry.is_dir {
|
||||
iced::font::Weight::Semibold
|
||||
} else {
|
||||
iced::font::Weight::Normal
|
||||
};
|
||||
|
||||
// Icon: we use ASCII markers to avoid font-coverage issues with Unicode
|
||||
// glyphs (the default iced font lacks many arrow/diamond characters,
|
||||
// which would render as tofu boxes). Directories get `[DIR]`, symlinks
|
||||
// get `[LNK]`, the parent entry gets `[..]`.
|
||||
let icon_text = if entry.is_parent() {
|
||||
"[..]".to_string()
|
||||
} else if entry.is_dir {
|
||||
"[DIR]".to_string()
|
||||
} else if entry.is_symlink {
|
||||
"[LNK]".to_string()
|
||||
} else {
|
||||
" ".to_string()
|
||||
};
|
||||
|
||||
let display_name = if entry.is_parent() {
|
||||
"..".to_string()
|
||||
} else {
|
||||
entry.name.clone()
|
||||
};
|
||||
|
||||
let name_row = row![
|
||||
text(icon_text).size(11).color(name_color),
|
||||
text(display_name).size(12).color(name_color).font(Font {
|
||||
weight,
|
||||
..Default::default()
|
||||
}),
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
let row_content = row![
|
||||
name_row.width(Length::Fill),
|
||||
text(entry.display_size()).size(11).color(theme_style::text_muted()).width(Length::Fixed(70.0)),
|
||||
text(entry.modified.as_str()).size(10).color(theme_style::text_muted()).width(Length::Fixed(110.0)),
|
||||
]
|
||||
.spacing(8)
|
||||
.padding([4, 8])
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
let b = button(row_content)
|
||||
.on_press(on_activate)
|
||||
.padding(0)
|
||||
.style(move |t, s| {
|
||||
let mut base = theme_style::ghost_button(t, s);
|
||||
if is_selected {
|
||||
base.background = Some(theme_style::accent_tint_color().into());
|
||||
base.border = Border {
|
||||
color: theme_style::accent(),
|
||||
width: 1.0,
|
||||
radius: 4.0.into(),
|
||||
};
|
||||
}
|
||||
base
|
||||
});
|
||||
b.into()
|
||||
}
|
||||
|
|
@ -0,0 +1,419 @@
|
|||
//! Modern widget styles for the shellm port.
|
||||
//!
|
||||
//! Design language:
|
||||
//! * Flat surfaces with subtle 1px borders
|
||||
//! * 6px corner radius on cards, buttons, inputs
|
||||
//! * Modern blue accent (#2563EB) for primary actions
|
||||
//! * Subtle hover states (light blue tint)
|
||||
//! * Card-style containers (white background, light grey border)
|
||||
//! * No 1995-style "groove" borders or beveled buttons
|
||||
//!
|
||||
//! All functions are `Fn(&Theme, Status) -> Style` (for buttons) or
|
||||
//! `Fn(&Theme) -> Style` (for containers) so they can be passed directly
|
||||
//! to `.style(...)`.
|
||||
|
||||
use iced::widget::button::{Status, Style as ButtonStyle};
|
||||
use iced::widget::container::Style as ContainerStyle;
|
||||
use iced::{Border, Color, Theme};
|
||||
|
||||
// ===== Modern palette =====
|
||||
//
|
||||
// `Color::from_rgb8` is not `const fn` in iced 0.13, so we expose these as
|
||||
// `fn`s rather than `const` values. Each is a one-liner that constructs the
|
||||
// color; the compiler inlines them.
|
||||
|
||||
/// Background of the main app surface (very light grey).
|
||||
pub fn surface() -> Color { Color::from_rgb8(0xF8, 0xFA, 0xFC) }
|
||||
/// Card / panel background (white).
|
||||
pub fn card_bg() -> Color { Color::from_rgb8(0xFF, 0xFF, 0xFF) }
|
||||
/// Subtle border between cards.
|
||||
pub fn border_color() -> Color { Color::from_rgb8(0xE2, 0xE8, 0xF0) }
|
||||
/// Stronger border (focus, hover).
|
||||
pub fn border_strong() -> Color { Color::from_rgb8(0xCB, 0xD5, 0xE1) }
|
||||
/// Primary text.
|
||||
pub fn text_color() -> Color { Color::from_rgb8(0x1E, 0x29, 0x3B) }
|
||||
/// Secondary text.
|
||||
pub fn text_muted() -> Color { Color::from_rgb8(0x64, 0x74, 0x8B) }
|
||||
/// Primary accent (modern blue).
|
||||
pub fn accent() -> Color { Color::from_rgb8(0x25, 0x63, 0xEB) }
|
||||
/// Accent hover (slightly darker).
|
||||
pub fn accent_hover() -> Color { Color::from_rgb8(0x1D, 0x4E, 0xC1) }
|
||||
/// Accent pressed (even darker).
|
||||
pub fn accent_pressed() -> Color { Color::from_rgb8(0x1E, 0x40, 0xAF) }
|
||||
/// Light accent tint (hover on transparent accent buttons).
|
||||
pub fn accent_tint_color() -> Color { Color::from_rgb8(0xEF, 0xF4, 0xFE) }
|
||||
/// Success / agent active (modern green).
|
||||
pub fn success() -> Color { Color::from_rgb8(0x16, 0xA3, 0x4A) }
|
||||
/// Danger (modern red).
|
||||
pub fn danger() -> Color { Color::from_rgb8(0xDC, 0x26, 0x26) }
|
||||
/// Danger hover.
|
||||
pub fn danger_hover() -> Color { Color::from_rgb8(0xB9, 0x1C, 0x1C) }
|
||||
/// Light grey for secondary buttons.
|
||||
pub fn secondary_bg() -> Color { Color::from_rgb8(0xF1, 0xF5, 0xF9) }
|
||||
pub fn secondary_hover_bg() -> Color { Color::from_rgb8(0xE2, 0xE8, 0xF0) }
|
||||
|
||||
/// Modern 6px corner radius.
|
||||
pub const RADIUS: f32 = 6.0;
|
||||
|
||||
fn border(color: Color, width: f32) -> Border {
|
||||
Border {
|
||||
color,
|
||||
width,
|
||||
radius: RADIUS.into(),
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Button styles =====
|
||||
|
||||
/// Primary button — solid blue background, white text. Used for the main
|
||||
/// action of a screen (e.g. Connect, Save).
|
||||
pub fn primary_button(_theme: &Theme, status: Status) -> ButtonStyle {
|
||||
let base = ButtonStyle {
|
||||
background: Some(accent().into()),
|
||||
text_color: Color::WHITE,
|
||||
border: border(accent(), 0.0),
|
||||
..Default::default()
|
||||
};
|
||||
match status {
|
||||
Status::Hovered => ButtonStyle {
|
||||
background: Some(accent_hover().into()),
|
||||
..base
|
||||
},
|
||||
Status::Pressed => ButtonStyle {
|
||||
background: Some(accent_pressed().into()),
|
||||
..base
|
||||
},
|
||||
Status::Disabled => ButtonStyle {
|
||||
background: Some(Color::from_rgb8(0xCB, 0xD5, 0xE1).into()),
|
||||
text_color: Color::from_rgb8(0x94, 0xA3, 0xB8),
|
||||
..base
|
||||
},
|
||||
_ => base,
|
||||
}
|
||||
}
|
||||
|
||||
/// Secondary button — white background, grey border. The default for most
|
||||
/// buttons.
|
||||
pub fn secondary_button(_theme: &Theme, status: Status) -> ButtonStyle {
|
||||
let base = ButtonStyle {
|
||||
background: Some(card_bg().into()),
|
||||
text_color: text_color(),
|
||||
border: border(border_color(), 1.0),
|
||||
..Default::default()
|
||||
};
|
||||
match status {
|
||||
Status::Hovered => ButtonStyle {
|
||||
background: Some(secondary_bg().into()),
|
||||
border: border(border_strong(), 1.0),
|
||||
..base
|
||||
},
|
||||
Status::Pressed => ButtonStyle {
|
||||
background: Some(secondary_hover_bg().into()),
|
||||
..base
|
||||
},
|
||||
Status::Disabled => ButtonStyle {
|
||||
background: Some(Color::from_rgb8(0xF8, 0xFA, 0xFC).into()),
|
||||
text_color: Color::from_rgb8(0x94, 0xA3, 0xB8),
|
||||
..base
|
||||
},
|
||||
_ => base,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ghost button — transparent background, no border, appears only on hover.
|
||||
/// Used for list items (e.g. connection entries, profile entries).
|
||||
pub fn ghost_button(_theme: &Theme, status: Status) -> ButtonStyle {
|
||||
let base = ButtonStyle {
|
||||
background: None,
|
||||
text_color: text_color(),
|
||||
border: border(Color::TRANSPARENT, 0.0),
|
||||
..Default::default()
|
||||
};
|
||||
match status {
|
||||
Status::Hovered => ButtonStyle {
|
||||
background: Some(secondary_bg().into()),
|
||||
..base
|
||||
},
|
||||
Status::Pressed => ButtonStyle {
|
||||
background: Some(secondary_hover_bg().into()),
|
||||
..base
|
||||
},
|
||||
_ => base,
|
||||
}
|
||||
}
|
||||
|
||||
/// Selected/active ghost button — light blue tint background.
|
||||
pub fn ghost_button_selected(_theme: &Theme, _status: Status) -> ButtonStyle {
|
||||
ButtonStyle {
|
||||
background: Some(accent_tint_color().into()),
|
||||
text_color: accent_pressed(),
|
||||
border: border(accent(), 1.0),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Danger button — solid red, white text. Used for delete actions.
|
||||
pub fn danger_button(_theme: &Theme, status: Status) -> ButtonStyle {
|
||||
let base = ButtonStyle {
|
||||
background: Some(danger().into()),
|
||||
text_color: Color::WHITE,
|
||||
border: border(danger(), 0.0),
|
||||
..Default::default()
|
||||
};
|
||||
match status {
|
||||
Status::Hovered => ButtonStyle {
|
||||
background: Some(danger_hover().into()),
|
||||
..base
|
||||
},
|
||||
Status::Pressed => ButtonStyle {
|
||||
background: Some(Color::from_rgb8(0x99, 0x1B, 0x1B).into()),
|
||||
..base
|
||||
},
|
||||
Status::Disabled => ButtonStyle {
|
||||
background: Some(Color::from_rgb8(0xFE, 0xCA, 0xCA).into()),
|
||||
text_color: Color::from_rgb8(0x94, 0xA3, 0xB8),
|
||||
..base
|
||||
},
|
||||
_ => base,
|
||||
}
|
||||
}
|
||||
|
||||
/// Secondary danger button — white background, red border + text. For less
|
||||
/// prominent destructive actions.
|
||||
pub fn danger_secondary_button(_theme: &Theme, status: Status) -> ButtonStyle {
|
||||
let base = ButtonStyle {
|
||||
background: Some(card_bg().into()),
|
||||
text_color: danger(),
|
||||
border: border(Color::from_rgb8(0xFC, 0xA5, 0xA5), 1.0),
|
||||
..Default::default()
|
||||
};
|
||||
match status {
|
||||
Status::Hovered => ButtonStyle {
|
||||
background: Some(Color::from_rgb8(0xFE, 0xF2, 0xF2).into()),
|
||||
..base
|
||||
},
|
||||
_ => base,
|
||||
}
|
||||
}
|
||||
|
||||
/// Tab button — inactive. Transparent, muted text; hover shows a light tint.
|
||||
pub fn tab_button(_theme: &Theme, status: Status) -> ButtonStyle {
|
||||
let base = ButtonStyle {
|
||||
background: None,
|
||||
text_color: text_muted(),
|
||||
border: border(Color::TRANSPARENT, 0.0),
|
||||
..Default::default()
|
||||
};
|
||||
match status {
|
||||
Status::Hovered => ButtonStyle {
|
||||
background: Some(secondary_bg().into()),
|
||||
text_color: text_color(),
|
||||
..base
|
||||
},
|
||||
_ => base,
|
||||
}
|
||||
}
|
||||
|
||||
/// Active tab button. White background with a blue underline (achieved via
|
||||
/// border-bottom). The underline effect is faked by giving the bottom edge
|
||||
/// a 3px-thick accent border.
|
||||
pub fn tab_button_active(_theme: &Theme, _status: Status) -> ButtonStyle {
|
||||
ButtonStyle {
|
||||
background: Some(card_bg().into()),
|
||||
text_color: accent_pressed(),
|
||||
border: Border {
|
||||
color: accent(),
|
||||
width: 0.0,
|
||||
radius: RADIUS.into(),
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Icon button — small, square, transparent. Used for "..." browse buttons.
|
||||
pub fn icon_button(_theme: &Theme, status: Status) -> ButtonStyle {
|
||||
let base = ButtonStyle {
|
||||
background: None,
|
||||
text_color: text_muted(),
|
||||
border: border(Color::TRANSPARENT, 0.0),
|
||||
..Default::default()
|
||||
};
|
||||
match status {
|
||||
Status::Hovered => ButtonStyle {
|
||||
background: Some(secondary_bg().into()),
|
||||
text_color: text_color(),
|
||||
..base
|
||||
},
|
||||
_ => base,
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Container styles =====
|
||||
|
||||
/// Card container — white background, 1px grey border, 6px radius.
|
||||
/// Replaces the old "groove container" with a modern surface.
|
||||
pub fn card(_theme: &Theme) -> ContainerStyle {
|
||||
ContainerStyle {
|
||||
background: Some(card_bg().into()),
|
||||
text_color: text_color().into(),
|
||||
border: border(border_color(), 1.0),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Subtle card — same as `card` but with a transparent background. Useful
|
||||
/// for grouping controls inside another card.
|
||||
pub fn card_subtle(_theme: &Theme) -> ContainerStyle {
|
||||
ContainerStyle {
|
||||
background: Some(surface().into()),
|
||||
text_color: text_color().into(),
|
||||
border: border(border_color(), 1.0),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// App background — the very light grey used for the main window.
|
||||
pub fn app_background(_theme: &Theme) -> ContainerStyle {
|
||||
ContainerStyle {
|
||||
background: Some(surface().into()),
|
||||
text_color: text_color().into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Status bar container — light grey, no border.
|
||||
pub fn status_bar(_theme: &Theme) -> ContainerStyle {
|
||||
ContainerStyle {
|
||||
background: Some(Color::from_rgb8(0xF1, 0xF5, 0xF9).into()),
|
||||
text_color: text_muted().into(),
|
||||
border: Border {
|
||||
color: border_color(),
|
||||
width: 1.0,
|
||||
radius: 0.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Menu bar container — same as status bar but no top border.
|
||||
pub fn menu_bar(_theme: &Theme) -> ContainerStyle {
|
||||
ContainerStyle {
|
||||
background: Some(card_bg().into()),
|
||||
text_color: text_color().into(),
|
||||
border: Border {
|
||||
color: border_color(),
|
||||
width: 1.0,
|
||||
radius: 0.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Modal overlay backdrop — semi-transparent black, dims the underlying UI.
|
||||
pub fn modal_backdrop(_theme: &Theme) -> ContainerStyle {
|
||||
ContainerStyle {
|
||||
background: Some(Color::from_rgba8(0x1E, 0x29, 0x3B, 0.5).into()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Modal dialog panel — white card with subtle shadow.
|
||||
pub fn modal_panel(_theme: &Theme) -> ContainerStyle {
|
||||
ContainerStyle {
|
||||
background: Some(card_bg().into()),
|
||||
text_color: text_color().into(),
|
||||
border: border(border_strong(), 1.0),
|
||||
shadow: iced::Shadow {
|
||||
color: Color::from_rgba8(0x1E, 0x29, 0x3B, 0.15),
|
||||
offset: iced::Vector::new(0.0, 8.0),
|
||||
blur_radius: 24.0,
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Green-tinted container — used for "agent active" indicator pill.
|
||||
pub fn success_tint(_theme: &Theme) -> ContainerStyle {
|
||||
ContainerStyle {
|
||||
background: Some(Color::from_rgb8(0xDC, 0xFC, 0xE7).into()),
|
||||
text_color: Color::from_rgb8(0x16, 0x6E, 0x35).into(),
|
||||
border: border(Color::from_rgb8(0xBB, 0xF7, 0xD0), 1.0),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Red-tinted container — used for "agent inactive" indicator pill.
|
||||
pub fn danger_tint(_theme: &Theme) -> ContainerStyle {
|
||||
ContainerStyle {
|
||||
background: Some(Color::from_rgb8(0xFE, 0xE2, 0xE2).into()),
|
||||
text_color: Color::from_rgb8(0x99, 0x1B, 0x1B).into(),
|
||||
border: border(Color::from_rgb8(0xFC, 0xA5, 0xA5), 1.0),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Accent-tinted container — for highlighted info panels.
|
||||
pub fn accent_tint(_theme: &Theme) -> ContainerStyle {
|
||||
ContainerStyle {
|
||||
background: Some(accent_tint_color().into()),
|
||||
text_color: accent_pressed().into(),
|
||||
border: border(Color::from_rgb8(0xBF, 0xDB, 0xFE), 1.0),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Backwards-compat aliases =====
|
||||
//
|
||||
// The original Tk-style style functions are kept as aliases so existing
|
||||
// call sites continue to work. They now delegate to the modern styles so
|
||||
// the whole app gets a consistent modern look.
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn groove_border(_c: Color) -> Border {
|
||||
border(border_color(), 1.0)
|
||||
}
|
||||
|
||||
/// Old name for `card_subtle`. Kept so existing call sites don't break.
|
||||
pub fn groove_container(t: &Theme) -> ContainerStyle {
|
||||
card_subtle(t)
|
||||
}
|
||||
|
||||
/// Old name for `success_tint`.
|
||||
pub fn green_indicator(t: &Theme) -> ContainerStyle {
|
||||
success_tint(t)
|
||||
}
|
||||
|
||||
/// Old name for `danger_tint`.
|
||||
pub fn red_indicator(t: &Theme) -> ContainerStyle {
|
||||
danger_tint(t)
|
||||
}
|
||||
|
||||
/// Old name for `secondary_button`. Kept so existing call sites don't break.
|
||||
pub fn tk_button(t: &Theme, s: Status) -> ButtonStyle {
|
||||
secondary_button(t, s)
|
||||
}
|
||||
|
||||
/// A small colored dot (filled circle) rendered as a styled container rather
|
||||
/// than a Unicode glyph. This avoids font-coverage issues with characters
|
||||
/// like `●` (U+25CF), which iced's default font does not include and which
|
||||
/// therefore render as tofu boxes on many systems.
|
||||
///
|
||||
/// Returns an `iced::Element` that can be placed in any row/column. The dot
|
||||
/// is `size` pixels in diameter.
|
||||
pub fn status_dot<'a, Message: 'a>(color: Color, size: f32) -> iced::Element<'a, Message> {
|
||||
use iced::widget::container;
|
||||
use iced::Length;
|
||||
container(iced::widget::Space::new(Length::Fixed(size), Length::Fixed(size)))
|
||||
.width(Length::Fixed(size))
|
||||
.height(Length::Fixed(size))
|
||||
.style(move |_t| container::Style {
|
||||
background: Some(color.into()),
|
||||
border: Border {
|
||||
color: Color::TRANSPARENT,
|
||||
width: 0.0,
|
||||
radius: (size / 2.0).into(),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
//! Top tab bar — modern underline-style design.
|
||||
//!
|
||||
//! Layout: each tab is a button with a label; the active tab has a blue
|
||||
//! underline (a 3px bottom border on the active button). Inactive tabs
|
||||
//! have transparent background with muted text; hover shows a light tint.
|
||||
|
||||
use iced::widget::{button, container, row, text};
|
||||
use iced::{Alignment, Border, Color, Element, Length};
|
||||
|
||||
use crate::message::{Message, Tab};
|
||||
use crate::state::Shellm;
|
||||
use crate::theme_style;
|
||||
|
||||
/// Render the modern tab bar.
|
||||
pub fn tab_bar(state: &Shellm) -> Element<Message> {
|
||||
let tabs = [
|
||||
(Tab::Connections, "Connections"),
|
||||
(Tab::Profiles, "Profiles"),
|
||||
(Tab::KeysAgent, "Keys & Agent"),
|
||||
(Tab::ScpBrowser, "SCP Browser"),
|
||||
(Tab::Configs, "Configs"),
|
||||
];
|
||||
|
||||
let mut btns: Vec<Element<Message>> = Vec::new();
|
||||
for (tab, label) in tabs {
|
||||
let is_active = state.active_tab == tab && !state.satellite_mode;
|
||||
let txt_color = if is_active {
|
||||
theme_style::ACCENT_PRESSED()
|
||||
} else {
|
||||
theme_style::TEXT_MUTED()
|
||||
};
|
||||
|
||||
let btn = button(
|
||||
text(label)
|
||||
.size(13)
|
||||
.color(txt_color)
|
||||
.font(iced::Font {
|
||||
weight: if is_active {
|
||||
iced::font::Weight::Semibold
|
||||
} else {
|
||||
iced::font::Weight::Normal
|
||||
},
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.on_press(Message::SwitchTab(tab))
|
||||
.padding([10, 16])
|
||||
.style(move |t, s| {
|
||||
let mut base = theme_style::secondary_button(t, s);
|
||||
base.text_color = txt_color;
|
||||
if is_active {
|
||||
// Active: white background + accent bottom border (underline).
|
||||
base.background = Some(iced::Color::WHITE.into());
|
||||
base.text_color = theme_style::ACCENT_PRESSED();
|
||||
base.border = Border {
|
||||
color: theme_style::BORDER(),
|
||||
width: 1.0,
|
||||
radius: 6.0.into(),
|
||||
};
|
||||
} else {
|
||||
// Inactive: transparent.
|
||||
base.background = match s {
|
||||
iced::widget::button::Status::Hovered => {
|
||||
Some(theme_style::SECONDARY().into())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
base.border = Border {
|
||||
color: Color::TRANSPARENT,
|
||||
width: 0.0,
|
||||
radius: 6.0.into(),
|
||||
};
|
||||
}
|
||||
base
|
||||
});
|
||||
btns.push(btn.into());
|
||||
}
|
||||
|
||||
// Satellite toggle button (right-aligned, ghost style).
|
||||
let sat_label = if state.satellite_mode { "Exit satellite" } else { "Satellite" };
|
||||
let sat_btn = button(text(sat_label).size(12).color(theme_style::text_muted()))
|
||||
.on_press(Message::ToggleSatellite)
|
||||
.padding([8, 12])
|
||||
.style(theme_style::ghost_button);
|
||||
btns.push(sat_btn.into());
|
||||
|
||||
let r = row(btns).spacing(2).align_y(Alignment::Center);
|
||||
|
||||
container(r)
|
||||
.padding([4, 8])
|
||||
.width(Length::Fill)
|
||||
.style(|_t| container::Style {
|
||||
background: Some(iced::Color::WHITE.into()),
|
||||
border: Border {
|
||||
color: theme_style::BORDER(),
|
||||
width: 1.0,
|
||||
radius: 0.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
Loading…
Reference in New Issue