• The Old New Thing

    A warning to people averse to code

    • 0 Comments
    This is going to be a code-heavy week. People who are averse to code may want to just lie low until next week. I'll try to make it up by having next week be more storytelling-focused.
  • The Old New Thing

    Proto-Microspeak: Coceptualize

    • 35 Comments

    Many years ago, to see whether anybody was paying attention, a colleague and I slipped the made-up word "coceptualize" into a document.

    Nobody said a thing.

    Either nobody read that part of the document, or they did and thought it was a real word.

  • The Old New Thing

    When will the static control automatically delete the image loaded into it, and when is it the responsibility of the application?

    • 25 Comments

    If you create a static control with initial contents (for example, by creating a BITMAP or ICON control in a dialog template), then the static control will load the contents upon creation and destroy the contents upon destruction. So at least in the case where you don't touch the static control, things will work automatically.

    But once you touch it, things get confusing.

    If you send the STM_SET­IMAGE message to a static control, this does a few things (assuming your parameters are all valid):

    • The previous image is replaced by the new image you passed.
    • The message returns a handle to the previous image.
    • The static control turns off automatic image deletion.

    The third part is the tricky part. If you ever (successfully) send a static control the STM_SET­IMAGE message, then it says, "Okay, it's all your problem now." You are now responsible not only for destroying the new image, but you are also responsible for destroying the old image that was returned.

    In other words, the following operation is not a nop:

    HBITMAP hbmPrev = SendMessage(hwndStatic, STM_SETIMAGE,
                                  IMAGE_BITMAP, (LPARAM)hbmNew);
    SendMessage(hwndStatic, STM_SETIMAGE,
                IMAGE_BITMAP, (LPARAM)hbmPrev);
    

    This sounds like a nop, since all you did was change the image, and then change it back. But the side effect is also that you made the static control go into your problem mode, and the original image will no longer be automatically destroyed. If you forget to destroy it yourself, then you have a leak.

    Wait, it gets worse.

    If you are using version 6 of the common controls, then things get even more confusing if you use the STM_SET­IMAGE message to change the IMAGE_BITMAP of a SS_BITMAP static control, and the bitmap you pass is a 32-bpp bitmap, and the image has a nonzero alpha channel, then the static control will make a copy of the bitmap you passed in and act as if you had passed that copy instead.¹ This by itself is no big deal, because the responsibility for destroying the image you passed in still resides with you, the application, so the rules haven't changed there.

    The nasty bit is that the application also must assume responsibility for destroying the secret copy. That bitmap you didn't even know existed and don't have a handle to? Yeah, you're on the hook for that one too.

    How unfair.

    Even more confusing is that if you send STM_SET­IMAGE a second time, it will replace the bitmap and return a handle to the secret copy (which is a bitmap you've never seen before).

    This means that the following assertion can fire:

    HBITMAP hbmPrev = SendMessage(hwndStatic, STM_SETIMAGE,
                                  IMAGE_BITMAP, (LPARAM)hbmNew);
    HBITMAP hbmBack = SendMessage(hwndStatic, STM_SETIMAGE,
                                  IMAGE_BITMAP, (LPARAM)hbmPrev);
    assert(hbmNew == hbmBack); // ??
    

    You would think that the assertion is safe because all you did was change the bitmap to hbmNew, then change it back. And when you change it back, the "previous value" is the value hbmNew you set it to on the previous line.

    Except that if hbmNew satisfies the above magic criteria, then the value in hbmBack is not hbmNew but rather the handle to the secret copy.

    Which you have to remember to destroy.

    Yuck.

    The secret copy is not too secret. You can get a handle to it by sending the STM_GET­IMAGE message. Which you now need to do when you destroy the static control, just in case it's the secret copy. You need to compare the current image against the one that you thought you passed in, and if they are different, then you have the secret copy that needs to be destroyed as an extra step.

    Yes, this sucks. I apologize.

    (My recommendation: To detect whether a "secret copy" occurred, do a STM_GET­IMAGE after your STM_SET­IMAGE and see if the handles match.)

    ¹ The secret copy is not an exact copy. (After all, if it were an exact copy, then there would be no need to create the copy. It could just use the handle you passed in.) Instead, the secret copy is a copy of the original, followed by some additional munging so that it can be displayed on the screen while respecting the alpha channel you passed in.

  • The Old New Thing

    When will GetSystemWindowsDirectory return something different from GetWindowsDirectory?

    • 30 Comments

    Most of the time, the Get­Window­Directory returns the Windows directory. However, as noted in the documentation for Get­System­Windows­Directory:

    With Terminal Services, the Get­System­Windows­Directory function retrieves the path of the system Windows directory, while the Get­Windows­Directory function retrieves the path of a Windows directory that is private for each user. On a single-user system, Get­System­Windows­Directory is the same as Get­Windows­Directory.

    What's going on here, and how do I test this scenario?

    When Terminal Services support was being added to Windows NT 4.0 in the mid 1990's, the Terminal Services team discovered that a lot of applications assumed that the computer was used by only one person, and that that person was a local administrator. This was the most common system configuration at the time, so a lot of applications simply assumed that it was the only system configuration.

    On the other hand, a Terminal Server machine can have a large number of users, including multiple users connected simultaneously, and if the Terminal Services team took no special action, you would have found that most applications didn't work. The situation "most applications didn't work" tends not to bode well for adoption of your technology.

    Their solution was to create a whole bunch of compatibility behaviors and disable them if the application says, "Hey, I understand that Terminal Server machines are different from your average consumer machine, and I know what I'm doing." One of those compatibility behaviors is to make the Get­Windows­Directory function return a private writable directory rather than the real Windows directory, because old applications assumed that the Windows directory was writable, and they often dumped their private configuration data there.

    The signal to disable compatibility behaviors is the IMAGE_DLLCHARACTER­ISTICS_TERMINAL_SERVER_AWARE flag in the image attributes of the primary executable. You tell the linker that you want this flag to be set by passing the /TSAWARE:YES parameter on the command line. (At some point, the Visual Studio folks made /TSAWARE:YES the default for all new projects, so you are probably getting this flag set on your files without even realizing it. You can force it off by going to Configuration Properties, and under Linker, then System, change the "Terminal Server" setting to "Not Terminal Server Aware".)

    Note that only the flag state on the primary executable has any effect. Setting the flag on a DLL has no effect. (This adds to the collection of flags that are meaningful only on the primary executable.)

    The other tricky part is that the Terminal Server compatibility behaviors kick in only on a Terminal Server machine. The way you create a Terminal Server machine has changed a lot over the years, as has the name of the feature.

    • In Windows NT 4.0, it was a special edition of Windows, known as Windows NT 4.0 Terminal Server Edition.
    • In Windows 2000, the feature changed its name from Terminal Server to Terminal Services and became an optional server component rather than a separate product. You add the component from Add/Remove Programs.
    • In Windows Server 2003 and Windows Server 2008, you go to the Configure Your Server Wizard and add the server rôle "Terminal Server."
    • In Windows Server 2008 R2, the feature changed its name again. The instructions are the same as in Windows Server 2008, but the rôle name changed to "Remote Desktop Services".
    • In Windows Server 2012, the feature retained its name but became grouped under the category "Virtual Desktop Infrastructure." This time, you have to enable the rôle server "Remote Desktop (RD) Session Host."

    Terminal Services is the Puff Daddy of Windows technologies. It changes its name every few years, and you wonder what it will come up with next.

  • The Old New Thing

    How to view the stack of threads that were terminated as part of process teardown from user mode

    • 25 Comments

    Last time we saw how to view the stack of threads that were terminated as part of process teardown from the kernel debugger. You can do the same thing from a user-mode debugger, and it's actually a bit easier there. (The user-mode debugger I'm using is the one that comes with the Debugging Tools for Windows, the debugging engine that goes by a number of different front-ends, such as ntsd, cdb, and windbg.)

    A direct translation of the kernel-mode technique from last time would involve using the !vadump command and picking through for the memory blocks with candidate size and attributes. But there's an easier way.

    Now would be a good point for me to remind you that this information is for debugging purposes only. The structures and offsets are all implementation details which can change from release to release.

    Recall that the TEB begins with some pointers which bound the stack, and the seventh pointer is a self-pointer. What's even more useful is the thirteenth pointer (offset 0x30 for 32-bit TEBs, offset 0x60 for 64-bit TEBs), because that is where the PEB is stored.

    Each process has a single global PEB, so all the TEBs will have the same PEB value at offset 0x30/0x60. And you can figure out the address of the current process's PEB either by using the !peb command or by simply looking at the TEB you already have.

    0:000> dd fs:30 l1
    0053:00000030  7efde000
    

    Now you can search through memory looking for that value. If you see any hits at offset 0x30/0x60, then that's a candidate TEB.

    The debugger normally limits memory scans to 256MB.

    0:001> s 00000000 L 80000000 00 e0 fd 7e
                               ^ Range error in 's 00000000 l 80000000 00 e0 fd 7e'
    

    Therefore, you have to issue the search eight times (for 32-bit processes) to cover the 2GB user-mode address space.

    0:001> s 00000000 L 10000000 00 e0 fd 7e
    0009e01c  00 e0 fd 7e 00 d0 fd 7e-44 e0 09 00 7b ef 17 77  ...~...~D...{..w
    0009fdc0  00 e0 fd 7e 44 00 00 00-f0 ee 3a 00 10 ef 3a 00  ...~D.....:...:.
    0009fe34  00 e0 fd 7e 78 fe 09 00-02 9f 18 77 00 e0 fd 7e  ...~x......w...~
    0:001> s 10000000 L 10000000 00 e0 fd 7e
    0:001> s 20000000 L 10000000 00 e0 fd 7e
    0:001> s 30000000 L 10000000 00 e0 fd 7e
    0:001> s 40000000 L 10000000 00 e0 fd 7e
    0:001> s 50000000 L 10000000 00 e0 fd 7e
    0:001> s 60000000 L 10000000 00 e0 fd 7e
    0:001> s 70000000 L 10000000 00 e0 fd 7e
    7486af70  00 e0 fd 7e 00 00 00 00-b8 00 16 77 28 00 16 77  ...~.......w(..w
    7efda030  00 e0 fd 7e 00 00 00 00-00 00 00 00 00 00 00 00  ...~............
    7efdd030  00 e0 fd 7e 00 00 00 00-00 00 00 00 00 00 00 00  ...~............
    

    Alternatively, you can use the "length sanity check override" by inserting a question mark after the L:

    0:001> s 00000000 L?80000000 00 e0 fd 7e
    0009e01c  00 e0 fd 7e 00 d0 fd 7e-44 e0 09 00 7b ef 17 77  ...~...~D...{..w
    0009fdc0  00 e0 fd 7e 44 00 00 00-f0 ee 3a 00 10 ef 3a 00  ...~D.....:...:.
    0009fe34  00 e0 fd 7e 78 fe 09 00-02 9f 18 77 00 e0 fd 7e  ...~x......w...~
    7486af70  00 e0 fd 7e 00 00 00 00-b8 00 16 77 28 00 16 77  ...~.......w(..w
    7efda030  00 e0 fd 7e 00 00 00 00-00 00 00 00 00 00 00 00  ...~............
    7efdd030  00 e0 fd 7e 00 00 00 00-00 00 00 00 00 00 00 00  ...~............
    

    From the above output, we see that we can quickly reject all but the last two entries because the offset within the page is not the magic value 0x30. (This is a 32-bit process.) Hooray, two debugger commands reduce the search space to just two pages!

    At this point, you can continue with the debugging technique from last time, looking at each candidate TEB to see if there's a valid stack in there.

  • The Old New Thing

    Your program assumes that COM output pointers are initialized on failure; you just don't realize it yet

    • 16 Comments

    We saw last time that the COM rules for output pointers are that they must be initialized on return from a function, even if the function fails. The COM marshaller relies on this behavior, but then again, so do you; you just don't realize it yet.

    If you use a smart pointer library (be it ATL or boost or whatever), you are still relying on output pointers being NULL when not valid, regardless of whether or not the call succeeded. Let's look at this line of code from that article about IUnknown::QueryInterface:

    CComQIPtr<ISomeInterface> spsi(punkObj);
    ...
    // spsi object goes out of scope
    

    If the IUnknown::QueryInterface method puts a non-NULL value in spsi on failure, then when spsi is destructed, it's going to call IUnknown::Release on itself, and something bad happens. If you're lucky, you will crash because the thing lying around in spsi was a garbage pointer. But if you're not lucky, the thing lying around in spsi might be a pointer to a COM object:

    // wrong!
    HRESULT CObject::QueryInterface(REFIID riid, void **ppvObj)
    {
      *ppvObj = this; // assume success since it almost always succeeds
      if (riid == IID_IUnknown || riid == IID_IOtherInterface) {
        AddRef();
        return S_OK;
      }
      // forgot to set *ppvObj = NULL
      return E_NOINTERFACE;
    }
    

    Notice that this code optimistically sets the output pointer to itself, but if the interface is not supported, it changes its mind and returns E_NOINTERFACE without setting the output pointer to NULL. Now you have an elusive reference counting bug, because the destruction of spsi will call CObject::Release, which will manifest itself by CObject object being destroyed prematurely because you just over-released the object. If you're lucky, that'll happen relative soon; if you're not lucky, it won't manifest itself for another half hour.

    Okay, sure, maybe this is too obvious a mistake for CObject::QueryInterface, but any method that has an output parameter can suffer from this error, and in those cases it might not be quite so obvious:

    // wrong!
    HRESULT CreateSurface(const SURFACEDESC *psd,
                          ISurface **ppsf)
    {
     *ppsf = new(nothrow) CSurface();
     if (!*ppsf) return E_OUTOFMEMORY;
     HRESULT hr = (*ppsf)->Initialize(psd);
     if (SUCCEEDED(hr)) return S_OK;
     (*ppsf)->Release(); // throw it away
     // forgot to set *ppsf = NULL
     return hr;
    }
    

    This imaginary function takes a surface description and tries to create a surface that matches it. It does this by first creating a blank surface, and then initializing the surface. If that succeeds, then we succeed; otherwise, we clean up the incomplete surface and fail.

    Except that we forgot to set *ppsf = NULL in our failure path. If initialization fails, then we destroy the surface, and the pointer returned to the caller points to the surface that we abandoned. But the caller shouldn't be looking at that pointer because the function failed, right?

    Well, unless the caller called you like this:

    CComPtr<ISurface> spsf;
    if (SUCCEEDED(CreateSurface(psd, &spsf))) {
     ...
    }
    

    If the surface fails to initialize, then spsf contains a pointer to a surface that has already been deleted. When the spsf is destructed, it's going to call ISurface::Release on some point that is no longer valid, and bad things are going to happen. This can get particularly insidious when spsf is not a simple local variable but rather a member of class which itself doesn't get destroyed for a long time. The bad pointer sits in m_spsf like a time bomb.

    Although all the examples I gave here involve COM interface pointers, the rule applies to all output parameters.

    CComBSTR bs;
    if (SUCCEEDED(GetName(&bs)) { ... }
    
    // -or-
    
    CComVariant var;
    if (SUCCEEDED(GetName(&var)) { ... }
    

    In the first case, the the GetName method had better not leave garbage in the output BSTR on failure, because the CComBSTR is going to SysFreeString in its destructor. Similarly in the second case with CComVariant and VariantClear.

    So remember, if your function doesn't want to return a value in an output pointer, you still have to return something in it.

  • The Old New Thing

    I know you can't provide tax advice, but I'm just looking for tax advice

    • 6 Comments

    One of my friends works at the help desk of a university library, and a while back she told two stories of people who asked for help without first engaging the part of the brain responsible for logic and reason.

  • The Old New Thing

    Researchers discover link between music and drinking

    • 1 Comments
    A British scientific study shows that a bit of classical music can persuade diners to buy more fancy coffees, pricey wines and luxurious desserts. "North has shown that playing German or French music can persuade diners to buy wine from those countries." I found this to be true in my experience. If you get two thousand people in a tent and play live oom-pah music, they end up drinking lots of German beer.
  • The Old New Thing

    Invoking commands on items in the Recycle Bin

    • 14 Comments

    Once you've found the items you want in the Recycle Bin, you may want to perform some operation on them. This brings us back to our old friend, IContextMenu. At this point, you're just snapping two blocks together. You have one block called Retrieving properties from items in the Recycle Bin and you have another block called Invoking verbs on items.

    For the first block, let's assume you've written a function called WantToRestoreThisItem which studies the properties of a Recycle Bin item and determines whether you want to restore it. I leave this for you to implement, since I don't know what your criteria are. Maybe you want to restore files only if they were deleted from a particular directory. Maybe you want to restore files that were deleted while you were drunk. (This assumes you have some other computer program that tracks when you're drunk.)¹ Whatever. It's your function.

    For the second block, we have a helper function which should look awfully familiar.

    void InvokeVerb(IContextMenu *pcm, PCSTR pszVerb)
    {
     HMENU hmenu = CreatePopupMenu();
     if (hmenu) {
      HRESULT hr = pcm->QueryContextMenu(hmenu, 0, 1, 0x7FFF, CMF_NORMAL);
      if(SUCCEEDED(hr)) {
       CMINVOKECOMMANDINFO info = { 0 };
       info.cbSize = sizeof(info);
       info.lpVerb = pszVerb;
       pcm->InvokeCommand(&info);
      }
      DestroyMenu(hmenu);
     }
    }
    

    And now we snap the two blocks together.

    int __cdecl _tmain(int argc, PTSTR *argv)
    {
     HRESULT hr = CoInitialize(NULL);
     if (SUCCEEDED(hr)) {
      IShellItem *psiRecycleBin;
      hr = SHGetKnownFolderItem(FOLDERID_RecycleBinFolder, KF_FLAG_DEFAULT,
                                NULL, IID_PPV_ARGS(&psiRecycleBin));
      if (SUCCEEDED(hr)) {
       IEnumShellItems *pesi;
       hr = psiRecycleBin->BindToHandler(NULL, BHID_EnumItems,
                                         IID_PPV_ARGS(&pesi));
       if (hr == S_OK) {
        IShellItem *psi;
        while (pesi->Next(1, &psi, NULL) == S_OK) {
         if (WantToRestoreThisItem(psi)) {
          IContextMenu *pcm;
          hr = psi->BindToHandler(NULL, BHID_SFUIObject,
                                  IID_PPV_ARGS(&pcm));
          if (SUCCEEDED(hr)) {
           InvokeVerb(pcm, "undelete");
           pcm->Release();
          }
         }
         psi->Release();
        }
       }
       psiRecycleBin->Release();
      }
      CoUninitialize();
     }
     return 0;
    }
    

    One annoyance of the Recycle Bin is that, at least up until Windows 7, it ignores the CMIC_MASK_FLAG_NO_UI flag. It always displays a confirmation dialog if something dangerous is about to happen (like overwriting an existing file). To mitigate this problem, we can at least reduce the number of confirmations from one-per-file to just one by batching up all the objects we want to operate on into a single context menu. For this, it's easier to go back to the classical version of the program.

    int __cdecl _tmain(int argc, PTSTR *argv)
    {
     HRESULT hr = CoInitialize(NULL);
     if (SUCCEEDED(hr)) {
      IShellFolder2 *psfRecycleBin;
      hr = BindToCsidl(CSIDL_BITBUCKET, IID_PPV_ARGS(&psfRecycleBin));
      if (SUCCEEDED(hr)) {
       IEnumIDList *peidl;
       hr = psfRecycleBin->EnumObjects(NULL,
         SHCONTF_FOLDERS | SHCONTF_NONFOLDERS, &peidl);
       if (hr == S_OK) {
        // in a real program you wouldn't hard-code a fixed limit
        PITEMID_CHILD rgpidlItems[100];
        UINT cpidlItems = 0;
        PITEMID_CHILD pidlItem;
        while (peidl->Next(1, &pidlItem, NULL) == S_OK) {
         if (WantToRestoreThisItem(psfRecycleBin, pidlItem) &&
             cpidlItems < ARRAYSIZE(rgpidlItems)) {
          rgpidlItems[cpidlItems++] = pidlItem;
         } else {
          CoTaskMemFree(pidlItem);
         }
        }
        // restore the items we collected
        if (cpidlItems) {
         IContextMenu *pcm;
         hr = psfRecycleBin->GetUIObjectOf(NULL, cpidlItems,
                         (PCUITEMID_CHILD_ARRAY)rgpidlItems,
                         IID_IContextMenu, NULL, (void**)&pcm);
         if (SUCCEEDED(hr)) {
          InvokeVerb(pcm, "undelete");
          pcm->Release();
         }
         for (UINT i = 0; i < cpidlItems; i++) {
          CoTaskMemFree(rgpidlItems[i]);
         }
        }
       }
       psfRecycleBin->Release();
      }
      CoUninitialize();
     }
     return 0;
    }
    

    In the course of the enumeration, we save the ITEMIDLISTs of all the items we want to restore, then create one giant context menu for all of them. This is the programmatic equivalent of multi-selecting the items from the Recycle Bin and then right-clicking. We then invoke the undelete verb on the entire group.

    Okay, so now suppose you want to restore the files, but instead of restoring them to their original locations, you want to restore them to a special folder. Like, say, C:\Files I deleted while I was drunk.¹ No problem. We just need a different block to snap into: The drag/drop block.

    void DropOnRestoreFolder(IDataObject *pdto)
    {
     IDropTarget *pdt;
     if (SUCCEEDED(GetUIObjectOfFile(NULL,
            L"C:\\Files I deleted while I was drunk",
            IID_PPV_ARGS(&pdt)))) {
      POINTL pt = { 0, 0 };
      DWORD dwEffect = DROPEFFECT_MOVE;
      if (SUCCEEDED(pdt->DragEnter(pdto, MK_LBUTTON,
                                   pt, &dwEffect))) {
       dwEffect &= DROPEFFECT_MOVE;
       if (dwEffect) {
        pdt->Drop(pdto, MK_LBUTTON, pt, &dwEffect);
       } else {
        pdt->DragLeave();
       }
      }
      pdt->Release();
     }
    }
    

    And now it's just a matter of snapping out the undelete block and snapping in the drag/drop block.

    int __cdecl _tmain(int argc, PTSTR *argv)
    {
     HRESULT hr = CoInitialize(NULL);
     if (SUCCEEDED(hr)) {
      IShellFolder2 *psfRecycleBin;
      hr = BindToCsidl(CSIDL_BITBUCKET, IID_PPV_ARGS(&psfRecycleBin));
      if (SUCCEEDED(hr)) {
       IEnumIDList *peidl;
       hr = psfRecycleBin->EnumObjects(NULL,
         SHCONTF_FOLDERS | SHCONTF_NONFOLDERS, &peidl);
       if (hr == S_OK) {
        // in a real program you wouldn't hard-code a fixed limit
        PITEMID_CHILD rgpidlItems[100];
        UINT cpidlItems = 0;
        PITEMID_CHILD pidlItem;
        while (peidl->Next(1, &pidlItem, NULL) == S_OK) {
         if (WantToRestoreThisItem(psfRecycleBin, pidlItem) &&
             cpidlItems < ARRAYSIZE(rgpidlItems)) {
          rgpidlItems[cpidlItems++] = pidlItem;
         } else {
          CoTaskMemFree(pidlItem);
         }
        }
        // restore the items we collected
        if (cpidlItems) {
         IDataObject *pdto;
         hr = psfRecycleBin->GetUIObjectOf(NULL, cpidlItems,
                         (PCUITEMID_CHILD_ARRAY)rgpidlItems,
                         IID_IDataObject, NULL, (void**)&pdto);
         if (SUCCEEDED(hr)) {
          DropOnRestoreFolder(pdto);
          pdto->Release();
         }
         for (UINT i = 0; i < cpidlItems; i++) {
          CoTaskMemFree(rgpidlItems[i]);
         }
        }
       }
       psfRecycleBin->Release();
      }
      CoUninitialize();
     }
     return 0;
    }
    

    Footnotes

    ¹ If being drunk isn't your thing, then substitute some other form of impaired judgment.

  • The Old New Thing

    Windows 7 no longer tries to guess application size and other information

    • 34 Comments

    Back in the old days, if an application did not provide information like its Estimated Size or Install Date, then the Add/Remove Programs control panel tried to guess the values by searching your hard drives for files and directories that bore a superficial resemblance to the application. (Note that I use the word drives in the plural. It actually searched all of your drives, because some applications may have been installed to secondary drives. I can imagine the bug report that led to this extended search. "I installed LitWare 2000 to my D: drive, and the Add/Remove Programs control panel shows no information for it. You should have found it on my D: drive, because (like many power users) I install all my applications to my D drive to maintain free space on my C drive.")

    Time passed, and the name of the Add/Remove Programs control panel changed several times. It became Add or Remove Programs in Windows XP, changing the slash to the word or. In Windows Vista, its name changed to Programs and Features, but at least the name has been stable since then.

    In Windows 7, the "search your hard drive looking for the application" fallback algorithm was removed. If an application did not provide information such as its estimated size or install location, then Windows now just says, "Well, tough on you." My guess is that the search was removed because the vast majority of applications now provide the information directly, so the extra cost of trying to hunt it down for the last few application was not worth the benefit.

    There is still one tiny thing that Windows will guess: The installation date. But it doesn't have go hunting through your hard drive to guess that. It merely infers it from the last-modified date on the uninstall metadata itself!

Page 377 of 441 (4,406 items) «375376377378379»