diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65a3270..ed503f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: os: [ubuntu-22.04, macos-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: ./.github/actions/setup-rust with: cache-key: test @@ -63,7 +63,7 @@ jobs: artifact: windows runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: ./.github/actions/setup-rust with: cache-key: release @@ -79,7 +79,7 @@ jobs: - name: Bundle tinyclient.app if: matrix.artifact == 'macos' run: packaging/bundle.sh --no-build - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v6 if: matrix.artifact == 'macos' with: name: tinyclient-macos @@ -94,7 +94,7 @@ jobs: desktop-file-validate packaging/linux/dev.tinyclient.app.desktop dpkg-deb --info dist/tinyclient-ci-linux-x86_64.deb dpkg-deb --contents dist/tinyclient-ci-linux-x86_64.deb - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v6 if: matrix.artifact == 'linux-x86_64' with: name: tinyclient-linux-x86_64 @@ -110,7 +110,7 @@ jobs: target/release/mediad.exe, ` target/release/tinyclient-login.exe ` -DestinationPath tinyclient-windows-x86_64.zip - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v6 if: matrix.artifact == 'windows' with: name: tinyclient-windows-x86_64 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b17e966..d6a3873 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,7 +29,7 @@ jobs: macos: runs-on: macos-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Name the files run: packaging/ci-label.sh >> "$GITHUB_ENV" - name: Reuse binaries built by CI @@ -72,7 +72,7 @@ jobs: test "$out" = "$program $VERSION" done hdiutil detach "$mnt" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v6 with: name: release-macos path: dist/* @@ -91,7 +91,7 @@ jobs: artifact: linux-aarch64 runs-on: ${{ matrix.runner }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Name the files run: packaging/ci-label.sh >> "$GITHUB_ENV" - name: Reuse binaries built by CI @@ -120,7 +120,7 @@ jobs: run: | eval "$(packaging/linux/tools.sh)" packaging/linux.sh --no-build "$LABEL" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v6 with: name: release-linux-${{ matrix.arch }} path: dist/* @@ -167,10 +167,10 @@ jobs: kind: rpm runs-on: ${{ matrix.runner }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Name the files run: packaging/ci-label.sh >> "$GITHUB_ENV" - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v7 with: name: release-linux-${{ matrix.arch }} path: dist @@ -180,7 +180,7 @@ jobs: windows: runs-on: windows-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Name the files shell: bash run: packaging/ci-label.sh >> "$GITHUB_ENV" @@ -215,7 +215,7 @@ jobs: - name: Smoke test the installer and the zip shell: pwsh run: packaging/windows/smoke.ps1 -Label $env:LABEL -Version $env:VERSION - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v6 with: name: release-windows path: dist/* @@ -226,10 +226,10 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Name the files run: packaging/ci-label.sh >> "$GITHUB_ENV" - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v7 with: pattern: release-* path: dist @@ -246,11 +246,11 @@ jobs: sha256sum tinyclient-* > SHA256SUMS sha256sum -c SHA256SUMS ls -l - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v6 with: name: release-all path: dist/* - - uses: softprops/action-gh-release@v2 + - uses: softprops/action-gh-release@v3 if: github.ref_type == 'tag' && (github.event_name == 'push' || inputs.publish) with: files: dist/* diff --git a/.github/workflows/site.yml b/.github/workflows/site.yml index 333d2d7..65de0fc 100644 --- a/.github/workflows/site.yml +++ b/.github/workflows/site.yml @@ -19,7 +19,7 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 # The site promises it loads nothing from anywhere else; fail the deploy if a page would. - name: No third-party requests run: | diff --git a/crates/chatd/src/adapters/teams/message.rs b/crates/chatd/src/adapters/teams/message.rs index 9526714..2f53d78 100644 --- a/crates/chatd/src/adapters/teams/message.rs +++ b/crates/chatd/src/adapters/teams/message.rs @@ -136,6 +136,17 @@ fn card_title(card: &Value, depth: u8) -> Option { } } +/// The shell's `client_id` on a copy of a message we sent. Teams names it `clientmessageid`, +/// as text or as a number, and the store replaces the pending row only when it comes back on +/// the message. Someone else's copy, or an id that is already the message's own, is not one. +fn echoed_client_id(raw: &Value, outgoing: bool, id: &str) -> Option { + if !outgoing { + return None; + } + let echoed = id_of(&raw["clientmessageid"]); + (!echoed.is_empty() && echoed != id).then_some(echoed) +} + /// `Event/Call`: `125`. fn call_line(content: &str) -> String { let longest = inside(content, "duration").iter().filter_map(|text| text.trim().parse::().ok()).fold(0., f64::max); @@ -446,6 +457,7 @@ impl Teams { message.text = "Unsupported message".to_owned(); message.kind = MessageKind::Unsupported { link: self.message_link(conversation, &id) }; } + message.client_id = echoed_client_id(raw, message.outgoing, &id); Read::Message(Box::new(message)) } @@ -889,6 +901,18 @@ mod tests { assert_eq!((attached.thumb.as_ref().map(|thumb| thumb.id.clone()), media.len()), (Some(attached.file.id.clone()), 1)); } + #[test] + fn a_copy_of_our_message_keeps_the_client_id_teams_echoes() { + let raw = json!({"id": "1760001000000", "clientmessageid": "1760000999000111"}); + assert_eq!(echoed_client_id(&raw, true, "1760001000000").as_deref(), Some("1760000999000111")); + // Teams writes the same id as a number in some payloads. + let numbered = json!({"id": "1760001000000", "clientmessageid": 1760000999000111u64}); + assert_eq!(echoed_client_id(&numbered, true, "1760001000000").as_deref(), Some("1760000999000111")); + assert_eq!(echoed_client_id(&raw, false, "1760001000000"), None); + let same = json!({"id": "1760001000000", "clientmessageid": "1760001000000"}); + assert_eq!(echoed_client_id(&same, true, "1760001000000"), None); + } + #[test] fn reaction_keys_read_as_emoji_and_back() { assert_eq!((emoji_of("like"), emoji_of("heart"), emoji_of("sad")), ("👍".to_owned(), "❤️".to_owned(), "🙁".to_owned())); diff --git a/crates/mediad/tests/play.rs b/crates/mediad/tests/play.rs index faa95ba..07abe13 100644 --- a/crates/mediad/tests/play.rs +++ b/crates/mediad/tests/play.rs @@ -2,7 +2,7 @@ //! replaced by a file (`TINYCLIENT_MEDIAD_SINK`) that drains at a device's pace. use protocol::{read_frame, write_frame, PlayerEvent, PlayerRequest}; -use std::io::BufReader; +use std::io::{BufReader, Write}; use std::path::{Path, PathBuf}; use std::process::{Child, ChildStdout, Command, Stdio}; use std::time::{Duration, Instant}; @@ -11,6 +11,36 @@ fn fixture(name: &str) -> String { Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures").join(name).display().to_string() } +/// A silent WAV of `seconds`. The fixtures are about a second long, which a busy machine can +/// play out before the thread reading stdin is scheduled, so a pause test needs a longer one. +fn silence(path: &Path, seconds: u32) { + let rate = 48_000u32; + let channels = 2u16; + let data = rate * seconds * u32::from(channels) * 2; + let mut file = std::fs::File::create(path).unwrap(); + let mut head = Vec::with_capacity(44); + head.extend_from_slice(b"RIFF"); + head.extend_from_slice(&(36 + data).to_le_bytes()); + head.extend_from_slice(b"WAVEfmt "); + head.extend_from_slice(&16u32.to_le_bytes()); + head.extend_from_slice(&1u16.to_le_bytes()); + head.extend_from_slice(&channels.to_le_bytes()); + head.extend_from_slice(&rate.to_le_bytes()); + head.extend_from_slice(&(rate * u32::from(channels) * 2).to_le_bytes()); + head.extend_from_slice(&(channels * 2).to_le_bytes()); + head.extend_from_slice(&16u16.to_le_bytes()); + head.extend_from_slice(b"data"); + head.extend_from_slice(&data.to_le_bytes()); + file.write_all(&head).unwrap(); + let piece = vec![0u8; 48_000]; + let mut left = data; + while left > 0 { + let n = (left as usize).min(piece.len()); + file.write_all(&piece[..n]).unwrap(); + left -= n as u32; + } +} + struct Player { child: Child, out: BufReader, @@ -74,16 +104,21 @@ fn a_voice_message_plays_to_its_end_and_the_process_goes() { #[test] fn closing_stdin_pauses_and_says_where() { - let mut player = Player::open("pause", &fixture("tone.opus"), 0, 100); - assert!(matches!(player.next(), PlayerEvent::Playing { .. })); + let wav = std::env::temp_dir().join(format!("tinyclient-play-{}-pause.wav", std::process::id())); + silence(&wav, 30); + let mut player = Player::open("pause", &wav.display().to_string(), 0, 100); + let PlayerEvent::Playing { duration, .. } = player.next() else { panic!("not playing") }; + assert!(duration >= 30_000, "{duration} ms"); let playing = Instant::now(); std::thread::sleep(Duration::from_millis(400)); // Measured, not assumed: a busy machine stretches the sleep, and the sound plays on meanwhile. let played = playing.elapsed().as_millis() as u32; drop(player.child.stdin.take()); - let PlayerEvent::Stopped { at } = player.next() else { panic!("did not stop") }; + let event = player.next(); + let PlayerEvent::Stopped { at } = event else { panic!("did not stop: {event:?}") }; assert!((played.saturating_sub(100)..=played + 100).contains(&at), "stopped at {at} ms after {played} ms of playing"); player.exits_cleanly(); + let _ = std::fs::remove_file(&wav); } #[test]