Skip to content

Commit f864f57

Browse files
authored
Merge pull request #9607 from keymanapp/feat/developer/kmc-package-windows-installer
feat(developer): Windows package installer compiler 🎺
2 parents 70dfbce + e1087ed commit f864f57

File tree

17 files changed

+382
-71
lines changed

17 files changed

+382
-71
lines changed

common/web/types/src/package/kps-file.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,8 +160,14 @@ export interface KpsFileStartMenuItems {
160160
}
161161

162162
export interface KpsFileStrings {
163-
//TODO: validate this structure
164-
string: string[] | string;
163+
string: KpsFileString[] | KpsFileString;
164+
}
165+
166+
export interface KpsFileString {
167+
$: {
168+
name: string;
169+
value: string;
170+
}
165171
}
166172

167173
export interface KpsFileLanguageExamples {

core/tests/unit/ldml/keyboards/meson.build

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ endforeach
7171

7272
foreach kbd : tests_with_testdata
7373
configure_file(
74-
command: kmc_cmd + ['build-test-data', '@INPUT@', '--out-file', '@OUTPUT@'],
74+
command: kmc_cmd + ['build', 'ldml-test-data', '@INPUT@', '--out-file', '@OUTPUT@'],
7575
input: kbd + '-test.xml',
7676
output: kbd + '-test.json',
7777
)
@@ -90,7 +90,7 @@ foreach kbd : tests_from_cldr
9090
output: kbd + '.kmx',
9191
)
9292
configure_file(
93-
command: kmc_cmd + ['build-test-data', '@INPUT@', '--out-file', '@OUTPUT@'],
93+
command: kmc_cmd + ['build', 'ldml-test-data', '@INPUT@', '--out-file', '@OUTPUT@'],
9494
input: join_paths(ldml_testdata, kbd + '-test.xml'),
9595
output: kbd + '-test.json',
9696
)

developer/src/kmc-package/src/compiler/kmp-compiler.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ export class KmpCompiler {
2222
}
2323

