Project Scaffold

Auf dem Developer Open Space 2014 in Leipzig stellten A. Groß und S. Forkmann im Rahmen einer Vorführung des Paketmanagers Paket das Projekt ProjectScaffold vor.

Die Idee ist, ähnlich wie bei Rails mit einem kleinen kommandozeilenbasierten Tool ein paar benötigte Daten abzufragen, um daraus eine neue Visual Studio Umgebung zu erzeugen. Diese Umgebung basiert auf dem Buildmanager FAKE und dem Paketmanager Paket. Man kann sie direkt mit einem Github-Projekt verknüpfen, muss aber nicht. Heraus kommt ein Skelett, bestehend aus einem kleinen Projekt inklusive Dokumentation, dass man per build.cmd direkt bauen, testen und deployen kann.

Da man eigenes Projekt sowieso zur Zeit noch ältere Funktionen von FAKE benutzt, nehme ich die Gelegenheit zum Anlass um umzusteigen.

Installation

Die Installationsidee ist erst einmal gewöhnungsbedürftig: man klont das Github-Projekt:

git clone https://github.com/fsprojects/ProjectScaffold.git

Danach ruft man erstmalig die build.cmd auf, die auch später per Doppelklick das Projekt bauen wird. Beim ersten Aufruf werden bereits alle benötigten Softwarepakete mittels Paket heruntergeladen. Der initiale Build dauert dementsprechend etwas länger. Es werden einige Information zu dem Projekt abgefragt, wie etwa Name, eine kurze Beschreibung, eine längere Beschreibung, Autor und einige Dinge mehr, die im Build-Skript hinterlegt werden. Danach stürzt das Skript beim Ausführen der mitgelieferten Tests ab – das liegt allerdings daran, dass ich den Batch unter der Cygwin-Bash gestartet hab. Startet man ihn in einem Konsolenfenster, läuft er sauber durch.

 

Bestandsaufnahme

Uns wurde also ein Projektrahmen erzeugt – aber was fangen wir nun damit an?

Beginnen wir mit den Git-Dateien. Wer sich von Github bereits eine .gitignore hat zusammenstellen lassen, kann diese natürlich weiterverwenden. Die hier mitgelieferte ist jedenfalls um einiges umfangreicher als meine bisherige. Die .gitattributes war bei mir bisher leer, so dass ich auch diese Daten gerne übernehme.

Die README.md hatte ich bereits mit einer kleinen Beschreibung gefüllt, der neue Inhalt wird also verworfen. Die RELEASE_NOTES.md dagegen nutze ich als Template und fülle die erste Versionszeile. Eine LICENSE.txt hat mir bislang auch gefehlt.

Die für die Mono-Version meines Projekts zuständigen Dateien build.sh sowie .travis.yml lasse ich weg. Durch die WPF-lastigkeit der Programme ist eine Kompilierung unter dieser Umgebung zumindest fragwürdig.

Die appveyor.yml für den gleichnamigen Online-Buildserver wird übernommen, die build.cmd ebenso. Letztere wird allerdings so geändert, dass sie wie bisher auf der Kommandozeile nachfragt, ob ich einen weiteren Build starten möchte:

@echo off
cls

.paket\paket.bootstrapper.exe
if errorlevel 1 (
  exit /b %errorlevel%
)

.paket\paket.exe restore
if errorlevel 1 (
  exit /b %errorlevel%
)

IF NOT EXIST build.fsx (
  .paket\paket.exe update
  packages\FAKE\tools\FAKE.exe init.fsx
)

:Build
packages\FAKE\tools\FAKE.exe build.fsx %*

rem Bail if we're running a TeamCity build.
if defined TEAMCITY_PROJECT_NAME goto Quit
if defined APPVEYOR goto Quit

rem Loop the build script.
set CHOICE=nothing
echo (Q)uit, (Enter) runs the build again
set /P CHOICE= 
if /i "%CHOICE%"=="Q" goto :Quit

GOTO Build

:Quit
exit /b %errorlevel%

Die Dateien paket.dependencies und paket.lock werden ebenso übernommen. In ihnen sind die bisherigen Abhängigkeiten des Projekts vermerkt.

Als Schaltstelle der zu erzeugenden Solutions, der auszuführenden Test, der zu erstellenden Dokumente sowie die zusammenzustellenden Deployments wird später die build.fsx  die größten Änderungen erfahren.

Ich füge noch das Verzeichnis .paket zur Quellcodeverwaltung hinzu und kann bereits den ersten Versuch starten – dem Skript fehlen NUnit-Tests, und die AssemblyInfo.cs werden in den falschen Unterverzeichnissen erzeugt. Da ich noch nichts an meine Solutions angepasst habe, ist das ein zufriedenstellendes Ergebnis.

 

Anpassungen

Die build.fsx ist in dokumentierte Abschnitte unterteilt. So kann ich die zusammengehörenden Elemente bestens lokalisieren. Mein Projekt benötigt beispielsweise keine NuGet-Unterstützung, also kann ich im selben Step auch die Tags entfernen, die das Init-Skript angelegt hat. Letztendlich werde ich aber fast alle Targets entfernen, da ich auch (noch?) keine Dokumentation habe, die zu erstellen wäre. Auch das automatische Committen auf GitHub entfällt. Dementsprechend kann ich natürlich auch die Abhängigkeiten aus der paket.dependencies löschen, wie z.B. NUnit oder Octokit.

ProjectScaffold schlägt vor, die Solutions direkt im Root-Pfad des Projekts abzulegen. Der Vorteil, der sich bietet: die Paket-Abhängigkeiten von allen enthaltenen Solutions und die des Build-Prozesses selbst werden in einem einzigen packages-Unterordner vereint. Ich werde den Vorschlag insofern aufgreifen, dass ich die Solution aus dem Ordner src/BackOffice nach src verschiebe. So kann ich die Pakete der verschiedenen Solutions vereinen, aber dennoch getrennt halten von denen des Build-Systems.
Wahrscheinlich wäre es besser, dennoch beide packages-Ordner zusammenzufassen. Dadurch könnten alle benötigten Abhängigkeiten zu Beginn des Buildskripts nachgeladen werden, so muss ich sie zusätzlich zur Quellcodeverwaltung hinzufügen. Ich beruhige mein Gewissen damit, dass ich das bisher auch so handhabe und merke mir vor, die andere Variante in Zukunft wenigstens einmal auszuprobieren.

 

Versionsinfo

Der von ProjectScaffold angedachte Weg, die aktuelle Versionsnummer in die Assemblys einzupflegen, sieht vor, alle .csproj-Dateien zu finden und ihre AssemblyInfo.cs neu zu generieren. Hier bin ich mir noch nicht sicher, ob eventuell vorhandene Dateien überschrieben werden. Das hatte in früheren Versionen von FAKE zur Folge, dass man pro Projekt die GUID in der build.fsx angeben musste. Da es nun eine eigene Funktion ist, die die AssemblyInfo.cs erzeugt, sollte dem eigentlich nicht mehr so sein.
Ein neues Problem ergibt sich dadurch, dass genCSAssemblyInfo davon ausgeht, dass das Projekt direkt unter src/ProjektName zu finden ist. Das ist bei mir jedoch nicht der Fall, die Projekte liegen unter src/BackOffice/Projektname. Eventuell passe ich das Skript hier noch an, so dass der Pfad zur AssemblyInfo.cs korrekt zusammengesetzt wird.

Solange behelfe ich mir mit meiner bisherigen Methode, auf die auch meine Studio-Projekte ausgelegt sind: die AssemblyInfo.cs wurde zweigeteilt. In der Datei mit diesem Namen befinden sich die projektspezifischen Einstellungen wie GUID, Description und Title. Die Versionsnummer befindet sich in der Datei src/VersionInfo.cs. Diese wird in jedes Projekt verlinkt und muss dadurch nur einmal erzeugt werden.

Target "SetAssemblyInfo" (fun _ ->

    [Attribute.Product project
     Attribute.Version currentVersion
     Attribute.Company "Slesa Solutions"
     Attribute.Copyright "Copyright ©  2012"
     Attribute.Trademark "GPL V2"]
     |> CreateCSharpAssemblyInfo "./src/VersionInfo.cs"
)

Der Nachteil liegt auf der Hand: bei jedem neuen Projekt müssen einmal die doppelten Einträge aus der lokalen AssemblyInfo.cs entfernt werden, zusätzlich muss der Link zur VersionInfo.cs hinzugefügt werden. Das automatische Generieren der Dateien pro Projekt ist eindeutig vorzuziehen.

 

