banner

flameshot-org/flameshot

Last updated 

Introduction

Flameshot is my favorite screenshot software. I like the UI and appreciate the annotation features that streamline the creation of documentation. However, the Windows version has always had two weaknesses:

  1. The screenshot hotkey is hard-coded and cannot be configured. Unfortunately, it is hard-coded to PrntScr, which many keyboards do not have, and is already taken by the Windows screen snipping software.
  2. The executable does not respond to command-line arguments (unlike the Linux version), so I cannot overcome issue #1 by mapping a macro key to the command flameshot gui

Finally tired of using Greenshot (a distant second-place in my list of preferred screenshot tools), I decided to add command-line support for flameshot on Windows.

Discussion

It turned out to be rather trivial to add support for command-line arguments, but rather difficult to handle the resulting stdout.

Argument Parsing

The developers had added a preprocessor macro to remove the argument parsing functionality from Windows builds; Adding support for command-line arguments was as simple as removing the macro.

I assume the macro was there due to the stdout issue (which I'll describe momentarily), but I still find it confusing. Surely functionality without stdout is better than no functionality at all?

Console vs Windows Apps

The vast majority of Windows applications are either console or windows applications, i.e. they use the console or windows subsystems. This designation is set by an executable's headers. You can check an executable's subsystem using dumpbin and change its subsystem using editbin. (These tools are included with Visual Studio's build tools.)

ASCII Table

There are a few key differences between these two types of applications:

  1. A console application always spawns a console unless it is started from one
  2. A windows application does not spawn a console, though it can attach to an existing console or create a new one using AllocConsole
  3. When you start a console application from a console, the command doesn't return until the console application exits
  4. When you start a windows application from a console, the command returns immediately

The four differences are paraphrased from users oefe and Knorad Rudolph on this Stack Overflow post.

Subsystem Impacts

The flameshot executable is a windows app, meaning it will not return any output if called from a console. Even if you use AttachConsole(ATTACH_PARENT_PROCESS) to attach the process's stdout and stderr to the parent console, it will have adverse effects because of difference #4: the command prompt will return immediately, and the process's output will return after a new prompt has been created. The command prompt will not recognize that the new output has been provided, and will continue overwriting - without ever clearing - the returned text. It is messy, to say the least.

Alternatively, AllocConsole could be called conditionally to provide any needed output. However, it would be provided in a new, separate console window, which would hardly be the expected behavior for a command-line app.

The executable could be converted to a console app that calls FreeConsole if not run from an existing console, but this still causes a noticeable console window "flash" when the program is run.

This is a well-known conundrum, and much discussed on various programming forums. From user Hans Passant on Stack Overflow:

Only two good ways to solve this unsolvable problem. Either build your program as a console mode app and call FreeConsole() when you find out you want to display a GUI. Or always call AllocConsole(). These are not great alternatives. The first approach is the one used by the Java JVM on Windows. One of the oldest bugs filed against the JVM and driving Java programmers completely batty from the flashing console window.

The third alternative is the only decent one, and the one you don't want, create another EXE that will always use the console. Like Java does, javaw.exe vs java.exe.

A trick is possible, you can rename that file from "yourapp2.exe" to "yourapp.com". It will be picked first when the user types "yourapp" at the command line prompt, a desktop shortcut can still point to "yourapp.exe". Visual Studio uses this trick, devenv.com vs devenv.exe.

I took the third alternative but named the dedicated console app flameshot-cli.exe rather than "hiding" it with Han's trick.

Flameshot CLI

To accomplish my purpose, I left flameshot as a windows app that respects command-line arguments but does not return any stdout to the console. I created a new source file and edited the cmake configurations to build a new flameshot-cli executable when built on/for Windows. The flameshot-cli executable is a console app that provides a minimal wrapper around the flameshot executable and returns stdout to the console.

#include <iostream>
#include <windows.h>

std::string joinArgs(int argc, char* argv[])
{
    std::string result;
    for (int i = 1; i < argc; ++i) {
        if (i > 1) {
            result += " ";
        }
        result += argv[i];
    }
    return result;
}

bool setDirectory()
{
    char path[MAX_PATH];
    int pathLength = GetModuleFileName(NULL, path, MAX_PATH);
    // fullpath - length of 'flameshot-cli.exe'
    std::string moduleDir = std::string(path, pathLength - 18);
    return SetCurrentDirectory(moduleDir.c_str());
}

void CallFlameshot(const std::string args, bool wait)
{
    // _popen doesn't handle spaces in filepath,
    // so make sure we are in right directory before calling
    setDirectory();
    std::string cmd = "flameshot.exe " + args;
    FILE* stream = _popen(cmd.c_str(), "r");
    if (wait) {
        if (stream) {
            const int MAX_BUFFER = 2048;
            char buffer[MAX_BUFFER];
            while (!feof(stream)) {
                if (fgets(buffer, MAX_BUFFER, stream) != NULL) {
                    std::cout << buffer;
                }
            }
        }
        _pclose(stream);
    }
    return;
}

// Console 'wrapper' for flameshot on windows
int main(int argc, char* argv[])
{
    if (argc == 1) {
        std::cout << "Starting flameshot in daemon mode";
        CallFlameshot("", false);
    } else {
        std::string argString = joinArgs(argc, argv);
        CallFlameshot(argString, true);
    }
    std::cout.flush();
    return 0;
}

PR Message

Link to PR

Description

Adds support for command-line usage / command-line arguments on Windows.

Closes #2118

Approach

I assume this wasn't done previously because Windows apps can be console apps or windows apps, but not both. That leaves two options with a single flameshot executable:

  1. A GUI app that respects CLI arguments but never returns any text to console
  2. A console app that always pops up a console window, even when started with a double-click

There are some workarounds to mitigate the inconveniences of these approaches, but ultimately, the best solution is two executables.

Good discussions on this topic can be found on Stack Overflow here and here.

This commit adds the windows-cli.cpp source, which compiles into flameshot-cli.exe when built on Windows. It is a minimal wrapper around flameshot.exe but ensures that all stdout is captured and output to the console.

Code Changes

  • The preprocessor macro that prevented arg parsing on Windows has been removed from main.cpp
  • windows-cli.cpp has been added as the source for flameshot-cli.exe
  • The flameshot-cli target has been added to cmake (only when building on Windows)
  • README updates

README Updates

Usage on Windows

On Windows, flameshot.exe will behave as expected for all supported command-line arguments, but it will not output any text to the console. This is problematic if, for example, you are running flameshot.exe -h.

If you require console output, run flameshot-cli.exe instead.

Dev Environment

Setting up a development environment to build flameshot on Windows is not a particularly quick process, so I scripted and documented the process. The following script will setup a flameshot dev environment on a blank/'stock' Windows 11 VM.

Note: To create the development environment, I referenced the Windows CI/CD workflow located in .github/workflows/Windows-pack.yml.

# replicate environment dirs from github ci/cd
$env:GITHUB_WORKSPACE="C:\workspace"
$env:VCINSTALLDIR="C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\VC\"
$env:Qt5_DIR="C:\workspace\build\Qt\5.12.2\msvc2017_64\lib\cmake\Qt5\" # should be 2019
$env:QTDIR="C:\workspace\build\Qt\5.12.2\msvc2017_64\" # should be 2019
$env:VCPKG_VERSION="cef0b3ec767df6e83806899fe9525f6cf8d7bc91"
$env:VCPKG_PACKAGES="openssl-windows"
$env:OPENSSL_ROOT_DIR="C:\workspace\vcpkg\installed\x64-windows\"

# replicate "matrix" from github ci/cd
$matrix = @{
    qt_ver = "5.15.2"
    qt_target = "desktop"
    config = @(
        @{
            arch = "x86"
            generator = "-G'Visual Studio 16 2019' -A Win32"
            vcpkg_triplet = "x86-windows"
            qt_arch = "win32_msvc2019"
            qt_arch_install = "msvc2019"
            pak_arch = "win32"
        },
        @{
            arch = "x64"
            generator = "-G'Visual Studio 16 2019' -A x64"
            vcpkg_triplet = "x64-windows"
            qt_arch = "win64_msvc2019_64"
            qt_arch_install = "msvc2019_64"
            pak_arch = "win64"
        }
    )
    type = @("portable", "installer")
}

# install choco
Set-ExecutionPolicy Bypass -Scope Process -Force 
[System.Net.ServicePointManager]::SecurityProtocol = `
    [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; 
Invoke-Expression (
    (
        New-Object System.Net.WebClient
    ).DownloadString('https://community.chocolatey.org/install.ps1')
)

# refresh path
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") `
    + ";" + [System.Environment]::GetEnvironmentVariable("Path","User")

# install VS 2019 vctools
choco install git visualstudio2019-workload-vctools -y

# refresh path
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") `
    + ";" + [System.Environment]::GetEnvironmentVariable("Path","User")

# create working directory
New-Item -ItemType Directory -Path "$env:GITHUB_WORKSPACE"

# get src
Set-Location -Path "$env:GITHUB_WORKSPACE"
git clone "https://github.com/flameshot-org/flameshot" . 

# get vcpkg
git clone "https://github.com/microsoft/vcpkg.git"
Set-Location "./vcpkg"
git reset --hard "$env:VCPKG_VERSION"
./bootstrap-vcpkg.bat

# get vcpkg package(s)
.\vcpkg.exe --host-triplet="$($matrix.config[1].vcpkg_triplet)" `
    install "$env:VCPKG_PACKAGES"
$env:Path += $env:GITHUB_WORKSPACE + `
	"\vcpkg\downloads\tools\cmake-3.29.2-windows\cmake-3.29.2-windows-i386\bin;"

# Create build folder
New-Item -ItemType Directory -Path "$env:GITHUB_WORKSPACE\build"
Set-Location "$env:GITHUB_WORKSPACE\build"

# download and install QT5
$qtInstaller = "C:\Users\Administrator\Downloads\qt-opensource-windows-x86-5.12.2.exe"
(New-Object System.Net.WebClient).DownloadFile(
    "https://download.qt.io/archive/qt/5.12/5.12.2/qt-opensource-windows-x86-5.12.2.exe", 
    $qtInstaller
)
$qtVersion = [string]::Format(
    "qt.qt{0}.{1}.{2}",
    $matrix.qt_ver[0],
    $matrix.qt_ver.Replace(".", ""),
    $matrix.config[1].qt_arch
)
###########################################################################
# haven't found a way to install without user interaction - very annoying #
###########################################################################
& $qtInstaller --root "$env:GITHUB_WORKSPACE\build\Qt\" `
    --accept-licenses --accept-obligations `
    --default-answer --confirm-command `
    install $qtVersion

# get VS environment variables
$varsPath = `
    "C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\VC\Auxiliary\Build\vcvars64.bat"
# from https://github.com/majkinetor/posh/blob/master/MM_Admin/Invoke-Environment.ps1
$command = [string]::Format(
    '"{0}" > nul 2>&1 && set', $varsPath
)
cmd /c $command | % {
    if ($_ -match '^([^=]+)=(.*)') {
        [System.Environment]::SetEnvironmentVariable($matches[1], $matches[2])
    }
}

# BUILD function to repeatedly build and test
function BUILD {
    param(
        [string]$Name = "TestBuild"
    )
    $dest = "C:\Users\Administrator\Downloads\$Name"
    cmake .. -G 'Visual Studio 16 2019' -A x64 `
        -D CMAKE_TOOLCHAIN_FILE="$env:GITHUB_WORKSPACE\vcpkg\scripts\buildsystems\vcpkg.cmake" `
        -D ENABLE_OPENSSL=ON `
        -D CMAKE_BUILD_TYPE=Release `
        -D RUN_IN_PLACE='portable'
    cmake --build . --config Release
    cpack -G ZIP -B "$env:GITHUB_WORKSPACE\build\Package"
    Get-Process | ? Name -match "flameshot" | Stop-Process
    Remove-Item -Path "C:\Users\Administrator\Downloads\flameshot*.zip"
    Remove-Item -Path "$dest\*" -Recurse -Force
    Move-Item -Path "$env:GITHUB_WORKSPACE\build\Package\*.zip" `
        -Destination "C:\Users\Administrator\Downloads\"
    Expand-Archive -Path "C:\Users\Administrator\Downloads\*.zip" `
        -DestinationPath $dest
    Move-Item -Path "$dest\*\bin\*" `
        -Destination "$dest\"
}

BUILD