Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 3 additions & 5 deletions PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ NoteDeck の開発者はサーバーを一切運営していません。ユー

- **Misskey アクセストークン** (各サーバー / 各アカウントごと)
- **AI プロバイダーの API キー** (Secret Vault 経由、接続単位で格納)
- **WebPush 通知用 VAPID キー** (Push 通知有効時のみ)

格納先:

Expand Down Expand Up @@ -61,11 +60,10 @@ Linux の暗号化ファイルは、同じ OS ユーザーの他プロセスか

NoteDeck が行う外部通信は以下に限定されます:

1. **Misskey サーバー** — REST / WebSocket ストリーミング、画像取得
1. **Misskey サーバー** — REST / WebSocket ストリーミング、画像取得。Android ではアプリを閉じている間も、通知の確認のために同じサーバーへ定期的に REST で接続します
2. **AI プロバイダー** — AI チャット使用時のみ。Anthropic Messages API / OpenAI Chat Completions API 互換
3. **WebPush エンドポイント** — Push 通知有効化時のみ
4. **アップデートチェック** — `https://github.com/notedeck-dev/notedeck/releases/latest` (Tauri Updater)
5. **OGP 取得** — ノート内 URL の OGP 情報取得 (設定で無効化可能)
3. **アップデートチェック** — `https://github.com/notedeck-dev/notedeck/releases/latest` (Tauri Updater)
4. **OGP 取得** — ノート内 URL の OGP 情報取得 (設定で無効化可能)

### NoteDeck が行わない通信

Expand Down
76 changes: 68 additions & 8 deletions crates/notecli/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -980,14 +980,17 @@ impl MisskeyClient {
.collect())
}

/// 通知を取得する。`mark_as_read` は本家の `markAsRead` (既定 true = 取得しただけで
/// 全部既読) を明示で送る。アプリは「通知カラムを見たとき」の取得だけ true にする
pub async fn get_notifications(
&self,
host: &str,
token: &str,
account_id: &str,
options: TimelineOptions,
mark_as_read: bool,
) -> Result<Vec<NormalizedNotification>, NoteDeckError> {
let mut params = json!({ "limit": options.limit() });
let mut params = json!({ "limit": options.limit(), "markAsRead": mark_as_read });
apply_pagination(
&mut params,
options.since_id.as_deref(),
Expand All @@ -1007,8 +1010,9 @@ impl MisskeyClient {
token: &str,
account_id: &str,
options: TimelineOptions,
mark_as_read: bool,
) -> Result<Vec<NormalizedNotification>, NoteDeckError> {
let mut params = json!({ "limit": options.limit() });
let mut params = json!({ "limit": options.limit(), "markAsRead": mark_as_read });
apply_pagination(
&mut params,
options.since_id.as_deref(),
Expand Down Expand Up @@ -1504,15 +1508,18 @@ impl MisskeyClient {

// --- Notifications ---

/// 未読の通知数。本家に専用のエンドポイントは無く、`i` の
/// `unreadNotificationsCount` (既読位置より後の件数) で取る
pub async fn get_unread_notification_count(
&self,
host: &str,
token: &str,
) -> Result<i64, NoteDeckError> {
let data = self
.request(host, token, "notifications/unread-count", json!({}))
.await?;
Ok(data.get("count").and_then(|v| v.as_i64()).unwrap_or(0))
let data = self.request(host, token, "i", json!({})).await?;
Ok(data
.get("unreadNotificationsCount")
.and_then(|v| v.as_i64())
.unwrap_or(0))
}

pub async fn mark_all_notifications_as_read(
Expand Down Expand Up @@ -3087,14 +3094,67 @@ mod tests {

let client = MisskeyClient::with_base_url(&server.uri());
let notifs = client
.get_notifications("h", "token", "acc1", TimelineOptions::default())
.get_notifications("h", "token", "acc1", TimelineOptions::default(), false)
.await
.unwrap();
assert_eq!(notifs.len(), 1);
assert_eq!(notifs[0].notification_type, "reaction");
assert_eq!(notifs[0].reaction.as_deref(), Some(":star:"));
}

/// 通知の取得は既読にするかを明示で送る。本家の既定は true (取得しただけで
/// 全部既読) なので、アプリは false を明示し、見たときの取得だけ true にする
#[tokio::test]
async fn get_notifications_sends_mark_as_read_explicitly() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/i/notifications"))
.and(body_partial_json(json!({ "markAsRead": false })))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/i/notifications-grouped"))
.and(body_partial_json(json!({ "markAsRead": true })))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
.expect(1)
.mount(&server)
.await;

let client = MisskeyClient::with_base_url(&server.uri());
client
.get_notifications("h", "token", "acc1", TimelineOptions::default(), false)
.await
.unwrap();
client
.get_notifications_grouped("h", "token", "acc1", TimelineOptions::default(), true)
.await
.unwrap();
}

/// 未読数は `i` の unreadNotificationsCount から取る (専用エンドポイントは本家に無い)
#[tokio::test]
async fn get_unread_notification_count_reads_i() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/i"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "u1",
"unreadNotificationsCount": 3,
"hasUnreadNotification": true
})))
.mount(&server)
.await;

let client = MisskeyClient::with_base_url(&server.uri());
let count = client
.get_unread_notification_count("h", "token")
.await
.unwrap();
assert_eq!(count, 3);
}

