feat(app): add internal components batch 1
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf16"
|
||||
)
|
||||
|
||||
const macDirectoryPickerScript = `ObjC.import('AppKit');
|
||||
function run(arguments) {
|
||||
var application = $.NSApplication.sharedApplication;
|
||||
application.setActivationPolicy($.NSApplicationActivationPolicyAccessory);
|
||||
var panel = $.NSOpenPanel.openPanel;
|
||||
panel.setTitle('选择下载文件夹');
|
||||
panel.setPrompt('选择');
|
||||
panel.setCanChooseFiles(false);
|
||||
panel.setCanChooseDirectories(true);
|
||||
panel.setAllowsMultipleSelection(false);
|
||||
panel.setCanCreateDirectories(true);
|
||||
panel.setDirectoryURL($.NSURL.fileURLWithPath(arguments[0]));
|
||||
application.activateIgnoringOtherApps(true);
|
||||
return panel.runModal === $.NSModalResponseOK ? ObjC.unwrap(panel.URL.path) : '';
|
||||
}`
|
||||
|
||||
const windowsDirectoryPickerScript = `$ErrorActionPreference = 'Stop'
|
||||
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
[System.Windows.Forms.Application]::EnableVisualStyles()
|
||||
$dialog = New-Object System.Windows.Forms.FolderBrowserDialog
|
||||
$owner = New-Object System.Windows.Forms.Form
|
||||
try {
|
||||
$dialog.Description = '选择下载文件夹'
|
||||
$dialog.ShowNewFolderButton = $true
|
||||
$dialog.SelectedPath = $env:JUKU_PICKER_START_DIRECTORY
|
||||
$owner.ShowInTaskbar = $false
|
||||
$owner.TopMost = $true
|
||||
$owner.Opacity = 0
|
||||
$owner.Show()
|
||||
$owner.Activate()
|
||||
if ($dialog.ShowDialog($owner) -eq [System.Windows.Forms.DialogResult]::OK) {
|
||||
[Console]::Write($dialog.SelectedPath)
|
||||
}
|
||||
} finally {
|
||||
$dialog.Dispose()
|
||||
$owner.Dispose()
|
||||
}`
|
||||
|
||||
var configureDirectoryPickerProcess = func(command *exec.Cmd) {}
|
||||
|
||||
func directoryPickerCommand(ctx context.Context, system, initial string) (*exec.Cmd, bool, error) {
|
||||
var command *exec.Cmd
|
||||
cancelExit := false
|
||||
switch system {
|
||||
case "darwin":
|
||||
command = exec.CommandContext(ctx, "osascript", "-l", "JavaScript", "-e", macDirectoryPickerScript, initial)
|
||||
case "windows":
|
||||
encoded := utf16.Encode([]rune(windowsDirectoryPickerScript))
|
||||
body := make([]byte, len(encoded)*2)
|
||||
for index, value := range encoded {
|
||||
body[index*2], body[index*2+1] = byte(value), byte(value>>8)
|
||||
}
|
||||
command = exec.CommandContext(ctx, "powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-STA", "-EncodedCommand", base64.StdEncoding.EncodeToString(body))
|
||||
command.Env = append(os.Environ(), "JUKU_PICKER_START_DIRECTORY="+initial)
|
||||
case "linux":
|
||||
if os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" {
|
||||
return nil, false, errors.New("当前没有桌面会话,请手动输入下载路径")
|
||||
}
|
||||
if executable, err := exec.LookPath("zenity"); err == nil {
|
||||
command = exec.CommandContext(ctx, executable, "--file-selection", "--directory", "--title=选择下载文件夹", "--filename="+initial+string(os.PathSeparator))
|
||||
} else if executable, err := exec.LookPath("kdialog"); err == nil {
|
||||
command = exec.CommandContext(ctx, executable, "--getexistingdirectory", initial, "--title", "选择下载文件夹")
|
||||
} else {
|
||||
return nil, false, errors.New("当前桌面没有 zenity 或 kdialog,请安装其一或手动输入下载路径")
|
||||
}
|
||||
cancelExit = true
|
||||
default:
|
||||
return nil, false, errors.New("当前系统暂不支持原生文件夹选择,请手动输入下载路径")
|
||||
}
|
||||
configureDirectoryPickerProcess(command)
|
||||
return command, cancelExit, nil
|
||||
}
|
||||
|
||||
func nativeDirectoryPicker(ctx context.Context, initial string) (string, error) {
|
||||
command, cancelExit, err := directoryPickerCommand(ctx, runtime.GOOS, initial)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
output, err := command.Output()
|
||||
if ctx.Err() != nil {
|
||||
return "", ctx.Err()
|
||||
}
|
||||
if err != nil {
|
||||
var exitError *exec.ExitError
|
||||
if cancelExit && errors.As(err, &exitError) && exitError.ExitCode() == 1 && strings.TrimSpace(string(exitError.Stderr)) == "" {
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("系统文件夹选择框启动失败,请确认桌面会话可用,或手动输入路径: %w", err)
|
||||
}
|
||||
return strings.TrimRight(strings.TrimPrefix(string(output), "\ufeff"), "\r\n"), nil
|
||||
}
|
||||
|
||||
func pickerInitialDirectory(initial, fallback string) string {
|
||||
for _, candidate := range []string{initial, fallback, "."} {
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
absolute, err := filepath.Abs(candidate)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for {
|
||||
if info, err := os.Stat(absolute); err == nil && info.IsDir() {
|
||||
return absolute
|
||||
}
|
||||
parent := filepath.Dir(absolute)
|
||||
if parent == absolute {
|
||||
break
|
||||
}
|
||||
absolute = parent
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func directorySelectionPath(absolute string) string {
|
||||
if working, err := os.Getwd(); err == nil {
|
||||
if relative, err := filepath.Rel(working, absolute); err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative) {
|
||||
return relative
|
||||
}
|
||||
}
|
||||
return absolute
|
||||
}
|
||||
|
||||
func localDirectoryPickerRequest(request *http.Request) bool {
|
||||
remote, _, err := net.SplitHostPort(request.RemoteAddr)
|
||||
if err != nil || !net.ParseIP(remote).IsLoopback() {
|
||||
return false
|
||||
}
|
||||
host := (&url.URL{Host: request.Host}).Hostname()
|
||||
if !strings.EqualFold(host, "localhost") && !net.ParseIP(host).IsLoopback() {
|
||||
return false
|
||||
}
|
||||
if site := request.Header.Get("Sec-Fetch-Site"); site != "" && site != "same-origin" && site != "none" {
|
||||
return false
|
||||
}
|
||||
if origin := request.Header.Get("Origin"); origin != "" {
|
||||
parsed, err := url.Parse(origin)
|
||||
if err != nil || parsed.User != nil || parsed.Scheme != "http" && parsed.Scheme != "https" || !strings.EqualFold(parsed.Host, request.Host) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *UIApp) handleDirectoryPicker(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.Header().Set("Cache-Control", "no-store")
|
||||
if request.Method != http.MethodPost {
|
||||
writer.Header().Set("Allow", http.MethodPost)
|
||||
writeJSON(writer, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||
return
|
||||
}
|
||||
if !localDirectoryPickerRequest(request) {
|
||||
writeJSON(writer, http.StatusForbidden, map[string]string{"error": "请在运行程序的电脑上使用 localhost 或 127.0.0.1 打开界面;远程访问请手动输入服务器路径"})
|
||||
return
|
||||
}
|
||||
contentType, _, _ := mime.ParseMediaType(request.Header.Get("Content-Type"))
|
||||
if contentType != "application/json" {
|
||||
writeJSON(writer, http.StatusUnsupportedMediaType, map[string]string{"error": "请求必须使用 JSON"})
|
||||
return
|
||||
}
|
||||
var selection struct {
|
||||
InitialPath string `json:"initialPath"`
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(writer, request.Body, 16384))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&selection); err != nil || len(selection.InitialPath) > 8192 || strings.ContainsAny(selection.InitialPath, "\x00\r\n") {
|
||||
writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "无效的初始目录"})
|
||||
return
|
||||
}
|
||||
if !a.directoryPickerMu.TryLock() {
|
||||
writeJSON(writer, http.StatusConflict, map[string]string{"error": "系统文件夹选择框已打开,请先完成或取消选择"})
|
||||
return
|
||||
}
|
||||
defer a.directoryPickerMu.Unlock()
|
||||
a.mu.Lock()
|
||||
output, picker := a.cfg.OutputDir, a.directoryPicker
|
||||
a.mu.Unlock()
|
||||
if picker == nil {
|
||||
picker = nativeDirectoryPicker
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(request.Context(), 5*time.Minute)
|
||||
defer cancel()
|
||||
path, err := picker(ctx, pickerInitialDirectory(selection.InitialPath, output))
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
writeJSON(writer, http.StatusGatewayTimeout, map[string]string{"error": "文件夹选择超时,请重试或手动输入路径"})
|
||||
} else {
|
||||
writeJSON(writer, http.StatusServiceUnavailable, map[string]string{"error": a.redactError(err)})
|
||||
}
|
||||
return
|
||||
}
|
||||
if path == "" {
|
||||
writeJSON(writer, http.StatusOK, map[string]bool{"canceled": true})
|
||||
return
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || !filepath.IsAbs(path) || strings.ContainsAny(path, "\x00\r\n") || !info.IsDir() {
|
||||
writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "系统未返回有效文件夹,请重新选择"})
|
||||
return
|
||||
}
|
||||
path = filepath.Clean(path)
|
||||
writeJSON(writer, http.StatusOK, map[string]any{"path": path, "selectionPath": directorySelectionPath(path), "canceled": false})
|
||||
}
|
||||
Reference in New Issue
Block a user