diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0944571..9b1a080 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -183,6 +183,65 @@ jobs: vab examples/sokol/particles -o apks/particles.apk [ -f apks/particles.apk ] + ubuntu-latest-extra-command: + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + VFLAGS: -d vab_allow_extra_commands + VAB_FLAGS: -v 3 + steps: + - name: Checkout V + uses: actions/checkout@v4 + with: + repository: vlang/v + + - name: Build local v + run: make -j4 && sudo ./v symlink + + - name: Install dependencies + run: | + v retry -- sudo apt-get update + v retry -- sudo apt-get install --quiet -y libsdl2-dev libsdl2-ttf-dev + v retry -- sudo apt-get install --quiet -y libsdl2-mixer-dev libsdl2-image-dev + + - name: Checkout SDL + uses: actions/checkout@v4 + with: + repository: vlang/sdl + fetch-depth: 0 + path: sdl + + - name: Simulate "v install sdl" + run: mv sdl ~/.vmodules + + - name: Setup vlang/sdl + run: v ~/.vmodules/sdl/setup.vsh + + - name: Checkout vab + uses: actions/checkout@v4 + with: + path: vab + + - name: Install vab + run: | + mv vab ~/.vmodules + v -g ~/.vmodules/vab + sudo ln -s ~/.vmodules/vab/vab /usr/local/bin/vab + + - name: Run 'vab --help' + run: vab --help + + - name: Install extra command + run: | + vab install extra larpon/vab-sdl + + - name: Run vab doctor + run: vab doctor + + - name: Test extra command + run: | + vab sdl doctor + ubuntu-latest-vab-can-live-anywhere: runs-on: ubuntu-latest timeout-minutes: 10 diff --git a/.github/workflows/ci_emulator_run.yml b/.github/workflows/ci_emulator_run.yml index 3c907fd..4722b1b 100644 --- a/.github/workflows/ci_emulator_run.yml +++ b/.github/workflows/ci_emulator_run.yml @@ -51,6 +51,9 @@ jobs: - name: Run vab --help run: vab --help + - name: Run vab doctor *before* + run: vab doctor + - name: Install dependencies run: | v retry -- sudo apt update @@ -66,7 +69,7 @@ jobs: vab install bundletool vab install aapt2 - - name: Run vab doctor + - name: Run vab doctor *after* run: vab doctor - name: Cache emulator diff --git a/README.md b/README.md index f085b44..1931c2b 100644 --- a/README.md +++ b/README.md @@ -207,8 +207,16 @@ in the [FAQ](docs/FAQ.md). # Tests `vab`, like many other V modules, can be tested with `v test .`. + Note that `vab` has *runtime* tests that requires all [runtime dependencies](#runtime-dependencies) to be installed in order for the tests to run correctly. +Runtime tests can be run with `vab test-runtime` (also part of `vab test-all`). + +# Extending `vab` + +The `vab` command-line tool can be extended with custom user commands. +See the "[Extending `vab`](docs/docs.md#extending-vab)" section +in the [documentation](docs/docs.md). # Notes diff --git a/android/emulator/emulator.v b/android/emulator/emulator.v index 020633b..dd4768b 100644 --- a/android/emulator/emulator.v +++ b/android/emulator/emulator.v @@ -81,11 +81,7 @@ pub fn (o &Options) validate() ! { if o.avd == '' { return error('${@MOD}.${@STRUCT}.${@FN}: No Android Virtual Device (avd) sat') } - avdmanager := env.avdmanager() - avdmanager_list_cmd := [avdmanager, 'list', 'avd', '-c'] - util.verbosity_print_cmd(avdmanager_list_cmd, o.verbosity) - avdmanager_list := util.run_or_error(avdmanager_list_cmd)! - avds := avdmanager_list.split('\n') + avds := Emulator.list_avds()! if o.avd !in avds { return error('${@MOD}.${@STRUCT}.${@FN}: Android Virtual Device (avd) "${o.avd}" not found.') } @@ -128,6 +124,55 @@ pub fn (mut e Emulator) start(options Options) ! { } } +// has_avd returns `true` if `avd_name` can be found. Use `list_avds` to see all locations of AVD's +pub fn Emulator.has_avd(avd_name string) bool { + avds := Emulator.list_avds() or { return false } + return avd_name in avds.keys() +} + +// list_avds returns a list of devices detected by running `emulator -list-avds` +// NOTE: for Google reasons, this list can be different from `avdmanager list avd -c`... +pub fn Emulator.list_avds() !map[string]string { + emulator_exe := env.emulator() + list_cmd := [emulator_exe, '-list-avds'] + list_res := util.run_or_error(list_cmd)! + list := list_res.split('\n').filter(it != '').filter(!it.contains(' ')) + mut m := map[string]string{} + for entry in list { + m[entry] = entry // TODO: should be a path to the AVD... + } + return m + // TODO: find out how to fix this dumb mess for users + // if vab_test_avd !in avds { + // Locating a deterministic location of AVD's has, like so many other Android related things, become a mess. + // (`avdmanager` can put them in places that the `emulator` does not pickup on the *same* host etc... Typical Google-mess) + // ... even passing `--path` to `avdmanager` does not work. + // Here we try a few places and set `ANDROID_AVD_HOME` to make runs a bit more predictable. + // mut avd_home := os.join_path(os.home_dir(), '.android', 'avd') + // eprintln('warning: "${vab_test_avd}" still not in list: ${avds}... trying new location "${avd_home}"') + // os.setenv('ANDROID_AVD_HOME', avd_home, true) + // + // avds = emulator.Emulator.list_avds() or { + // eprintln('${exe_name} error: ${err}') + // exit(1) + // } + // if vab_test_avd !in avds { + // config_dir := os.config_dir() or { + // eprintln('${exe_name} error: ${err}') + // exit(1) + // } + // avd_home = os.join_path(config_dir, '.android', 'avd') + // eprintln('warning: "${vab_test_avd}" still not in list: ${avds}... trying new location "${avd_home}"') + // os.setenv('ANDROID_AVD_HOME', avd_home, true) + // + // avds = emulator.Emulator.list_avds() or { + // eprintln('${exe_name} error: ${err}') + // exit(1) + // } + // } + // } +} + // wait_for_boot blocks execution and waits for the emulator to boot. // NOTE: this feature is unique to emulator devices. pub fn (mut e Emulator) wait_for_boot() ! { diff --git a/android/env/env.v b/android/env/env.v index cea2a97..b27258d 100644 --- a/android/env/env.v +++ b/android/env/env.v @@ -6,6 +6,7 @@ import os import semver import net.http import vab.cache +import vab.extra import vab.util as vabutil import vab.android.sdk import vab.android.ndk @@ -198,13 +199,15 @@ pub fn managable() bool { return sdk_is_writable && has_sdkmanager && sdkmanger_works } +@[deprecated: 'use install_components instead'] pub fn install(components string, verbosity int) int { mut iopts := []InstallOptions{} mut ensure_sdk := true + // Allows to specify a string list of things to install components_array := components.split(',') for comp in components_array { - mut component := comp + mut component := comp.trim_space() mut version := '' is_auto := component.contains('auto') @@ -317,6 +320,145 @@ pub fn install(components string, verbosity int) int { return 0 } +// install_components installs various external components that vab can use. +// These components can be Android SDK components or extra commands. +pub fn install_components(arguments []string, verbosity int) ! { + mut iopts := []InstallOptions{} + mut ensure_sdk := true + + if arguments.len == 0 { + return error('${@FN} requires at least one argument') + } + + mut args := arguments.clone() + if args[0] == 'install' { + args = args[1..].clone() // skip `install` part + } + if args.len == 0 { + return error(@FN + ' requires an argument') + } + + components := args[0] + // vab install extra ... + if components == 'extra' { + if args.len == 1 { + return error('${@FN} extra requires an argument') + } + extra.install_command(input: args[1..].clone(), verbosity: verbosity) or { + return error('Installing of command failed: ${err}') + } + return + } + + // vab install "x;y;z,i;j;k" (sdkmanager compatible tuple) + // Allows to specify a string list of things to install + components_array := components.split(',') + for comp in components_array { + mut component := comp.trim_space() + mut version := '' + is_auto := component.contains('auto') + + def_components := get_default_components()! + mut split_component := []string{} + if !is_auto { + version = def_components[component]['version'] // Set default version + if component.contains(';') { // If user has specified a version, use that + split_component = component.split(';') + component = split_component.first() + version = split_component.last() + } + } + + if component !in accepted_components { + return error('${@FN} component "${component}" not recognized. Available components ${accepted_components}.') + } + + if !is_auto { + if version == '' { + if component !in ['platform-tools', 'emulator', 'system-images'] { + return error('${@FN} install component "${component}" has no version.') + } + } + if component == 'system-images' { + if split_component.len != 4 { + return error('${@FN} install component "${component}" should be 4 fields delimited by `;`.') + } + } + } + + item := if version != '' { component + ';' + version } else { component } + + match component { + 'auto' { + cmdline_tools_comp := def_components['cmdline-tools']['name'] + ';' + + def_components['cmdline-tools']['version'] + platform_tools_comp := def_components['platform-tools']['name'] //+ ';' + def_components['platform-tools']['version'] + ndk_comp := def_components['ndk']['name'] + ';' + def_components['ndk']['version'] + build_tools_comp := def_components['build-tools']['name'] + ';' + + def_components['build-tools']['version'] + platforms_comp := def_components['platforms']['name'] + ';' + + def_components['platforms']['version'] + iopts = [ + InstallOptions{.cmdline_tools, cmdline_tools_comp, verbosity}, + InstallOptions{.platform_tools, platform_tools_comp, verbosity}, + InstallOptions{.ndk, ndk_comp, verbosity}, + InstallOptions{.build_tools, build_tools_comp, verbosity}, + InstallOptions{.platforms, platforms_comp, verbosity}, + ] + break + } + 'cmdline-tools' { + iopts << InstallOptions{.cmdline_tools, item, verbosity} + } + 'platform-tools' { + iopts << InstallOptions{.platform_tools, item, verbosity} + } + 'emulator' { + iopts << InstallOptions{.emulator, item, verbosity} + } + 'system-images' { + iopts << InstallOptions{.system_images, comp, verbosity} + } + 'ndk' { + iopts << InstallOptions{.ndk, item, verbosity} + } + 'build-tools' { + iopts << InstallOptions{.build_tools, item, verbosity} + } + 'platforms' { + iopts << InstallOptions{.platforms, item, verbosity} + } + 'bundletool' { + ensure_sdk = false + iopts << InstallOptions{.bundletool, item, verbosity} + } + 'aapt2' { + ensure_sdk = false + iopts << InstallOptions{.aapt2, item, verbosity} + } + else { + return error('${@FN} unknown component "${component}"') + } + } + } + + if ensure_sdk { + ensure_sdkmanager(verbosity)! + } + + for iopt in iopts { + install_opt(iopt)! + } + + if verbosity > 0 { + if components != 'auto' { + println('Installed ${components} successfully') + } else { + println('Installed all dependencies successfully') + } + } +} + fn install_opt(opt InstallOptions) !bool { loose := opt.dep == .bundletool || opt.dep == .aapt2 @@ -843,24 +985,80 @@ pub fn avdmanager() string { return avdmanager_exe } -// has_emulator returns `true` if `emulator` can be located on the system. -pub fn has_emulator() bool { - return emulator() != '' -} - // emulator returns the full path to the `emulator` tool, if found. An empty string otherwise. pub fn emulator() string { - mut emulator_path := os.getenv('EMULATOR') - if !os.exists(emulator_path) { - emulator_path = os.join_path(sdk.root(), 'emulator', 'emulator${dot_exe}') + mut emulator_exe := cache.get_string(@MOD + '.' + @FN) + if emulator_exe != '' { + return emulator_exe + } + + emulator_exe = os.getenv('EMULATOR') + // Check in cache + if !os.is_executable(emulator_exe) { + emulator_exe = os.join_path(util.cache_dir(), 'emulator${dot_exe}') + if !os.is_executable(emulator_exe) { + emulator_exe = os.join_path(sdk.cache_dir(), 'cmdline-tools', '3.0', 'bin', + 'emulator${dot_exe}') + } + if !os.is_executable(emulator_exe) { + emulator_exe = os.join_path(sdk.cache_dir(), 'cmdline-tools', '2.1', 'bin', + 'emulator${dot_exe}') + } + if !os.is_executable(emulator_exe) { + emulator_exe = os.join_path(sdk.cache_dir(), 'cmdline-tools', 'tools', 'bin', + 'emulator${dot_exe}') + } } - if !os.exists(emulator_path) { - emulator_path = '' + // Try if one is in PATH + if !os.is_executable(emulator_exe) { if os.exists_in_system_path('emulator') { - emulator_path = os.find_abs_path_of_executable('emulator') or { '' } + emulator_exe = os.find_abs_path_of_executable('emulator${dot_exe}') or { '' } } } - return emulator_path + // Try detecting it in the SDK + if sdk.found() { + if !os.is_executable(emulator_exe) { + emulator_exe = os.join_path(sdk.root(), 'cmdline-tools', 'tools', 'bin', 'emulator${dot_exe}') + } + if !os.is_executable(emulator_exe) { + emulator_exe = os.join_path(sdk.tools_root(), 'bin', 'emulator${dot_exe}') + } + // It's often found next to `sdkmanager` + if !os.is_executable(emulator_exe) { + for relative_path in possible_relative_to_sdk_sdkmanager_paths { + emulator_exe = os.join_path(sdk.root(), relative_path, 'emulator${dot_exe}') + if os.is_executable(emulator_exe) { + break + } + } + } + if !os.is_executable(emulator_exe) { + version_dirs := util.ls_sorted(os.join_path(sdk.root(), 'cmdline-tools')).filter(fn (a string) bool { + return util.is_version(a) + }) + for version_dir in version_dirs { + emulator_exe = os.join_path(sdk.root(), 'cmdline-tools', version_dir, + 'bin', 'emulator${dot_exe}') + if os.is_executable(emulator_exe) { + break + } + } + } + if !os.exists(emulator_exe) { + emulator_exe = os.join_path(sdk.root(), 'emulator', 'emulator${dot_exe}') + } + } + // Give up + if !os.is_executable(emulator_exe) { + emulator_exe = '' + } + cache.set_string(@MOD + '.' + @FN, emulator_exe) + return emulator_exe +} + +// has_emulator returns `true` if `emulator` can be located on the system. +pub fn has_emulator() bool { + return emulator() != '' } // has_bundletool returns `true` if `bundletool` can be located on the system. diff --git a/cli/cli.v b/cli/cli.v index 157bd90..ea3d81f 100644 --- a/cli/cli.v +++ b/cli/cli.v @@ -3,8 +3,10 @@ module cli import os import flag import vab.vxt +import vab.vabxt import vab.java import vab.paths +import vab.extra import vab.android import vab.android.sdk import vab.android.ndk @@ -71,14 +73,16 @@ pub const vab_documentation_config = flag.DocConfig{ } } -// run_vab_sub_command runs (compiles if needed) a sub-command if found in `args`. -// If the command is found this function will call `exit()` with the result -// returned by the executed command. +// run_vab_sub_command runs a sub-command if found in `args`. +// If the command is found this function will call `exit()` with the +// exit code returned by the executed command. pub fn run_vab_sub_command(args []string) { - // Indentify sub-commands. + // Execute extra installed commands first if any match is found + extra.run_command(args) + // Run builtin sub-commands, if found for subcmd in subcmds { if subcmd in args { - // First encountered known sub-command is executed on the spot. + // First encountered known sub-command is executed on the spot exit(launch_cmd(args[args.index(subcmd)..])) } } @@ -351,7 +355,9 @@ pub fn launch_cmd(args []string) int { } } if os.is_executable(tool_exe) { - os.setenv('VAB_EXE', os.join_path(exe_dir, exe_name), true) + if vabxt.found() { + os.setenv('VAB_EXE', vabxt.vabexe(), true) + } $if windows { exit(os.system('${os.quoted_path(tool_exe)} ${tool_args}')) } $else $if js { diff --git a/cli/doctor.v b/cli/doctor.v index af2deef..7506111 100644 --- a/cli/doctor.v +++ b/cli/doctor.v @@ -4,6 +4,7 @@ import os import vab.vxt import vab.java import vab.util +import vab.extra import vab.android import vab.android.sdk import vab.android.ndk @@ -19,13 +20,13 @@ pub fn doctor(opt Options) { // Validate Android `sdkmanager` tool // Just warnings/notices as `sdkmanager` isn't used to in the build process. if sdkm == '' { - extra_details := if env_managable { + details_text := if env_managable { 'You can run `${exe_short_name} install cmdline-tools` to install it.\n' } else { '' } details := util.Details{ - details: extra_details + + details: details_text + 'You can set the `SDKMANAGER` env variable or try your luck with `${exe_short_name} install auto`. See https://stackoverflow.com/a/61176718/1904615 for more help.\n' } @@ -76,7 +77,20 @@ See https://stackoverflow.com/a/61176718/1904615 for more help.\n' Version ${exe_version} ${exe_git_hash} Path "${exe_dir}" Base files "${default_base_files_path}" - os.args: "${os.args}"') + os.args: "${os.args}"\n') + + println('Extra\n\tCommands') + $if vab_allow_extra_commands ? { + extra_commands := extra.commands() + println('\t\tAllowed: true + Installed ${extra.installed()} + Data path "${extra.data_path}"') + for _, extra_command in extra_commands { + println('\t\t${extra_command.alias} ${extra_command.source}:${extra_command.unit} ${extra_command.hash}') + } + } $else { + println('\t\tAllowed: false') + } // Shell environment print_var_if_set := fn (vars map[string]string, var_name string) { diff --git a/cli/options.v b/cli/options.v index 9b80a3a..d053c9d 100644 --- a/cli/options.v +++ b/cli/options.v @@ -277,8 +277,8 @@ pub fn options_from_arguments(arguments []string, defaults Options) !(Options, [ for i := 0; i < args.len; i++ { arg := args[i] - if i <= 1 && arg in subcmds_builtin { - // rip built in sub-commands at the start of the args array + if arg in subcmds_builtin { + // rip built in sub-commands from the args array run_builtin_cmd = arg args.delete(i) i-- diff --git a/cli/utils.v b/cli/utils.v index 829fa5a..9600d77 100644 --- a/cli/utils.v +++ b/cli/utils.v @@ -1,6 +1,8 @@ module cli import os +import strings +import vab.extra // kill_adb will try to kill the `adb` process. pub fn kill_adb() { @@ -44,3 +46,17 @@ fn version() string { } return v } + +// input_suggestions returns alternative suggestions to the `input` string. +pub fn input_suggestions(input string) []string { + mut suggests := []string{} + $if vab_allow_extra_commands ? { + for extra_alias in extra.installed_aliases() { + similarity := f32(int(strings.levenshtein_distance_percentage(input, extra_alias) * 1000)) / 1000 + if similarity > 0.25 { + suggests << extra_alias + } + } + } + return suggests +} diff --git a/cmd/all.v b/cmd/all.v index 77dc578..db3c511 100644 --- a/cmd/all.v +++ b/cmd/all.v @@ -32,26 +32,33 @@ fn v_test_all() { { res := run([v_exe, 'test', vab_home]) if res.exit_code != 0 { + eprintln(res.output) + errors << res.output + } + } + { + res := run([v_exe, 'check-md', '-hide-warnings', vab_home]) + if res.exit_code != 0 { + eprintln(res.output) errors << res.output } } { res := run([vab_exe, 'test-cleancode', vab_home]) if res.exit_code != 0 { + eprintln(res.output) errors << res.output } } { res := run([vab_exe, 'test-runtime']) if res.exit_code != 0 { + eprintln(res.output) errors << res.output } } if errors.len > 0 { eprintln('ERROR: some test(s) failed.') - for e in errors { - eprintln(e) - } exit(1) } } diff --git a/docs/docs.md b/docs/docs.md index 19f8db7..ce50b2a 100644 --- a/docs/docs.md +++ b/docs/docs.md @@ -17,6 +17,7 @@ Welcome - and have a productive time using V and `vab`! - [Compile C code to Android shared library (.so)](#compile-c-code-to-android-shared-library-so) - [Find and Invoke NDK Compiler Manually](#find-and-invoke-ndk-compiler-manually) - [Debugging](#debugging) +- [Extending `vab`](#extending-vab) # Introduction @@ -358,3 +359,58 @@ Ctrl+C to cancel logging ``` Use Ctrl + C in the terminal to stop the output and disconnect from the device, leaving the app running on the device. + +# Extending `vab` + +Sometimes `vab`'s functionality is just not enough to reach a desired goal. An example of +such a thing would be compiling and packaging of a thirdparty library or framework that requires a +special way to be build, has a custom main entry function and/or a custom Java Android activity, +or other factors that makes it impossible or very cumbersome to get things working with +`v` and/or `vab`'s default functionality. + +One way to deal with such problems, without reinventing the wheel, is using `vab` as a module +in combination with the feature that allows users to install and call thirdparty executables. + +In `vab` terms this feature is called *extra commands* and can be enabled by passing +`-d vab_allow_extra_commands` when compiling `vab` with `v`. + +*Extra commands* is a powerful feature that allows users to extend `vab` with custom functionality +*via the command-line*. + +## Example extra command + +An example of one such *extra command* is [`larpon/vab-sdl`](https://github.com/larpon/vab-sdl/) which makes it easier +to compile and run V applications that uses SDL2 via ['vlang/sdl'](https://github.com/vlang/sdl/) +on Android via `vab`. + +To enable support for this in `vab`, you can do the following: + +1. Enable *extra command* support when building `vab`: + ```bash + v -d vab_allow_extra_commands ~/.vmodules/vab + ``` +2. Install the [`larpon/vab-sdl`](https://github.com/larpon/vab-sdl/) *extra command*: + ```bash + vab install extra larpon/vab-sdl + ``` +3. Build your application that uses ['vlang/sdl'](https://github.com/vlang/sdl/), for example: + ```bash + vab sdl ~/.vmodules/sdl/examples/basic_window -o /tmp/sdl_app.apk + ``` +Notice how the *extra command* name `vab-sdl` is called as `vab sdl`. +You should now be able to install `/tmp/sdl_app.apk` on your device and run the example +without the need to do anything special. + +**NOTE** Use `vab doctor` to see more detailed information about *extra commands* +including where they are installed and more. + +## Important notes about *extra commands* + +When you enable and use *extra commands* you are advised to be careful about installing and running +thirdparty *extra command* software from sources you do not trust. + +When you enable and use *extra commands* it is likely that the developer team can not +provide support for any bug or situation that an *extra command* may have caused. + +If possible, always refer to the author, source code and documentation of any *extra commands* +for how to use the commands correctly. diff --git a/extra/extra.v b/extra/extra.v new file mode 100644 index 0000000..97de9a0 --- /dev/null +++ b/extra/extra.v @@ -0,0 +1,397 @@ +// Copyright(C) 2019-2024 Lars Pontoppidan. All rights reserved. +// Use of this source code is governed by an MIT license file distributed with this software package +// This module handles everything related to extra commands. +module extra + +import os +import strings +import compress.szip +import vab.paths +import vab.util +import vab.vxt +import net.http + +const valid_sources = ['github'] +pub const command_prefix = 'vab' +pub const data_path = os.join_path(paths.data(), 'extra') +pub const temp_path = os.join_path(paths.tmp_work(), 'extra') + +@[params] +pub struct InstallOptions { +pub: + input []string + verbosity int +} + +pub struct Command { +pub: + id string + alias string + source string + unit string + hash string + exe string +} + +struct GitHubInfo { +pub: + sha string + url string +} + +// verbose prints `msg` to STDOUT if `InstallOptions.verbosity` level is >= `verbosity_level`. +pub fn (io &InstallOptions) verbose(verbosity_level int, msg string) { + if io.verbosity >= verbosity_level { + println(msg) + } +} + +// run_command runs a extra installed command if found in `args`. +// If the command is found this function will call `exit()` with the result +// returned by the executed command. +pub fn run_command(args []string) { + // Indentify extra installed commands + extra_commands := commands() + for _, extra_command in extra_commands { + short_id := extra_command.id.trim_left('${command_prefix}-') + if short_id in args { + mut complete_index := args.len + if 'complete' in args { + complete_index = args.index('complete') + } + short_id_index := args.index(short_id) + if complete_index < short_id_index { + // if `complete` is found before the extra command vab is + // highly likely trying to tab complete something in which case + // nothing nothing should be executed + return + } + // First encountered known sub-command is executed on the spot. + exit(launch_command(args[short_id_index..])) + } + } +} + +fn launch_command(args []string) int { + $if !vab_allow_extra_commands ? { + util.vab_error('To enable running extra commands, pass `-d vab_allow_extra_commands` when building vab') + exit(2) + } + mut cmd := args[0] + extra_commands := commands() + if command := extra_commands['${command_prefix}-' + cmd] { + tool_args := args[1..].clone() + tool_exe := command.exe + if os.is_executable(tool_exe) { + // os.setenv('VAB_EXE', os.join_path(exe_dir, exe_name), true) + $if windows { + exit(os.system('${os.quoted_path(tool_exe)} ${tool_args}')) + } $else $if js { + // no way to implement os.execvp in JS backend + exit(os.system('${tool_exe} ${tool_args}')) + } $else { + os.execvp(tool_exe, tool_args) or { panic(err) } + } + exit(2) + } + exec := (tool_exe + ' ' + tool_args.join(' ')).trim_right(' ') + eprintln(@MOD + '.' + @FN + ' failed executing "${exec}"') + return 1 + } + + eprintln(@MOD + '.' + @FN + ' failed to identify "${args}"') + return 1 +} + +// install_command retrieves, installs and registers external extra commands +pub fn install_command(opt InstallOptions) ! { + // `vab install cmd xyz/abc` + if opt.input.len == 0 { + return error('${@FN} requires input') + } + + component := opt.input[0] // Only 1 argument needed for now + if component.count(':') == 0 { + // no source protocol detected, slap on default and try again... + mod_opt := InstallOptions{ + ...opt + input: ['github:${component}'] + } + return install_command(mod_opt) + } + + $if !vab_allow_extra_commands ? { + util.vab_notice('To enable running extra commands, pass `-d vab_allow_extra_commands` when building vab') + } + + source := component.all_before(':') + if source !in valid_sources { + return error('${@FN} unknown source `${source}`. Valid sources are ${valid_sources}') + } + unit := component.all_after(':') + + match source { + 'github' { + return install_from_github(unit, opt.verbosity) + } + else { + return error('${@FN} unknown source `${source}`. Valid sources are ${valid_sources}') + } + } +} + +fn install_from_github(unit string, verbosity int) ! { + if unit.count('/') != 1 { + return error('${@MOD} ${@FN} `${unit}` should contain exactly one "/" character') + } + unit_parts := unit.split('/') + + // TODO: support @ notation for specific commits/branches? + // mut at_part := unit.all_after('@') + + if !(valid_identifier(unit_parts[0]) && valid_identifier(unit_parts[1])) { + return error('${@MOD} ${@FN} `${unit}` is not a valid identifier') + } + + cmd_author := unit_parts[0] + cmd_name := unit_parts[1] + if has_command(cmd_name) { + extra_commands := commands() + if command := extra_commands[cmd_name] { + if command.unit != unit { + return error('${@MOD} ${@FN} `${unit}` is already installed from `${command.unit}` via ${command.source}') + } + } + } + + initial_dst := os.join_path(data_path, 'commands', 'github', cmd_author) + + tmp_downloads := os.join_path(temp_path, 'downloads') + paths.ensure(tmp_downloads)! + + github_info := get_github_info(unit)! + + sha := github_info.sha + url := github_info.url + + zip_file := os.join_path(tmp_downloads, 'github-${unit.replace('/', '-')}.${sha}.zip') + if !os.exists(zip_file) { + if verbosity > 1 { + println('Downloading `${unit}` from "${url}"...') + } + http.download_file(url, zip_file) or { + return error('${@MOD} ${@FN} failed to download `${unit}`: ${err}') + } + } + final_dst := os.join_path(initial_dst, unit_parts[1]) + // Install + if verbosity > 1 { + println('Installing `${unit}` to "${final_dst}"...') + } + paths.ensure(initial_dst)! + + unzip(zip_file, initial_dst)! + unzipped_dst := os.join_path(initial_dst, '${cmd_name}-${sha}') + if os.exists(final_dst) { + os.rmdir_all(final_dst) or {} + } + os.mv(unzipped_dst, final_dst)! + + build_command(final_dst, verbosity)! + record_install(cmd_name, 'github', unit, sha)! +} + +fn record_install(id string, source string, unit string, hash string) ! { + path := data_path + paths.ensure(path)! + installs_db := os.join_path(path, 'installed.txt') + installs_db_bak := os.join_path(path, 'installed.txt.bak') + if !os.exists(installs_db) { + os.create(installs_db)! + } + mut installs := os.read_lines(installs_db)! + + for i, install_line in installs { + if install_line == '' || install_line.starts_with('#') { + continue + } + split := install_line.split(';') + if split.len > 2 { + if split[1] == source && split[2] == unit { + installs.delete(i) + break + } + } + } + installs << '${id};${source};${unit};${hash}' + os.mv(installs_db, installs_db_bak, overwrite: true)! + os.write_lines(installs_db, installs)! +} + +fn get_github_info(unit string) !GitHubInfo { + tmp_downloads := os.join_path(temp_path, 'downloads') + paths.ensure(tmp_downloads)! + + base_url := 'https://api.github.com/repos/${unit}' + meta_file := os.join_path(tmp_downloads, 'github-${unit.replace('/', '-')}.meta') + http.download_file(base_url, meta_file) or { + return error('${@MOD} ${@FN} failed to download `${base_url}`: ${err}') + } + + default_branch := os.read_file(meta_file)!.all_after('default_branch').trim_left('" ,:').all_before('"') + + refs_url := '${base_url}/git/refs/heads' + refs_file := os.join_path(tmp_downloads, 'github-${unit.replace('/', '-')}.refs') + http.download_file(refs_url, refs_file) or { + return error('${@MOD} ${@FN} failed to download `${refs_url}`: ${err}') + } + + mut raw := strings.find_between_pair_u8(os.read_file(refs_file)!, `[`, `]`) + mut found := false + mut chunk := '' + ref := 'refs/heads/${default_branch}' + for _ in 0 .. 20 { + chunk = strings.find_between_pair_u8(raw, `{`, `}`) + if chunk.contains(ref) { + found = true + break + } + raw = raw.replace('{${chunk}}', '') + } + if !found { + return error('${@MOD} ${@FN} failed to get git information via `${refs_url}`') + } + + sha := chunk.all_after('sha').trim_left('" ,:').all_before('"') + url := 'https://github.com/${unit}/archive/${sha}.zip' + return GitHubInfo{ + sha: sha + url: url + } +} + +// installed returns an array of the extra commands installed via +// `vab install extra ...` +// See also: installed_aliases +pub fn installed() []string { + cmds := commands() + return cmds.keys() +} + +// installed_aliases returns an array of the extra commands' aliases installed via +// `vab install extra ...` +// See also: installed +pub fn installed_aliases() []string { + mut aliases := []string{} + for id, _ in commands() { + aliases << id.trim_left('${command_prefix}-') + } + return aliases +} + +// has_command returns `true` if `command` is installed as an extra command +pub fn has_command(command string) bool { + cmds := commands() + return command in cmds.keys() +} + +// has_command_alias returns `true` if `alias` is installed as an extra command +pub fn has_command_alias(alias string) bool { + cmds := commands() + for _, extra_command in cmds { + if extra_command.id.trim_left('${command_prefix}-') == alias { + return true + } + } + return false +} + +// commands returns all extra commands installed via +// `vab install extra ...` +// See also: installed +pub fn commands() map[string]Command { + mut installed := map[string]Command{} + path := data_path + installs_db := os.join_path(path, 'installed.txt') + if os.exists(installs_db) { + installs := os.read_lines(installs_db) or { return installed } + for install_line in installs { + if install_line == '' || install_line.starts_with('#') { + continue + } + split := install_line.split(';') + if split.len > 3 { + id := split[0] + alias := id.trim_left('${command_prefix}-') + source := split[1] or { 'unknown' } + unit := split[2] or { 'unknown/unknown' } + hash := split[3] or { 'deadbeef' } + unit_parts := unit.split('/') + final_dst := os.join_path(data_path, 'commands', source, unit_parts[0], + unit_parts[1]) + + installed[id] = Command{ + id: id + alias: alias + source: source + unit: unit + hash: hash + exe: os.join_path(final_dst, id) + } + } + } + } + return installed +} + +fn unzip(file string, dir string) ! { + if !os.is_dir(dir) { + os.mkdir_all(dir)! + } + szip.extract_zip_to_dir(file, dir)! +} + +fn valid_identifier(s string) bool { + if s.len == 0 { + return false + } + for ch in s { + if !(ch.is_letter() || ch.is_digit() || ch == `_` || ch == `-`) { + return false + } + } + return true +} + +fn build_command(path string, verbosity int) ! { + if !vxt.found() { + return error('${@MOD} ${@FN} failed to locate a V compiler') + } + v_exe := vxt.vexe() + v_cmd := [ + v_exe, + path, + ] + verbosity_print_cmd(v_cmd, verbosity) + res := run(v_cmd) + if res.exit_code != 0 { + return error('${@MOD} ${@FN} "${v_cmd.join(' ')}" failed:\n${res.output}') + } +} + +// verbosity_print_cmd prints information about the `args` at certain `verbosity` levels. +fn verbosity_print_cmd(args []string, verbosity int) { + if args.len > 0 && verbosity > 1 { + cmd_short := args[0].all_after_last(os.path_separator) + mut output := 'Running ${cmd_short} From: ${os.getwd()}' + if verbosity > 2 { + output += '\n' + args.join(' ') + } + println(output) + } +} + +fn run(args []string) os.Result { + res := os.execute(args.join(' ')) + return res +} diff --git a/paths/paths.v b/paths/paths.v index 7391930..2487767 100644 --- a/paths/paths.v +++ b/paths/paths.v @@ -18,6 +18,12 @@ pub fn ensure(path string) ! { } } +// data returns a `string` with the path to `vab`'s' data directory. +// NOTE: the returned path may not exist on disk. Use `ensure/1` to ensure it exists. +pub fn data() string { + return os.join_path(os.data_dir(), vab_namespace) +} + // config returns a `string` with the path to `vab`'s' configuration directory. // NOTE: the returned path may not exist on disk. Use `ensure/1` to ensure it exists. pub fn config() string { @@ -36,6 +42,12 @@ pub fn cache() string { return os.join_path(os.cache_dir(), vab_namespace) } +// exe_data returns a `string` with the path to the executable's data directory. +// NOTE: the returned path may not exist on disk. Use `ensure/1` to ensure it exists. +pub fn exe_data() string { + return os.join_path(os.data_dir(), sanitized_exe_name) +} + // exe_config returns a `string` with the path to the executable's configuration directory. // NOTE: the returned path may not exist on disk. Use `ensure/1` to ensure it exists. pub fn exe_config() string { diff --git a/tests/at-runtime/emulator/emulator_test.vv b/tests/at-runtime/emulator/emulator_test.vv index ab51079..c2b36c1 100644 --- a/tests/at-runtime/emulator/emulator_test.vv +++ b/tests/at-runtime/emulator/emulator_test.vv @@ -72,6 +72,10 @@ fn ensure_env() { vab_home := vabxt.home() assert vab_home != '' + // vab (per design) implicitly deploys to any devices sat via `--device-id`. + // Make sure no deployment is done after build if CI/other sets `ANDROID_SERIAL` + os.unsetenv('ANDROID_SERIAL') + if !env.has_emulator() { assert env_is_managable == true, 'These tests requires a *writable* SDK' eprintln('No emulator detected. Installing...') @@ -85,27 +89,50 @@ fn ensure_env() { // TODO: add env.has_system_image('android-XY','type','host-arch') - avdmanager := env.avdmanager() - avdmanager_list_res := run([avdmanager, 'list', 'avd', '-c']) - if avdmanager_list_res.exit_code != 0 { - eprintln('${exe_name} error running cmd') - eprintln(avdmanager_list_res.output) - exit(1) - } - avds := avdmanager_list_res.output.split('\n') - if vab_test_avd !in avds { + if !emulator.Emulator.has_avd(vab_test_avd) { + avdmanager := env.avdmanager() eprintln('${exe_name} ${vab_test_avd} not found. Creating...') - avdmanager_create_res := run(['echo', 'no', '|', avdmanager, 'create', 'avd', '--force', - '--name', vab_test_avd, '--abi', 'aosp_atd/x86_64', '--package', + avdmanager_create_res := run(['echo', 'no', '|', avdmanager, '--verbose', 'create', 'avd', + '--force', '--name', vab_test_avd, '--abi', 'aosp_atd/x86_64', '--package', "'system-images;android-30;aosp_atd;x86_64'"]) if avdmanager_create_res.exit_code != 0 { eprintln(avdmanager_create_res.output) exit(1) } } - // vab (per design) implicitly deploys to any devices sat via `--device-id`. - // Make sure no deployment is done after build if CI/other sets `ANDROID_SERIAL` - os.unsetenv('ANDROID_SERIAL') + + // TODO: find out how to fix this dumb mess for users + if !emulator.Emulator.has_avd(vab_test_avd) { + // Locating a deterministic location of AVD's has, like so many other Android related things, become a mess. + // (`avdmanager` can put them in places that the `emulator` does not pickup on the *same* host etc... Typical Google-mess) + // ... even passing `--path` to `avdmanager` does not work. + // Here we try a few places and set `ANDROID_AVD_HOME` to make runs a bit more predictable. + mut avd_home := os.join_path(os.home_dir(), '.android', 'avd') + eprintln('warning: "${vab_test_avd}" still not detected by emulator... trying new location "${avd_home}"') + os.setenv('ANDROID_AVD_HOME', avd_home, true) + + if !emulator.Emulator.has_avd(vab_test_avd) { + config_dir := os.config_dir() or { + eprintln('${exe_name} error: ${err}') + exit(1) + } + avd_home = os.join_path(config_dir, '.android', 'avd') + eprintln('warning: "${vab_test_avd}" still not detected by emulator... trying new location "${avd_home}"') + os.setenv('ANDROID_AVD_HOME', avd_home, true) + } + } + eprintln('Listing avds after creation...') + avds := emulator.Emulator.list_avds() or { + eprintln('${exe_name} error: ${err}') + exit(1) + } + for avd, path in avds { + eprintln('${avd}: ${path}') + } + if !emulator.Emulator.has_avd(vab_test_avd) { + eprintln('error: "${vab_test_avd}" still not in list: ${avds.keys()}') + exit(1) + } } fn setup_test_dir(id string) string { diff --git a/tests/check_invalid_input.toml b/tests/check_invalid_input.toml index 9b87a8b..752ae07 100644 --- a/tests/check_invalid_input.toml +++ b/tests/check_invalid_input.toml @@ -1,5 +1,5 @@ execute = 'vab invalid_input' [expect] exit_code = 1 -[compare] - output.from_line = -1 # Test only x lines. Negative value means from bottom, positive from top, 0 is default (all) +# [compare] +# output.from_line = -1 # Test only x lines. Negative value means from bottom, positive from top, 0 is default (all) diff --git a/tests/check_non_existing_flag.out b/tests/check_non_existing_flag.out index 782167c..f9e515d 100644 --- a/tests/check_non_existing_flag.out +++ b/tests/check_non_existing_flag.out @@ -1,2 +1 @@ -error: Could not parse arguments. No matches for ['--non-existing-flag'] -Use `vab -h` to see all flags +error: Could not parse arguments diff --git a/util/shelljob.v b/util/shelljob.v index 120f4cf..8eaf3e7 100644 --- a/util/shelljob.v +++ b/util/shelljob.v @@ -76,6 +76,7 @@ pub fn run_jobs(jobs []ShellJob, parallel bool, verbosity int) ! { } } +// verbosity_print_cmd prints information about the `args` at certain `verbosity` levels. fn verbosity_print_cmd(args []string, verbosity int) { if args.len > 0 && verbosity > 1 { cmd_short := args[0].all_after_last(os.path_separator) diff --git a/vab.v b/vab.v index 2b294b5..1fb7140 100644 --- a/vab.v +++ b/vab.v @@ -26,23 +26,26 @@ fn main() { mut opt := cli.Options{} opt = cli.options_from_dot_vab(input, opt) or { - util.vab_error('Could not parse `.vab`: ${err}') + util.vab_error('Could not parse `.vab`', details: '${err}') exit(1) } opt = cli.options_from_env(opt) or { - util.vab_error('Could not parse `VAB_FLAGS`: ${err}\nUse `${cli.exe_short_name} -h` to see all flags') + util.vab_error('Could not parse `VAB_FLAGS`', details: '${err}') + util.vab_notice('Use `${cli.exe_short_name} -h` to see all flags') exit(1) } mut unmatched_args := []string{} opt, unmatched_args = cli.options_from_arguments(args, opt) or { - util.vab_error('Could not parse `os.args`: ${err}\nUse `${cli.exe_short_name} -h` to see all flags') + util.vab_error('Could not parse `os.args`', details: '${err}') + util.vab_notice('Use `${cli.exe_short_name} -h` to see all flags') exit(1) } if unmatched_args.len > 0 { - util.vab_error('Could not parse arguments. No matches for ${unmatched_args}\nUse `${cli.exe_short_name} -h` to see all flags') + util.vab_error('Could not parse arguments', details: 'No matches for ${unmatched_args}') + util.vab_notice('Use `${cli.exe_short_name} -h` to see all flags') exit(1) } @@ -54,7 +57,9 @@ fn main() { if opt.dump_usage { documentation := flag.to_doc[cli.Options](cli.vab_documentation_config) or { - util.vab_error('Could not generate usage documentation via `flag.to_doc[cli.Options](...)` this should not happen.\nError message: ${err}') + util.vab_error('Could not generate usage documentation via `flag.to_doc[cli.Options](...)` this should not happen', + details: '${err}' + ) exit(1) } println(documentation) @@ -63,7 +68,9 @@ fn main() { if opt.list_ndks { if !ndk.found() { - util.vab_error('No NDK could be found. Please use `${cli.exe_short_name} doctor` to get more information.') + util.vab_error('No NDK could be found', + details: 'Use `${cli.exe_short_name} doctor` to get more information.' + ) exit(1) } for ndk_v in ndk.versions_available() { @@ -74,7 +81,9 @@ fn main() { if opt.list_apis { if !sdk.found() { - util.vab_error('No SDK could be found. Please use `${cli.exe_short_name} doctor` to get more information.') + util.vab_error('No SDK could be found', + details: 'Use `${cli.exe_short_name} doctor` to get more information.' + ) exit(1) } for api in sdk.apis_available() { @@ -85,7 +94,9 @@ fn main() { if opt.list_build_tools { if !sdk.found() { - util.vab_error('No SDK could be found. Please use `${cli.exe_short_name} doctor` to get more information.') + util.vab_error('No SDK could be found', + details: 'Use `${cli.exe_short_name} doctor` to get more information.' + ) exit(1) } for btv in sdk.build_tools_available() { @@ -96,7 +107,7 @@ fn main() { if opt.list_devices { devices := android.adb_get_device_list(opt.verbosity) or { - util.vab_error('Error getting device list: ${err}') + util.vab_error('Could not get device list', details: '${err}') exit(1) } println('Device IDs:\n') @@ -122,23 +133,19 @@ fn main() { path: opt.screenshot delay: opt.screenshot_delay ) or { - util.vab_error('Failed to take screenshot:\n${err}') + util.vab_error('Failed to take screenshot', details: '${err}') exit(1) } exit(0) } if opt.run_builtin_cmd == 'install' { - install_arg := input - res := env.install(install_arg, opt.verbosity) - if res == 0 && opt.verbosity > 0 { - if install_arg != 'auto' { - opt.verbose(1, 'Installed ${install_arg} successfully.') - } else { - opt.verbose(1, 'Installed all dependencies successfully.') - } + install_args := os.args[os.args.index('install')..] + env.install_components(install_args, opt.verbosity) or { + util.vab_error('Failed to install components', details: '${err}') + exit(1) } - exit(res) + exit(0) } // Validate environment @@ -146,7 +153,14 @@ fn main() { opt.resolve(true) cli.validate_input(input) or { - util.vab_error('${cli.exe_short_name}: ${err}') + suggestions := cli.input_suggestions(input) + if suggestions.len > 0 { + util.vab_error('${cli.exe_short_name}: ${err}', + details: 'Did you mean `${suggestions.join('` ,`')}`?' + ) + } else { + util.vab_error('${cli.exe_short_name}: ${err}') + } exit(1) } opt.input = input @@ -160,12 +174,12 @@ fn main() { // Keystore file keystore := opt.resolve_keystore() or { - util.vab_error('${cli.exe_short_name}: could not resolve keystore: ${err}') + util.vab_error('Could not resolve keystore', details: '${err}') exit(1) } ado := opt.as_android_deploy_options() or { - util.vab_error('Could not create deploy options.\n${err}') + util.vab_error('Could not create deploy options', details: '${err}') exit(1) } deploy_opt := android.DeployOptions{ @@ -184,7 +198,7 @@ fn main() { if deploy_opt.device_id != '' { deploy(deploy_opt) android.screenshot(screenshot_opt) or { - util.vab_error('${cli.exe_short_name} screenshot did not succeed.\n${err}') + util.vab_error('Screenshot did not succeed', details: '${err}') exit(1) } exit(0) @@ -197,7 +211,7 @@ fn main() { cache_key: if os.is_dir(input) || input_ext == '.v' { opt.input } else { '' } } android.compile(comp_opt) or { - util.vab_error('${cli.exe_short_name} compiling didn\'t succeed.\n${err}') + util.vab_error('Compiling did not succeed', details: '${err}') exit(1) } @@ -207,23 +221,23 @@ fn main() { keystore: keystore } android.package(pck_opt) or { - util.vab_error("Packaging didn't succeed.\n${err}") + util.vab_error('Packaging did not succeed', details: '${err}') exit(1) } if deploy_opt.device_id != '' { deploy(deploy_opt) android.screenshot(screenshot_opt) or { - util.vab_error('${cli.exe_short_name} screenshot did not succeed.\n${err}') + util.vab_error('Screenshot did not succeed', details: '${err}') exit(1) } } else { if opt.verbosity > 0 { opt.verbose(1, 'Generated ${os.real_path(opt.output)}') - opt.verbose(1, 'Use `${cli.exe_short_name} --device ${os.real_path(opt.output)}` to deploy package') - opt.verbose(1, 'Use `${cli.exe_short_name} --device run ${os.real_path(opt.output)}` to both deploy and run the package') + util.vab_notice('Use `${cli.exe_short_name} --device ${os.real_path(opt.output)}` to deploy package') + util.vab_notice('Use `${cli.exe_short_name} --device run ${os.real_path(opt.output)}` to both deploy and run the package') if deploy_opt.run != '' { - opt.verbose(1, 'Use `adb -s "" shell am start -n "${deploy_opt.run}"` to run the app on the device, via adb') + util.vab_notice('Use `adb -s "" shell am start -n "${deploy_opt.run}"` to run the app on the device, via adb') } } } @@ -231,7 +245,7 @@ fn main() { fn deploy(deploy_opt android.DeployOptions) { android.deploy(deploy_opt) or { - util.vab_error('${cli.exe_short_name} deployment didn\'t succeed.\n${err}') + util.vab_error('Deployment did not succeed', details: '${err}') if deploy_opt.kill_adb { cli.kill_adb() }