Implement controlled app launch (T-401)

This commit is contained in:
ila
2026-07-19 21:10:59 +08:00
parent d0cf333394
commit 87083c387f
32 changed files with 1802 additions and 31 deletions
+26 -2
View File
@@ -195,9 +195,13 @@ func (service *InstallService) Install(request InstallRequest) (InstallResult, e
SHA256: file.SHA256,
})
}
record.Entrypoint = expectation.App.Entrypoint
record.WorkingDirectory = extracted.WorkingDir
record.MinOS = expectation.App.MinOS
record.RequiresAdmin = expectation.App.RequiresAdmin
var recordWriteErr error
switcher := installer.NewSwitcher(func(currentPath string) error {
switcher := installer.NewSwitcherWithPreSwitchCheck(func(currentPath string) error {
if err := service.health(currentPath); err != nil {
return err
}
@@ -206,7 +210,7 @@ func (service *InstallService) Install(request InstallRequest) (InstallResult, e
return fmt.Errorf("%w: %w", ErrInstallRecordWrite, err)
}
return nil
})
}, service.preSwitchCheck(appRoot, record.ID, expectation.App.Entrypoint))
if err := switcher.Switch(appRoot); err != nil {
return InstallResult{}, service.installError(stageForSwitchError(err, recordWriteErr), err)
}
@@ -218,6 +222,26 @@ func (service *InstallService) Install(request InstallRequest) (InstallResult, e
}, nil
}
func (service *InstallService) preSwitchCheck(
appRoot string,
appID string,
entrypoint string,
) installer.PreSwitchCheck {
return func() error {
running, err := service.targetState.IsRunning(
appID,
filepath.Join(appRoot, "current", entrypoint),
)
if err != nil {
return fmt.Errorf("%w: %w", ErrTargetStateCheck, err)
}
if running {
return ErrTargetRunning
}
return nil
}
}
func (service *InstallService) preExtractCheck(
appRoot string,
appID string,
+99
View File
@@ -45,6 +45,10 @@ func TestInstallServiceInstallsVerifiedPackageAndRecordsPayloadFiles(t *testing.
if record.Version != "1.2.3" || len(record.Files) != 2 {
t.Fatalf("record = %#v", record)
}
if record.Entrypoint != "bin/App.exe" || record.WorkingDirectory != "." ||
record.MinOS != "windows-10" || record.RequiresAdmin {
t.Fatalf("launch metadata = %#v", record)
}
if record.Files[0].Path != "bin/App.exe" || record.Files[0].Size != int64(len("new executable")) {
t.Fatalf("record first file = %#v", record.Files[0])
}
@@ -184,6 +188,101 @@ func TestInstallServiceRollsBackHealthAndRecordWriteFailure(t *testing.T) {
}
}
func TestInstallServiceRechecksTargetImmediatelyBeforeUpdateSwitch(t *testing.T) {
appsRoot := filepath.Join(t.TempDir(), "apps")
store := storage.NewInstalledAppStore(appsRoot)
oldArchive, oldPackage := writeInstallPackage(t, "1.0.0", "old executable")
initial := newInstallService(t, store, func(string) error { return nil })
if _, err := initial.Install(InstallRequest{
Entry: installEntry(oldPackage, "1.0.0"),
Architecture: catalog.ArchitectureAMD64,
DownloadPath: oldArchive,
}); err != nil {
t.Fatalf("initial Install() error = %v", err)
}
archivePath, publishedPackage := writeInstallPackage(t, "1.1.0", "new executable")
appRoot := filepath.Join(appsRoot, "test-app")
oldRecord := mustReadFile(t, filepath.Join(appRoot, "installed-app.json"))
targetErr := errors.New("target probe failed")
tests := []struct {
name string
probe func(int) (bool, error)
wantErr error
wantCode FailureCode
}{
{
name: "target starts during extraction",
probe: func(call int) (bool, error) {
return call == 2, nil
},
wantErr: ErrTargetRunning,
wantCode: FailureCodeAppRunning,
},
{
name: "target state fails at switch",
probe: func(call int) (bool, error) {
if call == 2 {
return false, targetErr
}
return false, nil
},
wantErr: targetErr,
wantCode: FailureCodeTargetStateUnavailable,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
calls := 0
service := newInstallServiceWithCheckers(
t,
store,
func(string) error { return nil },
diskSpaceCheckerFunc(func(string) (int64, error) {
return StagingDiskReserveBytes + 64*1024, nil
}),
targetStateCheckerFunc(func(appID, entrypoint string) (bool, error) {
calls++
if appID != "test-app" || entrypoint != filepath.Join(appRoot, "current", "bin", "App.exe") {
t.Fatalf("target check = (%q, %q)", appID, entrypoint)
}
return test.probe(calls)
}),
)
_, err := service.Install(InstallRequest{
Entry: installEntry(publishedPackage, "1.1.0"),
Architecture: catalog.ArchitectureAMD64,
DownloadPath: archivePath,
})
if !errors.Is(err, test.wantErr) {
t.Fatalf("Install() error = %v, want %v", err, test.wantErr)
}
if stage := installErrorStage(t, err); stage != InstallStageSwitch {
t.Fatalf("stage = %q, want %q", stage, InstallStageSwitch)
}
if code := installErrorCode(t, err); code != test.wantCode {
t.Fatalf("code = %q, want %q", code, test.wantCode)
}
if calls != 2 {
t.Fatalf("target check calls = %d, want 2", calls)
}
if got := mustReadFile(t, filepath.Join(appRoot, "current", "bin", "App.exe")); got != "old executable" {
t.Fatalf("current after failed update = %q", got)
}
if got := mustReadFile(t, filepath.Join(appRoot, "installed-app.json")); got != oldRecord {
t.Fatal("installed-app record changed after failed update")
}
for _, path := range []string{"staging", "backup", "install-transaction.json"} {
if _, statErr := os.Stat(filepath.Join(appRoot, path)); !os.IsNotExist(statErr) {
t.Fatalf("failed update left %s, stat error = %v", path, statErr)
}
}
})
}
}
func TestNewInstallServiceRequiresPreflightCheckers(t *testing.T) {
extractor, err := installer.NewExtractor(installTestLimits())
if err != nil {
+319
View File
@@ -0,0 +1,319 @@
// 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 (
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-]+$`)
)
// 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)
}
// AuthorizationChecker decides whether the user may launch one app. It is a
// required boundary; license policy is implemented by the later licensing task.
type AuthorizationChecker interface {
IsAuthorized(appID string) (bool, error)
}
// 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)
}
authorized, err := service.authorization.IsAuthorized(record.ID)
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
}
}
+288
View File
@@ -0,0 +1,288 @@
package launch
import (
"errors"
"os"
"path/filepath"
"testing"
"softbox.local/core/storage"
)
func TestServiceStartsOnlyVerifiedCurrentEntrypoint(t *testing.T) {
store, appRoot := seedInstalledApp(t)
launcher := &recordingLauncher{pid: 42}
service := newService(t, store, launcher)
result, err := service.Start(Request{AppID: "test-app"})
if err != nil {
t.Fatalf("Start() error = %v", err)
}
if result != (Result{AppID: "test-app", PID: 42}) {
t.Fatalf("result = %#v", result)
}
wantEntrypoint := filepath.Join(appRoot, "current", "bin", "App.exe")
if launcher.command.Entrypoint != wantEntrypoint ||
launcher.command.WorkingDirectory != filepath.Join(appRoot, "current", "bin") ||
!launcher.command.RequiresAdmin {
t.Fatalf("command = %#v", launcher.command)
}
}
func TestServiceRejectsUnsafeOrUnavailableLaunchStates(t *testing.T) {
errCompatibility := errors.New("compatibility unavailable")
errAuthorization := errors.New("authorization unavailable")
errTargetState := errors.New("target state unavailable")
errStart := errors.New("start failed")
tests := []struct {
name string
mutate func(*storage.InstalledApp, string)
service func(*Service)
wantErr error
wantCode FailureCode
}{
{
name: "missing legacy metadata",
mutate: func(record *storage.InstalledApp, _ string) {
record.Entrypoint = ""
},
wantErr: ErrLaunchMetadata,
wantCode: FailureCodeLaunchMetadataInvalid,
},
{
name: "entrypoint is absent",
mutate: func(_ *storage.InstalledApp, appRoot string) {
if err := os.Remove(filepath.Join(appRoot, "current", "bin", "App.exe")); err != nil {
t.Fatalf("remove entrypoint: %v", err)
}
},
wantErr: ErrEntrypointMissing,
wantCode: FailureCodeEntrypointMissing,
},
{
name: "unsafe current layout",
service: func(service *Service) {
service.records = resolverFunc(func(string) (storage.InstalledApp, string, error) {
return storage.InstalledApp{}, "", storage.ErrStorageLayoutUnsafe
})
},
wantErr: ErrLaunchTargetUnsafe,
wantCode: FailureCodeLaunchTargetUnsafe,
},
{
name: "incompatible system",
service: func(service *Service) {
service.compatibility = compatibilityFunc(func(string) (bool, error) { return false, nil })
},
wantErr: ErrAppIncompatible,
wantCode: FailureCodeAppIncompatible,
},
{
name: "compatibility failure",
service: func(service *Service) {
service.compatibility = compatibilityFunc(func(string) (bool, error) { return false, errCompatibility })
},
wantErr: errCompatibility,
wantCode: FailureCodeCompatibilityUnavailable,
},
{
name: "unauthorized",
service: func(service *Service) {
service.authorization = authorizationFunc(func(string) (bool, error) { return false, nil })
},
wantErr: ErrLaunchUnauthorized,
wantCode: FailureCodeLaunchUnauthorized,
},
{
name: "authorization failure",
service: func(service *Service) {
service.authorization = authorizationFunc(func(string) (bool, error) { return false, errAuthorization })
},
wantErr: errAuthorization,
wantCode: FailureCodeAuthorizationFailed,
},
{
name: "already running",
service: func(service *Service) {
service.targetState = targetStateFunc(func(string, string) (bool, error) { return true, nil })
},
wantErr: ErrAppRunning,
wantCode: FailureCodeAppRunning,
},
{
name: "target state failure",
service: func(service *Service) {
service.targetState = targetStateFunc(func(string, string) (bool, error) { return false, errTargetState })
},
wantErr: errTargetState,
wantCode: FailureCodeTargetStateUnavailable,
},
{
name: "launcher failure",
service: func(service *Service) {
service.launcher = &recordingLauncher{err: errStart}
},
wantErr: errStart,
wantCode: FailureCodeLaunchFailed,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
store, appRoot := seedInstalledApp(t)
record, found, err := store.Read("test-app")
if err != nil || !found {
t.Fatalf("Read() found=%t err=%v", found, err)
}
if test.mutate != nil {
test.mutate(&record, appRoot)
if err := store.Write(record); err != nil {
t.Fatalf("Write() error = %v", err)
}
}
launcher := &recordingLauncher{pid: 42}
service := newService(t, store, launcher)
if test.service != nil {
test.service(service)
}
_, err = service.Start(Request{AppID: "test-app"})
if !errors.Is(err, test.wantErr) {
t.Fatalf("Start() error = %v, want %v", err, test.wantErr)
}
if code := launchErrorCode(t, err); code != test.wantCode {
t.Fatalf("code = %q, want %q", code, test.wantCode)
}
if launcher.calls != 0 {
t.Fatalf("launcher calls = %d, want 0", launcher.calls)
}
})
}
}
func TestNewServiceRequiresEveryDependency(t *testing.T) {
store, _ := seedInstalledApp(t)
config := ServiceConfig{
Records: store,
Compatibility: compatibilityFunc(func(string) (bool, error) { return true, nil }),
Authorization: authorizationFunc(func(string) (bool, error) { return true, nil }),
TargetState: targetStateFunc(func(string, string) (bool, error) { return false, nil }),
Launcher: &recordingLauncher{pid: 1},
}
for _, test := range []struct {
name string
mutate func(*ServiceConfig)
}{
{"records", func(config *ServiceConfig) { config.Records = nil }},
{"compatibility", func(config *ServiceConfig) { config.Compatibility = nil }},
{"authorization", func(config *ServiceConfig) { config.Authorization = nil }},
{"target state", func(config *ServiceConfig) { config.TargetState = nil }},
{"launcher", func(config *ServiceConfig) { config.Launcher = nil }},
} {
t.Run(test.name, func(t *testing.T) {
candidate := config
test.mutate(&candidate)
if _, err := NewService(candidate); !errors.Is(err, ErrLaunchConfig) {
t.Fatalf("NewService() error = %v, want %v", err, ErrLaunchConfig)
}
})
}
}
func seedInstalledApp(t *testing.T) (*storage.InstalledAppStore, string) {
t.Helper()
appsRoot := filepath.Join(t.TempDir(), "apps")
store := storage.NewInstalledAppStore(appsRoot)
record := storage.InstalledApp{
SchemaVersion: 1,
ID: "test-app",
Version: "1.2.3",
Architecture: "amd64",
Channel: "stable",
Entrypoint: "bin/App.exe",
WorkingDirectory: "bin",
MinOS: "windows-10",
RequiresAdmin: true,
Files: []storage.InstalledFile{{
Path: "bin/App.exe",
Size: 1,
SHA256: "0000000000000000000000000000000000000000000000000000000000000000",
}},
}
if err := store.Write(record); err != nil {
t.Fatalf("Write() error = %v", err)
}
appRoot := filepath.Join(appsRoot, record.ID)
entrypoint := filepath.Join(appRoot, "current", "bin", "App.exe")
if err := os.MkdirAll(filepath.Dir(entrypoint), 0o700); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.WriteFile(entrypoint, []byte("x"), 0o700); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
return store, appRoot
}
func newService(t *testing.T, records InstalledAppResolver, launcher ProcessLauncher) *Service {
t.Helper()
service, err := NewService(ServiceConfig{
Records: records,
Compatibility: compatibilityFunc(func(string) (bool, error) { return true, nil }),
Authorization: authorizationFunc(func(string) (bool, error) { return true, nil }),
TargetState: targetStateFunc(func(string, string) (bool, error) { return false, nil }),
Launcher: launcher,
})
if err != nil {
t.Fatalf("NewService() error = %v", err)
}
return service
}
func launchErrorCode(t *testing.T, err error) FailureCode {
t.Helper()
var launchErr *Error
if !errors.As(err, &launchErr) {
t.Fatalf("error = %v, want launch Error", err)
}
return launchErr.Code
}
type compatibilityFunc func(string) (bool, error)
type resolverFunc func(string) (storage.InstalledApp, string, error)
func (resolver resolverFunc) ResolveCurrent(appID string) (storage.InstalledApp, string, error) {
return resolver(appID)
}
func (checker compatibilityFunc) IsCompatible(minOS string) (bool, error) {
return checker(minOS)
}
type authorizationFunc func(string) (bool, error)
func (checker authorizationFunc) IsAuthorized(appID string) (bool, error) {
return checker(appID)
}
type targetStateFunc func(string, string) (bool, error)
func (checker targetStateFunc) IsRunning(appID, entrypoint string) (bool, error) {
return checker(appID, entrypoint)
}
type recordingLauncher struct {
command Command
pid int
err error
calls int
}
func (launcher *recordingLauncher) Start(command Command) (int, error) {
launcher.calls++
launcher.command = command
if launcher.err != nil {
return 0, launcher.err
}
return launcher.pid, nil
}