#!/usr/bin/env python3
#--------------------------------------------------------------------------------------------------------
# Name: Linux Lite - Lite Share Folder
# Architecture: amd64
# Author: Jerry Bezencon
# Website: https://www.linuxliteos.com
# Language: Python/GTK4
# Licence: GPLv2
#--------------------------------------------------------------------------------------------------------
# Per-folder Samba sharing helper, invoked from Thunar via Custom Action.
# Wraps `net usershare` so the user does not need root once they are in
# the sambashare group. First-run setup is performed by
# /usr/bin/lite-share-folder-setup via pkexec — it adds the user to
# sambashare, ensures smb.conf has the usershare stanza, enables smbd,
# opens firewalld, and (optionally) sets a Samba password for the user
# so remote machines can authenticate.

import os
import sys
import pwd
import grp
import subprocess
import re
from pathlib import Path

import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw, GLib, Gio, Gdk

APP_ID = "com.linuxliteos.LiteShareFolder"
SETUP_HELPER = "/usr/bin/lite-share-folder-setup"

# Absolute-path prefixes the user may not share. A folder is denied if it
# equals one of these or is a descendant.
DENY_ABSOLUTE = [
    "/etc", "/root", "/boot", "/proc", "/sys", "/dev", "/run",
    "/var/log", "/var/cache", "/var/lib",
    "/usr", "/snap",
]

# Home-relative folders treated as credential stores.
DENY_HOME_REL = [
    ".ssh", ".gnupg", ".config", ".local/share/keyrings",
    ".mozilla", ".thunderbird", ".pki", ".password-store",
    ".aws", ".kube", ".docker",
]

INVALID_SHARENAME_RE = re.compile(r"[^A-Za-z0-9_\-]")


# ----------------------------------------------------------------------
# helpers
# ----------------------------------------------------------------------

def run(cmd, **kw):
    p = subprocess.run(cmd, capture_output=True, text=True, **kw)
    return p.returncode, p.stdout, p.stderr


def run_with_stdin(cmd, stdin_text):
    """Run cmd, feeding stdin_text to its stdin. Returns (rc, stdout, stderr)."""
    p = subprocess.Popen(cmd, stdin=subprocess.PIPE,
                         stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                         text=True)
    out, err = p.communicate(input=stdin_text)
    return p.returncode, out, err


def current_user():
    return pwd.getpwuid(os.getuid()).pw_name


def user_in_sambashare_on_disk():
    """Is the user listed in /etc/group's sambashare entry? True
    immediately after gpasswd -a, even though the running process won't
    pick up the new GID until the next login."""
    user = current_user()
    try:
        return user in grp.getgrnam("sambashare").gr_mem
    except KeyError:
        return False


def process_can_usershare():
    """Does THIS process have sambashare in its kernel supplementary
    groups? Without this, `net usershare add` fails with a permission
    error even when /etc/group says the user is in the group."""
    try:
        gid = grp.getgrnam("sambashare").gr_gid
        return gid in os.getgroups()
    except KeyError:
        return False


def smbd_running():
    rc, _, _ = run(["systemctl", "is-active", "--quiet", "smbd"])
    return rc == 0


def folder_is_denied(path):
    try:
        p = Path(path).resolve()
    except (OSError, RuntimeError):
        return True
    s = str(p)

    if not p.is_dir():
        return True

    for prefix in DENY_ABSOLUTE:
        if s == prefix or s.startswith(prefix + "/"):
            return True

    home = str(Path.home())
    if s.startswith(home + "/"):
        rel = s[len(home) + 1:]
        for sub in DENY_HOME_REL:
            if rel == sub or rel.startswith(sub + "/"):
                return True

    if s == "/home":
        return True
    if s.startswith("/home/") and not s.startswith(home + "/") and s != home:
        return True

    return False


def sanitize_sharename(suggestion):
    s = INVALID_SHARENAME_RE.sub("_", suggestion).strip("_")
    if not s:
        s = "share"
    return s[:80]


def folder_to_default_name(path):
    base = os.path.basename(os.path.normpath(path)) or "share"
    return sanitize_sharename(base)


