2026-07-19 21:10:59 +08:00
|
|
|
// Package launch contains the core-only, fail-closed application startup use
|
|
|
|
|
// case. Platform process, compatibility and process-creation capabilities are
|
|
|
|
|
// supplied by the caller.
|
|
|
|
|
package launch
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"os"
|
|
|
|
|
"regexp"
|
|
|
|
|
|
|
|
|
|
"softbox.local/core/internal/safepath"
|
|
|
|
|
"softbox.local/core/storage"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
var (
|
2026-07-20 09:07:30 +08:00
|
|
|
ErrLaunchConfig = errors.New("invalid launch service configuration")
|
|
|
|
|
ErrLaunchRequest = errors.New("invalid launch request")
|
|
|
|
|
ErrAppNotInstalled = errors.New("app is not installed")
|
|
|
|
|
ErrLaunchMetadata = errors.New("installed launch metadata is invalid")
|
|
|
|
|
ErrLaunchTargetUnsafe = errors.New("installed launch target is unsafe")
|
|
|
|
|
ErrEntrypointMissing = errors.New("installed entrypoint is missing")
|
|
|
|
|
ErrCompatibilityCheck = errors.New("system compatibility check failed")
|
|
|
|
|
ErrAppIncompatible = errors.New("installed app is incompatible with this system")
|
|
|
|
|
ErrAuthorizationCheck = errors.New("launch authorization check failed")
|
|
|
|
|
ErrLaunchUnauthorized = errors.New("launch is not authorized")
|
|
|
|
|
ErrTargetStateCheck = errors.New("launch target state check failed")
|
|
|
|
|
ErrAppRunning = errors.New("installed app is already running")
|
|
|
|
|
ErrProcessStart = errors.New("start installed app")
|
|
|
|
|
launchAppIDPattern = regexp.MustCompile(`^[a-z0-9-]+$`)
|
|
|
|
|
launchProductIDPattern = regexp.MustCompile(`^[a-z0-9-]+$`)
|
2026-07-19 21:10:59 +08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// FailureCode is the stable, non-localized result of a launch attempt.
|
|
|
|
|
type FailureCode string
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
FailureCodeNotInstalled FailureCode = "not_installed"
|
|
|
|
|
FailureCodeLaunchMetadataInvalid FailureCode = "launch_metadata_invalid"
|
|
|
|
|
FailureCodeLaunchTargetUnsafe FailureCode = "launch_target_unsafe"
|
|
|
|
|
FailureCodeEntrypointMissing FailureCode = "entrypoint_missing"
|
|
|
|
|
FailureCodeCompatibilityUnavailable FailureCode = "compatibility_unavailable"
|
|
|
|
|
FailureCodeAppIncompatible FailureCode = "app_incompatible"
|
|
|
|
|
FailureCodeAuthorizationFailed FailureCode = "authorization_unavailable"
|
|
|
|
|
FailureCodeLaunchUnauthorized FailureCode = "not_authorized"
|
|
|
|
|
FailureCodeAppRunning FailureCode = "app_running"
|
|
|
|
|
FailureCodeTargetStateUnavailable FailureCode = "target_state_unavailable"
|
|
|
|
|
FailureCodeLaunchFailed FailureCode = "launch_failed"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Error preserves a stable launch code and the diagnostic cause.
|
|
|
|
|
type Error struct {
|
|
|
|
|
Code FailureCode
|
|
|
|
|
Err error
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (err *Error) Error() string {
|
|
|
|
|
return fmt.Sprintf("launch (%s): %v", err.Code, err.Err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (err *Error) Unwrap() error {
|
|
|
|
|
return err.Err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Request names the installed app to start. The caller cannot supply a path,
|
|
|
|
|
// arguments or working directory.
|
|
|
|
|
type Request struct {
|
|
|
|
|
AppID string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Command contains only paths resolved from verified installed metadata.
|
|
|
|
|
type Command struct {
|
|
|
|
|
Entrypoint string
|
|
|
|
|
WorkingDirectory string
|
|
|
|
|
RequiresAdmin bool
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Result is returned only after the platform accepted the process start.
|
|
|
|
|
type Result struct {
|
|
|
|
|
AppID string
|
|
|
|
|
PID int
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// InstalledAppResolver returns an installed record paired with a validated
|
|
|
|
|
// real current directory.
|
|
|
|
|
type InstalledAppResolver interface {
|
|
|
|
|
ResolveCurrent(appID string) (storage.InstalledApp, string, error)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// CompatibilityChecker reports whether a recorded min_os may run here.
|
|
|
|
|
type CompatibilityChecker interface {
|
|
|
|
|
IsCompatible(minOS string) (bool, error)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-20 09:07:30 +08:00
|
|
|
// AuthorizationChecker decides whether the user may launch one signed
|
|
|
|
|
// package product. It is a required fail-closed boundary.
|
2026-07-19 21:10:59 +08:00
|
|
|
type AuthorizationChecker interface {
|
2026-07-20 09:07:30 +08:00
|
|
|
IsAuthorized(productID string) (bool, error)
|
2026-07-19 21:10:59 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TargetStateChecker reports whether this precise entrypoint is running.
|
|
|
|
|
type TargetStateChecker interface {
|
|
|
|
|
IsRunning(appID string, entrypointPath string) (bool, error)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ProcessLauncher starts one verified command without accepting shell input.
|
|
|
|
|
type ProcessLauncher interface {
|
|
|
|
|
Start(command Command) (int, error)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ServiceConfig makes every external launch dependency explicit.
|
|
|
|
|
type ServiceConfig struct {
|
|
|
|
|
Records InstalledAppResolver
|
|
|
|
|
Compatibility CompatibilityChecker
|
|
|
|
|
Authorization AuthorizationChecker
|
|
|
|
|
TargetState TargetStateChecker
|
|
|
|
|
Launcher ProcessLauncher
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Service executes the safe local launch flow.
|
|
|
|
|
type Service struct {
|
|
|
|
|
records InstalledAppResolver
|
|
|
|
|
compatibility CompatibilityChecker
|
|
|
|
|
authorization AuthorizationChecker
|
|
|
|
|
targetState TargetStateChecker
|
|
|
|
|
launcher ProcessLauncher
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func NewService(config ServiceConfig) (*Service, error) {
|
|
|
|
|
if config.Records == nil {
|
|
|
|
|
return nil, fmt.Errorf("%w: installed app resolver is required", ErrLaunchConfig)
|
|
|
|
|
}
|
|
|
|
|
if config.Compatibility == nil {
|
|
|
|
|
return nil, fmt.Errorf("%w: compatibility checker is required", ErrLaunchConfig)
|
|
|
|
|
}
|
|
|
|
|
if config.Authorization == nil {
|
|
|
|
|
return nil, fmt.Errorf("%w: authorization checker is required", ErrLaunchConfig)
|
|
|
|
|
}
|
|
|
|
|
if config.TargetState == nil {
|
|
|
|
|
return nil, fmt.Errorf("%w: target state checker is required", ErrLaunchConfig)
|
|
|
|
|
}
|
|
|
|
|
if config.Launcher == nil {
|
|
|
|
|
return nil, fmt.Errorf("%w: process launcher is required", ErrLaunchConfig)
|
|
|
|
|
}
|
|
|
|
|
return &Service{
|
|
|
|
|
records: config.Records,
|
|
|
|
|
compatibility: config.Compatibility,
|
|
|
|
|
authorization: config.Authorization,
|
|
|
|
|
targetState: config.TargetState,
|
|
|
|
|
launcher: config.Launcher,
|
|
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Start validates local metadata and filesystem identity before invoking the
|
|
|
|
|
// platform process launcher.
|
|
|
|
|
func (service *Service) Start(request Request) (Result, error) {
|
|
|
|
|
if !launchAppIDPattern.MatchString(request.AppID) {
|
|
|
|
|
return Result{}, launchError(ErrLaunchRequest)
|
|
|
|
|
}
|
|
|
|
|
record, current, err := service.records.ResolveCurrent(request.AppID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
|
|
|
return Result{}, launchError(ErrAppNotInstalled)
|
|
|
|
|
}
|
|
|
|
|
if errors.Is(err, storage.ErrStorageLayoutUnsafe) {
|
|
|
|
|
return Result{}, launchError(fmt.Errorf("%w: %w", ErrLaunchTargetUnsafe, err))
|
|
|
|
|
}
|
|
|
|
|
return Result{}, launchError(fmt.Errorf("resolve installed app: %w", err))
|
|
|
|
|
}
|
|
|
|
|
command, err := launchCommand(record, current)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return Result{}, launchError(err)
|
|
|
|
|
}
|
|
|
|
|
compatible, err := service.compatibility.IsCompatible(record.MinOS)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return Result{}, launchError(fmt.Errorf("%w: %w", ErrCompatibilityCheck, err))
|
|
|
|
|
}
|
|
|
|
|
if !compatible {
|
|
|
|
|
return Result{}, launchError(ErrAppIncompatible)
|
|
|
|
|
}
|
2026-07-20 09:07:30 +08:00
|
|
|
if !launchProductIDPattern.MatchString(record.ProductID) {
|
|
|
|
|
return Result{}, launchError(ErrAuthorizationCheck)
|
|
|
|
|
}
|
|
|
|
|
authorized, err := service.authorization.IsAuthorized(record.ProductID)
|
2026-07-19 21:10:59 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return Result{}, launchError(fmt.Errorf("%w: %w", ErrAuthorizationCheck, err))
|
|
|
|
|
}
|
|
|
|
|
if !authorized {
|
|
|
|
|
return Result{}, launchError(ErrLaunchUnauthorized)
|
|
|
|
|
}
|
|
|
|
|
running, err := service.targetState.IsRunning(record.ID, command.Entrypoint)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return Result{}, launchError(fmt.Errorf("%w: %w", ErrTargetStateCheck, err))
|
|
|
|
|
}
|
|
|
|
|
if running {
|
|
|
|
|
return Result{}, launchError(ErrAppRunning)
|
|
|
|
|
}
|
|
|
|
|
pid, err := service.launcher.Start(command)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return Result{}, launchError(fmt.Errorf("%w: %w", ErrProcessStart, err))
|
|
|
|
|
}
|
|
|
|
|
if pid <= 0 {
|
|
|
|
|
return Result{}, launchError(fmt.Errorf("%w: invalid process ID", ErrProcessStart))
|
|
|
|
|
}
|
|
|
|
|
return Result{AppID: record.ID, PID: pid}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func launchCommand(record storage.InstalledApp, current string) (Command, error) {
|
|
|
|
|
if record.Entrypoint == "" || record.WorkingDirectory == "" || record.MinOS == "" {
|
|
|
|
|
return Command{}, ErrLaunchMetadata
|
|
|
|
|
}
|
|
|
|
|
if err := safepath.ValidateRelative(record.Entrypoint); err != nil {
|
|
|
|
|
return Command{}, fmt.Errorf("%w: entrypoint: %v", ErrLaunchMetadata, err)
|
|
|
|
|
}
|
|
|
|
|
if record.WorkingDirectory != "." {
|
|
|
|
|
if err := safepath.ValidateRelative(record.WorkingDirectory); err != nil {
|
|
|
|
|
return Command{}, fmt.Errorf("%w: working directory: %v", ErrLaunchMetadata, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !validMinOS(record.MinOS) {
|
|
|
|
|
return Command{}, ErrLaunchMetadata
|
|
|
|
|
}
|
|
|
|
|
if !containsEntrypoint(record.Files, record.Entrypoint) {
|
|
|
|
|
return Command{}, fmt.Errorf("%w: entrypoint is not in installed files", ErrLaunchMetadata)
|
|
|
|
|
}
|
|
|
|
|
if err := requireRealDirectory(current); err != nil {
|
|
|
|
|
return Command{}, err
|
|
|
|
|
}
|
|
|
|
|
entrypoint, err := safepath.JoinUnder(current, record.Entrypoint)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return Command{}, fmt.Errorf("%w: entrypoint: %v", ErrLaunchTargetUnsafe, err)
|
|
|
|
|
}
|
|
|
|
|
workingDirectory := current
|
|
|
|
|
if record.WorkingDirectory != "." {
|
|
|
|
|
workingDirectory, err = safepath.JoinUnder(current, record.WorkingDirectory)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return Command{}, fmt.Errorf("%w: working directory: %v", ErrLaunchTargetUnsafe, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if err := requireRealDirectory(workingDirectory); err != nil {
|
|
|
|
|
return Command{}, err
|
|
|
|
|
}
|
|
|
|
|
if err := requireRegularFile(entrypoint); err != nil {
|
|
|
|
|
return Command{}, err
|
|
|
|
|
}
|
|
|
|
|
return Command{
|
|
|
|
|
Entrypoint: entrypoint,
|
|
|
|
|
WorkingDirectory: workingDirectory,
|
|
|
|
|
RequiresAdmin: record.RequiresAdmin,
|
|
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func containsEntrypoint(files []storage.InstalledFile, entrypoint string) bool {
|
|
|
|
|
for _, file := range files {
|
|
|
|
|
if file.Path == entrypoint {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func validMinOS(minOS string) bool {
|
|
|
|
|
return minOS == "windows-7-sp1" || minOS == "windows-10" || minOS == "windows-11"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func requireRealDirectory(path string) error {
|
|
|
|
|
info, err := os.Lstat(path)
|
|
|
|
|
if os.IsNotExist(err) {
|
|
|
|
|
return fmt.Errorf("%w: %s", ErrLaunchTargetUnsafe, path)
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("%w: inspect %s: %w", ErrLaunchTargetUnsafe, path, err)
|
|
|
|
|
}
|
|
|
|
|
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
|
|
|
|
return fmt.Errorf("%w: %s is not a real directory", ErrLaunchTargetUnsafe, path)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func requireRegularFile(path string) error {
|
|
|
|
|
info, err := os.Lstat(path)
|
|
|
|
|
if os.IsNotExist(err) {
|
|
|
|
|
return fmt.Errorf("%w: %s", ErrEntrypointMissing, path)
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("%w: inspect %s: %w", ErrLaunchTargetUnsafe, path, err)
|
|
|
|
|
}
|
|
|
|
|
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
|
|
|
|
return fmt.Errorf("%w: %s is not a regular file", ErrLaunchTargetUnsafe, path)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func launchError(err error) error {
|
|
|
|
|
return &Error{Code: failureCodeFor(err), Err: err}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func failureCodeFor(err error) FailureCode {
|
|
|
|
|
switch {
|
|
|
|
|
case errors.Is(err, ErrAppNotInstalled):
|
|
|
|
|
return FailureCodeNotInstalled
|
|
|
|
|
case errors.Is(err, ErrLaunchMetadata):
|
|
|
|
|
return FailureCodeLaunchMetadataInvalid
|
|
|
|
|
case errors.Is(err, ErrLaunchTargetUnsafe):
|
|
|
|
|
return FailureCodeLaunchTargetUnsafe
|
|
|
|
|
case errors.Is(err, ErrEntrypointMissing):
|
|
|
|
|
return FailureCodeEntrypointMissing
|
|
|
|
|
case errors.Is(err, ErrCompatibilityCheck):
|
|
|
|
|
return FailureCodeCompatibilityUnavailable
|
|
|
|
|
case errors.Is(err, ErrAppIncompatible):
|
|
|
|
|
return FailureCodeAppIncompatible
|
|
|
|
|
case errors.Is(err, ErrAuthorizationCheck):
|
|
|
|
|
return FailureCodeAuthorizationFailed
|
|
|
|
|
case errors.Is(err, ErrLaunchUnauthorized):
|
|
|
|
|
return FailureCodeLaunchUnauthorized
|
|
|
|
|
case errors.Is(err, ErrAppRunning):
|
|
|
|
|
return FailureCodeAppRunning
|
|
|
|
|
case errors.Is(err, ErrTargetStateCheck):
|
|
|
|
|
return FailureCodeTargetStateUnavailable
|
|
|
|
|
default:
|
|
|
|
|
return FailureCodeLaunchFailed
|
|
|
|
|
}
|
|
|
|
|
}
|