Initial Hex Mines fork with hex tiles and mine-count slider.
Based on StefanOltmann/mines (AGPL-3.0): flat-top hex grid, configurable board size, and a mine-count slider replacing fixed difficulty presets. Co-authored-by: Cursor <[email protected]>
@@ -0,0 +1,26 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
indent_size = 4
|
||||||
|
indent_style = space
|
||||||
|
insert_final_newline = true
|
||||||
|
max_line_length = 120
|
||||||
|
tab_width = 4
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
ij_continuation_indent_size = 4
|
||||||
|
ij_formatter_off_tag = @formatter:off
|
||||||
|
ij_formatter_on_tag = @formatter:on
|
||||||
|
ij_formatter_tags_enabled = true
|
||||||
|
ij_smart_tabs = false
|
||||||
|
ij_wrap_on_typing = false
|
||||||
|
ij_visual_guides = 100, 110, 120
|
||||||
|
|
||||||
|
[{*.js,*.yml,*.json,gradlew}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[{*.yml,*.md,*.txt}]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
max_line_length = off
|
||||||
|
ij_visual_guides = 80
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
github: StefanOltmann
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
|
||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2018 GitHub, Inc. and contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Sign Android Release Action
|
||||||
|
|
||||||
|
This action will help you sign an Android `.apk` or `.aab` (Android App Bundle) file for release.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
### `releaseDirectory`
|
||||||
|
|
||||||
|
**Required:** The relative directory path in your project where your Android release file will be
|
||||||
|
located
|
||||||
|
|
||||||
|
### `signingKeyBase64`
|
||||||
|
|
||||||
|
**Required:** The base64 encoded signing key used to sign your app
|
||||||
|
|
||||||
|
This action will directly decode this input to a file to sign your release with. You can prepare
|
||||||
|
your key by running this command on *nix systems.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl base64 < some_signing_key.jks | tr -d '\n' | tee some_signing_key.jks.base64.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Then copy the contents of the `.txt` file to your GH secrets
|
||||||
|
|
||||||
|
### `alias`
|
||||||
|
|
||||||
|
**Required:** The alias of your signing key
|
||||||
|
|
||||||
|
### `keyStorePassword`
|
||||||
|
|
||||||
|
**Required:** The password to your signing keystore
|
||||||
|
|
||||||
|
### `keyPassword`
|
||||||
|
|
||||||
|
**Optional:** The private key password for your signing keystore
|
||||||
|
|
||||||
|
## ENV: `BUILD_TOOLS_VERSION`
|
||||||
|
|
||||||
|
**Optional:** You can manually specify a version of build-tools to use. We use `29.0.3` by default.
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
### `signedReleaseFile`
|
||||||
|
|
||||||
|
The path to the signed release file from this action
|
||||||
|
|
||||||
|
### ENV: `SIGNED_RELEASE_FILE`
|
||||||
|
|
||||||
|
This also set's an environment variable that points to the signed release file
|
||||||
|
|
||||||
|
## Example usage
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
steps:
|
||||||
|
# ...
|
||||||
|
|
||||||
|
- uses: r0adkll/sign-android-release@v1
|
||||||
|
name: Sign app APK
|
||||||
|
# ID used to access action output
|
||||||
|
id: sign_app
|
||||||
|
with:
|
||||||
|
releaseDirectory: app/build/outputs/apk/release
|
||||||
|
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
|
||||||
|
alias: ${{ secrets.ALIAS }}
|
||||||
|
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
|
||||||
|
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
||||||
|
env:
|
||||||
|
BUILD_TOOLS_VERSION: "30.0.2"
|
||||||
|
|
||||||
|
# Example use of `signedReleaseFile` output -- not needed
|
||||||
|
- uses: actions/upload-artifact@v2
|
||||||
|
with:
|
||||||
|
name: Signed app bundle
|
||||||
|
path: ${{steps.sign_app.outputs.signedReleaseFile}}
|
||||||
|
```
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
name: 'Sign Android release'
|
||||||
|
description: 'An action to sign an Android release APK or AAB'
|
||||||
|
author: 'r0adkll'
|
||||||
|
branding:
|
||||||
|
icon: 'edit'
|
||||||
|
color: 'orange'
|
||||||
|
inputs:
|
||||||
|
releaseDirectory:
|
||||||
|
description: 'The directory to find your release to sign'
|
||||||
|
required: true
|
||||||
|
signingKeyBase64:
|
||||||
|
description: 'The key used to sign your release in base64 encoded format'
|
||||||
|
required: true
|
||||||
|
alias:
|
||||||
|
description: 'The key alias'
|
||||||
|
required: true
|
||||||
|
keyStorePassword:
|
||||||
|
description: 'The password to the keystore'
|
||||||
|
required: true
|
||||||
|
keyPassword:
|
||||||
|
description: 'The password for the key'
|
||||||
|
required: false
|
||||||
|
outputs:
|
||||||
|
signedReleaseFile:
|
||||||
|
description: 'The signed release APK or AAB file'
|
||||||
|
runs:
|
||||||
|
using: 'node12'
|
||||||
|
main: 'lib/main.js'
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"use strict";
|
||||||
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||||
|
return (mod && mod.__esModule) ? mod : {"default": mod};
|
||||||
|
};
|
||||||
|
Object.defineProperty(exports, "__esModule", {value: true});
|
||||||
|
exports.findReleaseFile = exports.getReleaseFile = void 0;
|
||||||
|
const fs_1 = __importDefault(require("fs"));
|
||||||
|
|
||||||
|
function getReleaseFile(files) {
|
||||||
|
if (files.length > 0) {
|
||||||
|
return files[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.getReleaseFile = getReleaseFile;
|
||||||
|
|
||||||
|
function findReleaseFile(releaseDir) {
|
||||||
|
const releaseFiles = fs_1.default.readdirSync(releaseDir, {withFileTypes: true})
|
||||||
|
.filter(item => !item.isDirectory())
|
||||||
|
.filter(item => item.name.endsWith(".apk") || item.name.endsWith(".aab"));
|
||||||
|
return getReleaseFile(releaseFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.findReleaseFile = findReleaseFile;
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"use strict";
|
||||||
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function (o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
Object.defineProperty(o, k2, {
|
||||||
|
enumerable: true, get: function () {
|
||||||
|
return m[k];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}) : (function (o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
o[k2] = m[k];
|
||||||
|
}));
|
||||||
|
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function (o, v) {
|
||||||
|
Object.defineProperty(o, "default", {enumerable: true, value: v});
|
||||||
|
}) : function (o, v) {
|
||||||
|
o["default"] = v;
|
||||||
|
});
|
||||||
|
var __importStar = (this && this.__importStar) || function (mod) {
|
||||||
|
if (mod && mod.__esModule) return mod;
|
||||||
|
var result = {};
|
||||||
|
if (mod != null) for (var k in mod) if (k !== "default" && Object.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
||||||
|
__setModuleDefault(result, mod);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
||||||
|
function adopt(value) {
|
||||||
|
return value instanceof P ? value : new P(function (resolve) {
|
||||||
|
resolve(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new (P || (P = Promise))(function (resolve, reject) {
|
||||||
|
function fulfilled(value) {
|
||||||
|
try {
|
||||||
|
step(generator.next(value));
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejected(value) {
|
||||||
|
try {
|
||||||
|
step(generator["throw"](value));
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function step(result) {
|
||||||
|
result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected);
|
||||||
|
}
|
||||||
|
|
||||||
|
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||||
|
return (mod && mod.__esModule) ? mod : {"default": mod};
|
||||||
|
};
|
||||||
|
Object.defineProperty(exports, "__esModule", {value: true});
|
||||||
|
const core = __importStar(require("@actions/core"));
|
||||||
|
const signing_1 = require("./signing");
|
||||||
|
const path_1 = __importDefault(require("path"));
|
||||||
|
const fs_1 = __importDefault(require("fs"));
|
||||||
|
const io = __importStar(require("./io-utils"));
|
||||||
|
|
||||||
|
function run() {
|
||||||
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
|
try {
|
||||||
|
if (process.env.DEBUG_ACTION === 'true') {
|
||||||
|
core.debug("DEBUG FLAG DETECTED, SHORTCUTTING ACTION.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const releaseDir = core.getInput('releaseDirectory');
|
||||||
|
const signingKeyBase64 = core.getInput('signingKeyBase64');
|
||||||
|
const alias = core.getInput('alias');
|
||||||
|
const keyStorePassword = core.getInput('keyStorePassword');
|
||||||
|
const keyPassword = core.getInput('keyPassword');
|
||||||
|
console.log(`Preparing to sign key @ ${releaseDir} with signing key`);
|
||||||
|
// 1. Find release file
|
||||||
|
const releaseFile = io.findReleaseFile(releaseDir);
|
||||||
|
if (releaseFile !== undefined) {
|
||||||
|
core.debug(`Found release to sign: ${releaseFile.name}`);
|
||||||
|
// 3. Now that we have a release file, decode and save the signing key
|
||||||
|
const signingKey = path_1.default.join(releaseDir, 'signingKey.jks');
|
||||||
|
fs_1.default.writeFileSync(signingKey, signingKeyBase64, 'base64');
|
||||||
|
// 4. Now zipalign the release file
|
||||||
|
const releaseFilePath = path_1.default.join(releaseDir, releaseFile.name);
|
||||||
|
let signedReleaseFile = '';
|
||||||
|
if (releaseFile.name.endsWith('.apk')) {
|
||||||
|
signedReleaseFile = yield signing_1.signApkFile(releaseFilePath, signingKey, alias, keyStorePassword, keyPassword);
|
||||||
|
} else if (releaseFile.name.endsWith('.aab')) {
|
||||||
|
signedReleaseFile = yield signing_1.signAabFile(releaseFilePath, signingKey, alias, keyStorePassword, keyPassword);
|
||||||
|
} else {
|
||||||
|
core.error('No valid release file to sign, abort.');
|
||||||
|
core.setFailed('No valid release file to sign.');
|
||||||
|
}
|
||||||
|
console.log('Release signed!');
|
||||||
|
core.debug('Release signed! Setting outputs');
|
||||||
|
core.exportVariable("SIGNED_RELEASE_FILE", signedReleaseFile);
|
||||||
|
core.setOutput('signedReleaseFile', signedReleaseFile);
|
||||||
|
} else {
|
||||||
|
core.error("No release file (.apk or .aab) could be found. Abort.");
|
||||||
|
core.setFailed('No release file (.apk or .aab) could be found.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
core.setFailed(error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"use strict";
|
||||||
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function (o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
Object.defineProperty(o, k2, {
|
||||||
|
enumerable: true, get: function () {
|
||||||
|
return m[k];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}) : (function (o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
o[k2] = m[k];
|
||||||
|
}));
|
||||||
|
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function (o, v) {
|
||||||
|
Object.defineProperty(o, "default", {enumerable: true, value: v});
|
||||||
|
}) : function (o, v) {
|
||||||
|
o["default"] = v;
|
||||||
|
});
|
||||||
|
var __importStar = (this && this.__importStar) || function (mod) {
|
||||||
|
if (mod && mod.__esModule) return mod;
|
||||||
|
var result = {};
|
||||||
|
if (mod != null) for (var k in mod) if (k !== "default" && Object.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
||||||
|
__setModuleDefault(result, mod);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
||||||
|
function adopt(value) {
|
||||||
|
return value instanceof P ? value : new P(function (resolve) {
|
||||||
|
resolve(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new (P || (P = Promise))(function (resolve, reject) {
|
||||||
|
function fulfilled(value) {
|
||||||
|
try {
|
||||||
|
step(generator.next(value));
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejected(value) {
|
||||||
|
try {
|
||||||
|
step(generator["throw"](value));
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function step(result) {
|
||||||
|
result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected);
|
||||||
|
}
|
||||||
|
|
||||||
|
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
Object.defineProperty(exports, "__esModule", {value: true});
|
||||||
|
exports.signAabFile = exports.signApkFile = void 0;
|
||||||
|
const exec = __importStar(require("@actions/exec"));
|
||||||
|
const core = __importStar(require("@actions/core"));
|
||||||
|
const io = __importStar(require("@actions/io"));
|
||||||
|
const path = __importStar(require("path"));
|
||||||
|
const fs = __importStar(require("fs"));
|
||||||
|
|
||||||
|
function signApkFile(apkFile, signingKeyFile, alias, keyStorePassword, keyPassword) {
|
||||||
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
|
core.debug("Zipaligning APK file");
|
||||||
|
// Find zipalign executable
|
||||||
|
const buildToolsVersion = process.env.BUILD_TOOLS_VERSION || '34.0.0';
|
||||||
|
const androidHome = process.env.ANDROID_HOME;
|
||||||
|
const buildTools = path.join(androidHome, `build-tools/${buildToolsVersion}`);
|
||||||
|
if (!fs.existsSync(buildTools)) {
|
||||||
|
core.error(`Couldnt find the Android build tools @ ${buildTools}`);
|
||||||
|
}
|
||||||
|
const zipAlign = path.join(buildTools, 'zipalign');
|
||||||
|
core.debug(`Found 'zipalign' @ ${zipAlign}`);
|
||||||
|
// Align the apk file
|
||||||
|
const alignedApkFile = apkFile.replace('.apk', '-aligned.apk');
|
||||||
|
yield exec.exec(`"${zipAlign}"`, [
|
||||||
|
'-c',
|
||||||
|
'-v', '4',
|
||||||
|
apkFile
|
||||||
|
]);
|
||||||
|
yield exec.exec(`"cp"`, [
|
||||||
|
apkFile,
|
||||||
|
alignedApkFile
|
||||||
|
]);
|
||||||
|
core.debug("Signing APK file");
|
||||||
|
// find apksigner path
|
||||||
|
const apkSigner = path.join(buildTools, 'apksigner');
|
||||||
|
core.debug(`Found 'apksigner' @ ${apkSigner}`);
|
||||||
|
// apksigner sign --ks my-release-key.jks --out my-app-release.apk my-app-unsigned-aligned.apk
|
||||||
|
const signedApkFile = apkFile.replace('.apk', '-signed.apk');
|
||||||
|
const args = [
|
||||||
|
'sign',
|
||||||
|
'--ks', signingKeyFile,
|
||||||
|
'--ks-key-alias', alias,
|
||||||
|
'--ks-pass', `pass:${keyStorePassword}`,
|
||||||
|
'--out', signedApkFile
|
||||||
|
];
|
||||||
|
if (keyPassword) {
|
||||||
|
args.push('--key-pass', `pass:${keyPassword}`);
|
||||||
|
}
|
||||||
|
args.push(alignedApkFile);
|
||||||
|
yield exec.exec(`"${apkSigner}"`, args);
|
||||||
|
// Verify
|
||||||
|
core.debug("Verifying Signed APK");
|
||||||
|
yield exec.exec(`"${apkSigner}"`, [
|
||||||
|
'verify',
|
||||||
|
signedApkFile
|
||||||
|
]);
|
||||||
|
return signedApkFile;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.signApkFile = signApkFile;
|
||||||
|
|
||||||
|
function signAabFile(aabFile, signingKeyFile, alias, keyStorePassword, keyPassword) {
|
||||||
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
|
core.debug("Signing AAB file");
|
||||||
|
const jarSignerPath = yield io.which('jarsigner', true);
|
||||||
|
core.debug(`Found 'jarsigner' @ ${jarSignerPath}`);
|
||||||
|
const args = [
|
||||||
|
'-keystore', signingKeyFile,
|
||||||
|
'-storepass', keyStorePassword,
|
||||||
|
];
|
||||||
|
if (keyPassword) {
|
||||||
|
args.push('-keypass', keyPassword);
|
||||||
|
}
|
||||||
|
args.push(aabFile, alias);
|
||||||
|
yield exec.exec(`"${jarSignerPath}"`, args);
|
||||||
|
return aabFile;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.signAabFile = signAabFile;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright 2019 GitHub
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||||
|
associated documentation files (the "Software"), to deal in the Software without restriction,
|
||||||
|
including without limitation the rights to use, copy, modify, merge, publish, distribute,
|
||||||
|
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||||
|
portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
|
||||||
|
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||||
|
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES
|
||||||
|
OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||||
|
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
# `@actions/core`
|
||||||
|
|
||||||
|
> Core functions for setting results, logging, registering secrets and exporting variables across actions
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Import the package
|
||||||
|
|
||||||
|
```js
|
||||||
|
// javascript
|
||||||
|
const core = require('@actions/core');
|
||||||
|
|
||||||
|
// typescript
|
||||||
|
import * as core from '@actions/core';
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Inputs/Outputs
|
||||||
|
|
||||||
|
Action inputs can be read with `getInput`. Outputs can be set with `setOutput` which makes them
|
||||||
|
available to be mapped into inputs of other actions to ensure they are decoupled.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const myInput = core.getInput('inputName', { required: true });
|
||||||
|
|
||||||
|
core.setOutput('outputKey', 'outputVal');
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Exporting variables
|
||||||
|
|
||||||
|
Since each step runs in a separate process, you can use `exportVariable` to add it to this step and
|
||||||
|
future steps environment blocks.
|
||||||
|
|
||||||
|
```js
|
||||||
|
core.exportVariable('envVar', 'Val');
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Setting a secret
|
||||||
|
|
||||||
|
Setting a secret registers the secret with the runner to ensure it is masked in logs.
|
||||||
|
|
||||||
|
```js
|
||||||
|
core.setSecret('myPassword');
|
||||||
|
```
|
||||||
|
|
||||||
|
#### PATH Manipulation
|
||||||
|
|
||||||
|
To make a tool's path available in the path for the remainder of the job (without altering the
|
||||||
|
machine or containers state), use `addPath`. The runner will prepend the path given to the jobs
|
||||||
|
PATH.
|
||||||
|
|
||||||
|
```js
|
||||||
|
core.addPath('/path/to/mytool');
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Exit codes
|
||||||
|
|
||||||
|
You should use this library to set the failing exit code for your action. If status is not set and
|
||||||
|
the script runs to completion, that will lead to a success.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const core = require('@actions/core');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Do stuff
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
// setFailed logs the message and sets a failing exit code
|
||||||
|
core.setFailed(`Action failed with error ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
Note that `setNeutral` is not yet implemented in actions V2 but equivalent functionality is being planned.
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Logging
|
||||||
|
|
||||||
|
Finally, this library provides some utilities for logging. Note that debug logging is hidden from
|
||||||
|
the logs by default. This behavior can be toggled by enabling
|
||||||
|
the [Step Debug Logs](../../docs/action-debugging.md#step-debug-logs).
|
||||||
|
|
||||||
|
```js
|
||||||
|
const core = require('@actions/core');
|
||||||
|
|
||||||
|
const myInput = core.getInput('input');
|
||||||
|
try {
|
||||||
|
core.debug('Inside try block');
|
||||||
|
|
||||||
|
if (!myInput) {
|
||||||
|
core.warning('myInput was not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (core.isDebug()) {
|
||||||
|
// curl -v https://github.com
|
||||||
|
} else {
|
||||||
|
// curl https://github.com
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do stuff
|
||||||
|
core.info('Output to the actions build log')
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
core.error(`Error ${err}, action may still succeed though`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This library can also wrap chunks of output in foldable groups.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const core = require('@actions/core')
|
||||||
|
|
||||||
|
// Manually wrap output
|
||||||
|
core.startGroup('Do some function')
|
||||||
|
doSomeFunction()
|
||||||
|
core.endGroup()
|
||||||
|
|
||||||
|
// Wrap an asynchronous function call
|
||||||
|
const result = await core.group('Do something async', async () => {
|
||||||
|
const response = await doSomeHTTPRequest()
|
||||||
|
return response
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Action state
|
||||||
|
|
||||||
|
You can use this library to save state and get state for sharing information between a given wrapper
|
||||||
|
action:
|
||||||
|
|
||||||
|
**action.yml**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: 'Wrapper action sample'
|
||||||
|
inputs:
|
||||||
|
name:
|
||||||
|
default: 'GitHub'
|
||||||
|
runs:
|
||||||
|
using: 'node12'
|
||||||
|
main: 'main.js'
|
||||||
|
post: 'cleanup.js'
|
||||||
|
```
|
||||||
|
|
||||||
|
In action's `main.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const core = require('@actions/core');
|
||||||
|
|
||||||
|
core.saveState("pidToKill", 12345);
|
||||||
|
```
|
||||||
|
|
||||||
|
In action's `cleanup.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const core = require('@actions/core');
|
||||||
|
|
||||||
|
var pid = core.getState("pidToKill");
|
||||||
|
|
||||||
|
process.kill(pid);
|
||||||
|
```
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
interface CommandProperties {
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Commands
|
||||||
|
*
|
||||||
|
* Command Format:
|
||||||
|
* ::name key=value,key=value::message
|
||||||
|
*
|
||||||
|
* Examples:
|
||||||
|
* ::warning::This is the message
|
||||||
|
* ::set-env name=MY_VAR::some value
|
||||||
|
*/
|
||||||
|
export declare function issueCommand(command: string, properties: CommandProperties, message: any): void;
|
||||||
|
export declare function issue(name: string, message?: string): void;
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"use strict";
|
||||||
|
var __importStar = (this && this.__importStar) || function (mod) {
|
||||||
|
if (mod && mod.__esModule) return mod;
|
||||||
|
var result = {};
|
||||||
|
if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k];
|
||||||
|
result["default"] = mod;
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
const os = __importStar(require("os"));
|
||||||
|
const utils_1 = require("./utils");
|
||||||
|
/**
|
||||||
|
* Commands
|
||||||
|
*
|
||||||
|
* Command Format:
|
||||||
|
* ::name key=value,key=value::message
|
||||||
|
*
|
||||||
|
* Examples:
|
||||||
|
* ::warning::This is the message
|
||||||
|
* ::set-env name=MY_VAR::some value
|
||||||
|
*/
|
||||||
|
function issueCommand(command, properties, message) {
|
||||||
|
const cmd = new Command(command, properties, message);
|
||||||
|
process.stdout.write(cmd.toString() + os.EOL);
|
||||||
|
}
|
||||||
|
exports.issueCommand = issueCommand;
|
||||||
|
function issue(name, message = '') {
|
||||||
|
issueCommand(name, {}, message);
|
||||||
|
}
|
||||||
|
exports.issue = issue;
|
||||||
|
const CMD_STRING = '::';
|
||||||
|
class Command {
|
||||||
|
constructor(command, properties, message) {
|
||||||
|
if (!command) {
|
||||||
|
command = 'missing.command';
|
||||||
|
}
|
||||||
|
this.command = command;
|
||||||
|
this.properties = properties;
|
||||||
|
this.message = message;
|
||||||
|
}
|
||||||
|
toString() {
|
||||||
|
let cmdStr = CMD_STRING + this.command;
|
||||||
|
if (this.properties && Object.keys(this.properties).length > 0) {
|
||||||
|
cmdStr += ' ';
|
||||||
|
let first = true;
|
||||||
|
for (const key in this.properties) {
|
||||||
|
if (this.properties.hasOwnProperty(key)) {
|
||||||
|
const val = this.properties[key];
|
||||||
|
if (val) {
|
||||||
|
if (first) {
|
||||||
|
first = false;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
cmdStr += ',';
|
||||||
|
}
|
||||||
|
cmdStr += `${key}=${escapeProperty(val)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cmdStr += `${CMD_STRING}${escapeData(this.message)}`;
|
||||||
|
return cmdStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function escapeData(s) {
|
||||||
|
return utils_1.toCommandValue(s)
|
||||||
|
.replace(/%/g, '%25')
|
||||||
|
.replace(/\r/g, '%0D')
|
||||||
|
.replace(/\n/g, '%0A');
|
||||||
|
}
|
||||||
|
function escapeProperty(s) {
|
||||||
|
return utils_1.toCommandValue(s)
|
||||||
|
.replace(/%/g, '%25')
|
||||||
|
.replace(/\r/g, '%0D')
|
||||||
|
.replace(/\n/g, '%0A')
|
||||||
|
.replace(/:/g, '%3A')
|
||||||
|
.replace(/,/g, '%2C');
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=command.js.map
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"command.js","sourceRoot":"","sources":["../src/command.ts"],"names":[],"mappings":";;;;;;;;;AAAA,uCAAwB;AACxB,mCAAsC;AAWtC;;;;;;;;;GASG;AACH,SAAgB,YAAY,CAC1B,OAAe,EACf,UAA6B,EAC7B,OAAY;IAEZ,MAAM,GAAG,GAAG,IAAI,OAAO,CAAC,OAAO,EAAE,UAAU,EAAE,OAAO,CAAC,CAAA;IACrD,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,QAAQ,EAAE,GAAG,EAAE,CAAC,GAAG,CAAC,CAAA;AAC/C,CAAC;AAPD,oCAOC;AAED,SAAgB,KAAK,CAAC,IAAY,EAAE,UAAkB,EAAE;IACtD,YAAY,CAAC,IAAI,EAAE,EAAE,EAAE,OAAO,CAAC,CAAA;AACjC,CAAC;AAFD,sBAEC;AAED,MAAM,UAAU,GAAG,IAAI,CAAA;AAEvB,MAAM,OAAO;IAKX,YAAY,OAAe,EAAE,UAA6B,EAAE,OAAe;QACzE,IAAI,CAAC,OAAO,EAAE;YACZ,OAAO,GAAG,iBAAiB,CAAA;SAC5B;QAED,IAAI,CAAC,OAAO,GAAG,OAAO,CAAA;QACtB,IAAI,CAAC,UAAU,GAAG,UAAU,CAAA;QAC5B,IAAI,CAAC,OAAO,GAAG,OAAO,CAAA;IACxB,CAAC;IAED,QAAQ;QACN,IAAI,MAAM,GAAG,UAAU,GAAG,IAAI,CAAC,OAAO,CAAA;QAEtC,IAAI,IAAI,CAAC,UAAU,IAAI,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,MAAM,GAAG,CAAC,EAAE;YAC9D,MAAM,IAAI,GAAG,CAAA;YACb,IAAI,KAAK,GAAG,IAAI,CAAA;YAChB,KAAK,MAAM,GAAG,IAAI,IAAI,CAAC,UAAU,EAAE;gBACjC,IAAI,IAAI,CAAC,UAAU,CAAC,cAAc,CAAC,GAAG,CAAC,EAAE;oBACvC,MAAM,GAAG,GAAG,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,CAAA;oBAChC,IAAI,GAAG,EAAE;wBACP,IAAI,KAAK,EAAE;4BACT,KAAK,GAAG,KAAK,CAAA;yBACd;6BAAM;4BACL,MAAM,IAAI,GAAG,CAAA;yBACd;wBAED,MAAM,IAAI,GAAG,GAAG,IAAI,cAAc,CAAC,GAAG,CAAC,EAAE,CAAA;qBAC1C;iBACF;aACF;SACF;QAED,MAAM,IAAI,GAAG,UAAU,GAAG,UAAU,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAA;QACpD,OAAO,MAAM,CAAA;IACf,CAAC;CACF;AAED,SAAS,UAAU,CAAC,CAAM;IACxB,OAAO,sBAAc,CAAC,CAAC,CAAC;SACrB,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC;SACpB,OAAO,CAAC,KAAK,EAAE,KAAK,CAAC;SACrB,OAAO,CAAC,KAAK,EAAE,KAAK,CAAC,CAAA;AAC1B,CAAC;AAED,SAAS,cAAc,CAAC,CAAM;IAC5B,OAAO,sBAAc,CAAC,CAAC,CAAC;SACrB,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC;SACpB,OAAO,CAAC,KAAK,EAAE,KAAK,CAAC;SACrB,OAAO,CAAC,KAAK,EAAE,KAAK,CAAC;SACrB,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC;SACpB,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC,CAAA;AACzB,CAAC"}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* Interface for getInput options
|
||||||
|
*/
|
||||||
|
export interface InputOptions {
|
||||||
|
/** Optional. Whether the input is required. If required and not present, will throw. Defaults to false */
|
||||||
|
required?: boolean;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* The code to exit an action
|
||||||
|
*/
|
||||||
|
export declare enum ExitCode {
|
||||||
|
/**
|
||||||
|
* A code indicating that the action was successful
|
||||||
|
*/
|
||||||
|
Success = 0,
|
||||||
|
/**
|
||||||
|
* A code indicating that the action was a failure
|
||||||
|
*/
|
||||||
|
Failure = 1
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Sets env variable for this action and future actions in the job
|
||||||
|
* @param name the name of the variable to set
|
||||||
|
* @param val the value of the variable. Non-string values will be converted to a string via JSON.stringify
|
||||||
|
*/
|
||||||
|
export declare function exportVariable(name: string, val: any): void;
|
||||||
|
/**
|
||||||
|
* Registers a secret which will get masked from logs
|
||||||
|
* @param secret value of the secret
|
||||||
|
*/
|
||||||
|
export declare function setSecret(secret: string): void;
|
||||||
|
/**
|
||||||
|
* Prepends inputPath to the PATH (for this action and future actions)
|
||||||
|
* @param inputPath
|
||||||
|
*/
|
||||||
|
export declare function addPath(inputPath: string): void;
|
||||||
|
/**
|
||||||
|
* Gets the value of an input. The value is also trimmed.
|
||||||
|
*
|
||||||
|
* @param name name of the input to get
|
||||||
|
* @param options optional. See InputOptions.
|
||||||
|
* @returns string
|
||||||
|
*/
|
||||||
|
export declare function getInput(name: string, options?: InputOptions): string;
|
||||||
|
/**
|
||||||
|
* Sets the value of an output.
|
||||||
|
*
|
||||||
|
* @param name name of the output to set
|
||||||
|
* @param value value to store. Non-string values will be converted to a string via JSON.stringify
|
||||||
|
*/
|
||||||
|
export declare function setOutput(name: string, value: any): void;
|
||||||
|
/**
|
||||||
|
* Enables or disables the echoing of commands into stdout for the rest of the step.
|
||||||
|
* Echoing is disabled by default if ACTIONS_STEP_DEBUG is not set.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export declare function setCommandEcho(enabled: boolean): void;
|
||||||
|
/**
|
||||||
|
* Sets the action status to failed.
|
||||||
|
* When the action exits it will be with an exit code of 1
|
||||||
|
* @param message add error issue message
|
||||||
|
*/
|
||||||
|
export declare function setFailed(message: string | Error): void;
|
||||||
|
/**
|
||||||
|
* Gets whether Actions Step Debug is on or not
|
||||||
|
*/
|
||||||
|
export declare function isDebug(): boolean;
|
||||||
|
/**
|
||||||
|
* Writes debug message to user log
|
||||||
|
* @param message debug message
|
||||||
|
*/
|
||||||
|
export declare function debug(message: string): void;
|
||||||
|
/**
|
||||||
|
* Adds an error issue
|
||||||
|
* @param message error issue message. Errors will be converted to string via toString()
|
||||||
|
*/
|
||||||
|
export declare function error(message: string | Error): void;
|
||||||
|
/**
|
||||||
|
* Adds an warning issue
|
||||||
|
* @param message warning issue message. Errors will be converted to string via toString()
|
||||||
|
*/
|
||||||
|
export declare function warning(message: string | Error): void;
|
||||||
|
/**
|
||||||
|
* Writes info to log with console.log.
|
||||||
|
* @param message info message
|
||||||
|
*/
|
||||||
|
export declare function info(message: string): void;
|
||||||
|
/**
|
||||||
|
* Begin an output group.
|
||||||
|
*
|
||||||
|
* Output until the next `groupEnd` will be foldable in this group
|
||||||
|
*
|
||||||
|
* @param name The name of the output group
|
||||||
|
*/
|
||||||
|
export declare function startGroup(name: string): void;
|
||||||
|
/**
|
||||||
|
* End an output group.
|
||||||
|
*/
|
||||||
|
export declare function endGroup(): void;
|
||||||
|
/**
|
||||||
|
* Wrap an asynchronous function call in a group.
|
||||||
|
*
|
||||||
|
* Returns the same type as the function itself.
|
||||||
|
*
|
||||||
|
* @param name The name of the group
|
||||||
|
* @param fn The function to wrap in the group
|
||||||
|
*/
|
||||||
|
export declare function group<T>(name: string, fn: () => Promise<T>): Promise<T>;
|
||||||
|
/**
|
||||||
|
* Saves state for current action, the state can only be retrieved by this action's post job execution.
|
||||||
|
*
|
||||||
|
* @param name name of the state to store
|
||||||
|
* @param value value to store. Non-string values will be converted to a string via JSON.stringify
|
||||||
|
*/
|
||||||
|
export declare function saveState(name: string, value: any): void;
|
||||||
|
/**
|
||||||
|
* Gets the value of an state set by this action's main execution.
|
||||||
|
*
|
||||||
|
* @param name name of the state to get
|
||||||
|
* @returns string
|
||||||
|
*/
|
||||||
|
export declare function getState(name: string): string;
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
"use strict";
|
||||||
|
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
||||||
|
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
||||||
|
return new (P || (P = Promise))(function (resolve, reject) {
|
||||||
|
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
||||||
|
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
||||||
|
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
||||||
|
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
var __importStar = (this && this.__importStar) || function (mod) {
|
||||||
|
if (mod && mod.__esModule) return mod;
|
||||||
|
var result = {};
|
||||||
|
if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k];
|
||||||
|
result["default"] = mod;
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
const command_1 = require("./command");
|
||||||
|
const file_command_1 = require("./file-command");
|
||||||
|
const utils_1 = require("./utils");
|
||||||
|
const os = __importStar(require("os"));
|
||||||
|
const path = __importStar(require("path"));
|
||||||
|
/**
|
||||||
|
* The code to exit an action
|
||||||
|
*/
|
||||||
|
var ExitCode;
|
||||||
|
(function (ExitCode) {
|
||||||
|
/**
|
||||||
|
* A code indicating that the action was successful
|
||||||
|
*/
|
||||||
|
ExitCode[ExitCode["Success"] = 0] = "Success";
|
||||||
|
/**
|
||||||
|
* A code indicating that the action was a failure
|
||||||
|
*/
|
||||||
|
ExitCode[ExitCode["Failure"] = 1] = "Failure";
|
||||||
|
})(ExitCode = exports.ExitCode || (exports.ExitCode = {}));
|
||||||
|
//-----------------------------------------------------------------------
|
||||||
|
// Variables
|
||||||
|
//-----------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Sets env variable for this action and future actions in the job
|
||||||
|
* @param name the name of the variable to set
|
||||||
|
* @param val the value of the variable. Non-string values will be converted to a string via JSON.stringify
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function exportVariable(name, val) {
|
||||||
|
const convertedVal = utils_1.toCommandValue(val);
|
||||||
|
process.env[name] = convertedVal;
|
||||||
|
const filePath = process.env['GITHUB_ENV'] || '';
|
||||||
|
if (filePath) {
|
||||||
|
const delimiter = '_GitHubActionsFileCommandDelimeter_';
|
||||||
|
const commandValue = `${name}<<${delimiter}${os.EOL}${convertedVal}${os.EOL}${delimiter}`;
|
||||||
|
file_command_1.issueCommand('ENV', commandValue);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
command_1.issueCommand('set-env', { name }, convertedVal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.exportVariable = exportVariable;
|
||||||
|
/**
|
||||||
|
* Registers a secret which will get masked from logs
|
||||||
|
* @param secret value of the secret
|
||||||
|
*/
|
||||||
|
function setSecret(secret) {
|
||||||
|
command_1.issueCommand('add-mask', {}, secret);
|
||||||
|
}
|
||||||
|
exports.setSecret = setSecret;
|
||||||
|
/**
|
||||||
|
* Prepends inputPath to the PATH (for this action and future actions)
|
||||||
|
* @param inputPath
|
||||||
|
*/
|
||||||
|
function addPath(inputPath) {
|
||||||
|
const filePath = process.env['GITHUB_PATH'] || '';
|
||||||
|
if (filePath) {
|
||||||
|
file_command_1.issueCommand('PATH', inputPath);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
command_1.issueCommand('add-path', {}, inputPath);
|
||||||
|
}
|
||||||
|
process.env['PATH'] = `${inputPath}${path.delimiter}${process.env['PATH']}`;
|
||||||
|
}
|
||||||
|
exports.addPath = addPath;
|
||||||
|
/**
|
||||||
|
* Gets the value of an input. The value is also trimmed.
|
||||||
|
*
|
||||||
|
* @param name name of the input to get
|
||||||
|
* @param options optional. See InputOptions.
|
||||||
|
* @returns string
|
||||||
|
*/
|
||||||
|
function getInput(name, options) {
|
||||||
|
const val = process.env[`INPUT_${name.replace(/ /g, '_').toUpperCase()}`] || '';
|
||||||
|
if (options && options.required && !val) {
|
||||||
|
throw new Error(`Input required and not supplied: ${name}`);
|
||||||
|
}
|
||||||
|
return val.trim();
|
||||||
|
}
|
||||||
|
exports.getInput = getInput;
|
||||||
|
/**
|
||||||
|
* Sets the value of an output.
|
||||||
|
*
|
||||||
|
* @param name name of the output to set
|
||||||
|
* @param value value to store. Non-string values will be converted to a string via JSON.stringify
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function setOutput(name, value) {
|
||||||
|
command_1.issueCommand('set-output', { name }, value);
|
||||||
|
}
|
||||||
|
exports.setOutput = setOutput;
|
||||||
|
/**
|
||||||
|
* Enables or disables the echoing of commands into stdout for the rest of the step.
|
||||||
|
* Echoing is disabled by default if ACTIONS_STEP_DEBUG is not set.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
function setCommandEcho(enabled) {
|
||||||
|
command_1.issue('echo', enabled ? 'on' : 'off');
|
||||||
|
}
|
||||||
|
exports.setCommandEcho = setCommandEcho;
|
||||||
|
//-----------------------------------------------------------------------
|
||||||
|
// Results
|
||||||
|
//-----------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Sets the action status to failed.
|
||||||
|
* When the action exits it will be with an exit code of 1
|
||||||
|
* @param message add error issue message
|
||||||
|
*/
|
||||||
|
function setFailed(message) {
|
||||||
|
process.exitCode = ExitCode.Failure;
|
||||||
|
error(message);
|
||||||
|
}
|
||||||
|
exports.setFailed = setFailed;
|
||||||
|
//-----------------------------------------------------------------------
|
||||||
|
// Logging Commands
|
||||||
|
//-----------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Gets whether Actions Step Debug is on or not
|
||||||
|
*/
|
||||||
|
function isDebug() {
|
||||||
|
return process.env['RUNNER_DEBUG'] === '1';
|
||||||
|
}
|
||||||
|
exports.isDebug = isDebug;
|
||||||
|
/**
|
||||||
|
* Writes debug message to user log
|
||||||
|
* @param message debug message
|
||||||
|
*/
|
||||||
|
function debug(message) {
|
||||||
|
command_1.issueCommand('debug', {}, message);
|
||||||
|
}
|
||||||
|
exports.debug = debug;
|
||||||
|
/**
|
||||||
|
* Adds an error issue
|
||||||
|
* @param message error issue message. Errors will be converted to string via toString()
|
||||||
|
*/
|
||||||
|
function error(message) {
|
||||||
|
command_1.issue('error', message instanceof Error ? message.toString() : message);
|
||||||
|
}
|
||||||
|
exports.error = error;
|
||||||
|
/**
|
||||||
|
* Adds an warning issue
|
||||||
|
* @param message warning issue message. Errors will be converted to string via toString()
|
||||||
|
*/
|
||||||
|
function warning(message) {
|
||||||
|
command_1.issue('warning', message instanceof Error ? message.toString() : message);
|
||||||
|
}
|
||||||
|
exports.warning = warning;
|
||||||
|
/**
|
||||||
|
* Writes info to log with console.log.
|
||||||
|
* @param message info message
|
||||||
|
*/
|
||||||
|
function info(message) {
|
||||||
|
process.stdout.write(message + os.EOL);
|
||||||
|
}
|
||||||
|
exports.info = info;
|
||||||
|
/**
|
||||||
|
* Begin an output group.
|
||||||
|
*
|
||||||
|
* Output until the next `groupEnd` will be foldable in this group
|
||||||
|
*
|
||||||
|
* @param name The name of the output group
|
||||||
|
*/
|
||||||
|
function startGroup(name) {
|
||||||
|
command_1.issue('group', name);
|
||||||
|
}
|
||||||
|
exports.startGroup = startGroup;
|
||||||
|
/**
|
||||||
|
* End an output group.
|
||||||
|
*/
|
||||||
|
function endGroup() {
|
||||||
|
command_1.issue('endgroup');
|
||||||
|
}
|
||||||
|
exports.endGroup = endGroup;
|
||||||
|
/**
|
||||||
|
* Wrap an asynchronous function call in a group.
|
||||||
|
*
|
||||||
|
* Returns the same type as the function itself.
|
||||||
|
*
|
||||||
|
* @param name The name of the group
|
||||||
|
* @param fn The function to wrap in the group
|
||||||
|
*/
|
||||||
|
function group(name, fn) {
|
||||||
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
|
startGroup(name);
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = yield fn();
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
endGroup();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
exports.group = group;
|
||||||
|
//-----------------------------------------------------------------------
|
||||||
|
// Wrapper action state
|
||||||
|
//-----------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Saves state for current action, the state can only be retrieved by this action's post job execution.
|
||||||
|
*
|
||||||
|
* @param name name of the state to store
|
||||||
|
* @param value value to store. Non-string values will be converted to a string via JSON.stringify
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function saveState(name, value) {
|
||||||
|
command_1.issueCommand('save-state', { name }, value);
|
||||||
|
}
|
||||||
|
exports.saveState = saveState;
|
||||||
|
/**
|
||||||
|
* Gets the value of an state set by this action's main execution.
|
||||||
|
*
|
||||||
|
* @param name name of the state to get
|
||||||
|
* @returns string
|
||||||
|
*/
|
||||||
|
function getState(name) {
|
||||||
|
return process.env[`STATE_${name}`] || '';
|
||||||
|
}
|
||||||
|
exports.getState = getState;
|
||||||
|
//# sourceMappingURL=core.js.map
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"core.js","sourceRoot":"","sources":["../src/core.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;AAAA,uCAA6C;AAC7C,iDAA+D;AAC/D,mCAAsC;AAEtC,uCAAwB;AACxB,2CAA4B;AAU5B;;GAEG;AACH,IAAY,QAUX;AAVD,WAAY,QAAQ;IAClB;;OAEG;IACH,6CAAW,CAAA;IAEX;;OAEG;IACH,6CAAW,CAAA;AACb,CAAC,EAVW,QAAQ,GAAR,gBAAQ,KAAR,gBAAQ,QAUnB;AAED,yEAAyE;AACzE,YAAY;AACZ,yEAAyE;AAEzE;;;;GAIG;AACH,8DAA8D;AAC9D,SAAgB,cAAc,CAAC,IAAY,EAAE,GAAQ;IACnD,MAAM,YAAY,GAAG,sBAAc,CAAC,GAAG,CAAC,CAAA;IACxC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,YAAY,CAAA;IAEhC,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,CAAC,YAAY,CAAC,IAAI,EAAE,CAAA;IAChD,IAAI,QAAQ,EAAE;QACZ,MAAM,SAAS,GAAG,qCAAqC,CAAA;QACvD,MAAM,YAAY,GAAG,GAAG,IAAI,KAAK,SAAS,GAAG,EAAE,CAAC,GAAG,GAAG,YAAY,GAAG,EAAE,CAAC,GAAG,GAAG,SAAS,EAAE,CAAA;QACzF,2BAAgB,CAAC,KAAK,EAAE,YAAY,CAAC,CAAA;KACtC;SAAM;QACL,sBAAY,CAAC,SAAS,EAAE,EAAC,IAAI,EAAC,EAAE,YAAY,CAAC,CAAA;KAC9C;AACH,CAAC;AAZD,wCAYC;AAED;;;GAGG;AACH,SAAgB,SAAS,CAAC,MAAc;IACtC,sBAAY,CAAC,UAAU,EAAE,EAAE,EAAE,MAAM,CAAC,CAAA;AACtC,CAAC;AAFD,8BAEC;AAED;;;GAGG;AACH,SAAgB,OAAO,CAAC,SAAiB;IACvC,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,CAAC,aAAa,CAAC,IAAI,EAAE,CAAA;IACjD,IAAI,QAAQ,EAAE;QACZ,2BAAgB,CAAC,MAAM,EAAE,SAAS,CAAC,CAAA;KACpC;SAAM;QACL,sBAAY,CAAC,UAAU,EAAE,EAAE,EAAE,SAAS,CAAC,CAAA;KACxC;IACD,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,GAAG,GAAG,SAAS,GAAG,IAAI,CAAC,SAAS,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAA;AAC7E,CAAC;AARD,0BAQC;AAED;;;;;;GAMG;AACH,SAAgB,QAAQ,CAAC,IAAY,EAAE,OAAsB;IAC3D,MAAM,GAAG,GACP,OAAO,CAAC,GAAG,CAAC,SAAS,IAAI,CAAC,OAAO,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC,WAAW,EAAE,EAAE,CAAC,IAAI,EAAE,CAAA;IACrE,IAAI,OAAO,IAAI,OAAO,CAAC,QAAQ,IAAI,CAAC,GAAG,EAAE;QACvC,MAAM,IAAI,KAAK,CAAC,oCAAoC,IAAI,EAAE,CAAC,CAAA;KAC5D;IAED,OAAO,GAAG,CAAC,IAAI,EAAE,CAAA;AACnB,CAAC;AARD,4BAQC;AAED;;;;;GAKG;AACH,8DAA8D;AAC9D,SAAgB,SAAS,CAAC,IAAY,EAAE,KAAU;IAChD,sBAAY,CAAC,YAAY,EAAE,EAAC,IAAI,EAAC,EAAE,KAAK,CAAC,CAAA;AAC3C,CAAC;AAFD,8BAEC;AAED;;;;GAIG;AACH,SAAgB,cAAc,CAAC,OAAgB;IAC7C,eAAK,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,CAAA;AACvC,CAAC;AAFD,wCAEC;AAED,yEAAyE;AACzE,UAAU;AACV,yEAAyE;AAEzE;;;;GAIG;AACH,SAAgB,SAAS,CAAC,OAAuB;IAC/C,OAAO,CAAC,QAAQ,GAAG,QAAQ,CAAC,OAAO,CAAA;IAEnC,KAAK,CAAC,OAAO,CAAC,CAAA;AAChB,CAAC;AAJD,8BAIC;AAED,yEAAyE;AACzE,mBAAmB;AACnB,yEAAyE;AAEzE;;GAEG;AACH,SAAgB,OAAO;IACrB,OAAO,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC,KAAK,GAAG,CAAA;AAC5C,CAAC;AAFD,0BAEC;AAED;;;GAGG;AACH,SAAgB,KAAK,CAAC,OAAe;IACnC,sBAAY,CAAC,OAAO,EAAE,EAAE,EAAE,OAAO,CAAC,CAAA;AACpC,CAAC;AAFD,sBAEC;AAED;;;GAGG;AACH,SAAgB,KAAK,CAAC,OAAuB;IAC3C,eAAK,CAAC,OAAO,EAAE,OAAO,YAAY,KAAK,CAAC,CAAC,CAAC,OAAO,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,CAAA;AACzE,CAAC;AAFD,sBAEC;AAED;;;GAGG;AACH,SAAgB,OAAO,CAAC,OAAuB;IAC7C,eAAK,CAAC,SAAS,EAAE,OAAO,YAAY,KAAK,CAAC,CAAC,CAAC,OAAO,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,CAAA;AAC3E,CAAC;AAFD,0BAEC;AAED;;;GAGG;AACH,SAAgB,IAAI,CAAC,OAAe;IAClC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,OAAO,GAAG,EAAE,CAAC,GAAG,CAAC,CAAA;AACxC,CAAC;AAFD,oBAEC;AAED;;;;;;GAMG;AACH,SAAgB,UAAU,CAAC,IAAY;IACrC,eAAK,CAAC,OAAO,EAAE,IAAI,CAAC,CAAA;AACtB,CAAC;AAFD,gCAEC;AAED;;GAEG;AACH,SAAgB,QAAQ;IACtB,eAAK,CAAC,UAAU,CAAC,CAAA;AACnB,CAAC;AAFD,4BAEC;AAED;;;;;;;GAOG;AACH,SAAsB,KAAK,CAAI,IAAY,EAAE,EAAoB;;QAC/D,UAAU,CAAC,IAAI,CAAC,CAAA;QAEhB,IAAI,MAAS,CAAA;QAEb,IAAI;YACF,MAAM,GAAG,MAAM,EAAE,EAAE,CAAA;SACpB;gBAAS;YACR,QAAQ,EAAE,CAAA;SACX;QAED,OAAO,MAAM,CAAA;IACf,CAAC;CAAA;AAZD,sBAYC;AAED,yEAAyE;AACzE,uBAAuB;AACvB,yEAAyE;AAEzE;;;;;GAKG;AACH,8DAA8D;AAC9D,SAAgB,SAAS,CAAC,IAAY,EAAE,KAAU;IAChD,sBAAY,CAAC,YAAY,EAAE,EAAC,IAAI,EAAC,EAAE,KAAK,CAAC,CAAA;AAC3C,CAAC;AAFD,8BAEC;AAED;;;;;GAKG;AACH,SAAgB,QAAQ,CAAC,IAAY;IACnC,OAAO,OAAO,CAAC,GAAG,CAAC,SAAS,IAAI,EAAE,CAAC,IAAI,EAAE,CAAA;AAC3C,CAAC;AAFD,4BAEC"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export declare function issueCommand(command: string, message: any): void;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"use strict";
|
||||||
|
// For internal use, subject to change.
|
||||||
|
var __importStar = (this && this.__importStar) || function (mod) {
|
||||||
|
if (mod && mod.__esModule) return mod;
|
||||||
|
var result = {};
|
||||||
|
if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k];
|
||||||
|
result["default"] = mod;
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
// We use any as a valid input type
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
const fs = __importStar(require("fs"));
|
||||||
|
const os = __importStar(require("os"));
|
||||||
|
const utils_1 = require("./utils");
|
||||||
|
function issueCommand(command, message) {
|
||||||
|
const filePath = process.env[`GITHUB_${command}`];
|
||||||
|
if (!filePath) {
|
||||||
|
throw new Error(`Unable to find environment variable for file command ${command}`);
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
throw new Error(`Missing file at path: ${filePath}`);
|
||||||
|
}
|
||||||
|
fs.appendFileSync(filePath, `${utils_1.toCommandValue(message)}${os.EOL}`, {
|
||||||
|
encoding: 'utf8'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
exports.issueCommand = issueCommand;
|
||||||
|
//# sourceMappingURL=file-command.js.map
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"file-command.js","sourceRoot":"","sources":["../src/file-command.ts"],"names":[],"mappings":";AAAA,uCAAuC;;;;;;;;;AAEvC,mCAAmC;AACnC,uDAAuD;AAEvD,uCAAwB;AACxB,uCAAwB;AACxB,mCAAsC;AAEtC,SAAgB,YAAY,CAAC,OAAe,EAAE,OAAY;IACxD,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,CAAC,UAAU,OAAO,EAAE,CAAC,CAAA;IACjD,IAAI,CAAC,QAAQ,EAAE;QACb,MAAM,IAAI,KAAK,CACb,wDAAwD,OAAO,EAAE,CAClE,CAAA;KACF;IACD,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE;QAC5B,MAAM,IAAI,KAAK,CAAC,yBAAyB,QAAQ,EAAE,CAAC,CAAA;KACrD;IAED,EAAE,CAAC,cAAc,CAAC,QAAQ,EAAE,GAAG,sBAAc,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,GAAG,EAAE,EAAE;QACjE,QAAQ,EAAE,MAAM;KACjB,CAAC,CAAA;AACJ,CAAC;AAdD,oCAcC"}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* Sanitizes an input into a string so it can be passed into issueCommand safely
|
||||||
|
* @param input input to sanitize into a string
|
||||||
|
*/
|
||||||
|
export declare function toCommandValue(input: any): string;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"use strict";
|
||||||
|
// We use any as a valid input type
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
/**
|
||||||
|
* Sanitizes an input into a string so it can be passed into issueCommand safely
|
||||||
|
* @param input input to sanitize into a string
|
||||||
|
*/
|
||||||
|
function toCommandValue(input) {
|
||||||
|
if (input === null || input === undefined) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
else if (typeof input === 'string' || input instanceof String) {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
return JSON.stringify(input);
|
||||||
|
}
|
||||||
|
exports.toCommandValue = toCommandValue;
|
||||||
|
//# sourceMappingURL=utils.js.map
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"utils.js","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":";AAAA,mCAAmC;AACnC,uDAAuD;;AAEvD;;;GAGG;AACH,SAAgB,cAAc,CAAC,KAAU;IACvC,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,SAAS,EAAE;QACzC,OAAO,EAAE,CAAA;KACV;SAAM,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,YAAY,MAAM,EAAE;QAC/D,OAAO,KAAe,CAAA;KACvB;IACD,OAAO,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAA;AAC9B,CAAC;AAPD,wCAOC"}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"_args": [
|
||||||
|
[
|
||||||
|
"@actions/[email protected]",
|
||||||
|
"/Users/drew.heavner/Documents/Github/sign-android-release"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"_from": "@actions/[email protected]",
|
||||||
|
"_id": "@actions/[email protected]",
|
||||||
|
"_inBundle": false,
|
||||||
|
"_integrity": "sha512-ZQYitnqiyBc3D+k7LsgSBmMDVkOVidaagDG7j3fOym77jNunWRuYx7VSHa9GNfFZh+zh61xsCjRj4JxMZlDqTA==",
|
||||||
|
"_location": "/@actions/core",
|
||||||
|
"_phantomChildren": {},
|
||||||
|
"_requested": {
|
||||||
|
"type": "version",
|
||||||
|
"registry": true,
|
||||||
|
"raw": "@actions/[email protected]",
|
||||||
|
"name": "@actions/core",
|
||||||
|
"escapedName": "@actions%2fcore",
|
||||||
|
"scope": "@actions",
|
||||||
|
"rawSpec": "1.2.6",
|
||||||
|
"saveSpec": null,
|
||||||
|
"fetchSpec": "1.2.6"
|
||||||
|
},
|
||||||
|
"_requiredBy": [
|
||||||
|
"/"
|
||||||
|
],
|
||||||
|
"_resolved": "https://registry.npmjs.org/@actions/core/-/core-1.2.6.tgz",
|
||||||
|
"_spec": "1.2.6",
|
||||||
|
"_where": "/Users/drew.heavner/Documents/Github/sign-android-release",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/actions/toolkit/issues"
|
||||||
|
},
|
||||||
|
"description": "Actions core lib",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^12.0.2"
|
||||||
|
},
|
||||||
|
"directories": {
|
||||||
|
"lib": "lib",
|
||||||
|
"test": "__tests__"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"lib",
|
||||||
|
"!.DS_Store"
|
||||||
|
],
|
||||||
|
"homepage": "https://github.com/actions/toolkit/tree/main/packages/core",
|
||||||
|
"keywords": [
|
||||||
|
"github",
|
||||||
|
"actions",
|
||||||
|
"core"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"main": "lib/core.js",
|
||||||
|
"name": "@actions/core",
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/actions/toolkit.git",
|
||||||
|
"directory": "packages/core"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"audit-moderate": "npm install && npm audit --json --audit-level=moderate > audit.json",
|
||||||
|
"test": "echo \"Error: run tests from root\" && exit 1",
|
||||||
|
"tsc": "tsc"
|
||||||
|
},
|
||||||
|
"types": "lib/core.d.ts",
|
||||||
|
"version": "1.2.6"
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# `@actions/exec`
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
#### Basic
|
||||||
|
|
||||||
|
You can use this package to execute tools in a cross-platform way:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const exec = require('@actions/exec');
|
||||||
|
|
||||||
|
await exec.exec('node index.js');
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Args
|
||||||
|
|
||||||
|
You can also pass in arg arrays:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const exec = require('@actions/exec');
|
||||||
|
|
||||||
|
await exec.exec('node', ['index.js', 'foo=bar']);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Output/options
|
||||||
|
|
||||||
|
Capture output or
|
||||||
|
specify [other options](https://github.com/actions/toolkit/blob/d9347d4ab99fd507c0b9104b2cf79fb44fcc827d/packages/exec/src/interfaces.ts#L5):
|
||||||
|
|
||||||
|
```js
|
||||||
|
const exec = require('@actions/exec');
|
||||||
|
|
||||||
|
let myOutput = '';
|
||||||
|
let myError = '';
|
||||||
|
|
||||||
|
const options = {};
|
||||||
|
options.listeners = {
|
||||||
|
stdout: (data: Buffer) => {
|
||||||
|
myOutput += data.toString();
|
||||||
|
},
|
||||||
|
stderr: (data: Buffer) => {
|
||||||
|
myError += data.toString();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
options.cwd = './lib';
|
||||||
|
|
||||||
|
await exec.exec('node', ['index.js', 'foo=bar'], options);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Exec tools not in the PATH
|
||||||
|
|
||||||
|
You can specify the full path for tools not in the PATH:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const exec = require('@actions/exec');
|
||||||
|
|
||||||
|
await exec.exec('"/path/to/my-tool"', ['arg1']);
|
||||||
|
```
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { ExecOptions } from './interfaces';
|
||||||
|
export { ExecOptions };
|
||||||
|
/**
|
||||||
|
* Exec a command.
|
||||||
|
* Output will be streamed to the live console.
|
||||||
|
* Returns promise with return code
|
||||||
|
*
|
||||||
|
* @param commandLine command to execute (can include additional args). Must be correctly escaped.
|
||||||
|
* @param args optional arguments for tool. Escaping is handled by the lib.
|
||||||
|
* @param options optional exec options. See ExecOptions
|
||||||
|
* @returns Promise<number> exit code
|
||||||
|
*/
|
||||||
|
export declare function exec(commandLine: string, args?: string[], options?: ExecOptions): Promise<number>;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"use strict";
|
||||||
|
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
||||||
|
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
||||||
|
return new (P || (P = Promise))(function (resolve, reject) {
|
||||||
|
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
||||||
|
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
||||||
|
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
||||||
|
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
var __importStar = (this && this.__importStar) || function (mod) {
|
||||||
|
if (mod && mod.__esModule) return mod;
|
||||||
|
var result = {};
|
||||||
|
if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k];
|
||||||
|
result["default"] = mod;
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
const tr = __importStar(require("./toolrunner"));
|
||||||
|
/**
|
||||||
|
* Exec a command.
|
||||||
|
* Output will be streamed to the live console.
|
||||||
|
* Returns promise with return code
|
||||||
|
*
|
||||||
|
* @param commandLine command to execute (can include additional args). Must be correctly escaped.
|
||||||
|
* @param args optional arguments for tool. Escaping is handled by the lib.
|
||||||
|
* @param options optional exec options. See ExecOptions
|
||||||
|
* @returns Promise<number> exit code
|
||||||
|
*/
|
||||||
|
function exec(commandLine, args, options) {
|
||||||
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
|
const commandArgs = tr.argStringToArray(commandLine);
|
||||||
|
if (commandArgs.length === 0) {
|
||||||
|
throw new Error(`Parameter 'commandLine' cannot be null or empty.`);
|
||||||
|
}
|
||||||
|
// Path to tool to execute should be first arg
|
||||||
|
const toolPath = commandArgs[0];
|
||||||
|
args = commandArgs.slice(1).concat(args || []);
|
||||||
|
const runner = new tr.ToolRunner(toolPath, args, options);
|
||||||
|
return runner.exec();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
exports.exec = exec;
|
||||||
|
//# sourceMappingURL=exec.js.map
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"exec.js","sourceRoot":"","sources":["../src/exec.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;AACA,iDAAkC;AAIlC;;;;;;;;;GASG;AACH,SAAsB,IAAI,CACxB,WAAmB,EACnB,IAAe,EACf,OAAqB;;QAErB,MAAM,WAAW,GAAG,EAAE,CAAC,gBAAgB,CAAC,WAAW,CAAC,CAAA;QACpD,IAAI,WAAW,CAAC,MAAM,KAAK,CAAC,EAAE;YAC5B,MAAM,IAAI,KAAK,CAAC,kDAAkD,CAAC,CAAA;SACpE;QACD,8CAA8C;QAC9C,MAAM,QAAQ,GAAG,WAAW,CAAC,CAAC,CAAC,CAAA;QAC/B,IAAI,GAAG,WAAW,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,IAAI,EAAE,CAAC,CAAA;QAC9C,MAAM,MAAM,GAAkB,IAAI,EAAE,CAAC,UAAU,CAAC,QAAQ,EAAE,IAAI,EAAE,OAAO,CAAC,CAAA;QACxE,OAAO,MAAM,CAAC,IAAI,EAAE,CAAA;IACtB,CAAC;CAAA;AAdD,oBAcC"}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/// <reference types="node" />
|
||||||
|
import * as stream from 'stream';
|
||||||
|
/**
|
||||||
|
* Interface for exec options
|
||||||
|
*/
|
||||||
|
export interface ExecOptions {
|
||||||
|
/** optional working directory. defaults to current */
|
||||||
|
cwd?: string;
|
||||||
|
/** optional envvar dictionary. defaults to current process's env */
|
||||||
|
env?: {
|
||||||
|
[key: string]: string;
|
||||||
|
};
|
||||||
|
/** optional. defaults to false */
|
||||||
|
silent?: boolean;
|
||||||
|
/** optional out stream to use. Defaults to process.stdout */
|
||||||
|
outStream?: stream.Writable;
|
||||||
|
/** optional err stream to use. Defaults to process.stderr */
|
||||||
|
errStream?: stream.Writable;
|
||||||
|
/** optional. whether to skip quoting/escaping arguments if needed. defaults to false. */
|
||||||
|
windowsVerbatimArguments?: boolean;
|
||||||
|
/** optional. whether to fail if output to stderr. defaults to false */
|
||||||
|
failOnStdErr?: boolean;
|
||||||
|
/** optional. defaults to failing on non zero. ignore will not fail leaving it up to the caller */
|
||||||
|
ignoreReturnCode?: boolean;
|
||||||
|
/** optional. How long in ms to wait for STDIO streams to close after the exit event of the process before terminating. defaults to 10000 */
|
||||||
|
delay?: number;
|
||||||
|
/** optional. input to write to the process on STDIN. */
|
||||||
|
input?: Buffer;
|
||||||
|
/** optional. Listeners for output. Callback functions that will be called on these events */
|
||||||
|
listeners?: {
|
||||||
|
stdout?: (data: Buffer) => void;
|
||||||
|
stderr?: (data: Buffer) => void;
|
||||||
|
stdline?: (data: string) => void;
|
||||||
|
errline?: (data: string) => void;
|
||||||
|
debug?: (data: string) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
//# sourceMappingURL=interfaces.js.map
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"interfaces.js","sourceRoot":"","sources":["../src/interfaces.ts"],"names":[],"mappings":""}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/// <reference types="node" />
|
||||||
|
import * as events from 'events';
|
||||||
|
import * as im from './interfaces';
|
||||||
|
export declare class ToolRunner extends events.EventEmitter {
|
||||||
|
constructor(toolPath: string, args?: string[], options?: im.ExecOptions);
|
||||||
|
private toolPath;
|
||||||
|
private args;
|
||||||
|
private options;
|
||||||
|
private _debug;
|
||||||
|
private _getCommandString;
|
||||||
|
private _processLineBuffer;
|
||||||
|
private _getSpawnFileName;
|
||||||
|
private _getSpawnArgs;
|
||||||
|
private _endsWith;
|
||||||
|
private _isCmdFile;
|
||||||
|
private _windowsQuoteCmdArg;
|
||||||
|
private _uvQuoteCmdArg;
|
||||||
|
private _cloneExecOptions;
|
||||||
|
private _getSpawnOptions;
|
||||||
|
/**
|
||||||
|
* Exec a tool.
|
||||||
|
* Output will be streamed to the live console.
|
||||||
|
* Returns promise with return code
|
||||||
|
*
|
||||||
|
* @param tool path to tool to exec
|
||||||
|
* @param options optional exec options. See ExecOptions
|
||||||
|
* @returns number
|
||||||
|
*/
|
||||||
|
exec(): Promise<number>;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Convert an arg string to an array of args. Handles escaping
|
||||||
|
*
|
||||||
|
* @param argString string of arguments
|
||||||
|
* @returns string[] array of arguments
|
||||||
|
*/
|
||||||
|
export declare function argStringToArray(argString: string): string[];
|
||||||
@@ -0,0 +1,600 @@
|
|||||||
|
"use strict";
|
||||||
|
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
||||||
|
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
||||||
|
return new (P || (P = Promise))(function (resolve, reject) {
|
||||||
|
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
||||||
|
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
||||||
|
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
||||||
|
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
var __importStar = (this && this.__importStar) || function (mod) {
|
||||||
|
if (mod && mod.__esModule) return mod;
|
||||||
|
var result = {};
|
||||||
|
if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k];
|
||||||
|
result["default"] = mod;
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
const os = __importStar(require("os"));
|
||||||
|
const events = __importStar(require("events"));
|
||||||
|
const child = __importStar(require("child_process"));
|
||||||
|
const path = __importStar(require("path"));
|
||||||
|
const io = __importStar(require("@actions/io"));
|
||||||
|
const ioUtil = __importStar(require("@actions/io/lib/io-util"));
|
||||||
|
/* eslint-disable @typescript-eslint/unbound-method */
|
||||||
|
const IS_WINDOWS = process.platform === 'win32';
|
||||||
|
/*
|
||||||
|
* Class for running command line tools. Handles quoting and arg parsing in a platform agnostic way.
|
||||||
|
*/
|
||||||
|
class ToolRunner extends events.EventEmitter {
|
||||||
|
constructor(toolPath, args, options) {
|
||||||
|
super();
|
||||||
|
if (!toolPath) {
|
||||||
|
throw new Error("Parameter 'toolPath' cannot be null or empty.");
|
||||||
|
}
|
||||||
|
this.toolPath = toolPath;
|
||||||
|
this.args = args || [];
|
||||||
|
this.options = options || {};
|
||||||
|
}
|
||||||
|
_debug(message) {
|
||||||
|
if (this.options.listeners && this.options.listeners.debug) {
|
||||||
|
this.options.listeners.debug(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_getCommandString(options, noPrefix) {
|
||||||
|
const toolPath = this._getSpawnFileName();
|
||||||
|
const args = this._getSpawnArgs(options);
|
||||||
|
let cmd = noPrefix ? '' : '[command]'; // omit prefix when piped to a second tool
|
||||||
|
if (IS_WINDOWS) {
|
||||||
|
// Windows + cmd file
|
||||||
|
if (this._isCmdFile()) {
|
||||||
|
cmd += toolPath;
|
||||||
|
for (const a of args) {
|
||||||
|
cmd += ` ${a}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Windows + verbatim
|
||||||
|
else if (options.windowsVerbatimArguments) {
|
||||||
|
cmd += `"${toolPath}"`;
|
||||||
|
for (const a of args) {
|
||||||
|
cmd += ` ${a}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Windows (regular)
|
||||||
|
else {
|
||||||
|
cmd += this._windowsQuoteCmdArg(toolPath);
|
||||||
|
for (const a of args) {
|
||||||
|
cmd += ` ${this._windowsQuoteCmdArg(a)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// OSX/Linux - this can likely be improved with some form of quoting.
|
||||||
|
// creating processes on Unix is fundamentally different than Windows.
|
||||||
|
// on Unix, execvp() takes an arg array.
|
||||||
|
cmd += toolPath;
|
||||||
|
for (const a of args) {
|
||||||
|
cmd += ` ${a}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
_processLineBuffer(data, strBuffer, onLine) {
|
||||||
|
try {
|
||||||
|
let s = strBuffer + data.toString();
|
||||||
|
let n = s.indexOf(os.EOL);
|
||||||
|
while (n > -1) {
|
||||||
|
const line = s.substring(0, n);
|
||||||
|
onLine(line);
|
||||||
|
// the rest of the string ...
|
||||||
|
s = s.substring(n + os.EOL.length);
|
||||||
|
n = s.indexOf(os.EOL);
|
||||||
|
}
|
||||||
|
strBuffer = s;
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
// streaming lines to console is best effort. Don't fail a build.
|
||||||
|
this._debug(`error processing line. Failed with error ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_getSpawnFileName() {
|
||||||
|
if (IS_WINDOWS) {
|
||||||
|
if (this._isCmdFile()) {
|
||||||
|
return process.env['COMSPEC'] || 'cmd.exe';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.toolPath;
|
||||||
|
}
|
||||||
|
_getSpawnArgs(options) {
|
||||||
|
if (IS_WINDOWS) {
|
||||||
|
if (this._isCmdFile()) {
|
||||||
|
let argline = `/D /S /C "${this._windowsQuoteCmdArg(this.toolPath)}`;
|
||||||
|
for (const a of this.args) {
|
||||||
|
argline += ' ';
|
||||||
|
argline += options.windowsVerbatimArguments
|
||||||
|
? a
|
||||||
|
: this._windowsQuoteCmdArg(a);
|
||||||
|
}
|
||||||
|
argline += '"';
|
||||||
|
return [argline];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.args;
|
||||||
|
}
|
||||||
|
_endsWith(str, end) {
|
||||||
|
return str.endsWith(end);
|
||||||
|
}
|
||||||
|
_isCmdFile() {
|
||||||
|
const upperToolPath = this.toolPath.toUpperCase();
|
||||||
|
return (this._endsWith(upperToolPath, '.CMD') ||
|
||||||
|
this._endsWith(upperToolPath, '.BAT'));
|
||||||
|
}
|
||||||
|
_windowsQuoteCmdArg(arg) {
|
||||||
|
// for .exe, apply the normal quoting rules that libuv applies
|
||||||
|
if (!this._isCmdFile()) {
|
||||||
|
return this._uvQuoteCmdArg(arg);
|
||||||
|
}
|
||||||
|
// otherwise apply quoting rules specific to the cmd.exe command line parser.
|
||||||
|
// the libuv rules are generic and are not designed specifically for cmd.exe
|
||||||
|
// command line parser.
|
||||||
|
//
|
||||||
|
// for a detailed description of the cmd.exe command line parser, refer to
|
||||||
|
// http://stackoverflow.com/questions/4094699/how-does-the-windows-command-interpreter-cmd-exe-parse-scripts/7970912#7970912
|
||||||
|
// need quotes for empty arg
|
||||||
|
if (!arg) {
|
||||||
|
return '""';
|
||||||
|
}
|
||||||
|
// determine whether the arg needs to be quoted
|
||||||
|
const cmdSpecialChars = [
|
||||||
|
' ',
|
||||||
|
'\t',
|
||||||
|
'&',
|
||||||
|
'(',
|
||||||
|
')',
|
||||||
|
'[',
|
||||||
|
']',
|
||||||
|
'{',
|
||||||
|
'}',
|
||||||
|
'^',
|
||||||
|
'=',
|
||||||
|
';',
|
||||||
|
'!',
|
||||||
|
"'",
|
||||||
|
'+',
|
||||||
|
',',
|
||||||
|
'`',
|
||||||
|
'~',
|
||||||
|
'|',
|
||||||
|
'<',
|
||||||
|
'>',
|
||||||
|
'"'
|
||||||
|
];
|
||||||
|
let needsQuotes = false;
|
||||||
|
for (const char of arg) {
|
||||||
|
if (cmdSpecialChars.some(x => x === char)) {
|
||||||
|
needsQuotes = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// short-circuit if quotes not needed
|
||||||
|
if (!needsQuotes) {
|
||||||
|
return arg;
|
||||||
|
}
|
||||||
|
// the following quoting rules are very similar to the rules that by libuv applies.
|
||||||
|
//
|
||||||
|
// 1) wrap the string in quotes
|
||||||
|
//
|
||||||
|
// 2) double-up quotes - i.e. " => ""
|
||||||
|
//
|
||||||
|
// this is different from the libuv quoting rules. libuv replaces " with \", which unfortunately
|
||||||
|
// doesn't work well with a cmd.exe command line.
|
||||||
|
//
|
||||||
|
// note, replacing " with "" also works well if the arg is passed to a downstream .NET console app.
|
||||||
|
// for example, the command line:
|
||||||
|
// foo.exe "myarg:""my val"""
|
||||||
|
// is parsed by a .NET console app into an arg array:
|
||||||
|
// [ "myarg:\"my val\"" ]
|
||||||
|
// which is the same end result when applying libuv quoting rules. although the actual
|
||||||
|
// command line from libuv quoting rules would look like:
|
||||||
|
// foo.exe "myarg:\"my val\""
|
||||||
|
//
|
||||||
|
// 3) double-up slashes that precede a quote,
|
||||||
|
// e.g. hello \world => "hello \world"
|
||||||
|
// hello\"world => "hello\\""world"
|
||||||
|
// hello\\"world => "hello\\\\""world"
|
||||||
|
// hello world\ => "hello world\\"
|
||||||
|
//
|
||||||
|
// technically this is not required for a cmd.exe command line, or the batch argument parser.
|
||||||
|
// the reasons for including this as a .cmd quoting rule are:
|
||||||
|
//
|
||||||
|
// a) this is optimized for the scenario where the argument is passed from the .cmd file to an
|
||||||
|
// external program. many programs (e.g. .NET console apps) rely on the slash-doubling rule.
|
||||||
|
//
|
||||||
|
// b) it's what we've been doing previously (by deferring to node default behavior) and we
|
||||||
|
// haven't heard any complaints about that aspect.
|
||||||
|
//
|
||||||
|
// note, a weakness of the quoting rules chosen here, is that % is not escaped. in fact, % cannot be
|
||||||
|
// escaped when used on the command line directly - even though within a .cmd file % can be escaped
|
||||||
|
// by using %%.
|
||||||
|
//
|
||||||
|
// the saving grace is, on the command line, %var% is left as-is if var is not defined. this contrasts
|
||||||
|
// the line parsing rules within a .cmd file, where if var is not defined it is replaced with nothing.
|
||||||
|
//
|
||||||
|
// one option that was explored was replacing % with ^% - i.e. %var% => ^%var^%. this hack would
|
||||||
|
// often work, since it is unlikely that var^ would exist, and the ^ character is removed when the
|
||||||
|
// variable is used. the problem, however, is that ^ is not removed when %* is used to pass the args
|
||||||
|
// to an external program.
|
||||||
|
//
|
||||||
|
// an unexplored potential solution for the % escaping problem, is to create a wrapper .cmd file.
|
||||||
|
// % can be escaped within a .cmd file.
|
||||||
|
let reverse = '"';
|
||||||
|
let quoteHit = true;
|
||||||
|
for (let i = arg.length; i > 0; i--) {
|
||||||
|
// walk the string in reverse
|
||||||
|
reverse += arg[i - 1];
|
||||||
|
if (quoteHit && arg[i - 1] === '\\') {
|
||||||
|
reverse += '\\'; // double the slash
|
||||||
|
}
|
||||||
|
else if (arg[i - 1] === '"') {
|
||||||
|
quoteHit = true;
|
||||||
|
reverse += '"'; // double the quote
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
quoteHit = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reverse += '"';
|
||||||
|
return reverse
|
||||||
|
.split('')
|
||||||
|
.reverse()
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
_uvQuoteCmdArg(arg) {
|
||||||
|
// Tool runner wraps child_process.spawn() and needs to apply the same quoting as
|
||||||
|
// Node in certain cases where the undocumented spawn option windowsVerbatimArguments
|
||||||
|
// is used.
|
||||||
|
//
|
||||||
|
// Since this function is a port of quote_cmd_arg from Node 4.x (technically, lib UV,
|
||||||
|
// see https://github.com/nodejs/node/blob/v4.x/deps/uv/src/win/process.c for details),
|
||||||
|
// pasting copyright notice from Node within this function:
|
||||||
|
//
|
||||||
|
// Copyright Joyent, Inc. and other Node contributors. All rights reserved.
|
||||||
|
//
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to
|
||||||
|
// deal in the Software without restriction, including without limitation the
|
||||||
|
// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||||
|
// sell copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
//
|
||||||
|
// The above copyright notice and this permission notice shall be included in
|
||||||
|
// all copies or substantial portions of the Software.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||||
|
// IN THE SOFTWARE.
|
||||||
|
if (!arg) {
|
||||||
|
// Need double quotation for empty argument
|
||||||
|
return '""';
|
||||||
|
}
|
||||||
|
if (!arg.includes(' ') && !arg.includes('\t') && !arg.includes('"')) {
|
||||||
|
// No quotation needed
|
||||||
|
return arg;
|
||||||
|
}
|
||||||
|
if (!arg.includes('"') && !arg.includes('\\')) {
|
||||||
|
// No embedded double quotes or backslashes, so I can just wrap
|
||||||
|
// quote marks around the whole thing.
|
||||||
|
return `"${arg}"`;
|
||||||
|
}
|
||||||
|
// Expected input/output:
|
||||||
|
// input : hello"world
|
||||||
|
// output: "hello\"world"
|
||||||
|
// input : hello""world
|
||||||
|
// output: "hello\"\"world"
|
||||||
|
// input : hello\world
|
||||||
|
// output: hello\world
|
||||||
|
// input : hello\\world
|
||||||
|
// output: hello\\world
|
||||||
|
// input : hello\"world
|
||||||
|
// output: "hello\\\"world"
|
||||||
|
// input : hello\\"world
|
||||||
|
// output: "hello\\\\\"world"
|
||||||
|
// input : hello world\
|
||||||
|
// output: "hello world\\" - note the comment in libuv actually reads "hello world\"
|
||||||
|
// but it appears the comment is wrong, it should be "hello world\\"
|
||||||
|
let reverse = '"';
|
||||||
|
let quoteHit = true;
|
||||||
|
for (let i = arg.length; i > 0; i--) {
|
||||||
|
// walk the string in reverse
|
||||||
|
reverse += arg[i - 1];
|
||||||
|
if (quoteHit && arg[i - 1] === '\\') {
|
||||||
|
reverse += '\\';
|
||||||
|
}
|
||||||
|
else if (arg[i - 1] === '"') {
|
||||||
|
quoteHit = true;
|
||||||
|
reverse += '\\';
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
quoteHit = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reverse += '"';
|
||||||
|
return reverse
|
||||||
|
.split('')
|
||||||
|
.reverse()
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
_cloneExecOptions(options) {
|
||||||
|
options = options || {};
|
||||||
|
const result = {
|
||||||
|
cwd: options.cwd || process.cwd(),
|
||||||
|
env: options.env || process.env,
|
||||||
|
silent: options.silent || false,
|
||||||
|
windowsVerbatimArguments: options.windowsVerbatimArguments || false,
|
||||||
|
failOnStdErr: options.failOnStdErr || false,
|
||||||
|
ignoreReturnCode: options.ignoreReturnCode || false,
|
||||||
|
delay: options.delay || 10000
|
||||||
|
};
|
||||||
|
result.outStream = options.outStream || process.stdout;
|
||||||
|
result.errStream = options.errStream || process.stderr;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
_getSpawnOptions(options, toolPath) {
|
||||||
|
options = options || {};
|
||||||
|
const result = {};
|
||||||
|
result.cwd = options.cwd;
|
||||||
|
result.env = options.env;
|
||||||
|
result['windowsVerbatimArguments'] =
|
||||||
|
options.windowsVerbatimArguments || this._isCmdFile();
|
||||||
|
if (options.windowsVerbatimArguments) {
|
||||||
|
result.argv0 = `"${toolPath}"`;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Exec a tool.
|
||||||
|
* Output will be streamed to the live console.
|
||||||
|
* Returns promise with return code
|
||||||
|
*
|
||||||
|
* @param tool path to tool to exec
|
||||||
|
* @param options optional exec options. See ExecOptions
|
||||||
|
* @returns number
|
||||||
|
*/
|
||||||
|
exec() {
|
||||||
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
|
// root the tool path if it is unrooted and contains relative pathing
|
||||||
|
if (!ioUtil.isRooted(this.toolPath) &&
|
||||||
|
(this.toolPath.includes('/') ||
|
||||||
|
(IS_WINDOWS && this.toolPath.includes('\\')))) {
|
||||||
|
// prefer options.cwd if it is specified, however options.cwd may also need to be rooted
|
||||||
|
this.toolPath = path.resolve(process.cwd(), this.options.cwd || process.cwd(), this.toolPath);
|
||||||
|
}
|
||||||
|
// if the tool is only a file name, then resolve it from the PATH
|
||||||
|
// otherwise verify it exists (add extension on Windows if necessary)
|
||||||
|
this.toolPath = yield io.which(this.toolPath, true);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this._debug(`exec tool: ${this.toolPath}`);
|
||||||
|
this._debug('arguments:');
|
||||||
|
for (const arg of this.args) {
|
||||||
|
this._debug(` ${arg}`);
|
||||||
|
}
|
||||||
|
const optionsNonNull = this._cloneExecOptions(this.options);
|
||||||
|
if (!optionsNonNull.silent && optionsNonNull.outStream) {
|
||||||
|
optionsNonNull.outStream.write(this._getCommandString(optionsNonNull) + os.EOL);
|
||||||
|
}
|
||||||
|
const state = new ExecState(optionsNonNull, this.toolPath);
|
||||||
|
state.on('debug', (message) => {
|
||||||
|
this._debug(message);
|
||||||
|
});
|
||||||
|
const fileName = this._getSpawnFileName();
|
||||||
|
const cp = child.spawn(fileName, this._getSpawnArgs(optionsNonNull), this._getSpawnOptions(this.options, fileName));
|
||||||
|
const stdbuffer = '';
|
||||||
|
if (cp.stdout) {
|
||||||
|
cp.stdout.on('data', (data) => {
|
||||||
|
if (this.options.listeners && this.options.listeners.stdout) {
|
||||||
|
this.options.listeners.stdout(data);
|
||||||
|
}
|
||||||
|
if (!optionsNonNull.silent && optionsNonNull.outStream) {
|
||||||
|
optionsNonNull.outStream.write(data);
|
||||||
|
}
|
||||||
|
this._processLineBuffer(data, stdbuffer, (line) => {
|
||||||
|
if (this.options.listeners && this.options.listeners.stdline) {
|
||||||
|
this.options.listeners.stdline(line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const errbuffer = '';
|
||||||
|
if (cp.stderr) {
|
||||||
|
cp.stderr.on('data', (data) => {
|
||||||
|
state.processStderr = true;
|
||||||
|
if (this.options.listeners && this.options.listeners.stderr) {
|
||||||
|
this.options.listeners.stderr(data);
|
||||||
|
}
|
||||||
|
if (!optionsNonNull.silent &&
|
||||||
|
optionsNonNull.errStream &&
|
||||||
|
optionsNonNull.outStream) {
|
||||||
|
const s = optionsNonNull.failOnStdErr
|
||||||
|
? optionsNonNull.errStream
|
||||||
|
: optionsNonNull.outStream;
|
||||||
|
s.write(data);
|
||||||
|
}
|
||||||
|
this._processLineBuffer(data, errbuffer, (line) => {
|
||||||
|
if (this.options.listeners && this.options.listeners.errline) {
|
||||||
|
this.options.listeners.errline(line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
cp.on('error', (err) => {
|
||||||
|
state.processError = err.message;
|
||||||
|
state.processExited = true;
|
||||||
|
state.processClosed = true;
|
||||||
|
state.CheckComplete();
|
||||||
|
});
|
||||||
|
cp.on('exit', (code) => {
|
||||||
|
state.processExitCode = code;
|
||||||
|
state.processExited = true;
|
||||||
|
this._debug(`Exit code ${code} received from tool '${this.toolPath}'`);
|
||||||
|
state.CheckComplete();
|
||||||
|
});
|
||||||
|
cp.on('close', (code) => {
|
||||||
|
state.processExitCode = code;
|
||||||
|
state.processExited = true;
|
||||||
|
state.processClosed = true;
|
||||||
|
this._debug(`STDIO streams have closed for tool '${this.toolPath}'`);
|
||||||
|
state.CheckComplete();
|
||||||
|
});
|
||||||
|
state.on('done', (error, exitCode) => {
|
||||||
|
if (stdbuffer.length > 0) {
|
||||||
|
this.emit('stdline', stdbuffer);
|
||||||
|
}
|
||||||
|
if (errbuffer.length > 0) {
|
||||||
|
this.emit('errline', errbuffer);
|
||||||
|
}
|
||||||
|
cp.removeAllListeners();
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
resolve(exitCode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (this.options.input) {
|
||||||
|
if (!cp.stdin) {
|
||||||
|
throw new Error('child process missing stdin');
|
||||||
|
}
|
||||||
|
cp.stdin.end(this.options.input);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.ToolRunner = ToolRunner;
|
||||||
|
/**
|
||||||
|
* Convert an arg string to an array of args. Handles escaping
|
||||||
|
*
|
||||||
|
* @param argString string of arguments
|
||||||
|
* @returns string[] array of arguments
|
||||||
|
*/
|
||||||
|
function argStringToArray(argString) {
|
||||||
|
const args = [];
|
||||||
|
let inQuotes = false;
|
||||||
|
let escaped = false;
|
||||||
|
let arg = '';
|
||||||
|
function append(c) {
|
||||||
|
// we only escape double quotes.
|
||||||
|
if (escaped && c !== '"') {
|
||||||
|
arg += '\\';
|
||||||
|
}
|
||||||
|
arg += c;
|
||||||
|
escaped = false;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < argString.length; i++) {
|
||||||
|
const c = argString.charAt(i);
|
||||||
|
if (c === '"') {
|
||||||
|
if (!escaped) {
|
||||||
|
inQuotes = !inQuotes;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
append(c);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c === '\\' && escaped) {
|
||||||
|
append(c);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c === '\\' && inQuotes) {
|
||||||
|
escaped = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c === ' ' && !inQuotes) {
|
||||||
|
if (arg.length > 0) {
|
||||||
|
args.push(arg);
|
||||||
|
arg = '';
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
append(c);
|
||||||
|
}
|
||||||
|
if (arg.length > 0) {
|
||||||
|
args.push(arg.trim());
|
||||||
|
}
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
exports.argStringToArray = argStringToArray;
|
||||||
|
class ExecState extends events.EventEmitter {
|
||||||
|
constructor(options, toolPath) {
|
||||||
|
super();
|
||||||
|
this.processClosed = false; // tracks whether the process has exited and stdio is closed
|
||||||
|
this.processError = '';
|
||||||
|
this.processExitCode = 0;
|
||||||
|
this.processExited = false; // tracks whether the process has exited
|
||||||
|
this.processStderr = false; // tracks whether stderr was written to
|
||||||
|
this.delay = 10000; // 10 seconds
|
||||||
|
this.done = false;
|
||||||
|
this.timeout = null;
|
||||||
|
if (!toolPath) {
|
||||||
|
throw new Error('toolPath must not be empty');
|
||||||
|
}
|
||||||
|
this.options = options;
|
||||||
|
this.toolPath = toolPath;
|
||||||
|
if (options.delay) {
|
||||||
|
this.delay = options.delay;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CheckComplete() {
|
||||||
|
if (this.done) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.processClosed) {
|
||||||
|
this._setResult();
|
||||||
|
}
|
||||||
|
else if (this.processExited) {
|
||||||
|
this.timeout = setTimeout(ExecState.HandleTimeout, this.delay, this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_debug(message) {
|
||||||
|
this.emit('debug', message);
|
||||||
|
}
|
||||||
|
_setResult() {
|
||||||
|
// determine whether there is an error
|
||||||
|
let error;
|
||||||
|
if (this.processExited) {
|
||||||
|
if (this.processError) {
|
||||||
|
error = new Error(`There was an error when attempting to execute the process '${this.toolPath}'. This may indicate the process failed to start. Error: ${this.processError}`);
|
||||||
|
}
|
||||||
|
else if (this.processExitCode !== 0 && !this.options.ignoreReturnCode) {
|
||||||
|
error = new Error(`The process '${this.toolPath}' failed with exit code ${this.processExitCode}`);
|
||||||
|
}
|
||||||
|
else if (this.processStderr && this.options.failOnStdErr) {
|
||||||
|
error = new Error(`The process '${this.toolPath}' failed because one or more lines were written to the STDERR stream`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// clear the timeout
|
||||||
|
if (this.timeout) {
|
||||||
|
clearTimeout(this.timeout);
|
||||||
|
this.timeout = null;
|
||||||
|
}
|
||||||
|
this.done = true;
|
||||||
|
this.emit('done', error, this.processExitCode);
|
||||||
|
}
|
||||||
|
static HandleTimeout(state) {
|
||||||
|
if (state.done) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!state.processClosed && state.processExited) {
|
||||||
|
const message = `The STDIO streams did not close within ${state.delay /
|
||||||
|
1000} seconds of the exit event from process '${state.toolPath}'. This may indicate a child process inherited the STDIO streams and has not yet exited.`;
|
||||||
|
state._debug(message);
|
||||||
|
}
|
||||||
|
state._setResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=toolrunner.js.map
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
{
|
||||||
|
"_args": [
|
||||||
|
[
|
||||||
|
"@actions/[email protected]",
|
||||||
|
"/Users/drew.heavner/Documents/Github/sign-android-release"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"_from": "@actions/[email protected]",
|
||||||
|
"_id": "@actions/[email protected]",
|
||||||
|
"_inBundle": false,
|
||||||
|
"_integrity": "sha512-4DPChWow9yc9W3WqEbUj8Nr86xkpyE29ZzWjXucHItclLbEW6jr80Zx4nqv18QL6KK65+cifiQZXvnqgTV6oHw==",
|
||||||
|
"_location": "/@actions/exec",
|
||||||
|
"_phantomChildren": {},
|
||||||
|
"_requested": {
|
||||||
|
"type": "version",
|
||||||
|
"registry": true,
|
||||||
|
"raw": "@actions/[email protected]",
|
||||||
|
"name": "@actions/exec",
|
||||||
|
"escapedName": "@actions%2fexec",
|
||||||
|
"scope": "@actions",
|
||||||
|
"rawSpec": "1.0.4",
|
||||||
|
"saveSpec": null,
|
||||||
|
"fetchSpec": "1.0.4"
|
||||||
|
},
|
||||||
|
"_requiredBy": [
|
||||||
|
"/"
|
||||||
|
],
|
||||||
|
"_resolved": "https://registry.npmjs.org/@actions/exec/-/exec-1.0.4.tgz",
|
||||||
|
"_spec": "1.0.4",
|
||||||
|
"_where": "/Users/drew.heavner/Documents/Github/sign-android-release",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/actions/toolkit/issues"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@actions/io": "^1.0.1"
|
||||||
|
},
|
||||||
|
"description": "Actions exec lib",
|
||||||
|
"directories": {
|
||||||
|
"lib": "lib",
|
||||||
|
"test": "__tests__"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"lib"
|
||||||
|
],
|
||||||
|
"homepage": "https://github.com/actions/toolkit/tree/master/packages/exec",
|
||||||
|
"keywords": [
|
||||||
|
"github",
|
||||||
|
"actions",
|
||||||
|
"exec"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"main": "lib/exec.js",
|
||||||
|
"name": "@actions/exec",
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/actions/toolkit.git",
|
||||||
|
"directory": "packages/exec"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"audit-moderate": "npm install && npm audit --audit-level=moderate",
|
||||||
|
"test": "echo \"Error: run tests from root\" && exit 1",
|
||||||
|
"tsc": "tsc"
|
||||||
|
},
|
||||||
|
"types": "lib/exec.d.ts",
|
||||||
|
"version": "1.0.4"
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# `@actions/io`
|
||||||
|
|
||||||
|
> Core functions for cli filesystem scenarios
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
#### mkdir -p
|
||||||
|
|
||||||
|
Recursively make a directory. Follow rules specified
|
||||||
|
in [man mkdir](https://linux.die.net/man/1/mkdir) with the `-p` option specified:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const io = require('@actions/io');
|
||||||
|
|
||||||
|
await io.mkdirP('path/to/make');
|
||||||
|
```
|
||||||
|
|
||||||
|
#### cp/mv
|
||||||
|
|
||||||
|
Copy or move files or folders. Follow rules specified in [man cp](https://linux.die.net/man/1/cp)
|
||||||
|
and [man mv](https://linux.die.net/man/1/mv):
|
||||||
|
|
||||||
|
```js
|
||||||
|
const io = require('@actions/io');
|
||||||
|
|
||||||
|
// Recursive must be true for directories
|
||||||
|
const options = { recursive: true, force: false }
|
||||||
|
|
||||||
|
await io.cp('path/to/directory', 'path/to/dest', options);
|
||||||
|
await io.mv('path/to/file', 'path/to/dest');
|
||||||
|
```
|
||||||
|
|
||||||
|
#### rm -rf
|
||||||
|
|
||||||
|
Remove a file or folder recursively. Follow rules specified
|
||||||
|
in [man rm](https://linux.die.net/man/1/rm) with the `-r` and `-f` rules specified.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const io = require('@actions/io');
|
||||||
|
|
||||||
|
await io.rmRF('path/to/directory');
|
||||||
|
await io.rmRF('path/to/file');
|
||||||
|
```
|
||||||
|
|
||||||
|
#### which
|
||||||
|
|
||||||
|
Get the path to a tool and resolves via paths. Follows the rules specified
|
||||||
|
in [man which](https://linux.die.net/man/1/which).
|
||||||
|
|
||||||
|
```js
|
||||||
|
const exec = require('@actions/exec');
|
||||||
|
const io = require('@actions/io');
|
||||||
|
|
||||||
|
const pythonPath: string = await io.which('python', true)
|
||||||
|
|
||||||
|
await exec.exec(`"${pythonPath}"`, ['main.py']);
|
||||||
|
```
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/// <reference types="node" />
|
||||||
|
import * as fs from 'fs';
|
||||||
|
export declare const chmod: typeof fs.promises.chmod, copyFile: typeof fs.promises.copyFile, lstat: typeof fs.promises.lstat, mkdir: typeof fs.promises.mkdir, readdir: typeof fs.promises.readdir, readlink: typeof fs.promises.readlink, rename: typeof fs.promises.rename, rmdir: typeof fs.promises.rmdir, stat: typeof fs.promises.stat, symlink: typeof fs.promises.symlink, unlink: typeof fs.promises.unlink;
|
||||||
|
export declare const IS_WINDOWS: boolean;
|
||||||
|
export declare function exists(fsPath: string): Promise<boolean>;
|
||||||
|
export declare function isDirectory(fsPath: string, useStat?: boolean): Promise<boolean>;
|
||||||
|
/**
|
||||||
|
* On OSX/Linux, true if path starts with '/'. On Windows, true for paths like:
|
||||||
|
* \, \hello, \\hello\share, C:, and C:\hello (and corresponding alternate separator cases).
|
||||||
|
*/
|
||||||
|
export declare function isRooted(p: string): boolean;
|
||||||
|
/**
|
||||||
|
* Recursively create a directory at `fsPath`.
|
||||||
|
*
|
||||||
|
* This implementation is optimistic, meaning it attempts to create the full
|
||||||
|
* path first, and backs up the path stack from there.
|
||||||
|
*
|
||||||
|
* @param fsPath The path to create
|
||||||
|
* @param maxDepth The maximum recursion depth
|
||||||
|
* @param depth The current recursion depth
|
||||||
|
*/
|
||||||
|
export declare function mkdirP(fsPath: string, maxDepth?: number, depth?: number): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Best effort attempt to determine whether a file exists and is executable.
|
||||||
|
* @param filePath file path to check
|
||||||
|
* @param extensions additional file extensions to try
|
||||||
|
* @return if file exists and is executable, returns the file path. otherwise empty string.
|
||||||
|
*/
|
||||||
|
export declare function tryGetExecutablePath(filePath: string, extensions: string[]): Promise<string>;
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"use strict";
|
||||||
|
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
||||||
|
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
||||||
|
return new (P || (P = Promise))(function (resolve, reject) {
|
||||||
|
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
||||||
|
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
||||||
|
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
||||||
|
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
var _a;
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
const assert_1 = require("assert");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
_a = fs.promises, exports.chmod = _a.chmod, exports.copyFile = _a.copyFile, exports.lstat = _a.lstat, exports.mkdir = _a.mkdir, exports.readdir = _a.readdir, exports.readlink = _a.readlink, exports.rename = _a.rename, exports.rmdir = _a.rmdir, exports.stat = _a.stat, exports.symlink = _a.symlink, exports.unlink = _a.unlink;
|
||||||
|
exports.IS_WINDOWS = process.platform === 'win32';
|
||||||
|
function exists(fsPath) {
|
||||||
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
|
try {
|
||||||
|
yield exports.stat(fsPath);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
if (err.code === 'ENOENT') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
exports.exists = exists;
|
||||||
|
function isDirectory(fsPath, useStat = false) {
|
||||||
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
|
const stats = useStat ? yield exports.stat(fsPath) : yield exports.lstat(fsPath);
|
||||||
|
return stats.isDirectory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
exports.isDirectory = isDirectory;
|
||||||
|
/**
|
||||||
|
* On OSX/Linux, true if path starts with '/'. On Windows, true for paths like:
|
||||||
|
* \, \hello, \\hello\share, C:, and C:\hello (and corresponding alternate separator cases).
|
||||||
|
*/
|
||||||
|
function isRooted(p) {
|
||||||
|
p = normalizeSeparators(p);
|
||||||
|
if (!p) {
|
||||||
|
throw new Error('isRooted() parameter "p" cannot be empty');
|
||||||
|
}
|
||||||
|
if (exports.IS_WINDOWS) {
|
||||||
|
return (p.startsWith('\\') || /^[A-Z]:/i.test(p) // e.g. \ or \hello or \\hello
|
||||||
|
); // e.g. C: or C:\hello
|
||||||
|
}
|
||||||
|
return p.startsWith('/');
|
||||||
|
}
|
||||||
|
exports.isRooted = isRooted;
|
||||||
|
/**
|
||||||
|
* Recursively create a directory at `fsPath`.
|
||||||
|
*
|
||||||
|
* This implementation is optimistic, meaning it attempts to create the full
|
||||||
|
* path first, and backs up the path stack from there.
|
||||||
|
*
|
||||||
|
* @param fsPath The path to create
|
||||||
|
* @param maxDepth The maximum recursion depth
|
||||||
|
* @param depth The current recursion depth
|
||||||
|
*/
|
||||||
|
function mkdirP(fsPath, maxDepth = 1000, depth = 1) {
|
||||||
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
|
assert_1.ok(fsPath, 'a path argument must be provided');
|
||||||
|
fsPath = path.resolve(fsPath);
|
||||||
|
if (depth >= maxDepth)
|
||||||
|
return exports.mkdir(fsPath);
|
||||||
|
try {
|
||||||
|
yield exports.mkdir(fsPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
switch (err.code) {
|
||||||
|
case 'ENOENT': {
|
||||||
|
yield mkdirP(path.dirname(fsPath), maxDepth, depth + 1);
|
||||||
|
yield exports.mkdir(fsPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
let stats;
|
||||||
|
try {
|
||||||
|
stats = yield exports.stat(fsPath);
|
||||||
|
}
|
||||||
|
catch (err2) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (!stats.isDirectory())
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
exports.mkdirP = mkdirP;
|
||||||
|
/**
|
||||||
|
* Best effort attempt to determine whether a file exists and is executable.
|
||||||
|
* @param filePath file path to check
|
||||||
|
* @param extensions additional file extensions to try
|
||||||
|
* @return if file exists and is executable, returns the file path. otherwise empty string.
|
||||||
|
*/
|
||||||
|
function tryGetExecutablePath(filePath, extensions) {
|
||||||
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
|
let stats = undefined;
|
||||||
|
try {
|
||||||
|
// test file exists
|
||||||
|
stats = yield exports.stat(filePath);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
if (err.code !== 'ENOENT') {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`Unexpected error attempting to determine if executable file exists '${filePath}': ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (stats && stats.isFile()) {
|
||||||
|
if (exports.IS_WINDOWS) {
|
||||||
|
// on Windows, test for valid extension
|
||||||
|
const upperExt = path.extname(filePath).toUpperCase();
|
||||||
|
if (extensions.some(validExt => validExt.toUpperCase() === upperExt)) {
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if (isUnixExecutable(stats)) {
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// try each extension
|
||||||
|
const originalFilePath = filePath;
|
||||||
|
for (const extension of extensions) {
|
||||||
|
filePath = originalFilePath + extension;
|
||||||
|
stats = undefined;
|
||||||
|
try {
|
||||||
|
stats = yield exports.stat(filePath);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
if (err.code !== 'ENOENT') {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`Unexpected error attempting to determine if executable file exists '${filePath}': ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (stats && stats.isFile()) {
|
||||||
|
if (exports.IS_WINDOWS) {
|
||||||
|
// preserve the case of the actual file (since an extension was appended)
|
||||||
|
try {
|
||||||
|
const directory = path.dirname(filePath);
|
||||||
|
const upperName = path.basename(filePath).toUpperCase();
|
||||||
|
for (const actualName of yield exports.readdir(directory)) {
|
||||||
|
if (upperName === actualName.toUpperCase()) {
|
||||||
|
filePath = path.join(directory, actualName);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`Unexpected error attempting to determine the actual case of the file '${filePath}': ${err}`);
|
||||||
|
}
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if (isUnixExecutable(stats)) {
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
exports.tryGetExecutablePath = tryGetExecutablePath;
|
||||||
|
function normalizeSeparators(p) {
|
||||||
|
p = p || '';
|
||||||
|
if (exports.IS_WINDOWS) {
|
||||||
|
// convert slashes on Windows
|
||||||
|
p = p.replace(/\//g, '\\');
|
||||||
|
// remove redundant slashes
|
||||||
|
return p.replace(/\\\\+/g, '\\');
|
||||||
|
}
|
||||||
|
// remove redundant slashes
|
||||||
|
return p.replace(/\/\/+/g, '/');
|
||||||
|
}
|
||||||
|
// on Mac/Linux, test the execute bit
|
||||||
|
// R W X R W X R W X
|
||||||
|
// 256 128 64 32 16 8 4 2 1
|
||||||
|
function isUnixExecutable(stats) {
|
||||||
|
return ((stats.mode & 1) > 0 ||
|
||||||
|
((stats.mode & 8) > 0 && stats.gid === process.getgid()) ||
|
||||||
|
((stats.mode & 64) > 0 && stats.uid === process.getuid()));
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=io-util.js.map
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"io-util.js","sourceRoot":"","sources":["../src/io-util.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,mCAAyB;AACzB,yBAAwB;AACxB,6BAA4B;AAEf,gBAYE,qTAAA;AAEF,QAAA,UAAU,GAAG,OAAO,CAAC,QAAQ,KAAK,OAAO,CAAA;AAEtD,SAAsB,MAAM,CAAC,MAAc;;QACzC,IAAI;YACF,MAAM,YAAI,CAAC,MAAM,CAAC,CAAA;SACnB;QAAC,OAAO,GAAG,EAAE;YACZ,IAAI,GAAG,CAAC,IAAI,KAAK,QAAQ,EAAE;gBACzB,OAAO,KAAK,CAAA;aACb;YAED,MAAM,GAAG,CAAA;SACV;QAED,OAAO,IAAI,CAAA;IACb,CAAC;CAAA;AAZD,wBAYC;AAED,SAAsB,WAAW,CAC/B,MAAc,EACd,UAAmB,KAAK;;QAExB,MAAM,KAAK,GAAG,OAAO,CAAC,CAAC,CAAC,MAAM,YAAI,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,aAAK,CAAC,MAAM,CAAC,CAAA;QAChE,OAAO,KAAK,CAAC,WAAW,EAAE,CAAA;IAC5B,CAAC;CAAA;AAND,kCAMC;AAED;;;GAGG;AACH,SAAgB,QAAQ,CAAC,CAAS;IAChC,CAAC,GAAG,mBAAmB,CAAC,CAAC,CAAC,CAAA;IAC1B,IAAI,CAAC,CAAC,EAAE;QACN,MAAM,IAAI,KAAK,CAAC,0CAA0C,CAAC,CAAA;KAC5D;IAED,IAAI,kBAAU,EAAE;QACd,OAAO,CACL,CAAC,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,8BAA8B;SACxE,CAAA,CAAC,sBAAsB;KACzB;IAED,OAAO,CAAC,CAAC,UAAU,CAAC,GAAG,CAAC,CAAA;AAC1B,CAAC;AAbD,4BAaC;AAED;;;;;;;;;GASG;AACH,SAAsB,MAAM,CAC1B,MAAc,EACd,WAAmB,IAAI,EACvB,QAAgB,CAAC;;QAEjB,WAAE,CAAC,MAAM,EAAE,kCAAkC,CAAC,CAAA;QAE9C,MAAM,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,CAAA;QAE7B,IAAI,KAAK,IAAI,QAAQ;YAAE,OAAO,aAAK,CAAC,MAAM,CAAC,CAAA;QAE3C,IAAI;YACF,MAAM,aAAK,CAAC,MAAM,CAAC,CAAA;YACnB,OAAM;SACP;QAAC,OAAO,GAAG,EAAE;YACZ,QAAQ,GAAG,CAAC,IAAI,EAAE;gBAChB,KAAK,QAAQ,CAAC,CAAC;oBACb,MAAM,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,QAAQ,EAAE,KAAK,GAAG,CAAC,CAAC,CAAA;oBACvD,MAAM,aAAK,CAAC,MAAM,CAAC,CAAA;oBACnB,OAAM;iBACP;gBACD,OAAO,CAAC,CAAC;oBACP,IAAI,KAAe,CAAA;oBAEnB,IAAI;wBACF,KAAK,GAAG,MAAM,YAAI,CAAC,MAAM,CAAC,CAAA;qBAC3B;oBAAC,OAAO,IAAI,EAAE;wBACb,MAAM,GAAG,CAAA;qBACV;oBAED,IAAI,CAAC,KAAK,CAAC,WAAW,EAAE;wBAAE,MAAM,GAAG,CAAA;iBACpC;aACF;SACF;IACH,CAAC;CAAA;AAlCD,wBAkCC;AAED;;;;;GAKG;AACH,SAAsB,oBAAoB,CACxC,QAAgB,EAChB,UAAoB;;QAEpB,IAAI,KAAK,GAAyB,SAAS,CAAA;QAC3C,IAAI;YACF,mBAAmB;YACnB,KAAK,GAAG,MAAM,YAAI,CAAC,QAAQ,CAAC,CAAA;SAC7B;QAAC,OAAO,GAAG,EAAE;YACZ,IAAI,GAAG,CAAC,IAAI,KAAK,QAAQ,EAAE;gBACzB,sCAAsC;gBACtC,OAAO,CAAC,GAAG,CACT,uEAAuE,QAAQ,MAAM,GAAG,EAAE,CAC3F,CAAA;aACF;SACF;QACD,IAAI,KAAK,IAAI,KAAK,CAAC,MAAM,EAAE,EAAE;YAC3B,IAAI,kBAAU,EAAE;gBACd,uCAAuC;gBACvC,MAAM,QAAQ,GAAG,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,WAAW,EAAE,CAAA;gBACrD,IAAI,UAAU,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC,QAAQ,CAAC,WAAW,EAAE,KAAK,QAAQ,CAAC,EAAE;oBACpE,OAAO,QAAQ,CAAA;iBAChB;aACF;iBAAM;gBACL,IAAI,gBAAgB,CAAC,KAAK,CAAC,EAAE;oBAC3B,OAAO,QAAQ,CAAA;iBAChB;aACF;SACF;QAED,qBAAqB;QACrB,MAAM,gBAAgB,GAAG,QAAQ,CAAA;QACjC,KAAK,MAAM,SAAS,IAAI,UAAU,EAAE;YAClC,QAAQ,GAAG,gBAAgB,GAAG,SAAS,CAAA;YAEvC,KAAK,GAAG,SAAS,CAAA;YACjB,IAAI;gBACF,KAAK,GAAG,MAAM,YAAI,CAAC,QAAQ,CAAC,CAAA;aAC7B;YAAC,OAAO,GAAG,EAAE;gBACZ,IAAI,GAAG,CAAC,IAAI,KAAK,QAAQ,EAAE;oBACzB,sCAAsC;oBACtC,OAAO,CAAC,GAAG,CACT,uEAAuE,QAAQ,MAAM,GAAG,EAAE,CAC3F,CAAA;iBACF;aACF;YAED,IAAI,KAAK,IAAI,KAAK,CAAC,MAAM,EAAE,EAAE;gBAC3B,IAAI,kBAAU,EAAE;oBACd,yEAAyE;oBACzE,IAAI;wBACF,MAAM,SAAS,GAAG,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAA;wBACxC,MAAM,SAAS,GAAG,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,WAAW,EAAE,CAAA;wBACvD,KAAK,MAAM,UAAU,IAAI,MAAM,eAAO,CAAC,SAAS,CAAC,EAAE;4BACjD,IAAI,SAAS,KAAK,UAAU,CAAC,WAAW,EAAE,EAAE;gCAC1C,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,UAAU,CAAC,CAAA;gCAC3C,MAAK;6BACN;yBACF;qBACF;oBAAC,OAAO,GAAG,EAAE;wBACZ,sCAAsC;wBACtC,OAAO,CAAC,GAAG,CACT,yEAAyE,QAAQ,MAAM,GAAG,EAAE,CAC7F,CAAA;qBACF;oBAED,OAAO,QAAQ,CAAA;iBAChB;qBAAM;oBACL,IAAI,gBAAgB,CAAC,KAAK,CAAC,EAAE;wBAC3B,OAAO,QAAQ,CAAA;qBAChB;iBACF;aACF;SACF;QAED,OAAO,EAAE,CAAA;IACX,CAAC;CAAA;AA5ED,oDA4EC;AAED,SAAS,mBAAmB,CAAC,CAAS;IACpC,CAAC,GAAG,CAAC,IAAI,EAAE,CAAA;IACX,IAAI,kBAAU,EAAE;QACd,6BAA6B;QAC7B,CAAC,GAAG,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,IAAI,CAAC,CAAA;QAE1B,2BAA2B;QAC3B,OAAO,CAAC,CAAC,OAAO,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAA;KACjC;IAED,2BAA2B;IAC3B,OAAO,CAAC,CAAC,OAAO,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAA;AACjC,CAAC;AAED,qCAAqC;AACrC,6BAA6B;AAC7B,6BAA6B;AAC7B,SAAS,gBAAgB,CAAC,KAAe;IACvC,OAAO,CACL,CAAC,KAAK,CAAC,IAAI,GAAG,CAAC,CAAC,GAAG,CAAC;QACpB,CAAC,CAAC,KAAK,CAAC,IAAI,GAAG,CAAC,CAAC,GAAG,CAAC,IAAI,KAAK,CAAC,GAAG,KAAK,OAAO,CAAC,MAAM,EAAE,CAAC;QACxD,CAAC,CAAC,KAAK,CAAC,IAAI,GAAG,EAAE,CAAC,GAAG,CAAC,IAAI,KAAK,CAAC,GAAG,KAAK,OAAO,CAAC,MAAM,EAAE,CAAC,CAC1D,CAAA;AACH,CAAC"}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Interface for cp/mv options
|
||||||
|
*/
|
||||||
|
export interface CopyOptions {
|
||||||
|
/** Optional. Whether to recursively copy all subdirectories. Defaults to false */
|
||||||
|
recursive?: boolean;
|
||||||
|
/** Optional. Whether to overwrite existing files in the destination. Defaults to true */
|
||||||
|
force?: boolean;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Interface for cp/mv options
|
||||||
|
*/
|
||||||
|
export interface MoveOptions {
|
||||||
|
/** Optional. Whether to overwrite existing files in the destination. Defaults to true */
|
||||||
|
force?: boolean;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Copies a file or folder.
|
||||||
|
* Based off of shelljs - https://github.com/shelljs/shelljs/blob/9237f66c52e5daa40458f94f9565e18e8132f5a6/src/cp.js
|
||||||
|
*
|
||||||
|
* @param source source path
|
||||||
|
* @param dest destination path
|
||||||
|
* @param options optional. See CopyOptions.
|
||||||
|
*/
|
||||||
|
export declare function cp(source: string, dest: string, options?: CopyOptions): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Moves a path.
|
||||||
|
*
|
||||||
|
* @param source source path
|
||||||
|
* @param dest destination path
|
||||||
|
* @param options optional. See MoveOptions.
|
||||||
|
*/
|
||||||
|
export declare function mv(source: string, dest: string, options?: MoveOptions): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Remove a path recursively with force
|
||||||
|
*
|
||||||
|
* @param inputPath path to remove
|
||||||
|
*/
|
||||||
|
export declare function rmRF(inputPath: string): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Make a directory. Creates the full path with folders in between
|
||||||
|
* Will throw if it fails
|
||||||
|
*
|
||||||
|
* @param fsPath path to create
|
||||||
|
* @returns Promise<void>
|
||||||
|
*/
|
||||||
|
export declare function mkdirP(fsPath: string): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Returns path of a tool had the tool actually been invoked. Resolves via paths.
|
||||||
|
* If you check and the tool does not exist, it will throw.
|
||||||
|
*
|
||||||
|
* @param tool name of the tool
|
||||||
|
* @param check whether to check if tool exists
|
||||||
|
* @returns Promise<string> path to tool
|
||||||
|
*/
|
||||||
|
export declare function which(tool: string, check?: boolean): Promise<string>;
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
"use strict";
|
||||||
|
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
||||||
|
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
||||||
|
return new (P || (P = Promise))(function (resolve, reject) {
|
||||||
|
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
||||||
|
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
||||||
|
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
||||||
|
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
const childProcess = require("child_process");
|
||||||
|
const path = require("path");
|
||||||
|
const util_1 = require("util");
|
||||||
|
const ioUtil = require("./io-util");
|
||||||
|
const exec = util_1.promisify(childProcess.exec);
|
||||||
|
/**
|
||||||
|
* Copies a file or folder.
|
||||||
|
* Based off of shelljs - https://github.com/shelljs/shelljs/blob/9237f66c52e5daa40458f94f9565e18e8132f5a6/src/cp.js
|
||||||
|
*
|
||||||
|
* @param source source path
|
||||||
|
* @param dest destination path
|
||||||
|
* @param options optional. See CopyOptions.
|
||||||
|
*/
|
||||||
|
function cp(source, dest, options = {}) {
|
||||||
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
|
const { force, recursive } = readCopyOptions(options);
|
||||||
|
const destStat = (yield ioUtil.exists(dest)) ? yield ioUtil.stat(dest) : null;
|
||||||
|
// Dest is an existing file, but not forcing
|
||||||
|
if (destStat && destStat.isFile() && !force) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// If dest is an existing directory, should copy inside.
|
||||||
|
const newDest = destStat && destStat.isDirectory()
|
||||||
|
? path.join(dest, path.basename(source))
|
||||||
|
: dest;
|
||||||
|
if (!(yield ioUtil.exists(source))) {
|
||||||
|
throw new Error(`no such file or directory: ${source}`);
|
||||||
|
}
|
||||||
|
const sourceStat = yield ioUtil.stat(source);
|
||||||
|
if (sourceStat.isDirectory()) {
|
||||||
|
if (!recursive) {
|
||||||
|
throw new Error(`Failed to copy. ${source} is a directory, but tried to copy without recursive flag.`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
yield cpDirRecursive(source, newDest, 0, force);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if (path.relative(source, newDest) === '') {
|
||||||
|
// a file cannot be copied to itself
|
||||||
|
throw new Error(`'${newDest}' and '${source}' are the same file`);
|
||||||
|
}
|
||||||
|
yield copyFile(source, newDest, force);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
exports.cp = cp;
|
||||||
|
/**
|
||||||
|
* Moves a path.
|
||||||
|
*
|
||||||
|
* @param source source path
|
||||||
|
* @param dest destination path
|
||||||
|
* @param options optional. See MoveOptions.
|
||||||
|
*/
|
||||||
|
function mv(source, dest, options = {}) {
|
||||||
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
|
if (yield ioUtil.exists(dest)) {
|
||||||
|
let destExists = true;
|
||||||
|
if (yield ioUtil.isDirectory(dest)) {
|
||||||
|
// If dest is directory copy src into dest
|
||||||
|
dest = path.join(dest, path.basename(source));
|
||||||
|
destExists = yield ioUtil.exists(dest);
|
||||||
|
}
|
||||||
|
if (destExists) {
|
||||||
|
if (options.force == null || options.force) {
|
||||||
|
yield rmRF(dest);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
throw new Error('Destination already exists');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
yield mkdirP(path.dirname(dest));
|
||||||
|
yield ioUtil.rename(source, dest);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
exports.mv = mv;
|
||||||
|
/**
|
||||||
|
* Remove a path recursively with force
|
||||||
|
*
|
||||||
|
* @param inputPath path to remove
|
||||||
|
*/
|
||||||
|
function rmRF(inputPath) {
|
||||||
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
|
if (ioUtil.IS_WINDOWS) {
|
||||||
|
// Node doesn't provide a delete operation, only an unlink function. This means that if the file is being used by another
|
||||||
|
// program (e.g. antivirus), it won't be deleted. To address this, we shell out the work to rd/del.
|
||||||
|
try {
|
||||||
|
if (yield ioUtil.isDirectory(inputPath, true)) {
|
||||||
|
yield exec(`rd /s /q "${inputPath}"`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
yield exec(`del /f /a "${inputPath}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
// if you try to delete a file that doesn't exist, desired result is achieved
|
||||||
|
// other errors are valid
|
||||||
|
if (err.code !== 'ENOENT')
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
// Shelling out fails to remove a symlink folder with missing source, this unlink catches that
|
||||||
|
try {
|
||||||
|
yield ioUtil.unlink(inputPath);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
// if you try to delete a file that doesn't exist, desired result is achieved
|
||||||
|
// other errors are valid
|
||||||
|
if (err.code !== 'ENOENT')
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
let isDir = false;
|
||||||
|
try {
|
||||||
|
isDir = yield ioUtil.isDirectory(inputPath);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
// if you try to delete a file that doesn't exist, desired result is achieved
|
||||||
|
// other errors are valid
|
||||||
|
if (err.code !== 'ENOENT')
|
||||||
|
throw err;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isDir) {
|
||||||
|
yield exec(`rm -rf "${inputPath}"`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
yield ioUtil.unlink(inputPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
exports.rmRF = rmRF;
|
||||||
|
/**
|
||||||
|
* Make a directory. Creates the full path with folders in between
|
||||||
|
* Will throw if it fails
|
||||||
|
*
|
||||||
|
* @param fsPath path to create
|
||||||
|
* @returns Promise<void>
|
||||||
|
*/
|
||||||
|
function mkdirP(fsPath) {
|
||||||
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
|
yield ioUtil.mkdirP(fsPath);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
exports.mkdirP = mkdirP;
|
||||||
|
/**
|
||||||
|
* Returns path of a tool had the tool actually been invoked. Resolves via paths.
|
||||||
|
* If you check and the tool does not exist, it will throw.
|
||||||
|
*
|
||||||
|
* @param tool name of the tool
|
||||||
|
* @param check whether to check if tool exists
|
||||||
|
* @returns Promise<string> path to tool
|
||||||
|
*/
|
||||||
|
function which(tool, check) {
|
||||||
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
|
if (!tool) {
|
||||||
|
throw new Error("parameter 'tool' is required");
|
||||||
|
}
|
||||||
|
// recursive when check=true
|
||||||
|
if (check) {
|
||||||
|
const result = yield which(tool, false);
|
||||||
|
if (!result) {
|
||||||
|
if (ioUtil.IS_WINDOWS) {
|
||||||
|
throw new Error(`Unable to locate executable file: ${tool}. Please verify either the file path exists or the file can be found within a directory specified by the PATH environment variable. Also verify the file has a valid extension for an executable file.`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
throw new Error(`Unable to locate executable file: ${tool}. Please verify either the file path exists or the file can be found within a directory specified by the PATH environment variable. Also check the file mode to verify the file is executable.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// build the list of extensions to try
|
||||||
|
const extensions = [];
|
||||||
|
if (ioUtil.IS_WINDOWS && process.env.PATHEXT) {
|
||||||
|
for (const extension of process.env.PATHEXT.split(path.delimiter)) {
|
||||||
|
if (extension) {
|
||||||
|
extensions.push(extension);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// if it's rooted, return it if exists. otherwise return empty.
|
||||||
|
if (ioUtil.isRooted(tool)) {
|
||||||
|
const filePath = yield ioUtil.tryGetExecutablePath(tool, extensions);
|
||||||
|
if (filePath) {
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
// if any path separators, return empty
|
||||||
|
if (tool.includes('/') || (ioUtil.IS_WINDOWS && tool.includes('\\'))) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
// build the list of directories
|
||||||
|
//
|
||||||
|
// Note, technically "where" checks the current directory on Windows. From a toolkit perspective,
|
||||||
|
// it feels like we should not do this. Checking the current directory seems like more of a use
|
||||||
|
// case of a shell, and the which() function exposed by the toolkit should strive for consistency
|
||||||
|
// across platforms.
|
||||||
|
const directories = [];
|
||||||
|
if (process.env.PATH) {
|
||||||
|
for (const p of process.env.PATH.split(path.delimiter)) {
|
||||||
|
if (p) {
|
||||||
|
directories.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// return the first match
|
||||||
|
for (const directory of directories) {
|
||||||
|
const filePath = yield ioUtil.tryGetExecutablePath(directory + path.sep + tool, extensions);
|
||||||
|
if (filePath) {
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
throw new Error(`which failed with message ${err.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
exports.which = which;
|
||||||
|
function readCopyOptions(options) {
|
||||||
|
const force = options.force == null ? true : options.force;
|
||||||
|
const recursive = Boolean(options.recursive);
|
||||||
|
return { force, recursive };
|
||||||
|
}
|
||||||
|
function cpDirRecursive(sourceDir, destDir, currentDepth, force) {
|
||||||
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
|
// Ensure there is not a run away recursive copy
|
||||||
|
if (currentDepth >= 255)
|
||||||
|
return;
|
||||||
|
currentDepth++;
|
||||||
|
yield mkdirP(destDir);
|
||||||
|
const files = yield ioUtil.readdir(sourceDir);
|
||||||
|
for (const fileName of files) {
|
||||||
|
const srcFile = `${sourceDir}/${fileName}`;
|
||||||
|
const destFile = `${destDir}/${fileName}`;
|
||||||
|
const srcFileStat = yield ioUtil.lstat(srcFile);
|
||||||
|
if (srcFileStat.isDirectory()) {
|
||||||
|
// Recurse
|
||||||
|
yield cpDirRecursive(srcFile, destFile, currentDepth, force);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
yield copyFile(srcFile, destFile, force);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Change the mode for the newly created directory
|
||||||
|
yield ioUtil.chmod(destDir, (yield ioUtil.stat(sourceDir)).mode);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Buffered file copy
|
||||||
|
function copyFile(srcFile, destFile, force) {
|
||||||
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
|
if ((yield ioUtil.lstat(srcFile)).isSymbolicLink()) {
|
||||||
|
// unlink/re-link it
|
||||||
|
try {
|
||||||
|
yield ioUtil.lstat(destFile);
|
||||||
|
yield ioUtil.unlink(destFile);
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
// Try to override file permission
|
||||||
|
if (e.code === 'EPERM') {
|
||||||
|
yield ioUtil.chmod(destFile, '0666');
|
||||||
|
yield ioUtil.unlink(destFile);
|
||||||
|
}
|
||||||
|
// other errors = it doesn't exist, no work to do
|
||||||
|
}
|
||||||
|
// Copy over symlink
|
||||||
|
const symlinkFull = yield ioUtil.readlink(srcFile);
|
||||||
|
yield ioUtil.symlink(symlinkFull, destFile, ioUtil.IS_WINDOWS ? 'junction' : null);
|
||||||
|
}
|
||||||
|
else if (!(yield ioUtil.exists(destFile)) || force) {
|
||||||
|
yield ioUtil.copyFile(srcFile, destFile);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=io.js.map
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"_args": [
|
||||||
|
[
|
||||||
|
"@actions/[email protected]",
|
||||||
|
"/Users/drew.heavner/Documents/Github/sign-android-release"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"_from": "@actions/[email protected]",
|
||||||
|
"_id": "@actions/[email protected]",
|
||||||
|
"_inBundle": false,
|
||||||
|
"_integrity": "sha512-J8KuFqVPr3p6U8W93DOXlXW6zFvrQAJANdS+vw0YhusLIq+bszW8zmK2Fh1C2kDPX8FMvwIl1OUcFgvJoXLbAg==",
|
||||||
|
"_location": "/@actions/io",
|
||||||
|
"_phantomChildren": {},
|
||||||
|
"_requested": {
|
||||||
|
"type": "version",
|
||||||
|
"registry": true,
|
||||||
|
"raw": "@actions/[email protected]",
|
||||||
|
"name": "@actions/io",
|
||||||
|
"escapedName": "@actions%2fio",
|
||||||
|
"scope": "@actions",
|
||||||
|
"rawSpec": "1.0.2",
|
||||||
|
"saveSpec": null,
|
||||||
|
"fetchSpec": "1.0.2"
|
||||||
|
},
|
||||||
|
"_requiredBy": [
|
||||||
|
"/",
|
||||||
|
"/@actions/exec"
|
||||||
|
],
|
||||||
|
"_resolved": "https://registry.npmjs.org/@actions/io/-/io-1.0.2.tgz",
|
||||||
|
"_spec": "1.0.2",
|
||||||
|
"_where": "/Users/drew.heavner/Documents/Github/sign-android-release",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/actions/toolkit/issues"
|
||||||
|
},
|
||||||
|
"description": "Actions io lib",
|
||||||
|
"directories": {
|
||||||
|
"lib": "lib",
|
||||||
|
"test": "__tests__"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"lib"
|
||||||
|
],
|
||||||
|
"homepage": "https://github.com/actions/toolkit/tree/master/packages/io",
|
||||||
|
"keywords": [
|
||||||
|
"github",
|
||||||
|
"actions",
|
||||||
|
"io"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"main": "lib/io.js",
|
||||||
|
"name": "@actions/io",
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/actions/toolkit.git",
|
||||||
|
"directory": "packages/io"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"audit-moderate": "npm install && npm audit --audit-level=moderate",
|
||||||
|
"test": "echo \"Error: run tests from root\" && exit 1",
|
||||||
|
"tsc": "tsc"
|
||||||
|
},
|
||||||
|
"types": "lib/io.d.ts",
|
||||||
|
"version": "1.0.2"
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
name: Build Android APK
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- '*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Android
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout workspace
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Set up JDK
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: 'zulu'
|
||||||
|
java-version: '17'
|
||||||
|
- name: Display Java version
|
||||||
|
run: java -version
|
||||||
|
- name: Build
|
||||||
|
run: |
|
||||||
|
chmod +x ./gradlew
|
||||||
|
./gradlew assembleRelease bundleRelease --info
|
||||||
|
- name: List files
|
||||||
|
run: ls -lah app/build/outputs/apk/release
|
||||||
|
- name: Sign APK
|
||||||
|
uses: ./.github/actions/sign-android-release
|
||||||
|
with:
|
||||||
|
releaseDirectory: app/build/outputs/apk/release
|
||||||
|
signingKeyBase64: ${{ secrets.ANDROID_KEYSTORE }}
|
||||||
|
alias: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||||
|
keyStorePassword: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
|
keyPassword: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
|
env:
|
||||||
|
BUILD_TOOLS_VERSION: 35.0.0
|
||||||
|
- name: Sign Bundle
|
||||||
|
uses: ./.github/actions/sign-android-release
|
||||||
|
with:
|
||||||
|
releaseDirectory: app/build/outputs/bundle/release
|
||||||
|
signingKeyBase64: ${{ secrets.ANDROID_KEYSTORE }}
|
||||||
|
alias: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||||
|
keyStorePassword: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
|
keyPassword: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
|
env:
|
||||||
|
BUILD_TOOLS_VERSION: 35.0.0
|
||||||
|
- name: Move artifact
|
||||||
|
run: |
|
||||||
|
mv app/build/outputs/apk/release/app-release-unsigned-signed.apk mines.apk
|
||||||
|
mv app/build/outputs/bundle/release/app-release.aab mines.aab
|
||||||
|
- name: List files
|
||||||
|
run: find .
|
||||||
|
- name: Upload mines.apk & mines.aab
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
if-no-files-found: error
|
||||||
|
name: android
|
||||||
|
path: |
|
||||||
|
mines.apk
|
||||||
|
mines.aab
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
name: Build Desktop app
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- '*'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build Desktop app
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout workspace
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Set up JDK
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: 'zulu'
|
||||||
|
java-version: '17'
|
||||||
|
- name: Display Java version
|
||||||
|
run: java -version
|
||||||
|
- name: List all files
|
||||||
|
run: find .
|
||||||
|
- name: Run Gradle build
|
||||||
|
run: |
|
||||||
|
chmod +x gradlew
|
||||||
|
./gradlew build allTests createDistributable
|
||||||
|
- name: Run Conveyor
|
||||||
|
uses: hydraulic-software/conveyor/actions/[email protected]
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
command: make copied-site
|
||||||
|
signing_key: ${{ secrets.SIGNING_KEY }}
|
||||||
|
agree_to_license: 1
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
name: Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "main" ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build Kotlin/Wasm
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout workspace
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Set up JDK
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: 'zulu'
|
||||||
|
java-version: '17'
|
||||||
|
- name: Display Java version
|
||||||
|
run: java -version
|
||||||
|
- name: List all files
|
||||||
|
run: find .
|
||||||
|
- name: Run Gradle build
|
||||||
|
run: |
|
||||||
|
chmod +x gradlew
|
||||||
|
./gradlew build allTests wasmJsBrowserDistribution
|
||||||
|
- name: Fix permissions
|
||||||
|
run: |
|
||||||
|
chmod -v -R +rX "app/build/dist/wasmJs/productionExecutable/" | while read line; do
|
||||||
|
echo "::warning title=Invalid file permissions automatically fixed::$line"
|
||||||
|
done
|
||||||
|
- name: Deploy over FTP
|
||||||
|
uses: modern-dev/ftp-mirror@v2
|
||||||
|
with:
|
||||||
|
server: ${{ secrets.FTP_SERVER }}
|
||||||
|
user: ${{ secrets.FTP_USERNAME }}
|
||||||
|
password: ${{ secrets.FTP_PASSWORD }}
|
||||||
|
local_dir: "app/build/dist/wasmJs/productionExecutable"
|
||||||
|
remote_dir: "/httpdocs/mines"
|
||||||
|
delete: true
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
!src/**/build/
|
||||||
|
**/build/
|
||||||
|
*.iml
|
||||||
|
.DS_Store
|
||||||
|
.cxx
|
||||||
|
.externalNativeBuild
|
||||||
|
.gradle
|
||||||
|
.idea
|
||||||
|
.kotlin
|
||||||
|
/output/
|
||||||
|
captures
|
||||||
|
local.properties
|
||||||
|
.android-sdk/
|
||||||
|
.jdks/
|
||||||
|
xcuserdata
|
||||||
|
/app/.flatpak-builder
|
||||||
|
/app/build-dir
|
||||||
|
/app/repo
|
||||||
|
/repo
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
# Mines Project Development Guidelines
|
||||||
|
|
||||||
|
This document provides essential information for developers working on the Mines project.
|
||||||
|
|
||||||
|
## Build/Configuration Instructions
|
||||||
|
|
||||||
|
### Project Overview
|
||||||
|
|
||||||
|
Mines is a KMP Minesweeper clone with Compose Multiplatform for UI targeting Android, JVM (Desktop), and WebAssembly JS (Browser).
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- JDK 11 (for Android compatibility)
|
||||||
|
- Android SDK (for Android builds)
|
||||||
|
- Latest Gradle version
|
||||||
|
|
||||||
|
### Building the Project
|
||||||
|
|
||||||
|
For all platforms: `./gradlew build`
|
||||||
|
|
||||||
|
Platform-specific builds:
|
||||||
|
|
||||||
|
- Android: `./gradlew assembleDebug` or `./gradlew assembleRelease`
|
||||||
|
- Desktop: `./gradlew packageDistributionForCurrentOS`
|
||||||
|
- Web: `./gradlew wasmJsBrowserDistribution`
|
||||||
|
|
||||||
|
### Running the Application
|
||||||
|
|
||||||
|
- Android: Use IntelliJ IDEA with Android plugin or `./gradlew installDebug`
|
||||||
|
- Desktop: `./gradlew run`
|
||||||
|
- Web: `./gradlew wasmJsBrowserRun`
|
||||||
|
|
||||||
|
## Testing Information
|
||||||
|
|
||||||
|
### Test Structure
|
||||||
|
|
||||||
|
The project follows Kotlin Multiplatform conventions:
|
||||||
|
|
||||||
|
- `commonTest`: Platform-independent tests
|
||||||
|
- `jvmTest`: JVM-specific tests
|
||||||
|
- `androidTest`: Android-specific tests
|
||||||
|
- `wasmJsTest`: WebAssembly JS-specific tests
|
||||||
|
|
||||||
|
Note: Desktop (JVM) is the primary development platform.
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
All tests: `./gradlew allTests`
|
||||||
|
|
||||||
|
Platform-specific tests:
|
||||||
|
|
||||||
|
- JVM: `./gradlew jvmTest`
|
||||||
|
- Android: `./gradlew androidTest`
|
||||||
|
- WebAssembly JS: `./gradlew wasmJsTest`
|
||||||
|
|
||||||
|
Specific test: `./gradlew test --tests "de.stefan_oltmann.mines.model.GameDifficultyTest"`
|
||||||
|
|
||||||
|
Important: Place all logic/non-UI tests in `commonTest` and test with `./gradlew jvmTest`. No need to run `androidTest` or `wasmJsTest` if `jvmTest` passes.
|
||||||
|
|
||||||
|
### Adding New Tests
|
||||||
|
|
||||||
|
1. Create a test file in the appropriate directory:
|
||||||
|
- Common: `app/src/commonTest/kotlin/de/stefan_oltmann/mines/...`
|
||||||
|
- JVM: `app/src/jvmTest/kotlin/de/stefan_oltmann/mines/...`
|
||||||
|
- Android: `app/src/androidTest/kotlin/de/stefan_oltmann/mines/...`
|
||||||
|
- WebAssembly JS: `app/src/wasmJsTest/kotlin/de/stefan_oltmann/mines/...`
|
||||||
|
|
||||||
|
2. Use the Kotlin Test framework:
|
||||||
|
```kotlin
|
||||||
|
class MyTest {
|
||||||
|
@Test
|
||||||
|
fun testSomething() {
|
||||||
|
assertEquals(expected, actual)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Configure dependencies in `app/build.gradle.kts`:
|
||||||
|
```kotlin
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(libs.kotlin.test)
|
||||||
|
}
|
||||||
|
jvmTest.dependencies {
|
||||||
|
implementation(libs.kotlin.test.junit)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compose UI Testing
|
||||||
|
|
||||||
|
Use Compose Desktop UI testing framework (not Android UI testing).
|
||||||
|
|
||||||
|
Setup:
|
||||||
|
|
||||||
|
1. Add dependencies:
|
||||||
|
```kotlin
|
||||||
|
jvmTest.dependencies {
|
||||||
|
implementation(compose.desktop.uiTestJUnit4)
|
||||||
|
implementation(compose.desktop.currentOs)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create UI tests in `jvmTest`:
|
||||||
|
```kotlin
|
||||||
|
class MyComposeUiTest {
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createComposeRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testMyComposable() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
MyComposable()
|
||||||
|
}
|
||||||
|
/* Verify UI elements */
|
||||||
|
composeTestRule.onNodeWithText("Expected Text").assertIsDisplayed()
|
||||||
|
/* Perform actions */
|
||||||
|
composeTestRule.onNodeWithContentDescription("Button").performClick()
|
||||||
|
/* Verify state changes */
|
||||||
|
composeTestRule.onNodeWithText("Updated Text").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For details: [Compose Desktop UI Testing documentation](https://www.jetbrains.com.cn/en-us/help/kotlin-multiplatform-dev/compose-desktop-ui-testing.html)
|
||||||
|
|
||||||
|
### Example Test
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class GameDifficultyTest {
|
||||||
|
@Test
|
||||||
|
fun testCalcMineCount() {
|
||||||
|
|
||||||
|
/* Test EASY difficulty (10%) */
|
||||||
|
assertEquals(1, GameDifficulty.EASY.calcMineCount(3, 3))
|
||||||
|
assertEquals(4, GameDifficulty.EASY.calcMineCount(10, 4))
|
||||||
|
|
||||||
|
/* Test MEDIUM difficulty (15%) */
|
||||||
|
assertEquals(1, GameDifficulty.MEDIUM.calcMineCount(3, 3))
|
||||||
|
assertEquals(6, GameDifficulty.MEDIUM.calcMineCount(10, 4))
|
||||||
|
|
||||||
|
/* Test HARD difficulty (20%) */
|
||||||
|
assertEquals(1, GameDifficulty.HARD.calcMineCount(3, 3))
|
||||||
|
assertEquals(8, GameDifficulty.HARD.calcMineCount(10, 4))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Additional Development Information
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
- `app/src/commonMain`: Shared code for all platforms
|
||||||
|
- `app/src/androidMain`: Android-specific code
|
||||||
|
- `app/src/jvmMain`: Desktop-specific code
|
||||||
|
- `app/src/wasmJsMain`: WebAssembly JS-specific code
|
||||||
|
|
||||||
|
### Key Components
|
||||||
|
|
||||||
|
- `GameConfig`: Configuration (cell size, map dimensions, difficulty)
|
||||||
|
- `GameDifficulty`: Difficulty levels (EASY, MEDIUM, HARD)
|
||||||
|
- `GameState`: Game state management
|
||||||
|
|
||||||
|
### Versioning
|
||||||
|
|
||||||
|
Uses `androidGitVersion` plugin with version numbers derived from Git tags.
|
||||||
|
|
||||||
|
### Code Style
|
||||||
|
|
||||||
|
Follow Clean Code practices and official Kotlin conventions plus these rules:
|
||||||
|
|
||||||
|
1. **Indentation and Spacing**
|
||||||
|
- 4 spaces indentation, 120 character line limit
|
||||||
|
- No braces for single-line if statements
|
||||||
|
- Use blank lines to separate logical blocks
|
||||||
|
- No consecutive blank lines
|
||||||
|
- Add blank line before if/for/while statements
|
||||||
|
- Don't separate consecutive assert statements
|
||||||
|
- Add blank line between function signature and body for multi-line functions
|
||||||
|
- Example:
|
||||||
|
```kotlin
|
||||||
|
fun calculateValue(param1: Int, param2: Int): Int {
|
||||||
|
|
||||||
|
val result = param1 + param2
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Naming Conventions**
|
||||||
|
- Constants: UPPER_SNAKE_CASE with `const val`
|
||||||
|
- Classes: PascalCase
|
||||||
|
- Functions/variables: camelCase
|
||||||
|
- Packages: lowercase.with.dots
|
||||||
|
|
||||||
|
3. **Imports**: Alphabetical order, no wildcards
|
||||||
|
|
||||||
|
4. **Function Formatting**: Align parameters in multi-line declarations, use trailing commas
|
||||||
|
|
||||||
|
5. **Type Declarations**: Explicit types for public APIs
|
||||||
|
|
||||||
|
6. **Braces**: Opening at line end, closing on own line, use braces for multi-line bodies only
|
||||||
|
|
||||||
|
7. **String Templates**: Prefer `"Value: $value"` over concatenation
|
||||||
|
|
||||||
|
9. **Documentation**: KDoc format for all public APIs
|
||||||
|
|
||||||
|
10. **Forbidden Practices**
|
||||||
|
- No `print`/`println` (use logger)
|
||||||
|
- Don't remove TODO/FIXME/STOPSHIP comments unless resolved
|
||||||
|
- No magic numbers
|
||||||
|
|
||||||
|
11. **Comment Style**
|
||||||
|
- Use only block comments (`/* */`), never line comments (`//`)
|
||||||
|
- Align stars in multi-line comments
|
||||||
|
- Each file needs license header as block comment
|
||||||
|
|
||||||
|
### Dependencies Management
|
||||||
|
|
||||||
|
Managed in `gradle/libs.versions.toml` using Gradle's version catalog.
|
||||||
|
|
||||||
|
### UI Framework
|
||||||
|
|
||||||
|
Compose Multiplatform for UI. No platform-specific UI libraries.
|
||||||
|
|
||||||
|
### Final Checks
|
||||||
|
|
||||||
|
- Ensure code compiles and tests pass
|
||||||
|
- Remove unused code and imports
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="Browser" type="GradleRunConfiguration" factoryName="Gradle">
|
||||||
|
<ExternalSystemSettings>
|
||||||
|
<option name="executionName"/>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$"/>
|
||||||
|
<option name="externalSystemIdString" value="GRADLE"/>
|
||||||
|
<option name="scriptParameters" value=""/>
|
||||||
|
<option name="taskDescriptions">
|
||||||
|
<list/>
|
||||||
|
</option>
|
||||||
|
<option name="taskNames">
|
||||||
|
<list>
|
||||||
|
<option value="wasmJsBrowserDevelopmentRun"/>
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
<option name="vmOptions"/>
|
||||||
|
</ExternalSystemSettings>
|
||||||
|
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
|
||||||
|
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
|
||||||
|
<ExternalSystemDebugDisabled>false</ExternalSystemDebugDisabled>
|
||||||
|
<DebugAllEnabled>false</DebugAllEnabled>
|
||||||
|
<RunAsTest>false</RunAsTest>
|
||||||
|
<GradleProfilingDisabled>true</GradleProfilingDisabled>
|
||||||
|
<GradleCoverageDisabled>true</GradleCoverageDisabled>
|
||||||
|
<method v="2"/>
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="Desktop" type="GradleRunConfiguration" factoryName="Gradle">
|
||||||
|
<ExternalSystemSettings>
|
||||||
|
<option name="executionName"/>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$"/>
|
||||||
|
<option name="externalSystemIdString" value="GRADLE"/>
|
||||||
|
<option name="scriptParameters" value=""/>
|
||||||
|
<option name="taskDescriptions">
|
||||||
|
<list/>
|
||||||
|
</option>
|
||||||
|
<option name="taskNames">
|
||||||
|
<list>
|
||||||
|
<option value="run"/>
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
<option name="vmOptions"/>
|
||||||
|
</ExternalSystemSettings>
|
||||||
|
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
|
||||||
|
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
|
||||||
|
<DebugAllEnabled>false</DebugAllEnabled>
|
||||||
|
<RunAsTest>false</RunAsTest>
|
||||||
|
<method v="2"/>
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
@@ -0,0 +1,661 @@
|
|||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and
|
||||||
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published
|
||||||
|
by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
Privacy Policy
|
||||||
|
|
||||||
|
Mines is a free and open-source app that does not collect, store, or share any personal data.
|
||||||
|
It has no analytics, tracking, ads, or third-party services.
|
||||||
|
All functionality runs locally on your device without external data transmission.
|
||||||
|
|
||||||
|
The app may request certain permissions necessary for core features,
|
||||||
|
but these are never used to collect or share data.
|
||||||
|
|
||||||
|
The source code is publicly available at https://github.com/StefanOltmann/mines
|
||||||
|
for transparency and verification.
|
||||||
|
|
||||||
|
For questions, visit https://github.com/StefanOltmann/mines/discussions.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Hex Mines
|
||||||
|
|
||||||
|
Fork of [StefanOltmann/mines](https://github.com/StefanOltmann/mines) with **hexagonal tiles**.
|
||||||
|
|
||||||
|
A minesweeper-like puzzle game built with Kotlin Multiplatform + Compose.
|
||||||
|
Board size and difficulty remain configurable in settings.
|
||||||
|
|
||||||
|
## Changes from upstream
|
||||||
|
|
||||||
|
- Flat-top hex grid (odd-q offset coordinates)
|
||||||
|
- Neighbor counts use 6 adjacent tiles (max number is 6)
|
||||||
|
- Hex canvas drawing and hit-testing
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
AGPL-3.0 — same as upstream. Credit: Stefan Oltmann.
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
|
||||||
|
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
|
||||||
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlinMultiplatform)
|
||||||
|
alias(libs.plugins.androidApplication)
|
||||||
|
alias(libs.plugins.composeMultiplatform)
|
||||||
|
alias(libs.plugins.composeCompiler)
|
||||||
|
alias(libs.plugins.androidGitVersion)
|
||||||
|
alias(libs.plugins.hydraulicConveyor)
|
||||||
|
}
|
||||||
|
|
||||||
|
androidGitVersion {
|
||||||
|
format = "%tag%"
|
||||||
|
}
|
||||||
|
|
||||||
|
version = androidGitVersion.name()
|
||||||
|
|
||||||
|
logger.lifecycle("App version $version (Code: ${androidGitVersion.code()})")
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
|
||||||
|
androidTarget {
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget.set(JvmTarget.JVM_17)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jvm()
|
||||||
|
|
||||||
|
jvmToolchain(jdkVersion = 21)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalWasmDsl::class)
|
||||||
|
wasmJs {
|
||||||
|
|
||||||
|
outputModuleName = "app"
|
||||||
|
|
||||||
|
browser {
|
||||||
|
|
||||||
|
val rootDirPath = project.rootDir.path
|
||||||
|
val projectDirPath = project.projectDir.path
|
||||||
|
|
||||||
|
commonWebpackConfig {
|
||||||
|
outputFileName = "app.js"
|
||||||
|
devServer = (devServer ?: KotlinWebpackConfig.DevServer()).apply {
|
||||||
|
static = (static ?: mutableListOf()).apply {
|
||||||
|
// Serve sources to debug inside browser
|
||||||
|
add(rootDirPath)
|
||||||
|
add(projectDirPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
binaries.executable()
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
|
||||||
|
commonMain.dependencies {
|
||||||
|
|
||||||
|
/* Compose UI */
|
||||||
|
implementation(compose.runtime)
|
||||||
|
implementation(compose.foundation)
|
||||||
|
implementation(compose.material3)
|
||||||
|
implementation(compose.ui)
|
||||||
|
implementation(compose.components.resources)
|
||||||
|
|
||||||
|
/* Platform Tools */
|
||||||
|
implementation(libs.platformtools.core)
|
||||||
|
implementation(libs.platformtools.darkmodedetector)
|
||||||
|
|
||||||
|
/* Coroutines */
|
||||||
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
|
||||||
|
/* Datetime */
|
||||||
|
implementation(libs.kotlinx.datetime)
|
||||||
|
|
||||||
|
/* Settings */
|
||||||
|
implementation(libs.multiplatformSettings)
|
||||||
|
|
||||||
|
/* Lottie Animations */
|
||||||
|
implementation(libs.compottie)
|
||||||
|
implementation(libs.compottie.dot)
|
||||||
|
}
|
||||||
|
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(libs.kotlin.test)
|
||||||
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
|
}
|
||||||
|
|
||||||
|
androidMain.dependencies {
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
|
}
|
||||||
|
|
||||||
|
jvmMain.dependencies {
|
||||||
|
implementation(compose.desktop.currentOs)
|
||||||
|
implementation(libs.kotlinx.coroutines.swing)
|
||||||
|
}
|
||||||
|
|
||||||
|
jvmTest.dependencies {
|
||||||
|
implementation(libs.kotlin.test.junit)
|
||||||
|
implementation(compose.desktop.currentOs)
|
||||||
|
implementation(compose.desktop.uiTestJUnit4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
|
||||||
|
namespace = "de.stefan_oltmann.mines"
|
||||||
|
compileSdk = libs.versions.android.compileSdk.get().toInt()
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
|
||||||
|
applicationId = "de.stefan_oltmann.hexmines"
|
||||||
|
|
||||||
|
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||||
|
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
||||||
|
|
||||||
|
if (androidGitVersion.code() == 0) {
|
||||||
|
|
||||||
|
/* Values for the dev version. */
|
||||||
|
versionName = "1.0.0"
|
||||||
|
versionCode = 1
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
versionName = androidGitVersion.name()
|
||||||
|
versionCode = androidGitVersion.code()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
packaging {
|
||||||
|
resources {
|
||||||
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
getByName("release") {
|
||||||
|
/*
|
||||||
|
* As an open source project we don't need ProGuard.
|
||||||
|
*/
|
||||||
|
isMinifyEnabled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compose.desktop {
|
||||||
|
|
||||||
|
application {
|
||||||
|
|
||||||
|
mainClass = "de.stefan_oltmann.mines.MainKt"
|
||||||
|
|
||||||
|
nativeDistributions {
|
||||||
|
|
||||||
|
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
|
||||||
|
|
||||||
|
packageName = "Mines"
|
||||||
|
|
||||||
|
if (androidGitVersion.code() == 0) {
|
||||||
|
|
||||||
|
/* Values for the dev version. */
|
||||||
|
packageVersion = "1.0.0"
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
packageVersion = version.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
macOS {
|
||||||
|
iconFile.set(project.file("../icon/icon.icns"))
|
||||||
|
}
|
||||||
|
|
||||||
|
windows {
|
||||||
|
iconFile.set(project.file("../icon/icon.ico"))
|
||||||
|
}
|
||||||
|
|
||||||
|
linux {
|
||||||
|
iconFile.set(project.file("../icon/icon.png"))
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes.release.proguard {
|
||||||
|
isEnabled = true
|
||||||
|
obfuscate.set(false)
|
||||||
|
optimize.set(true)
|
||||||
|
configurationFiles.from(project.file("proguard-rules.pro"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
|
||||||
|
implementation(libs.androidx.runtime.android)
|
||||||
|
debugImplementation(compose.uiTooling)
|
||||||
|
|
||||||
|
linuxAmd64(compose.desktop.linux_x64)
|
||||||
|
macAmd64(compose.desktop.macos_x64)
|
||||||
|
macAarch64(compose.desktop.macos_arm64)
|
||||||
|
windowsAmd64(compose.desktop.windows_x64)
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
-keepclasseswithmembers public class de.stefan_oltmann.mines.MainKt { #
|
||||||
|
public static void main(java.lang.String[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
-dontwarn kotlinx.coroutines.debug.*
|
||||||
|
|
||||||
|
-keep class kotlin.** { *; }
|
||||||
|
-keep class kotlinx.** { *; }
|
||||||
|
-keep class kotlinx.coroutines.** { *; }
|
||||||
|
-keep class org.jetbrains.skia.** { *; }
|
||||||
|
-keep class org.jetbrains.skiko.** { *; }
|
||||||
|
-keep class com.sun.jna.** { *; }
|
||||||
|
-keep class * implements com.sun.jna.** { *; }
|
||||||
|
-keepclassmembers class * extends com.sun.jna.* { public *; }
|
||||||
|
-keepclassmembers class * implements com.sun.jna.* { public *; }
|
||||||
|
-dontwarn com.sun.jna.**
|
||||||
|
|
||||||
|
# Keep specific JNA Platform classes used in the project
|
||||||
|
-keep class com.sun.jna.platform.** { *; }
|
||||||
|
-keep class com.sun.jna.win32.** { *; }
|
||||||
|
-dontwarn com.sun.jna.platform.**
|
||||||
|
|
||||||
|
|
||||||
|
-keep class com.kdroid.composetray.** { *; }
|
||||||
|
|
||||||
|
-assumenosideeffects public class androidx.compose.runtime.ComposerKt {
|
||||||
|
void sourceInformation(androidx.compose.runtime.Composer,java.lang.String);
|
||||||
|
void sourceInformationMarkerStart(androidx.compose.runtime.Composer,int,java.lang.String);
|
||||||
|
void sourceInformationMarkerEnd(androidx.compose.runtime.Composer);
|
||||||
|
}
|
||||||
|
|
||||||
|
# Keep `Companion` object fields of serializable classes.
|
||||||
|
# This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects.
|
||||||
|
-if @kotlinx.serialization.Serializable class **
|
||||||
|
-keepclassmembers class <1> {
|
||||||
|
static <1>$Companion Companion;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Keep `serializer()` on companion objects (both default and named) of serializable classes.
|
||||||
|
-if @kotlinx.serialization.Serializable class ** {
|
||||||
|
static **$* *;
|
||||||
|
}
|
||||||
|
-keepclassmembers class <2>$<3> {
|
||||||
|
kotlinx.serialization.KSerializer serializer(...);
|
||||||
|
}
|
||||||
|
|
||||||
|
# Keep `INSTANCE.serializer()` of serializable objects.
|
||||||
|
-if @kotlinx.serialization.Serializable class ** {
|
||||||
|
public static ** INSTANCE;
|
||||||
|
}
|
||||||
|
-keepclassmembers class <1> {
|
||||||
|
public static <1> INSTANCE;
|
||||||
|
kotlinx.serialization.KSerializer serializer(...);
|
||||||
|
}
|
||||||
|
|
||||||
|
# @Serializable and @Polymorphic are used at runtime for polymorphic serialization.
|
||||||
|
-keepattributes RuntimeVisibleAnnotations,AnnotationDefault
|
||||||
|
|
||||||
|
-keepattributes *Annotation*, InnerClasses
|
||||||
|
-dontnote kotlinx.serialization.AnnotationsKt # core serialization annotations
|
||||||
|
-dontnote kotlinx.serialization.SerializationKt
|
||||||
|
|
||||||
|
|
||||||
|
# OkHttp platform used only on JVM and when Conscrypt and other security providers are available.
|
||||||
|
-dontwarn okhttp3.internal.platform.**
|
||||||
|
-dontwarn org.conscrypt.**
|
||||||
|
-dontwarn org.bouncycastle.**
|
||||||
|
-dontwarn org.openjsse.**
|
||||||
|
#################################### SLF4J #####################################
|
||||||
|
-dontwarn org.slf4j.**
|
||||||
|
|
||||||
|
# Prevent runtime crashes from use of class.java.getName()
|
||||||
|
-dontwarn javax.naming.**
|
||||||
|
|
||||||
|
# Keep enum classes
|
||||||
|
-keepclassmembers class * extends java.lang.Enum {
|
||||||
|
<fields>;
|
||||||
|
public static **[] values();
|
||||||
|
public static ** valueOf(java.lang.String);
|
||||||
|
}
|
||||||
|
|
||||||
|
# Specifically keep GameDifficulty enum
|
||||||
|
-keep enum de.stefan_oltmann.mines.model.GameDifficulty { *; }
|
||||||
|
|
||||||
|
# Ignore warnings and Don't obfuscate for now
|
||||||
|
-dontobfuscate
|
||||||
|
-ignorewarnings
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||||
|
<activity
|
||||||
|
android:exported="true"
|
||||||
|
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|mnc|colorMode|density|fontScale|fontWeightAdjustment|keyboard|layoutDirection|locale|mcc|navigation|smallestScreenSize|touchscreen|uiMode"
|
||||||
|
android:name="MainActivity">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,57 @@
|
|||||||
|
/*
|
||||||
|
* 💣 Mines 💣
|
||||||
|
* Copyright (C) 2025 Stefan Oltmann
|
||||||
|
* https://github.com/StefanOltmann/mines
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package de.stefan_oltmann.mines
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
val settingsSharedPreferences = getSharedPreferences("settings", MODE_PRIVATE)
|
||||||
|
SettingsProvider.init(settingsSharedPreferences)
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
|
||||||
|
/*
|
||||||
|
* On newer Android versions we need the proper paddings.
|
||||||
|
*/
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(Color.Black)
|
||||||
|
.statusBarsPadding()
|
||||||
|
.navigationBarsPadding()
|
||||||
|
) {
|
||||||
|
|
||||||
|
App()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package de.stefan_oltmann.mines
|
||||||
|
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.compose.foundation.ScrollState
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import com.russhwolf.settings.Settings
|
||||||
|
import com.russhwolf.settings.SharedPreferencesSettings
|
||||||
|
|
||||||
|
@Suppress("LateinitUsage", "MatchingDeclarationName")
|
||||||
|
object SettingsProvider {
|
||||||
|
|
||||||
|
lateinit var settings: SharedPreferencesSettings
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun init(prefs: SharedPreferences) {
|
||||||
|
settings = SharedPreferencesSettings(prefs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actual val settings: Settings = SettingsProvider.settings
|
||||||
|
|
||||||
|
actual val defaultMapWidth: Int = 7
|
||||||
|
actual val defaultMapHeight: Int = 7
|
||||||
|
|
||||||
|
actual val isDesktop: Boolean = false
|
||||||
|
|
||||||
|
/* Not effective as there is no right-click on Android */
|
||||||
|
actual fun Modifier.addRightClickListener(key: Any?, onClick: (Offset) -> Unit): Modifier = this
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
actual fun BoxScope.HorizontalScrollbar(scrollState: ScrollState) = Unit
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
actual fun BoxScope.VerticalScrollbar(scrollState: ScrollState) = Unit
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:endX="85.84757"
|
||||||
|
android:endY="92.4963"
|
||||||
|
android:startX="42.9492"
|
||||||
|
android:startY="49.59793"
|
||||||
|
android:type="linear">
|
||||||
|
<item
|
||||||
|
android:color="#44000000"
|
||||||
|
android:offset="0.0"/>
|
||||||
|
<item
|
||||||
|
android:color="#00000000"
|
||||||
|
android:offset="1.0"/>
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:fillType="nonZero"
|
||||||
|
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||||
|
android:strokeWidth="1"
|
||||||
|
android:strokeColor="#00000000"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#3DDC84"
|
||||||
|
android:pathData="M0,0h108v108h-108z"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M9,0L9,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,0L19,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,0L29,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,0L39,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,0L49,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,0L59,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,0L69,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,0L79,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M89,0L89,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M99,0L99,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,9L108,9"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,19L108,19"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,29L108,29"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,39L108,39"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,49L108,49"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,59L108,59"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,69L108,69"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,79L108,79"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,89L108,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,99L108,99"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,29L89,29"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,39L89,39"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,49L89,49"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,59L89,59"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,69L89,69"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,79L89,79"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,19L29,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,19L39,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,19L49,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,19L59,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,19L69,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,19L79,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 692 B |
|
After Width: | Height: | Size: 488 B |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 648 B |
|
After Width: | Height: | Size: 312 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 686 B |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 974 B |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#F2F2F2</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">Hex Mines</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
Lottie Simple License (FL 9.13.21)
|
||||||
|
|
||||||
|
Copyright © 2021 Design Barn Inc.
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of the public animation files available
|
||||||
|
for download at the LottieFiles site ("Files") to download, reproduce, modify, publish, distribute, publicly display,
|
||||||
|
and publicly digitally perform such Files, including for commercial purposes, provided that any display,
|
||||||
|
publication, performance, or distribution of Files must contain (and be subject to) the same terms and conditions
|
||||||
|
of this license. Modifications to Files are deemed derivative works and must also be expressly distributed under
|
||||||
|
the same terms and conditions of this license. You may not purport to impose any additional or different terms or
|
||||||
|
conditions on, or apply any technical measures that restrict exercise of, the rights granted under this license. This
|
||||||
|
license does not include the right to collect or compile Files from LottieFiles to replicate or develop a similar or
|
||||||
|
competing service.
|
||||||
|
|
||||||
|
Use of Files without attributing the creator(s) of the Files is permitted under this license, though attribution is
|
||||||
|
strongly encouraged. If attributions are included, such attributions should be visible to the end user.
|
||||||
|
|
||||||
|
FILES ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
|
||||||
|
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. EXCEPT
|
||||||
|
TO THE EXTENT REQUIRED BY APPLICABLE LAW, IN NO EVENT WILL THE CREATOR(S) OF FILES OR DESIGN BARN, INC.
|
||||||
|
BE LIABLE ON ANY LEGAL THEORY FOR ANY SPECIAL, INCIDENTAL, CONSEQUENTIAL, PUNITIVE, OR EXEMPLARY
|
||||||
|
DAMAGES ARISING OUT OF THIS LICENSE OR THE USE OF SUCH FILES.
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
Copyright (c) 2012, Vicente Lamonaca ([email protected] www.tipografia-montevideo.info www.tipotype.com),
|
||||||
|
with Reserved Font Name Economica.
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://openfontlicense.org
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
/*
|
||||||
|
* 💣 Mines 💣
|
||||||
|
* Copyright (C) 2025 Stefan Oltmann
|
||||||
|
* https://github.com/StefanOltmann/mines
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package de.stefan_oltmann.mines
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.russhwolf.settings.get
|
||||||
|
import com.russhwolf.settings.set
|
||||||
|
import de.stefan_oltmann.mines.model.Game
|
||||||
|
import de.stefan_oltmann.mines.model.GameConfig
|
||||||
|
import de.stefan_oltmann.mines.model.GameDifficulty
|
||||||
|
import de.stefan_oltmann.mines.model.clampMineCount
|
||||||
|
import de.stefan_oltmann.mines.ui.AppFooter
|
||||||
|
import de.stefan_oltmann.mines.ui.GameOverOverlay
|
||||||
|
import de.stefan_oltmann.mines.ui.MinefieldCanvas
|
||||||
|
import de.stefan_oltmann.mines.ui.PlusVersionButton
|
||||||
|
import de.stefan_oltmann.mines.ui.SettingsDialog
|
||||||
|
import de.stefan_oltmann.mines.ui.SponsorButton
|
||||||
|
import de.stefan_oltmann.mines.ui.Toolbar
|
||||||
|
import de.stefan_oltmann.mines.ui.lottie.ConfettiLottieImage
|
||||||
|
import de.stefan_oltmann.mines.ui.theme.DefaultSpacer
|
||||||
|
import de.stefan_oltmann.mines.ui.theme.EconomicaFontFamily
|
||||||
|
import de.stefan_oltmann.mines.ui.theme.colorBackground
|
||||||
|
import de.stefan_oltmann.mines.ui.theme.colorCardBackground
|
||||||
|
import de.stefan_oltmann.mines.ui.theme.colorCardBorder
|
||||||
|
import de.stefan_oltmann.mines.ui.theme.colorCardBorderGameOver
|
||||||
|
import de.stefan_oltmann.mines.ui.theme.colorCardBorderGameWon
|
||||||
|
import de.stefan_oltmann.mines.ui.theme.defaultRoundedCornerShape
|
||||||
|
import de.stefan_oltmann.mines.ui.theme.defaultSpacing
|
||||||
|
import de.stefan_oltmann.mines.ui.theme.doublePadding
|
||||||
|
import de.stefan_oltmann.mines.ui.theme.doubleSpacing
|
||||||
|
import io.github.alexzhirkevich.compottie.DotLottie
|
||||||
|
import io.github.alexzhirkevich.compottie.LottieCompositionSpec
|
||||||
|
import io.github.alexzhirkevich.compottie.rememberLottieComposition
|
||||||
|
import mines.app.generated.resources.Res
|
||||||
|
import org.jetbrains.compose.resources.ExperimentalResourceApi
|
||||||
|
|
||||||
|
@OptIn(ExperimentalResourceApi::class)
|
||||||
|
@Composable
|
||||||
|
fun App() {
|
||||||
|
|
||||||
|
val fontFamily = EconomicaFontFamily()
|
||||||
|
|
||||||
|
val game = remember { Game() }
|
||||||
|
|
||||||
|
val gameConfig = remember {
|
||||||
|
mutableStateOf(
|
||||||
|
GameConfig(
|
||||||
|
cellSize = settings["mines_cell_size"] ?: DEFAULT_CELL_SIZE,
|
||||||
|
mapWidth = settings["mines_map_width"] ?: defaultMapWidth,
|
||||||
|
mapHeight = settings["mines_map_height"] ?: defaultMapHeight,
|
||||||
|
mineCount = resolveSavedMineCount(
|
||||||
|
mapWidth = settings["mines_map_width"] ?: defaultMapWidth,
|
||||||
|
mapHeight = settings["mines_map_height"] ?: defaultMapHeight
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val redrawState = remember { mutableStateOf(0) }
|
||||||
|
|
||||||
|
/* State to trigger scrolling to the middle of the field */
|
||||||
|
val scrollToMiddleTrigger = remember { mutableStateOf(0) }
|
||||||
|
|
||||||
|
/* Remember scroll states so they can be accessed from the restartGame lambda */
|
||||||
|
val verticalScrollState = rememberScrollState()
|
||||||
|
val horizontalScrollState = rememberScrollState()
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Pre-load lotties to avoid delays in playback.
|
||||||
|
*/
|
||||||
|
|
||||||
|
val confettiLottieComposition by rememberLottieComposition {
|
||||||
|
|
||||||
|
LottieCompositionSpec.DotLottie(
|
||||||
|
archive = Res.readBytes("files/confetti.lottie")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val explosionLottieComposition by rememberLottieComposition {
|
||||||
|
|
||||||
|
LottieCompositionSpec.DotLottie(
|
||||||
|
archive = Res.readBytes("files/explosion.lottie")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Initially start a new game when opened.
|
||||||
|
*/
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
|
||||||
|
game.restart(gameConfig.value)
|
||||||
|
|
||||||
|
/* Trigger scrolling to the middle of the field */
|
||||||
|
scrollToMiddleTrigger.value += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(gameConfig.value) {
|
||||||
|
|
||||||
|
val newGameConfig = gameConfig.value
|
||||||
|
|
||||||
|
val oldMapWidth = settings["mines_map_width"] ?: defaultMapWidth
|
||||||
|
val oldMapHeight = settings["mines_map_height"] ?: defaultMapHeight
|
||||||
|
val oldMineCount = resolveSavedMineCount(oldMapWidth, oldMapHeight)
|
||||||
|
|
||||||
|
/* Save new settings to config */
|
||||||
|
settings["mines_cell_size"] = newGameConfig.cellSize
|
||||||
|
settings["mines_map_width"] = newGameConfig.mapWidth
|
||||||
|
settings["mines_map_height"] = newGameConfig.mapHeight
|
||||||
|
settings["mines_mine_count"] = newGameConfig.mineCount
|
||||||
|
|
||||||
|
val mapSettingsChanged =
|
||||||
|
oldMapWidth != newGameConfig.mapWidth ||
|
||||||
|
oldMapHeight != newGameConfig.mapHeight ||
|
||||||
|
oldMineCount != newGameConfig.mineCount
|
||||||
|
|
||||||
|
/* Launch a new game every time the settings change something that influences the map */
|
||||||
|
if (mapSettingsChanged) {
|
||||||
|
|
||||||
|
game.restart(gameConfig.value)
|
||||||
|
|
||||||
|
/* Trigger scrolling to the middle of the field */
|
||||||
|
scrollToMiddleTrigger.value += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/* HACK */
|
||||||
|
redrawState.value += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Effect to scroll to the middle of the field when the trigger changes */
|
||||||
|
LaunchedEffect(scrollToMiddleTrigger.value) {
|
||||||
|
|
||||||
|
horizontalScrollState.scrollTo(horizontalScrollState.maxValue / 2)
|
||||||
|
|
||||||
|
verticalScrollState.scrollTo(verticalScrollState.maxValue / 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
val elapsedSeconds by game.elapsedSeconds.collectAsState()
|
||||||
|
|
||||||
|
val showSettings = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Force redraw if state changes.
|
||||||
|
*
|
||||||
|
* FIXME This is a hack
|
||||||
|
*/
|
||||||
|
redrawState.value
|
||||||
|
|
||||||
|
Column {
|
||||||
|
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1F)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(colorBackground)
|
||||||
|
) {
|
||||||
|
|
||||||
|
val borderColor = when {
|
||||||
|
game.gameOver -> colorCardBorderGameOver
|
||||||
|
game.gameWon -> colorCardBorderGameWon
|
||||||
|
else -> colorCardBorder
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors().copy(
|
||||||
|
containerColor = colorCardBackground
|
||||||
|
),
|
||||||
|
shape = defaultRoundedCornerShape,
|
||||||
|
border = BorderStroke(1.dp, borderColor),
|
||||||
|
modifier = Modifier.doublePadding()
|
||||||
|
) {
|
||||||
|
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(
|
||||||
|
start = defaultSpacing,
|
||||||
|
end = defaultSpacing,
|
||||||
|
bottom = doubleSpacing
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
|
||||||
|
Toolbar(
|
||||||
|
highlightRestartButton = game.gameOver || game.gameWon,
|
||||||
|
elapsedSeconds = elapsedSeconds,
|
||||||
|
remainingFlagsCount = game.gameState?.getRemainingFlagsCount() ?: 0,
|
||||||
|
fontFamily = fontFamily,
|
||||||
|
showSettings = {
|
||||||
|
showSettings.value = true
|
||||||
|
},
|
||||||
|
restartGame = {
|
||||||
|
|
||||||
|
game.restart(gameConfig.value)
|
||||||
|
|
||||||
|
/* FIXME This is a hack */
|
||||||
|
redrawState.value += 1
|
||||||
|
|
||||||
|
/* Trigger scrolling to the middle of the field */
|
||||||
|
scrollToMiddleTrigger.value += 1
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.weight(1f, fill = false)
|
||||||
|
) {
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.verticalScroll(verticalScrollState)
|
||||||
|
.horizontalScroll(horizontalScrollState)
|
||||||
|
) {
|
||||||
|
|
||||||
|
val gameState = game.gameState
|
||||||
|
|
||||||
|
if (gameState != null) {
|
||||||
|
|
||||||
|
MinefieldCanvas(
|
||||||
|
gameState,
|
||||||
|
gameConfig,
|
||||||
|
redrawState,
|
||||||
|
fontFamily,
|
||||||
|
hit = { x, y -> game.hit(x, y) },
|
||||||
|
flag = { x, y -> game.flag(x, y) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verticalScrollState.canScrollForward || verticalScrollState.canScrollBackward)
|
||||||
|
VerticalScrollbar(verticalScrollState)
|
||||||
|
|
||||||
|
if (horizontalScrollState.canScrollForward || horizontalScrollState.canScrollBackward)
|
||||||
|
HorizontalScrollbar(horizontalScrollState)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDesktop) {
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(top = defaultSpacing)
|
||||||
|
) {
|
||||||
|
|
||||||
|
val uriHandler = LocalUriHandler.current
|
||||||
|
|
||||||
|
SponsorButton(
|
||||||
|
fontFamily = fontFamily,
|
||||||
|
onClick = {
|
||||||
|
uriHandler.openUri("https://github.com/sponsors/StefanOltmann")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
DefaultSpacer()
|
||||||
|
|
||||||
|
PlusVersionButton(
|
||||||
|
fontFamily = fontFamily,
|
||||||
|
onClick = {
|
||||||
|
|
||||||
|
if (isDesktop)
|
||||||
|
uriHandler.openUri("https://apps.microsoft.com/detail/9nd96xcdzrgb")
|
||||||
|
else
|
||||||
|
uriHandler.openUri("https://play.google.com/store/apps/details?id=de.stefan_oltmann.mines_plus")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when {
|
||||||
|
|
||||||
|
game.gameWon ->
|
||||||
|
confettiLottieComposition?.let { lottieComposition ->
|
||||||
|
ConfettiLottieImage(lottieComposition)
|
||||||
|
}
|
||||||
|
|
||||||
|
game.gameOver ->
|
||||||
|
explosionLottieComposition?.let { lottieComposition ->
|
||||||
|
GameOverOverlay(
|
||||||
|
explosionLottieComposition = lottieComposition,
|
||||||
|
fontFamily = fontFamily
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The settings must overlay the "game over" text.
|
||||||
|
*/
|
||||||
|
if (showSettings.value)
|
||||||
|
SettingsDialog(
|
||||||
|
gameConfig = gameConfig.value,
|
||||||
|
fontFamily = fontFamily,
|
||||||
|
onCancel = {
|
||||||
|
showSettings.value = false
|
||||||
|
},
|
||||||
|
onConfirm = { newGameSettings ->
|
||||||
|
|
||||||
|
showSettings.value = false
|
||||||
|
|
||||||
|
gameConfig.value = newGameSettings
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
AppFooter(fontFamily)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefer an explicit saved mine count. Fall back to the old difficulty
|
||||||
|
* preset so existing installs keep a sensible default.
|
||||||
|
*/
|
||||||
|
private fun resolveSavedMineCount(mapWidth: Int, mapHeight: Int): Int {
|
||||||
|
|
||||||
|
val savedMineCount: Int? = settings["mines_mine_count"]
|
||||||
|
|
||||||
|
if (savedMineCount != null)
|
||||||
|
return clampMineCount(savedMineCount, mapWidth, mapHeight)
|
||||||
|
|
||||||
|
val difficulty = GameDifficulty.fromSettingsValue(settings["mines_difficulty"])
|
||||||
|
|
||||||
|
return clampMineCount(
|
||||||
|
mineCount = difficulty.calcMineCount(mapWidth, mapHeight),
|
||||||
|
mapWidth = mapWidth,
|
||||||
|
mapHeight = mapHeight
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/*
|
||||||
|
* 💣 Mines 💣
|
||||||
|
* Copyright (C) 2025 Stefan Oltmann
|
||||||
|
* https://github.com/StefanOltmann/mines
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package de.stefan_oltmann.mines
|
||||||
|
|
||||||
|
const val APP_TITLE = "Hex Mines"
|
||||||
|
|
||||||
|
const val MIN_CELL_SIZE: Int = 30
|
||||||
|
const val MAX_CELL_SIZE: Int = 99
|
||||||
|
const val DEFAULT_CELL_SIZE: Int = 40
|
||||||
|
|
||||||
|
const val MIN_LONG_SIDE: Int = 5
|
||||||
|
const val MAX_LONG_SIDE: Int = 50
|
||||||
|
|
||||||
|
const val MIN_MINE_COUNT: Int = 1
|
||||||
|
|
||||||
|
const val FONT_SIZE: Int = 20
|
||||||
|
|
||||||
|
const val LONG_PRESS_TIMEOUT_MS: Long = 200
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/*
|
||||||
|
* 💣 Mines 💣
|
||||||
|
* Copyright (C) 2025 Stefan Oltmann
|
||||||
|
* https://github.com/StefanOltmann/mines
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package de.stefan_oltmann.mines
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ScrollState
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
|
||||||
|
import com.russhwolf.settings.Settings
|
||||||
|
|
||||||
|
expect val settings: Settings
|
||||||
|
|
||||||
|
expect val defaultMapWidth: Int
|
||||||
|
expect val defaultMapHeight: Int
|
||||||
|
|
||||||
|
expect val isDesktop: Boolean
|
||||||
|
|
||||||
|
expect fun Modifier.addRightClickListener(
|
||||||
|
key: Any?,
|
||||||
|
onClick: (Offset) -> Unit
|
||||||
|
): Modifier
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
expect fun BoxScope.HorizontalScrollbar(scrollState: ScrollState)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
expect fun BoxScope.VerticalScrollbar(scrollState: ScrollState)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/*
|
||||||
|
* 💣 Mines 💣
|
||||||
|
* Copyright (C) 2025 Stefan Oltmann
|
||||||
|
* https://github.com/StefanOltmann/mines
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package de.stefan_oltmann.mines.model
|
||||||
|
|
||||||
|
enum class CellType(
|
||||||
|
val adjacentMineCount: Int
|
||||||
|
) {
|
||||||
|
|
||||||
|
EMPTY(0),
|
||||||
|
MINE(-1),
|
||||||
|
ONE(1),
|
||||||
|
TWO(2),
|
||||||
|
THREE(3),
|
||||||
|
FOUR(4),
|
||||||
|
FIVE(5),
|
||||||
|
SIX(6);
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
fun ofMineCount(mineCount: Int): CellType =
|
||||||
|
when (mineCount) {
|
||||||
|
0 -> EMPTY
|
||||||
|
1 -> ONE
|
||||||
|
2 -> TWO
|
||||||
|
3 -> THREE
|
||||||
|
4 -> FOUR
|
||||||
|
5 -> FIVE
|
||||||
|
6 -> SIX
|
||||||
|
else -> EMPTY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
/*
|
||||||
|
* 💣 Mines 💣
|
||||||
|
* Copyright (C) 2025 Stefan Oltmann
|
||||||
|
* https://github.com/StefanOltmann/mines
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package de.stefan_oltmann.mines.model
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.ExperimentalTime
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
private val gameStateScope = CoroutineScope(Dispatchers.Default)
|
||||||
|
|
||||||
|
class Game {
|
||||||
|
|
||||||
|
private val _elapsedSeconds = MutableStateFlow(0L)
|
||||||
|
val elapsedSeconds = _elapsedSeconds.asStateFlow()
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTime::class)
|
||||||
|
private var gameStartTime = Instant.DISTANT_PAST
|
||||||
|
|
||||||
|
private var isTimerRunning = false
|
||||||
|
|
||||||
|
var gameOver = false
|
||||||
|
|
||||||
|
var gameWon = false
|
||||||
|
|
||||||
|
var gameState: GameState? = null
|
||||||
|
|
||||||
|
val minefield: Minefield?
|
||||||
|
get() = gameState?.minefield
|
||||||
|
|
||||||
|
private fun generateSeed() =
|
||||||
|
(1..Int.MAX_VALUE).random()
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTime::class)
|
||||||
|
private fun startTimer() {
|
||||||
|
|
||||||
|
if (isTimerRunning)
|
||||||
|
return
|
||||||
|
|
||||||
|
isTimerRunning = true
|
||||||
|
|
||||||
|
gameStateScope.launch {
|
||||||
|
|
||||||
|
gameStartTime = Clock.System.now()
|
||||||
|
|
||||||
|
while (isTimerRunning) {
|
||||||
|
|
||||||
|
/*
|
||||||
|
* We try to prevent shifts that occur from delay() not being super-accurate.
|
||||||
|
*/
|
||||||
|
|
||||||
|
val result = Clock.System.now() - gameStartTime
|
||||||
|
|
||||||
|
_elapsedSeconds.value = result.inWholeSeconds
|
||||||
|
|
||||||
|
delay(1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun restart(
|
||||||
|
gameConfig: GameConfig
|
||||||
|
) {
|
||||||
|
|
||||||
|
isTimerRunning = false
|
||||||
|
_elapsedSeconds.value = 0
|
||||||
|
|
||||||
|
gameOver = false
|
||||||
|
gameWon = false
|
||||||
|
|
||||||
|
gameState =
|
||||||
|
GameState(
|
||||||
|
minefield = Minefield.create(
|
||||||
|
config = gameConfig,
|
||||||
|
seed = generateSeed()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hit(x: Int, y: Int) {
|
||||||
|
|
||||||
|
val gameState = gameState ?: return
|
||||||
|
|
||||||
|
/* Ignore further inputs if game ended. */
|
||||||
|
if (gameOver || gameWon)
|
||||||
|
return
|
||||||
|
|
||||||
|
/* Start timer on first interaction after reset. */
|
||||||
|
if (!isTimerRunning)
|
||||||
|
startTimer()
|
||||||
|
|
||||||
|
/* Ignore clicks on flagged cells as these are most likely accidents. */
|
||||||
|
if (gameState.isFlagged(x, y))
|
||||||
|
return
|
||||||
|
|
||||||
|
val revealed = gameState.isRevealed(x, y)
|
||||||
|
|
||||||
|
if (!revealed) {
|
||||||
|
|
||||||
|
/* Reveal the field in any case */
|
||||||
|
gameState.reveal(x, y)
|
||||||
|
|
||||||
|
/* On hitting a mine the game is over. */
|
||||||
|
if (gameState.minefield.isMine(x, y)) {
|
||||||
|
|
||||||
|
isTimerRunning = false
|
||||||
|
|
||||||
|
gameOver = true
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Tapping on a revealed number should reveal all
|
||||||
|
* adjacent fields if a matching number of flags is set.
|
||||||
|
*/
|
||||||
|
if (revealed) {
|
||||||
|
|
||||||
|
val hitMineWhileRevealingAdjacentCells = gameState.revealAdjacentCells(x, y)
|
||||||
|
|
||||||
|
if (hitMineWhileRevealingAdjacentCells) {
|
||||||
|
|
||||||
|
isTimerRunning = false
|
||||||
|
|
||||||
|
gameOver = true
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Check win condition */
|
||||||
|
if (gameState.isAllRevealed()) {
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Many games flag all remaining mines
|
||||||
|
* for the finish screen.
|
||||||
|
*/
|
||||||
|
gameState.flagAllMines()
|
||||||
|
|
||||||
|
isTimerRunning = false
|
||||||
|
|
||||||
|
gameWon = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun flag(x: Int, y: Int) {
|
||||||
|
|
||||||
|
val gameState = gameState ?: return
|
||||||
|
|
||||||
|
/* Ignore further inputs if game ended. */
|
||||||
|
if (gameOver || gameWon)
|
||||||
|
return
|
||||||
|
|
||||||
|
/* Start timer on first interaction after reset. */
|
||||||
|
if (!isTimerRunning)
|
||||||
|
startTimer()
|
||||||
|
|
||||||
|
/* Only non-revealed fields can be flagged. */
|
||||||
|
if (gameState.isRevealed(x, y))
|
||||||
|
return
|
||||||
|
|
||||||
|
gameState.toggleFlag(x, y)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/*
|
||||||
|
* 💣 Mines 💣
|
||||||
|
* Copyright (C) 2025 Stefan Oltmann
|
||||||
|
* https://github.com/StefanOltmann/mines
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package de.stefan_oltmann.mines.model
|
||||||
|
|
||||||
|
import de.stefan_oltmann.mines.MIN_LONG_SIDE
|
||||||
|
import de.stefan_oltmann.mines.MIN_MINE_COUNT
|
||||||
|
|
||||||
|
data class GameConfig(
|
||||||
|
val cellSize: Int,
|
||||||
|
val mapWidth: Int,
|
||||||
|
val mapHeight: Int,
|
||||||
|
val mineCount: Int
|
||||||
|
) {
|
||||||
|
|
||||||
|
init {
|
||||||
|
/* Ensure no illegal configs can be created. */
|
||||||
|
require(mapWidth >= MIN_LONG_SIDE) { "Map width must be greater than $MIN_LONG_SIDE." }
|
||||||
|
require(mapHeight >= MIN_LONG_SIDE) { "Map height must be greater than $MIN_LONG_SIDE." }
|
||||||
|
require(mineCount >= MIN_MINE_COUNT) { "Mine count must be at least $MIN_MINE_COUNT." }
|
||||||
|
require(mineCount <= maxPlaceableMines(mapWidth, mapHeight)) {
|
||||||
|
"Mine count must fit outside the protected starting area."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum mines that can be placed without filling the protected center zone.
|
||||||
|
*/
|
||||||
|
fun maxPlaceableMines(mapWidth: Int, mapHeight: Int): Int {
|
||||||
|
|
||||||
|
val protectedCells =
|
||||||
|
Minefield.calcProtectedRange(mapWidth).count() *
|
||||||
|
Minefield.calcProtectedRange(mapHeight).count()
|
||||||
|
|
||||||
|
return (mapWidth * mapHeight - protectedCells).coerceAtLeast(MIN_MINE_COUNT)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clampMineCount(mineCount: Int, mapWidth: Int, mapHeight: Int): Int =
|
||||||
|
mineCount.coerceIn(MIN_MINE_COUNT, maxPlaceableMines(mapWidth, mapHeight))
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/*
|
||||||
|
* 💣 Mines 💣
|
||||||
|
* Copyright (C) 2025 Stefan Oltmann
|
||||||
|
* https://github.com/StefanOltmann/mines
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package de.stefan_oltmann.mines.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Difficulty tiers that map to a percentage of mines on the board.
|
||||||
|
*/
|
||||||
|
enum class GameDifficulty(
|
||||||
|
private val minePercentage: Int,
|
||||||
|
private val settingsKey: String
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** Lower mine density for a relaxed game. */
|
||||||
|
EASY(10, "easy"),
|
||||||
|
|
||||||
|
/** Balanced mine density for typical play. */
|
||||||
|
MEDIUM(15, "medium"),
|
||||||
|
|
||||||
|
/** Higher mine density for advanced play. */
|
||||||
|
HARD(20, "hard");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate mine count as a percentage of total cells.
|
||||||
|
*
|
||||||
|
* @param mapWidth Board width in cells.
|
||||||
|
* @param mapHeight Board height in cells.
|
||||||
|
*/
|
||||||
|
fun calcMineCount(mapWidth: Int, mapHeight: Int): Int {
|
||||||
|
|
||||||
|
val cellCount = mapWidth * mapHeight
|
||||||
|
|
||||||
|
return (cellCount * (minePercentage / 100f)).toInt().coerceAtLeast(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the stable key used for persisting this difficulty in settings.
|
||||||
|
*
|
||||||
|
* This value stays constant even when enum names are obfuscated.
|
||||||
|
*/
|
||||||
|
fun toSettingsValue(): String = settingsKey
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a persisted difficulty value without relying on enum names.
|
||||||
|
*
|
||||||
|
* This keeps settings compatible with older builds that stored enum names,
|
||||||
|
* while allowing ProGuard/R8 to freely obfuscate the enum.
|
||||||
|
*
|
||||||
|
* @param value Stored settings value, accepts legacy enum names as well.
|
||||||
|
*/
|
||||||
|
fun fromSettingsValue(value: String?): GameDifficulty {
|
||||||
|
|
||||||
|
val normalized = value?.trim()?.lowercase()
|
||||||
|
|
||||||
|
return when (normalized) {
|
||||||
|
EASY.settingsKey -> EASY
|
||||||
|
MEDIUM.settingsKey -> MEDIUM
|
||||||
|
HARD.settingsKey -> HARD
|
||||||
|
else -> EASY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
/*
|
||||||
|
* 💣 Mines 💣
|
||||||
|
* Copyright (C) 2025 Stefan Oltmann
|
||||||
|
* https://github.com/StefanOltmann/mines
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package de.stefan_oltmann.mines.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the state of a game, including which cells are revealed and flagged.
|
||||||
|
*/
|
||||||
|
class GameState(
|
||||||
|
val minefield: Minefield
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val revealedMatrix: Array<Array<Boolean>> =
|
||||||
|
Array(minefield.width) {
|
||||||
|
Array(minefield.height) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val flaggedMatrix: Array<Array<Boolean>> =
|
||||||
|
Array(minefield.width) {
|
||||||
|
Array(minefield.height) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getRemainingFlagsCount(): Int =
|
||||||
|
minefield.config.mineCount - flaggedMatrix.flatten().count { it }
|
||||||
|
|
||||||
|
fun isRevealed(x: Int, y: Int): Boolean =
|
||||||
|
revealedMatrix[x][y]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if all non-mine fields are revealed now.
|
||||||
|
*/
|
||||||
|
fun isAllRevealed(): Boolean {
|
||||||
|
|
||||||
|
for (x in 0 until minefield.width)
|
||||||
|
for (y in 0 until minefield.height)
|
||||||
|
if (!minefield.isMine(x, y) && !isRevealed(x, y))
|
||||||
|
return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reveal(x: Int, y: Int) {
|
||||||
|
|
||||||
|
/* Ignore call if coordinates are already revealed. */
|
||||||
|
if (revealedMatrix[x][y])
|
||||||
|
return
|
||||||
|
|
||||||
|
/* Mark the current cell as revealed */
|
||||||
|
revealedMatrix[x][y] = true
|
||||||
|
|
||||||
|
/* Remove any flags that may have set on non-minefields. */
|
||||||
|
flaggedMatrix[x][y] = false
|
||||||
|
|
||||||
|
/* If the cell is empty, recursively reveal adjacent cells */
|
||||||
|
if (minefield.getCellType(x, y) == CellType.EMPTY) {
|
||||||
|
|
||||||
|
performOnAdjacentCells(x, y) { adjX, adjY ->
|
||||||
|
|
||||||
|
if (isRevealed(adjX, adjY))
|
||||||
|
return@performOnAdjacentCells
|
||||||
|
|
||||||
|
reveal(adjX, adjY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reveal adjacent cells around a number field.
|
||||||
|
*
|
||||||
|
* Returns if we hit a mine.
|
||||||
|
*/
|
||||||
|
fun revealAdjacentCells(x: Int, y: Int): Boolean {
|
||||||
|
|
||||||
|
val cellType = minefield.getCellType(x, y)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Ignore non-number cells.
|
||||||
|
*/
|
||||||
|
if (cellType == CellType.EMPTY || cellType == CellType.MINE)
|
||||||
|
return false
|
||||||
|
|
||||||
|
var hitMine = false
|
||||||
|
|
||||||
|
if (cellType.adjacentMineCount > 0) {
|
||||||
|
|
||||||
|
val adjacentFlags = countAdjacentFlags(x, y)
|
||||||
|
|
||||||
|
if (cellType.adjacentMineCount == adjacentFlags) {
|
||||||
|
|
||||||
|
performOnAdjacentCells(x, y) { adjX, adjY ->
|
||||||
|
|
||||||
|
if (isRevealed(adjX, adjY) || isFlagged(adjX, adjY))
|
||||||
|
return@performOnAdjacentCells
|
||||||
|
|
||||||
|
reveal(adjX, adjY)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* We want to reveal all adjacent cells,
|
||||||
|
* so we don't immediately return here.
|
||||||
|
*/
|
||||||
|
if (minefield.isMine(adjX, adjY))
|
||||||
|
hitMine = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hitMine
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isFlagged(x: Int, y: Int): Boolean =
|
||||||
|
flaggedMatrix[x][y]
|
||||||
|
|
||||||
|
fun toggleFlag(x: Int, y: Int) {
|
||||||
|
flaggedMatrix[x][y] = !flaggedMatrix[x][y]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun flagAllMines() {
|
||||||
|
|
||||||
|
for (x in 0 until minefield.width)
|
||||||
|
for (y in 0 until minefield.height)
|
||||||
|
if (minefield.isMine(x, y))
|
||||||
|
flaggedMatrix[x][y] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun countAdjacentFlags(x: Int, y: Int): Int {
|
||||||
|
|
||||||
|
var count = 0
|
||||||
|
|
||||||
|
performOnAdjacentCells(x, y) { adjX, adjY ->
|
||||||
|
if (isFlagged(adjX, adjY))
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun performOnAdjacentCells(
|
||||||
|
x: Int,
|
||||||
|
y: Int,
|
||||||
|
action: (Int, Int) -> Unit
|
||||||
|
) {
|
||||||
|
|
||||||
|
forEachAdjacentCell(
|
||||||
|
x = x,
|
||||||
|
y = y,
|
||||||
|
width = minefield.width,
|
||||||
|
height = minefield.height,
|
||||||
|
action = action
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
/*
|
||||||
|
* 💣 Mines 💣
|
||||||
|
* Copyright (C) 2025 Stefan Oltmann
|
||||||
|
* https://github.com/StefanOltmann/mines
|
||||||
|
*
|
||||||
|
* Hex geometry helpers for flat-top odd-r offset grids.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package de.stefan_oltmann.mines.model
|
||||||
|
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
import kotlin.math.sin
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flat-top hex layout with odd-q offset coordinates
|
||||||
|
* (odd columns are shoved down).
|
||||||
|
*
|
||||||
|
* [hexSize] is the center-to-vertex radius in pixels.
|
||||||
|
*/
|
||||||
|
object HexGeometry {
|
||||||
|
|
||||||
|
private val sqrt3 = sqrt(3f)
|
||||||
|
|
||||||
|
fun horizontalSpacing(hexSize: Float): Float =
|
||||||
|
hexSize * 1.5f
|
||||||
|
|
||||||
|
fun verticalSpacing(hexSize: Float): Float =
|
||||||
|
hexSize * sqrt3
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pixel center of cell (col, row), with origin at the
|
||||||
|
* center of cell (0, 0).
|
||||||
|
*/
|
||||||
|
fun cellCenter(col: Int, row: Int, hexSize: Float): Pair<Float, Float> {
|
||||||
|
|
||||||
|
val x = hexSize * 1.5f * col
|
||||||
|
val y = hexSize * sqrt3 * (row + 0.5f * (col and 1))
|
||||||
|
|
||||||
|
return x to y
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canvas size needed to fit [cols] x [rows] hexes,
|
||||||
|
* including a full hex margin around centers.
|
||||||
|
*/
|
||||||
|
fun boardSize(cols: Int, rows: Int, hexSize: Float): Pair<Float, Float> {
|
||||||
|
|
||||||
|
if (cols <= 0 || rows <= 0)
|
||||||
|
return 0f to 0f
|
||||||
|
|
||||||
|
var minX = Float.POSITIVE_INFINITY
|
||||||
|
var maxX = Float.NEGATIVE_INFINITY
|
||||||
|
var minY = Float.POSITIVE_INFINITY
|
||||||
|
var maxY = Float.NEGATIVE_INFINITY
|
||||||
|
|
||||||
|
for (col in 0 until cols) {
|
||||||
|
for (row in 0 until rows) {
|
||||||
|
|
||||||
|
val (cx, cy) = cellCenter(col, row, hexSize)
|
||||||
|
|
||||||
|
minX = minOf(minX, cx)
|
||||||
|
maxX = maxOf(maxX, cx)
|
||||||
|
minY = minOf(minY, cy)
|
||||||
|
maxY = maxOf(maxY, cy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add radius so edges of outer hexes are included. */
|
||||||
|
val width = (maxX - minX) + hexSize * 2f
|
||||||
|
val height = (maxY - minY) + hexSize * 2f
|
||||||
|
|
||||||
|
return width to height
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Origin offset so cell (0,0) center sits at (hexSize, hexSize)
|
||||||
|
* within the canvas (after accounting for odd-column stagger).
|
||||||
|
*/
|
||||||
|
fun originOffset(hexSize: Float): Pair<Float, Float> =
|
||||||
|
hexSize to hexSize
|
||||||
|
|
||||||
|
fun pixelToCell(px: Float, py: Float, hexSize: Float): Pair<Int, Int> {
|
||||||
|
|
||||||
|
val (ox, oy) = originOffset(hexSize)
|
||||||
|
|
||||||
|
val x = px - ox
|
||||||
|
val y = py - oy
|
||||||
|
|
||||||
|
/* Convert pixel -> axial (flat top). */
|
||||||
|
val q = (2f / 3f * x) / hexSize
|
||||||
|
val r = (-1f / 3f * x + sqrt3 / 3f * y) / hexSize
|
||||||
|
|
||||||
|
val (rq, rr) = axialRound(q, r)
|
||||||
|
|
||||||
|
/* Axial -> odd-q offset (matches cellCenter formula). */
|
||||||
|
val col = rq
|
||||||
|
val row = rr + (rq - (rq and 1)) / 2
|
||||||
|
|
||||||
|
return col to row
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun axialRound(q: Float, r: Float): Pair<Int, Int> {
|
||||||
|
|
||||||
|
val s = -q - r
|
||||||
|
|
||||||
|
var rq = q.roundToInt()
|
||||||
|
var rr = r.roundToInt()
|
||||||
|
val rs = s.roundToInt()
|
||||||
|
|
||||||
|
val qDiff = abs(rq - q)
|
||||||
|
val rDiff = abs(rr - r)
|
||||||
|
val sDiff = abs(rs - s)
|
||||||
|
|
||||||
|
when {
|
||||||
|
qDiff > rDiff && qDiff > sDiff ->
|
||||||
|
rq = -rr - rs
|
||||||
|
|
||||||
|
rDiff > sDiff ->
|
||||||
|
rr = -rq - rs
|
||||||
|
}
|
||||||
|
|
||||||
|
return rq to rr
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Six vertices of a flat-top hex centered at [cx], [cy].
|
||||||
|
*/
|
||||||
|
fun hexVertices(cx: Float, cy: Float, hexSize: Float): List<Pair<Float, Float>> {
|
||||||
|
|
||||||
|
val vertices = ArrayList<Pair<Float, Float>>(6)
|
||||||
|
|
||||||
|
for (i in 0 until 6) {
|
||||||
|
|
||||||
|
val angle = (PI / 180.0) * (60.0 * i)
|
||||||
|
val vx = cx + hexSize * cos(angle).toFloat()
|
||||||
|
val vy = cy + hexSize * sin(angle).toFloat()
|
||||||
|
|
||||||
|
vertices.add(vx to vy)
|
||||||
|
}
|
||||||
|
|
||||||
|
return vertices
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
/*
|
||||||
|
* 💣 Mines 💣
|
||||||
|
* Copyright (C) 2025 Stefan Oltmann
|
||||||
|
* https://github.com/StefanOltmann/mines
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package de.stefan_oltmann.mines.model
|
||||||
|
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
class Minefield(
|
||||||
|
val config: GameConfig,
|
||||||
|
val seed: Int,
|
||||||
|
val matrix: Array<Array<CellType>>
|
||||||
|
) {
|
||||||
|
|
||||||
|
val width
|
||||||
|
get() = config.mapWidth
|
||||||
|
|
||||||
|
val height
|
||||||
|
get() = config.mapHeight
|
||||||
|
|
||||||
|
fun getCellType(x: Int, y: Int): CellType =
|
||||||
|
matrix[x][y]
|
||||||
|
|
||||||
|
fun isMine(x: Int, y: Int): Boolean =
|
||||||
|
matrix[x][y] == CellType.MINE
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
fun create(
|
||||||
|
config: GameConfig,
|
||||||
|
seed: Int
|
||||||
|
): Minefield =
|
||||||
|
Minefield(
|
||||||
|
config = config,
|
||||||
|
seed = seed,
|
||||||
|
matrix = createMatrix(
|
||||||
|
width = config.mapWidth,
|
||||||
|
height = config.mapHeight,
|
||||||
|
mineCount = config.mineCount,
|
||||||
|
seed = seed
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun createMatrix(
|
||||||
|
width: Int,
|
||||||
|
height: Int,
|
||||||
|
mineCount: Int,
|
||||||
|
seed: Int
|
||||||
|
): Array<Array<CellType>> {
|
||||||
|
|
||||||
|
val matrix = createEmptyMatrix(width, height)
|
||||||
|
|
||||||
|
placeMines(matrix, width, height, mineCount, seed)
|
||||||
|
|
||||||
|
placeCounts(matrix, width, height)
|
||||||
|
|
||||||
|
return matrix
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createEmptyMatrix(width: Int, height: Int): Array<Array<CellType>> =
|
||||||
|
Array(width) {
|
||||||
|
Array(height) {
|
||||||
|
CellType.EMPTY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Calculates a centered protected range that scales with board size */
|
||||||
|
fun calcProtectedRange(length: Int): IntRange {
|
||||||
|
|
||||||
|
val targetSize = (length * 0.3).toInt().coerceAtLeast(2)
|
||||||
|
|
||||||
|
val protectedSize =
|
||||||
|
if (targetSize % 2 == length % 2)
|
||||||
|
targetSize
|
||||||
|
else
|
||||||
|
targetSize + 1
|
||||||
|
|
||||||
|
val start = (length - protectedSize) / 2
|
||||||
|
|
||||||
|
return start until (start + protectedSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun placeMines(
|
||||||
|
matrix: Array<Array<CellType>>,
|
||||||
|
width: Int,
|
||||||
|
height: Int,
|
||||||
|
mineCount: Int,
|
||||||
|
seed: Int
|
||||||
|
) {
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Mines are placed according to seed to reproduce results.
|
||||||
|
*/
|
||||||
|
val random = Random(seed)
|
||||||
|
|
||||||
|
val protectedXRange = calcProtectedRange(width)
|
||||||
|
val protectedYRange = calcProtectedRange(height)
|
||||||
|
|
||||||
|
var placedMinesCount = 0
|
||||||
|
|
||||||
|
while (placedMinesCount < mineCount) {
|
||||||
|
|
||||||
|
val x = random.nextInt(width)
|
||||||
|
val y = random.nextInt(height)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Keep the middle free of mines to give players a starting point.
|
||||||
|
*/
|
||||||
|
if (x in protectedXRange && y in protectedYRange)
|
||||||
|
continue
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Only place mines into empty cells.
|
||||||
|
*
|
||||||
|
* This guarantees that we have enough mines,
|
||||||
|
* even if the randomizer selects the same cell twice.
|
||||||
|
*/
|
||||||
|
if (matrix[x][y] == CellType.EMPTY) {
|
||||||
|
|
||||||
|
matrix[x][y] = CellType.MINE
|
||||||
|
|
||||||
|
placedMinesCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun placeCounts(
|
||||||
|
matrix: Array<Array<CellType>>,
|
||||||
|
width: Int,
|
||||||
|
height: Int
|
||||||
|
) {
|
||||||
|
|
||||||
|
for (x in 0 until width) {
|
||||||
|
for (y in 0 until height) {
|
||||||
|
|
||||||
|
/* Minefields stay as they are. */
|
||||||
|
if (matrix[x][y] == CellType.MINE)
|
||||||
|
continue
|
||||||
|
|
||||||
|
val mineCount = countMinesInAdjacentCells(matrix, x, y)
|
||||||
|
|
||||||
|
matrix[x][y] = CellType.ofMineCount(mineCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun countMinesInAdjacentCells(
|
||||||
|
matrix: Array<Array<CellType>>,
|
||||||
|
x: Int,
|
||||||
|
y: Int
|
||||||
|
): Int {
|
||||||
|
|
||||||
|
var count = 0
|
||||||
|
|
||||||
|
forEachAdjacentCell(x, y, matrix.size, matrix[x].size) { adjX, adjY ->
|
||||||
|
if (matrix[adjX][adjY] == CellType.MINE)
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/*
|
||||||
|
* 💣 Mines 💣
|
||||||
|
* Copyright (C) 2025 Stefan Oltmann
|
||||||
|
* https://github.com/StefanOltmann/mines
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package de.stefan_oltmann.mines.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flat-top hex neighbors using odd-q offset coordinates
|
||||||
|
* (odd columns are shoved down).
|
||||||
|
*
|
||||||
|
* See https://www.redblobgames.com/grids/hexagons/#neighbors-offset
|
||||||
|
*/
|
||||||
|
fun directionsOfAdjacentCells(x: Int, y: Int): List<Pair<Int, Int>> =
|
||||||
|
if (x and 1 == 0) {
|
||||||
|
/* Even column */
|
||||||
|
listOf(
|
||||||
|
1 to 0,
|
||||||
|
1 to -1,
|
||||||
|
0 to -1,
|
||||||
|
-1 to -1,
|
||||||
|
-1 to 0,
|
||||||
|
0 to 1
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
/* Odd column */
|
||||||
|
listOf(
|
||||||
|
1 to 1,
|
||||||
|
1 to 0,
|
||||||
|
0 to -1,
|
||||||
|
-1 to 0,
|
||||||
|
-1 to 1,
|
||||||
|
0 to 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun forEachAdjacentCell(
|
||||||
|
x: Int,
|
||||||
|
y: Int,
|
||||||
|
width: Int,
|
||||||
|
height: Int,
|
||||||
|
action: (adjX: Int, adjY: Int) -> Unit
|
||||||
|
) {
|
||||||
|
|
||||||
|
for ((dx, dy) in directionsOfAdjacentCells(x, y)) {
|
||||||
|
|
||||||
|
val adjX = x + dx
|
||||||
|
val adjY = y + dy
|
||||||
|
|
||||||
|
if (adjX in 0 until width && adjY in 0 until height)
|
||||||
|
action(adjX, adjY)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/*
|
||||||
|
* 💣 Mines 💣
|
||||||
|
* Copyright (C) 2025 Stefan Oltmann
|
||||||
|
* https://github.com/StefanOltmann/mines
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package de.stefan_oltmann.mines.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.requiredWidthIn
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import de.stefan_oltmann.mines.ui.theme.colorForeground
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppFooter(
|
||||||
|
fontFamily: FontFamily
|
||||||
|
) {
|
||||||
|
|
||||||
|
val uriHandler = LocalUriHandler.current
|
||||||
|
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.height(32.dp)
|
||||||
|
.background(Color.Black)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(
|
||||||
|
horizontal = 2.dp
|
||||||
|
)
|
||||||
|
.noRippleClickable {
|
||||||
|
uriHandler.openUri("https://stefan-oltmann.de")
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "made by Stefan Oltmann",
|
||||||
|
color = colorForeground,
|
||||||
|
fontFamily = fontFamily,
|
||||||
|
fontSize = 20.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.requiredWidthIn(min = 180.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package de.stefan_oltmann.mines.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import de.stefan_oltmann.mines.ui.theme.colorBackground
|
||||||
|
import de.stefan_oltmann.mines.ui.theme.defaultRoundedCornerShape
|
||||||
|
import de.stefan_oltmann.mines.ui.theme.doublePadding
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DelayedGameOverText(
|
||||||
|
text: String,
|
||||||
|
color: Color,
|
||||||
|
fontFamily: FontFamily
|
||||||
|
) {
|
||||||
|
|
||||||
|
var showText by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
|
||||||
|
delay(500)
|
||||||
|
|
||||||
|
showText = true
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showText,
|
||||||
|
enter = fadeIn(animationSpec = tween(durationMillis = 1500))
|
||||||
|
) {
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.background(
|
||||||
|
color = colorBackground,
|
||||||
|
shape = defaultRoundedCornerShape
|
||||||
|
)
|
||||||
|
.doublePadding()
|
||||||
|
|
||||||
|
) {
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
color = color,
|
||||||
|
fontSize = 32.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontFamily = fontFamily
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||