# ----------------------------------------------------------------------
# usershare backend
# ----------------------------------------------------------------------

def parse_usershare_info(text):
    info = {"name": None, "path": None, "comment": "", "acl": "", "guest": False}
    for line in text.splitlines():
        line = line.strip()
        if line.startswith("[") and line.endswith("]"):
            info["name"] = line[1:-1]
        elif line.startswith("path="):
            info["path"] = line[5:]
        elif line.startswith("comment="):
            info["comment"] = line[8:]
        elif line.startswith("usershare_acl="):
            info["acl"] = line[len("usershare_acl="):]
        elif line.startswith("guest_ok="):
            info["guest"] = line.split("=", 1)[1].strip().lower() in ("y", "yes", "true", "1")
    return info


def list_usershares():
    out = {}
    rc, names, _ = run(["net", "usershare", "list"])
    if rc != 0:
        return out
    for name in names.splitlines():
        name = name.strip()
        if not name:
            continue
        rc, info, _ = run(["net", "usershare", "info", "--long", name])
        if rc != 0:
            continue
        parsed = parse_usershare_info(info)
        if parsed["name"]:
            out[parsed["name"]] = parsed
    return out


def find_share_for_path(target_path):
    target = str(Path(target_path).resolve())
    for name, info in list_usershares().items():
        if info.get("path") == target:
            return name, info
    return None, None


def acl_is_writable(acl):
    return ":F" in (acl or "").upper()


def usershare_add(name, path, comment, writable, guest):
    everyone = "S-1-1-0"
    acl = f"{everyone}:{'F' if writable else 'R'}"
    guest_arg = "guest_ok=y" if guest else "guest_ok=n"
    return run([
        "net", "usershare", "add",
        name, str(path), comment or "", acl, guest_arg,
    ])


def usershare_delete(name):
    return run(["net", "usershare", "delete", name])


def hostname():
    rc, out, _ = run(["hostname"])
    return out.strip() if rc == 0 else os.uname().nodename


# ----------------------------------------------------------------------
# UI
# ----------------------------------------------------------------------

class FolderHeader(Gtk.Box):
    """Folder-icon + path block sitting at the top of every state."""

    def __init__(self, path):
        super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
        self.add_css_class("card")
        self.set_margin_top(0)
        self.set_margin_bottom(16)

        icon = Gtk.Image.new_from_icon_name("folder")
        icon.set_pixel_size(40)
        icon.set_margin_start(12)
        icon.set_margin_top(10)
        icon.set_margin_bottom(10)
        self.append(icon)

        text = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
        text.set_valign(Gtk.Align.CENTER)
        text.set_margin_top(10)
        text.set_margin_bottom(10)
        text.set_margin_end(12)
        text.set_hexpand(True)

        base = os.path.basename(os.path.normpath(path)) or path
        name = Gtk.Label(label=base)
        name.set_xalign(0)
        name.add_css_class("heading")
        name.set_ellipsize(2)
        text.append(name)

        full = Gtk.Label(label=path)
        full.set_xalign(0)
        full.add_css_class("dim-label")
        full.add_css_class("caption")
        full.set_ellipsize(2)
        text.append(full)

        self.append(text)


