Wild idea: custom column and dialog to show what locks a file

It is a wild idea that I'm thinking about implementing.
There are tools that allow you to show and terminate processes that use a specific file if you want to delete it.
However, it creates extra friction, and many of these apps require admin rights. Thus, you have to go through the UAC elevation confirmation dialog.
I thought it would be possible to integrate the functionality of these unlocking apps directly into DOpus, thus allowing the creation of custom columns and dialogs.
For example, a mockup of how custom columns can look:

You can think it shouldn't be a big problem to create a custom script or COM DLL to allow that, or maybe it can be a simple feature request for DOpus developers. But you can't believe how difficult such a seemingly simple task can be.

I have embarked on a crazy journey to investigate how to do it. And this is the journey full of pain and frustration. A journey that even involves reverse-engineering a Windows kernel driver using IDA Pro.

I will keep you posted on the progress.

However, I would like to know, do you think it is useful?

This would definitely be cool and useful. The only question is whether that satisfaction factor/utility justifes the effort of however difficult it proves to be.

E.g. I recently created a bunch of bat files to kill various programs and then just realised I could just use the kill feature in Flow Launcher.

As a quick aside, I have a similar but easier goal of creating a custom column that shows which files/folders are in path (environment variables).

An “In Use” label does sound helpful.
I got Locksmith, so I got that on my right click menu, but it isn’t always reliable.
You would think Microsoft would just give us info right on the error pop up, when something can’t be modified.

Everything v1.5 has a column named "Opened-By". You can run searches using that. You could also import it as a column using the EvColumnizer script.

You got me thinking about it. A quick Google search and I found a way that doesn't require admin privileges or any additional tools (just PowerShell).

It doesn't work for folders. Folders by themselves can be locked, too.

Basically, I think what I want to do is the same idea as Opened-By in SearchEverything. And it requires solving many non-trivial technical problems because it must be very fast and up to date. Also, in my solution, I would show the file as locked by the admin, not only by the user.
Wait, but if Everything offers this column, then there is no need for me to do anything :slight_smile:
Ha :slight_smile: Ok let me think about it. Thanks a lot for the information.

Everything does have a column like that, but I think there's a difference between "open" and "locked." The ev column is for the former. I'm not sure if it works for folders also.
To clarify, your idea was to create a plugin (not script addin) for Opus that adds those columns directly and doesn't require administrator privileges?

I think that would be very well received by the Opus community!

I'm not sure there is a difference between "open" and "locked". I checked your script, it only showed locked files. It doesn't show locked modules (the DLLs that are loaded by a process).
I'm not sure it is possible to check if file is opened but not locked. I think they gave the column a misleading name.
Could you please test it? What if you just open a txt file in a Windows Notepad, will Everything show that it is opened by Notepad?

I wanted to extend the DOpus-Scripting-Extensions to add extra COM interface that would allow to create the columns and dialogs for working with locked files. The installation already requires admin rights anyway. But usage is not. Thus it will not support portable DOpus.

However, if Everything already provides this column it is easier to integrate it into DOpus than me recreating the same functionality and maintaining it

There's a whole thread in the Everything forum about that.

Could you give me an example so I can check this with the Opened-By column?

I tried with almost every text editor I have (including Notepad), and it doesn't show any value in that column for the opened file.

dopuslib.dll will be loaded by Opus and usually some other processes as well.

A lot of them will load the files into memory and not keep them open/locked, only re-opening if they need to be reloaded or saved over.

FYI an easy way to hold a file open is from a DOS prompt with the more command (e.g. more test.txt). As long as the file is longer than a page, the command will wait for you to press return after showing the first part and has the file locked the whole time.

@errante,

Wow, very creative solution. One nitpick: folders can be locked without any files locked inside them.
You're a script-producing factory. How do you write so much code so fast?

There's a whole thread in the Everything forum about that.

Yes, basically, there is no difference. If a program holds a handle to a file, you can't remove it, and we call it "locked", but it is the same as "opened".

This python helper works for me. I've added it to my visual indicators script, alongside an indicator for path variables.

#!/usr/bin/env python3
# C:\Utilities\lock_check.py
#
# Batched lock checker for Directory Opus
#
# Usage:
#   python lock_check.py "C:\Temp\lock_input.txt" "C:\Temp\lock_output.txt"
#
# Input file:
#   One full file path per line
#
# Output file:
#   One result per line in this format:
#   <full-path>|<0-or-1-or-?>|<process names>
#
# Examples:
#   C:\Temp\a.txt|1|Microsoft Word
#   C:\Temp\b.txt|0|
#   C:\Temp\c.txt|?|
#
# Debug log:
#   C:\Utilities\lock_check.log

