using System.Diagnostics;
using System.Net.Sockets;
using Sunfish.Config;
using Sunfish.Host;
using Sunfish.Net;
namespace Sunfish.WinForms;
/// <summary>
/// Ventana principal con el aspecto de la 2.0: pequena y vertical. Arriba, menu raiz y puerto;
/// en medio, los shares con su icono de carpeta (se arrastran carpetas para compartirlas); abajo,
/// botones con imagen. Direcciones y registro estan en la ventana de informacion (i). "Ok"
/// manda la ventana al area de notificacion sin parar el servidor.
/// </summary>
internal sealed class MainForm : Form
{
private const int ItemHeight = 32;
private const int TopHeight = 58;
private const int ClientHeight = 384;
private readonly SunfishHost _host;
private readonly List<string> _pendingFolders;
private readonly ToolTip _tips = new();
private readonly CheckBox _rootMenu = new() { AutoSize = true, Margin = new Padding(3, 5, 6, 0) };
private readonly CheckBox _listen = new() { AutoSize = true, Margin = new Padding(3, 5, 2, 0) };
private readonly NumericUpDown _port = new() { Minimum = 1, Maximum = 65535, Width = 62, Margin = new Padding(0, 3, 0, 0) };
private readonly CheckBox _ftp = new() { AutoSize = true, Margin = new Padding(3, 5, 2, 0) };
private readonly NumericUpDown _ftpPort = new() { Minimum = 1, Maximum = 65535, Width = 62, Margin = new Padding(0, 3, 0, 0) };
private readonly ListBox _shares = new()
{
Location = new Point(0, TopHeight), DrawMode = DrawMode.OwnerDrawFixed, ItemHeight = UiImages.Px(ItemHeight),
IntegralHeight = false, AllowDrop = true, BorderStyle = BorderStyle.FixedSingle,
Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right,
};
private readonly Button _add;
private readonly Button _remove;
private readonly Button _edit;
private readonly Button _users;
private readonly Button _explore;
private readonly Button _info;
private readonly Button _ok = new() { UseVisualStyleBackColor = true, Size = new Size(70, 26), Anchor = AnchorStyles.Bottom | AnchorStyles.Right };
private readonly ContextMenuStrip _menu = new() { ImageScalingSize = new Size(UiImages.Px(16), UiImages.Px(16)) };
private readonly NotifyIcon _tray = new();
private readonly Font _small;
private InfoForm? _infoForm;
private bool _updating;
public MainForm(SunfishHost host, List<string> pendingFolders)
{
_host = host;
_pendingFolders = pendingFolders;
_small = new Font(Font.FontFamily, Font.Size - 1f);
_add = UiImages.IconButton("plus", Texts.Add, _tips);
_remove = UiImages.IconButton("minus", Texts.Remove, _tips);
_edit = UiImages.IconButton("pencil", Texts.Edit, _tips);
_users = UiImages.IconButton("users", Texts.Users, _tips);
_explore = UiImages.IconButton("share", Texts.Explore, _tips);
_info = UiImages.IconButton("info", Texts.Info, _tips);
SuspendLayout();
AutoScaleDimensions = new SizeF(96F, 96F);
AutoScaleMode = AutoScaleMode.Dpi;
ClientSize = new Size(292, ClientHeight);
MinimumSize = new Size(260, 260);
Text = "Sunfish";
Icon = LoadIcon();
StartPosition = FormStartPosition.CenterScreen;
MaximizeBox = false;
_rootMenu.Text = Texts.RootMenu;
_listen.Text = Texts.ListenOnPort;
_ftp.Text = Texts.FtpOnPort;
_tips.SetToolTip(_listen, Texts.ListenTip);
_tips.SetToolTip(_ftp, Texts.FtpTip);
_shares.Size = new Size(292, ClientHeight - TopHeight - 36);
_ok.Text = Texts.Hide;
_tips.SetToolTip(_ok, Texts.HideTip);
int y = ClientHeight - 31;
_add.Location = new Point(6, y);
_remove.Location = new Point(35, y);
_edit.Location = new Point(64, y);
_users.Location = new Point(100, y);
_explore.Location = new Point(129, y);
_info.Location = new Point(292 - 70 - 6 - 32, y);
_ok.Location = new Point(292 - 70 - 6, y);
foreach (var b in new[] { _add, _remove, _edit, _users, _explore })
b.Anchor = AnchorStyles.Bottom | AnchorStyles.Left;
_info.Anchor = AnchorStyles.Bottom | AnchorStyles.Right;
// Cabecera: menu raiz y, en columna, HTTP y FTP con sus puertos alineados. Las columnas se
// ajustan solas al largo de los textos en cada idioma.
var top = new TableLayoutPanel
{
Location = new Point(2, 2), Size = new Size(288, TopHeight - 4), ColumnCount = 3, RowCount = 2,
Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right,
};
top.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
top.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
top.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
top.Controls.Add(_rootMenu, 0, 0);
top.Controls.Add(_listen, 1, 0);
top.Controls.Add(_port, 2, 0);
top.Controls.Add(_ftp, 1, 1);
top.Controls.Add(_ftpPort, 2, 1);
Controls.AddRange([top, _shares, _add, _remove, _edit, _users, _explore, _info, _ok]);
ResumeLayout(false);
PerformLayout();
_rootMenu.CheckedChanged += async (_, _) => await ChangeRootMenuAsync();
_listen.CheckedChanged += async (_, _) => await ToggleServerAsync();
_port.ValueChanged += async (_, _) => await ChangePortAsync();
_ftp.CheckedChanged += async (_, _) => await ToggleFtpAsync();
_ftpPort.ValueChanged += async (_, _) => await ChangeFtpPortAsync();
_shares.DrawItem += DrawShare;
_shares.DoubleClick += (_, _) => EditSelected();
_shares.SelectedIndexChanged += (_, _) => UpdateButtons();
_shares.MouseDown += (_, e) =>
{
if (e.Button == MouseButtons.Right)
_shares.SelectedIndex = _shares.IndexFromPoint(e.Location);
};
_shares.KeyDown += (_, e) =>
{
if (e.KeyCode == Keys.Delete) RemoveSelected();
if (e.KeyCode == Keys.Enter) EditSelected();
if (e.KeyCode == Keys.Insert) AddShare(null);
if (e.KeyCode == Keys.Space) _ = ToggleSelectedAsync();
};
_shares.ContextMenuStrip = _menu;
_menu.Opening += (_, _) => BuildMenu();
_add.Click += (_, _) => AddShare(null);
_remove.Click += (_, _) => RemoveSelected();
_edit.Click += (_, _) => EditSelected();
_users.Click += (_, _) => ShowUsers();
_explore.Click += (_, _) => UiPlacement.ShowCentered(new ExplorerForm { Icon = Icon }, this);
_info.Click += (_, _) => ShowInfo();
_ok.Click += (_, _) => WindowState = FormWindowState.Minimized;
AllowDrop = true;
foreach (Control target in new Control[] { this, _shares })
{
target.DragEnter += (_, e) => e.Effect = e.Data?.GetDataPresent(DataFormats.FileDrop) == true ? DragDropEffects.Copy : DragDropEffects.None;
target.DragDrop += (_, e) =>
{
if (e.Data?.GetData(DataFormats.FileDrop) is string[] paths)
foreach (var path in paths)
AddShare(Directory.Exists(path) ? path : Path.GetDirectoryName(path));
};
}
BuildTray();
_host.Changed += () => SafeInvoke(RefreshAll);
}
private static Icon? LoadIcon()
{
using var stream = typeof(MainForm).Assembly.GetManifestResourceStream("sunfish.ico");
return stream is null ? null : new Icon(stream);
}
private void BuildTray()
{
_tray.Icon = Icon;
_tray.Text = "Sunfish";
var menu = new ContextMenuStrip();
menu.Items.Add(Texts.Restore, null, (_, _) => RestoreFromTray());
menu.Items.Add(Texts.Exit, null, (_, _) => Close());
_tray.ContextMenuStrip = menu;
_tray.DoubleClick += (_, _) => RestoreFromTray();
Resize += (_, _) =>
{
if (WindowState == FormWindowState.Minimized)
{
Hide();
_tray.Visible = true;
}
};
}
private void RestoreFromTray()
{
Show();
WindowState = FormWindowState.Normal;
_tray.Visible = false;
Activate();
}
protected override void OnShown(EventArgs e)
{
base.OnShown(e);
try
{
_host.Start();
}
catch (SocketException ex)
{
_host.Log.Error(Strings.CannotListen(_host.Config.Server.HttpPort, ex.Message));
MessageBox.Show(this, Texts.CannotStart(ex.Message), Text, MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
RefreshAll();
foreach (var folder in _pendingFolders)
AddShare(folder);
_pendingFolders.Clear();
}
protected override void OnFormClosed(FormClosedEventArgs e)
{
_tray.Visible = false;
_tray.Dispose();
base.OnFormClosed(e);
}
/// <summary>Otra instancia (o arrastrar sobre el .exe con la ventana abierta) trae carpetas.</summary>
public void ReceiveFromOtherInstance(List<string> folders)
{
RestoreFromTray();
foreach (var folder in folders)
AddShare(folder);
}
// ------------------------------------------------------------------ estado
private bool IsQuick => _host.Config.FilePath is null;
private void RefreshAll()
{
_updating = true;
try
{
Text = IsQuick ? Texts.QuickTitle : "Sunfish";
_rootMenu.Checked = _host.Config.Server.RootMenu;
// Cada casilla es el interruptor del servicio; en rojo si deberia escuchar y no puede
// (puerto ocupado).
var server = _host.Config.Server;
_listen.Checked = server.Http;
_listen.ForeColor = server.Http && !_host.HttpRunning ? Color.Firebrick : SystemColors.ControlText;
_port.Value = Math.Clamp(server.HttpPort, 1, 65535);
_ftp.Checked = server.Ftp;
_ftp.ForeColor = server.Ftp && !_host.FtpRunning ? Color.Firebrick : SystemColors.ControlText;
_ftpPort.Value = Math.Clamp(server.FtpPort, 1, 65535);
string? selected = SelectedShare()?.Name;
_shares.BeginUpdate();
_shares.Items.Clear();
foreach (var s in _host.Config.Shares)
_shares.Items.Add(s.Name);
_shares.SelectedIndex = selected is null ? -1 : _shares.Items.IndexOf(selected);
_shares.EndUpdate();
_tray.Text = "Sunfish" + (_host.HttpRunning ? $" http:{_host.HttpPort}" : "") + (_host.FtpRunning ? $" ftp:{_host.FtpPort}" : "");
UpdateButtons();
}
finally
{
_updating = false;
}
}
private void UpdateButtons()
{
bool any = SelectedShare() is not null;
_remove.Enabled = any;
_edit.Enabled = any;
}
/// <summary>Como en la 2.0: icono de carpeta (gris si esta desactivado), nombre y debajo, en pequeno, la location.</summary>
private void DrawShare(object? sender, DrawItemEventArgs e)
{
e.DrawBackground();
if (e.Index < 0 || e.Index >= _shares.Items.Count)
return;
var share = _host.Config.Shares.FirstOrDefault(s => s.Name == (string)_shares.Items[e.Index]);
if (share is null)
return;
bool selected = (e.State & DrawItemState.Selected) != 0;
var g = e.Graphics;
int icon = UiImages.Px(24);
int top = e.Bounds.Top + (e.Bounds.Height - icon) / 2;
g.DrawImage(UiImages.Get(share.Enabled ? "foldericon" : "foldericond", 24), e.Bounds.Left + UiImages.Px(3), top, icon, icon);
int x = e.Bounds.Left + icon + UiImages.Px(8);
var main = selected ? SystemColors.HighlightText : share.Enabled ? SystemColors.WindowText : SystemColors.GrayText;
var sub = selected ? SystemColors.HighlightText : SystemColors.GrayText;
int lineHeight = Font.Height;
TextRenderer.DrawText(g, share.Name, Font, new Point(x, e.Bounds.Top + UiImages.Px(1)), main);
string detail = share.Location + " " + Permissions(share) + (share.Users.Count > 0 ? " " + string.Join(", ", share.Users) : "");
TextRenderer.DrawText(g, detail, _small, new Point(x + UiImages.Px(4), e.Bounds.Top + lineHeight), sub);
if (share.Upload)
{
int badge = UiImages.Px(12);
g.DrawImage(UiImages.Get("upload", 12), e.Bounds.Left + UiImages.Px(1), e.Bounds.Bottom - badge - UiImages.Px(1), badge, badge);
}
e.DrawFocusRectangle();
}
private static string Permissions(ShareConfig s) =>
(s.Listing ? "L" : "·") + (s.Upload ? "U" : "·") + (s.Overwrite ? "O" : "·") +
(s.Rename ? "R" : "·") + (s.Delete ? "D" : "·") + (s.Zip ? "Z" : "·");
private void SafeInvoke(Action action)
{
if (IsDisposed || !IsHandleCreated)
return;
try
{
BeginInvoke(action);
}
catch (InvalidOperationException) { }
}
// ------------------------------------------------------------------ acciones
private async Task ToggleServerAsync()
{
if (_updating || _listen.Checked == _host.Config.Server.Http)
return;
bool on = _listen.Checked;
await ChangeAsync(c => c.Server.Http = on);
}
private async Task ToggleFtpAsync()
{
if (_updating || _ftp.Checked == _host.Config.Server.Ftp)
return;
bool on = _ftp.Checked;
await ChangeAsync(c => c.Server.Ftp = on);
}
private async Task ChangeFtpPortAsync()
{
if (_updating || (int)_ftpPort.Value == _host.Config.Server.FtpPort)
return;
int port = (int)_ftpPort.Value;
await ChangeAsync(c => c.Server.FtpPort = port);
}
private async Task ChangePortAsync()
{
if (_updating || (int)_port.Value == _host.Config.Server.HttpPort)
return;
int port = (int)_port.Value;
await ChangeAsync(c => c.Server.HttpPort = port);
}
private async Task ChangeRootMenuAsync()
{
if (_updating || _rootMenu.Checked == _host.Config.Server.RootMenu)
return;
bool value = _rootMenu.Checked;
await ChangeAsync(c => c.Server.RootMenu = value);
}
private ShareConfig? SelectedShare() =>
_shares.SelectedItem is string name ? _host.Config.Shares.FirstOrDefault(s => s.Name == name) : null;
private async Task ToggleSelectedAsync()
{
if (SelectedShare() is not { } share)
return;
var copy = share.Clone();
copy.Enabled = !share.Enabled;
if (ShareSettings.Validate(_host.Config, copy, share.Name) is { } problem)
{
MessageBox.Show(this, problem, Text, MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
await ChangeAsync(c => c.Shares.First(s => s.Name == share.Name).Enabled = copy.Enabled);
}
private void AddShare(string? folder)
{
var share = new ShareConfig();
if (folder is not null && Directory.Exists(folder))
{
string full = Path.GetFullPath(folder);
string name = Path.GetFileName(Path.TrimEndingDirectorySeparator(full));
if (name.Length == 0)
name = "share";
share.Name = ShareSettings.SuggestName(_host.Config, name);
share.Location = ShareSettings.SuggestLocation(_host.Config, share.Name);
share.Path = full;
}
using var dialog = new ShareDialog(share, _host.Config, null);
if (dialog.ShowDialog(this) == DialogResult.OK)
_ = ChangeAsync(c => c.Shares.Add(dialog.Result));
}
private void EditSelected()
{
if (SelectedShare() is not { } share)
return;
using var dialog = new ShareDialog(share.Clone(), _host.Config, share.Name);
if (dialog.ShowDialog(this) == DialogResult.OK)
{
_ = ChangeAsync(c =>
{
int index = c.Shares.FindIndex(s => s.Name == share.Name);
if (index >= 0)
c.Shares[index] = dialog.Result;
});
}
}
private void RemoveSelected()
{
if (SelectedShare() is not { } share)
return;
if (MessageBox.Show(this, Strings.AskDeleteShare(share.Name), Text, MessageBoxButtons.YesNo, MessageBoxIcon.Question) == DialogResult.Yes)
_ = ChangeAsync(c => c.Shares.RemoveAll(s => s.Name == share.Name));
}
private void ShowUsers()
{
using var dialog = new UsersDialog(_host.Config.Clone());
if (dialog.ShowDialog(this) == DialogResult.OK && dialog.Changed)
_ = ChangeAsync(c =>
{
c.Users.Clear();
c.Users.AddRange(dialog.Config.Users);
foreach (var s in c.Shares)
s.Users.RemoveAll(u => !c.Users.Any(x => x.Name.Equals(u, StringComparison.OrdinalIgnoreCase)));
});
}
private void ShowInfo()
{
if (_infoForm is null || _infoForm.IsDisposed)
{
_infoForm = new InfoForm(_host) { Icon = Icon };
UiPlacement.ShowCentered(_infoForm, this);
}
else
{
_infoForm.Activate();
}
}
private void BuildMenu()
{
_menu.Items.Clear();
if (SelectedShare() is not { } share)
{
_menu.Items.Add(Texts.Add.Replace("&", ""), UiImages.Get("plus"), (_, _) => AddShare(null));
return;
}
var addresses = LocalAddresses.Get();
if (addresses.Count > 0 && (_host.HttpRunning || _host.FtpRunning))
{
if (_host.HttpRunning)
{
string first = LocalAddresses.HttpUrl(addresses[0].Address, _host.HttpPort, share);
_menu.Items.Add(Texts.OpenInBrowser, UiImages.Get("share"), (_, _) => Open(first));
}
var copy = new ToolStripMenuItem(Texts.CopyUrl);
foreach (var address in addresses)
{
var urls = new List<string>();
if (_host.HttpRunning)
urls.Add(LocalAddresses.HttpUrl(address.Address, _host.HttpPort, share));
if (_host.FtpRunning && share.Ftp)
urls.Add(LocalAddresses.FtpUrl(address.Address, _host.FtpPort, share));
foreach (var url in urls)
copy.DropDownItems.Add($"{url} ({address.Interface})", null, (_, _) => Clipboard.SetText(url));
}
_menu.Items.Add(copy);
}
_menu.Items.Add(Texts.OpenFolder, UiImages.Get("foldericon"), (_, _) => Open(share.Path));
_menu.Items.Add(new ToolStripSeparator());
var edit = new ToolStripMenuItem(Texts.Edit.Replace("&", ""), UiImages.Get("pencil"), (_, _) => EditSelected());
edit.Font = new Font(edit.Font, FontStyle.Bold);
_menu.Items.Add(edit);
_menu.Items.Add(share.Enabled ? Texts.Disable : Texts.Enable, UiImages.Get(share.Enabled ? "foldericond" : "foldericon"), async (_, _) => await ToggleSelectedAsync());
_menu.Items.Add(Texts.Remove.Replace("&", ""), UiImages.Get("delete"), (_, _) => RemoveSelected());
}
internal static void Open(string? target)
{
if (string.IsNullOrEmpty(target))
return;
try
{
Process.Start(new ProcessStartInfo(target) { UseShellExecute = true });
}
catch (Exception e) when (e is System.ComponentModel.Win32Exception or InvalidOperationException) { }
}
/// <summary>Cambia una copia de la configuracion, la guarda (si hay fichero) y la aplica.</summary>
private async Task ChangeAsync(Action<SunfishConfig> change)
{
var copy = _host.Config.Clone();
change(copy);
bool ok = IsQuick ? await _host.ApplyAsync(copy) : await _host.SaveAndApplyAsync(copy);
if (!ok)
MessageBox.Show(this, Texts.CannotStart(Strings.CannotListen(copy.Server.HttpPort, "")), Text, MessageBoxButtons.OK, MessageBoxIcon.Warning);
RefreshAll();
}
}