class MainWindow(Adw.ApplicationWindow):
    def __init__(self, app, folder_path):
        super().__init__(application=app)
        self.set_title("Share Folder")
        self.set_default_size(540, 1)
        self.set_resizable(False)
        self.set_icon_name("folder-publicshare")

        self.folder_path = folder_path
        self.toast_overlay = Adw.ToastOverlay()
        self.set_content(self.toast_overlay)

        self.toolbar = Adw.ToolbarView()
        self.toast_overlay.set_child(self.toolbar)

        self.header = Adw.HeaderBar()
        self.toolbar.add_top_bar(self.header)

        self.content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
        self.content_box.set_margin_start(20)
        self.content_box.set_margin_end(20)
        self.content_box.set_margin_top(16)
        self.content_box.set_margin_bottom(20)
        self.toolbar.set_content(self.content_box)

        # Set once the user has completed first-run successfully in this
        # session. Forces the relogin state on subsequent refreshes
        # because os.getgroups() won't reflect the new group until the
        # next login.
        self.setup_completed_this_session = False

        self._register_css()
        self.refresh()

    def _register_css(self):
        css = b"""
.lite-share-success-banner {
    background-color: #e8f5e9;
    border: 1px solid #a5d6a7;
    border-radius: 12px;
    padding: 14px 16px;
}
.lite-share-success-title {
    color: #1b5e20;
    font-weight: 600;
}
.lite-share-success-sub {
    color: #2e7d32;
    opacity: 1;
}
.lite-share-success-icon {
    color: #2e7d32;
}
"""
        provider = Gtk.CssProvider()
        try:
            provider.load_from_string(css.decode("utf-8"))
        except (AttributeError, TypeError):
            provider.load_from_data(css, -1)
        display = Gdk.Display.get_default()
        if display is not None:
            Gtk.StyleContext.add_provider_for_display(
                display,
                provider,
                Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
            )

    # ------------------------------------------------------------------
    # state routing
    # ------------------------------------------------------------------

    def refresh(self):
        child = self.content_box.get_first_child()
        while child is not None:
            self.content_box.remove(child)
            child = self.content_box.get_first_child()

        self.content_box.append(FolderHeader(self.folder_path))

        if folder_is_denied(self.folder_path):
            self.build_denied_state()
            return

        if not user_in_sambashare_on_disk():
            self.build_first_run_state()
            return

        # Process-level group check: even if the user is in sambashare on
        # disk, we still can't `net usershare add` until the kernel-level
        # group list for this process includes it. That only happens
        # after the user logs out and back in.
        if not process_can_usershare() or self.setup_completed_this_session:
            self.build_relogin_state()
            return

        existing_name, existing_info = find_share_for_path(self.folder_path)
        if existing_name:
            self.build_edit_state(existing_name, existing_info)
        else:
            self.build_new_state()

    # ------------------------------------------------------------------
    # state: denied
    # ------------------------------------------------------------------

    def build_denied_state(self):
        banner = Adw.PreferencesGroup()
        row = Adw.ActionRow()
        row.set_title("This folder can't be shared.")
        row.set_subtitle(
            "Hidden folders that may contain credentials (.ssh, .gnupg, "
            ".config, …) and system folders are blocked for safety."
        )
        icon = Gtk.Image.new_from_icon_name("dialog-warning-symbolic")
        icon.set_pixel_size(32)
        row.add_prefix(icon)
        banner.add(row)
        self.content_box.append(banner)

        self.append_button_row(
            primary_label="OK",
            on_primary=lambda *_: self.close(),
            cancel_label=None,
            destructive=None,
        )

    # ------------------------------------------------------------------
    # state: first run — Samba never configured + user not in group
    # ------------------------------------------------------------------

    def build_first_run_state(self):
        # Explanation card
        group = Adw.PreferencesGroup()
        row = Adw.ActionRow()
        row.set_title("One-time setup needed.")
        row.set_subtitle(
            "Linux Lite will turn on Samba, add you to the <tt>sambashare</tt> "
            "group, and create a network password. Other computers will use "
            "this password when they connect to folders you share from this "
            "machine. It can be different from your login password."
        )
        row.set_subtitle_lines(8)
        icon = Gtk.Image.new_from_icon_name("dialog-information-symbolic")
        icon.set_pixel_size(32)
        row.add_prefix(icon)
        group.add(row)
        self.content_box.append(group)

        # Password fields
        gap = Gtk.Box(); gap.set_size_request(-1, 12)
        self.content_box.append(gap)

        pw_group = Adw.PreferencesGroup()
        pw_group.set_title("Network password")

        self.entry_pw1 = Adw.PasswordEntryRow()
        self.entry_pw1.set_title("Password")
        self.entry_pw1.connect("changed", self._on_setup_pw_changed)
        pw_group.add(self.entry_pw1)

        self.entry_pw2 = Adw.PasswordEntryRow()
        self.entry_pw2.set_title("Confirm password")
        self.entry_pw2.connect("changed", self._on_setup_pw_changed)
        pw_group.add(self.entry_pw2)

        self.content_box.append(pw_group)

        # Inline status text (mismatch / too short)
        self.pw_status = Gtk.Label(label="")
        self.pw_status.set_xalign(0)
        self.pw_status.add_css_class("caption")
        self.pw_status.add_css_class("error")
        self.pw_status.set_margin_top(6)
        self.pw_status.set_margin_start(4)
        self.pw_status.set_visible(False)
        self.content_box.append(self.pw_status)

        # Buttons (keep ref to primary so we can en/disable it)
        spacer = Gtk.Box(); spacer.set_size_request(-1, 18)
        self.content_box.append(spacer)

        bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        bar.set_halign(Gtk.Align.FILL)
        gap2 = Gtk.Box(); gap2.set_hexpand(True)
        bar.append(gap2)

        cancel = Gtk.Button(label="Cancel")
        cancel.connect("clicked", lambda *_: self.close())
        bar.append(cancel)

        self.setup_primary_btn = Gtk.Button(label="Set Up Sharing")
        self.setup_primary_btn.add_css_class("suggested-action")
        self.setup_primary_btn.set_sensitive(False)
        self.setup_primary_btn.connect("clicked", self._on_setup_clicked)
        bar.append(self.setup_primary_btn)

        self.content_box.append(bar)

    def _on_setup_pw_changed(self, _entry):
        pw1 = self.entry_pw1.get_text()
        pw2 = self.entry_pw2.get_text()
        if not pw1 and not pw2:
            self.pw_status.set_visible(False)
            self.setup_primary_btn.set_sensitive(False)
            return
        if len(pw1) < 4:
            self.pw_status.set_text("Password must be at least 4 characters.")
            self.pw_status.set_visible(True)
            self.setup_primary_btn.set_sensitive(False)
            return
        if pw1 != pw2:
            self.pw_status.set_text("Passwords don't match.")
            self.pw_status.set_visible(True)
            self.setup_primary_btn.set_sensitive(False)
            return
        self.pw_status.set_visible(False)
        self.setup_primary_btn.set_sensitive(True)

    def _on_setup_clicked(self, *_):
        password = self.entry_pw1.get_text()
        self.set_sensitive(False)
        rc, out, err = run_with_stdin(
            ["pkexec", SETUP_HELPER, current_user()],
            password + "\n",
        )
        self.set_sensitive(True)

        if rc == 0:
            self.setup_completed_this_session = True
            self.show_toast("Sharing is set up.")
            self.refresh()  # will land on the relogin state
            return

        msg = (err or out or "").strip().splitlines()
        tail = msg[-1] if msg else "Setup was cancelled or failed."
        if "Not authorized" in (err or "") or rc == 126 or rc == 127:
            tail = "Setup was cancelled (no administrator password entered)."
        self.show_toast(f"Setup didn't complete: {tail}")

    # ------------------------------------------------------------------
    # state: post-setup, awaiting re-login
    # ------------------------------------------------------------------

    def build_relogin_state(self):
        group = Adw.PreferencesGroup()
        row = Adw.ActionRow()
        row.set_title("Almost there — log out and back in.")
        row.set_subtitle(
            "Samba is set up and you're in the <tt>sambashare</tt> group. The "
            "change takes effect after you log out and back in. Then "
            "right-click this folder again to share it."
        )
        row.set_subtitle_lines(5)
        icon = Gtk.Image.new_from_icon_name("emblem-ok-symbolic")
        icon.set_pixel_size(32)
        row.add_prefix(icon)
        group.add(row)
        self.content_box.append(group)

        self.append_button_row(
            primary_label="Close",
            on_primary=lambda *_: self.close(),
            cancel_label=None,
            destructive=None,
        )

    # ------------------------------------------------------------------
    # state: new share
    # ------------------------------------------------------------------

    def build_new_state(self):
        self.build_share_form(
            initial_name=folder_to_default_name(self.folder_path),
            initial_comment="",
            initial_writable=False,
            initial_guest=False,
            existing_name=None,
            success_banner=None,
        )

    # ------------------------------------------------------------------
    # state: edit existing share
    # ------------------------------------------------------------------

    def build_edit_state(self, existing_name, info):
        host = hostname()
        success_banner = (
            f"This folder is shared as \\\\{host}\\{existing_name}.",
            "Reachable from other Linux Lite, Windows and macOS machines on "
            "this network.",
        )
        self.build_share_form(
            initial_name=existing_name,
            initial_comment=info.get("comment", ""),
            initial_writable=acl_is_writable(info.get("acl", "")),
            initial_guest=info.get("guest", False),
            existing_name=existing_name,
            success_banner=success_banner,
        )

    # ------------------------------------------------------------------
    # shared form
    # ------------------------------------------------------------------

    def build_share_form(self, initial_name, initial_comment, initial_writable,
                         initial_guest, existing_name, success_banner):
        if success_banner:
            banner = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
            banner.add_css_class("lite-share-success-banner")
            banner.set_margin_bottom(12)

            icon = Gtk.Image.new_from_icon_name("emblem-ok-symbolic")
            icon.set_pixel_size(24)
            icon.set_valign(Gtk.Align.START)
            icon.add_css_class("lite-share-success-icon")
            banner.append(icon)

            text_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=3)
            text_box.set_hexpand(True)

            title = Gtk.Label(label=success_banner[0])
            title.set_xalign(0)
            title.set_wrap(True)
            title.add_css_class("lite-share-success-title")
            text_box.append(title)

            sub = Gtk.Label(label=success_banner[1])
            sub.set_xalign(0)
            sub.set_wrap(True)
            sub.add_css_class("lite-share-success-sub")
            sub.add_css_class("caption")
            text_box.append(sub)

            banner.append(text_box)
            self.content_box.append(banner)

        group = Adw.PreferencesGroup()

        self.entry_name = Adw.EntryRow()
        self.entry_name.set_title("Share name")
        self.entry_name.set_text(initial_name)
        self.entry_name.connect("changed", self._on_name_changed)
        group.add(self.entry_name)

        self.entry_comment = Adw.EntryRow()
        self.entry_comment.set_title("Comment (optional)")
        self.entry_comment.set_text(initial_comment)
        group.add(self.entry_comment)

        self.switch_write = Adw.SwitchRow()
        self.switch_write.set_title("Allow writing")
        self.switch_write.set_subtitle("Others can modify and delete files.")
        self.switch_write.set_active(initial_writable)
        group.add(self.switch_write)

        self.switch_guest = Adw.SwitchRow()
        self.switch_guest.set_title("Guest access")
        self.switch_guest.set_subtitle("Allow anyone on the network, no password.")
        self.switch_guest.set_active(initial_guest)
        group.add(self.switch_guest)

        self.content_box.append(group)

        # Network-password row, in its own group below the share form
        gap = Gtk.Box(); gap.set_size_request(-1, 12)
        self.content_box.append(gap)

        pw_group = Adw.PreferencesGroup()
        pw_row = Adw.ActionRow()
        pw_row.set_title("Network password")
        pw_row.set_subtitle(
            "What other computers enter when they connect to your shares."
        )
        change_btn = Gtk.Button(label="Change…")
        change_btn.set_valign(Gtk.Align.CENTER)
        change_btn.connect("clicked", lambda *_: self._open_change_password())
        pw_row.add_suffix(change_btn)
        pw_group.add(pw_row)
        self.content_box.append(pw_group)

        # Buttons
        if existing_name:
            self.append_button_row(
                primary_label="Save Changes",
                on_primary=lambda *_: self._do_save(existing_name),
                cancel_label="Cancel",
                on_cancel=lambda *_: self.close(),
                destructive=("Stop Sharing",
                             lambda *_: self._do_stop(existing_name)),
            )
        else:
            self.append_button_row(
                primary_label="Share",
                on_primary=lambda *_: self._do_save(None),
                cancel_label="Cancel",
                on_cancel=lambda *_: self.close(),
                destructive=None,
            )

    def _on_name_changed(self, entry):
        raw = entry.get_text()
        cleaned = sanitize_sharename(raw)
        if cleaned != raw:
            entry.set_text(cleaned)

    def _do_save(self, existing_name):
        name = sanitize_sharename(self.entry_name.get_text())
        comment = self.entry_comment.get_text().strip()
        writable = self.switch_write.get_active()
        guest = self.switch_guest.get_active()

        if not name:
            self.show_toast("Share name can't be empty.")
            return

        if existing_name and existing_name != name:
            rc, _, err = usershare_delete(existing_name)
            if rc != 0:
                self.show_toast(f"Couldn't rename share: {err.strip()}")
                return

        existing = list_usershares()
        if name in existing and existing[name]["path"] != str(
                Path(self.folder_path).resolve()):
            self.show_toast(
                f"The name '{name}' is already used for another folder."
            )
            return

        rc, out, err = usershare_add(
            name, str(Path(self.folder_path).resolve()),
            comment, writable, guest,
        )
        if rc != 0:
            msg = (err or out or "").strip().splitlines()
            tail = msg[-1] if msg else "Couldn't save the share."
            tail_l = tail.lower()
            if "max share count" in tail_l:
                tail = ("You've reached the maximum number of shares "
                        "allowed on this computer.")
            elif "you are not in the" in tail_l or "permission denied" in tail_l:
                tail = ("Permission denied. Try logging out and back in, "
                        "then share the folder again.")
            self.show_toast(tail)
            return

        self.refresh()
        self.show_toast(
            f"Sharing as \\\\{hostname()}\\{name}." if not existing_name
            else "Share updated."
        )

    def _do_stop(self, name):
        dlg = Adw.AlertDialog.new(
            f"Stop sharing '{name}'?",
            "Other computers on the network won't be able to reach this "
            "folder anymore. The files themselves stay where they are."
        )
        dlg.add_response("cancel", "Cancel")
        dlg.add_response("stop", "Stop Sharing")
        dlg.set_response_appearance("stop", Adw.ResponseAppearance.DESTRUCTIVE)
        dlg.set_default_response("cancel")
        dlg.set_close_response("cancel")
        dlg.connect("response", self._on_stop_confirmed, name)
        dlg.present(self)

    def _on_stop_confirmed(self, _dlg, response, name):
        if response != "stop":
            return
        rc, out, err = usershare_delete(name)
        if rc != 0:
            self.show_toast((err or out).strip().splitlines()[-1])
            return
        self.refresh()
        self.show_toast("Folder is no longer shared.")

    # ------------------------------------------------------------------
    # change-password dialog
    # ------------------------------------------------------------------

    def _open_change_password(self):
        # Adw.AlertDialog's set_extra_child is unreliable, so build a
        # small Adw.Window the way other LL apps do.
        win = Adw.Window()
        win.set_title("Change network password")
        win.set_default_size(420, 1)
        win.set_resizable(False)
        win.set_modal(True)
        win.set_transient_for(self)

        toolbar = Adw.ToolbarView()
        toolbar.add_top_bar(Adw.HeaderBar())
        win.set_content(toolbar)

        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
        box.set_margin_start(20); box.set_margin_end(20)
        box.set_margin_top(8); box.set_margin_bottom(20)
        toolbar.set_content(box)

        info = Gtk.Label(label=(
            "Set a new password for connecting to your shares from other "
            "computers. The username stays the same as your login name."
        ))
        info.set_wrap(True)
        info.set_xalign(0)
        info.add_css_class("dim-label")
        info.set_margin_bottom(12)
        box.append(info)

        pw_group = Adw.PreferencesGroup()
        e1 = Adw.PasswordEntryRow(); e1.set_title("New password")
        e2 = Adw.PasswordEntryRow(); e2.set_title("Confirm new password")
        pw_group.add(e1); pw_group.add(e2)
        box.append(pw_group)

        status = Gtk.Label(label="")
        status.set_xalign(0)
        status.add_css_class("caption")
        status.add_css_class("error")
        status.set_margin_top(6)
        status.set_visible(False)
        box.append(status)

        gap = Gtk.Box(); gap.set_size_request(-1, 18)
        box.append(gap)

        bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        bar.set_halign(Gtk.Align.FILL)
        spacer = Gtk.Box(); spacer.set_hexpand(True)
        bar.append(spacer)

        cancel = Gtk.Button(label="Cancel")
        cancel.connect("clicked", lambda *_: win.close())
        bar.append(cancel)

        save = Gtk.Button(label="Save")
        save.add_css_class("suggested-action")
        save.set_sensitive(False)
        bar.append(save)
        box.append(bar)

        def validate(*_):
            p1 = e1.get_text()
            p2 = e2.get_text()
            if not p1 and not p2:
                status.set_visible(False)
                save.set_sensitive(False)
                return
            if len(p1) < 4:
                status.set_text("Password must be at least 4 characters.")
                status.set_visible(True)
                save.set_sensitive(False)
                return
            if p1 != p2:
                status.set_text("Passwords don't match.")
                status.set_visible(True)
                save.set_sensitive(False)
                return
            status.set_visible(False)
            save.set_sensitive(True)

        e1.connect("changed", validate)
        e2.connect("changed", validate)

        def do_save(*_):
            pw = e1.get_text()
            win.set_sensitive(False)
            rc, out, err = run_with_stdin(
                ["pkexec", SETUP_HELPER, "--password-only", current_user()],
                pw + "\n",
            )
            win.set_sensitive(True)
            if rc == 0:
                self.show_toast("Network password updated.")
                win.close()
            else:
                msg = (err or out or "").strip().splitlines()
                self.show_toast(msg[-1] if msg
                                else "Couldn't update the password.")
        save.connect("clicked", do_save)

        win.present()

    # ------------------------------------------------------------------
    # button row utility
    # ------------------------------------------------------------------

    def append_button_row(self, primary_label, on_primary,
                          cancel_label=None, on_cancel=None,
                          destructive=None):
        spacer = Gtk.Box(); spacer.set_size_request(-1, 18)
        self.content_box.append(spacer)

        bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        bar.set_halign(Gtk.Align.FILL)

        if destructive:
            label, cb = destructive
            btn = Gtk.Button(label=label)
            btn.add_css_class("destructive-action")
            btn.connect("clicked", cb)
            bar.append(btn)

        gap = Gtk.Box(); gap.set_hexpand(True)
        bar.append(gap)

        if cancel_label:
            cancel = Gtk.Button(label=cancel_label)
            cancel.connect("clicked", on_cancel or (lambda *_: self.close()))
            bar.append(cancel)

        primary = Gtk.Button(label=primary_label)
        primary.add_css_class("suggested-action")
        primary.connect("clicked", on_primary)
        bar.append(primary)

        self.content_box.append(bar)

    def show_toast(self, text):
        self.toast_overlay.add_toast(Adw.Toast.new(text))