from __future__ import annotations

import ctypes
from ctypes import wintypes
import datetime as dt
import os
import sys
import traceback
import uuid

CCH_RM_MAX_APP_NAME = 255
CCH_RM_MAX_SVC_NAME = 63
CCH_RM_SESSION_KEY = 32 + 1  # include null terminator

ERROR_SUCCESS = 0
ERROR_MORE_DATA = 234

LOG_PATH = r"C:\Utilities\lock_check.log"

rstrtmgr = ctypes.WinDLL("rstrtmgr", use_last_error=True)


# ------------------------------------------------------------
# Logging
# ------------------------------------------------------------
def log(msg: str) -> None:
    try:
        ts = dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
        with open(LOG_PATH, "a", encoding="utf-8") as f:
            f.write(f"[{ts}] {msg}\n")
    except Exception:
        pass


def log_exception(prefix: str) -> None:
    try:
        log(prefix)
        log(traceback.format_exc().rstrip())
    except Exception:
        pass


# ------------------------------------------------------------
# Restart Manager structs
# ------------------------------------------------------------
class FILETIME(ctypes.Structure):
    _fields_ = [
        ("dwLowDateTime", wintypes.DWORD),
        ("dwHighDateTime", wintypes.DWORD),
    ]


class RM_UNIQUE_PROCESS(ctypes.Structure):
    _fields_ = [
        ("dwProcessId", wintypes.DWORD),
        ("ProcessStartTime", FILETIME),
    ]


class RM_PROCESS_INFO(ctypes.Structure):
    _fields_ = [
        ("Process", RM_UNIQUE_PROCESS),
        ("strAppName", wintypes.WCHAR * (CCH_RM_MAX_APP_NAME + 1)),
        ("strServiceShortName", wintypes.WCHAR * (CCH_RM_MAX_SVC_NAME + 1)),
        ("ApplicationType", wintypes.DWORD),
        ("AppStatus", wintypes.ULONG),
        ("TSSessionId", wintypes.DWORD),
        ("bRestartable", wintypes.BOOL),
    ]


rstrtmgr.RmStartSession.argtypes = [
    ctypes.POINTER(wintypes.DWORD),
    wintypes.DWORD,
    wintypes.LPWSTR,
]
rstrtmgr.RmStartSession.restype = wintypes.DWORD

rstrtmgr.RmRegisterResources.argtypes = [
    wintypes.DWORD,
    wintypes.UINT,
    ctypes.POINTER(wintypes.LPCWSTR),
    wintypes.UINT,
    ctypes.c_void_p,
    wintypes.UINT,
    ctypes.c_void_p,
]
rstrtmgr.RmRegisterResources.restype = wintypes.DWORD

rstrtmgr.RmGetList.argtypes = [
    wintypes.DWORD,
    ctypes.POINTER(wintypes.UINT),
    ctypes.POINTER(wintypes.UINT),
    ctypes.POINTER(RM_PROCESS_INFO),
    ctypes.POINTER(wintypes.DWORD),
]
rstrtmgr.RmGetList.restype = wintypes.DWORD

rstrtmgr.RmEndSession.argtypes = [wintypes.DWORD]
rstrtmgr.RmEndSession.restype = wintypes.DWORD


# ------------------------------------------------------------
# Helpers
# ------------------------------------------------------------
def winmsg(code: int) -> str:
    try:
        return ctypes.FormatError(code).strip()
    except Exception:
        return f"Win32 error {code}"


def ensure_parent_dir(path: str) -> None:
    parent = os.path.dirname(path)
    if parent:
        os.makedirs(parent, exist_ok=True)


