channels: add native share-link lifecycle

This commit is contained in:
DeFiDude 2026-07-31 04:21:57 -06:00
parent b777e97548
commit 20207eb9e6
16 changed files with 843 additions and 6 deletions

View file

@ -105,7 +105,12 @@ fn build_channel_share_target(
})
}
fn parse_channel_share_target(payload: &str) -> Result<ChannelShareTarget, String> {
/// Parse one canonical, key-free Ratspeak channel share target.
///
/// This is the shared authority for pasted/QR shares and native deep links.
/// Native shell integrations must never interpret or persist the raw URL
/// themselves.
pub fn parse_channel_share_target(payload: &str) -> Result<ChannelShareTarget, String> {
if payload != payload.trim() {
return Err("Channel share is not in canonical form".into());
}

View file

@ -402,6 +402,142 @@ fn channels_keep_hubs_live_only_and_wire_bounded_local_history_across_the_produc
assert!(share_test.contains("channel share tests passed"));
}
#[test]
fn native_channel_share_lifecycle_uses_rust_inbox_and_requires_preview() {
let root = repo_root();
let index = read_source(root.join("dashboard/index.html")).expect("dashboard index");
let channels =
read_source(root.join("dashboard/static/js/channels.js")).expect("channels frontend");
let bridge = read_source(root.join("dashboard/static/js/native_channel_share.js"))
.expect("native channel-share bridge");
let bridge_test = read_source(root.join("dashboard/scripts/test_channels_native_link.js"))
.expect("native channel-share test");
let native =
read_source(root.join("src-tauri/src/channel_deep_link.rs")).expect("native Rust bridge");
let lib = read_source(root.join("src-tauri/src/lib.rs")).expect("Tauri entry point");
let cargo = read_source(root.join("src-tauri/Cargo.toml")).expect("Tauri manifest");
let base_config: serde_json::Value = serde_json::from_str(
&read_source(root.join("src-tauri/tauri.conf.json")).expect("base Tauri config"),
)
.expect("valid base Tauri config");
let android_config: serde_json::Value = serde_json::from_str(
&read_source(root.join("src-tauri/tauri.android.conf.json")).expect("Android Tauri config"),
)
.expect("valid Android Tauri config");
let ios_config: serde_json::Value = serde_json::from_str(
&read_source(root.join("src-tauri/tauri.ios.conf.json")).expect("iOS Tauri config"),
)
.expect("valid iOS Tauri config");
assert!(base_config["plugins"]["deep-link"].is_null());
assert!(android_config["plugins"]["deep-link"].is_null());
assert!(ios_config["plugins"]["deep-link"].is_null());
for platform in ["linux", "macos", "windows"] {
let config: serde_json::Value = serde_json::from_str(
&read_source(
root.join("src-tauri")
.join(format!("tauri.{platform}.conf.json")),
)
.expect("desktop Tauri config"),
)
.expect("valid desktop Tauri config");
assert_eq!(
config["plugins"]["deep-link"]["desktop"]["schemes"],
serde_json::json!(["ratspeak"])
);
assert!(config["plugins"]["deep-link"]["mobile"].is_null());
}
let android_manifest =
read_source(root.join("src-tauri/gen/android/app/src/main/AndroidManifest.xml"))
.expect("Android manifest");
let ios_info = read_source(root.join("src-tauri/gen/apple/ratspeak_iOS/Info.plist"))
.expect("iOS Info.plist");
let ios_entitlements =
read_source(root.join("src-tauri/gen/apple/ratspeak_iOS/ratspeak_iOS.entitlements"))
.expect("iOS entitlements");
assert!(android_manifest.contains(r#"android:scheme="ratspeak""#));
assert!(android_manifest.contains(r#"android:host="channel""#));
assert!(android_manifest.contains("android.intent.category.BROWSABLE"));
assert!(ios_info.contains("<key>CFBundleURLTypes</key>"));
assert!(ios_info.contains("<string>ratspeak</string>"));
assert!(ios_entitlements.contains("Multicast Networking entitlement"));
assert!(!ios_entitlements.contains("com.apple.developer.associated-domains"));
assert!(cargo.contains(r#"tauri-plugin-deep-link = "2.4.9""#));
assert!(
cargo.contains(
r#"tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }"#
)
);
let single_instance = lib
.find(".plugin(tauri_plugin_single_instance::init")
.expect("single-instance plugin");
let deep_link = lib
.find(".plugin(tauri_plugin_deep_link::init())")
.expect("deep-link plugin");
let notification = lib
.find(".plugin(tauri_plugin_notification::init())")
.expect("notification plugin");
assert!(
single_instance < deep_link && single_instance < notification,
"single-instance must be the first plugin for secondary-process URLs"
);
assert!(lib.contains("channel_deep_link::NativeChannelShareInbox::default()"));
assert!(lib.contains("channel_deep_link::take_native_channel_share"));
assert!(lib.contains("channel_deep_link::install(app)"));
assert!(native.contains("Mutex<Option<ChannelShareTarget>>"));
assert!(native.contains("parse_channel_share_target(payload)"));
assert!(native.contains("app.emit(NATIVE_CHANNEL_SHARE_AVAILABLE, ())"));
assert!(native.contains("app.deep_link().on_open_url"));
assert!(native.contains("app.deep_link().get_current()"));
assert!(!native.contains("std::fs"));
assert!(!native.contains("localStorage"));
let mut capability_files = Vec::new();
collect_files(&root.join("src-tauri/capabilities"), &mut capability_files);
let capabilities = capability_files
.iter()
.map(|path| read_source(path).expect("capability source"))
.collect::<Vec<_>>()
.join("\n");
assert!(
!capabilities.contains("deep-link:"),
"the deep-link plugin command API must not be callable from JavaScript"
);
let channels_pos = index
.find("/static/js/channels.js")
.expect("channels script");
let bridge_pos = index
.find("/static/js/native_channel_share.js")
.expect("native channel-share bridge");
let events_pos = index
.find("/static/js/tauri_events.js")
.expect("general Tauri event bridge");
assert!(channels_pos < bridge_pos && bridge_pos < events_pos);
assert!(channels.contains("function channelsOpenNativeSharedChannel(target)"));
assert!(channels.contains("_channelsPresentSharedTarget(target);"));
assert!(channels.contains("hasOwnProperty.call(target, 'key')"));
assert!(channels.contains("hasOwnProperty.call(target, 'join_key')"));
assert!(bridge.contains("RS.invoke('take_native_channel_share')"));
assert!(bridge.contains("'native_channel_share_available'"));
assert!(bridge.contains("_isSetupActive()"));
assert!(bridge.contains(".bottom-sheet.open"));
assert!(bridge.contains(".modal-overlay.active"));
assert!(bridge.contains(".game-modal-overlay"));
assert!(bridge.contains(".block-list-overlay"));
assert!(bridge.contains("#rs-image-viewer.open"));
assert!(bridge.contains(".action-popover.open"));
assert!(bridge.contains("MutationObserver"));
assert!(!bridge.contains("deep-link://new-url"));
assert!(!bridge.contains("localStorage"));
assert!(!bridge.contains("connect_channel_hub"));
assert!(!bridge.contains("join_channel"));
assert!(bridge_test.contains("native channel link tests passed"));
}
/// The hub persists operator policy and nothing else, creates rooms only for
/// the operator, and never stores a join key. Each assertion below stands for
/// a deliberate divergence from rrcd recorded in the fix registry; losing one

View file

@ -1848,13 +1848,14 @@
<script src="/static/js/vendor/jsQR.js"></script>
<script src="/static/js/contact_card.js"></script>
<script src="/static/js/lxmf.js"></script>
<script src="/static/js/channels.js?v=1.0.32"></script>
<script src="/static/js/channels.js?v=1.0.33"></script>
<script src="/static/js/channel_hub.js?v=1.0.29"></script>
<script src="/static/js/propagation.js"></script>
<script src="/static/js/settings.js"></script>
<script src="/static/js/identity.js"></script>
<script src="/static/js/confetti.js"></script>
<script src="/static/js/games_tab.js"></script>
<script src="/static/js/native_channel_share.js?v=1.0.1"></script>
<script src="/static/js/tauri_events.js"></script>
<script src="/static/js/init_dom_handlers.js"></script>
</body>

View file

@ -0,0 +1,158 @@
#!/usr/bin/env node
// Deterministic lifecycle coverage for the native channel-share inbox bridge.
'use strict';
var assert = require('assert');
var fs = require('fs');
var path = require('path');
var vm = require('vm');
var source = fs.readFileSync(
path.join(__dirname, '..', 'static', 'js', 'native_channel_share.js'),
'utf8'
);
async function main() {
var setupActive = true;
var dialogOpen = false;
var blockerSelector = '';
var observerCallback = null;
var timers = [];
var listener = null;
var invokes = [];
var presented = [];
var targets = [
{
format: 'ratspeak.channel.v1',
payload: 'ratspeak://channel?v=1&hub=00112233445566778899aabbccddeeff',
hub_destination_hash: '00112233445566778899aabbccddeeff',
room: null
},
{
format: 'ratspeak.channel.v1',
payload: 'ratspeak://channel?v=1&hub=ffeeddccbbaa99887766554433221100&room=field',
hub_destination_hash: 'ffeeddccbbaa99887766554433221100',
room: 'field'
}
];
var rs = {
diag: function() {},
invoke: function(command) {
invokes.push(command);
return Promise.resolve(targets.shift() || null);
},
listen: function(eventName, handler, options) {
assert.strictEqual(eventName, 'native_channel_share_available');
assert.strictEqual(options.required, true);
assert.deepStrictEqual(Object.keys(options), ['required']);
listener = handler;
return Promise.resolve(function() {});
}
};
var context = {
window: {
__RATSPEAK_DESKTOP__: true,
__RATSPEAK_MOBILE__: false,
RS: rs,
channelsOpenNativeSharedChannel: function(target) {
presented.push(target);
return true;
}
},
RS: rs,
document: {
body: {},
querySelector: function(selector) {
blockerSelector = selector;
return dialogOpen ? {} : null;
}
},
_isSetupActive: function() {
return setupActive;
},
MutationObserver: function(callback) {
observerCallback = callback;
this.observe = function() {};
},
setTimeout: function(callback, delay) {
timers.push({ callback: callback, delay: delay || 0 });
return timers.length;
}
};
function microtasks() {
return Promise.resolve().then(function() {
return Promise.resolve();
}).then(function() {
return Promise.resolve();
});
}
async function settle() {
await microtasks();
for (var round = 0; round < 20 && timers.length; round++) {
var pending = timers;
timers = [];
pending.forEach(function(timer) {
timer.callback();
});
await microtasks();
}
assert(timers.length === 0, 'native bridge timer loop did not settle');
}
vm.runInNewContext(source, context, {
filename: 'native-channel-share.js'
});
await settle();
assert.strictEqual(invokes.length, 0,
'cold-start target must remain in Rust throughout setup');
assert.strictEqual(presented.length, 0);
assert.strictEqual(typeof listener, 'function');
setupActive = false;
observerCallback();
await settle();
assert(blockerSelector.indexOf('.bottom-sheet.open') !== -1);
assert(blockerSelector.indexOf('.modal-overlay.active') !== -1);
assert(blockerSelector.indexOf('.game-modal-overlay') !== -1);
assert(blockerSelector.indexOf('.block-list-overlay') !== -1);
assert(blockerSelector.indexOf('#rs-image-viewer.open') !== -1);
assert(blockerSelector.indexOf('.action-popover.open') !== -1);
assert(blockerSelector.indexOf('[class*="-scrim"].active') !== -1);
assert.deepStrictEqual(invokes, ['take_native_channel_share']);
assert.strictEqual(presented.length, 1);
assert.strictEqual(
presented[0].hub_destination_hash,
'00112233445566778899aabbccddeeff'
);
dialogOpen = true;
listener();
await settle();
assert.strictEqual(invokes.length, 1,
'a running-app target must stay in Rust while another dialog is open');
assert.strictEqual(presented.length, 1);
dialogOpen = false;
observerCallback();
await settle();
assert.deepStrictEqual(invokes, [
'take_native_channel_share',
'take_native_channel_share'
]);
assert.strictEqual(presented.length, 2);
assert.strictEqual(
presented[1].hub_destination_hash,
'ffeeddccbbaa99887766554433221100'
);
console.log('native channel link tests passed');
}
main().catch(function(error) {
console.error(error);
process.exitCode = 1;
});

View file

@ -17,6 +17,10 @@ var contactCardSource = fs.readFileSync(
path.join(__dirname, '..', 'static', 'js', 'contact_card.js'),
'utf8'
);
var nativeShareSource = fs.readFileSync(
path.join(__dirname, '..', 'static', 'js', 'native_channel_share.js'),
'utf8'
);
function sourceRange(startName, endName) {
var start = channelsSource.indexOf('function ' + startName);
@ -130,8 +134,13 @@ function main() {
'preview code must not join as a side effect');
assert(shareFlow.indexOf('localStorage') === -1,
'imported targets and pending transitions must stay ephemeral');
assert(shareFlow.indexOf('join_key') === -1 && shareFlow.indexOf('key:') === -1,
'share generation and parsing must not accept a room key field');
assert(shareFlow.indexOf('key:') === -1,
'share generation and parsing must not construct a room key field');
assert(
shareFlow.indexOf("hasOwnProperty.call(target, 'key')") !== -1 &&
shareFlow.indexOf("hasOwnProperty.call(target, 'join_key')") !== -1,
'the native typed boundary must explicitly reject any injected key field'
);
assert(connectSheet.indexOf("sharedRoom ? 'Connect and review' : 'Connect'") !== -1);
assert(connectSheet.indexOf('preserve_pending_share: true') !== -1);
@ -141,6 +150,62 @@ function main() {
assert(contactCardSource.indexOf('window.RS.qr = {') !== -1,
'channel shares must reuse the common QR implementation');
assert(contactCardSource.indexOf('openScanner: openContactQrScanner') !== -1);
assert(nativeShareSource.indexOf("RS.invoke('take_native_channel_share')") !== -1,
'native URLs must drain only the app-owned typed inbox');
assert(nativeShareSource.indexOf("'native_channel_share_available'") !== -1);
assert(nativeShareSource.indexOf('_isSetupActive()') !== -1,
'native previews must wait until first-run setup is complete');
assert(nativeShareSource.indexOf('.bottom-sheet.open') !== -1,
'native previews must not stack over an existing decision sheet');
assert(nativeShareSource.indexOf('.modal-overlay.active') !== -1 &&
nativeShareSource.indexOf('.game-modal-overlay') !== -1 &&
nativeShareSource.indexOf('.block-list-overlay') !== -1 &&
nativeShareSource.indexOf('#rs-image-viewer.open') !== -1 &&
nativeShareSource.indexOf('.action-popover.open') !== -1,
'native previews must wait for non-sheet decision surfaces too');
assert(nativeShareSource.indexOf('deep-link://new-url') === -1,
'the frontend must not subscribe to the plugin URL event');
assert(nativeShareSource.indexOf('localStorage') === -1,
'native targets must remain process-memory only');
assert(nativeShareSource.indexOf('connect_channel_hub') === -1);
assert(nativeShareSource.indexOf('join_channel') === -1);
var nativeEntry = sourceRange(
'channelsOpenNativeSharedChannel',
'channelsScanSharedChannel'
);
var presentedNativeTargets = [];
var nativeEntryContext = {
window: {},
Object: Object,
String: String,
_channelsPresentSharedTarget: function(target) {
presentedNativeTargets.push(target);
}
};
vm.runInNewContext(nativeEntry, nativeEntryContext, {
filename: 'channels-native-entry.js'
});
var validNativeTarget = {
format: 'ratspeak.channel.v1',
payload: 'ratspeak://channel?v=1&hub=00112233445566778899aabbccddeeff',
hub_destination_hash: '00112233445566778899aabbccddeeff',
room: null
};
assert.strictEqual(
nativeEntryContext.channelsOpenNativeSharedChannel(validNativeTarget),
true
);
assert.deepStrictEqual(presentedNativeTargets, [validNativeTarget]);
assert.strictEqual(
nativeEntryContext.channelsOpenNativeSharedChannel(Object.assign(
{},
validNativeTarget,
{ key: 'must-never-cross-this-boundary' }
)),
false
);
assert.strictEqual(presentedNativeTargets.length, 1);
var exact = applyContext(snapshot(1, 1, 'connecting', 'hub-a'));
exact.context.channelsPendingShareJoin = {

View file

@ -3006,6 +3006,27 @@ function _channelsPresentSharedTarget(target) {
_channelsPresentSheet(built, review);
}
// Native URI handling receives only the typed result of the canonical Rust
// parser. Keep this entry point preview-only: the sheet can lead the user to a
// separate connection or join review, but opening a URI performs neither.
function channelsOpenNativeSharedChannel(target) {
if (!target || target.format !== 'ratspeak.channel.v1') return false;
if (!/^[0-9a-f]{32}$/.test(String(target.hub_destination_hash || ''))) {
return false;
}
if (typeof target.payload !== 'string' || target.payload.length > 230) {
return false;
}
if (target.room != null && typeof target.room !== 'string') return false;
if (Object.prototype.hasOwnProperty.call(target, 'key') ||
Object.prototype.hasOwnProperty.call(target, 'join_key')) {
return false;
}
_channelsPresentSharedTarget(target);
return true;
}
window.channelsOpenNativeSharedChannel = channelsOpenNativeSharedChannel;
function channelsScanSharedChannel() {
if (!RS.qr || typeof RS.qr.openScanner !== 'function') {
if (typeof showToast === 'function') {

View file

@ -0,0 +1,136 @@
// Native `ratspeak://channel` handoff. This frontend never subscribes to the
// plugin's URL event: Rust validates platform-delivered URLs and this bridge
// drains only a typed, key-free preview target from a bounded memory inbox.
(function() {
'use strict';
var stagedTarget = null;
var drainInFlight = false;
var drainScheduled = false;
var needsDrain = true;
var listenerAttached = false;
var listenerAttempts = 0;
var observer = null;
function isNativeShell() {
return window.__RATSPEAK_DESKTOP__ === true ||
window.__RATSPEAK_MOBILE__ === true;
}
function uiBlocksChannelShare() {
if (typeof _isSetupActive === 'function' && _isSetupActive()) return true;
return !!document.querySelector(
'.bottom-sheet.open, .bottom-sheet-overlay.active, ' +
'.modal-overlay.active, .game-modal-overlay, .block-list-overlay, ' +
'#rs-image-viewer.open, .action-popover.open, ' +
'[class*="-scrim"].active, ' +
'[role="dialog"][aria-modal="true"]:not(.bottom-sheet)'
);
}
function scheduleDrain(delay) {
if (drainScheduled || !isNativeShell()) return;
drainScheduled = true;
setTimeout(function() {
drainScheduled = false;
drainNativeChannelShare();
}, delay || 0);
}
function presentStagedTarget() {
if (!stagedTarget || uiBlocksChannelShare()) return false;
var target = stagedTarget;
stagedTarget = null;
try {
if (typeof window.channelsOpenNativeSharedChannel !== 'function' ||
window.channelsOpenNativeSharedChannel(target) !== true) {
window.RS.diag(
'warn',
'[native-channel-share] rejected malformed typed target'
);
}
} catch (error) {
window.RS.diag(
'warn',
'[native-channel-share] could not present typed target:',
error
);
}
return true;
}
function drainNativeChannelShare() {
if (!isNativeShell()) return;
if (drainInFlight) return;
if (uiBlocksChannelShare()) return;
if (stagedTarget) {
presentStagedTarget();
return;
}
if (!needsDrain) return;
needsDrain = false;
drainInFlight = true;
RS.invoke('take_native_channel_share').then(function(target) {
if (target) stagedTarget = target;
}, function(error) {
needsDrain = true;
window.RS.diag(
'warn',
'[native-channel-share] inbox unavailable:',
error
);
}).then(function() {
drainInFlight = false;
presentStagedTarget();
if (needsDrain) scheduleDrain(500);
});
}
function signalNativeChannelShare() {
needsDrain = true;
scheduleDrain(0);
}
function attachNativeListener() {
if (!isNativeShell() || listenerAttached) return;
listenerAttempts++;
RS.listen(
'native_channel_share_available',
signalNativeChannelShare,
{ required: true }
).then(function() {
listenerAttached = true;
signalNativeChannelShare();
}, function(error) {
window.RS.diag(
'warn',
'[native-channel-share] listener unavailable:',
error
);
if (listenerAttempts < 30) {
setTimeout(attachNativeListener, 1000);
}
});
}
function installUiReadinessObserver() {
if (observer || typeof MutationObserver !== 'function' || !document.body) {
return;
}
observer = new MutationObserver(function() {
if (needsDrain || stagedTarget) scheduleDrain(0);
});
observer.observe(document.body, {
attributes: true,
attributeFilter: ['class'],
childList: true,
subtree: true
});
}
if (!isNativeShell()) return;
installUiReadinessObserver();
scheduleDrain(0);
attachNativeListener();
})();

92
src-tauri/Cargo.lock generated
View file

@ -845,6 +845,26 @@ version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "const-random"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
dependencies = [
"const-random-macro",
]
[[package]]
name = "const-random-macro"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
dependencies = [
"getrandom 0.2.17",
"once_cell",
"tiny-keccak",
]
[[package]]
name = "convert_case"
version = "0.4.0"
@ -1417,6 +1437,15 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "dlv-list"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
dependencies = [
"const-random",
]
[[package]]
name = "dom_query"
version = "0.25.1"
@ -3626,6 +3655,16 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
name = "opus-rs"
version = "0.1.19"
[[package]]
name = "ordered-multimap"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
dependencies = [
"dlv-list",
"hashbrown 0.14.5",
]
[[package]]
name = "ordered-stream"
version = "0.2.0"
@ -4361,6 +4400,7 @@ dependencies = [
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-deep-link",
"tauri-plugin-haptics",
"tauri-plugin-notification",
"tauri-plugin-single-instance",
@ -4852,6 +4892,16 @@ dependencies = [
"smallvec",
]
[[package]]
name = "rust-ini"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7"
dependencies = [
"cfg-if",
"ordered-multimap",
]
[[package]]
name = "rustc-hash"
version = "2.1.1"
@ -5729,6 +5779,27 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-deep-link"
version = "2.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70ee75bc5627f77bfdf40c913255ebc258117b10ebe2b2239a1a1cf40b0b58aa"
dependencies = [
"dunce",
"plist",
"rust-ini",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"tracing",
"url",
"windows-registry",
"windows-result 0.3.4",
]
[[package]]
name = "tauri-plugin-haptics"
version = "2.3.2"
@ -5771,6 +5842,7 @@ dependencies = [
"serde",
"serde_json",
"tauri",
"tauri-plugin-deep-link",
"thiserror 2.0.18",
"tracing",
"windows-sys 0.60.2",
@ -5993,6 +6065,15 @@ dependencies = [
"time-core",
]
[[package]]
name = "tiny-keccak"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
dependencies = [
"crunchy",
]
[[package]]
name = "tinystr"
version = "0.8.2"
@ -7091,6 +7172,17 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-registry"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
dependencies = [
"windows-link 0.1.3",
"windows-result 0.3.4",
"windows-strings 0.4.2",
]
[[package]]
name = "windows-result"
version = "0.1.2"

View file

@ -21,6 +21,7 @@ tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon", "devtools"] }
tauri-plugin-deep-link = "2.4.9"
tauri-plugin-notification = "2"
url = "2"
serde = { version = "1", features = ["derive"] }
@ -33,7 +34,7 @@ base64 = "0.22"
ratspeak-tauri = { path = "../crates/ratspeak-tauri", features = ["ble", "hardware"] }
rfd = "0.15"
dirs = "6"
tauri-plugin-single-instance = "2"
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
[target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies]
ratspeak-tauri = { path = "../crates/ratspeak-tauri", default-features = false, features = ["ble", "rnode-tcp", "mobile-throttle", "seed"] }

View file

@ -66,6 +66,18 @@
<meta-data
android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
android:resource="@xml/usb_device_filter" />
<!-- Keep mobile scheme registration static. Build-time plugin
rewriting can mutate platform files during native build/sign
phases, so mobile configs intentionally omit deep-link setup. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<!-- ChromeOS ARC++ uses a different action for deep links -->
<action android:name="org.chromium.arc.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="ratspeak" />
<data android:host="channel" />
</intent-filter>
</activity>
<service

View file

@ -64,5 +64,17 @@
<string>bluetooth-central</string>
<string>bluetooth-peripheral</string>
</array>
<key>CFBundleURLTypes</key>
<!-- Kept static because build-time plugin rewriting can race Xcode signing. -->
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>ratspeak</string>
</array>
<key>CFBundleURLName</key>
<string>ratspeak</string>
</dict>
</array>
</dict>
</plist>

View file

@ -0,0 +1,155 @@
//! Native `ratspeak://channel` lifecycle integration.
//!
//! The frontend is not granted the plugin command API and never subscribes to
//! the plugin's URL event. Platform-delivered URLs cross the canonical Rust
//! parser once; only the typed, key-free target is retained in this bounded
//! process-memory inbox and consumed by application JavaScript.
use std::sync::{Mutex, MutexGuard};
use ratspeak_tauri::commands::channels::{parse_channel_share_target, ChannelShareTarget};
use tauri::{AppHandle, Emitter, Manager, Runtime, State};
use tauri_plugin_deep_link::DeepLinkExt;
use url::Url;
const NATIVE_CHANNEL_SHARE_AVAILABLE: &str = "native_channel_share_available";
#[derive(Default)]
pub(crate) struct NativeChannelShareInbox {
pending: Mutex<Option<ChannelShareTarget>>,
}
impl NativeChannelShareInbox {
fn pending(&self) -> MutexGuard<'_, Option<ChannelShareTarget>> {
self.pending
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
/// Accept every valid target in arrival order and retain only the newest.
///
/// Returning `true` means the observable pending value changed. Repeated
/// delivery of the same URL while it is still pending is coalesced.
fn accept_payloads<'a>(&self, payloads: impl IntoIterator<Item = &'a str>) -> bool {
let mut latest = None;
let mut rejected = 0usize;
for payload in payloads {
match parse_channel_share_target(payload) {
Ok(target) => latest = Some(target),
Err(_) => rejected = rejected.saturating_add(1),
}
}
if rejected > 0 {
tracing::debug!(
rejected,
reason = "invalid_native_channel_share",
"ignored non-canonical native channel share"
);
}
let Some(latest) = latest else {
return false;
};
let mut pending = self.pending();
if pending.as_ref() == Some(&latest) {
return false;
}
*pending = Some(latest);
true
}
fn take(&self) -> Option<ChannelShareTarget> {
self.pending().take()
}
}
fn enqueue_native_channel_shares<R: Runtime>(app: &AppHandle<R>, urls: Vec<Url>) {
let inbox = app.state::<NativeChannelShareInbox>();
if inbox.accept_payloads(urls.iter().map(Url::as_str))
&& app.emit(NATIVE_CHANNEL_SHARE_AVAILABLE, ()).is_err()
{
tracing::debug!(
reason = "native_channel_share_event_unavailable",
"could not notify the WebView about a native channel share"
);
}
}
/// Register the running-app listener before sampling the cold-start value.
/// The inbox coalesces the harmless overlap if both paths report the same URL.
pub(crate) fn install(app: &mut tauri::App) {
let listener_app = app.handle().clone();
app.deep_link().on_open_url(move |event| {
enqueue_native_channel_shares(&listener_app, event.urls());
});
match app.deep_link().get_current() {
Ok(Some(urls)) => enqueue_native_channel_shares(app.handle(), urls),
Ok(None) => {}
Err(_) => tracing::debug!(
reason = "native_channel_share_cold_start_unavailable",
"could not inspect the native channel-share launch target"
),
}
}
#[tauri::command]
pub(crate) fn take_native_channel_share(
inbox: State<'_, NativeChannelShareInbox>,
) -> Option<ChannelShareTarget> {
inbox.take()
}
#[cfg(test)]
mod tests {
use super::*;
const HUB_A: &str = "00112233445566778899aabbccddeeff";
const HUB_B: &str = "ffeeddccbbaa99887766554433221100";
fn share(hub: &str, room: &str) -> String {
format!("ratspeak://channel?v=1&hub={hub}&room={room}")
}
#[test]
fn inbox_keeps_only_the_latest_canonical_target() {
let inbox = NativeChannelShareInbox::default();
let first = share(HUB_A, "general");
let second = share(HUB_B, "field");
assert!(inbox.accept_payloads([first.as_str(), second.as_str()]));
let target = inbox.take().expect("latest target");
assert_eq!(target.hub_destination_hash, HUB_B);
assert_eq!(target.room.as_deref(), Some("field"));
assert!(inbox.take().is_none(), "taking is one-shot");
}
#[test]
fn invalid_or_key_bearing_urls_never_replace_a_pending_target() {
let inbox = NativeChannelShareInbox::default();
let valid = share(HUB_A, "general");
assert!(inbox.accept_payloads([valid.as_str()]));
let with_key = format!("{valid}&key=secret");
assert!(!inbox.accept_payloads([
"ratspeak://contact?v=1",
with_key.as_str(),
"https://channel.invalid/"
]));
assert_eq!(
inbox.take().expect("original target").hub_destination_hash,
HUB_A
);
}
#[test]
fn repeated_pending_target_is_coalesced_but_can_be_opened_again_after_take() {
let inbox = NativeChannelShareInbox::default();
let valid = share(HUB_A, "general");
assert!(inbox.accept_payloads([valid.as_str()]));
assert!(!inbox.accept_payloads([valid.as_str()]));
assert!(inbox.take().is_some());
assert!(inbox.accept_payloads([valid.as_str()]));
}
}

View file

@ -1,3 +1,4 @@
mod channel_deep_link;
mod paths;
#[cfg(not(any(target_os = "android", target_os = "ios")))]
@ -639,13 +640,20 @@ pub fn run() {
let linux_webkit_dmabuf_workaround = apply_linux_webkit_rendering_workarounds();
let builder = tauri::Builder::default().plugin(tauri_plugin_notification::init());
let builder =
tauri::Builder::default().manage(channel_deep_link::NativeChannelShareInbox::default());
// Tauri requires single-instance to be the first plugin when it forwards
// secondary-process deep-link arguments into the primary process.
#[cfg(not(any(target_os = "android", target_os = "ios")))]
let builder = builder.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
show_main_window(app);
}));
let builder = builder
.plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_notification::init());
// Mobile haptics bridge — navigator.vibrate is a no-op in WKWebView so
// iOS needs UIImpactFeedbackGenerator via this plugin.
#[cfg(any(target_os = "ios", target_os = "android"))]
@ -661,6 +669,7 @@ pub fn run() {
set_window_decorations,
save_image_to_photos,
request_microphone_permission,
channel_deep_link::take_native_channel_share,
ratspeak_tauri::commands::system::api_version,
ratspeak_tauri::commands::system::api_startup_progress,
ratspeak_tauri::commands::system::api_setup_status,
@ -999,6 +1008,7 @@ pub fn run() {
});
let _window = window.build()?;
channel_deep_link::install(app);
#[cfg(all(
not(any(target_os = "android", target_os = "ios")),

View file

@ -0,0 +1,11 @@
{
"plugins": {
"deep-link": {
"desktop": {
"schemes": [
"ratspeak"
]
}
}
}
}

View file

@ -0,0 +1,11 @@
{
"plugins": {
"deep-link": {
"desktop": {
"schemes": [
"ratspeak"
]
}
}
}
}

View file

@ -0,0 +1,11 @@
{
"plugins": {
"deep-link": {
"desktop": {
"schemes": [
"ratspeak"
]
}
}
}
}