2424
public transformKpsToKmpObject(kpsFilename: string): KmpJsonFile.KmpJsonFile {
25+
const kps = this.loadKpsFile(kpsFilename);
26+
return this.transformKpsFileToKmpObject(kpsFilename, kps);
27+
}
28+
29+
public loadKpsFile(kpsFilename: string): KpsFile.KpsFile {
2530
// Load the KPS data from XML as JS structured data.
2631
const buffer = this.callbacks.loadFile(kpsFilename);
2732
if(!buffer) {
@@ -41,7 +46,11 @@ export class KmpCompiler {
4146
return a;
4247
})();
4348

44-
let kps: KpsFile.KpsFile = kpsPackage.package;
49+
const kps: KpsFile.KpsFile = kpsPackage.package;
50+
return kps;
51+
}
52+
53+
public transformKpsFileToKmpObject(kpsFilename: string, kps: KpsFile.KpsFile): KmpJsonFile.KmpJsonFile {
4554

4655
//
4756
// To convert to kmp.json, we need to:
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
/**
2+
* Create a .exe installer that bundles one or more .kmp files, together with
3+
* setup.exe, keymandesktop.msi, and generates and includes a setup.inf also.
4+
*
5+
* This module is effectively deprecated, but is present to keep parity with the
6+
* legacy kmcomp compiler. Thus, it is included as part of the package compiler,
7+
* and will be removed in a future version.
8+
*
9+
* This tool assumes that the installer .msi is the same version as the
10+
* compiler, unlike the legacy compiler which read this metadata from the .msi.
11+
*/
12+
13+
import JSZip from 'jszip';
14+
import { CompilerCallbacks, KeymanFileTypes, KmpJsonFile, KpsFile } from "@keymanapp/common-types";
15+
import KEYMAN_VERSION from "@keymanapp/keyman-version";
16+
import { KmpCompiler } from "./kmp-compiler.js";
17+
import { CompilerMessages } from "./messages.js";
18+
19+
const SETUP_INF_FILENAME = 'setup.inf';
20+
const PRODUCT_NAME = 'Keyman';
21+
22+
export interface WindowsPackageInstallerSources {
23+
msiFilename: string;
24+
setupExeFilename: string;
25+
licenseFilename: string; // MIT license
26+
titleImageFilename?: string;
27+
28+
appName?: string;
29+
startDisabled: boolean;
30+
startWithConfiguration: boolean;
31+
};
32+
33+
export class WindowsPackageInstallerCompiler {
34+
private kmpCompiler: KmpCompiler;
35+
36+
constructor(private callbacks: CompilerCallbacks) {
37+
this.kmpCompiler = new KmpCompiler(this.callbacks);
38+
}
39+
40+
public async compile(kpsFilename: string, sources: WindowsPackageInstallerSources): Promise<Uint8Array> {
41+
const kps = this.kmpCompiler.loadKpsFile(kpsFilename);
42+
43+
// Check existence of required files
44+
for(const filename of [sources.licenseFilename, sources.msiFilename, sources.setupExeFilename]) {
45+
if(!this.callbacks.fs.existsSync(filename)) {
46+
this.callbacks.reportMessage(CompilerMessages.Error_FileDoesNotExist({filename}));
47+
return null;
48+
}
49+
}
50+
51+
// Check existence of optional files
52+
for(const filename of [sources.titleImageFilename]) {
53+
if(filename && !this.callbacks.fs.existsSync(filename)) {
54+
this.callbacks.reportMessage(CompilerMessages.Error_FileDoesNotExist({filename}));
55+
return null;
56+
}
57+
}
58+
59+
// Note: we never use the MSIFileName field from the .kps any more
60+
// Nor do we use the MSIOptions field.
61+
62+
// Build the zip
63+
const zipBuffer = await this.buildZip(kps, kpsFilename, sources);
64+
if(!zipBuffer) {
65+
// Error messages already reported by buildZip
66+
return null;
67+
}
68+
69+
// Build the sfx
70+
const sfxBuffer = this.buildSfx(zipBuffer, sources);
71+
return sfxBuffer;
72+
}
73+
74+
private async buildZip(kps: KpsFile.KpsFile, kpsFilename: string, sources: WindowsPackageInstallerSources): Promise<Uint8Array> {
75+
const kmpJson: KmpJsonFile.KmpJsonFile = this.kmpCompiler.transformKpsFileToKmpObject(kpsFilename, kps);
76+
if(!kmpJson.info?.name?.description) {
77+
this.callbacks.reportMessage(CompilerMessages.Error_PackageNameCannotBeBlank());
78+
return null;
79+
}
80+
81+
const kmpFilename = this.callbacks.path.basename(kpsFilename, KeymanFileTypes.Source.Package) + KeymanFileTypes.Binary.Package;
82+
const setupInfBuffer = this.buildSetupInf(sources, kmpJson, kmpFilename, kps);
83+
const kmpBuffer = await this.kmpCompiler.buildKmpFile(kpsFilename, kmpJson);
84+
85+
// Note that this does not technically generate a "valid" sfx according to
86+
// the zip spec, because the offsets in the .zip are relative to the start
87+
// of the zip, rather than to the start of the sfx. However, as Keyman's
88+
// installer chops out the zip from the sfx and loads it in a new stream, it
89+
// works as expected.
90+
const zip = JSZip();
91+
zip.file(SETUP_INF_FILENAME, setupInfBuffer);
92+
zip.file(kmpFilename, kmpBuffer);
93+
zip.file(this.callbacks.path.basename(sources.msiFilename), this.callbacks.loadFile(sources.msiFilename));
94+
zip.file(this.callbacks.path.basename(sources.licenseFilename), this.callbacks.loadFile(sources.licenseFilename));
95+
if(sources.titleImageFilename) {
96+
zip.file(this.callbacks.path.basename(sources.titleImageFilename), this.callbacks.loadFile(sources.titleImageFilename));
97+
}
98+
99+
return zip.generateAsync({type: 'uint8array', compression:'DEFLATE'});
100+
}
101+
102+
private buildSetupInf(sources: WindowsPackageInstallerSources, kmpJson: KmpJsonFile.KmpJsonFile, kmpFilename: string, kps: KpsFile.KpsFile) {
103+
let setupInf = `[Setup]
104+
Version=${KEYMAN_VERSION.VERSION}
105+
MSIFileName=${this.callbacks.path.basename(sources.msiFilename)}
106+
MSIOptions=
107+
AppName=${sources.appName ?? PRODUCT_NAME}
108+
License=${this.callbacks.path.basename(sources.licenseFilename)}
109+
`;
110+
if (sources.titleImageFilename) {
111+
setupInf += `TitleImage=${this.callbacks.path.basename(sources.titleImageFilename)}\n`;
112+
}
113+
if (kmpJson.options.graphicFile) {
114+
setupInf += `BitmapFileName=${this.callbacks.path.basename(kmpJson.options.graphicFile)}\n`;
115+
}
116+
if (sources.startDisabled) {
117+
setupInf += `StartDisabled=True\n`;
118+
}
119+
if (sources.startWithConfiguration) {
120+
setupInf += `StartWithConfiguration=True\n`;
121+
}
122+
123+
setupInf += `\n[Packages]\n`;
124+
setupInf += kmpFilename + '\n';
125+
// TODO: multiple packages?
126+
const strings = !kps.strings?.string ? [] : (Array.isArray(kps.strings.string) ? kps.strings.string : [kps.strings.string]);
127+
if (strings.length) {
128+
setupInf += `\n[Strings]\n`;
129+
for (const str of strings) {
130+
setupInf += `${str.$?.name}=${str.$?.value}\n`;
131+
}
132+
}
133+
134+
const setupInfBuffer = new TextEncoder().encode(setupInf);
135+
return setupInfBuffer;
136+
}
137+
138+
private buildSfx(zipBuffer: Uint8Array, sources: WindowsPackageInstallerSources): Uint8Array {
139+
const setupRedistBuffer = this.callbacks.loadFile(sources.setupExeFilename);
140+
const buffer = new Uint8Array(setupRedistBuffer.length + zipBuffer.length);
141+
buffer.set(setupRedistBuffer, 0);
142+
buffer.set(zipBuffer, setupRedistBuffer.length);
143+
return buffer;
144+
}
145+
}
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export { KmpCompiler } from "./compiler/kmp-compiler.js";
2-
export { PackageValidation } from "./compiler/package-validation.js";
2+
export { PackageValidation } from "./compiler/package-validation.js";
3+
export { WindowsPackageInstallerSources, WindowsPackageInstallerCompiler } from "./compiler/windows-package-installer-compiler.js";
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
This placeholder file represents keymandesktop.msi, the Windows Installer package for Keyman for Windows.
2+
3+
(Not including the real file to reduce file size in repo, as it is not necessary in order to complete the unit test)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
This placeholder file represents license.txt.
2+
3+
(Not including the real file to reduce file size in repo, as it is not necessary in order to complete the unit test)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
This placeholder file represents setup-redist.exe, the sfx loader.
2+
3+
(Not including the real file to reduce file size in repo, as it is not necessary in order to complete the unit test)
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import 'mocha';
2+
import * as fs from 'fs';
3+
import * as path from 'path';
4+
import { assert } from 'chai';
5+
import JSZip from 'jszip';
6+
import KEYMAN_VERSION from "@keymanapp/keyman-version";
7+
import { TestCompilerCallbacks } from '@keymanapp/developer-test-helpers';
8+
import { makePathToFixture } from './helpers/index.js';
9+
import { WindowsPackageInstallerCompiler, WindowsPackageInstallerSources } from '../src/compiler/windows-package-installer-compiler.js';
10+
11+
describe('WindowsPackageInstallerCompiler', function () {
12+
it(`should build an SFX archive`, async function () {
13+
this.timeout(10000); // this test can take a little while to run
14+
15+
const callbacks = new TestCompilerCallbacks();
16+
let compiler = new WindowsPackageInstallerCompiler(callbacks);
17+
18+
const kpsPath = makePathToFixture('khmer_angkor', 'source', 'khmer_angkor.kps');
19+
const sources: WindowsPackageInstallerSources = {
20+
licenseFilename: makePathToFixture('windows-installer', 'license.txt'),
21+
msiFilename: makePathToFixture('windows-installer', 'keymandesktop.txt'),
22+
setupExeFilename: makePathToFixture('windows-installer', 'setup.txt'),
23+
startDisabled: false,
24+
startWithConfiguration: true,
25+
appName: 'Testing',
26+
};
27+
28+
const sfxBuffer = await compiler.compile(kpsPath, sources);
29+
30+
// This returns a buffer with a SFX loader and a zip suffix. For the sake of repository size
31+
// we actually provide a stub SFX loader and a stub MSI file, which is enough to verify that
32+
// the compiler is generating what it thinks is a valid file.
33+
34+
const zip = JSZip();
35+
36+
// Check that file.kmp contains just 3 files - setup.inf, keymandesktop.msi, and khmer_angkor.kmp,
37+
// and that they match exactly what we expect
38+
const setupExeSize = fs.statSync(sources.setupExeFilename).size;
39+
const setupExe = sfxBuffer.slice(0, setupExeSize);
40+
const zipBuffer = sfxBuffer.slice(setupExeSize);
41+
42+
// Verify setup.exe sfx loader
43+
const setupExeFixture = fs.readFileSync(sources.setupExeFilename);
44+
assert.deepEqual(setupExe, setupExeFixture);
45+
46+
// Load the zip from the buffer
47+
const zipFile = await zip.loadAsync(zipBuffer, {checkCRC32: true});
48+
49+
// Verify setup.inf; note that BitmapFileName splash.gif comes from the .kmp
50+
const setupInfFixture = `[Setup]
51+
Version=${KEYMAN_VERSION.VERSION}
52+
MSIFileName=${path.basename(sources.msiFilename)}
53+
MSIOptions=
54+
AppName=${sources.appName}
55+
License=${path.basename(sources.licenseFilename)}
56+
BitmapFileName=splash.gif
57+
StartWithConfiguration=True
58+
59+
[Packages]
60+
khmer_angkor.kmp
61+
`;
62+
63+
const setupInf = new TextDecoder().decode(await zipFile.file('setup.inf').async('uint8array'));
64+
assert.equal(setupInf.trim(), setupInfFixture.trim());
65+
66+
const verifyFile = async (filename: string) => {
67+
const fixture = fs.readFileSync(filename);
68+
const file = await zipFile.file(path.basename(filename)).async('uint8array');
69+
assert.deepEqual(file, fixture, `File in zip '${filename}' did not match fixture`);
70+
};
71+
72+
await verifyFile(sources.msiFilename);
73+
await verifyFile(sources.licenseFilename);
74+
75+
// We only test for existence of the file in the zip for now
76+
const kmp = await zipFile.file('khmer_angkor.kmp').async('uint8array');
77+
assert.isNotEmpty(kmp);
78+
});
79+
});

developer/src/kmc/README.md

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,17 +38,29 @@ To see more command line options by using the `--help` option:
3838

3939
kmc --help
4040

41-
kmlmc Usage
42-
-----------
41+
---
4342

44-
To compile a lexical model from its `.model.ts` source, use `kmlmc`:
43+
To compile a lexical model from its `.model.ts` source, use `kmc`:
4544

46-
kmlmc my-lexical-model.model.ts --outFile my-lexical-model.js
45+
kmc build my-lexical-model.model.ts --outFile my-lexical-model.model.js
4746

4847
To see more command line options by using the `--help` option:
4948

50-
kmlmc --help
51-
kmlmp --help
49+
kmc --help
50+
51+
---
52+
53+
kmc can now build package installers for Windows. Example usage (Bash on
54+
Windows, using 'node .' instead of 'kmc' to run the local build):
55+
56+
```
57+
node . build windows-package-installer \
58+
$KEYMAN_ROOT/developer/src/kmc-package/test/fixtures/khmer_angkor/source/khmer_angkor.kps \
59+
--msi /c/Program\ Files\ \(x86\)/Common\ Files/Keyman/Cached\ Installer\ Files/keymandesktop.msi \
60+
--exe $KEYMAN_ROOT/windows/bin/desktop/setup-redist.exe \
61+
--license $KEYMAN_ROOT/LICENSE.md \
62+
--out-file ./khmer.exe
63+
```
5264

5365
How to build from source
5466
------------------------

0 commit comments

Comments
 (0)