As part of an application, I've added a shortcut bar for relevantly used programs. I have it set up to check if the application is open already, and if it is to switch to it instead of opening another instance. This works fine for programs like calc and notepad, but all the MS Office programs open another instance no matter what, and I'd like them not to.
Office Button
private void wordButton_Click(object sender, RoutedEventArgs e)
{
try
{
SwitchToProcess("winword.exe", "C:\\Program Files (x86)\\Microsoft Office\\Office14\\winword.exe");
}
catch (Win32Exception)
{
try
{
SwitchToProcess("winword.exe", "C:\\Program Files\\Microsoft Office\\Office14\\winword.exe");
}
catch (Win32Exception)
{
}
}
}
Notepad Button
private void notepadLink_Click(object sender, RoutedEventArgs e)
{
SwitchToProcess("notepad.exe");
}
Methods
private void SwitchToProcess(string name)
{
Process[] procs = Process.GetProcesses();
if (procs.Length != 0)
{
for (int i = 0; i < procs.Length; i++)
{
try
{
if (procs[i].MainModule.ModuleName == name)
{
IntPtr hwnd = procs[i].MainWindowHandle;
ShowWindowAsync(hwnd, SW_RESTORE);
SetForegroundWindow(hwnd);
return;
}
}
catch
{
}
}
}
else
{
MessageBox.Show("No process running");
return;
}
launchApp.StartInfo.FileName = name;
launchApp.Start();
}
private void SwitchToProcess(string name, string path)
{
Process[] procs = Process.GetProcesses();
if (procs.Length != 0)
{
for (int i = 0; i < procs.Length; i++)
{
try
{
if (procs[i].MainModule.ModuleName == name)
{
IntPtr hwnd = procs[i].MainWindowHandle;
ShowWindowAsync(hwnd, SW_RESTORE);
SetForegroundWindow(hwnd);
return;
}
}
catch
{
}
}
}
else
{
MessageBox.Show("No process running");
return;
}
launchApp.StartInfo.FileName = path;
launchApp.Start();
}
The reason for the two different directories in the Office button is a simple way of ensuring that x86/x64 install locations don't cause a problem. The computers I'm developing this for have the registry locked out, so I can't check which one is correct.
In your SwitchToProcess method you need to change the following line:
if (procs[i].MainModule.ModuleName == name)
To this:
if (procs[i].MainModule.ModuleName.ToLower() == name.ToLower())
Reason being that the process name for Word is WINWORD.EXE and you are passing the parameter value in lowercase.
As an aside you could change your wordButton_Click event to this:
private void wordButton_Click(object sender, EventArgs e)
{
if (Environment.Is64BitOperatingSystem)
{
SwitchToProcess("winword.exe", "C:\\Program Files (x86)\\Microsoft Office\\Office14\\winword.exe");
}
else
{
SwitchToProcess("winword.exe", "C:\\Program Files\\Microsoft Office\\Office14\\winword.exe");
}
}
Ok, so after digging a little deeper into Google, I finally figured out the problem. I had the program targeted for an x86 processor, and am running it on x64. Switched the target to AnyCPU and it works perfectly. Apparently it was catching an error of Only part of a ReadProcessMemory or WriteProcessMemory request was completed, but since I had the try-catch block in there it wasn't displaying the error until I used StepInto repeatedly on the 77-item processor array. Thanks everyone for the help though.
Related
I am trying to efficiently open-close-reopen a power bi file (.pbix) from a WPF application button click. My method starts by creating a process that opens the pbix file then kills the process when the file is closed and then when the button is clicked again creates a new process to re-open the file.
Kindly find below the code I use to execute the steps above.
namespace TestApp
{
public class MainWindowViewModel : INotifyPropertyChanged
{
public int CheckFileIsOpen(string filenamepath)
{
try
{
using FileStream fs = new FileStream(filenamepath, FileMode.Open, FileAccess.ReadWrite, FileShare.None);
return 0;
}
catch (Exception)
{
WindowEffect = new BlurEffect();
Mouse.OverrideCursor = null;
bool? Result = new CustomMessageBox($"File: {filenamepath.Split(#"\").Last()} in use!\nClose it and try again.", "File used by another process", MessageType.Error, MessageButtons.Ok).ShowDialog(); //this is a MessageBox object
if (Result.Value)
{
WindowEffect = null;
return 1;
}
else
{
WindowEffect = null;
return 2;
}
}
}
private void OpenOnlineLocally(bool open_local)
{
Process p = new Process();
string copy_name = "File_Copy.pbix";
string path = AppDomain.CurrentDomain.BaseDirectory; //the directory the .exe file runs.
try
{
Mouse.OverrideCursor = Cursors.Wait;
if (open_local == true)
{
int IsPBIFileOpen = CheckFileIsOpen($#"{path}{copy_name}");
if (new[] { 1, 2 }.Contains(IsPBIFileOpen))
{
return;
}
//Open the file using the system process
p.StartInfo = new ProcessStartInfo($"{path}{copy_name}")
{
UseShellExecute = true
};
p.Start();
}
else
{
OpenUrl("https://app.powerbi.com/...");
}
}
finally
{
if (p.HasExited) { p.Kill(); } //kill the process if the user closed the .pbix file
}
}
public ICommand ExportPowerBICommand //binded to a button click command in xaml
{
get { return new DelegateCommand<object>(FuncExportPowerBI); }
}
public void FuncExportPowerBI(object parameter)
{
Mouse.OverrideCursor = Cursors.Wait;
try
{
OpenOnlineLocally(true);
}
finally
{
Mouse.OverrideCursor = null;
}
}
}
}
The above code generates this error in the finally statement:
System.InvalidOperationException: 'No process is associated with this object.'
Some notes after experimentation:
The process should be killed when the user closes the .pbix file (i.e. clicks the X icon on top right corner of the desktop app). If the process is not killed and the user re-clicks the button to re-open the file then I get an error that the file is already opened and used by another process.
I prefer to avoid a solution that uses process.WaitForExit(), for two reasons. First, the application freezes while the file is used by the user. Second, it takes a couple of seconds for the desktop to realize that the process has exited so it can kill() it (not time efficient).
Since you're running .NET 5, there's an asynchronous method Process.WaitForExitAsync(). Async operation will not block the UI.
I've made the changes to two methods
private async Task OpenOnlineLocally(bool open_local)
{
Process p = new Process();
string copy_name = "File_Copy.pbix";
string dir = AppDomain.CurrentDomain.BaseDirectory; //the directory the .exe file runs.
string path = Path.Combine(dir, copy_name);
try
{
if (open_local == true)
{
int IsPBIFileOpen = CheckFileIsOpen(path);
if (IsPBIFileOpen != 0)
{
return;
}
//Open the file using the system process
p.StartInfo = new ProcessStartInfo(path)
{
UseShellExecute = true
};
p.Start();
await p.WaitForExitAsync();
}
else
{
OpenUrl("https://app.powerbi.com/...");
}
}
finally
{
if (!p.HasExited) { p.Kill(); } //kill the process if the user closed the .pbix file
}
}
public async void FuncExportPowerBI(object parameter)
{
Mouse.OverrideCursor = Cursors.Wait;
try
{
await OpenOnlineLocally(true);
}
catch (Exception ex)
{
Debug.WriteLine(ex.Message); // handle possible exception here
}
Mouse.OverrideCursor = null;
}
I launch a program located on one of my file servers. After launching the program it shows as an Open file in Computer Management.
Is there a way I can close this open file while my program runs so it doesn't show up in Computer Management?
My code is below. I'd be happy to take suggestions on improving my program, but I'm really just looking for a solution to stop all these Open Files from appearing.
Program.cs -- starts the program, handles logic to launch my application
using System;
using System.Windows.Forms;
using System.Diagnostics;
namespace IT_TaskbarApp
{
static class Program
{
/// <summary>
/// The main entry point for the application.
/// </summary>
[STAThread]
static void Main()
{
string programName = "TEG System Helper";
//Process[] proc = Process.GetProcessesByName(programName);
if (Process.GetProcessesByName(programName).Length == 1)
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new Primary());
}
else
{
foreach (Process p in Process.GetProcessesByName(programName))
{
if (Process.GetCurrentProcess().Id != p.Id)
{
p.CloseMainWindow();
p.Close();
p.Kill();
p.Dispose();
}
}
Main();
}
}
}
}
Primary.cs -- creates an icon in the system icons which I can use to send notifications and easily access utilities within our organization
using System;
using System.ComponentModel;
using System.Diagnostics;
using System.Drawing;
using System.IO;
using System.Reflection;
using System.Threading;
using System.Windows.Forms;
namespace IT_TaskbarApp
{
public partial class Primary : Form
{
private NotifyIcon notifyIcon;
private ContextMenu contextMenu;
private MenuItem[] menuItem = new MenuItem[8];
private IContainer components;
//private Boolean SendNotices = true;
private DateTime startTime = DateTime.Now;
private DateTime currentTime;
private Icon tegroupIcon = new Icon(Assembly.GetExecutingAssembly().GetManifestResourceStream("IT_TaskbarApp.Src.tegroup.ico"));
private string prevNotification = "";
private bool isRunning = true;
private BackgroundWorker bgNotify = new BackgroundWorker();
private const string programName = "TEG System Helper";
public Primary()
{
this.FormClosing += Primary_FormClosing; //remove ghost icon in taskbar
ForeColor = Color.Blue;
BackColor = Color.Green;
components = new Container();
contextMenu = new ContextMenu();
for (int i = 0; i < menuItem.Length; i++)
{
menuItem[i] = new MenuItem();
menuItem[i].Index = i;
menuItem[i].Click += new EventHandler(LoadProcess);
}
menuItem[0].Text = programName;
menuItem[1].Text = "Knowledge Base";
menuItem[2].Text = "Policies";
menuItem[3].Text = "Feedback";
menuItem[4].Text = "Global Shop Search";
menuItem[5].Text = "-";
menuItem[6].Text = "Submit Ticket";
menuItem[7].Text = "Send Email";
//initialize contextMenu
contextMenu.MenuItems.AddRange(menuItem);
// Create the NotifyIcon.
notifyIcon = new NotifyIcon(components)
{
Icon = tegroupIcon,
BalloonTipIcon = new ToolTipIcon(),
ContextMenu = contextMenu, //the menu when right clicked
Text = programName,
Visible = true,
BalloonTipTitle = programName,
};
notifyIcon.DoubleClick += new EventHandler(Icon_DoubleClick);
InitializeComponent();
bgNotify.WorkerSupportsCancellation = true;
bgNotify.WorkerReportsProgress = true;
bgNotify.DoWork += NotifyUser;
bgNotify.ProgressChanged += SendNotice;
//bgNotify.RunWorkerCompleted += BgNotify_RunWorkerCompleted; //enable this to perform an action when the thread dies
bgNotify.RunWorkerAsync();
//Thread tNotify = new Thread();
}
#region SupportedFunctions
private void NotifyUser(object Sender, EventArgs e)
{
Console.WriteLine("enter");
while (isRunning)
{
currentTime = DateTime.Now;
#region DisplayCurrentTime
if (currentTime.Hour < 10 || currentTime.Minute < 10)
{
if (currentTime.Hour < 10)
{
if (currentTime.Minute < 10)
{
Console.WriteLine("0{0}:0{1}", currentTime.Hour, currentTime.Minute);
}
else
{
Console.WriteLine("0{0}:{1}", currentTime.Hour, currentTime.Minute);
}
}
else
{
if (currentTime.Minute < 10)
{
Console.WriteLine("{0}:0{1}", currentTime.Hour, currentTime.Minute);
}
}
}
else
{
Console.WriteLine("{0}:{1}", currentTime.Hour, currentTime.Minute);
}
#endregion
FileStream fs = new FileStream("\\\\te-admin\\public\\TaskbarNotices.txt", FileMode.Open);
StreamReader sr = new StreamReader(fs);
string noticeText = sr.ReadToEnd();
sr.Close();
fs.Close();
if (noticeText != "" && noticeText != prevNotification)
{
prevNotification = noticeText;
bgNotify.ReportProgress(1);
}
else
{
bgNotify.ReportProgress(2);
}
Console.WriteLine("Inner Text: {0} TOF: {1}", noticeText, noticeText != "");
Thread.Sleep(10000);
}
}
private void SendNotice(object Sender, ProgressChangedEventArgs e)
{
if (e.ProgressPercentage == 1)
{
Console.WriteLine("notification sent");
this.notifyIcon.BalloonTipText = prevNotification;
this.notifyIcon.ShowBalloonTip(1500);
}
}
private void LoadProcess(object Sender, EventArgs e)
{
if (Sender is MenuItem)
{
MenuItem tempMenu = Sender as MenuItem;
string ProgramTag = "http://";
switch (tempMenu.Index)
{
case 0: //home page
ProgramTag += "teg";
break;
case 1: //docviewer
ProgramTag += "teg/docViewer";
break;
case 2: //policies
ProgramTag += "teg/Policies";
break;
case 3: //feedback
ProgramTag += "teg/Feedback";
break;
case 4: //inventory search
ProgramTag = "http://searchglobalshop/inventory/index.aspx";
break;
case 6: //submit ticket
ProgramTag = "https://timberlandgroup.on.spiceworks.com/portal/tickets";
break;
case 7: //send email
string sendto = "admin#tewinch.com";
string emailSubject = "Assistance Request";
string emailBody = "";
string mailto = string.Format("mailto:{0}?Subject={1}&Body={2}", sendto, emailSubject, emailBody);
ProgramTag = Uri.EscapeUriString(mailto);
break;
}
/*
Try to launch the choice the user made with the default processing method.
Should the default method fail we try to control how the process is run.
We open internet explorer and then we show them what to do otherwise.
*/
#region LaunchSelectedProcess
try
{
if (ProgramTag != "" && ProgramTag != "http://")
Process.Start(ProgramTag);
}
catch (System.ComponentModel.Win32Exception)
{
try
{
if (ProgramTag.StartsWith("http://") || ProgramTag.StartsWith("https://"))
Process.Start("iexplore.exe", ProgramTag);
}
catch (System.ComponentModel.Win32Exception)
{
Process.Start("control.exe", "/name Microsoft.DefaultPrograms");
string message = "";
if (tempMenu.Index <= 6)
{
message = "You must have a default browser set\n\tClick [Set Default Program]\n";
if (Environment.OSVersion.ToString().Contains("NT 10.")) //windows 10
{
message += "\tUnder [Web Browser] Edge is currently set as default\n\tClick on Microsoft Edge\n\tSelect the browser you use";
}
else //windows 7 -- "NT 6.1")
{
message += "Select the browser you use\n\tClick [Set this program as default]";
}
}
else
{
if (Environment.OSVersion.ToString().Contains("NT 10.")) //windows 10
{
message += "Please setup a default email application";
}
}
message += "\n\nIf this issue persists please contact your Administrator.\nPhone: 519-537-6262\nEmail: admin#tewinch.com";
MessageBox.Show(message, "Application Warning", MessageBoxButtons.OK, MessageBoxIcon.Information);
//if ( == DialogResult.OK)
}
}
#endregion
}
}
private void Icon_DoubleClick(object Sender, EventArgs e)
{
Process.Start("http://teg");
}
#endregion
#region BuiltFunctions
private void Primary_FormClosing(object sender, FormClosingEventArgs e)
{
notifyIcon.Icon = null;
notifyIcon.Dispose();
isRunning = false;
Dispose(true);
}
private void InitializeComponent()
{
this.SuspendLayout();
//
// Primary
//
this.Icon = tegroupIcon;
this.BackgroundImageLayout = System.Windows.Forms.ImageLayout.None;
this.CausesValidation = false;
this.ClientSize = new System.Drawing.Size(120, 23);
this.ControlBox = false;
this.Enabled = false;
this.MaximizeBox = false;
this.MinimizeBox = false;
this.Name = "Primary";
this.Opacity = 0D;
this.ShowIcon = false;
this.ShowInTaskbar = false;
this.SizeGripStyle = System.Windows.Forms.SizeGripStyle.Hide;
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen;
this.WindowState = System.Windows.Forms.FormWindowState.Minimized;
this.ResumeLayout(false);
}
protected override void Dispose(bool disposing)
{
// Clean up any components being used.
if (disposing)
if (components != null)
components.Dispose();
base.Dispose(disposing);
}
#endregion
}
}
Instead of cancelling the program on start up, I kill the other running instances of the program. The idea is that if any issues arise with the program I just launch another instance and resolve the issues. Right now not much can go wrong but we will be developing this program to complete many more tasks in the future.
The only area I can see which would keep a file open is when I pull an Embedded Resource tegroup.ico I was looking to see if I missed something while opening this, but I couldn't see a way to close the ManifestResourceStream after reading it in.
Any tips/suggestions would be wonderful but again, I really just want to know if there's a way I can close these Open Files
Example below
Open File after app launch
I might be trying to solve something which is a known result of using Application.Run() if this is the case then please suggest alternatives I can use. My other ideas would be loading the program into memory and launching it locally, using the .exe on the server as a starting point for this method though.
I believe that Windows doesn't load an entire executable into ram. It isn't just about files from the resource section of a PE file. Portions of the exe are only loaded when referenced and even after loading everything there is to load, Windows will maintain an open file handle until the process closes. Trying to close that handle yourself is a bad idea.
c/c++ allow a "SWAPFILE" flag to be specified that tells windows to put the whole thing into the page file but I don't know how you would do that with c# and I don't know if that would even stop windows from keeping the handle open anyways (I doubt it).
If this is truly important, iffin' I were your exe... I would:
Check a mutex for an existing running instance, exit if exist
Check where I was running from.
If running from temp, set a mutex that I am running and just run.
If not running from temp, copy myself to %temp%, start that copy, and exit.
Good luck.
This is a web application
I have 2 pc's: A: 192.168.1.200 and B: 192.168.1.201, I want copy from A to B, this code working is single pc, but it's not working in network.
protected void Button1_Click(object sender, EventArgs e)
{
string sourcePath = #"D:\Source\";
string[] filePaths = Directory.GetFiles(sourcePath, "*.txt");
foreach (string a in filePaths)
{
CopyFiles(a, a.Replace("D:\\Source\\", "D:\\Source1\\New\\"));
//CopyFiles(a, a.Replace("D:\\Source\\", "192.168.1.201\\Source1\\New\\"));
}
}
private bool CopyFiles(string Source, string Destn)
{
try
{
if (File.Exists(Source) == true)
{
File.Copy(Source, Destn);
return true;
}
else
{
Response.Write("Source path . does not exist");
return false;
}
}
catch (FileNotFoundException exFile)
{
Response.Write("File Not Found " + exFile.Message);
return false;
}
catch (DirectoryNotFoundException exDir)
{
Response.Write("Directory Not Found " + exDir.Message);
return false;
}
catch (Exception ex)
{
Response.Write(ex.Message);
return false;
}
}
Try with:
CopyFiles(a, a.Replace("D:\\Source\\", "\\192.168.1.201\\Source1\\New\\"));
You also need to make sure that the Source1 folder is shared on B, and that you have write access to it.
Did you create the Windows share "Source1" on the receiver machine?
If you did, I would try to mount it on your sender machine and change the code to:
CopyFiles(a, a.Replace("D:\\Source\\", "\\\\192.168.1.201\\Source1\\New\\"));
You have to be allowed to write on the target machine. A work round can be used here, you can make a virtual drive pointing to a network place e.g Z:. Now you can use local notation. But before anything, get sure about permissions on the remote pc.
I was reading this article (Can't delete a file using threads) about my problem but things are getting difficult to me.
My problem is really simple, I just want to delete this old file, if I start the method "dlMoveNovaVersao" normally the file is deleted but if I put this on a thread (like bellow) I got "You are not allow". Someone knows what's the problem? (I wanna use thread).
private void verificaVersaoSupervisor_Tick(object sender, EventArgs e)
{
Thread threadConexao = new Thread(threadVerificaConexao);
threadConexao.Start();
}
public void threadVerificaConexao()
{
try
{
Dns.GetHostEntry("www.google.com.br");
if (verificaVersao())
{
try
{
verificaKillSupervisor();
dlMoveNovaVersao();
Application.Exit();
}
catch (Exception)
{ }
}
else
{
Application.Exit();
}
}
catch (Exception)
{ }
}
public void dlMoveNovaVersao()
{
WebClient webClient = new WebClient();
webClient.DownloadFile("Anywebsite", #"c:\temp\supervisor.exe);
try
{
File.Delete(#"c:\Test\supervisor.exe); //This file is always there!
}
catch (Exception err)
{
MessageBox.Show(err.Message);
}
Just discribe the purpose, My program (Supervisor Starter) check on website if I have an old version of "Supervisor" running (using XML), If it's true my "Supervisor Starter" verify if there is a process called "Supervisor" running and kill it after that "Supervisor Starter" download the new version and run it. (The program is small and the update don't take more then 4 seconds).
The problem start when my "Supervisor Starter" try delete the old version of my program. If I use thread I receive "I haven't permission to access the file", if I use the same method on Form class the file is deleted.
I suspect that you're running the thread while the file is in use. When the thread runs, it runs in parallel with the current thread. Have you ensured that that file is closed?.
Otherwise I think that the thread maybe being created with a credentials that are not yours. But I'm pretty sure this is not the case.
See if this is different for each case
catch (Exception err)
{
MessageBox.Show("User {0}. Message {1}",
System.Security.Principal.WindowsIdentity.GetCurrent().Name,
err.Message);
}
This is my functions for deleting files in threads if the files are in used
private static void Delete(System.IO.FileInfo file)
{
if (file.Exists)
{
int Attempt = 0;
bool ShouldStop = false;
while (!ShouldStop)
{
if (CanDelete(file))
{
file.Delete();
ShouldStop = true;
}
else if (Attempt >= 3)
{
ShouldStop = true;
}
else
{
// wait one sec
System.Threading.Thread.Sleep(1000);
}
Attempt++;
}
}
}
private static bool CanDelete(System.IO.FileInfo file)
{
try
{
//Just opening the file as open/create
using (FileStream fs = new FileStream(file.FullName, FileMode.OpenOrCreate))
{
//If required we can check for read/write by using fs.CanRead or fs.CanWrite
}
return false;
}
catch (IOException ex)
{
//check if message is for a File IO
string __message = ex.Message.ToString();
if (__message.Contains("The process cannot access the file"))
return true;
else
throw;
}
}
Greetings,
I've attempted to shrink the code down as much as I can. Basically we use the Adobe Acrobat standard 6 Com libraries to print. It works as well as any batch pdf print solution that I've seen, but I can't seem to make it work with Citrix. Citrix appears to remap the netowrk printing locations and I can't seem to make it work with out existing solution. All the code runs on Citirix it just doesn't print anything. When it runs locally it runs and prints just fine.
Any help would be greatly appreciated,
Thank you,
Brian
private void btnTest_Click(object sender, EventArgs e)
{
try
{
Cursor.Current = Cursors.WaitCursor;
PrintDialog PrintDialog1 = new PrintDialog();
PrintDialog1.ShowDialog();
CAcroAVDoc acroDoc = null;
const string fileName = #"SomeFile.pdf";
var acroApp = instantiateAcrobat();
acroDoc = GetAcrobatAVDoc();
acroDoc.Open(fileName, "");
CAcroPDDoc pdDoc = (CAcroPDDoc)acroDoc.GetPDDoc();
int numPages = pdDoc.GetNumPages();
UnManagedMethods.SetDefaultPrinter(PrintDialog1.PrinterSettings.PrinterName);
acroDoc.PrintPagesSilent(0, numPages - 1, 2, 1, 0);
MessageBox.Show("Printed!!");
}
catch (Exception ex)
{
MessageBox.Show(ex.Message);
}
}
private static CAcroAVDoc GetAcrobatAVDoc()
{
Type acroApp = Type.GetTypeFromProgID("AcroExch.AVDoc", true);
return (CAcroAVDoc)Activator.CreateInstance(acroApp);
}
private static CAcroApp instantiateAcrobat()
{
killAllAcrobatProcesses();
Type acroApp = Type.GetTypeFromProgID("AcroExch.App", true);
return (CAcroApp)Activator.CreateInstance(acroApp);
}
private static void killAllAcrobatProcesses()
{
Process[] acrobatProcesses = Process.GetProcessesByName("Acrobat");
if (acrobatProcesses.Length > 0)
{
foreach (Process process in acrobatProcesses)
{
process.Kill();
}
}
Process[] acroRd32Processes = Process.GetProcessesByName("AcroRd32");
if (acroRd32Processes.Length > 0)
{
foreach (Process process in acroRd32Processes)
{
process.Kill();
}
}
}
If anyone is interested this was fixed by installing the printer on the citrix box outside of Citrix. The trick was to remote desktop into the citrix box install the printer get the application working so it will print. Then when you log on through citrix printing worked just fine. It seems very obvious in retrospect.