Buildserver

Warum ich den ganzen Spaß überhaupt auf mich genommen habe – AppVeyor hat stets die Buildnummer als Versionsnummer interpretiert. Dadurch kam es zur Fehlermeldung beim Erzeugen des Setup-Skripts – eine Version 17 ist bei WiX verboten.
Mir ist nicht ganz klar, was genau sich durch das neue Skript alles geändert hat, aber offensichtlich werden die Werte nun richtig interpretiert, das Skript läuft durch. Hurra!

 

Fazit

Ich denke, ProjectScaffold kommt seinem Ruby-Vorbild überaus nahe. Ohne Installation einer Komponente erzeugt sich durch das Abrufen eines GitHub-Projektes ein neues, eigenes Projekt wie von selbst. Das sowieso benötigte Buildskript fragt beim ersten Aufruf die benötigten Daten ab. Es werden jede Menge Build-Targets erzeugt, die entweder direkt so benutzt werden können, wie zB das erzeugte NuGet-Paket, oder man entfernt die nicht benötigten Targets. Das Ergebnis lässt sich ohne weiteres auf einem Buildserver wie TeamCity oder AppVeyor starten und liefert ein erstes Ergebnis.

Was man hierbei auch nicht unterschätzen darf: die ganze Welt von FAKE steckt hier unter der Haube. Ein zusätzliches ClickOnce-Setup, andere Prozesse aufrufen, … das Buildsystem hat so mannigfaltige Möglichkeiten, die es zu erkunden gilt.

Im Zusammenspiel mit dem neuen Paketmanager Paket entsteht so ein Ökosystem, auf dem man mit Sicherheit aufbauen kann.

Veröffentlicht unter .NET, Build-Tools, C#, F#, FAKE | Verschlagwortet mit , , | Hinterlasse einen Kommentar

Fluent Bash

 

Das Buch

Mein aktuelles Buch ist Fluent Forever von Gabriel Wyner. Es handelt davon, wie man sich eine Sprache so aneignet, dass man sie innerhalb kurzer Zeit flüssig sprechen kann und dabei die Vokabeln so lernt, dass man sie nicht so leicht vergisst. Er setzt dabei auf das Spaced Repetition System, eine Ausprägung des alten Karteikartensystems mit angepassten Abfragezeiten.

Seine favorisierte digitale Version des Karteikastens hört auf den Namen Anki und ist für Windows, Mac, Linux, Android sowie iOS verfügbar. Beim Ausprobieren der Software kam mir der Gedanke – was wenn man etwas anderes damit lernt als Vokabeln und Grammatiken?

Hotkeys

Noch heute kenne ich ein paar der alten WordStar-Kommandos von Turbo Pascal. Ebenso ein paar Cheats aus Doom. Nur mit der Vielfalt der Vi-Kombinationen tue ich mich noch schwer. Sicher, man kann sich für jede Woche ein paar für den Einsatz vornehmen – letztendlich hat man es aber wieder einmal eilig, und ein paar mal h drücken ist doch fast genauso gut wie jetzt b nachzuschlagen…

Die Bash

Ein nicht ganz so großes Unterfangen zum Testen stellt die Bash dar. Schon lange stört es mich, mit dem Cursor die Zeile zeichenweise nach links bzw nach rechts zu wandern, eines der zuletzt benutzten Kommandos mit Hilfe von Pfeil-hoch suchen zu müssen usw., obwohl ich doch weiß, dass es auch besser geht.
Gesagt, getan. Von einer beliebigen Seite habe ich mir die Hotkeys der Bash abgetippt in entsprechende Karteikarten von Anki.

Anki

Die Besonderheit gegenüber den Vokabeln liegt nun natürlich darin, dass man sich keine tollen Fotos mit Aussprache hinterlegt wie in der von Wyner beschriebenen Variante (wobei ich das bei manchen nicht ausschließen möchte…). Statt dessen macht man sich eine Bash auf und führt das abgefragte Kommando aus:

Bash

 

Fazit

Ob diese Vorgehensweise wirklich zum Beherrschen der Hotkeys aller von mir benutzten Programme führt, kann ich nicht sagen. Aber die 20 Tastenkombinationen der Bash sind in 2 Minuten abgefragt. Diese Zeit nimmt man sich eher als immer mal wieder nach genau diesem einen Befehl zu suchen, den man jetzt gerade braucht.
Und selbst wenn es bei Vi 5 Minuten sein mögen – durch die Kombination von Wiederholung und Anwendung des gerade Gelernten sollte man seinem (hoffentlich) gesteckten Ziel näher kommen: know your tools!

Veröffentlicht unter Bash, Build-Tools, Cygwin | Verschlagwortet mit , , , | Hinterlasse einen Kommentar

Select Text on Focus

 

Das Problem

Bei Bildschirmmasken möchte man gerne das Standardverhalten zurück, dass der Text beim Durchwandern der Eingabezeilen per TAB selektiert wird. WPF setzt per Default lediglich den Cursor an die erste Zeile.

Standard

Beim Suchen im Netz stößt man meist auf die Formel

protected override void OnStartup(StartupEventArgs e) 
{ 
  EventManager.RegisterClassHandler(typeof(TextBox)
   , TextBox.GotFocusEvent
   , new RoutedEventHandler(TextBox_GotFocus)); 
  base.OnStartup(e); 
} 

void TextBox_GotFocus(object sender, RoutedEventArgs e) 
{ 
  var tb = sender as TextBox; 
  if (tb == null) return; 
  tb.Focus(); tb.SelectAll(); 
}

Das funktioniert auch auf Anhieb – nur leider eben global und somit überall.

 

Als Behavior

Eher im Sinne der WPF wäre ein Behavior für Textboxen. Das liest sich analog

public class SelectTextOnFocus :  Behavior<TextBox>
{
  protected override void OnAttached()
  {
    base.OnAttached();
    AssociatedObject.GotFocus += OnFocus;
  }

  protected override void OnDetaching()
  {
    AssociatedObject.GotFocus -= OnFocus;
    base.OnDetaching();
  }

  void OnFocus(object sender, RoutedEventArgs e)
  {
    AssociatedObject.SelectAll();
  }
}

Der Vorteil liegt auf der Hand: man kann die Verhaltensweise auf diejenigen Textboxen einschränken, bei denen man dies auch haben möchte.

Selected

 

Der Nachteil ist, dass Behaviors Teil des Blend SDKs sind und waren. Nun ist Blend 4 als Teil des ursprünglichen Expression Blend in Visual Studio aufgegangen. Welches aber ist der Nachfolger?
Es gibt verschiedene inoffizielle, aber auch von Microsoft herausgegebene NuGet-Pakete. Expression.Blend.SDK scheint das kompletteste zu sein, mit verschiedenen Plattformen und .NET Versionen. Aber auch das Paket System.Windows.Interactivity v4.0 for WPF versieht klaglos seinen Dienst.

Der XAML-Code der zugehörigen View liest sich wie folgt:

<Window
    x:Class="SelectOnFocus.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:i="clr-namespace:System.Windows.Interactivity;assembly=System.Windows.Interactivity"
    xmlns:local="clr-namespace:SelectOnFocus"
    Title="Eingabe"
    Width="525"
    Height="350"
    FocusManager.FocusedElement="{Binding ElementName=boxName}">
    
    <StackPanel>

        <Label Content="_Name" Target="{Binding ElementName=boxName}" />
        <TextBox x:Name="boxName" Text="Donald Duck">
            <i:Interaction.Behaviors>
                <local:SelectTextOnFocus/>
            </i:Interaction.Behaviors>
        </TextBox>

        <Label Content="_Beruf" Target="{Binding ElementName=boxBeruf}" />
        <TextBox x:Name="boxBeruf" Text="Lebemann" />

        <Label Content="Stadt" Target="{Binding ElementName=boxStadt}" />
        <TextBox x:Name="boxStadt" Text="Entenhausen">
            <i:Interaction.Behaviors>
                <local:SelectTextOnFocus/>
            </i:Interaction.Behaviors>
        </TextBox>

    </StackPanel>
</Window>

Die komplette Solution kann im GitHub abgerufen werden.

DotNetKicks-DE Image
Veröffentlicht unter .NET, C#, WPF | Verschlagwortet mit , , | Hinterlasse einen Kommentar

Tap-Through-Verhalten mit WPF

 

Das Problem

In der WPF scheint es eine kleine Inkonsistenz bezüglich des Verhaltens verdeckter Komponenten zu geben: überdeckt ein heruntergeklapptes Menü ein anderes Control, z.B. einen Button, so kann man ihn trotzdem anklicken.