def read_input_paths(input_file: str) -> list[str]:
    log(f"Reading input file: {input_file!r}")

    with open(input_file, "rb") as f:
        raw = f.read()

    if raw.startswith(b"\xff\xfe") or raw.startswith(b"\xfe\xff"):
        text = raw.decode("utf-16")
        log("Input file decoded as UTF-16")
    elif raw.startswith(b"\xef\xbb\xbf"):
        text = raw.decode("utf-8-sig")
        log("Input file decoded as UTF-8 with BOM")
    else:
        try:
            text = raw.decode("utf-8")
            log("Input file decoded as UTF-8")
        except UnicodeDecodeError:
            text = raw.decode("utf-16")
            log("Input file decoded as UTF-16 (fallback)")

    raw_lines = text.splitlines()

    paths: list[str] = []
    for raw_line in raw_lines:
        line = raw_line.rstrip("\r\n")
        if not line.strip():
            continue
        paths.append(line)

    log(f"Input paths loaded: {len(paths)}")
    return paths


def write_output_lines(output_file: str, lines: list[str]) -> None:
    log(f"Writing output file: {output_file!r} with {len(lines)} lines")
    ensure_parent_dir(output_file)
    with open(output_file, "w", encoding="utf-16", newline="\n") as f:
        for line in lines:
            f.write(line + "\n")


def sanitize_field(text: str) -> str:
    return (text or "").replace("\r", " ").replace("\n", " ").replace("|", "/").strip()


def unique_preserve_order(values: list[str]) -> list[str]:
    seen: set[str] = set()
    out: list[str] = []
    for v in values:
        key = v.casefold()
        if key in seen:
            continue
        seen.add(key)
        out.append(v)
    return out


# ------------------------------------------------------------
# Core lock probing
# ------------------------------------------------------------
def probe_lock(path: str) -> tuple[str, str]:
    """
    Returns:
        state: "1" if locked, "0" if unlocked, "?" on probe failure
        process_names: semicolon-separated locking process names, or empty string
    """
    log(f"probe_lock raw path: {path!r}")

    if not path:
        log("probe_lock: empty path")
        return "0", ""

    if not os.path.exists(path):
        log("probe_lock: path does not exist")
        return "0", ""

    if os.path.isdir(path):
        log("probe_lock: path is a directory; skipping")
        return "0", ""

    abs_path = os.path.abspath(path)
    log(f"absolute path: {abs_path!r}")

    session_handle = wintypes.DWORD(0)
    session_key_buf = ctypes.create_unicode_buffer(str(uuid.uuid4()).replace("-", ""), CCH_RM_SESSION_KEY)
    log(f"session key: {session_key_buf.value}")

    session_started = False
    try:
        rv = rstrtmgr.RmStartSession(ctypes.byref(session_handle), 0, session_key_buf)
        log(f"RmStartSession rv={rv}, handle={session_handle.value}")
        if rv != ERROR_SUCCESS:
            log(f"RmStartSession failed: {winmsg(rv)}")
            return "?", ""

        session_started = True

        resources = (wintypes.LPCWSTR * 1)()
        resources[0] = abs_path

        rv = rstrtmgr.RmRegisterResources(
            session_handle,
            1,
            resources,
            0,
            None,
            0,
            None,
        )
        log(f"RmRegisterResources rv={rv}")
        if rv != ERROR_SUCCESS:
            log(f"RmRegisterResources failed: {winmsg(rv)}")
            return "?", ""

        needed = wintypes.UINT(0)
        count = wintypes.UINT(0)
        reboot_reasons = wintypes.DWORD(0)

        rv = rstrtmgr.RmGetList(
            session_handle,
            ctypes.byref(needed),
            ctypes.byref(count),
            None,
            ctypes.byref(reboot_reasons),
        )
        log(
            "RmGetList(size) rv=%s, needed=%s, count=%s, reboot_reasons=%s"
            % (rv, needed.value, count.value, reboot_reasons.value)
        )

        if rv == ERROR_SUCCESS and needed.value == 0:
            log("RmGetList returned ERROR_SUCCESS on size query => unlocked")
            return "0", ""

        if rv not in (ERROR_MORE_DATA, ERROR_SUCCESS):
            log(f"RmGetList(size) failed: {winmsg(rv)}")
            return "?", ""

        if needed.value == 0:
            log("RmGetList(size) reports zero needed => unlocked")
            return "0", ""

        proc_array = (RM_PROCESS_INFO * needed.value)()
        count = wintypes.UINT(needed.value)

        rv = rstrtmgr.RmGetList(
            session_handle,
            ctypes.byref(needed),
            ctypes.byref(count),
            proc_array,
            ctypes.byref(reboot_reasons),
        )
        log(
            "RmGetList(data) rv=%s, needed=%s, count=%s, reboot_reasons=%s"
            % (rv, needed.value, count.value, reboot_reasons.value)
        )

        if rv != ERROR_SUCCESS:
            log(f"RmGetList(data) failed: {winmsg(rv)}")
            return "?", ""

        names: list[str] = []
        for i in range(count.value):
            info = proc_array[i]
            app_name = sanitize_field(info.strAppName)
            svc_name = sanitize_field(info.strServiceShortName)
            pid = int(info.Process.dwProcessId)

            chosen = app_name or svc_name or f"PID {pid}"
            names.append(chosen)

            log(
                "locker[%d]: pid=%s app=%r svc=%r chosen=%r"
                % (i, pid, app_name, svc_name, chosen)
            )

        names = unique_preserve_order(names)
        joined = "; ".join(names)

        if count.value > 0:
            log(f"result => LOCKED|1|{joined}")
            return "1", joined

        log("result => UNLOCKED|0|")
        return "0", ""

    except Exception:
        log_exception(f"probe_lock exception for {abs_path!r}")
        return "?", ""

    finally:
        if session_started:
            try:
                rv = rstrtmgr.RmEndSession(session_handle)
                log(f"RmEndSession rv={rv}, handle={session_handle.value}")
            except Exception:
                log_exception("RmEndSession exception")


