@bobfrankston/rmfmail 1.2.173 → 1.2.177
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.
- package/TODO.md +3 -3
- package/bin/build-rmfshare-exe.js +79 -0
- package/bin/mailx.js +52 -1
- package/bin/mailx.js.map +1 -1
- package/bin/mailx.ts +51 -1
- package/bin/rmfshare-src/Program.cs +225 -0
- package/bin/rmfshare-src/rmfshare.csproj +36 -0
- package/bin/rmfshare.exe +0 -0
- package/bin/share-target.js +314 -0
- package/bin/share-target.js.map +1 -0
- package/bin/share-target.ts +331 -0
- package/client/app.bundle.js +70 -9
- package/client/app.bundle.js.map +3 -3
- package/client/app.js +69 -0
- package/client/app.js.map +1 -1
- package/client/app.ts +72 -0
- package/client/components/alarms.js +19 -9
- package/client/components/alarms.js.map +1 -1
- package/client/components/alarms.ts +21 -7
- package/client/compose/compose.bundle.js +4 -0
- package/client/compose/compose.bundle.js.map +2 -2
- package/client/lib/api-client.js +7 -0
- package/client/lib/api-client.js.map +1 -1
- package/client/lib/api-client.ts +12 -0
- package/client/package.json +1 -1
- package/package.json +6 -6
- package/packages/mailx-imap/package-lock.json +2 -2
- package/packages/mailx-imap/package.json +1 -1
- package/packages/mailx-service/index.d.ts +22 -0
- package/packages/mailx-service/index.d.ts.map +1 -1
- package/packages/mailx-service/index.js +82 -0
- package/packages/mailx-service/index.js.map +1 -1
- package/packages/mailx-service/index.ts +76 -0
- package/packages/mailx-service/jsonrpc.js +2 -0
- package/packages/mailx-service/jsonrpc.js.map +1 -1
- package/packages/mailx-service/jsonrpc.ts +2 -0
- package/packages/mailx-service/package.json +1 -1
- package/packages/mailx-settings/package.json +1 -1
- package/packages/mailx-store/package.json +1 -1
- package/packages/mailx-store-web/package.json +1 -1
- package/packages/mailx-types/mailx-api.d.ts +3 -0
- package/packages/mailx-types/mailx-api.d.ts.map +1 -1
- package/packages/mailx-types/mailx-api.ts +3 -0
- package/packages/mailx-types/package.json +1 -1
- /package/packages/mailx-imap/{node_modules.npmglobalize-stash-7284 → node_modules.npmglobalize-stash-69680}/.package-lock.json +0 -0
package/bin/mailx.ts
CHANGED
|
@@ -368,6 +368,21 @@ if (hasFlag("register-mailto") || hasFlag("unregister-mailto")) {
|
|
|
368
368
|
process.exit(r.ok ? 0 : 1);
|
|
369
369
|
}
|
|
370
370
|
|
|
371
|
+
// C46: Windows Share-target registration. Loose AppX deployment of
|
|
372
|
+
// bin/rmfshare.exe (see bin/share-target.ts for the full flow). Android
|
|
373
|
+
// gets the same feature from the MAUI manifest; macOS/Linux have no
|
|
374
|
+
// equivalent OS share sheet to register into.
|
|
375
|
+
if (hasFlag("register-share") || hasFlag("unregister-share")) {
|
|
376
|
+
if (process.platform !== "win32") {
|
|
377
|
+
console.error("-register-share / -unregister-share: Windows only (the Share sheet is a Windows shell feature).");
|
|
378
|
+
process.exit(1);
|
|
379
|
+
}
|
|
380
|
+
const { registerShareWindows } = await import("./share-target.js");
|
|
381
|
+
const r = registerShareWindows(hasFlag("unregister-share"));
|
|
382
|
+
console.log(r.message);
|
|
383
|
+
process.exit(r.ok ? 0 : 1);
|
|
384
|
+
}
|
|
385
|
+
|
|
371
386
|
// Read our own version once — used for the instance file + upgrade check below.
|
|
372
387
|
const __selfRoot = path.join(import.meta.dirname, "..");
|
|
373
388
|
const __selfVersion: string = (() => {
|
|
@@ -523,7 +538,7 @@ const recoverMode = hasFlag("recover");
|
|
|
523
538
|
const importMode = hasFlag("import");
|
|
524
539
|
|
|
525
540
|
// Validate arguments
|
|
526
|
-
const knownFlags = ["verbose", "kill", "v", "version", "setup", "add", "test", "rebuild", "repair", "recover", "fix-flags", "relink-bodies", "log", "import", "email", "mail", "daemon", "reauth", "mailto", "register-mailto", "unregister-mailto", "allow-elevated", "another", "no-browser", "debug-server", "send", "account"];
|
|
541
|
+
const knownFlags = ["verbose", "kill", "v", "version", "setup", "add", "test", "rebuild", "repair", "recover", "fix-flags", "relink-bodies", "log", "import", "email", "mail", "daemon", "reauth", "mailto", "register-mailto", "unregister-mailto", "register-share", "unregister-share", "allow-elevated", "another", "no-browser", "debug-server", "send", "account"];
|
|
527
542
|
for (const arg of args) {
|
|
528
543
|
// Strip a leading -/-- and any `=value` suffix before checking.
|
|
529
544
|
const flag = arg.replace(/^--?/, "").split("=")[0];
|
|
@@ -563,6 +578,10 @@ for (const arg of args) {
|
|
|
563
578
|
" -register-mailto Register as the OS-level mailto: handler\n" +
|
|
564
579
|
" -unregister-mailto Remove the mailto: handler registration\n" +
|
|
565
580
|
" -mailto <url> (internal) Decode a mailto URL and open compose\n" +
|
|
581
|
+
" Share target (Windows):\n" +
|
|
582
|
+
" -register-share Add rmfmail to the Windows Share menu\n" +
|
|
583
|
+
" (needs Developer Mode for the unsigned manifest)\n" +
|
|
584
|
+
" -unregister-share Remove rmfmail from the Windows Share menu\n" +
|
|
566
585
|
" Internal:\n" +
|
|
567
586
|
" -daemon (internal) marker for the detached daemon respawn\n" +
|
|
568
587
|
" -allow-elevated Bypass the admin/root refusal (debug only)"
|
|
@@ -2673,6 +2692,37 @@ RFC 5322 with CRLF line endings. Bodies are quoted-printable encoded (readable i
|
|
|
2673
2692
|
}
|
|
2674
2693
|
}
|
|
2675
2694
|
|
|
2695
|
+
// Pending Windows-share handler (C46). Same two-path pickup as mailto
|
|
2696
|
+
// above: the client's startup consumePendingShare poll covers the
|
|
2697
|
+
// "rmfshare.exe spawned us" race; this fs.watch covers shares that land
|
|
2698
|
+
// while we're already running. The daemon (not the client) base64s the
|
|
2699
|
+
// staged files — the WebView can't read disk.
|
|
2700
|
+
{
|
|
2701
|
+
const { consumePendingShare, loadShareAttachments, cleanupShareStaging, getPendingShareFile } =
|
|
2702
|
+
await import("./share-target.js");
|
|
2703
|
+
try {
|
|
2704
|
+
cleanupShareStaging();
|
|
2705
|
+
const pendingPath = getPendingShareFile();
|
|
2706
|
+
const dir = path.dirname(pendingPath);
|
|
2707
|
+
const baseName = path.basename(pendingPath);
|
|
2708
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
2709
|
+
fs.watch(dir, (_event, filename) => {
|
|
2710
|
+
if (filename !== baseName) return;
|
|
2711
|
+
if (!fs.existsSync(pendingPath)) return;
|
|
2712
|
+
setTimeout(() => {
|
|
2713
|
+
const data = consumePendingShare();
|
|
2714
|
+
if (!data) return;
|
|
2715
|
+
const { attachments, skipped } = loadShareAttachments(data.files);
|
|
2716
|
+
handle.send({ _event: "openShare", type: "openShare",
|
|
2717
|
+
title: data.title, text: data.text, url: data.url,
|
|
2718
|
+
attachments, skipped });
|
|
2719
|
+
}, 50);
|
|
2720
|
+
});
|
|
2721
|
+
} catch (e: any) {
|
|
2722
|
+
console.error(` [share] watch setup failed: ${e.message}`);
|
|
2723
|
+
}
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2676
2726
|
// No artificial pause here. msger's stdin is queued — writes from
|
|
2677
2727
|
// imapManager events arriving before the WebView is ready get buffered
|
|
2678
2728
|
// and delivered when the WebView attaches. The earlier 500 ms wait
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// rmfshare — Windows Share-target activation handler for rmfmail (C46).
|
|
2
|
+
//
|
|
3
|
+
// Flow when the user picks "rmfmail" in the Windows Share sheet:
|
|
4
|
+
// 1. Windows activates this exe (declared in the sparse AppxManifest that
|
|
5
|
+
// `rmfmail -register-share` registered) with ShareTarget activation.
|
|
6
|
+
// 2. We read the DataPackageView — title, text, web link, storage items —
|
|
7
|
+
// while the ShareOperation is still alive (the source app's data is
|
|
8
|
+
// only guaranteed reachable until ReportCompleted).
|
|
9
|
+
// 3. Files are copied into ~/.rmfmail/share-staging/<stamp>/ because the
|
|
10
|
+
// source StorageFiles live in the sharing app's sandbox — a raw path
|
|
11
|
+
// may not exist or may vanish as soon as the share pane closes.
|
|
12
|
+
// 4. Write ~/.rmfmail/pending-share.json (atomic rename), ReportCompleted,
|
|
13
|
+
// then launch/poke the daemon exactly like the mailto handler: if a
|
|
14
|
+
// daemon runs, its fs.watch fires; if not, we spawn `node mailx.js`
|
|
15
|
+
// and the client's startup poll consumes the file.
|
|
16
|
+
//
|
|
17
|
+
// Launched without share activation (e.g. double-clicked): just launch
|
|
18
|
+
// rmfmail — harmless, and better than an error box.
|
|
19
|
+
|
|
20
|
+
using System.Diagnostics;
|
|
21
|
+
using System.Runtime.InteropServices;
|
|
22
|
+
using System.Text;
|
|
23
|
+
using Windows.ApplicationModel;
|
|
24
|
+
using Windows.ApplicationModel.Activation;
|
|
25
|
+
using Windows.ApplicationModel.DataTransfer;
|
|
26
|
+
using Windows.ApplicationModel.DataTransfer.ShareTarget;
|
|
27
|
+
using Windows.Storage;
|
|
28
|
+
|
|
29
|
+
internal static class Program
|
|
30
|
+
{
|
|
31
|
+
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
|
32
|
+
private static extern int MessageBoxW(nint hwnd, string text, string caption, uint type);
|
|
33
|
+
|
|
34
|
+
private static void ShowError(string msg) =>
|
|
35
|
+
MessageBoxW(0, msg, "rmfmail share target", 0x10 /* MB_ICONERROR */);
|
|
36
|
+
|
|
37
|
+
private static string RmfmailDir =>
|
|
38
|
+
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".rmfmail");
|
|
39
|
+
|
|
40
|
+
[STAThread]
|
|
41
|
+
private static int Main()
|
|
42
|
+
{
|
|
43
|
+
try
|
|
44
|
+
{
|
|
45
|
+
IActivatedEventArgs? activation = null;
|
|
46
|
+
try { activation = AppInstance.GetActivatedEventArgs(); }
|
|
47
|
+
catch { /* unpackaged launch (double-click outside the manifest) */ }
|
|
48
|
+
|
|
49
|
+
if (activation is ShareTargetActivatedEventArgs share)
|
|
50
|
+
{
|
|
51
|
+
HandleShare(share).GetAwaiter().GetResult();
|
|
52
|
+
}
|
|
53
|
+
LaunchDaemon();
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
catch (Exception ex)
|
|
57
|
+
{
|
|
58
|
+
ShowError($"Share to rmfmail failed:\n{ex.Message}");
|
|
59
|
+
return 1;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private static async Task HandleShare(ShareTargetActivatedEventArgs share)
|
|
64
|
+
{
|
|
65
|
+
var op = share.ShareOperation;
|
|
66
|
+
try { op.ReportStarted(); } catch { /* optional progress hint only */ }
|
|
67
|
+
|
|
68
|
+
var data = op.Data;
|
|
69
|
+
string title = "";
|
|
70
|
+
try { title = data.Properties?.Title ?? ""; } catch { }
|
|
71
|
+
string text = "";
|
|
72
|
+
string url = "";
|
|
73
|
+
var files = new List<(string Name, string Path, long Size)>();
|
|
74
|
+
|
|
75
|
+
if (data.Contains(StandardDataFormats.Text))
|
|
76
|
+
{
|
|
77
|
+
try { text = await data.GetTextAsync(); } catch { }
|
|
78
|
+
}
|
|
79
|
+
if (data.Contains(StandardDataFormats.WebLink))
|
|
80
|
+
{
|
|
81
|
+
try { url = (await data.GetWebLinkAsync())?.AbsoluteUri ?? ""; } catch { }
|
|
82
|
+
}
|
|
83
|
+
if (data.Contains(StandardDataFormats.StorageItems))
|
|
84
|
+
{
|
|
85
|
+
var stamp = DateTime.Now.ToString("yyyyMMdd_HHmmss") + "-" +
|
|
86
|
+
Random.Shared.Next(10000).ToString("D4");
|
|
87
|
+
var staging = Path.Combine(RmfmailDir, "share-staging", stamp);
|
|
88
|
+
var items = await data.GetStorageItemsAsync();
|
|
89
|
+
foreach (var item in items)
|
|
90
|
+
{
|
|
91
|
+
if (item is not StorageFile f) continue; // folders: not meaningful as attachments
|
|
92
|
+
Directory.CreateDirectory(staging);
|
|
93
|
+
var dest = Path.Combine(staging, SafeName(f.Name, files.Count));
|
|
94
|
+
// Copy via the WinRT stream — f.Path can be empty for
|
|
95
|
+
// virtual/sandboxed items (e.g. shares out of UWP apps).
|
|
96
|
+
using (var src = (await f.OpenReadAsync()).AsStreamForRead())
|
|
97
|
+
using (var dst = File.Create(dest))
|
|
98
|
+
{
|
|
99
|
+
await src.CopyToAsync(dst);
|
|
100
|
+
}
|
|
101
|
+
files.Add((f.Name, dest, new FileInfo(dest).Length));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
WritePendingShare(title, text, url, files);
|
|
106
|
+
|
|
107
|
+
try { op.ReportCompleted(); } catch { /* pane may already be gone */ }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/// Avoid collisions when two shared files carry the same name.
|
|
111
|
+
private static string SafeName(string name, int index)
|
|
112
|
+
{
|
|
113
|
+
foreach (var c in Path.GetInvalidFileNameChars()) name = name.Replace(c, '_');
|
|
114
|
+
return index == 0 ? name
|
|
115
|
+
: Path.GetFileNameWithoutExtension(name) + $" ({index})" + Path.GetExtension(name);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/// Hand-rolled JSON writer — keeps NativeAOT trivially happy (no
|
|
119
|
+
/// reflection serializer, no source-gen context for a 5-field payload).
|
|
120
|
+
private static void WritePendingShare(
|
|
121
|
+
string title, string text, string url, List<(string Name, string Path, long Size)> files)
|
|
122
|
+
{
|
|
123
|
+
var sb = new StringBuilder();
|
|
124
|
+
sb.Append('{');
|
|
125
|
+
sb.Append("\"title\":").Append(Quote(title)).Append(',');
|
|
126
|
+
sb.Append("\"text\":").Append(Quote(text)).Append(',');
|
|
127
|
+
sb.Append("\"url\":").Append(Quote(url)).Append(',');
|
|
128
|
+
sb.Append("\"files\":[");
|
|
129
|
+
for (int i = 0; i < files.Count; i++)
|
|
130
|
+
{
|
|
131
|
+
if (i > 0) sb.Append(',');
|
|
132
|
+
sb.Append("{\"name\":").Append(Quote(files[i].Name))
|
|
133
|
+
.Append(",\"path\":").Append(Quote(files[i].Path))
|
|
134
|
+
.Append(",\"size\":").Append(files[i].Size).Append('}');
|
|
135
|
+
}
|
|
136
|
+
sb.Append("],");
|
|
137
|
+
sb.Append("\"writtenAt\":").Append(DateTimeOffset.Now.ToUnixTimeMilliseconds());
|
|
138
|
+
sb.Append('}');
|
|
139
|
+
|
|
140
|
+
Directory.CreateDirectory(RmfmailDir);
|
|
141
|
+
var target = Path.Combine(RmfmailDir, "pending-share.json");
|
|
142
|
+
var tmp = target + ".tmp";
|
|
143
|
+
File.WriteAllText(tmp, sb.ToString(), new UTF8Encoding(false));
|
|
144
|
+
File.Move(tmp, target, overwrite: true);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private static string Quote(string s)
|
|
148
|
+
{
|
|
149
|
+
var sb = new StringBuilder(s.Length + 2);
|
|
150
|
+
sb.Append('"');
|
|
151
|
+
foreach (var c in s)
|
|
152
|
+
{
|
|
153
|
+
switch (c)
|
|
154
|
+
{
|
|
155
|
+
case '"': sb.Append("\\\""); break;
|
|
156
|
+
case '\\': sb.Append("\\\\"); break;
|
|
157
|
+
case '\n': sb.Append("\\n"); break;
|
|
158
|
+
case '\r': sb.Append("\\r"); break;
|
|
159
|
+
case '\t': sb.Append("\\t"); break;
|
|
160
|
+
default:
|
|
161
|
+
if (c < ' ') sb.Append("\\u").Append(((int)c).ToString("x4"));
|
|
162
|
+
else sb.Append(c);
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
sb.Append('"');
|
|
167
|
+
return sb.ToString();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/// Same node + mailx.js lookup as the Rust rmfmailto launcher: env
|
|
171
|
+
/// overrides first, then the standard npm -g install location, then PATH.
|
|
172
|
+
/// A bare `node mailx.js` is correct both ways — if a daemon is already
|
|
173
|
+
/// running the new process exits at the instance check (fs.watch on the
|
|
174
|
+
/// pending file has already fired); otherwise it becomes the daemon and
|
|
175
|
+
/// the client's startup poll consumes the file.
|
|
176
|
+
private static void LaunchDaemon()
|
|
177
|
+
{
|
|
178
|
+
var mailxJs = Environment.GetEnvironmentVariable("RMFMAIL_JS");
|
|
179
|
+
if (string.IsNullOrEmpty(mailxJs) || !File.Exists(mailxJs))
|
|
180
|
+
{
|
|
181
|
+
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
|
|
182
|
+
mailxJs = Path.Combine(appData, "npm", "node_modules",
|
|
183
|
+
"@bobfrankston", "rmfmail", "bin", "mailx.js");
|
|
184
|
+
}
|
|
185
|
+
if (!File.Exists(mailxJs))
|
|
186
|
+
{
|
|
187
|
+
ShowError($"rmfmail not found at {mailxJs}.\nInstall with: npm install -g @bobfrankston/rmfmail");
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
var node = FindNode();
|
|
192
|
+
if (node == null)
|
|
193
|
+
{
|
|
194
|
+
ShowError("node.exe not found on PATH — rmfmail needs Node.js installed.");
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
var psi = new ProcessStartInfo
|
|
199
|
+
{
|
|
200
|
+
FileName = node,
|
|
201
|
+
UseShellExecute = false,
|
|
202
|
+
CreateNoWindow = true,
|
|
203
|
+
WorkingDirectory = Path.GetDirectoryName(mailxJs)!,
|
|
204
|
+
};
|
|
205
|
+
psi.ArgumentList.Add(mailxJs);
|
|
206
|
+
Process.Start(psi);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private static string? FindNode()
|
|
210
|
+
{
|
|
211
|
+
var env = Environment.GetEnvironmentVariable("NODE_EXE");
|
|
212
|
+
if (!string.IsNullOrEmpty(env) && File.Exists(env)) return env;
|
|
213
|
+
foreach (var dir in (Environment.GetEnvironmentVariable("PATH") ?? "").Split(';'))
|
|
214
|
+
{
|
|
215
|
+
if (dir.Length == 0) continue;
|
|
216
|
+
try
|
|
217
|
+
{
|
|
218
|
+
var candidate = Path.Combine(dir.Trim(), "node.exe");
|
|
219
|
+
if (File.Exists(candidate)) return candidate;
|
|
220
|
+
}
|
|
221
|
+
catch { /* malformed PATH entry */ }
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<Project Sdk="Microsoft.NET.Sdk">
|
|
2
|
+
<!--
|
|
3
|
+
rmfshare — Windows Share-target activation helper (C46).
|
|
4
|
+
|
|
5
|
+
Declared as the Application executable in the sparse AppxManifest that
|
|
6
|
+
`rmfmail -register-share` registers (see bin/share-target.ts). When the
|
|
7
|
+
user picks "rmfmail" in the Windows Share sheet, Windows launches this
|
|
8
|
+
exe with ShareTarget activation; we read the shared data via WinRT,
|
|
9
|
+
stage any files under ~/.rmfmail/share-staging/, drop
|
|
10
|
+
~/.rmfmail/pending-share.json, and poke the daemon (same pending-file
|
|
11
|
+
pattern as the P115 mailto handler).
|
|
12
|
+
|
|
13
|
+
NativeAOT so the shipped bin/rmfshare.exe is a single self-contained
|
|
14
|
+
binary — no .NET runtime required on install machines (mirrors the
|
|
15
|
+
checked-in Rust rmfmailto.exe). Build via bin/build-rmfshare-exe.js
|
|
16
|
+
(hooked into `npm run build`); requires the .NET 10 SDK + VS C++ tools
|
|
17
|
+
on the BUILD machine only.
|
|
18
|
+
-->
|
|
19
|
+
<PropertyGroup>
|
|
20
|
+
<OutputType>WinExe</OutputType>
|
|
21
|
+
<TargetFramework>net10.0-windows10.0.19041.0</TargetFramework>
|
|
22
|
+
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
|
|
23
|
+
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
|
24
|
+
<PublishAot>true</PublishAot>
|
|
25
|
+
<SelfContained>true</SelfContained>
|
|
26
|
+
<InvariantGlobalization>true</InvariantGlobalization>
|
|
27
|
+
<ImplicitUsings>enable</ImplicitUsings>
|
|
28
|
+
<Nullable>enable</Nullable>
|
|
29
|
+
<AssemblyName>rmfshare</AssemblyName>
|
|
30
|
+
<!-- FileDescription — anything that surfaces the exe name shows "rmfmail" -->
|
|
31
|
+
<AssemblyTitle>rmfmail</AssemblyTitle>
|
|
32
|
+
<Product>rmfmail</Product>
|
|
33
|
+
<Company>Bob Frankston</Company>
|
|
34
|
+
<Version>1.0.0</Version>
|
|
35
|
+
</PropertyGroup>
|
|
36
|
+
</Project>
|
package/bin/rmfshare.exe
ADDED
|
Binary file
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows Share-target integration (C46).
|
|
3
|
+
*
|
|
4
|
+
* Share flow when rmfmail is picked in the Windows Share sheet:
|
|
5
|
+
*
|
|
6
|
+
* 1. Windows activates rmfshare.exe (bin/rmfshare-src/, declared in the
|
|
7
|
+
* AppxManifest registered by `rmfmail -register-share`).
|
|
8
|
+
* 2. rmfshare.exe copies shared files to ~/.rmfmail/share-staging/<stamp>/,
|
|
9
|
+
* writes ~/.rmfmail/pending-share.json (atomic rename), and launches
|
|
10
|
+
* `node mailx.js` — which exits at the instance check if a daemon
|
|
11
|
+
* already runs (its fs.watch on the pending file has fired) or becomes
|
|
12
|
+
* the daemon (the client's startup poll consumes the file).
|
|
13
|
+
* 3. The daemon loads the staged files as base64 and pushes an `openShare`
|
|
14
|
+
* event; the client opens compose with the shared content attached.
|
|
15
|
+
*
|
|
16
|
+
* Same pending-file indirection as the P115 mailto handler (bin/mailto.ts) —
|
|
17
|
+
* no IPC socket needed, one share = one file write.
|
|
18
|
+
*
|
|
19
|
+
* Registration is a "loose" AppX deployment: a staged folder holding
|
|
20
|
+
* rmfshare.exe + AppxManifest.xml + Assets, registered per-user via
|
|
21
|
+
* `Add-AppxPackage -Register`. That requires Developer Mode (unsigned
|
|
22
|
+
* manifest) — the registration path checks and says so. No MSIX signing,
|
|
23
|
+
* no Store, no admin.
|
|
24
|
+
*/
|
|
25
|
+
import fs from "node:fs";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
import { execFileSync } from "node:child_process";
|
|
28
|
+
/** Attachments larger than this are skipped (typical SMTP servers reject
|
|
29
|
+
* messages this size anyway); the skip is reported, never silent. */
|
|
30
|
+
export const SHARE_ATTACHMENT_MAX_BYTES = 25 * 1024 * 1024;
|
|
31
|
+
const IDENTITY_NAME = "BobFrankston.rmfmail";
|
|
32
|
+
function rmfmailDir() {
|
|
33
|
+
const home = process.env.USERPROFILE || process.env.HOME || ".";
|
|
34
|
+
return path.join(home, ".rmfmail");
|
|
35
|
+
}
|
|
36
|
+
export function getPendingShareFile() {
|
|
37
|
+
return path.join(rmfmailDir(), "pending-share.json");
|
|
38
|
+
}
|
|
39
|
+
/** Read + delete the pending-share file. One-shot, same contract as
|
|
40
|
+
* consumePendingMailto: caller acts on the result, the file is gone either
|
|
41
|
+
* way, stale files (crashed handoff) are dropped. */
|
|
42
|
+
export function consumePendingShare() {
|
|
43
|
+
const target = getPendingShareFile();
|
|
44
|
+
let raw;
|
|
45
|
+
try {
|
|
46
|
+
raw = fs.readFileSync(target, "utf-8");
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
fs.unlinkSync(target);
|
|
53
|
+
}
|
|
54
|
+
catch { /* */ }
|
|
55
|
+
let parsed;
|
|
56
|
+
try {
|
|
57
|
+
parsed = JSON.parse(raw);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
const age = Date.now() - (parsed.writtenAt || 0);
|
|
63
|
+
if (age > 10 * 60_000) {
|
|
64
|
+
// Crashed handoff — user shared but nothing consumed it for 10
|
|
65
|
+
// minutes. Drop it (staging cleanup reaps the copied files).
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
parsed.title = parsed.title || "";
|
|
69
|
+
parsed.text = parsed.text || "";
|
|
70
|
+
parsed.url = parsed.url || "";
|
|
71
|
+
parsed.files = Array.isArray(parsed.files) ? parsed.files : [];
|
|
72
|
+
return parsed;
|
|
73
|
+
}
|
|
74
|
+
const MIME_BY_EXT = {
|
|
75
|
+
".pdf": "application/pdf",
|
|
76
|
+
".png": "image/png",
|
|
77
|
+
".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
|
78
|
+
".gif": "image/gif",
|
|
79
|
+
".webp": "image/webp",
|
|
80
|
+
".bmp": "image/bmp",
|
|
81
|
+
".svg": "image/svg+xml",
|
|
82
|
+
".heic": "image/heic",
|
|
83
|
+
".txt": "text/plain", ".log": "text/plain", ".md": "text/plain",
|
|
84
|
+
".html": "text/html", ".htm": "text/html",
|
|
85
|
+
".csv": "text/csv",
|
|
86
|
+
".json": "application/json",
|
|
87
|
+
".xml": "application/xml",
|
|
88
|
+
".zip": "application/zip",
|
|
89
|
+
".7z": "application/x-7z-compressed",
|
|
90
|
+
".doc": "application/msword",
|
|
91
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
92
|
+
".xls": "application/vnd.ms-excel",
|
|
93
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
94
|
+
".ppt": "application/vnd.ms-powerpoint",
|
|
95
|
+
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
96
|
+
".eml": "message/rfc822",
|
|
97
|
+
".ics": "text/calendar",
|
|
98
|
+
".mp3": "audio/mpeg",
|
|
99
|
+
".mp4": "video/mp4",
|
|
100
|
+
".mov": "video/quicktime",
|
|
101
|
+
};
|
|
102
|
+
export function mimeForFilename(name) {
|
|
103
|
+
return MIME_BY_EXT[path.extname(name).toLowerCase()] || "application/octet-stream";
|
|
104
|
+
}
|
|
105
|
+
/** Load staged share files as compose-init attachments (base64). Oversized
|
|
106
|
+
* or unreadable files land in `skipped` with a reason — surfaced to the
|
|
107
|
+
* user in the compose body, never dropped silently. Consumed staging dirs
|
|
108
|
+
* are deleted afterwards; the content lives on in the compose draft. */
|
|
109
|
+
export function loadShareAttachments(files) {
|
|
110
|
+
const attachments = [];
|
|
111
|
+
const skipped = [];
|
|
112
|
+
const dirs = new Set();
|
|
113
|
+
for (const f of files) {
|
|
114
|
+
try {
|
|
115
|
+
const st = fs.statSync(f.path);
|
|
116
|
+
if (st.size > SHARE_ATTACHMENT_MAX_BYTES) {
|
|
117
|
+
skipped.push({ name: f.name, reason: `larger than ${Math.round(SHARE_ATTACHMENT_MAX_BYTES / 1024 / 1024)} MB` });
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
attachments.push({
|
|
121
|
+
filename: f.name,
|
|
122
|
+
mimeType: mimeForFilename(f.name),
|
|
123
|
+
dataBase64: fs.readFileSync(f.path).toString("base64"),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
dirs.add(path.dirname(f.path));
|
|
127
|
+
}
|
|
128
|
+
catch (e) {
|
|
129
|
+
skipped.push({ name: f.name, reason: e?.message || "unreadable" });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
for (const d of dirs) {
|
|
133
|
+
// Only reap inside our own staging root — a hand-written pending
|
|
134
|
+
// file must never turn this into an arbitrary directory delete.
|
|
135
|
+
if (d.startsWith(path.join(rmfmailDir(), "share-staging") + path.sep)) {
|
|
136
|
+
try {
|
|
137
|
+
fs.rmSync(d, { recursive: true, force: true });
|
|
138
|
+
}
|
|
139
|
+
catch { /* next startup sweep */ }
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return { attachments, skipped };
|
|
143
|
+
}
|
|
144
|
+
/** Reap staging dirs older than 2 days — leftovers from shares that never
|
|
145
|
+
* got consumed (daemon crash between copy and compose). Called once at
|
|
146
|
+
* daemon startup. */
|
|
147
|
+
export function cleanupShareStaging() {
|
|
148
|
+
const root = path.join(rmfmailDir(), "share-staging");
|
|
149
|
+
let entries;
|
|
150
|
+
try {
|
|
151
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const cutoff = Date.now() - 2 * 24 * 3600_000;
|
|
157
|
+
for (const e of entries) {
|
|
158
|
+
if (!e.isDirectory())
|
|
159
|
+
continue;
|
|
160
|
+
const full = path.join(root, e.name);
|
|
161
|
+
try {
|
|
162
|
+
if (fs.statSync(full).mtimeMs < cutoff) {
|
|
163
|
+
fs.rmSync(full, { recursive: true, force: true });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
catch { /* in use or already gone */ }
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// ── Registration (Windows only) ─────────────────────────────────────────
|
|
170
|
+
function buildAppxManifest(version) {
|
|
171
|
+
// Unsigned loose deployment: Publisher only has to be well-formed, it
|
|
172
|
+
// isn't validated against a certificate. AppListEntry="none" keeps the
|
|
173
|
+
// entry out of the Start menu — rmfshare.exe is a share sink, not an
|
|
174
|
+
// app the user launches.
|
|
175
|
+
return `<?xml version="1.0" encoding="utf-8"?>
|
|
176
|
+
<Package
|
|
177
|
+
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
|
|
178
|
+
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
|
|
179
|
+
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
|
|
180
|
+
IgnorableNamespaces="uap rescap">
|
|
181
|
+
<Identity Name="${IDENTITY_NAME}" Publisher="CN=Bob Frankston" Version="${version}" ProcessorArchitecture="x64" />
|
|
182
|
+
<Properties>
|
|
183
|
+
<DisplayName>rmfmail</DisplayName>
|
|
184
|
+
<PublisherDisplayName>Bob Frankston</PublisherDisplayName>
|
|
185
|
+
<Logo>Assets\\logo150.png</Logo>
|
|
186
|
+
</Properties>
|
|
187
|
+
<Dependencies>
|
|
188
|
+
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.19041.0" MaxVersionTested="10.0.26100.0" />
|
|
189
|
+
</Dependencies>
|
|
190
|
+
<Resources>
|
|
191
|
+
<Resource Language="en-us" />
|
|
192
|
+
</Resources>
|
|
193
|
+
<Applications>
|
|
194
|
+
<Application Id="rmfmail" Executable="rmfshare.exe" EntryPoint="Windows.FullTrustApplication">
|
|
195
|
+
<uap:VisualElements DisplayName="rmfmail" Description="Share to rmfmail"
|
|
196
|
+
BackgroundColor="transparent"
|
|
197
|
+
Square150x150Logo="Assets\\logo150.png" Square44x44Logo="Assets\\logo44.png"
|
|
198
|
+
AppListEntry="none" />
|
|
199
|
+
<Extensions>
|
|
200
|
+
<uap:Extension Category="windows.shareTarget">
|
|
201
|
+
<uap:ShareTarget Description="Share to rmfmail">
|
|
202
|
+
<uap:SupportedFileTypes>
|
|
203
|
+
<uap:SupportsAnyFileType />
|
|
204
|
+
</uap:SupportedFileTypes>
|
|
205
|
+
<uap:DataFormat>Text</uap:DataFormat>
|
|
206
|
+
<uap:DataFormat>WebLink</uap:DataFormat>
|
|
207
|
+
<uap:DataFormat>StorageItems</uap:DataFormat>
|
|
208
|
+
</uap:ShareTarget>
|
|
209
|
+
</uap:Extension>
|
|
210
|
+
</Extensions>
|
|
211
|
+
</Application>
|
|
212
|
+
</Applications>
|
|
213
|
+
<Capabilities>
|
|
214
|
+
<rescap:Capability Name="runFullTrust" />
|
|
215
|
+
</Capabilities>
|
|
216
|
+
</Package>
|
|
217
|
+
`;
|
|
218
|
+
}
|
|
219
|
+
function powershell(script) {
|
|
220
|
+
return execFileSync("powershell", ["-NoProfile", "-NonInteractive", "-Command", script], { stdio: ["pipe", "pipe", "pipe"] }).toString();
|
|
221
|
+
}
|
|
222
|
+
function developerModeEnabled() {
|
|
223
|
+
try {
|
|
224
|
+
const out = powershell("(Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\AppModelUnlock' -ErrorAction SilentlyContinue).AllowDevelopmentWithoutDevLicense");
|
|
225
|
+
return out.trim() === "1";
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/** Register (or unregister) rmfmail as a Windows Share target. Mirrors
|
|
232
|
+
* registerMailtoWindows in mailx.ts: returns outcome text instead of
|
|
233
|
+
* printing so the CLI and any future Settings toggle share one path. */
|
|
234
|
+
export function registerShareWindows(unregister) {
|
|
235
|
+
const lines = [];
|
|
236
|
+
if (unregister) {
|
|
237
|
+
try {
|
|
238
|
+
powershell(`Get-AppxPackage '${IDENTITY_NAME}' | Remove-AppxPackage`);
|
|
239
|
+
lines.push("rmfmail removed from the Windows Share menu.");
|
|
240
|
+
}
|
|
241
|
+
catch (e) {
|
|
242
|
+
return { ok: false, message: `Remove-AppxPackage failed: ${e?.stderr?.toString?.() || e.message}` };
|
|
243
|
+
}
|
|
244
|
+
return { ok: true, message: lines.join("\n") };
|
|
245
|
+
}
|
|
246
|
+
const pkgExe = path.join(import.meta.dirname, "rmfshare.exe");
|
|
247
|
+
if (!fs.existsSync(pkgExe)) {
|
|
248
|
+
return { ok: false, message: `rmfshare.exe not found at ${pkgExe} — package was built without it. Re-run 'npm run build' on the build machine.` };
|
|
249
|
+
}
|
|
250
|
+
if (!developerModeEnabled()) {
|
|
251
|
+
return {
|
|
252
|
+
ok: false,
|
|
253
|
+
message: "Registering an unsigned Share target needs Windows Developer Mode:\n" +
|
|
254
|
+
" Settings → System → For developers → Developer Mode → On\n" +
|
|
255
|
+
"then re-run `rmfmail -register-share`.",
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
// Stage under %LOCALAPPDATA%\rmfmail\share-target — NOT inside
|
|
259
|
+
// node_modules: the registered package points at absolute paths, and an
|
|
260
|
+
// npm upgrade replacing node_modules would strand the registration.
|
|
261
|
+
const stageDir = path.join(process.env.LOCALAPPDATA || rmfmailDir(), "rmfmail", "share-target");
|
|
262
|
+
const assetsDir = path.join(stageDir, "Assets");
|
|
263
|
+
fs.mkdirSync(assetsDir, { recursive: true });
|
|
264
|
+
fs.copyFileSync(pkgExe, path.join(stageDir, "rmfshare.exe"));
|
|
265
|
+
// Manifest logos scaled from the app icon at register time (System.
|
|
266
|
+
// Drawing via PowerShell — registration is already a Windows-only,
|
|
267
|
+
// PowerShell-driven path).
|
|
268
|
+
const iconPng = path.join(import.meta.dirname, "..", "client", "icon.png");
|
|
269
|
+
try {
|
|
270
|
+
powershell(`
|
|
271
|
+
Add-Type -AssemblyName System.Drawing
|
|
272
|
+
$src = [System.Drawing.Image]::FromFile('${iconPng.replace(/'/g, "''")}')
|
|
273
|
+
foreach ($s in 44,150) {
|
|
274
|
+
$bmp = New-Object System.Drawing.Bitmap $s, $s
|
|
275
|
+
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
|
276
|
+
$g.InterpolationMode = 'HighQualityBicubic'
|
|
277
|
+
$g.DrawImage($src, 0, 0, $s, $s)
|
|
278
|
+
$bmp.Save('${assetsDir.replace(/'/g, "''")}\\logo' + $s + '.png', [System.Drawing.Imaging.ImageFormat]::Png)
|
|
279
|
+
$g.Dispose(); $bmp.Dispose()
|
|
280
|
+
}
|
|
281
|
+
$src.Dispose()`);
|
|
282
|
+
}
|
|
283
|
+
catch (e) {
|
|
284
|
+
return { ok: false, message: `Icon scaling failed: ${e?.stderr?.toString?.() || e.message}` };
|
|
285
|
+
}
|
|
286
|
+
// Manifest version tracks the package version so re-registering after an
|
|
287
|
+
// upgrade redeploys cleanly (4th part must be 0 for AppX).
|
|
288
|
+
let pkgVersion = "1.0.0";
|
|
289
|
+
try {
|
|
290
|
+
pkgVersion = JSON.parse(fs.readFileSync(path.join(import.meta.dirname, "..", "package.json"), "utf-8")).version || pkgVersion;
|
|
291
|
+
}
|
|
292
|
+
catch { /* fall back */ }
|
|
293
|
+
const appxVersion = pkgVersion.split(".").slice(0, 3).map(p => String(parseInt(p, 10) || 0)).join(".") + ".0";
|
|
294
|
+
fs.writeFileSync(path.join(stageDir, "AppxManifest.xml"), buildAppxManifest(appxVersion));
|
|
295
|
+
try {
|
|
296
|
+
// Remove any prior registration first — Add-AppxPackage -Register
|
|
297
|
+
// rejects same-version re-registration, and downgrades after a
|
|
298
|
+
// rollback would fail outright.
|
|
299
|
+
powershell(`Get-AppxPackage '${IDENTITY_NAME}' | Remove-AppxPackage`);
|
|
300
|
+
}
|
|
301
|
+
catch { /* not registered — fine */ }
|
|
302
|
+
try {
|
|
303
|
+
powershell(`Add-AppxPackage -Register '${path.join(stageDir, "AppxManifest.xml").replace(/'/g, "''")}'`);
|
|
304
|
+
}
|
|
305
|
+
catch (e) {
|
|
306
|
+
return { ok: false, message: `Add-AppxPackage failed: ${e?.stderr?.toString?.() || e.message}` };
|
|
307
|
+
}
|
|
308
|
+
lines.push("rmfmail registered as a Windows Share target.");
|
|
309
|
+
lines.push("It now appears in the Share sheet (Explorer right-click → Share, browser Share, etc.).");
|
|
310
|
+
lines.push(`Staged at: ${stageDir}`);
|
|
311
|
+
lines.push("Note: re-run -register-share after major upgrades so the staged rmfshare.exe stays current.");
|
|
312
|
+
return { ok: true, message: lines.join("\n") };
|
|
313
|
+
}
|
|
314
|
+
//# sourceMappingURL=share-target.js.map
|