MainMenu

Klickt man an dieser Stelle den Change-Background-Knopf, wird sich der Hintergrund ändern.

Etwas anders reagiert die Oberfläche, wenn die Auswahl einer Combobox den Button überdeckt:

ComboBox

Wenn man hier den Button drückt, wird sich die Combobox schließen, es ist ein erneuter Klick notwendig, um eine Reaktion zu erzielen.

 

Ansätze

Ein kurzer Blick in die ComboBox.cs offenbart das Problem: das Öffnen der Selektionsbox fängt die Maus mittels Mouse.Capture. Beim erneuten Click wird die Box geschlossen und das Event wird als behandelt markiert, die Maus wieder freigegeben.

Was tun? Ein erster Ansatz wäre natürlich eine eigene Variante der Combobox, die dieses Verhalten irgendwie unterbindet. Was nicht gerade dem Ansatz der WPF entspricht: es wäre ein Control, was genau das selbe macht wie ein anderes, und sich nur in einem bestimmten Verhalten unterscheidet. An dieser Stelle sollte es *Klick* machen: Behaviors gab es doch an anderer Stelle schon einmal, nämlich in Microsoft Expression Blend. Sie werden an Controls attached, um ihr Verhalten zu beeinflussen. Das sollte also der richtige Weg sein.

 

Umsetzung

Nach dem der Weg klar ist, ist die Hülle schnell geschrieben. Im Xaml die entsprechende Combobox mit dem Verhalten ausstatten

<Window
    x:Class="TapThroughPopup.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
    xmlns:local="clr-namespace:TapThroughPopup"
...
<ComboBox Width="200" HorizontalAlignment="Left" SelectedIndex="0">
  <i:Interaction.Behaviors>
    <local:TapThroughBehavior/>
  </i:Interaction.Behaviors>
<ComboBoxItem Content="Element 1" />
...

und die Hülle dafür schreiben

namespace TapThroughPopup
{
    class TapThroughBehavior : Behavior<ComboBox>
    {
        protected override void OnAttached()
        {
        }
    }
}

Aber was genau gilt es nun zu tun? Mein erster Versuch war, das Mausevent abzufangen, es zu Verdoppeln und dem Dialog erneut zur Verarbeitung anzubieten.

Zum Beispiel:

protected override void OnAttached() { AssociatedObject.PreviewMouseDown += PreviewMouseDown; } void PreviewMouseDown(object sender, MouseButtonEventArgs e) { if (!AssociatedObject.IsDropDownOpen) return; var parent = AssociatedObject.Parent as UIElement; if (parent == null) return; parent.RaiseEvent(new RoutedEventArgs(Button.ClickEvent, this)); }

Aber Button.ClickEvent spielte ja nur darauf ab, daß ich im Beispiel einen Button klicken wollte. Sinnvoller wäre ein neues MouseButtonDown-Event (was mir je nach Klick in den Dialog eine Exception einbrachte).

Ein kurzes Innehalten – kann das so überhaupt funktionieren? Die Maus ist ja immer noch gecaptured, jedes Event, das ich generiere, müsste immer noch von der Combobox abgefangen werden.

 

Lösung

Letztendlich war der entscheidende Hinweis, daß man aus dem Behavior die Maus wieder freigeben kann, und so das ursprüngliche Event durchgereicht werden kann. Was mich vom zeitlichen Ablauf der Ereignis-Kette immer noch verwundert. Es scheint daran zu liegen, daß die PreviewMouseDownEvents innerhalb des Element-Trees zunächst getunnelt werden, in diesem Ablauf die Freigabe der Maus erfolgt, und erst dann die MouseDownEvents wieder nach oben bubbeln, wodurch der Click weitergereicht werden kann.

Bleibt noch das Problem zu erkennen, wann die Maus außerhalb des Bereiches der Combobox geklickt wird. Man könnte die entsprechende Funktionalität vielleicht in obigen PreviewMouseDown-Handler stecken, müsste dann aber nachsehen, ob überhaupt das DropDown geöffnet wurde, ob die Maus außerhalb des erlaubten Bereichs war… es sollte doch einen einfacheren Weg geben. Den gibt es, er trägt den unscheinbaren Namen Mouse.AddPreviewMouseDownOutsideCapturedElementHandler. Danke an dieser Stelle an den entsprechenden Eintrag in StackOverflow.

Wie so oft: wenn man erst mal weiß, wie es geht, ist es einfach. Am Ende übrig bleibt ein Zweizeiler:

using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Interactivity;

namespace TapThroughPopup
{
    class TapThroughBehavior : Behavior<ComboBox>
    {
        protected override void OnAttached()
        {
            Mouse.AddPreviewMouseDownOutsideCapturedElementHandler(AssociatedObject, MouseDownOutsideCapturedElement);
        }

        void MouseDownOutsideCapturedElement(object sender, MouseButtonEventArgs e)
        {
            AssociatedObject.ReleaseMouseCapture();
        }
    }
}
DotNetKicks-DE Image
Veröffentlicht unter .NET, C#, WPF | Hinterlasse einen Kommentar

Drag & Drop mit Caliburn.Micro

 

Prolog

Auf der Suche nach einer Möglichkeit, Zeilen innerhalb eines DataGrids per Drag&Drop zu verschieben, bin ich meistens auf Implementierungen gestoßen, die auf MouseUp- bzw MouseDown-Events reagieren. In dem Fall muss man sich natürlich um die ausgewählten Elemente selbst kümmern, die Selektion zwischenspeichern… sollte das WPF nicht mittlerweile selbst können? Und wie spricht man diese Funktionalität nun unter Caliburn.Micro an?

 

Die Spielwiese

Das Datenmodell bildet eine einfache Klasse GridItem, die Elemente werden in GridItems zu einer Liste zusammengefaßt und mit rudimentären Speicher- und Ladefunktionen versehen.

Um überhaupt eine Form der Reihenfolge zu ermöglichen, werden die Elemente beim Zugriff über ihre Id sortiert:

public class GridItems
{
  List<GridItem> _items;
  public List<GridItem> Items
  {
    get
    {
      if (_items==null ) Load();
      var query = from item in _items orderby item.Id select item;
      return query.ToList();
    }
  }
...

Damit die Elemente im Grid bearbeitet werden können, bildet ein GridItemRowViewModel die Properties des GridItem mit den nötigen Aufrufen von NotifyOfPropertyChange ab. Das Bereitstellen der ItemSource für das DataGrid erfolgt ebenfalls per Linq

public class ShellViewModel : Screen, IShell
{
  readonly GridItems _gridItems = new GridItems();

