chatdeck 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chatdeck/__init__.py +2 -0
- chatdeck/__main__.py +3 -0
- chatdeck/autostart.py +91 -0
- chatdeck/cli.py +152 -0
- chatdeck/config.py +34 -0
- chatdeck/demo.py +141 -0
- chatdeck/desktop.py +93 -0
- chatdeck/hooks.py +131 -0
- chatdeck/server.py +345 -0
- chatdeck/static/index.html +342 -0
- chatdeck/static/logo-192.png +0 -0
- chatdeck/static/logo-512.png +0 -0
- chatdeck/static/logo.ico +0 -0
- chatdeck/static/logo.svg +13 -0
- chatdeck/terminals.py +218 -0
- chatdeck/transcripts.py +216 -0
- chatdeck/usage.py +96 -0
- chatdeck/windows/bubble.ps1 +303 -0
- chatdeck/windows/focus_wt.ps1 +68 -0
- chatdeck/windows/start.ps1 +35 -0
- chatdeck-0.1.0.dist-info/METADATA +164 -0
- chatdeck-0.1.0.dist-info/RECORD +26 -0
- chatdeck-0.1.0.dist-info/WHEEL +5 -0
- chatdeck-0.1.0.dist-info/entry_points.txt +2 -0
- chatdeck-0.1.0.dist-info/licenses/LICENSE +21 -0
- chatdeck-0.1.0.dist-info/top_level.txt +1 -0
chatdeck/transcripts.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Reading Claude Code transcripts (~/.claude/projects/<project>/<session>.jsonl).
|
|
2
|
+
|
|
3
|
+
The index reads each file incrementally: it remembers how far it got and only parses
|
|
4
|
+
lines appended since, so a 30 MB chat that grows by one message costs one message."""
|
|
5
|
+
import json, os, re, threading
|
|
6
|
+
|
|
7
|
+
UUID = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
|
|
8
|
+
TAG = re.compile(r"<(system-reminder|local-command-[a-z]+|command-[a-z]+)>.*?</\1>", re.S)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _user_text(c):
|
|
12
|
+
"""Text the person typed, or None for tool results, command output and injected context."""
|
|
13
|
+
if isinstance(c, str):
|
|
14
|
+
return None if c.lstrip().startswith("<") else c.strip() or None
|
|
15
|
+
if isinstance(c, list):
|
|
16
|
+
parts = [b.get("text", "") for b in c if isinstance(b, dict) and b.get("type") == "text"]
|
|
17
|
+
t = TAG.sub("", "\n".join(parts)).strip()
|
|
18
|
+
return t or None
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _assistant_text(c):
|
|
23
|
+
if not isinstance(c, list): return ""
|
|
24
|
+
return "\n".join(b.get("text", "") for b in c if isinstance(b, dict) and b.get("type") == "text").strip()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Entry:
|
|
28
|
+
__slots__ = ("ino", "offset", "size", "mtime", "custom", "ai", "summary", "first", "cwd", "ctx", "msgs",
|
|
29
|
+
"days", "seen", "cost", "text", "text_lc", "cwds")
|
|
30
|
+
|
|
31
|
+
def __init__(self, ino):
|
|
32
|
+
self.ino = ino; self.offset = 0; self.size = 0; self.mtime = 0.0
|
|
33
|
+
self.custom = self.ai = self.summary = self.first = self.cwd = None
|
|
34
|
+
self.ctx = self.msgs = 0; self.days = {}; self.seen = set(); self.cost = 0.0
|
|
35
|
+
self.text = []; self.text_lc = ""; self.cwds = {} # folder -> lines written there, to tell where the chat mostly worked
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def title(self):
|
|
39
|
+
return self.custom or self.ai or self.summary or self.first
|
|
40
|
+
|
|
41
|
+
def feed(self, d):
|
|
42
|
+
t = d.get("type")
|
|
43
|
+
if t == "custom-title" and d.get("customTitle"): self.custom = d["customTitle"]
|
|
44
|
+
elif t == "ai-title" and d.get("aiTitle"): self.ai = d["aiTitle"]
|
|
45
|
+
elif t == "summary" and d.get("summary"): self.summary = d["summary"]
|
|
46
|
+
elif t == "cost-state": self.cost = float(d.get("totalCostUSD") or 0)
|
|
47
|
+
elif t == "system" and d.get("subtype") == "compact_boundary": # context shrinks right away, not at the next reply
|
|
48
|
+
self.ctx = (d.get("compactMetadata") or {}).get("postTokens") or self.ctx
|
|
49
|
+
if d.get("cwd"):
|
|
50
|
+
self.cwd = d["cwd"]; self.cwds[self.cwd] = self.cwds.get(self.cwd, 0) + 1
|
|
51
|
+
if d.get("isSidechain") or d.get("isMeta"): return
|
|
52
|
+
m = d.get("message") or {}
|
|
53
|
+
if t == "user":
|
|
54
|
+
txt = _user_text(m.get("content"))
|
|
55
|
+
if txt:
|
|
56
|
+
self.msgs += 1; self.first = self.first or txt.replace("\n", " ")[:90]
|
|
57
|
+
self._add_text(txt)
|
|
58
|
+
elif t == "assistant":
|
|
59
|
+
u = m.get("usage") or {}
|
|
60
|
+
v = u.get("input_tokens", 0) + u.get("cache_read_input_tokens", 0) + u.get("cache_creation_input_tokens", 0)
|
|
61
|
+
if v: self.ctx = v
|
|
62
|
+
mid = m.get("id"); ts = d.get("timestamp", "")
|
|
63
|
+
if u and mid and mid not in self.seen and len(ts) >= 10:
|
|
64
|
+
self.seen.add(mid); day = self.days.setdefault(ts[:10], [0, 0, 0, 0])
|
|
65
|
+
day[0] += u.get("input_tokens", 0); day[1] += u.get("output_tokens", 0)
|
|
66
|
+
day[2] += u.get("cache_read_input_tokens", 0); day[3] += u.get("cache_creation_input_tokens", 0)
|
|
67
|
+
txt = _assistant_text(m.get("content"))
|
|
68
|
+
if txt: self._add_text(txt)
|
|
69
|
+
|
|
70
|
+
def _add_text(self, s):
|
|
71
|
+
self.text.append(s); self.text_lc += s.lower() + "\n"
|
|
72
|
+
|
|
73
|
+
def info(self):
|
|
74
|
+
return {"title": self.title, "cwd": self.cwd, "ctx": self.ctx, "msgs": self.msgs, "days": self.days,
|
|
75
|
+
"cost": round(self.cost, 2), "size": self.size, "mtime": self.mtime}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Index:
|
|
79
|
+
def __init__(self, root):
|
|
80
|
+
self.root = root; self.entries = {}; self.lock = threading.Lock()
|
|
81
|
+
|
|
82
|
+
def files(self):
|
|
83
|
+
out = {}
|
|
84
|
+
try:
|
|
85
|
+
for proj in os.scandir(self.root):
|
|
86
|
+
if not proj.is_dir(): continue
|
|
87
|
+
for f in os.scandir(proj.path):
|
|
88
|
+
if f.name.endswith(".jsonl") and UUID.match(f.name[:-6]): out[f.name[:-6]] = f.path
|
|
89
|
+
except FileNotFoundError:
|
|
90
|
+
pass
|
|
91
|
+
return out
|
|
92
|
+
|
|
93
|
+
def get(self, path):
|
|
94
|
+
"""The entry for a transcript, reading only what was appended since last time."""
|
|
95
|
+
with self.lock:
|
|
96
|
+
st = os.stat(path); e = self.entries.get(path)
|
|
97
|
+
if e is None or e.ino != st.st_ino or st.st_size < e.offset: # new, replaced or truncated
|
|
98
|
+
e = self.entries[path] = Entry(st.st_ino)
|
|
99
|
+
if st.st_size > e.offset:
|
|
100
|
+
with open(path, "rb") as f:
|
|
101
|
+
f.seek(e.offset); chunk = f.read(st.st_size - e.offset)
|
|
102
|
+
end = chunk.rfind(b"\n") + 1 # leave a half-written last line for next time
|
|
103
|
+
for line in chunk[:end].splitlines():
|
|
104
|
+
try: e.feed(json.loads(line))
|
|
105
|
+
except Exception: pass
|
|
106
|
+
e.offset += end
|
|
107
|
+
e.size = st.st_size; e.mtime = st.st_mtime
|
|
108
|
+
return e
|
|
109
|
+
|
|
110
|
+
def all(self):
|
|
111
|
+
"""{sid: (path, Entry)} for every transcript; forgets deleted ones."""
|
|
112
|
+
fs = self.files(); out = {}
|
|
113
|
+
for sid, p in fs.items():
|
|
114
|
+
try: out[sid] = (p, self.get(p))
|
|
115
|
+
except FileNotFoundError: pass
|
|
116
|
+
with self.lock:
|
|
117
|
+
for p in [p for p in self.entries if p not in set(fs.values())]: del self.entries[p]
|
|
118
|
+
return out
|
|
119
|
+
|
|
120
|
+
def forget(self, path):
|
|
121
|
+
with self.lock: self.entries.pop(path, None)
|
|
122
|
+
|
|
123
|
+
def search(self, q, limit=200):
|
|
124
|
+
"""Chats whose messages contain every word of q, with a snippet around the first hit."""
|
|
125
|
+
words = q.lower().split()
|
|
126
|
+
if not words: return {}
|
|
127
|
+
hits = {}
|
|
128
|
+
for sid, (_, e) in self.all().items():
|
|
129
|
+
if all(w in e.text_lc for w in words):
|
|
130
|
+
hits[sid] = snippet(e, words[0])
|
|
131
|
+
if len(hits) >= limit: break
|
|
132
|
+
return hits
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def snippet(e, word, pad=60):
|
|
136
|
+
for s in e.text:
|
|
137
|
+
i = s.lower().find(word)
|
|
138
|
+
if i >= 0:
|
|
139
|
+
a, b = max(0, i - pad), min(len(s), i + len(word) + pad)
|
|
140
|
+
return ("…" if a else "") + s[a:b].replace("\n", " ") + ("…" if b < len(s) else "")
|
|
141
|
+
return ""
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _tool_summary(name, inp):
|
|
145
|
+
if not isinstance(inp, dict): return ""
|
|
146
|
+
for k in ("command", "file_path", "pattern", "url", "query", "description", "prompt", "skill"):
|
|
147
|
+
if inp.get(k): return str(inp[k])[:300]
|
|
148
|
+
return json.dumps(inp)[:300]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _result_text(c):
|
|
152
|
+
if isinstance(c, str): return c
|
|
153
|
+
if isinstance(c, list):
|
|
154
|
+
return "\n".join(b.get("text", "") if b.get("type") == "text" else f"[{b.get('type')}]" for b in c if isinstance(b, dict))
|
|
155
|
+
return ""
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def messages(path, max_items=1500, result_chars=1500):
|
|
159
|
+
"""The conversation as a flat list of items for the viewer:
|
|
160
|
+
{"k": "user"|"assistant"|"tool"|"result"|"command"|"compact", "text": ..., "ts": ..., ...}"""
|
|
161
|
+
items = []
|
|
162
|
+
with open(path, "rb") as f:
|
|
163
|
+
for line in f:
|
|
164
|
+
try: d = json.loads(line)
|
|
165
|
+
except Exception: continue
|
|
166
|
+
if d.get("isSidechain") or d.get("isMeta"): continue
|
|
167
|
+
t = d.get("type"); ts = d.get("timestamp"); m = d.get("message") or {}; c = m.get("content")
|
|
168
|
+
if t == "system" and d.get("subtype") == "compact_boundary":
|
|
169
|
+
items.append({"k": "compact", "ts": ts})
|
|
170
|
+
elif t == "user":
|
|
171
|
+
if isinstance(c, str) and "<command-name>" in c:
|
|
172
|
+
cmd = re.search(r"<command-name>(.*?)</command-name>", c, re.S)
|
|
173
|
+
args = re.search(r"<command-args>(.*?)</command-args>", c, re.S)
|
|
174
|
+
if cmd: items.append({"k": "command", "text": (cmd.group(1) + " " + (args.group(1) if args else "")).strip(), "ts": ts})
|
|
175
|
+
continue
|
|
176
|
+
txt = _user_text(c)
|
|
177
|
+
if txt: items.append({"k": "user", "text": txt, "ts": ts})
|
|
178
|
+
if isinstance(c, list):
|
|
179
|
+
for b in c:
|
|
180
|
+
if isinstance(b, dict) and b.get("type") == "tool_result":
|
|
181
|
+
r = _result_text(b.get("content"))
|
|
182
|
+
items.append({"k": "result", "text": r[:result_chars], "cut": len(r) > result_chars,
|
|
183
|
+
"err": bool(b.get("is_error")), "ts": ts})
|
|
184
|
+
elif t == "assistant" and isinstance(c, list):
|
|
185
|
+
for b in c:
|
|
186
|
+
if not isinstance(b, dict): continue
|
|
187
|
+
if b.get("type") == "text" and b.get("text", "").strip():
|
|
188
|
+
items.append({"k": "assistant", "text": b["text"].strip(), "ts": ts})
|
|
189
|
+
elif b.get("type") == "tool_use":
|
|
190
|
+
items.append({"k": "tool", "name": b.get("name", ""), "text": _tool_summary(b.get("name"), b.get("input")), "ts": ts})
|
|
191
|
+
return {"items": items[-max_items:], "hidden": max(0, len(items) - max_items)}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def markdown(path, title):
|
|
195
|
+
out = [f"# {title}", ""]
|
|
196
|
+
for it in messages(path, max_items=10**9)["items"]:
|
|
197
|
+
k = it["k"]
|
|
198
|
+
if k == "user": out += ["## You", "", it["text"], ""]
|
|
199
|
+
elif k == "assistant": out += ["## Claude", "", it["text"], ""]
|
|
200
|
+
elif k == "tool":
|
|
201
|
+
cmd = it["text"].replace("`", "'")[:200]
|
|
202
|
+
out += [f"> **{it['name']}** `{cmd}`", ""]
|
|
203
|
+
elif k == "command": out += [f"> `{it['text']}`", ""]
|
|
204
|
+
elif k == "compact": out += ["---", "*conversation compacted*", ""]
|
|
205
|
+
return "\n".join(out)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def rename(path, sid, title):
|
|
209
|
+
"""Give a saved chat a new name, the same way Claude Code's /rename records it."""
|
|
210
|
+
with open(path, "rb") as f:
|
|
211
|
+
f.seek(0, 2); ends_clean = True
|
|
212
|
+
if f.tell(): f.seek(-1, 2); ends_clean = f.read(1) == b"\n"
|
|
213
|
+
with open(path, "a") as f:
|
|
214
|
+
if not ends_clean: f.write("\n")
|
|
215
|
+
f.write(json.dumps({"type": "custom-title", "customTitle": title, "sessionId": sid}) + "\n")
|
|
216
|
+
f.write(json.dumps({"type": "agent-name", "agentName": title, "sessionId": sid}) + "\n")
|
chatdeck/usage.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Token usage from transcripts, plan limits from saved status-line payloads, and a forecast
|
|
2
|
+
of when each limit runs out at the current pace."""
|
|
3
|
+
import datetime, glob, json, os, threading, time
|
|
4
|
+
|
|
5
|
+
WINDOWS = {"five_hour": ("5-hour limit", 90 * 60), "seven_day": ("Weekly limit", 12 * 3600)} # key -> (label, lookback secs)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _secs(t):
|
|
9
|
+
if isinstance(t, (int, float)): return t / 1000 if t > 1e12 else t
|
|
10
|
+
try: return datetime.datetime.fromisoformat(str(t).replace("Z", "+00:00")).timestamp()
|
|
11
|
+
except Exception: return None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LimitHistory:
|
|
15
|
+
"""Samples of (time, window, used %, reset time), kept in memory and in a small JSONL file
|
|
16
|
+
so the forecast survives restarts."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, path, keep=8 * 86400):
|
|
19
|
+
self.path = path; self.keep = keep; self.samples = []; self.lock = threading.Lock()
|
|
20
|
+
try:
|
|
21
|
+
with open(path) as f:
|
|
22
|
+
self.samples = [json.loads(l) for l in f if l.strip()]
|
|
23
|
+
except (FileNotFoundError, ValueError):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
def add(self, at, data):
|
|
27
|
+
new = []
|
|
28
|
+
with self.lock:
|
|
29
|
+
for key in WINDOWS:
|
|
30
|
+
o = data.get(key) or {}
|
|
31
|
+
pct = o.get("used_percentage"); reset = _secs(o.get("resets_at"))
|
|
32
|
+
if pct is None or reset is None: continue
|
|
33
|
+
last = next((s for s in reversed(self.samples) if s["w"] == key), None)
|
|
34
|
+
if last and last["t"] >= at: continue
|
|
35
|
+
if last and last["p"] == pct and last["r"] == reset and at - last["t"] < 300: continue
|
|
36
|
+
new.append({"t": at, "w": key, "p": pct, "r": reset})
|
|
37
|
+
if not new: return
|
|
38
|
+
self.samples += new
|
|
39
|
+
cut = time.time() - self.keep
|
|
40
|
+
if self.samples[0]["t"] < cut:
|
|
41
|
+
self.samples = [s for s in self.samples if s["t"] >= cut]
|
|
42
|
+
self._write(self.samples, "w")
|
|
43
|
+
else:
|
|
44
|
+
self._write(new, "a")
|
|
45
|
+
|
|
46
|
+
def _write(self, rows, mode):
|
|
47
|
+
try:
|
|
48
|
+
os.makedirs(os.path.dirname(self.path), exist_ok=True)
|
|
49
|
+
with open(self.path, mode) as f:
|
|
50
|
+
f.writelines(json.dumps(r) + "\n" for r in rows)
|
|
51
|
+
except OSError:
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
def forecast(self, key, now=None):
|
|
55
|
+
"""{"eta": when it hits 100% at the current pace, or None, "rate": % per hour}."""
|
|
56
|
+
now = now or time.time(); lookback = WINDOWS[key][1]
|
|
57
|
+
with self.lock:
|
|
58
|
+
s = [x for x in self.samples if x["w"] == key]
|
|
59
|
+
if not s: return None
|
|
60
|
+
cur = s[-1]
|
|
61
|
+
win = [x for x in s if x["r"] == cur["r"] and x["t"] >= cur["t"] - lookback]
|
|
62
|
+
if len(win) < 2 or cur["t"] - win[0]["t"] < 900: return {"eta": None, "rate": None}
|
|
63
|
+
rate = (cur["p"] - win[0]["p"]) / (cur["t"] - win[0]["t"]) # % per second
|
|
64
|
+
if rate <= 0: return {"eta": None, "rate": 0}
|
|
65
|
+
eta = cur["t"] + (100 - cur["p"]) / rate
|
|
66
|
+
return {"eta": eta if eta < cur["r"] else None, "rate": round(rate * 3600, 1)}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def summary(entries, status_dir, history, today=None, project=None):
|
|
70
|
+
"""entries: iterable of transcript Entry objects; project(entry) names the project a chat belongs to."""
|
|
71
|
+
today = today or datetime.date.today()
|
|
72
|
+
week = {(today - datetime.timedelta(days=i)).isoformat(): 0 for i in range(6, -1, -1)}
|
|
73
|
+
by_proj = {}; tot = {"today": [0, 0, 0, 0], "week": [0, 0, 0, 0]}
|
|
74
|
+
for e in entries:
|
|
75
|
+
proj = os.path.basename((project(e) if project else e.cwd or "").rstrip("/")) or "~"
|
|
76
|
+
for day, v in e.days.items():
|
|
77
|
+
if day in week:
|
|
78
|
+
n = v[0] + v[1] + v[3] # fresh input + output + cache writes (cache reads are cheap re-reads)
|
|
79
|
+
week[day] += n; by_proj[proj] = by_proj.get(proj, 0) + n
|
|
80
|
+
tot["week"] = [a + b for a, b in zip(tot["week"], v)]
|
|
81
|
+
if day == today.isoformat(): tot["today"] = [a + b for a, b in zip(tot["today"], v)]
|
|
82
|
+
limits = None; cost = 0.0; now = time.time()
|
|
83
|
+
for f in sorted(glob.glob(os.path.join(status_dir, "*.json")), key=os.path.getmtime):
|
|
84
|
+
try:
|
|
85
|
+
with open(f) as fh: d = json.load(fh)
|
|
86
|
+
at = os.path.getmtime(f)
|
|
87
|
+
if at > now - 86400: cost += float((d.get("cost") or {}).get("total_cost_usd") or 0)
|
|
88
|
+
if d.get("rate_limits"):
|
|
89
|
+
limits = {"data": d["rate_limits"], "at": at}
|
|
90
|
+
history.add(at, d["rate_limits"])
|
|
91
|
+
except Exception:
|
|
92
|
+
pass
|
|
93
|
+
if limits:
|
|
94
|
+
limits["forecast"] = {k: history.forecast(k) for k in WINDOWS}
|
|
95
|
+
return {"week": [[k, v] for k, v in week.items()], "by_project": sorted(by_proj.items(), key=lambda x: -x[1])[:5],
|
|
96
|
+
"today": tot["today"], "week_tot": tot["week"], "limits": limits, "cost_24h": round(cost, 2)}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
<#
|
|
2
|
+
chatdeck bubble: a small always-on-top button in the bottom-left corner of the screen.
|
|
3
|
+
Click it to pop the dashboard up above it; click it again, or anywhere else, to tuck it away.
|
|
4
|
+
Drag it to move it. Right-click to hide it or for more. Starting it again while it is hidden
|
|
5
|
+
brings it back. The badge counts running chats and turns amber, with a pulsing ring, when a
|
|
6
|
+
chat is waiting for you. The same badge sits in the notification area (by the clock), where a
|
|
7
|
+
click pops the dashboard up in the bottom-right corner instead.
|
|
8
|
+
|
|
9
|
+
Needs the dashboard server running, and Chrome or Edge for the pop-up.
|
|
10
|
+
powershell -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File bubble.ps1 [-Port 47123]
|
|
11
|
+
#>
|
|
12
|
+
param([int]$Port = 47123, [int]$PopupWidth = 460, [int]$PopupHeight = 760)
|
|
13
|
+
|
|
14
|
+
# One bubble at a time: starting it again just asks the running one to show itself.
|
|
15
|
+
$created = $false
|
|
16
|
+
$script:mutex = New-Object System.Threading.Mutex($true, "Local\ChatdeckBubble", [ref]$created)
|
|
17
|
+
$script:showSignal = New-Object System.Threading.EventWaitHandle($false, [System.Threading.EventResetMode]::AutoReset, "Local\ChatdeckBubbleShow")
|
|
18
|
+
if (-not $created) { $script:showSignal.Set() | Out-Null; exit }
|
|
19
|
+
|
|
20
|
+
Add-Type -AssemblyName PresentationFramework, PresentationCore, WindowsBase, System.Windows.Forms, System.Drawing
|
|
21
|
+
Add-Type @'
|
|
22
|
+
using System; using System.Runtime.InteropServices;
|
|
23
|
+
public static class U {
|
|
24
|
+
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern IntPtr FindWindow(string c, string t);
|
|
25
|
+
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
|
26
|
+
[DllImport("user32.dll")] public static extern IntPtr GetAncestor(IntPtr h, uint f);
|
|
27
|
+
[DllImport("user32.dll")] static extern bool SetForegroundWindow(IntPtr h);
|
|
28
|
+
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int c);
|
|
29
|
+
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);
|
|
30
|
+
[DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);
|
|
31
|
+
[DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr h, IntPtr after, int x, int y, int w, int hh, uint f);
|
|
32
|
+
[DllImport("user32.dll")] public static extern bool PostMessage(IntPtr h, uint m, IntPtr w, IntPtr l);
|
|
33
|
+
[DllImport("user32.dll")] public static extern bool DestroyIcon(IntPtr h);
|
|
34
|
+
[DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern int GetClassName(IntPtr h, System.Text.StringBuilder s, int n);
|
|
35
|
+
public static string ClassOf(IntPtr h) { var s = new System.Text.StringBuilder(128); GetClassName(h, s, 128); return s.ToString(); }
|
|
36
|
+
[DllImport("user32.dll")] static extern void keybd_event(byte k, byte s, uint f, UIntPtr e);
|
|
37
|
+
public static void Front(IntPtr h) { // an Alt tap lets a background process take focus
|
|
38
|
+
keybd_event(0x12, 0, 0, UIntPtr.Zero); keybd_event(0x12, 0, 2, UIntPtr.Zero); SetForegroundWindow(h);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
'@
|
|
42
|
+
[System.Net.WebRequest]::DefaultWebProxy = $null # skip proxy detection, which adds seconds per request
|
|
43
|
+
|
|
44
|
+
$Base = "http://127.0.0.1:$Port"
|
|
45
|
+
$PopupTitle = "chatdeck" + [char]0x200B # reads "chatdeck"; the zero-width space tells the pop-up apart from the full window
|
|
46
|
+
$Size = 64
|
|
47
|
+
$StateFile = Join-Path $env:APPDATA "chatdeck\bubble.json"
|
|
48
|
+
$script:token = $null
|
|
49
|
+
$script:shown = $false
|
|
50
|
+
$script:shownAt = [DateTime]::MinValue
|
|
51
|
+
$script:hiddenUntil = $null # set while the bubble itself is hidden
|
|
52
|
+
$script:anchor = "bubble" # where the pop-up opens: above the bubble, or above the tray
|
|
53
|
+
|
|
54
|
+
# --- talking to the server -------------------------------------------------------------------
|
|
55
|
+
function Get-Summary {
|
|
56
|
+
if (-not $script:token) {
|
|
57
|
+
$html = (Invoke-WebRequest -UseBasicParsing -TimeoutSec 2 "$Base/").Content
|
|
58
|
+
if ($html -match 'TOKEN="([0-9a-f]+)"') { $script:token = $Matches[1] }
|
|
59
|
+
}
|
|
60
|
+
try { Invoke-RestMethod -TimeoutSec 2 -Headers @{ "X-Token" = $script:token } "$Base/api/summary" }
|
|
61
|
+
catch { $script:token = $null; throw } # the server may have restarted with a new token
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# --- the logo: the same shapes as assets/logo.svg (a deck of chat cards) -----------------------
|
|
65
|
+
$LogoXaml = @'
|
|
66
|
+
<Canvas xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" Width="64" Height="64">
|
|
67
|
+
<Rectangle Canvas.Left="23" Canvas.Top="5" Width="36" Height="28" RadiusX="7" RadiusY="7" Fill="#A9C4F7"/>
|
|
68
|
+
<Rectangle Canvas.Left="14" Canvas.Top="12" Width="38" Height="29" RadiusX="7" RadiusY="7" Fill="#6E9BEE"/>
|
|
69
|
+
<Path Data="M13 20h34a8 8 0 0 1 8 8v16a8 8 0 0 1-8 8H24l-11 9 2-9h-2a8 8 0 0 1-8-8V28a8 8 0 0 1 8-8z">
|
|
70
|
+
<Path.Fill><LinearGradientBrush StartPoint="0,0" EndPoint="1,1"><GradientStop Offset="0" Color="#4C86F0"/><GradientStop Offset="1" Color="#2A5BD7"/></LinearGradientBrush></Path.Fill>
|
|
71
|
+
</Path>
|
|
72
|
+
<Rectangle Canvas.Left="14" Canvas.Top="30" Width="26" Height="4.5" RadiusX="2.25" RadiusY="2.25" Fill="White"/>
|
|
73
|
+
<Rectangle Canvas.Left="14" Canvas.Top="38.5" Width="17" Height="4.5" RadiusX="2.25" RadiusY="2.25" Fill="White" Opacity="0.8"/>
|
|
74
|
+
</Canvas>
|
|
75
|
+
'@
|
|
76
|
+
|
|
77
|
+
function Logo-Bitmap($px) {
|
|
78
|
+
# The logo rendered to a System.Drawing bitmap, for the tray icon.
|
|
79
|
+
$vb = New-Object System.Windows.Controls.Viewbox
|
|
80
|
+
$vb.Child = [Windows.Markup.XamlReader]::Parse($LogoXaml)
|
|
81
|
+
$vb.Measure((New-Object System.Windows.Size($px, $px))); $vb.Arrange((New-Object System.Windows.Rect(0, 0, $px, $px)))
|
|
82
|
+
$rtb = New-Object System.Windows.Media.Imaging.RenderTargetBitmap($px, $px, 96, 96, [System.Windows.Media.PixelFormats]::Pbgra32)
|
|
83
|
+
$rtb.Render($vb)
|
|
84
|
+
$enc = New-Object System.Windows.Media.Imaging.PngBitmapEncoder
|
|
85
|
+
$enc.Frames.Add([System.Windows.Media.Imaging.BitmapFrame]::Create($rtb))
|
|
86
|
+
$ms = New-Object System.IO.MemoryStream; $enc.Save($ms); $ms.Position = 0
|
|
87
|
+
New-Object System.Drawing.Bitmap($ms)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
# --- the bubble ------------------------------------------------------------------------------
|
|
91
|
+
[xml]$xaml = @"
|
|
92
|
+
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
|
93
|
+
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
94
|
+
Title="chatdeck bubble" Width="$Size" Height="$Size" WindowStyle="None" AllowsTransparency="True"
|
|
95
|
+
Background="Transparent" Topmost="True" ShowInTaskbar="False" ResizeMode="NoResize" Cursor="Hand">
|
|
96
|
+
<Grid>
|
|
97
|
+
<Ellipse x:Name="Ring" Width="58" Height="58" Stroke="#F5B942" StrokeThickness="3" Opacity="0"/>
|
|
98
|
+
<Ellipse x:Name="Disc" Width="48" Height="48" Fill="#1B1F27" Stroke="#3A4150" StrokeThickness="1">
|
|
99
|
+
<Ellipse.Effect><DropShadowEffect BlurRadius="10" ShadowDepth="2" Opacity="0.45"/></Ellipse.Effect>
|
|
100
|
+
</Ellipse>
|
|
101
|
+
<Viewbox Width="30" Height="30" Margin="0,1,0,0">$LogoXaml</Viewbox>
|
|
102
|
+
<Border x:Name="Badge" Background="#3ECF8E" CornerRadius="9" MinWidth="18" Height="18" HorizontalAlignment="Right"
|
|
103
|
+
VerticalAlignment="Top" Margin="0,5,5,0" Padding="4,0" Visibility="Collapsed">
|
|
104
|
+
<TextBlock x:Name="Count" Foreground="#0F1115" FontSize="11" FontWeight="Bold" HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
|
105
|
+
</Border>
|
|
106
|
+
</Grid>
|
|
107
|
+
</Window>
|
|
108
|
+
"@
|
|
109
|
+
$win = [Windows.Markup.XamlReader]::Load((New-Object System.Xml.XmlNodeReader $xaml))
|
|
110
|
+
$ring = $win.FindName("Ring"); $disc = $win.FindName("Disc")
|
|
111
|
+
$badge = $win.FindName("Badge"); $count = $win.FindName("Count")
|
|
112
|
+
$win.ToolTip = "chatdeck"
|
|
113
|
+
|
|
114
|
+
$pulse = New-Object System.Windows.Media.Animation.DoubleAnimation(0.25, 1, [TimeSpan]::FromMilliseconds(700))
|
|
115
|
+
$pulse.AutoReverse = $true; $pulse.RepeatBehavior = [System.Windows.Media.Animation.RepeatBehavior]::Forever
|
|
116
|
+
|
|
117
|
+
function Home-Position {
|
|
118
|
+
$wa = [System.Windows.SystemParameters]::WorkArea
|
|
119
|
+
$win.Left = $wa.Left + 12; $win.Top = $wa.Bottom - $Size - 12
|
|
120
|
+
}
|
|
121
|
+
function Save-Position {
|
|
122
|
+
try {
|
|
123
|
+
New-Item -ItemType Directory -Force (Split-Path $StateFile) | Out-Null
|
|
124
|
+
@{ left = $win.Left; top = $win.Top } | ConvertTo-Json | Set-Content $StateFile
|
|
125
|
+
} catch {}
|
|
126
|
+
}
|
|
127
|
+
Home-Position
|
|
128
|
+
try {
|
|
129
|
+
$p = Get-Content $StateFile -Raw | ConvertFrom-Json
|
|
130
|
+
$vs = [System.Windows.SystemParameters]
|
|
131
|
+
if ($p.left -ge $vs::VirtualScreenLeft -and $p.left -le $vs::VirtualScreenLeft + $vs::VirtualScreenWidth - $Size -and
|
|
132
|
+
$p.top -ge $vs::VirtualScreenTop -and $p.top -le $vs::VirtualScreenTop + $vs::VirtualScreenHeight - $Size) {
|
|
133
|
+
$win.Left = $p.left; $win.Top = $p.top
|
|
134
|
+
}
|
|
135
|
+
} catch {}
|
|
136
|
+
|
|
137
|
+
# --- the pop-up: a Chrome/Edge app window placed above the bubble ----------------------------
|
|
138
|
+
function Popup-Handle { [U]::FindWindow("Chrome_WidgetWin_1", $PopupTitle) }
|
|
139
|
+
|
|
140
|
+
function Browser {
|
|
141
|
+
foreach ($b in @("$env:ProgramFiles\Google\Chrome\Application\chrome.exe", "${env:ProgramFiles(x86)}\Google\Chrome\Application\chrome.exe",
|
|
142
|
+
"$env:LOCALAPPDATA\Google\Chrome\Application\chrome.exe", "${env:ProgramFiles(x86)}\Microsoft\Edge\Application\msedge.exe",
|
|
143
|
+
"$env:ProgramFiles\Microsoft\Edge\Application\msedge.exe")) { if (Test-Path $b) { return $b } }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function Place-Popup($h) {
|
|
147
|
+
# Above the bubble, lined up with whichever edge of the screen the bubble is nearer, or in the
|
|
148
|
+
# bottom-right corner when opened from the tray; kept on screen.
|
|
149
|
+
$wa = [System.Windows.SystemParameters]::WorkArea
|
|
150
|
+
$scale = [System.Windows.PresentationSource]::FromVisual($win).CompositionTarget.TransformToDevice.M11
|
|
151
|
+
if ($script:anchor -eq "tray") { $left = $wa.Right - 8 - $PopupWidth; $top = $wa.Bottom - 8 - $PopupHeight }
|
|
152
|
+
else {
|
|
153
|
+
$left = if ($win.Left + $Size / 2 -lt $wa.Left + $wa.Width / 2) { $win.Left + 4 } else { $win.Left + $Size - 4 - $PopupWidth }
|
|
154
|
+
$top = $win.Top - 6 - $PopupHeight
|
|
155
|
+
}
|
|
156
|
+
if ($top -lt $wa.Top) { $top = $wa.Top }
|
|
157
|
+
$left = [Math]::Max($wa.Left, [Math]::Min($left, $wa.Right - $PopupWidth))
|
|
158
|
+
[U]::SetWindowPos($h, [IntPtr]::Zero, [int]($left * $scale), [int]($top * $scale),
|
|
159
|
+
[int]($PopupWidth * $scale), [int]($PopupHeight * $scale), 0x14) | Out-Null # no z-order change, no activate
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function Show-Popup($from = "bubble") {
|
|
163
|
+
$script:anchor = $from
|
|
164
|
+
$h = Popup-Handle
|
|
165
|
+
if ($h -eq [IntPtr]::Zero) {
|
|
166
|
+
$b = Browser
|
|
167
|
+
if (-not $b) { [System.Windows.MessageBox]::Show("The pop-up needs Chrome or Edge.", "chatdeck") | Out-Null; return }
|
|
168
|
+
Start-Process $b -ArgumentList "--app=$Base/?popup=1", "--window-size=$PopupWidth,$PopupHeight"
|
|
169
|
+
for ($i = 0; $i -lt 60 -and $h -eq [IntPtr]::Zero; $i++) { Start-Sleep -Milliseconds 100; $h = Popup-Handle }
|
|
170
|
+
if ($h -eq [IntPtr]::Zero) { return }
|
|
171
|
+
}
|
|
172
|
+
Place-Popup $h
|
|
173
|
+
[U]::ShowWindow($h, $(if ([U]::IsIconic($h)) { 9 } else { 5 })) | Out-Null
|
|
174
|
+
[U]::Front($h)
|
|
175
|
+
$script:shown = $true; $script:shownAt = Get-Date
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function Hide-Popup {
|
|
179
|
+
$h = Popup-Handle
|
|
180
|
+
if ($h -ne [IntPtr]::Zero) { [U]::ShowWindow($h, 0) | Out-Null }
|
|
181
|
+
$script:shown = $false
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function Toggle-Popup($from = "bubble") { if ($script:shown) { Hide-Popup } else { Show-Popup $from } }
|
|
185
|
+
|
|
186
|
+
function Hide-Bubble($until) { Hide-Popup; $win.Hide(); $script:hiddenUntil = $until }
|
|
187
|
+
function Show-Bubble { $script:hiddenUntil = $null; $win.Show(); [U]::ShowWindow($script:self, 4) | Out-Null }
|
|
188
|
+
|
|
189
|
+
# Click toggles; dragging moves the bubble (and the pop-up with it).
|
|
190
|
+
$win.Add_MouseLeftButtonDown({
|
|
191
|
+
$l = $win.Left; $t = $win.Top
|
|
192
|
+
$win.DragMove()
|
|
193
|
+
if ([Math]::Abs($win.Left - $l) -lt 3 -and [Math]::Abs($win.Top - $t) -lt 3) { Toggle-Popup }
|
|
194
|
+
else { Save-Position; if ($script:shown -and $script:anchor -eq "bubble") { $h = Popup-Handle; if ($h -ne [IntPtr]::Zero) { Place-Popup $h } } }
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
$menu = New-Object System.Windows.Controls.ContextMenu
|
|
198
|
+
function Add-Item($text, $action) { $i = New-Object System.Windows.Controls.MenuItem; $i.Header = $text; $i.Add_Click($action); $menu.Items.Add($i) | Out-Null }
|
|
199
|
+
Add-Item "Show chats" { Show-Popup }
|
|
200
|
+
Add-Item "Open the full dashboard" { $b = Browser; if ($b) { Start-Process $b -ArgumentList "--app=$Base/" } }
|
|
201
|
+
Add-Item "Move back to the corner" { Home-Position; Save-Position }
|
|
202
|
+
$menu.Items.Add((New-Object System.Windows.Controls.Separator)) | Out-Null
|
|
203
|
+
Add-Item "Hide for an hour" { Hide-Bubble ((Get-Date).AddHours(1)) }
|
|
204
|
+
Add-Item "Hide (the tray icon brings it back)" { Hide-Bubble ([DateTime]::MaxValue) }
|
|
205
|
+
Add-Item "Quit" { $win.Close() }
|
|
206
|
+
$win.ContextMenu = $menu
|
|
207
|
+
|
|
208
|
+
# --- the tray icon ------------------------------------------------------------------------------
|
|
209
|
+
$tray = New-Object System.Windows.Forms.NotifyIcon
|
|
210
|
+
$tray.Text = "chatdeck"
|
|
211
|
+
$tray.Add_MouseClick({ param($s, $e) if ($e.Button -eq [System.Windows.Forms.MouseButtons]::Left) { Toggle-Popup "tray" } })
|
|
212
|
+
$trayMenu = New-Object System.Windows.Forms.ContextMenuStrip
|
|
213
|
+
function Add-TrayItem($text, $action) { $i = $trayMenu.Items.Add($text); $i.Add_Click($action); $i }
|
|
214
|
+
Add-TrayItem "Show chats" { Show-Popup "tray" } | Out-Null
|
|
215
|
+
Add-TrayItem "Open the full dashboard" { $b = Browser; if ($b) { Start-Process $b -ArgumentList "--app=$Base/" } } | Out-Null
|
|
216
|
+
$bubbleItem = Add-TrayItem "Show the corner button" { if ($script:hiddenUntil) { Show-Bubble } else { Hide-Bubble ([DateTime]::MaxValue) } }
|
|
217
|
+
$trayMenu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator)) | Out-Null
|
|
218
|
+
Add-TrayItem "Quit" { $win.Close() } | Out-Null
|
|
219
|
+
$trayMenu.Add_Opening({ $bubbleItem.Text = $(if ($script:hiddenUntil) { "Show the corner button" } else { "Hide the corner button" }) })
|
|
220
|
+
$tray.ContextMenuStrip = $trayMenu
|
|
221
|
+
|
|
222
|
+
# The tray icon: the logo, with a count badge in its corner.
|
|
223
|
+
$script:trayKey = $null; $script:trayIcon = $null; $script:trayHandle = [IntPtr]::Zero
|
|
224
|
+
function Set-Tray($label, $argb, $tip) {
|
|
225
|
+
$key = "$label|$argb"
|
|
226
|
+
if ($key -ne $script:trayKey) {
|
|
227
|
+
$bmp = Logo-Bitmap 32
|
|
228
|
+
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
|
229
|
+
$g.SmoothingMode = "AntiAlias"; $g.TextRenderingHint = "AntiAliasGridFit"
|
|
230
|
+
$center = New-Object System.Drawing.StringFormat; $center.Alignment = "Center"; $center.LineAlignment = "Center"
|
|
231
|
+
if ($label) {
|
|
232
|
+
$g.FillEllipse((New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(255, 15, 17, 21))), 11, 11, 23, 23) # dark ring keeps it apart from the logo
|
|
233
|
+
$g.FillEllipse((New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb($argb))), 13, 13, 19, 19)
|
|
234
|
+
$badgeFont = New-Object System.Drawing.Font("Segoe UI", 13, [System.Drawing.FontStyle]::Bold, [System.Drawing.GraphicsUnit]::Pixel)
|
|
235
|
+
$g.DrawString($label, $badgeFont, (New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(255, 15, 17, 21))),
|
|
236
|
+
(New-Object System.Drawing.RectangleF(13, 13, 19, 19)), $center)
|
|
237
|
+
}
|
|
238
|
+
$g.Dispose()
|
|
239
|
+
$h = $bmp.GetHicon(); $tray.Icon = [System.Drawing.Icon]::FromHandle($h); $bmp.Dispose()
|
|
240
|
+
if ($script:trayHandle -ne [IntPtr]::Zero) { [U]::DestroyIcon($script:trayHandle) | Out-Null }
|
|
241
|
+
$script:trayHandle = $h; $script:trayKey = $key
|
|
242
|
+
}
|
|
243
|
+
$tray.Text = $(if ($tip.Length -gt 63) { $tip.Substring(0, 60) + "..." } else { $tip }) # Windows caps tray tips at 63 characters
|
|
244
|
+
$tray.Visible = $true
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
$win.Add_Closed({
|
|
248
|
+
$tray.Visible = $false; $tray.Dispose()
|
|
249
|
+
$h = Popup-Handle
|
|
250
|
+
if ($h -ne [IntPtr]::Zero) { [U]::PostMessage($h, 0x10, [IntPtr]::Zero, [IntPtr]::Zero) | Out-Null } # close the pop-up too
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
# --- timers: tuck the pop-up away when focus moves elsewhere, and keep the badge current -------
|
|
254
|
+
$script:self = [IntPtr]::Zero
|
|
255
|
+
$focusTimer = New-Object System.Windows.Threading.DispatcherTimer
|
|
256
|
+
$focusTimer.Interval = [TimeSpan]::FromMilliseconds(300)
|
|
257
|
+
$focusTimer.Add_Tick({
|
|
258
|
+
if ($script:showSignal.WaitOne(0) -or ($script:hiddenUntil -and (Get-Date) -ge $script:hiddenUntil)) { Show-Bubble }
|
|
259
|
+
if (-not $script:shown) { return }
|
|
260
|
+
$h = Popup-Handle
|
|
261
|
+
if ($h -eq [IntPtr]::Zero -or -not [U]::IsWindowVisible($h) -or [U]::IsIconic($h)) { $script:shown = $false; return }
|
|
262
|
+
if (((Get-Date) - $script:shownAt).TotalMilliseconds -lt 800) { return }
|
|
263
|
+
$fg = [U]::GetAncestor([U]::GetForegroundWindow(), 3) # root owner, so the pop-up's own dialogs count as the pop-up
|
|
264
|
+
# The taskbar and tray take focus while you click the tray icon, which toggles the pop-up itself.
|
|
265
|
+
$taskbar = @("Shell_TrayWnd", "Shell_SecondaryTrayWnd", "NotifyIconOverflowWindow", "TopLevelWindowForOverflowXamlIsland") -contains [U]::ClassOf($fg)
|
|
266
|
+
if ($fg -ne $h -and $fg -ne $script:self -and -not $taskbar) { Hide-Popup }
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
$script:wasWaiting = $false
|
|
270
|
+
function Update-Badge {
|
|
271
|
+
try {
|
|
272
|
+
$s = Get-Summary
|
|
273
|
+
$disc.Opacity = 1
|
|
274
|
+
$waiting = @($s.waiting)
|
|
275
|
+
if ($waiting.Count) {
|
|
276
|
+
$badge.Background = "#F5B942"; $count.Text = $waiting.Count; $badge.Visibility = "Visible"
|
|
277
|
+
$win.ToolTip = "Waiting for you: " + ($waiting -join ", ")
|
|
278
|
+
if (-not $script:wasWaiting) { $ring.BeginAnimation([System.Windows.UIElement]::OpacityProperty, $pulse) }
|
|
279
|
+
Set-Tray "$($waiting.Count)" 0xFFF5B942 $win.ToolTip
|
|
280
|
+
} else {
|
|
281
|
+
if ($script:wasWaiting) { $ring.BeginAnimation([System.Windows.UIElement]::OpacityProperty, $null); $ring.Opacity = 0 }
|
|
282
|
+
$badge.Background = "#3ECF8E"; $count.Text = $s.running
|
|
283
|
+
$badge.Visibility = $(if ($s.running) { "Visible" } else { "Collapsed" })
|
|
284
|
+
$win.ToolTip = $(if ($s.running) { "$($s.running) running, $($s.busy) working" } else { "No chats running" })
|
|
285
|
+
Set-Tray $(if ($s.running) { "$($s.running)" } else { "" }) 0xFF3ECF8E "chatdeck: $($win.ToolTip)"
|
|
286
|
+
}
|
|
287
|
+
$script:wasWaiting = [bool]$waiting.Count
|
|
288
|
+
} catch {
|
|
289
|
+
$disc.Opacity = 0.45; $badge.Visibility = "Collapsed"; $win.ToolTip = "Dashboard server isn't running"
|
|
290
|
+
Set-Tray "" 0 "chatdeck: the dashboard server isn't running"
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
$pollTimer = New-Object System.Windows.Threading.DispatcherTimer
|
|
294
|
+
$pollTimer.Interval = [TimeSpan]::FromSeconds(3)
|
|
295
|
+
$pollTimer.Add_Tick({ Update-Badge })
|
|
296
|
+
|
|
297
|
+
$win.Add_SourceInitialized({ $script:self = (New-Object System.Windows.Interop.WindowInteropHelper($win)).Handle })
|
|
298
|
+
# Started with -WindowStyle Hidden, Windows applies "hidden" to the first window the process shows, so show it explicitly.
|
|
299
|
+
$win.Add_Loaded({ [U]::ShowWindow($script:self, 4) | Out-Null; Update-Badge; $pollTimer.Start(); $focusTimer.Start() })
|
|
300
|
+
# Run a message loop rather than ShowDialog: hiding a dialog ends it, which took the tray icon down with the bubble.
|
|
301
|
+
$win.Add_Closed({ [System.Windows.Threading.Dispatcher]::CurrentDispatcher.InvokeShutdown() })
|
|
302
|
+
$win.Show()
|
|
303
|
+
[System.Windows.Threading.Dispatcher]::Run()
|