271 lines
8.7 KiB
Go
271 lines
8.7 KiB
Go
package launcher
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"aego/core/mathx"
|
|
"aego/engine/i18n"
|
|
"aego/project"
|
|
"aego/project/remoteexample"
|
|
"aego/ui/core"
|
|
"aego/ui/widgets"
|
|
)
|
|
|
|
// RemoteExamples is backed by the existing remoteexample store and HTTPS
|
|
// client. Implementations must not launch games, plugins or external commands.
|
|
type RemoteExamples interface {
|
|
List(context.Context) ([]RemoteCopy, error)
|
|
Open(context.Context, string, string, bool) (RemoteCopy, error)
|
|
Reopen(context.Context, string) (RemoteCopy, error)
|
|
Check(context.Context, string, string) (RemoteUpdate, error)
|
|
}
|
|
|
|
type RemoteCopy struct {
|
|
ID, Name, Version, Source, Directory, Entry string
|
|
}
|
|
type RemoteUpdate struct {
|
|
Available bool
|
|
Current, Remote string
|
|
}
|
|
type remoteRequest struct {
|
|
kind, source, origin, id string
|
|
}
|
|
type remoteReply struct {
|
|
kind string
|
|
copies []RemoteCopy
|
|
copy RemoteCopy
|
|
update RemoteUpdate
|
|
err error
|
|
}
|
|
type remoteJob struct {
|
|
cancel context.CancelFunc
|
|
done chan remoteReply
|
|
canceled bool
|
|
}
|
|
type remoteState struct {
|
|
url string
|
|
copies []RemoteCopy
|
|
pending *remoteRequest
|
|
job *remoteJob
|
|
status string
|
|
closed bool
|
|
fromLink bool
|
|
}
|
|
|
|
// RequestExampleLink routes an OS URL request to the ordinary, explicit remote
|
|
// confirmation. Both scene and project links open an isolated editable project;
|
|
// neither imports into the current project nor starts Play. No URL parameter
|
|
// can choose a filesystem path, invoke a command or approve its own origin.
|
|
func (l *Launcher) RequestExampleLink(raw string) error {
|
|
link, err := remoteexample.ParseLink(raw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if l.opt.Remote == nil || l.remote.closed || l.remote.job != nil || l.remote.pending != nil {
|
|
return fmt.Errorf("remote example request cannot replace an active operation")
|
|
}
|
|
l.SetTab(TabExamples)
|
|
l.remote.url = link.ManifestURL
|
|
l.remote.fromLink = true
|
|
l.confirmRemote("open", link.ManifestURL, "")
|
|
if l.remote.pending == nil {
|
|
return fmt.Errorf("remote example confirmation could not be prepared")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AwaitingExampleConfirmation lets the host defer unrelated automatic network
|
|
// work for a freshly opened protocol request. Explicit user actions remain
|
|
// separate, and an already running job cannot be canceled retroactively.
|
|
func (l *Launcher) AwaitingExampleConfirmation() bool {
|
|
return l.remote.fromLink && l.remote.pending != nil
|
|
}
|
|
|
|
// Close cancels in-flight HTTPS before the launcher is destroyed. The worker
|
|
// only owns its result channel and never touches UI state from another thread.
|
|
func (l *Launcher) Close() {
|
|
l.remote.closed = true
|
|
l.cancelRemote()
|
|
}
|
|
|
|
// If another project was chosen while a worker was finishing, its late reply
|
|
// must never replace the user's selection or start a different project.
|
|
func (l *Launcher) cancelRemote() {
|
|
l.remote.pending = nil
|
|
if l.remote.job != nil {
|
|
l.remote.job.canceled = true
|
|
l.remote.job.cancel()
|
|
}
|
|
}
|
|
|
|
func (l *Launcher) confirmRemote(kind, source, id string) {
|
|
if l.opt.Remote == nil || l.remote.closed || l.remote.job != nil {
|
|
return
|
|
}
|
|
source = strings.TrimSpace(source)
|
|
origin, err := remoteexample.Origin(source)
|
|
if err != nil {
|
|
l.err = err.Error()
|
|
return
|
|
}
|
|
l.err, l.remote.status = "", ""
|
|
// Freeze both URL and origin. Edits to the text field cannot silently
|
|
// change the operation the user is being asked to approve.
|
|
l.remote.pending = &remoteRequest{kind: kind, source: source, origin: origin, id: id}
|
|
}
|
|
|
|
func (l *Launcher) startRemote(req remoteRequest) {
|
|
if l.opt.Remote == nil || l.remote.closed || l.remote.job != nil {
|
|
return
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
job := &remoteJob{cancel: cancel, done: make(chan remoteReply, 1)}
|
|
l.remote.job = job
|
|
l.err, l.remote.status = "", ""
|
|
service := l.opt.Remote
|
|
go func() {
|
|
r := remoteReply{kind: req.kind}
|
|
switch req.kind {
|
|
case "list":
|
|
r.copies, r.err = service.List(ctx)
|
|
case "open", "clone":
|
|
r.copy, r.err = service.Open(ctx, req.source, req.origin, req.kind == "clone")
|
|
case "reopen":
|
|
r.copy, r.err = service.Reopen(ctx, req.id)
|
|
case "check":
|
|
r.update, r.err = service.Check(ctx, req.id, req.origin)
|
|
default:
|
|
r.err = fmt.Errorf("unsupported remote example operation")
|
|
}
|
|
job.done <- r
|
|
}()
|
|
}
|
|
|
|
func (l *Launcher) pollRemote() {
|
|
job := l.remote.job
|
|
if job == nil {
|
|
return
|
|
}
|
|
select {
|
|
case r := <-job.done:
|
|
l.remote.job = nil
|
|
job.cancel()
|
|
if job.canceled || l.remote.closed {
|
|
l.remote.status = i18n.Tr("launcher.remote.canceled", "Canceled. A copy already saved locally is kept.")
|
|
return
|
|
}
|
|
if r.err != nil {
|
|
l.err = r.err.Error()
|
|
return
|
|
}
|
|
switch r.kind {
|
|
case "list":
|
|
l.remote.copies = r.copies
|
|
if len(r.copies) == 0 {
|
|
l.remote.status = i18n.Tr("launcher.remote.no_copies", "No saved remote examples yet.")
|
|
}
|
|
case "open", "clone", "reopen":
|
|
if err := l.acceptRemoteCopy(r.copy); err != nil {
|
|
l.err = err.Error()
|
|
}
|
|
case "check":
|
|
if r.update.Available {
|
|
l.remote.status = i18n.Tr("launcher.remote.update_available", "Version {version} is available. Your local copy was not changed.", i18n.Str("version", r.update.Remote))
|
|
} else {
|
|
l.remote.status = i18n.Tr("launcher.remote.up_to_date", "No newer version. Your local copy was not changed.")
|
|
}
|
|
}
|
|
default:
|
|
}
|
|
}
|
|
|
|
func (l *Launcher) acceptRemoteCopy(c RemoteCopy) error {
|
|
root, err := filepath.Abs(c.Directory)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
entry, err := filepath.Abs(c.Entry)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel, err := filepath.Rel(root, entry)
|
|
if err != nil || !filepath.IsLocal(rel) || !strings.EqualFold(filepath.Ext(rel), ".aescene") {
|
|
return fmt.Errorf("%s", i18n.Tr("launcher.remote.bad_entry", "The remote entry scene is outside its project."))
|
|
}
|
|
st, err := os.Lstat(entry)
|
|
if err != nil || !st.Mode().IsRegular() {
|
|
return fmt.Errorf("%s", i18n.Tr("launcher.remote.bad_entry", "The remote entry scene is outside its project."))
|
|
}
|
|
p, err := project.Load(root)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if p.Native {
|
|
return fmt.Errorf("%s", i18n.Tr("launcher.remote.native_blocked", "Remote examples cannot start a native Go project."))
|
|
}
|
|
if err = l.Open(root); err != nil {
|
|
return err
|
|
}
|
|
l.resultEntry, l.resultRemote = filepath.ToSlash(rel), true
|
|
return nil
|
|
}
|
|
|
|
func (l *Launcher) remoteTab(ui *core.Context) {
|
|
if l.opt.Remote == nil {
|
|
return
|
|
}
|
|
l.heading(ui, i18n.Tr("launcher.remote.heading", "Remote examples"))
|
|
widgets.LabelWrapped(ui, i18n.Tr("launcher.remote.explanation", "HTTPS examples are checked and copied locally. Aego does not run the project automatically; external import tools are disabled."), false)
|
|
widgets.TextFieldRect(ui, ui.ID("remote.url"), widgets.Field(ui, i18n.Tr("launcher.remote.url", "Manifest HTTPS URL")), &l.remote.url, false)
|
|
if l.remote.job != nil {
|
|
widgets.LabelDim(ui, i18n.Tr("launcher.remote.working", "Checking or downloading the example…"))
|
|
if widgets.Button(ui, i18n.Tr("launcher.remote.cancel", "Cancel")) {
|
|
l.remote.job.canceled = true
|
|
l.remote.job.cancel()
|
|
}
|
|
return
|
|
}
|
|
if l.remote.pending != nil {
|
|
r := l.remote.pending
|
|
widgets.LabelWrapped(ui, i18n.Tr("launcher.remote.confirm_origin", "Allow network access to this origin for this operation: {origin}", i18n.Str("origin", r.origin)), true)
|
|
widgets.LabelWrapped(ui, r.source, true)
|
|
if widgets.Button(ui, i18n.Tr("launcher.remote.confirm", "Confirm origin and continue")) {
|
|
l.remote.pending = nil
|
|
l.startRemote(*r)
|
|
}
|
|
if widgets.Button(ui, i18n.Tr("launcher.remote.cancel", "Cancel")) {
|
|
l.remote.pending = nil
|
|
}
|
|
return
|
|
}
|
|
if widgets.Button(ui, i18n.Tr("launcher.remote.open", "Open saved copy or clone")) {
|
|
l.confirmRemote("open", l.remote.url, "")
|
|
}
|
|
if widgets.Button(ui, i18n.Tr("launcher.remote.clone", "Clone a new copy")) {
|
|
l.confirmRemote("clone", l.remote.url, "")
|
|
}
|
|
if widgets.Button(ui, i18n.Tr("launcher.remote.refresh", "Show saved remote examples")) {
|
|
l.startRemote(remoteRequest{kind: "list"})
|
|
}
|
|
if l.remote.status != "" {
|
|
widgets.LabelWrapped(ui, l.remote.status, false)
|
|
}
|
|
for _, item := range l.remote.copies {
|
|
ui.PushID(item.ID)
|
|
row := ui.Cursor().Next(0)
|
|
buttonW := min(float32(120), max(float32(55), (row.W-8)/3))
|
|
ui.TextIn(mathx.Rect{X: row.X, Y: row.Y, W: max(0, row.W-2*buttonW-8), H: row.H}, item.Name+" · "+item.Version, ui.Theme.Text, core.AlignLeft)
|
|
if widgets.ButtonRect(ui, ui.ID("open"), mathx.Rect{X: row.X + row.W - 2*buttonW - 4, Y: row.Y, W: buttonW, H: row.H}, i18n.Tr("launcher.remote.reopen", "Open offline")) {
|
|
l.startRemote(remoteRequest{kind: "reopen", id: item.ID})
|
|
}
|
|
if widgets.ButtonRect(ui, ui.ID("check"), mathx.Rect{X: row.X + row.W - buttonW, Y: row.Y, W: buttonW, H: row.H}, i18n.Tr("launcher.remote.check", "Check updates")) {
|
|
l.confirmRemote("check", item.Source, item.ID)
|
|
}
|
|
ui.PopID()
|
|
}
|
|
}
|