diff --git a/VERSION.txt b/VERSION.txt index 0ea3a94..0c62199 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -0.2.0 +0.2.1 diff --git a/internal/agent/session.go b/internal/agent/session.go index a48c8ae..9c6e1ce 100644 --- a/internal/agent/session.go +++ b/internal/agent/session.go @@ -26,6 +26,8 @@ func (a *Agent) runTerminal(parent context.Context, sid, user, dir string) { return } log.Printf("term %s: shell started (user %q)", sid, user) + termsBySID.Store(sid, term) + defer termsBySID.Delete(sid) a.bridgeStream(parent, sid, term, term.Resize) log.Printf("term %s: ended", sid) } diff --git a/internal/agent/termcwd.go b/internal/agent/termcwd.go new file mode 100644 index 0000000..661901b --- /dev/null +++ b/internal/agent/termcwd.go @@ -0,0 +1,62 @@ +package agent + +import ( + "errors" + "os" + "os/exec" + "runtime" + "strconv" + "strings" + "sync" + + "golang.org/x/sys/unix" +) + +// termsBySID are the running terminals by their session, so the panel can +// ask where one of them is. +var termsBySID sync.Map // sid -> *PTY + +// Cwd is the folder the terminal is in now: that of the program in front +// (the shell, or what it runs), else of the shell itself. +func (p *PTY) Cwd() (string, error) { + pid := 0 + if g, err := unix.IoctlGetInt(int(p.f.Fd()), unix.TIOCGPGRP); err == nil && g > 0 { + pid = g + } + if pid > 0 { + if d, err := procCwd(pid); err == nil { + return d, nil + } + } + return procCwd(p.cmd.Process.Pid) +} + +// procCwd is a process's working folder: /proc on Linux, lsof elsewhere. +func procCwd(pid int) (string, error) { + if runtime.GOOS == "linux" { + return os.Readlink("/proc/" + strconv.Itoa(pid) + "/cwd") + } + out, err := exec.Command("lsof", "-a", "-p", strconv.Itoa(pid), "-d", "cwd", "-Fn").Output() + if err != nil { + return "", err + } + for _, l := range strings.Split(string(out), "\n") { + if strings.HasPrefix(l, "n/") { + return l[1:], nil + } + } + return "", errors.New("папка терминала не найдена") +} + +// termCwd answers the panel: where the terminal of session sid is now. +func termCwd(sid string) (any, error) { + v, ok := termsBySID.Load(sid) + if !ok { + return nil, errors.New("терминал не найден") + } + d, err := v.(*PTY).Cwd() + if err != nil { + return nil, err + } + return map[string]string{"path": d}, nil +} diff --git a/internal/agent/terminal_test.go b/internal/agent/terminal_test.go index d532536..b49d9b6 100644 --- a/internal/agent/terminal_test.go +++ b/internal/agent/terminal_test.go @@ -1,11 +1,14 @@ package agent import ( + "io" "os" "path/filepath" "strings" "testing" "time" + + "github.com/x-grk/longpolling-remote-access/internal/proto" ) // A terminal opened with a folder starts its shell there; a folder that is @@ -60,3 +63,53 @@ func ptyPwd(t *testing.T, dir string) string { t.Fatalf("no pwd in %q", out.String()) return "" } + +// A terminal tells the folder its shell is in now, after a cd. +func TestPTYCwd(t *testing.T) { + dir, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + sub := filepath.Join(dir, "next one") + if err := os.Mkdir(sub, 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("SHELL", "/bin/sh") + p, err := StartPTY("", dir) + if err != nil { + t.Fatal(err) + } + defer p.Close() + go func() { _, _ = io.Copy(io.Discard, p) }() + if _, err := p.Write([]byte("cd 'next one'\n")); err != nil { + t.Fatal(err) + } + var got string + for i := 0; i < 50; i++ { + if got, err = p.Cwd(); err == nil && got == sub { + return + } + time.Sleep(100 * time.Millisecond) + } + t.Fatalf("Cwd = %q, %v; want %q", got, err, sub) +} + +// The panel asks for a terminal's folder by its session. +func TestTermCwdTask(t *testing.T) { + dir, _ := filepath.EvalSymlinks(t.TempDir()) + t.Setenv("SHELL", "/bin/sh") + p, err := StartPTY("", dir) + if err != nil { + t.Fatal(err) + } + defer p.Close() + termsBySID.Store("s1", p) + defer termsBySID.Delete("s1") + res, err := dispatchTask(proto.TaskEnvelope{Type: proto.TaskTermCwd, Args: []byte(`{"session":"s1"}`)}) + if err != nil || res.(map[string]string)["path"] != dir { + t.Fatalf("term.cwd = %v, %v; want %q", res, err, dir) + } + if _, err := dispatchTask(proto.TaskEnvelope{Type: proto.TaskTermCwd, Args: []byte(`{"session":"nope"}`)}); err == nil { + t.Fatal("an unknown session has a folder") + } +} diff --git a/internal/agent/users.go b/internal/agent/users.go index 4887a5b..97cd1fb 100644 --- a/internal/agent/users.go +++ b/internal/agent/users.go @@ -41,6 +41,10 @@ func dispatchTask(env proto.TaskEnvelope) (any, error) { return nil, addUser(a) case proto.TaskDBTables, proto.TaskDBRows, proto.TaskDBSet, proto.TaskDBAdd, proto.TaskDBDel, proto.TaskDBSQL: return dbTask(env) + case proto.TaskTermCwd: + var a proto.TermCwdArgs + _ = json.Unmarshal(env.Args, &a) + return termCwd(a.Session) } return dispatchFS(env) } diff --git a/internal/proto/proto.go b/internal/proto/proto.go index 7a11e35..fb83d55 100644 --- a/internal/proto/proto.go +++ b/internal/proto/proto.go @@ -173,6 +173,7 @@ const ( TaskDBAdd = "db.add" // add a row TaskDBDel = "db.del" // delete rows by their keys TaskDBSQL = "db.sql" // run a statement typed in the panel + TaskTermCwd = "term.cwd" // the folder a terminal is in now (TermCwdArgs); result {path} TaskSSHFSOpen = "sshfs.open" // connect to an SSH host for a file pane; result {id, home} TaskSSHFSClose = "sshfs.close" // drop that connection TaskSSHFSOp = "sshfs.op" // a file operation on the host: list, read, write, mkdir, delete, deltree, move, copy, stat, chmod @@ -401,3 +402,8 @@ type ShipProgress struct { Files int `json:"files"` Note string `json:"note,omitempty"` // what is going on: connecting, signing in } + +// TermCwdArgs names the terminal session whose folder the panel asks for. +type TermCwdArgs struct { + Session string `json:"session"` +} diff --git a/internal/server/frag_test.go b/internal/server/frag_test.go index 1ed138a..2541a8f 100644 --- a/internal/server/frag_test.go +++ b/internal/server/frag_test.go @@ -140,4 +140,12 @@ func TestViewPrefs(t *testing.T) { if got := post(`{"termFont":99}`); !strings.Contains(got, `"ok":true`) || strings.Contains(get(t, s, c, "/api/panel/account/prefs").Body.String(), "99") { t.Fatal("a font size out of range was kept") } + post(`{"openHere":"off"}`) + if got := get(t, s, c, "/api/panel/account/prefs").Body.String(); !strings.Contains(got, `"openHere":"off"`) { + t.Fatalf("openHere off not kept: %s", got) + } + post(`{"openHere":"sometimes"}`) + if got := get(t, s, c, "/api/panel/account/prefs").Body.String(); strings.Contains(got, "openHere") { + t.Fatalf("an unknown openHere was kept: %s", got) + } } diff --git a/internal/server/handlers_panel_fs.go b/internal/server/handlers_panel_fs.go index e9f60ef..cd01e24 100644 --- a/internal/server/handlers_panel_fs.go +++ b/internal/server/handlers_panel_fs.go @@ -29,6 +29,7 @@ var fsOps = map[string]string{ "db.add": proto.TaskDBAdd, "db.del": proto.TaskDBDel, "db.sql": proto.TaskDBSQL, + "termcwd": proto.TaskTermCwd, } // fsTaskTimeout bounds how long we wait for an agent to answer a file op. diff --git a/internal/server/prefs.go b/internal/server/prefs.go index c67a01f..486a656 100644 --- a/internal/server/prefs.go +++ b/internal/server/prefs.go @@ -8,13 +8,15 @@ import ( // Each user's look of the panel (Аккаунт › Вид), kept on the server so it // is the same on every device: where a tab's icons go, the terminal's font -// size, a compact file list, the time column, animations. +// size, a compact file list, the time column, animations, and whether a +// terminal or file pane opened «Здесь» starts in the active pane's folder. type viewPrefs struct { TabIcons string `json:"tabIcons,omitempty"` // left (default), right, hide TermFont int `json:"termFont,omitempty"` // 11-20; 0: the default Compact bool `json:"compact,omitempty"` // denser file lists FileTime string `json:"fileTime,omitempty"` // show (default), hide Motion string `json:"motion,omitempty"` // on (default), off + OpenHere string `json:"openHere,omitempty"` // on (default), off } func prefsKey(id int64) string { return "prefs_" + strconv.FormatInt(id, 10) } @@ -33,6 +35,9 @@ func (p viewPrefs) clean() viewPrefs { if p.Motion != "off" { p.Motion = "" } + if p.OpenHere != "off" { + p.OpenHere = "" + } return p } diff --git a/internal/server/web/static/account.js b/internal/server/web/static/account.js index d153000..d926175 100644 --- a/internal/server/web/static/account.js +++ b/internal/server/web/static/account.js @@ -62,6 +62,7 @@ ' ' + ' ' + ' ' + + ' ' + ' ' + '