#[tokio::test]
async fn get_notifications_parses_role_assigned() {
let server = MockServer::start().await;
Expand All @@ -3119,7 +3179,7 @@ mod tests {

let client = MisskeyClient::with_base_url(&server.uri());
let notifs = client
.get_notifications("h", "token", "acc1", TimelineOptions::default())
.get_notifications("h", "token", "acc1", TimelineOptions::default(), false)
.await
.unwrap();
assert_eq!(notifs.len(), 1);
Expand Down
3 changes: 3 additions & 0 deletions crates/notecli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,9 @@ pub enum Commands {
/// 取得する通知数 (1-100)
#[arg(long, short, default_value_t = 20)]
limit: i64,
/// 取得しても既読にしない (既定では本家と同じく取得した時点で全部既読になる)
#[arg(long)]
keep_unread: bool,
},

/// メンション一覧を取得
Expand Down
4 changes: 3 additions & 1 deletion crates/notecli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,9 @@ pub async fn run_cli(
Commands::React { note_id, reaction } => notes::run_react(&ctx, note_id, reaction).await,
Commands::Unreact { note_id } => notes::run_unreact(&ctx, note_id).await,
Commands::Renote { note_id } => notes::run_renote(&ctx, note_id).await,
Commands::Notifications { limit } => notes::run_notifications(&ctx, *limit).await,
Commands::Notifications { limit, keep_unread } => {
notes::run_notifications(&ctx, *limit, *keep_unread).await
}
Commands::Mentions { limit } => notes::run_mentions(&ctx, *limit).await,
Commands::Favorite { note_id } => notes::run_favorite(&ctx, note_id).await,
Commands::Unfavorite { note_id } => notes::run_unfavorite(&ctx, note_id).await,
Expand Down
7 changes: 6 additions & 1 deletion crates/notecli/src/commands/notes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,14 +215,19 @@ pub async fn run_renote(ctx: &CmdContext, note_id: &str) -> Result<(), NoteDeckE
Ok(())
}

pub async fn run_notifications(ctx: &CmdContext, limit: i64) -> Result<(), NoteDeckError> {
pub async fn run_notifications(
ctx: &CmdContext,
limit: i64,
keep_unread: bool,
) -> Result<(), NoteDeckError> {
let notifications = ctx
.client
.get_notifications(
&ctx.host,
&ctx.token,
&ctx.account.id,
TimelineOptions::new(limit, None, None),
!keep_unread,
)
.await?;
print_notifications(&notifications, ctx.fmt);
Expand Down
2 changes: 1 addition & 1 deletion crates/notecli/src/http_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,7 @@ async fn get_notifications(
let options = opts.into_timeline_options();
let notifications = state
.client
.get_notifications(&h, &token, &account_id, options)
.get_notifications(&h, &token, &account_id, options, false)
.await?;
Ok(Json(notifications))
}
Expand Down
53 changes: 51 additions & 2 deletions crates/notecli/src/streaming.rs
Original file line number Diff line number Diff line change
Expand Up @@ -352,9 +352,16 @@ enum WsCommand {
kind: String,
body: Value,
},
/// 通知を全部既読にする (`readNotification`、本家 Web が可視時の到着で送るもの)
ReadNotification,
Shutdown,
}

/// `readNotification` のフレーム。本家は body を見ず、その口座の通知を全部既読にする
fn read_notification_frame() -> Value {
json!({ "type": "readNotification" })
}

/// チャンネルへのメッセージの WS フレーム (misskey-js の `Connection.send` と同じ形)
fn channel_message_frame(id: &str, kind: &str, body: Value) -> Value {
json!({ "type": "ch", "body": { "id": id, "type": kind, "body": body } })
Expand Down Expand Up @@ -1002,6 +1009,21 @@ impl StreamingManager {
Ok(())
}

/// 口座の通知を全部既読にする (`readNotification`)。本家 Web UI が可視ウィンドウへの
/// 通知到着時に送るものと同じで、権限の検査が無いので既存のトークンで通る。
/// 接続が無い (polling) ときは何もしない: その場合は見たときの取得
/// (`markAsRead: true`) が既読にする
pub async fn read_notifications(&self, account_id: &str) -> Result<(), NoteDeckError> {
let conns = self.connections.lock().await;
if let Some(handle) = conns.get(account_id) {
return handle
.cmd_tx
.send(WsCommand::ReadNotification)
.map_err(|_| NoteDeckError::ConnectionClosed);
}
Ok(())
}

/// 会話のチャンネル (chatUser / chatRoom) で既読をサーバーに送る。本家 Web UI が
/// 会話を開いている間に届いた他人のメッセージごとに送る `read` と同じで、サーバーは
/// 相手 / ルーム単位で既読にする (ChatService.readUserChatMessage / readRoomChatMessage)。
Expand Down Expand Up @@ -1462,6 +1484,13 @@ async fn ws_loop(
tracing::warn!(error = %e, "channel message send failed");
}
}
Some(WsCommand::ReadNotification) => {
let msg = read_notification_frame();
let mut w = write.lock().await;
if let Err(e) = w.send(Message::Text(msg.to_string().into())).await {
tracing::warn!(error = %e, "readNotification send failed");
}
}
Some(WsCommand::Shutdown) | None => {
let mut w = write.lock().await;
let _ = w.close().await;
Expand Down Expand Up @@ -2124,6 +2153,7 @@ async fn polling_loop(
&token,
&account_id,
TimelineOptions::new(1, None, None),
false,
)
.await;
let mention_head = api_client
Expand Down Expand Up @@ -2161,6 +2191,7 @@ async fn polling_loop(
&token,
&account_id,
TimelineOptions::new(30, main_state.notif_since_id.clone(), None),
false,
)
.await
{
Expand Down Expand Up @@ -2688,6 +2719,22 @@ mod tests {
}
}

/// 本家 Web UI が可視ウィンドウへの通知到着時に送る `readNotification` と同じ
/// トップレベルメッセージを main の接続に送る。接続が無ければ (polling) 何もしない
#[tokio::test]
async fn read_notifications_sends_read_notification_frame() {
let (manager, mut cmd_rx, _dir) = manager_with_fake_ws().await;
manager.read_notifications("acc-1").await.unwrap();
assert!(matches!(cmd_rx.try_recv(), Ok(WsCommand::ReadNotification)));
// 接続の無い口座は no-op
manager.read_notifications("acc-none").await.unwrap();
assert!(cmd_rx.try_recv().is_err());
assert_eq!(
read_notification_frame(),
json!({ "type": "readNotification" })
);
}

#[tokio::test]
async fn read_chat_rejects_non_chat_subscription() {
let (manager, mut cmd_rx, _dir) = manager_with_fake_ws().await;
Expand Down Expand Up @@ -3228,7 +3275,7 @@ mod tests {
// 初回 prime (limit 1・sinceId なし): 既存分を返す。emit されないこと
Mock::given(method("POST"))
.and(path("/api/i/notifications"))
.and(body_partial_json(json!({"limit": 1})))
.and(body_partial_json(json!({"limit": 1, "markAsRead": false})))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(json!([raw_notification_json("notif-old")])),
Expand All @@ -3246,7 +3293,9 @@ mod tests {
// 2 回目以降: prime で確立したカーソル付きで新着を返す
Mock::given(method("POST"))
.and(path("/api/i/notifications"))
.and(body_partial_json(json!({"sinceId": "notif-old"})))
.and(body_partial_json(
json!({"sinceId": "notif-old", "markAsRead": false}),
))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(json!([raw_notification_json("notif-new")])),
Expand Down
3 changes: 3 additions & 0 deletions crates/notecore/src/auth_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ pub const DEFAULT_MIAUTH_PERMISSIONS: &[&str] = &[
"read:account",
"write:account",
"read:notifications",
// 明示の「全部既読」(notifications/mark-all-as-read) に要る。以前は要求して
// おらず、権限不足を握っていた
"write:notifications",
"read:reactions",
"read:favorites",
"read:drive",
Expand Down
7 changes: 7 additions & 0 deletions crates/notecore/src/commands/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,10 @@ pub fn replace_database_file(app_dir: &std::path::Path, src: &std::path::Path) -

/// アカウントを削除する (資格情報の失効を含む認可境界、表の種別 authz)
pub async fn delete_account(core: &Core, id: String) -> Result<()> {
// 口座セッションが張った main を含め、その口座の接続を先に落とす (#330 §3.1)
if let Ok(streaming) = core.streaming() {
streaming.disconnect(&id).await;
}
let db = core.db().await;
crate::account_service::delete(&db, &id)?;
crate::commands::export_account_list(core, &db);
Expand All @@ -195,6 +199,9 @@ pub async fn delete_account(core: &Core, id: String) -> Result<()> {

/// ログアウト: トークンだけ消し、アカウントの記録とカラムは残す
pub async fn logout_account(core: &Core, id: String) -> Result<()> {
if let Ok(streaming) = core.streaming() {
streaming.disconnect(&id).await;
}
let db = core.db().await;
crate::account_service::logout(&db, &id)?;
crate::commands::export_account_list(core, &db);
Expand Down
4 changes: 4 additions & 0 deletions crates/notecore/src/commands/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ pub async fn auth_complete_and_save(
auth_service::complete_and_save(&db, &client, &session.host, &session.session_id, software)
.await?;
crate::commands::export_account_list(core, &db);
// 口座の追加に追随して通知を配る main を張る (#330 §3.1)。張れなくても追加は成功
if let Err(e) = crate::commands::streaming::subscribe_main_for_account(core, &saved.id).await {
tracing::warn!(account = saved.id, "[stream] main after login: {e}");
}
Ok(saved)
}

Expand Down
Loading
Loading