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 @@ ' ' + ' ' + ' ' + + ' ' + ' ' + '
' + '

Ключи доступа

' + diff --git a/internal/server/web/static/filepane.js b/internal/server/web/static/filepane.js index e705eb7..cbc39c1 100644 --- a/internal/server/web/static/filepane.js +++ b/internal/server/web/static/filepane.js @@ -87,6 +87,8 @@ ? {kind: 'ssh', id: o.agent.id + '|' + o.ssh.user + '@' + o.ssh.host + ':' + (o.ssh.port || 22), label: o.ssh.user + '@' + o.ssh.host} : {kind: 'agent', id: o.agent.id, label: o.agent.name}; this.cwd = '/'; + // startDir: the folder to open first (or a promise of it), not home. + this.startDir = o.dir || ''; this.entries = []; this.selected = new Set(); this.uploads = []; @@ -202,6 +204,12 @@ async start() { // Start in the home folder of the machine's user (or the SSH home); // a listing that fails there falls back to the root. + const dir = await this.startDir; + this.startDir = ''; + if (dir) { + const d = await this.api('list', {path: dir}); + if (d.ok) { this.show(d.data); return; } + } const r = await this.api('list', {path: this.src.home()}); if (r.ok) this.show(r.data); else if (!this.trouble(r.error || '')) this.go('/'); } @@ -434,7 +442,7 @@ window.FilePane = FilePane; window.MachineSource = MachineSource; - Workspace.paneTypes.files = agent => new FilePane({kind: 'agent', agent}); + Workspace.paneTypes.files = (agent, o) => new FilePane({kind: 'agent', agent, dir: o.dir}); Workspace.paneTypes.sshfiles = (agent, o) => new FilePane({kind: 'ssh', agent, ssh: o.ssh, src: new SSHSource(agent, o.conn, o.home, o.caps)}); // sshFiles connects agent to the SSH host t, then opens its files in a // pane; the password or key goes to the machine once and is not kept here. diff --git a/internal/server/web/static/workspace.js b/internal/server/web/static/workspace.js index a87d834..91e57fc 100644 --- a/internal/server/web/static/workspace.js +++ b/internal/server/web/static/workspace.js @@ -567,9 +567,16 @@ if (here) item(content, '+Здесь' + esc(here.name) + ' · ' + esc(here.user + '@' + here.host) + '', () => { closeMenu(); - // A terminal from a machine's file pane starts in its folder. - const dir = kind === 'term' && activePane.kind === 'files' ? activePane.cwd : ''; - openPane(kind, here, dir ? {dir} : null); + // A terminal from a machine's file pane starts in its folder, a + // file pane from a machine's terminal in the terminal's (asked of + // the machine; home if it cannot tell) - unless turned off. + const on = !(window.Prefs && Prefs.get().openHere === 'off'); + let opts = null; + if (on && kind === 'term' && activePane.kind === 'files') opts = {dir: activePane.cwd}; + else if (on && kind === 'files' && activePane.kind === 'term' && activePane.sid) { + opts = {dir: new MachineSource(here).op('termcwd', {session: activePane.sid}).then(r => r.ok && r.data ? r.data.path : '')}; + } + openPane(kind, here, opts); }); const box = document.createElement('div'); box.innerHTML = '
загрузка…
';