Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c32f5856b | |||
| 1babd404f9 | |||
| ce35617bbe | |||
| f4c54837de | |||
| 483ef2fecf | |||
| ce236bc49f | |||
| b067cf572e | |||
| af95742f69 | |||
| 0b0f0af82d |
@@ -56,9 +56,10 @@ jobs:
|
|||||||
tag="${GITHUB_REF##*/}"
|
tag="${GITHUB_REF##*/}"
|
||||||
version="${tag#v}"
|
version="${tag#v}"
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
cp target/release/gosleep-timer dist/gosleep-timer
|
binary="gosleep-timer-${version}-linux-amd64"
|
||||||
tar -C dist -czf "dist/gosleep-timer-${version}-linux-amd64.tar.gz" gosleep-timer
|
cp target/release/gosleep-timer "dist/${binary}"
|
||||||
sha256sum "dist/gosleep-timer-${version}-linux-amd64.tar.gz" > "dist/gosleep-timer-${version}-linux-amd64.tar.gz.sha256"
|
cd dist
|
||||||
|
sha256sum "${binary}" > "${binary}.sha256"
|
||||||
|
|
||||||
- name: Create Gitea release
|
- name: Create Gitea release
|
||||||
id: release
|
id: release
|
||||||
@@ -100,7 +101,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
release_url="https://gitea.forust.xyz/api/v1/repos/forust/gosleep/releases"
|
release_url="https://gitea.forust.xyz/api/v1/repos/forust/gosleep/releases"
|
||||||
release_id="${{ steps.release.outputs.release_id }}"
|
release_id="${{ steps.release.outputs.release_id }}"
|
||||||
for asset in dist/*.tar.gz dist/*.sha256; do
|
for asset in dist/gosleep-timer-*; do
|
||||||
name="$(basename "$asset")"
|
name="$(basename "$asset")"
|
||||||
release="$(curl -fsS \
|
release="$(curl -fsS \
|
||||||
"${release_url}/${release_id}" \
|
"${release_url}/${release_id}" \
|
||||||
|
|||||||
@@ -46,10 +46,12 @@ Use a temporary config path so local tests do not mutate the user's real config:
|
|||||||
```bash
|
```bash
|
||||||
tmpdir="$(mktemp -d)"
|
tmpdir="$(mktemp -d)"
|
||||||
cargo run -- --config "$tmpdir/config.yaml" init
|
cargo run -- --config "$tmpdir/config.yaml" init
|
||||||
|
cargo run -- --config "$tmpdir/config.yaml" validate
|
||||||
|
cargo run -- --config "$tmpdir/config.yaml" status
|
||||||
cargo run -- --config "$tmpdir/config.yaml" preview
|
cargo run -- --config "$tmpdir/config.yaml" preview
|
||||||
```
|
```
|
||||||
|
|
||||||
For `run` smoke tests, disable actions first so the command does not call `playerctl`, `systemctl`, `pkill`, workspace tools, or custom shell commands.
|
For `run` smoke tests, pass `--no-actions` or disable actions first so the command does not call `playerctl`, `systemctl`, `pkill`, workspace tools, or custom shell commands.
|
||||||
|
|
||||||
## Versioning
|
## Versioning
|
||||||
|
|
||||||
@@ -73,20 +75,21 @@ test "$(cat VERSION)" = "$(cargo metadata --no-deps --format-version 1 | jq -r '
|
|||||||
Release workflow is tag-based:
|
Release workflow is tag-based:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git tag -a v1.1.1 -m "gosleep-timer v1.1.1"
|
git tag -a vMAJOR.MINOR.PATCH -m "gosleep-timer vMAJOR.MINOR.PATCH"
|
||||||
git push origin main
|
git push origin main
|
||||||
git push origin v1.1.1
|
git push origin vMAJOR.MINOR.PATCH
|
||||||
```
|
```
|
||||||
|
|
||||||
The Gitea release workflow:
|
The Gitea release workflow:
|
||||||
|
|
||||||
|
- runs formatting, tests, clippy, and YAML lint before building
|
||||||
- builds `target/release/gosleep-timer`
|
- builds `target/release/gosleep-timer`
|
||||||
- creates `gosleep-timer-<version>-linux-amd64.tar.gz`
|
- copies `gosleep-timer-<version>-linux-amd64`
|
||||||
- writes a `.sha256`
|
- writes a `.sha256`
|
||||||
- creates or reuses the matching Gitea release
|
- creates or reuses the matching Gitea release
|
||||||
- replaces release assets with matching names
|
- replaces release assets with matching names
|
||||||
|
|
||||||
The workflow uses the built-in `GITEA_TOKEN` and declares `permissions: contents: write`.
|
The workflow exposes `${{ secrets.GITEA_TOKEN }}` as `GITEA_TOKEN` and declares `permissions: contents: write`.
|
||||||
|
|
||||||
## Git Remote
|
## Git Remote
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,21 @@ All notable changes to this project are documented here.
|
|||||||
|
|
||||||
The project uses semantic versioning. Tags are formatted as `vMAJOR.MINOR.PATCH`.
|
The project uses semantic versioning. Tags are formatted as `vMAJOR.MINOR.PATCH`.
|
||||||
|
|
||||||
|
## [1.2.0] - 2026-07-05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added `status`, `validate`, `edit`, `history`, and `stats` CLI commands.
|
||||||
|
- Added `run --dry-run` and `run --no-actions` for safer timer verification.
|
||||||
|
- Added TUI pause/resume support and a fullscreen running timer view with large ASCII time-left display.
|
||||||
|
- Added history recording for completed sessions and aggregate sleep/timer statistics.
|
||||||
|
- Added red highlighting for dangerous post-timer actions and common destructive custom commands.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- TUI settings now hide dependent options when their parent action is disabled.
|
||||||
|
- Release assets are published as a raw Linux binary plus `.sha256` instead of a tar archive.
|
||||||
|
|
||||||
## [1.1.3] - 2026-07-03
|
## [1.1.3] - 2026-07-03
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -27,6 +42,7 @@ The project uses semantic versioning. Tags are formatted as `vMAJOR.MINOR.PATCH`
|
|||||||
- Added YAML configuration, CLI `init`, `preview`, and `run` commands.
|
- Added YAML configuration, CLI `init`, `preview`, and `run` commands.
|
||||||
- Added Gitea CI and tag-based release workflows.
|
- Added Gitea CI and tag-based release workflows.
|
||||||
|
|
||||||
|
[1.2.0]: https://gitea.forust.xyz/forust/gosleep/releases/tag/v1.2.0
|
||||||
[1.1.3]: https://gitea.forust.xyz/forust/gosleep/releases/tag/v1.1.3
|
[1.1.3]: https://gitea.forust.xyz/forust/gosleep/releases/tag/v1.1.3
|
||||||
[1.1.2]: https://gitea.forust.xyz/forust/gosleep/releases/tag/v1.1.2
|
[1.1.2]: https://gitea.forust.xyz/forust/gosleep/releases/tag/v1.1.2
|
||||||
[1.1.1]: https://gitea.forust.xyz/forust/gosleep/releases/tag/v1.1.1
|
[1.1.1]: https://gitea.forust.xyz/forust/gosleep/releases/tag/v1.1.1
|
||||||
|
|||||||
Generated
+1
-1
@@ -298,7 +298,7 @@ checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gosleep-timer"
|
name = "gosleep-timer"
|
||||||
version = "1.1.3"
|
version = "1.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "gosleep-timer"
|
name = "gosleep-timer"
|
||||||
version = "1.1.3"
|
version = "1.2.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.85"
|
rust-version = "1.85"
|
||||||
description = "Terminal sleep timer for Linux desktop actions, with YAML config and a Rust TUI"
|
description = "Terminal sleep timer for Linux desktop actions, with YAML config and a Rust TUI"
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ Rust TUI sleep timer for Linux desktop actions.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
- Current version: `1.1.1`
|
- Release tags: `vMAJOR.MINOR.PATCH`
|
||||||
- Release tags: `vMAJOR.MINOR.PATCH`, for example `v1.1.1`
|
|
||||||
- Primary repository: <https://gitea.forust.xyz/forust/gosleep>
|
- Primary repository: <https://gitea.forust.xyz/forust/gosleep>
|
||||||
- SSH upstream: `ssh://git@gitssh.forust.xyz:2221/forust/gosleep.git`
|
- SSH upstream: `ssh://git@gitssh.forust.xyz:2221/forust/gosleep.git`
|
||||||
|
|
||||||
@@ -34,11 +33,11 @@ install -Dm755 target/release/gosleep-timer ~/.local/bin/gosleep-timer
|
|||||||
|
|
||||||
### From release artifact
|
### From release artifact
|
||||||
|
|
||||||
Download `gosleep-timer-<version>-linux-amd64.tar.gz` from the Gitea release page, then:
|
Download `gosleep-timer-<version>-linux-amd64` and its `.sha256` from the Gitea release page, then:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tar -xzf gosleep-timer-1.1.1-linux-amd64.tar.gz
|
sha256sum -c gosleep-timer-<version>-linux-amd64.sha256
|
||||||
install -Dm755 gosleep-timer ~/.local/bin/gosleep-timer
|
install -Dm755 gosleep-timer-<version>-linux-amd64 ~/.local/bin/gosleep-timer
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
@@ -61,6 +60,24 @@ Preview commands that will run after the countdown:
|
|||||||
gosleep-timer preview
|
gosleep-timer preview
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Show the current config, enabled actions, and dangerous-action markers:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gosleep-timer status
|
||||||
|
```
|
||||||
|
|
||||||
|
Validate the config without starting the timer:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gosleep-timer validate
|
||||||
|
```
|
||||||
|
|
||||||
|
Open the config in `$VISUAL`, `$EDITOR`, or `vi`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gosleep-timer edit
|
||||||
|
```
|
||||||
|
|
||||||
Run the saved timer config:
|
Run the saved timer config:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -74,6 +91,20 @@ gosleep-timer run 45m
|
|||||||
gosleep-timer run 1h30m
|
gosleep-timer run 1h30m
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Run the countdown without executing post-timer actions:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gosleep-timer run --dry-run 10s
|
||||||
|
gosleep-timer run --no-actions 10s
|
||||||
|
```
|
||||||
|
|
||||||
|
Inspect recorded sessions and aggregate stats:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gosleep-timer history
|
||||||
|
gosleep-timer stats
|
||||||
|
```
|
||||||
|
|
||||||
Use a custom config path:
|
Use a custom config path:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -90,6 +121,7 @@ gosleep-timer --config ./config.yaml preview
|
|||||||
| `Enter` while editing | Apply edit |
|
| `Enter` while editing | Apply edit |
|
||||||
| `Esc` while editing | Cancel edit |
|
| `Esc` while editing | Cancel edit |
|
||||||
| `r` | Start/restart timer |
|
| `r` | Start/restart timer |
|
||||||
|
| `p` | Pause/resume running timer |
|
||||||
| `x` | Stop timer |
|
| `x` | Stop timer |
|
||||||
| `s` | Save config |
|
| `s` | Save config |
|
||||||
| `q` / `Esc` | Quit |
|
| `q` / `Esc` | Quit |
|
||||||
@@ -155,6 +187,12 @@ Other actions use common Linux desktop tools:
|
|||||||
- `systemctl`
|
- `systemctl`
|
||||||
|
|
||||||
Install only the tools needed for the actions you enable.
|
Install only the tools needed for the actions you enable.
|
||||||
|
Dangerous actions such as `pkill`, power actions, and common destructive custom commands are highlighted in red in the TUI preview.
|
||||||
|
|
||||||
|
## History
|
||||||
|
|
||||||
|
Completed timer sessions are appended to `history.yaml` next to the active config file.
|
||||||
|
If no poweroff or reboot action is configured, CLI `run` asks for wake confirmation after the timer finishes and records the elapsed sleep time.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -185,7 +223,7 @@ Version is stored in:
|
|||||||
Release tags must match the package version:
|
Release tags must match the package version:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git tag -a v1.1.1 -m "gosleep-timer v1.1.1"
|
git tag -a vMAJOR.MINOR.PATCH -m "gosleep-timer vMAJOR.MINOR.PATCH"
|
||||||
git push origin main --tags
|
git push origin main --tags
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -198,12 +236,11 @@ Required runner tools:
|
|||||||
- Rust stable toolchain with `cargo`, `rustfmt`, and `clippy`
|
- Rust stable toolchain with `cargo`, `rustfmt`, and `clippy`
|
||||||
- `curl`
|
- `curl`
|
||||||
- `jq`
|
- `jq`
|
||||||
- `tar`
|
|
||||||
- `sha256sum`
|
- `sha256sum`
|
||||||
|
|
||||||
Release authentication:
|
Release authentication:
|
||||||
|
|
||||||
- The workflow uses Gitea Actions' built-in `GITEA_TOKEN`.
|
- The workflow exposes Gitea Actions' built-in `${{ secrets.GITEA_TOKEN }}` as `GITEA_TOKEN`.
|
||||||
- The workflow declares `permissions: contents: write` so the token can create releases and upload assets.
|
- The workflow declares `permissions: contents: write` so the token can create releases and upload assets.
|
||||||
|
|
||||||
See [RELEASING.md](RELEASING.md) for the full release checklist.
|
See [RELEASING.md](RELEASING.md) for the full release checklist.
|
||||||
|
|||||||
+11
-11
@@ -36,7 +36,6 @@ https://gitea.forust.xyz/forust/gosleep.git
|
|||||||
- `clippy`
|
- `clippy`
|
||||||
- `curl`
|
- `curl`
|
||||||
- `jq`
|
- `jq`
|
||||||
- `tar`
|
|
||||||
- `sha256sum`
|
- `sha256sum`
|
||||||
- Docker, if using the YAML lint job as written
|
- Docker, if using the YAML lint job as written
|
||||||
4. Confirm Actions job token permissions allow `contents: write`.
|
4. Confirm Actions job token permissions allow `contents: write`.
|
||||||
@@ -44,10 +43,10 @@ https://gitea.forust.xyz/forust/gosleep.git
|
|||||||
|
|
||||||
## Version Bump Checklist
|
## Version Bump Checklist
|
||||||
|
|
||||||
For a patch bump, for example `1.1.1` to `1.1.2`:
|
For a patch bump, for example `1.1.3` to `1.1.4`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
version=1.1.2
|
version=1.1.4
|
||||||
```
|
```
|
||||||
|
|
||||||
Update:
|
Update:
|
||||||
@@ -70,20 +69,20 @@ cargo build --release --locked
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
git status --short
|
git status --short
|
||||||
git commit -am "chore: release v1.1.2"
|
git commit -am "chore: release v${version}"
|
||||||
git tag -a v1.1.2 -m "gosleep-timer v1.1.2"
|
git tag -a "v${version}" -m "gosleep-timer v${version}"
|
||||||
git push origin main
|
git push origin main
|
||||||
git push origin v1.1.2
|
git push origin "v${version}"
|
||||||
```
|
```
|
||||||
|
|
||||||
The `.gitea/workflows/release.yaml` workflow will:
|
The `.gitea/workflows/release.yaml` workflow will:
|
||||||
|
|
||||||
1. Re-run release gates: `cargo fmt --check`, `cargo test --locked`, `cargo clippy --locked -- -D warnings`, and YAML lint.
|
1. Re-run release gates: `cargo fmt --check`, `cargo test --locked`, `cargo clippy --locked -- -D warnings`, and YAML lint.
|
||||||
2. Build `target/release/gosleep-timer`.
|
2. Build `target/release/gosleep-timer`.
|
||||||
3. Package `gosleep-timer-<version>-linux-amd64.tar.gz`.
|
3. Copy binary as `gosleep-timer-<version>-linux-amd64`.
|
||||||
4. Generate a SHA-256 checksum.
|
4. Generate a SHA-256 checksum.
|
||||||
5. Create a Gitea release.
|
5. Create a Gitea release.
|
||||||
6. Upload the archive and checksum as release assets.
|
6. Upload the binary and checksum as release assets.
|
||||||
|
|
||||||
The workflow is idempotent: if the release already exists, it reuses it and replaces assets with matching names.
|
The workflow is idempotent: if the release already exists, it reuses it and replaces assets with matching names.
|
||||||
If any lint or test step fails, the build and release job will not run.
|
If any lint or test step fails, the build and release job will not run.
|
||||||
@@ -96,7 +95,8 @@ If Actions are unavailable:
|
|||||||
version="$(cat VERSION)"
|
version="$(cat VERSION)"
|
||||||
cargo build --release --locked
|
cargo build --release --locked
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
cp target/release/gosleep-timer dist/gosleep-timer
|
binary="gosleep-timer-${version}-linux-amd64"
|
||||||
tar -C dist -czf "dist/gosleep-timer-${version}-linux-amd64.tar.gz" gosleep-timer
|
cp target/release/gosleep-timer "dist/${binary}"
|
||||||
sha256sum "dist/gosleep-timer-${version}-linux-amd64.tar.gz" > "dist/gosleep-timer-${version}-linux-amd64.tar.gz.sha256"
|
cd dist
|
||||||
|
sha256sum "${binary}" > "${binary}.sha256"
|
||||||
```
|
```
|
||||||
|
|||||||
+686
-53
@@ -1,14 +1,14 @@
|
|||||||
use std::{
|
use std::{
|
||||||
fs,
|
fs,
|
||||||
io::{self, Write},
|
io::{self, Write},
|
||||||
path::PathBuf,
|
path::{Path, PathBuf},
|
||||||
process::{Command, Stdio},
|
process::{Command, Stdio},
|
||||||
sync::{
|
sync::{
|
||||||
Arc,
|
Arc,
|
||||||
atomic::{AtomicBool, Ordering},
|
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||||
},
|
},
|
||||||
thread,
|
thread,
|
||||||
time::{Duration, Instant},
|
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||||
};
|
};
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
@@ -42,7 +42,18 @@ struct Cli {
|
|||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum Commands {
|
enum Commands {
|
||||||
Run { duration: Option<String> },
|
Run {
|
||||||
|
#[arg(long)]
|
||||||
|
dry_run: bool,
|
||||||
|
#[arg(long)]
|
||||||
|
no_actions: bool,
|
||||||
|
duration: Option<String>,
|
||||||
|
},
|
||||||
|
Status,
|
||||||
|
Validate,
|
||||||
|
Edit,
|
||||||
|
History,
|
||||||
|
Stats,
|
||||||
Preview,
|
Preview,
|
||||||
Init,
|
Init,
|
||||||
}
|
}
|
||||||
@@ -129,6 +140,34 @@ struct ActionCommand {
|
|||||||
shell: Option<String>,
|
shell: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct HistoryEntry {
|
||||||
|
started_at: u64,
|
||||||
|
duration_seconds: u64,
|
||||||
|
slept_seconds: Option<u64>,
|
||||||
|
status: String,
|
||||||
|
actions: Vec<String>,
|
||||||
|
failures: Vec<String>,
|
||||||
|
dry_run: bool,
|
||||||
|
no_actions: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct TimerReport {
|
||||||
|
started_at: u64,
|
||||||
|
duration: Duration,
|
||||||
|
actions: Vec<String>,
|
||||||
|
failures: Vec<String>,
|
||||||
|
dry_run: bool,
|
||||||
|
no_actions: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||||
|
struct TimerOptions {
|
||||||
|
dry_run: bool,
|
||||||
|
no_actions: bool,
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for Config {
|
impl Default for Config {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -187,6 +226,44 @@ impl ActionCommand {
|
|||||||
fn display(&self) -> String {
|
fn display(&self) -> String {
|
||||||
self.shell.clone().unwrap_or_else(|| self.args.join(" "))
|
self.shell.clone().unwrap_or_else(|| self.args.join(" "))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_dangerous(&self) -> bool {
|
||||||
|
if matches!(self.label, "kill" | "power") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let command = self.display().to_lowercase();
|
||||||
|
[
|
||||||
|
"rm -rf",
|
||||||
|
"mkfs",
|
||||||
|
"dd if=",
|
||||||
|
"shutdown",
|
||||||
|
"reboot",
|
||||||
|
"poweroff",
|
||||||
|
"systemctl poweroff",
|
||||||
|
"systemctl reboot",
|
||||||
|
"wipefs",
|
||||||
|
"shred",
|
||||||
|
"killall",
|
||||||
|
"pkill",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.any(|needle| command.contains(needle))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TimerReport {
|
||||||
|
fn to_history(&self, status: &str, slept_seconds: Option<u64>) -> HistoryEntry {
|
||||||
|
HistoryEntry {
|
||||||
|
started_at: self.started_at,
|
||||||
|
duration_seconds: self.duration.as_secs(),
|
||||||
|
slept_seconds,
|
||||||
|
status: status.to_string(),
|
||||||
|
actions: self.actions.clone(),
|
||||||
|
failures: self.failures.clone(),
|
||||||
|
dry_run: self.dry_run,
|
||||||
|
no_actions: self.no_actions,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
@@ -195,18 +272,52 @@ fn main() -> Result<()> {
|
|||||||
let mut config = ensure_config(&path)?;
|
let mut config = ensure_config(&path)?;
|
||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Some(Commands::Run { duration }) => {
|
Some(Commands::Run {
|
||||||
|
dry_run,
|
||||||
|
no_actions,
|
||||||
|
duration,
|
||||||
|
}) => {
|
||||||
if let Some(duration) = duration {
|
if let Some(duration) = duration {
|
||||||
config.duration = duration;
|
config.duration = duration;
|
||||||
}
|
}
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
run_timer(
|
let options = TimerOptions {
|
||||||
|
dry_run,
|
||||||
|
no_actions,
|
||||||
|
};
|
||||||
|
let report = run_timer(
|
||||||
&config,
|
&config,
|
||||||
&Arc::new(AtomicBool::new(false)),
|
&Arc::new(AtomicBool::new(false)),
|
||||||
|
&Arc::new(AtomicBool::new(false)),
|
||||||
|
&Arc::new(AtomicU64::new(0)),
|
||||||
RunMode::Cli,
|
RunMode::Cli,
|
||||||
|
options,
|
||||||
Some(&mut stdout),
|
Some(&mut stdout),
|
||||||
|
)?;
|
||||||
|
let slept_seconds = prompt_wake_confirmation(&config, options)?;
|
||||||
|
append_history(
|
||||||
|
&history_path(&path),
|
||||||
|
report.to_history("finished", slept_seconds),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Some(Commands::Status) => {
|
||||||
|
print_status(&path, &config);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Some(Commands::Validate) => {
|
||||||
|
validate_config(&config)?;
|
||||||
|
println!("valid {}", path.display());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Some(Commands::Edit) => edit_config(&path),
|
||||||
|
Some(Commands::History) => {
|
||||||
|
print_history(&history_path(&path))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Some(Commands::Stats) => {
|
||||||
|
print_stats(&history_path(&path))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
Some(Commands::Preview) => {
|
Some(Commands::Preview) => {
|
||||||
for command in preview_commands(&config) {
|
for command in preview_commands(&config) {
|
||||||
println!("{command}");
|
println!("{command}");
|
||||||
@@ -233,7 +344,7 @@ fn default_config_path() -> PathBuf {
|
|||||||
.join("config.yaml")
|
.join("config.yaml")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_config(path: &PathBuf) -> Result<Config> {
|
fn ensure_config(path: &Path) -> Result<Config> {
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
let config = Config::default();
|
let config = Config::default();
|
||||||
save_config(path, &config)?;
|
save_config(path, &config)?;
|
||||||
@@ -242,10 +353,11 @@ fn ensure_config(path: &PathBuf) -> Result<Config> {
|
|||||||
let data = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
|
let data = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
|
||||||
let mut config: Config = serde_yaml::from_str(&data).context("parse config")?;
|
let mut config: Config = serde_yaml::from_str(&data).context("parse config")?;
|
||||||
normalize_config(&mut config);
|
normalize_config(&mut config);
|
||||||
|
validate_config(&config)?;
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn save_config(path: &PathBuf, config: &Config) -> Result<()> {
|
fn save_config(path: &Path, config: &Config) -> Result<()> {
|
||||||
let mut config = config.clone();
|
let mut config = config.clone();
|
||||||
normalize_config(&mut config);
|
normalize_config(&mut config);
|
||||||
validate_config(&config)?;
|
validate_config(&config)?;
|
||||||
@@ -257,6 +369,131 @@ fn save_config(path: &PathBuf, config: &Config) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn history_path(config_path: &Path) -> PathBuf {
|
||||||
|
config_path.with_file_name("history.yaml")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unix_timestamp() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_history(path: &Path) -> Result<Vec<HistoryEntry>> {
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let data = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
|
||||||
|
serde_yaml::from_str(&data).with_context(|| format!("parse {}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_history(path: &Path, entry: HistoryEntry) -> Result<()> {
|
||||||
|
let mut entries = read_history(path)?;
|
||||||
|
entries.push(entry);
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
|
||||||
|
}
|
||||||
|
let data = serde_yaml::to_string(&entries).context("serialize history")?;
|
||||||
|
fs::write(path, data).with_context(|| format!("write {}", path.display()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_status(path: &Path, config: &Config) {
|
||||||
|
println!("config: {}", path.display());
|
||||||
|
println!("history: {}", history_path(path).display());
|
||||||
|
println!("duration: {}", config.duration);
|
||||||
|
println!("actions:");
|
||||||
|
let commands = build_action_commands(config);
|
||||||
|
if commands.is_empty() {
|
||||||
|
println!(" none");
|
||||||
|
} else {
|
||||||
|
for command in commands {
|
||||||
|
let danger = if command.is_dangerous() { " !" } else { "" };
|
||||||
|
println!(" - [{}{}] {}", command.label, danger, command.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_history(path: &Path) -> Result<()> {
|
||||||
|
let entries = read_history(path)?;
|
||||||
|
if entries.is_empty() {
|
||||||
|
println!("no history");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
for entry in entries.iter().rev().take(20) {
|
||||||
|
let slept = entry
|
||||||
|
.slept_seconds
|
||||||
|
.map(|seconds| format!(" slept={}", format_duration(Duration::from_secs(seconds))))
|
||||||
|
.unwrap_or_default();
|
||||||
|
println!(
|
||||||
|
"{} status={} duration={} actions={} failures={}{}",
|
||||||
|
entry.started_at,
|
||||||
|
entry.status,
|
||||||
|
format_duration(Duration::from_secs(entry.duration_seconds)),
|
||||||
|
entry.actions.len(),
|
||||||
|
entry.failures.len(),
|
||||||
|
slept
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_stats(path: &Path) -> Result<()> {
|
||||||
|
let entries = read_history(path)?;
|
||||||
|
let finished = entries
|
||||||
|
.iter()
|
||||||
|
.filter(|entry| entry.status == "finished")
|
||||||
|
.count();
|
||||||
|
let total_duration: u64 = entries.iter().map(|entry| entry.duration_seconds).sum();
|
||||||
|
let total_slept: u64 = entries.iter().filter_map(|entry| entry.slept_seconds).sum();
|
||||||
|
let failures: usize = entries.iter().map(|entry| entry.failures.len()).sum();
|
||||||
|
println!("sessions: {}", entries.len());
|
||||||
|
println!("finished: {finished}");
|
||||||
|
println!(
|
||||||
|
"scheduled: {}",
|
||||||
|
format_duration(Duration::from_secs(total_duration))
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"slept: {}",
|
||||||
|
format_duration(Duration::from_secs(total_slept))
|
||||||
|
);
|
||||||
|
println!("action failures: {failures}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn edit_config(path: &Path) -> Result<()> {
|
||||||
|
let editor = std::env::var("VISUAL")
|
||||||
|
.or_else(|_| std::env::var("EDITOR"))
|
||||||
|
.unwrap_or_else(|_| "vi".to_string());
|
||||||
|
let status = Command::new(editor)
|
||||||
|
.arg(path)
|
||||||
|
.status()
|
||||||
|
.context("open editor")?;
|
||||||
|
if !status.success() {
|
||||||
|
bail!("editor exited with {status}");
|
||||||
|
}
|
||||||
|
let _ = ensure_config(path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_wake_confirmation(config: &Config, options: TimerOptions) -> Result<Option<u64>> {
|
||||||
|
if options.dry_run
|
||||||
|
|| options.no_actions
|
||||||
|
|| matches!(config.actions.power.mode.as_str(), "poweroff" | "reboot")
|
||||||
|
{
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
print!("timer finished. Press Enter when awake to record sleep time...");
|
||||||
|
io::stdout().flush().ok();
|
||||||
|
let started = Instant::now();
|
||||||
|
let mut line = String::new();
|
||||||
|
io::stdin()
|
||||||
|
.read_line(&mut line)
|
||||||
|
.context("read wake confirmation")?;
|
||||||
|
Ok(Some(started.elapsed().as_secs()))
|
||||||
|
}
|
||||||
|
|
||||||
fn normalize_config(config: &mut Config) {
|
fn normalize_config(config: &mut Config) {
|
||||||
if config.duration.is_empty() {
|
if config.duration.is_empty() {
|
||||||
config.duration = "25m".to_string();
|
config.duration = "25m".to_string();
|
||||||
@@ -491,12 +728,22 @@ fn write_timer_progress(output: &mut dyn Write, message: &str) {
|
|||||||
fn run_timer(
|
fn run_timer(
|
||||||
config: &Config,
|
config: &Config,
|
||||||
stop: &Arc<AtomicBool>,
|
stop: &Arc<AtomicBool>,
|
||||||
|
pause: &Arc<AtomicBool>,
|
||||||
|
remaining_ms: &Arc<AtomicU64>,
|
||||||
mode: RunMode,
|
mode: RunMode,
|
||||||
|
options: TimerOptions,
|
||||||
mut output: Option<&mut dyn Write>,
|
mut output: Option<&mut dyn Write>,
|
||||||
) -> Result<()> {
|
) -> Result<TimerReport> {
|
||||||
validate_config(config)?;
|
validate_config(config)?;
|
||||||
let duration = parse_duration(&config.duration)?;
|
let duration = parse_duration(&config.duration)?;
|
||||||
|
let started_at = unix_timestamp();
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
|
let mut remaining = duration;
|
||||||
|
let mut last_tick = started;
|
||||||
|
remaining_ms.store(
|
||||||
|
duration.as_millis().min(u128::from(u64::MAX)) as u64,
|
||||||
|
Ordering::Relaxed,
|
||||||
|
);
|
||||||
if let Some(output) = output.as_deref_mut() {
|
if let Some(output) = output.as_deref_mut() {
|
||||||
write_timer_line(output, &format!("timer started: {}", config.duration));
|
write_timer_line(output, &format!("timer started: {}", config.duration));
|
||||||
}
|
}
|
||||||
@@ -508,29 +755,79 @@ fn run_timer(
|
|||||||
}
|
}
|
||||||
bail!("timer stopped");
|
bail!("timer stopped");
|
||||||
}
|
}
|
||||||
let elapsed = started.elapsed();
|
|
||||||
if elapsed >= duration {
|
if pause.load(Ordering::Relaxed) {
|
||||||
|
last_tick = Instant::now();
|
||||||
|
thread::sleep(Duration::from_millis(250));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Instant::now();
|
||||||
|
let elapsed = now.saturating_duration_since(last_tick);
|
||||||
|
last_tick = now;
|
||||||
|
remaining = remaining.saturating_sub(elapsed);
|
||||||
|
remaining_ms.store(
|
||||||
|
remaining.as_millis().min(u128::from(u64::MAX)) as u64,
|
||||||
|
Ordering::Relaxed,
|
||||||
|
);
|
||||||
|
|
||||||
|
if remaining.is_zero() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if let Some(output) = output.as_deref_mut() {
|
if let Some(output) = output.as_deref_mut() {
|
||||||
write_timer_progress(
|
write_timer_progress(
|
||||||
output,
|
output,
|
||||||
&format!("remaining: {}", format_duration(duration - elapsed)),
|
&format!("remaining: {}", format_duration(remaining)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
thread::sleep(Duration::from_millis(250));
|
thread::sleep(Duration::from_millis(250));
|
||||||
}
|
}
|
||||||
|
remaining_ms.store(0, Ordering::Relaxed);
|
||||||
|
|
||||||
|
if stop.load(Ordering::Relaxed) {
|
||||||
|
if let Some(output) = output.as_deref_mut() {
|
||||||
|
write_timer_line(output, "stopped");
|
||||||
|
}
|
||||||
|
bail!("timer stopped");
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(output) = output.as_deref_mut() {
|
if let Some(output) = output.as_deref_mut() {
|
||||||
write_timer_line(output, "finished");
|
write_timer_line(output, "finished");
|
||||||
}
|
}
|
||||||
for command in build_action_commands(config) {
|
let mut failures = Vec::new();
|
||||||
|
let actions = if options.no_actions {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
build_action_commands(config)
|
||||||
|
};
|
||||||
|
for command in &actions {
|
||||||
if let Some(output) = output.as_deref_mut() {
|
if let Some(output) = output.as_deref_mut() {
|
||||||
write_timer_line(output, &format!("run: {}", command.display()));
|
let prefix = if options.dry_run { "would run" } else { "run" };
|
||||||
|
write_timer_line(output, &format!("{prefix}: {}", command.display()));
|
||||||
|
}
|
||||||
|
if options.dry_run {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Err(err) = run_command(command, mode) {
|
||||||
|
failures.push(format!("{} failed: {err}", command.label));
|
||||||
}
|
}
|
||||||
run_command(&command, mode).with_context(|| format!("{} failed", command.label))?;
|
|
||||||
}
|
}
|
||||||
Ok(())
|
let report = TimerReport {
|
||||||
|
started_at,
|
||||||
|
duration,
|
||||||
|
actions: actions.iter().map(ActionCommand::display).collect(),
|
||||||
|
failures: failures.clone(),
|
||||||
|
dry_run: options.dry_run,
|
||||||
|
no_actions: options.no_actions,
|
||||||
|
};
|
||||||
|
if !failures.is_empty() {
|
||||||
|
bail!(
|
||||||
|
"{} action(s) failed: {}",
|
||||||
|
failures.len(),
|
||||||
|
failures.join("; ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(report)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prepare_command_stdio(process: &mut Command, mode: RunMode) {
|
fn prepare_command_stdio(process: &mut Command, mode: RunMode) {
|
||||||
@@ -562,15 +859,18 @@ fn run_command(command: &ActionCommand, mode: RunMode) -> Result<()> {
|
|||||||
|
|
||||||
fn run_tui(path: PathBuf, config: Config) -> Result<()> {
|
fn run_tui(path: PathBuf, config: Config) -> Result<()> {
|
||||||
enable_raw_mode()?;
|
enable_raw_mode()?;
|
||||||
let mut stdout = io::stdout();
|
let result = (|| {
|
||||||
execute!(stdout, EnterAlternateScreen)?;
|
let mut stdout = io::stdout();
|
||||||
let backend = CrosstermBackend::new(stdout);
|
execute!(stdout, EnterAlternateScreen)?;
|
||||||
let mut terminal = Terminal::new(backend)?;
|
let backend = CrosstermBackend::new(stdout);
|
||||||
let mut app = App::new(path, config);
|
let mut terminal = Terminal::new(backend)?;
|
||||||
let result = app.run(&mut terminal);
|
let mut app = App::new(path, config);
|
||||||
|
let result = app.run(&mut terminal);
|
||||||
|
terminal.show_cursor().ok();
|
||||||
|
result
|
||||||
|
})();
|
||||||
disable_raw_mode().ok();
|
disable_raw_mode().ok();
|
||||||
execute!(terminal.backend_mut(), LeaveAlternateScreen).ok();
|
execute!(io::stdout(), LeaveAlternateScreen).ok();
|
||||||
terminal.show_cursor().ok();
|
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -585,10 +885,11 @@ struct App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct TimerState {
|
struct TimerState {
|
||||||
started: Instant,
|
|
||||||
duration: Duration,
|
duration: Duration,
|
||||||
stop: Arc<AtomicBool>,
|
stop: Arc<AtomicBool>,
|
||||||
handle: thread::JoinHandle<Result<()>>,
|
pause: Arc<AtomicBool>,
|
||||||
|
remaining_ms: Arc<AtomicU64>,
|
||||||
|
handle: thread::JoinHandle<Result<TimerReport>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct EditState {
|
struct EditState {
|
||||||
@@ -636,11 +937,13 @@ impl App {
|
|||||||
self.focus = self.focus.saturating_sub(1)
|
self.focus = self.focus.saturating_sub(1)
|
||||||
}
|
}
|
||||||
KeyCode::Down | KeyCode::Char('j') => {
|
KeyCode::Down | KeyCode::Char('j') => {
|
||||||
self.focus = (self.focus + 1).min(FIELDS.len() - 1);
|
let last = self.visible_fields().len().saturating_sub(1);
|
||||||
|
self.focus = (self.focus + 1).min(last);
|
||||||
}
|
}
|
||||||
KeyCode::Char(' ') | KeyCode::Enter => self.activate()?,
|
KeyCode::Char(' ') | KeyCode::Enter => self.activate()?,
|
||||||
KeyCode::Char('s') => self.save(),
|
KeyCode::Char('s') => self.save(),
|
||||||
KeyCode::Char('r') => self.start_timer(),
|
KeyCode::Char('r') => self.start_timer(),
|
||||||
|
KeyCode::Char('p') => self.toggle_pause(),
|
||||||
KeyCode::Char('x') => self.stop_timer(),
|
KeyCode::Char('x') => self.stop_timer(),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -655,6 +958,10 @@ impl App {
|
|||||||
frame.render_widget(Paragraph::new("terminal too small"), area);
|
frame.render_widget(Paragraph::new("terminal too small"), area);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if self.timer.is_some() {
|
||||||
|
self.draw_running(frame, area);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let chunks = Layout::default()
|
let chunks = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
@@ -673,7 +980,7 @@ impl App {
|
|||||||
edit.field.label, edit.value
|
edit.field.label, edit.value
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
"space/enter edit-toggle | r run | x stop | s save | q quit".to_string()
|
"space/enter edit-toggle | r run | p pause | x stop | s save | q quit".to_string()
|
||||||
};
|
};
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(footer).style(Style::default().fg(Color::DarkGray)),
|
Paragraph::new(footer).style(Style::default().fg(Color::DarkGray)),
|
||||||
@@ -681,6 +988,90 @@ impl App {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn draw_running(&self, frame: &mut Frame, area: Rect) {
|
||||||
|
let (_, remaining, progress) = self.timer_snapshot();
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(2),
|
||||||
|
Constraint::Length(7),
|
||||||
|
Constraint::Length(2),
|
||||||
|
Constraint::Min(4),
|
||||||
|
Constraint::Length(1),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new("gosleep-timer").style(
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
chunks[0],
|
||||||
|
);
|
||||||
|
|
||||||
|
let time_lines = ascii_time(&format_compact_duration(remaining))
|
||||||
|
.into_iter()
|
||||||
|
.map(|line| Line::from(Span::styled(line, Style::default().fg(Color::Cyan))))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
frame.render_widget(Paragraph::new(time_lines), chunks[1]);
|
||||||
|
|
||||||
|
frame.render_widget(
|
||||||
|
Gauge::default()
|
||||||
|
.gauge_style(Style::default().fg(Color::Magenta))
|
||||||
|
.label(format_gauge_label(progress))
|
||||||
|
.ratio(progress),
|
||||||
|
chunks[2],
|
||||||
|
);
|
||||||
|
|
||||||
|
let commands = build_action_commands(&self.config);
|
||||||
|
let lines = if commands.is_empty() {
|
||||||
|
vec![Line::from("no post-timer actions enabled")]
|
||||||
|
} else {
|
||||||
|
commands
|
||||||
|
.iter()
|
||||||
|
.flat_map(|command| {
|
||||||
|
let style = if command.is_dangerous() {
|
||||||
|
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::Green)
|
||||||
|
};
|
||||||
|
wrap_command(
|
||||||
|
&command.display(),
|
||||||
|
chunks[3].width.saturating_sub(4) as usize,
|
||||||
|
)
|
||||||
|
.into_iter()
|
||||||
|
.map(move |line| Line::from(Span::styled(line, style)))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(lines)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.title(" commands after countdown ")
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Color::Blue),
|
||||||
|
)
|
||||||
|
.wrap(Wrap { trim: false }),
|
||||||
|
chunks[3],
|
||||||
|
);
|
||||||
|
|
||||||
|
let footer = if self
|
||||||
|
.timer
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|timer| timer.pause.load(Ordering::Relaxed))
|
||||||
|
{
|
||||||
|
"paused | p resume | x stop | q quit"
|
||||||
|
} else {
|
||||||
|
"running | p pause | x stop | q quit"
|
||||||
|
};
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(footer).style(Style::default().fg(Color::DarkGray)),
|
||||||
|
chunks[4],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn draw_header(&self, frame: &mut Frame, area: Rect) {
|
fn draw_header(&self, frame: &mut Frame, area: Rect) {
|
||||||
let (_, remaining, progress) = self.timer_snapshot();
|
let (_, remaining, progress) = self.timer_snapshot();
|
||||||
let header = Layout::default()
|
let header = Layout::default()
|
||||||
@@ -747,8 +1138,10 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn draw_fields(&mut self, frame: &mut Frame, area: Rect) {
|
fn draw_fields(&mut self, frame: &mut Frame, area: Rect) {
|
||||||
|
let fields = self.visible_fields();
|
||||||
let visible = area.height.saturating_sub(2) as usize;
|
let visible = area.height.saturating_sub(2) as usize;
|
||||||
self.offset = self.offset.min(FIELDS.len().saturating_sub(visible));
|
self.focus = self.focus.min(fields.len().saturating_sub(1));
|
||||||
|
self.offset = self.offset.min(fields.len().saturating_sub(visible));
|
||||||
if self.focus < self.offset {
|
if self.focus < self.offset {
|
||||||
self.offset = self.focus;
|
self.offset = self.focus;
|
||||||
}
|
}
|
||||||
@@ -756,7 +1149,7 @@ impl App {
|
|||||||
self.offset = self.focus.saturating_sub(visible.saturating_sub(1));
|
self.offset = self.focus.saturating_sub(visible.saturating_sub(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
let items = FIELDS
|
let items = fields
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.skip(self.offset)
|
.skip(self.offset)
|
||||||
@@ -785,10 +1178,19 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn draw_preview(&self, frame: &mut Frame, area: Rect) {
|
fn draw_preview(&self, frame: &mut Frame, area: Rect) {
|
||||||
let lines = preview_commands(&self.config)
|
let commands = build_action_commands(&self.config);
|
||||||
.into_iter()
|
let lines = commands
|
||||||
.flat_map(|command| wrap_command(&command, area.width.saturating_sub(4) as usize))
|
.iter()
|
||||||
.map(|line| Line::from(Span::styled(line, Style::default().fg(Color::Green))))
|
.flat_map(|command| {
|
||||||
|
let style = if command.is_dangerous() {
|
||||||
|
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::Green)
|
||||||
|
};
|
||||||
|
wrap_command(&command.display(), area.width.saturating_sub(4) as usize)
|
||||||
|
.into_iter()
|
||||||
|
.map(move |line| Line::from(Span::styled(line, style)))
|
||||||
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let lines = if lines.is_empty() {
|
let lines = if lines.is_empty() {
|
||||||
vec![Line::from("no post-timer actions enabled")]
|
vec![Line::from("no post-timer actions enabled")]
|
||||||
@@ -809,11 +1211,13 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn activate(&mut self) -> Result<()> {
|
fn activate(&mut self) -> Result<()> {
|
||||||
match FIELDS[self.focus].kind {
|
let Some(field) = self.focused_field() else {
|
||||||
FieldKind::Bool => self.toggle_bool(FIELDS[self.focus].key),
|
return Ok(());
|
||||||
FieldKind::Cycle(options) => self.cycle(FIELDS[self.focus].key, options),
|
};
|
||||||
|
match field.kind {
|
||||||
|
FieldKind::Bool => self.toggle_bool(field.key),
|
||||||
|
FieldKind::Cycle(options) => self.cycle(field.key, options),
|
||||||
FieldKind::Edit | FieldKind::Int | FieldKind::Csv | FieldKind::Semi => {
|
FieldKind::Edit | FieldKind::Int | FieldKind::Csv | FieldKind::Semi => {
|
||||||
let field = FIELDS[self.focus];
|
|
||||||
self.edit = Some(EditState {
|
self.edit = Some(EditState {
|
||||||
field,
|
field,
|
||||||
value: self.field_value(&field),
|
value: self.field_value(&field),
|
||||||
@@ -900,23 +1304,55 @@ impl App {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let stop = Arc::new(AtomicBool::new(false));
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
|
let pause = Arc::new(AtomicBool::new(false));
|
||||||
|
let remaining_ms = Arc::new(AtomicU64::new(
|
||||||
|
duration.as_millis().min(u128::from(u64::MAX)) as u64,
|
||||||
|
));
|
||||||
let thread_stop = Arc::clone(&stop);
|
let thread_stop = Arc::clone(&stop);
|
||||||
|
let thread_pause = Arc::clone(&pause);
|
||||||
|
let thread_remaining_ms = Arc::clone(&remaining_ms);
|
||||||
let config = self.config.clone();
|
let config = self.config.clone();
|
||||||
let handle = thread::spawn(move || run_timer(&config, &thread_stop, RunMode::Tui, None));
|
let handle = thread::spawn(move || {
|
||||||
|
run_timer(
|
||||||
|
&config,
|
||||||
|
&thread_stop,
|
||||||
|
&thread_pause,
|
||||||
|
&thread_remaining_ms,
|
||||||
|
RunMode::Tui,
|
||||||
|
TimerOptions::default(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
});
|
||||||
self.timer = Some(TimerState {
|
self.timer = Some(TimerState {
|
||||||
started: Instant::now(),
|
|
||||||
duration,
|
duration,
|
||||||
stop,
|
stop,
|
||||||
|
pause,
|
||||||
|
remaining_ms,
|
||||||
handle,
|
handle,
|
||||||
});
|
});
|
||||||
self.status = "timer running".to_string();
|
self.status = "timer running".to_string();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn toggle_pause(&mut self) {
|
||||||
|
let Some(timer) = &self.timer else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let paused = !timer.pause.load(Ordering::Relaxed);
|
||||||
|
timer.pause.store(paused, Ordering::Relaxed);
|
||||||
|
self.status = if paused {
|
||||||
|
"timer paused".to_string()
|
||||||
|
} else {
|
||||||
|
"timer running".to_string()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
fn stop_timer(&mut self) {
|
fn stop_timer(&mut self) {
|
||||||
if let Some(timer) = self.timer.take() {
|
if let Some(timer) = self.timer.take() {
|
||||||
timer.stop.store(true, Ordering::Relaxed);
|
timer.stop.store(true, Ordering::Relaxed);
|
||||||
|
self.apply_timer_result(timer.handle.join());
|
||||||
|
} else {
|
||||||
|
self.status = "timer stopped".to_string();
|
||||||
}
|
}
|
||||||
self.status = "timer stopped".to_string();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reap_timer(&mut self) {
|
fn reap_timer(&mut self) {
|
||||||
@@ -926,11 +1362,27 @@ impl App {
|
|||||||
.is_some_and(|timer| timer.handle.is_finished())
|
.is_some_and(|timer| timer.handle.is_finished())
|
||||||
{
|
{
|
||||||
let timer = self.timer.take().expect("timer exists");
|
let timer = self.timer.take().expect("timer exists");
|
||||||
match timer.handle.join() {
|
self.apply_timer_result(timer.handle.join());
|
||||||
Ok(Ok(())) => self.status = "timer finished".to_string(),
|
}
|
||||||
Ok(Err(err)) => self.status = format!("timer failed: {err}"),
|
}
|
||||||
Err(_) => self.status = "timer failed: thread panicked".to_string(),
|
|
||||||
|
fn apply_timer_result(&mut self, result: thread::Result<Result<TimerReport>>) {
|
||||||
|
match result {
|
||||||
|
Ok(Ok(report)) => {
|
||||||
|
if let Err(err) = append_history(
|
||||||
|
&history_path(&self.path),
|
||||||
|
report.to_history("finished", None),
|
||||||
|
) {
|
||||||
|
self.status = format!("history failed: {err}");
|
||||||
|
} else {
|
||||||
|
self.status = "timer finished".to_string();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Ok(Err(err)) if err.to_string() == "timer stopped" => {
|
||||||
|
self.status = "timer stopped".to_string()
|
||||||
|
}
|
||||||
|
Ok(Err(err)) => self.status = format!("timer failed: {err}"),
|
||||||
|
Err(_) => self.status = "timer failed: thread panicked".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -938,9 +1390,13 @@ impl App {
|
|||||||
let Some(timer) = &self.timer else {
|
let Some(timer) = &self.timer else {
|
||||||
return (Duration::ZERO, Duration::ZERO, 0.0);
|
return (Duration::ZERO, Duration::ZERO, 0.0);
|
||||||
};
|
};
|
||||||
let elapsed = timer.started.elapsed().min(timer.duration);
|
let remaining = Duration::from_millis(timer.remaining_ms.load(Ordering::Relaxed));
|
||||||
let remaining = timer.duration.saturating_sub(elapsed);
|
let elapsed = timer.duration.saturating_sub(remaining).min(timer.duration);
|
||||||
let progress = elapsed.as_secs_f64() / timer.duration.as_secs_f64();
|
let progress = if timer.duration.is_zero() {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
elapsed.as_secs_f64() / timer.duration.as_secs_f64()
|
||||||
|
};
|
||||||
(elapsed, remaining, progress.clamp(0.0, 1.0))
|
(elapsed, remaining, progress.clamp(0.0, 1.0))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1005,6 +1461,14 @@ impl App {
|
|||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
*current = options[(index + 1) % options.len()].to_string();
|
*current = options[(index + 1) % options.len()].to_string();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn visible_fields(&self) -> Vec<&'static Field> {
|
||||||
|
visible_fields(&self.config)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn focused_field(&self) -> Option<Field> {
|
||||||
|
self.visible_fields().get(self.focus).map(|field| **field)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
@@ -1024,7 +1488,7 @@ enum FieldKind {
|
|||||||
Semi,
|
Semi,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
enum FieldKey {
|
enum FieldKey {
|
||||||
Duration,
|
Duration,
|
||||||
WorkspaceEnabled,
|
WorkspaceEnabled,
|
||||||
@@ -1127,6 +1591,33 @@ const FIELDS: &[Field] = &[
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
fn visible_fields(config: &Config) -> Vec<&'static Field> {
|
||||||
|
FIELDS
|
||||||
|
.iter()
|
||||||
|
.filter(|field| field_visible(config, field.key))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn field_visible(config: &Config, key: FieldKey) -> bool {
|
||||||
|
match key {
|
||||||
|
FieldKey::WorkspaceBackend | FieldKey::WorkspaceNumber => config.actions.workspace.enabled,
|
||||||
|
FieldKey::MediaAction => config.actions.media.enabled,
|
||||||
|
FieldKey::BrightnessValue => config.actions.brightness.enabled,
|
||||||
|
FieldKey::LockCommand => config.actions.lock.enabled,
|
||||||
|
FieldKey::KillProcesses => config.actions.kill.enabled,
|
||||||
|
FieldKey::CustomCommands => config.actions.custom.enabled,
|
||||||
|
FieldKey::Duration
|
||||||
|
| FieldKey::WorkspaceEnabled
|
||||||
|
| FieldKey::MediaEnabled
|
||||||
|
| FieldKey::BrightnessEnabled
|
||||||
|
| FieldKey::MuteEnabled
|
||||||
|
| FieldKey::LockEnabled
|
||||||
|
| FieldKey::KillEnabled
|
||||||
|
| FieldKey::PowerMode
|
||||||
|
| FieldKey::CustomEnabled => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn on_off(value: bool) -> String {
|
fn on_off(value: bool) -> String {
|
||||||
if value { "on" } else { "off" }.to_string()
|
if value { "on" } else { "off" }.to_string()
|
||||||
}
|
}
|
||||||
@@ -1179,6 +1670,37 @@ fn format_gauge_label(progress: f64) -> String {
|
|||||||
format!("{:>3}%", (progress * 100.0).round() as u16)
|
format!("{:>3}%", (progress * 100.0).round() as u16)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn ascii_time(value: &str) -> Vec<String> {
|
||||||
|
let mut rows = vec![String::new(); 5];
|
||||||
|
for ch in value.chars() {
|
||||||
|
let glyph = ascii_glyph(ch);
|
||||||
|
for (row, part) in rows.iter_mut().zip(glyph) {
|
||||||
|
if !row.is_empty() {
|
||||||
|
row.push(' ');
|
||||||
|
}
|
||||||
|
row.push_str(part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ascii_glyph(ch: char) -> [&'static str; 5] {
|
||||||
|
match ch {
|
||||||
|
'0' => [" ### ", "# #", "# #", "# #", " ### "],
|
||||||
|
'1' => [" # ", " ## ", " # ", " # ", " ### "],
|
||||||
|
'2' => [" ### ", "# #", " # ", " # ", "#####"],
|
||||||
|
'3' => ["#### ", " #", " ### ", " #", "#### "],
|
||||||
|
'4' => ["# #", "# #", "#####", " #", " #"],
|
||||||
|
'5' => ["#####", "# ", "#### ", " #", "#### "],
|
||||||
|
'6' => [" ### ", "# ", "#### ", "# #", " ### "],
|
||||||
|
'7' => ["#####", " #", " # ", " # ", " # "],
|
||||||
|
'8' => [" ### ", "# #", " ### ", "# #", " ### "],
|
||||||
|
'9' => [" ### ", "# #", " ####", " #", " ### "],
|
||||||
|
':' => [" ", " # ", " ", " # ", " "],
|
||||||
|
_ => [" ", " ", " ", " ", " "],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn wrap_command(command: &str, width: usize) -> Vec<String> {
|
fn wrap_command(command: &str, width: usize) -> Vec<String> {
|
||||||
let width = width.max(8);
|
let width = width.max(8);
|
||||||
let mut lines = Vec::new();
|
let mut lines = Vec::new();
|
||||||
@@ -1280,7 +1802,16 @@ mod tests {
|
|||||||
let stop = Arc::new(AtomicBool::new(false));
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
let config = quiet_config("1ns");
|
let config = quiet_config("1ns");
|
||||||
|
|
||||||
run_timer(&config, &stop, RunMode::Tui, None).unwrap();
|
run_timer(
|
||||||
|
&config,
|
||||||
|
&stop,
|
||||||
|
&Arc::new(AtomicBool::new(false)),
|
||||||
|
&Arc::new(AtomicU64::new(0)),
|
||||||
|
RunMode::Tui,
|
||||||
|
TimerOptions::default(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1289,7 +1820,16 @@ mod tests {
|
|||||||
let config = quiet_config("10ms");
|
let config = quiet_config("10ms");
|
||||||
let mut output = Vec::new();
|
let mut output = Vec::new();
|
||||||
|
|
||||||
run_timer(&config, &stop, RunMode::Cli, Some(&mut output)).unwrap();
|
run_timer(
|
||||||
|
&config,
|
||||||
|
&stop,
|
||||||
|
&Arc::new(AtomicBool::new(false)),
|
||||||
|
&Arc::new(AtomicU64::new(0)),
|
||||||
|
RunMode::Cli,
|
||||||
|
TimerOptions::default(),
|
||||||
|
Some(&mut output),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let text = String::from_utf8(output).unwrap();
|
let text = String::from_utf8(output).unwrap();
|
||||||
assert!(text.contains("timer started: 10ms"));
|
assert!(text.contains("timer started: 10ms"));
|
||||||
@@ -1297,6 +1837,50 @@ mod tests {
|
|||||||
assert!(text.contains("finished"));
|
assert!(text.contains("finished"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_timer_attempts_all_actions_before_returning_failures() {
|
||||||
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
|
let mut config = quiet_config("1ns");
|
||||||
|
config.actions.custom.enabled = true;
|
||||||
|
config.actions.custom.commands = vec!["false".to_string(), "true".to_string()];
|
||||||
|
let mut output = Vec::new();
|
||||||
|
|
||||||
|
let error = run_timer(
|
||||||
|
&config,
|
||||||
|
&stop,
|
||||||
|
&Arc::new(AtomicBool::new(false)),
|
||||||
|
&Arc::new(AtomicU64::new(0)),
|
||||||
|
RunMode::Cli,
|
||||||
|
TimerOptions::default(),
|
||||||
|
Some(&mut output),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
let text = String::from_utf8(output).unwrap();
|
||||||
|
assert!(text.contains("run: false"));
|
||||||
|
assert!(text.contains("run: true"));
|
||||||
|
assert!(error.to_string().contains("1 action(s) failed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ensure_config_validates_loaded_config() {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"gosleep-timer-invalid-{}-{}.yaml",
|
||||||
|
std::process::id(),
|
||||||
|
"power"
|
||||||
|
));
|
||||||
|
fs::write(
|
||||||
|
&path,
|
||||||
|
"duration: 25m\nactions:\n power:\n mode: suspend\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let error = ensure_config(&path).unwrap_err();
|
||||||
|
fs::remove_file(path).ok();
|
||||||
|
|
||||||
|
assert!(error.to_string().contains("invalid power mode"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn timer_header_text_compacts_for_narrow_widths() {
|
fn timer_header_text_compacts_for_narrow_widths() {
|
||||||
let remaining = Duration::from_secs(65);
|
let remaining = Duration::from_secs(65);
|
||||||
@@ -1323,13 +1907,62 @@ mod tests {
|
|||||||
assert_eq!(format_gauge_label(1.0), "100%");
|
assert_eq!(format_gauge_label(1.0), "100%");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn visible_fields_hide_disabled_action_settings() {
|
||||||
|
let mut config = quiet_config("25m");
|
||||||
|
|
||||||
|
let keys = visible_field_keys(&config);
|
||||||
|
assert!(keys.contains(&FieldKey::Duration));
|
||||||
|
assert!(keys.contains(&FieldKey::MediaEnabled));
|
||||||
|
assert!(!keys.contains(&FieldKey::MediaAction));
|
||||||
|
assert!(!keys.contains(&FieldKey::WorkspaceBackend));
|
||||||
|
assert!(!keys.contains(&FieldKey::WorkspaceNumber));
|
||||||
|
assert!(!keys.contains(&FieldKey::BrightnessValue));
|
||||||
|
assert!(!keys.contains(&FieldKey::LockCommand));
|
||||||
|
assert!(!keys.contains(&FieldKey::KillProcesses));
|
||||||
|
assert!(!keys.contains(&FieldKey::CustomCommands));
|
||||||
|
|
||||||
|
config.actions.media.enabled = true;
|
||||||
|
config.actions.workspace.enabled = true;
|
||||||
|
config.actions.brightness.enabled = true;
|
||||||
|
config.actions.lock.enabled = true;
|
||||||
|
config.actions.kill.enabled = true;
|
||||||
|
config.actions.custom.enabled = true;
|
||||||
|
|
||||||
|
let keys = visible_field_keys(&config);
|
||||||
|
assert!(keys.contains(&FieldKey::MediaAction));
|
||||||
|
assert!(keys.contains(&FieldKey::WorkspaceBackend));
|
||||||
|
assert!(keys.contains(&FieldKey::WorkspaceNumber));
|
||||||
|
assert!(keys.contains(&FieldKey::BrightnessValue));
|
||||||
|
assert!(keys.contains(&FieldKey::LockCommand));
|
||||||
|
assert!(keys.contains(&FieldKey::KillProcesses));
|
||||||
|
assert!(keys.contains(&FieldKey::CustomCommands));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visible_field_keys(config: &Config) -> Vec<FieldKey> {
|
||||||
|
visible_fields(config)
|
||||||
|
.into_iter()
|
||||||
|
.map(|field| field.key)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn dummy_timer_state() -> TimerState {
|
fn dummy_timer_state() -> TimerState {
|
||||||
let stop = Arc::new(AtomicBool::new(false));
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
let handle = thread::spawn(|| Ok(()));
|
let handle = thread::spawn(|| {
|
||||||
|
Ok(TimerReport {
|
||||||
|
started_at: 0,
|
||||||
|
duration: Duration::from_secs(1),
|
||||||
|
actions: Vec::new(),
|
||||||
|
failures: Vec::new(),
|
||||||
|
dry_run: false,
|
||||||
|
no_actions: false,
|
||||||
|
})
|
||||||
|
});
|
||||||
TimerState {
|
TimerState {
|
||||||
started: Instant::now(),
|
|
||||||
duration: Duration::from_secs(1),
|
duration: Duration::from_secs(1),
|
||||||
stop,
|
stop,
|
||||||
|
pause: Arc::new(AtomicBool::new(false)),
|
||||||
|
remaining_ms: Arc::new(AtomicU64::new(1000)),
|
||||||
handle,
|
handle,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user