  ObservableCollection<GridItemRowViewModel> _gridRows;
  public ObservableCollection<GridItemRowViewModel> GridRows
  {
    get
    {
      return _gridRows ??
        (_gridRows = new ObservableCollection<GridItemRowViewModel>(
         _gridItems.Items.Select(x => new GridItemRowViewModel(x))));
    }
  }
...

Die Properties werden eins zu eins im DataGrid abgebildet. Der SelectionMode wird hier auf Single gesetzt, es sollen nur einzelne Zeilen verschoben werden können. Wichtig hier ist natürlich, AllowDrop auf True zu setzen.

<DataGrid Name="DataItemsGrid" Margin="5" AutoGenerateColumns="False"

AllowDrop="True" RowHeight="16" AlternationCount="2" AlternatingRowBackground="#0F000000" CanUserAddRows="false" CanUserResizeRows="False" SelectionMode="Single" RowStyle="{StaticResource RowStyle}" RowHeaderStyle="{StaticResource RowHeaderStyle}" ItemsSource="{Binding Path=GridRows}"> <DataGrid.Columns> <DataGridTextColumn Header="Id" IsReadOnly="True" Binding="{Binding Id}"/> ...

Das Draggen soll in diesem Beispiel mit den beiden ReadOnly-Feldern Id und Name möglich sein. Man spart sich ein zusätzliches Icon-Feld, das nur zum “Anfassen” dient.

 

Kleine Helfer

Bei den verschiedenen Mausklicks muß man auf verschiedene Ebenen innerhalb des VisualTrees zugreifen. Einmal braucht man den Zugriff auf die geklickte Zelle, ein anderes mal die komplette Zeile. Eine generische Suchfunktion bietet sich geradezu an:

static T FindElementFromSource<T>(DependencyObject source) where T: UIElement
{
  var dep = source;
  while ((dep != null) && !(dep is T))
    dep = VisualTreeHelper.GetParent(dep);
  return dep as T;
}

Als kleinen Sonderfall benötigt man noch das ViewModel einer GridRow:

static GridItemRowViewModel GetViewModelOfEvent(DependencyObject source)
{
  var row = FindElementFromSource<DataGridRow>(source);
  if (row == null) return null;

  return row.DataContext as GridItemRowViewModel;
}

Wenn wir uns den theoretischen Code eines beliebigen DragEvents anschauen, fällt einem direkt die stetige Null-Überprüfung ins Auge:

public void OnDragXXX(DragEventArgs e)
{
  var dataObject = e.Data as DataObject;
  if (dataObject == null) return;

  var draggedItem = dataObject.GetData(typeof (DraggedGridItem)) as DraggedGridItem;
  if (draggedItem == null) return;

  var row = FindElementFromSource<DataGridRow>(source);
  if (row == null) return;

  ... // Do the action

Es kristallisieren sich 3 Grundfunktionalitäten heraus: es soll etwas mit dem im Event übermittelten DraggedGridItem geschehen, dadurch wird etwas in der Zeile geändert oder im zugrunde liegenden ViewModel der Zeile. Verknüpft man das mit anonymen Methoden, genügen die entsprechenden Funktionen:

static void WithDragItemDo(DragEventArgs e, Action<DraggedGridItem> action)
{
    var dataObject = e.Data as DataObject;
    if (dataObject == null) return;

    var draggedItem = dataObject.GetData(typeof (DraggedGridItem)) as DraggedGridItem;
    if (draggedItem == null) return;

    action(draggedItem);
}

static void WithDragItemsDo(DragEventArgs e, Action<DraggedGridItems> action)
{
    var dataObject = e.Data as DataObject;
    if (dataObject == null) return;

    var draggedItems = dataObject.GetData(typeof (DraggedGridItems)) as DraggedGridItems;
    if (draggedItems == null) return;

    action(draggedItems);
}

static void WithRowFromSourceDo(DependencyObject source, Action<DataGridRow> action)
{
    var row = FindElementFromSource<DataGridRow>(source);
    if (row == null) return;

    action(row);
}

 

Ziehen und Fallenlassen

Mit diesen Hilfsfunktionen besteht das Draggen nur noch aus der Visualisierung der betroffenen Zeilen. In diesem Fall soll lediglich die Hintergrundfarbe geändert werden. Die alte Hintergrundfarbe wird hierbei in den Event-Parametern selbst gespeichert.

public void OnDragEnter(DragEventArgs e)
{
  WithDragItemDo(e, di=> WithRowFromSourceDo((DependencyObject)e.OriginalSource, row=>
  {
    di.OldBackground = row.Background;
    row.Background = Brushes.Aquamarine;
  }));
}

public void OnDragLeave(DragEventArgs e)
{
  WithDragItemDo(e, di => WithRowFromSourceDo((DependencyObject)e.OriginalSource, row =>
  {
    if (di.OldBackground != null)
      row.Background = di.OldBackground;
   }));
}

Beim Fallenlassen wird der neue Index des Elements berechnet. Sofern er sich geändert hat, wird die gesamte Liste neu durchnummeriert:

public void OnDragDrop(DragEventArgs e)
{
  OnDragLeave(e);
  WithDragItemDo(e, di=> WithViewModelFromSourceDo((DependencyObject)e.OriginalSource, vm =>
  {
    var newIndex = GridRows.IndexOf(vm);
    if (newIndex == di.Index) return;

    GridRows.RemoveAt(di.Index);
    GridRows.Insert(newIndex, di.ViewModel);
    for (var i = 0; i < GridRows.Count; i++)
      GridRows[i].Id = i;
    }));
}

 

View-Events

Bislang weiß die View natürlich noch nichts von diesen Event-Methoden. Caliburn.Miro muß noch angewiesen werden, die entsprechenden Aktionen an das ViewModel weiterzuleiten. Hierfür wird eine Referenz auf System.Windows.Interactivity benötigt, hierin werden die Interaction-Trigger bereitgestellt:

<Window x:Class="DataGridDragDrop.Views.ShellView"

xmlns=”http://schemas.microsoft.com/winfx/2006/xaml/presentation

xmlns:x=”http://schemas.microsoft.com/winfx/2006/xaml

xmlns:i="clr-namespace:System.Windows.Interactivity;assembly=System.Windows.Interactivity"

xmlns:cal="clr-namespace:Caliburn.Micro;assembly=Caliburn.Micro">

 

<DataGrid Name="DataItemsGrid" ...> <i:Interaction.Triggers> <i:EventTrigger EventName="PreviewMouseLeftButtonDown"> <cal:ActionMessage MethodName="OnMouseDown"> <cal:Parameter Value="$eventargs" /> </cal:ActionMessage> </i:EventTrigger> <i:EventTrigger EventName="DragEnter"> <cal:ActionMessage MethodName="OnDragEnter"> <cal:Parameter Value="$eventargs"/> </cal:ActionMessage> </i:EventTrigger> <i:EventTrigger EventName="DragLeave"> <cal:ActionMessage MethodName="OnDragLeave"> <cal:Parameter Value="$eventargs" /> </cal:ActionMessage> </i:EventTrigger> <i:EventTrigger EventName="PreviewDrop"> <cal:ActionMessage MethodName="OnDragDrop"> <cal:Parameter Value="$eventargs"/> </cal:ActionMessage> </i:EventTrigger> </i:Interaction.Triggers> <DataGrid.Columns> …

Ein kurzes Durchzählen der hier aufgeführten Events bestätigt, daß noch ein Event-Handler fehlt. Das Initialisieren des Drag&Drop-Effekts geschieht nach wie vor durch den MouseDown-Event. Je nach dem, ob das Klicken auf einer schreibgeschützten Zelle stattfand, wird entweder das Draggen initialisiert oder direkt in den Bearbeitungsmodus der Zelle gewechselt:

public void OnMouseDown(MouseButtonEventArgs e)
{
  var dataGrid = (DataGrid) e.Source;
  if (dataGrid == null) return;

  var cell = FindElementFromSource<DataGridCell>((DependencyObject) e.OriginalSource);
  if (cell == null) return;

  if (!cell.Column.IsReadOnly)
  {
    cell.Focus();
    dataGrid.BeginEdit();
    return;
  }

  var viewModel = GetViewModelOfEvent((DependencyObject) e.OriginalSource);
  var index = GridRows.IndexOf(viewModel);

  var draggedElement = new DraggedGridItem {ViewModel = viewModel, Index = index};
  var dataObject = new DataObject(typeof (DraggedGridItem), draggedElement);
  DragDrop.DoDragDrop(dataGrid, dataObject, DragDropEffects.Move);
}

Multiselection

Mit oben genannter Lösung lassen sich leicht einzelne Elemente in ihrer Reihenfolge sortieren. Setzt man den SelectionMode des DataGrids jedoch auf Extended, funktioniert der Ansatz nicht mehr. Natürlich kann man sich im Datenobjekt des DragEvents die Liste der selektierten Einträge merken. Das Problem ist vielmehr, daß nun bei jedem Mausklick in das Grid jede selektierte Zeile in eine Drag-Aktion verpackt wird, die sofort als Drop interpretiert wird – ein richtiges Ziehen und Loslassen erfolgt in diesem Fall nicht mehr.

Wer an dieser Stelle weiter experimentieren möchte: das Projekt ist unter https://github.com/Slesa/Playground/tree/master/sketches/Caliburn.Micro/DataGridDragDrop abrufbar.

DotNetKicks-DE Image
Veröffentlicht unter .NET, C#, Caliburn.Micro, WPF | Hinterlasse einen Kommentar

Ein eigenes Setup mit WiX

 

Prolog

Im letzten Artikel Besser Bauen mit FAKE wurde darauf hingewiesen, dass die in FAKE integrierten Möglichkeiten zum Bauen eines Setups begrenzt sind. Statt dessen wurde durch das Build-Skript MSBuild mit einem WiX-Projekt als Target angestoßen. Um den Aufbau dieses Installer-Projekts geht es in diesem Post.

Nach Download und Installation des Windows Installer XML Toolkits wird ein neues Setup-Projekt angelegt. Da die Projektumgebung alle installierbaren Pakete enthält, genügt ein einzelnes Projekt im Verzeichnis Setup. Gleichzeitig ergibt sich daraus schon der prinzipielle Aufbau des Installers: einzelne Komponenten sollen abwählbar sein, der Installer selbst soll mehrsprachig sein und die Versionsnummer des Buildsystems soll berücksichtigt werden.

 

Der Projektrahmen

Durch den Projekt-Wizard wurde bereits das grundlegende XML-Gerüst angelegt. Wir erweitern die Informationen um die Versionsnummer sowie die Sprachunterstützung:

<Wix xmlns=http://schemas.microsoft.com/wix/2006/wi

xmlns:util="http://schemas.microsoft.com/wix/UtilExtension"> <!-- Produkt-Definition --> <Product Id="85d37c9c-685c-41b7-a996-e2c6d92031b6" UpgradeCode="f6f2968f-d114-44b9-874e-4f32ffe64427" Manufacturer="Slesa Solutions" Name="!(loc.ProductName)" Language="!(loc.Language)" Version="$(var.ProductVersion)" Codepage="utf-8">

Die gezeigten GUIDs sollten selbstverständlich durch eigens generierte ersetzt werden.

Es folgen die Beschreibung des Pakets wie dessen Systemvoraussetzungen…

    <!-- Package-Definition -->
    <Package InstallerVersion="200" 
             Manufacturer="Slesa Solutions"
             Compressed="yes"
             Description="!(loc.ProductDescr)"
             Comments="(c) 2011 Slesa Solutions"
             InstallPrivileges="elevated"
             />

    <!-- Requirements -->
    <PropertyRef Id="NETFRAMEWORK40FULL" />
    <Condition Message="!(loc.ErrorFramework)">NETFRAMEWORK40FULL</Condition>
    <Condition Message="!(loc.ErrorAdminRights)">Privileged</Condition>

… das Installer-Paket soll innerhalb einer Cabinet-Datei abgelegt werden…

    <Media Id="1" Cabinet="media1.cab" EmbedCab="yes" />

… und die Installation soll unterhalb des Programme-Verzeichnisses erfolgen

    <Directory Id="TARGETDIR" Name="SourceDir">
      <Directory Id="ProgramFilesFolder">
        <Directory Id="APPLICATIONFOLDER" Name="Poseidon" />
      </Directory>
      <Directory Id="DesktopFolder"/>
    </Directory>

Falls später noch Verknüpfungen auf dem Desktop abgelegt werden sollen, sollten die dort hinterlegten Icons an dieser Stelle definiert werden. Sie verweisen auf Bitmaps im Resources-Verzeichnis, das ebenfalls zum Projekt gehört:

    <Icon Id="I__BackOfficeIcon.exe" SourceFile="Resources\Poseidon.ico" />
    <Property Id="PRODUCTICON" Value="I__BackOfficeIcon.exe"/>

 

Die Komponenten

Mit dem Backoffice-Projekt im Hinterkopf können wir jetzt die installierbaren Komponenten auflisten. Die Dateien beziehen wir dabei aus dem Ziel-Ordner des Build-Skripts:

    <DirectoryRef Id="APPLICATIONFOLDER" FileSource="..\..\bin\build">

Wir beginnen mit den unverzichtbaren Dateien als Shared Components

      <Component Id="C__SharedComponents" Guid="b81ad615-4091-49a9-ab09-d0da690bcb04" DiskId="1">
        <File Id="F__CaliburnMicro" Name="Caliburn.Micro.dll" />
        <File Id="F__FLuentNHibernate" Name="FluentNHibernate.dll" />
        <File Id="F__IesiCollections" Name="Iesi.Collections.dll" />
        <File Id="F__NHibernate" Name="NHibernate.dll" />
        <File Id="F__Interactivity" Name="System.Windows.Interactivity.dll" />
      </Component>

Es folgt das Backoffice-Programm selbst, dass die übrigen Module nachlädt. Hier ist auch der oben erwähnte Shortcut auf den Desktop

<Component Id="C__BackOffice" Guid="{C46D1942-3C5C-4edf-8E06-36D48DFD8892}" DiskId="1"> <File Id="F__BackOffice" Name="BackOffice.exe" KeyPath="yes" />

<File Id="F__BackOfficeCfg" Name="BackOffice.exe.config" /> <File Id="F__BackOfficeContract" Name="BackOffice.Contracts.dll" /> <File Id="F__BackOfficeTheme" Name="BackOffice.Theme.dll" /> <File Id="F__ModelShared" Name="Model.Shared.dll" /> <File Id="F__PersistenceShared" Name="Persistence.Shared.dll" /> <Shortcut Id="S__BackOffice" Directory ="DesktopFolder" WorkingDirectory="APPLICATIONFOLDER" Name="Poseidon BackOffice" Icon="I__BackOfficeIcon.exe" Advertise="yes" Description="!(loc.BackOfficeDescription)" /> </Component>

und die drei Office-Module – Benutzer-, POS- sowie die Bestandsverwaltung

      <Component Id="C__BackOfficeIcs" Guid="{613F262E-156E-49FD-82F7-31F659F1511C}">
        <File Id="F__IcsModel" Name="Ics.Model.dll" />
        <File Id="F__IcsNHibernate" Name="Ics.NHibernate.dll" />
        <!-- File Id="F__IcsOfficeModule" Name="Ics.OfficeModule.dll" / -->
        <File Id="F__IcsResources" Name="Ics.Resources.dll" />
      </Component>
      
      <Component Id="C__BackOfficeUms" Guid="{A8A43C44-526D-487F-9319-855EA5BCF725}">
        <File Id="F__UmsModel" Name="Ums.Model.dll" />
        <File Id="F__UmsNHibernate" Name="Ums.NHibernate.dll" />
        <File Id="F__UmsOfficeModule" Name="Ums.OfficeModule.dll" />
        <File Id="F__UmsResources" Name="Ums.Resources.dll" />
      </Component>

      <Component Id="C__BackOfficePms" Guid="{BF7884AB-D67D-4773-B69B-6CFD563727B2}">
        <File Id="F__PmsModel" Name="Pms.Model.dll" />
        <File Id="F__PmsNHibernate" Name="Pms.NHibernate.dll" />
        <File Id="F__PmsOfficeModule" Name="Pms.OfficeModule.dll" />
        <File Id="F__PmsResources" Name="Pms.Resources.dll" />
      </Component>

    </DirectoryRef>

Es sei noch einmal darauf hingewiesen, daß es sich hierbei nur um die Auflistung der Dateien handelt. Der Installationsumfang wird noch einmal gesondert als Features angegeben.

 

Die Features

Nachdem die Komponenten und die darin enthaltenen Dateien definiert wurden, können sie von den Features referenziert werden. Zu beachten ist, dass hier bereits ein Abhängigkeitsbaum angegeben wird – ohne Office-Komponente auch keine Office-Module:

    <Feature Id="SharedFiles" Level="1" InstallDefault="local" 
             Absent="disallow" AllowAdvertise="no" TypicalDefault="install"
             Title="!(loc.SharedFeatureTitle)"
             ConfigurableDirectory="APPLICATIONFOLDER"
             Description="!(loc.SharedFeatureDescription)">
      <ComponentRef Id="C__SharedComponents" />
    </Feature>

    <Feature Id="BackOffice" Level="1" Display="expand" 
             InstallDefault="local" AllowAdvertise="no"
             Title="!(loc.BackOfficeFeatureTitle)"
             Description="!(loc.BackOfficeFeatureDescription)">
      <ComponentRef Id="C__BackOffice" />

      <Feature Id="BackOfficeUms" Level="1" InstallDefault="local" AllowAdvertise="no"
             Title="!(loc.BackOfficeUmsFeatureTitle)"
             Description="!(loc.BackOfficeUmsFeatureDescription)">
        <ComponentRef Id="C__BackOfficeUms" />
      </Feature>
      <Feature Id="BackOfficePms" Level="1" InstallDefault="local" AllowAdvertise="no"
             Title="!(loc.BackOfficePmsFeatureTitle)"
             Description="!(loc.BackOfficePmsFeatureDescription)">
        <ComponentRef Id="C__BackOfficePms" />
      </Feature>
      <Feature Id="BackOfficeIcs" Level="1" InstallDefault="local" AllowAdvertise="no"
             Title="!(loc.BackOfficeIcsFeatureTitle)"
             Description="!(loc.BackOfficeIcsFeatureDescription)">
        <ComponentRef Id="C__BackOfficeIcs" />
      </Feature>
      
    </Feature>

Ab hier sind dem Installer alle Dateien bekannt, es erfolgt lediglich noch die Anpassung der Setup-Oberfläche. Die Installation soll pro Maschine statt pro Benutzer erfolgen, es wird eine abzunickende Lizenz angegeben, die Bitmaps werden ausgetauscht, und die anzuzeigenden Wizard-Seiten werden angegeben:

    <Property Id="ApplicationFolderName" Value="Poseidon" />
    <Property Id="WixAppFolder" Value="WixPerMachineFolder" />

    <WixVariable Id="WixUILicenseRtf" Value="license.rtf"/>
    <WixVariable Id="WixUIDialogBmp" Value="$(sys.SOURCEFILEDIR)\Resources\WixUI_Bitmap.jpg" />
    <WixVariable Id="WixUIBannerBmp" Value="$(sys.SOURCEFILEDIR)\Resources\WixUI_Banner.jpg" />

    <UIRef Id="WixUI_ErrorProgressText" />
    <UIRef Id="WixUI_Advanced" />

  </Product>
</Wix>

 

Die Übersetzung

Ein beherzter Versuch, das Projekt zu kompilieren, schlägt leider fehl. Es wurden bereits Übersetzungs-Platzhalter verwandt, die es zuerst in einer Sprache anzugeben gilt. Wir fügen dem Projekt also noch zwei Wix Localization Files hinzu, hier 1031.wxl und 1033.wxl benannt nach den Windows Locale Codes. In diesen wird die Sprache angegeben, sowie die lokalisierten Strings, z.B.:

<?xml version="1.0" encoding="utf-8"?> <WixLocalization Culture="de-de" xmlns="http://schemas.microsoft.com/wix/2006/localization"> <String Id="Language">1031</String>

<String Id="ProductName">Poseidon</String> <String Id="ProductDescr">Poseidon - eine POS-Umgebung</String>

<String Id="BackOfficeDescription">BackOffice zum Bearbeiten der Stammdaten</String> <String Id="SharedFeatureTitle">Benötigte Dateien</String> <String Id="SharedFeatureDescription">Von den Anwendungen gemeinsam benutze Dateien und Komponenten.</String> <String Id="BackOfficeFeatureTitle">BackOffice</String> <String Id="BackOfficeFeatureDescription">Backoffice zum Bearbeiten der Daten und Beziehungen.</String> <String Id="BackOfficeUmsFeatureTitle">User Management System</String> <String Id="BackOfficeUmsFeatureDescription">Modul zur Verwaltung von Benutzers und Benutzerrechten.</String> <String Id="BackOfficePmsFeatureTitle">POS Management System</String> <String Id="BackOfficePmsFeatureDescription">Modul zur Verwaltung der POS-Umgebung.</String> <String Id="BackOfficeIcsFeatureTitle">Inventory Control System</String> <String Id="BackOfficeIcsFeatureDescription">Modul zur Verwaltung von Bestandszählern.</String> <String Id="ErrorAdminRights">Die Installation von [ProductName] erfordert administrative Privilegien.</String> <String Id="ErrorFramework">Microsoft .NET Framework wurde nicht gefunden. [ProductName] benötigt das .NET Framework 4.0.</String> </WixLocalization>

Mit diesem WiX-Skript steht nun bereits ein recht angenehmer Setup-Mechanismus zur Verfügung, um eigene Programme auf fremde Platten zu bannen. Angenehmer wäre es jedoch, wenn man eigene Grundeinstellungen ebenfalls eingeben könnte. In unserem Fall wäre dies z.B. der Connect-String für die Datenbank.

 

Eigene Optionen

Durch Auswahl einer der UI-Referenzen WixUI_Mondo, WixUI_Advanced oder WixUI_FeatureTree hat man bereits eine kleie Auswahl an Gestaltungsmöglichkeiten für die Oberfläche des Installers. Um jedoch eigene Seiten einzuarbeiten, um etwa einen Pfad eintragen zu lassen, muß der komplette Ablauf der Oberfläche umdefiniert werden.

Hierfür ist es zu allererst notwendig, bereits bestehende Referenzen wieder aus der Product.wxs zu entfernen, da sie sonst doppelt definiert sind. Dies betrifft die Zeilen

<!--<WixVariable Id="WixUILicenseRtf" Value="license.rtf"/>-->
<!--<WixVariable Id="WixUIDialogBmp" Value="$(sys.SOURCEFILEDIR)\Resources\WixUI_Bitmap.jpg" />-->
<!--<WixVariable Id="WixUIBannerBmp" Value="$(sys.SOURCEFILEDIR)\Resources\WixUI_Banner.jpg" />-->

sowie die bisher benutzte Oberfläche

<!--<UIRef Id="WixUI_Mondo" />-->
<!--<UIRef Id="WixUI_Advanced" />-->
<!--<UIRef Id="WixUI_FeatureTree" />-->

Sie werden durch folgende Zeile ersetzt

<UIRef Id="CustomUI_Mondo" />

Und diese Art der Oberfläche definieren wir jetzt.

 

Eine eigene Datenbankseite

Wie beginnen mit dem Layout der eigenen Optionsseite, im vorliegenden Fall der Eingabemöglichkeit des Datenbank-Verbindungsstrings. Hierfür müssen unter WiX die einzelnen Komponenten des Dialogs – ähnlich WinForms – innerhalb des Dialogs positioniert werden. Es gibt auch Vorgaben bezüglich der Fenstergrößen oder der Position der einzelnen Buttons.

Für das Installer-Skript kommen 2 zusätzliche Fragmente hinzu, ich habe die neue Datei dem Anlaß entsprechend DbConnectionDlg.wxs genannt. Der Header bereitet wieder alles für WiX vor

<?xml version="1.0" encoding="UTF-8"?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">

Jetzt folgt der neue Dialog als erstes Fragment. Die ID des Dialogs wird später als Referenz benötigt, sie sollte also sinngemäß sein. Der Layout wurde dem der übrigen Dialoge angepaßt:

<Fragment>

  <UI Id="CustomUIExtension">

    <Dialog Id="DbConnectionDlg" Width="370" Height="270" Title="!(loc.DbConnectionTitle)">
      <Control Id="Next" Type="PushButton" X="236" Y="243" Width="56" Height="17" Default="yes" Text="!(loc.WixUINext)">
        <Condition Action="disable"><![CDATA[DBCONNECTION = ""]]></Condition>
        <Condition Action="enable"><![CDATA[DBCONNECTION <> ""]]></Condition>
      </Control>
      <Control Id="Back" Type="PushButton" X="180" Y="243" Width="56" Height="17" Text="!(loc.WixUIBack)" />
      <Control Id="Cancel" Type="PushButton" X="304" Y="243" Width="56" Height="17" Cancel="yes" Text="!(loc.WixUICancel)">
        <Publish Event="SpawnDialog" Value="CancelDlg">1</Publish>
      </Control>

      <Control Id="Description" Type="Text" X="25" Y="23" Width="280" Height="15" Transparent="yes" NoPrefix="yes" Text="!(loc.DbConnectionDescr)" />
      <Control Id="Title" Type="Text" X="15" Y="6" Width="200" Height="15" Transparent="yes" NoPrefix="yes" Text="{\WixUI_Font_Title}!(loc.DbConnectionTitle)" />
      <Control Id="BannerBitmap" Type="Bitmap" X="0" Y="0" Width="370" Height="44" TabSkip="no" Text="!(loc.InstallDirDlgBannerBitmap)" />
      <Control Id="BannerLine" Type="Line" X="0" Y="44" Width="370" Height="0" />
      <Control Id="BottomLine" Type="Line" X="0" Y="234" Width="370" Height="0" />

      <Control Id="LblDbConnection" Type="Text" X="20" Y="60" Width="240" Height="12" NoPrefix="yes" Text="!(loc.DbConnectionStr)" />
      <Control Id="EditDbConnection" Type="Edit" X="20" Y="75" Width="240" Height="18" Property="DBCONNECTION" Indirect="no" />

    </Dialog>
  </UI>

</Fragment>

Man beachte die Beeinflussung der Darstellung des Dialog-Titels durch {\WixUI_Font_Title}!(loc.DbConnectionTitle) und das Verhindern einer leerer Eingabe durch die Conditions des Next-Buttons.

Dieser neue Dialog ist nun natürlich durch nichts mit den anderen verknüpft, es muß also eine Aufruf-Reihenfolge definiert werden. Diese bildet das zweite Fragment. Das UI-Element darin muß die selbe ID bekommen, die wir bereits als neue Oberfläche definiert haben, nämlich CustomUI_Mondo. Daneben sind alle Definitionen zu treffen, die die Standard-Oberflächen uns vormals abgenommen haben:

<Fragment>

  <UI Id="CustomUI_Mondo">
    <TextStyle Id="WixUI_Font_Normal" FaceName="Tahoma" Size="8" />
    <TextStyle Id="WixUI_Font_Bigger" FaceName="Tahoma" Size="12" />
    <TextStyle Id="WixUI_Font_Title" FaceName="Tahoma" Size="9" Bold="yes" />

    <Property Id="DefaultUIFont" Value="WixUI_Font_Normal" />
    <Property Id="WixUI_Mode" Value="Mondo" />

wir definieren unsere benötigte Variable für die Datenbankverbindung

    <Property Id="DBCONNECTION" Value="Server=.\SQLEXPRESS;initial catalog=Poseidon;Integrated Security=SSPI" Secure="yes">
      <RegistrySearch Id="Reg__DbConnection" Key="SOFTWARE\!(loc.ProductName)" Root="HKLM" Type="raw" Name="DbConnection" />
    </Property>

Wir referenzieren die benutzten Dialoge des Installers

     <DialogRef Id="ErrorDlg" />
      <DialogRef Id="LicenseAgreementDlg" />
      <DialogRef Id="FatalError" />
      <DialogRef Id="FilesInUse" />
      <DialogRef Id="MsiRMFilesInUse" />
      <DialogRef Id="PrepareDlg" />
      <DialogRef Id="ProgressDlg" />
      <DialogRef Id="ResumeDlg" />
      <DialogRef Id="UserExit" />

und verknüpfen nun die verschiedenen Dialoge miteinander:

  • der Exit-Dialog beendet den Installer
  • Der Willkommens-Dialog zum Lizenzabkommen
  • Das Lizenzabkommen führt zurück zum Willkommens-Dialog, und weiter zur Auswahl des Installationsortes
  • Die Installationsort-Nachfrage führt zurück zur Lizenz und weiter zu unserem eigenen Dialog. Daneben kann noch ein neuer Dialog zur Verzeichnisauswahl aufgerufen werden
  • Unsere Datenbank-Dialog führt zurück zum Installationsort und weiter zur Paketauswahl
  • Die Paketauswahl führt zurück zur Datenbank-Anbindung und weiter zum Sind-Sie-Sicher-Dialog
  • Der Sind-Sie-Sicher-Dialog führt zurück zum Paketauswahl-Dialog

Das hört sich zwar kompliziert an, läßt sich aber genau so auch hinschreiben

    <Publish Dialog="ExitDialog" Control="Finish" Event="EndDialog" Value="Return" Order="999">1</Publish>

    <Publish Dialog="WelcomeDlg" Control="Next" Event="NewDialog" Value="LicenseAgreementDlg">1</Publish>
      
    <Publish Dialog="LicenseAgreementDlg" Control="Back" Event="NewDialog" Value="WelcomeDlg">1</Publish>
    <Publish Dialog="LicenseAgreementDlg" Control="Next" Event="NewDialog" Value="InstallDirDlg">1</Publish>

    <Publish Dialog="InstallDirDlg" Control="Back" Event="NewDialog" Value="LicenseAgreementDlg">1</Publish>
    <Publish Dialog="InstallDirDlg" Control="ChangeFolder" Property="_BrowseProperty" Value="[WIXUI_INSTALLDIR]" Order="1">1</Publish>
    <Publish Dialog="InstallDirDlg" Control="ChangeFolder" Event="SpawnDialog" Value="BrowseDlg" Order="2">1</Publish>
    <Publish Dialog="InstallDirDlg" Control="Next" Event="SetTargetPath" Value="[WIXUI_INSTALLDIR]" Order="1">1</Publish>
    <Publish Dialog="InstallDirDlg" Control="Next" Event="NewDialog" Value="DbConnectionDlg" Order="2">1</Publish>

    <Publish Dialog="DbConnectionDlg" Control="Back" Event="NewDialog" Value="InstallDirDlg">1</Publish>
    <Publish Dialog="DbConnectionDlg" Control="Next" Event="NewDialog" Value="CustomizeDlg">1</Publish>

    <Publish Dialog="CustomizeDlg" Control="Back" Event="NewDialog" Value="DbConnectionDlg">1</Publish>
    <Publish Dialog="CustomizeDlg" Control="Next" Event="NewDialog" Value="VerifyReadyDlg">1</Publish>
               
    <Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="CustomizeDlg">1</Publish>

Das mag einem als viel Arbeit vorkommen, weil man ja eigentlich “nur den einen Dialog” dazwischen schieben wollte. Man darf aber nicht vergessen, daß sich gleichzeitig auch die komplette Reihenfolge der Dialoge, aber auch das Weglassen der selben auf ziemlich einfachen Weg realisieren läßt.

Der Connection-String zur Datenbank kann wird nun abgefragt – aber wie kommt er zum Programm? Bei unserer ersten Variante des Installers haben wir uns schon die Konfigurationsdatei des Backoffices mitinstallieren lassen. Jetzt können wir die nach der Installation verändern, z.B. den erfragten String einzutragen. Dieser Vorgang gehört zur Komponente C__BackOffice und geschieht nach dem Erstellen des Shortcuts auf dem Desktop:

        <Shortcut ...
        ...>

        <util:XmlFile Id="X__DbConnection" Action="setValue"
                      Permanent="yes"
                      ElementPath="/configuration/appSettings/add[\[]@key='DbConnection'[\]]"
                      File="[#F__BackOfficeCfg]" Name="value"
                      Value="[DBCONNECTION]"  Sequence="1" />


      </Component>

Hier sehen wir übrigens auch den Grund, warum im WiX-Skript der util-Namensbereich registriert wurde.

Nun werden an dieser Stelle noch die Variablen definiert, die wir aus dem Haupt-Skript herausgenommen haben:

  </UI>

  <UIRef Id="WixUI_Common" />

  <WixVariable Id="WixUIDialogBmp" Value="$(sys.SOURCEFILEDIR)\Resources\WixUI_Bitmap.jpg" />
  <WixVariable Id="WixUIBannerBmp" Value="$(sys.SOURCEFILEDIR)\Resources\WixUI_Banner.jpg" />
  <WixVariable Id="WixUILicenseRtf" Value="license.rtf"/>

</Fragment>


</Wix>

3 übersetzbare Strings kamen auch noch hinzu, diese sind auf die beiden Sprachdateien 1033.wxl und 1031.wxl zu verteilen:

<String Id="DbConnectionTitle">Konfiguration der Datenbank-Verbindung</String>
<String Id="DbConnectionDescr">An dieser Stelle können Sie den Verbindungs-String bearbeiten </String>
<String Id="DbConnectionStr">Connection-String:</String>

Nach dem Kompilieren sollte nun ein MSI-Installer zur Verfügung stehen, der nicht nur die Datenbank-Verbindung nachfragt, sondern sie auch in die Konfigurationsdateien der entsprechenden Programme, hier das Backoffice, einträgt.

 

Fazit

Pro Sprache wird nun ein eigenes MSI-File erzeugt. Zwar gibt es auch Mittel und Wege, um alle Installer in ein großes Setup-Packet zu packen, andererseits spart sich so auch etwas Bandbreite.

Meines Erachtens gibt es mit den WiX-Tools keine Ausreden mehr, warum ein Programm per Copy-Befehl installiert werden sollte. Wenn man keine eigenen Konfigurationen vornehmen lassen will, ist mit wenigen Zeilen Code ein Setup gezaubert. Sollen doch noch Werte nachgefragt werden, ist der Aufwand auch nicht sehr viel größer.

Zum Continuous Delivery fehlt nun nur noch das automatische Ausrollen per Ftp, sowie das automatische Erstellen eines Newsletters, in dem die Release-Notes aus der Quellcode-Verwaltung extrahiert werden. Aber das steht wieder in einem ganz anderen Artikel…

DotNetKicks-DE Image
Veröffentlicht unter .NET, Installer, WiX | Ein Kommentar

Besser bauen mit FAKE

Prolog

Mein Projekt “WPF over Data” ist umgezogen. Auf Github wurden unter Poseidon die Assemblies neu konzeptioniert, von Caliburn.Micro wurde die neuste Version eingespielt, damit einhergehend wird nun MEF anstatt Windsor.Castle eingesetzt. Ziel war es, das Projekt später unter einer CI-Umgebung wie TeamCity oder Hudson automatisch bauen zu können.

Voraussetzung hierfür ist ein Build-Tool, das alle Projekte compiliert, die darin enthaltenen Software-Tests aufruft und die verschiedenen Programme zu einer Setup-Routine zusammenpackt. Der erste Blick fällt dabei auf MSBuild, da es schon für die Solutions und Projekte eingesetzt wird. Allerdings ist die verwandte XML-Sprache sehr gesprächig, das behandeln der Dateien in Filesets erscheint umständlich. Es gibt noch andere Möglichkeiten, etwa Ant oder Rake – meine Wahl fiel auf Fake.

 

Alles auf Anfang

Spielt man den Zyklus eines Continuous Integration Servers durch, steht am Anfang das Abrufen der aktuellen Version, es folgt der Aufruf des Build-Scripts. Damit das funktionieren kann, müssen die Build-Tools, also Fake, in die Quellcodeverwaltung eingecheckt werden. Der Aufruf des Builds kann durch eine Batch-Datei simuliert werden. Da viel Trial-And-Error bei der Zusammensetzung des Build-Scripts mitspielt, sollte der Batch so lange aktiv sind, bis der Benutzer abbricht. Hier also die build.cmd:

@echo off

:Build
cls

SET TARGET="Default"

IF NOT [%1]==[] (set TARGET="%1")
  
"tools\Fake\Fake.exe" "build.fsx" "target=%TARGET%"

rem Bail if we're running a TeamCity build.
if defined TEAMCITY_PROJECT_NAME goto Quit

rem Loop the build script.
set CHOICE=nothing
echo (Q)uit, (Enter) runs the build again
set /P CHOICE= 
if /i "%CHOICE%"=="Q" goto :Quit

GOTO Build

:Quit
exit /b %errorlevel%

Zusätzlich prüft das Script, ob es von TeamCity aufgerufen wird und beendet sich nach einmaligem Durchlauf.

 

Bau-Plan

Als nächstes wird das im Build-Batch benutzte Build-Skript build.fsx erstellt. Unter Fake beginnt die Datei immer mit

#I @"tools\Fake"
#r "FakeLib.dll"

open Fake

Es folgen die Eigenschaften des Projekts, in unserem Fall

let projectName = "Poseidon"
let projectSummary = "Poseidon - a POS environment"
let authors = ["J. Preiss"]
let mail = "joerg.preiss@slesa.de"
let homepage = "http://github.com/Slesa/Poseidon"

Diese können zum Generieren der Einträge in der AssemblyInfo.cs benutzt werden. Zur Vereinfachung folgen die Definitionen der Verzeichnisse

let binDir = @".\bin\"
let buildDir = binDir @@ @"build\"
let testDir = binDir @@ @"test\"
let reportDir = binDir @@ @"report\"
let deployDir = binDir @@ @"deploy\"
let packagesDir = binDir @@ @"packages"
let mspecDir = packagesDir @@ "MSpec"
let setupDir = @"Setup\"

Um die verschiedenen Programme mit Daten zu füttern, folgt die Liste der zu verarbeitenden Dateien

let appReferences = !! @"src\**\*.*sproj"
let testReferences = !! @"src\**\*.Specs.*sproj"
let deployReferences = !! @"Setup\**\*.wixproj"

Fake setzt die Versionsnummer auf “LocalBuild”, falls keine angegeben wurde. Das führt mitunter zu Fehlern, da es sich aus Sicht von Visual Studio nicht um eine gültige Versionsnummer handelt. Aus diesem Grund wird eine eigene Versionsnummer generiert, falls das Skript nicht auf einem Build-Server ausgeführt wird

let currentVersion =
  if not isLocalBuild then buildVersion else
  "0.0.0.1"

 

Targets

Nachdem alle benötigten Informationen definiert wurden, können wir nun die zu erledigenden Ziele unseres Skripts angeben.

Zuerst das Löschen der temporären Dateien und Ergebnisse bisheriger Builds:

Target "Clean" (fun _ ->
  CleanDirs [buildDir; testDir; deployDir; reportDir; packagesDir]

  CreateDir mspecDir
  !! (@"src\Domain\packages\Machine.Specifications.*\**\*.*")
    |> CopyTo mspecDir
)

Nun das Target zum Erzeugen der AssemblyInfo.cs mit den Versionsinformationen. Im ursprünglichen Beispiel wurde pro Projekt eine eigene Datei mit allen Informationen angelegt. Hier werden die Daten aufgeteilt, die Versionsnummer wird in die Datei VersionInfo.cs geschrieben. Diese muß als Link zu jedem Projekt hinzugefügt werden. Durch diese Vorgehensweise müssen jedoch die Default-Parameter der AssemblyInfo-Funktion – Guid, ComVisible und CLSCompliant – überschrieben werden, da sie sonst doppelt im Projekt vorkommen.

Target "SetAssemblyInfo" (fun _ ->
  AssemblyInfo
    (fun p ->
    {p with
      CodeLanguage = CSharp;
      Guid = "";
      ComVisible = None;
      CLSCompliant = None;
      AssemblyCompany = "Slesa Solutions";
      AssemblyProduct = "Poseidon";
      AssemblyCopyright = "Copyright ©  2012";
      AssemblyTrademark = "GPL V2";
      AssemblyVersion = currentVersion;
      OutputFileName = @".\src\VersionInfo.cs"})
)

Das Erstellen der Programme und Tests wird an MSBuild delegiert:

Target "BuildApp" (fun _ ->
  MSBuildRelease buildDir "Build" appReferences
    |> Log "AppBuild-Output: "
)

Target "BuildTest" (fun _ ->
  MSBuildDebug testDir "Build" testReferences
    |> Log "TestBuildOutput: "
)

Die Tests sind mit Hilfe von Machine.Specifications  implementiert, der Konsolenrunner von MSpec kann von Fake auf die folgende Weise angesprochen werden:

Target "Test" (fun _ ->
  let mspecTool = mspecDir @@ "mspec-clr4.exe"

  !! (testDir @@ "*.Specs.dll")
    |> MSpec (fun p ->
      {p with
        ToolPath = mspecTool
        HtmlOutputDir = reportDir})
)

Nach den Tests soll das Setup-Paket geschnürt werden. Die in Fake integrierten Funktionen des WiX-Installers dienen mehr dazu, Dateien ähnlich den Projektdateien zu sammeln und zu einem einfachen Setup-Programm zu packen. In unserem Fall genügt das nicht, da ein umfangreicheres Setup-Programm geplant ist. Dort sollen die verschiedenen Module selektiert, sowie generelle Einstellungen wie Connect-Strings konfiguriert werden. Deshalb wird hier wiederum MSBuild bemüht, um ein komplettes WiX-Projekt zu erstellen. In der aktuellen Skript-Variante bedeutet dies, daß WiX auf dem Build-Server installiert sein muß. Geplant ist, das Tool-Verzeichnis ebenfalls an MSBuild zu übergeben, sodaß auch die WiX-Binaries der Quellcodeverwaltung anvertraut werden können.

Ein weiteres Problem stellt die Versionsnummer dar. Das hier benutzte MSBuildReleaseExt existiert im Original-Fake nicht. Ich habe es wie folgt implementiert

let MSBuildReleaseExt outputPath properties targets = 
    let properties = ("Configuration", "Release") :: properties; 
    MSBuild outputPath targets properties

Vielleicht findet sich ja noch eine elegantere Möglichkeit, MSBuild weitere Parameter zu übergeben. Das Setup-Target sieht nunmehr so aus:

Target "Deploy" (fun _ ->
  MSBuildReleaseExt deployDir ["Version", currentVersion] "Build" deployReferences
    |> Log "DeployBuildOutput: "
)

Es fehlt noch das Default-Target, das einfach nichts macht:

Target "Default" DoNothing

 

Und ab dafür

Jetzt müssen noch die Abhängigkeiten der verschiedenen Targets definiert werden. Nach dem Aufräumen werden die Assembly-Infos erzeugt, die Programme und Tests werden erzeugt, die Tests werden aufgerufen und das Setup erzeugt. Falls es an irgendeiner Stelle ein Problem gab, bricht der Build-Vorgang ab. Oder anders ausgedrückt:

// Dependencies
"Clean"
  ==> "SetAssemblyInfo"
  ==> "BuildApp" <=> "BuildTest"
  ==> "Test"
  ==> "Deploy"
  ==> "Default"

Es fehlt noch der Aufruf des eigentlichen Builds

RunParameterTargetOrDefault "target" "Default"

 

Fazit

In meinen Augen beschränkt sich Fake beim Anlegen des Build-Skripts auf das wesentliche. Im Gegensatz dazu stehen die XML-basierten Tools, die durch ihre Syntax sehr viel Overhead erzeugen. Neben Fake stehen natürlich noch weitere Alternativen zur Verfügung, etwa Rake oder PSake. In jedem Fall sollte das Erstellen der Software genau so einfach sein: auschecken, Doppelklick auf den Build-Batch – fertig.

Veröffentlicht unter .NET, Build-Tools, F# | 2 Kommentare