class App(Adw.Application):
    def __init__(self, folder_path):
        super().__init__(application_id=APP_ID,
                         flags=Gio.ApplicationFlags.NON_UNIQUE)
        self.folder_path = folder_path
        self.connect("activate", self._on_activate)

    def _on_activate(self, _app):
        win = MainWindow(self, self.folder_path)
        win.present()


def main():
    if len(sys.argv) < 2:
        print("usage: lite-share-folder <folder-path>", file=sys.stderr)
        return 2

    folder_path = sys.argv[1]
    if not os.path.isdir(folder_path):
        app = Adw.Application(application_id=APP_ID,
                              flags=Gio.ApplicationFlags.NON_UNIQUE)

        def _bad(app):
            win = Adw.ApplicationWindow(application=app, title="Share Folder")
            win.set_default_size(420, 1)
            tb = Adw.ToolbarView()
            tb.add_top_bar(Adw.HeaderBar())
            box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
            box.set_margin_start(20); box.set_margin_end(20)
            box.set_margin_top(20); box.set_margin_bottom(20)
            lbl = Gtk.Label(label=f"Folder not found:\n{folder_path}")
            lbl.set_wrap(True); lbl.set_xalign(0)
            box.append(lbl)
            ok = Gtk.Button(label="OK"); ok.add_css_class("suggested-action")
            ok.set_halign(Gtk.Align.END)
            ok.connect("clicked", lambda *_: win.close())
            box.append(ok)
            tb.set_content(box)
            win.set_content(tb)
            win.present()
        app.connect("activate", _bad)
        return app.run([])

    return App(folder_path).run([])


if __name__ == "__main__":
    sys.exit(main())