# ------------------------------------------------------------
# Main
# ------------------------------------------------------------
def main() -> int:
    try:
        log("----- start -----")
        log(f"argv={sys.argv!r}")
        log(f"python={sys.executable!r}")
        log(f"cwd={os.getcwd()!r}")

        if len(sys.argv) != 3:
            log("Invalid argument count. Expected: input_file output_file")
            return 1

        input_file = sys.argv[1]
        output_file = sys.argv[2]

        paths = read_input_paths(input_file)
        out_lines: list[str] = []

        for path in paths:
            try:
                state, names = probe_lock(path)
                out_line = f"{sanitize_field(path)}|{state}|{sanitize_field(names)}"
                out_lines.append(out_line)
            except Exception:
                log_exception(f"Unhandled per-file failure for {path!r}")
                out_lines.append(f"{sanitize_field(path)}|?|")

        write_output_lines(output_file, out_lines)
        log("----- end -----")
        return 0

    except Exception:
        log_exception("Fatal error in main")
        return 1


if __name__ == "__main__":
    sys.exit(main())

@errante, @jfour18,

At this point, isn't it simpler to use Opened-by field from Everything instead of creating you own solutions?

The Opened-by column doesn't seem to work for me outside of everything itself, but i'm probably doing something wrong.

Do you realize that I spent all my hobby project time budget from last month to create a prototype? I had to disassemble PROCEXP152.SYS driver! And you say that Everything had this already?
Ok, thank you for your help :slight_smile:

Don't blame the messenger :face_holding_back_tears:.

But seriously, you should continue if you want to.

That column is part of Everything, so it’s up to it whatever is shown as a value. For me, at least, there’s a difference between that column and my script; it doesn’t seem to work with modules, etc. Plus, the fact that it’s kind of a PITA to import it into Opus.

Maybe even you can expand it so can unlock selected handles for a given files.

Don't blame the messenger

I was joking, of course :slight_smile:

That column is part of Everything, so it’s up to it whatever is shown as a value. For me, at least, there’s a difference between that column and my script; it doesn’t seem to work with modules, etc. Plus, the fact that it’s kind of a PITA to import it into Opus.

Why is it hard to integrate into Opus?

For several reasons, as you may noticed in the script above, in EVColumnizer, and maybe elsewhere:

  • Right now, Opus's scripting API isn't compatible with Everything’s SDK3, so there's no direct way to communicate and fetch that kind of data. You have to rely on an external program (es.exe), with all the overhead that comes with it.

  • If you want to show that data in columns, you run into a multiplied overhead by calling external programs. This is due to how user columns currently work in the Opus API (I've talk about this up multiple times on the forum). Since there's no direct way to know which items are requesting the column value, or what's the context, you have to get very creative to minimize this issue, and that's where things get complicated.

  • If you want to use it as a command (like in my example script), the overhead of calling es.exe isn't as big of a deal, but you only get what Everything returns (most likely the process path, if any), not PIDs. So its usefulness ends up being exclusively informational (e.g. I was trying to unlock handles like other tools do, but built-in).

Great. Thank you for the detailed response. I have never used the API for Search Everything, thus I'm not aware of what is going on there.