mirror of
https://github.com/ratspeak/Ratspeak
synced 2026-08-12 18:07:35 -04:00
channels: add native share-link lifecycle
This commit is contained in:
parent
b777e97548
commit
20207eb9e6
16 changed files with 843 additions and 6 deletions
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
158
dashboard/scripts/test_channels_native_link.js
Normal file
158
dashboard/scripts/test_channels_native_link.js
Normal 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;
|
||||
});
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
136
dashboard/static/js/native_channel_share.js
Normal file
136
dashboard/static/js/native_channel_share.js
Normal 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
92
src-tauri/Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
155
src-tauri/src/channel_deep_link.rs
Normal file
155
src-tauri/src/channel_deep_link.rs
Normal 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()]));
|
||||
}
|
||||
}
|
||||
|
|
@ -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")),
|
||||
|
|
|
|||
11
src-tauri/tauri.linux.conf.json
Normal file
11
src-tauri/tauri.linux.conf.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"plugins": {
|
||||
"deep-link": {
|
||||
"desktop": {
|
||||
"schemes": [
|
||||
"ratspeak"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
src-tauri/tauri.macos.conf.json
Normal file
11
src-tauri/tauri.macos.conf.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"plugins": {
|
||||
"deep-link": {
|
||||
"desktop": {
|
||||
"schemes": [
|
||||
"ratspeak"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
src-tauri/tauri.windows.conf.json
Normal file
11
src-tauri/tauri.windows.conf.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"plugins": {
|
||||
"deep-link": {
|
||||
"desktop": {
|
||||
"schemes": [
|
||||
"ratspeak"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue